Merge branch 'main' into all-contributors/add-andriibratanin

This commit is contained in:
Simon Aronsson 2023-02-06 14:30:21 +01:00 committed by GitHub
commit 678955eefe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 1948 additions and 628 deletions

View file

@ -846,6 +846,12 @@
"name": "Andrii Bratanin",
"avatar_url": "https://avatars.githubusercontent.com/u/20169213?v=4",
"profile": "https://github.com/andriibratanin",
},
{
"login": "IAmTamal",
"name": "Tamal Das ",
"avatar_url": "https://avatars.githubusercontent.com/u/72851613?v=4",
"profile": "https://tamal.vercel.app/",
"contributions": [
"doc"
]

14
.editorconfig Normal file
View file

@ -0,0 +1,14 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
[*.css]
indent_style = space
indent_size = 2
[{go.mod,go.sum,*.go}]
indent_style = tab
indent_size = 4

2
.github/FUNDING.yml vendored
View file

@ -1,2 +0,0 @@
custom: https://www.amazon.com/hz/wishlist/ls/F94JJV822VX6
github: simskij

71
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View file

@ -0,0 +1,71 @@
name: 🐛 Bug report
description: Create a report to help us improve
labels: ["Priority: Medium, Status: Available, Type: Bug"]
body:
- type: markdown
attributes:
value: Before submitting your issue, please make sure you're using the containrrr/watchtower:latest image. If not, switch to this image prior to posting your report. Other forks, or the old `v2tec` image are **not** supported.
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to reproduce
description: Steps to reproduce the behavior
value: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Please add screenshots if applicable
validations:
required: false
- type: textarea
attributes:
label: Environment
description: We would want to know the following things
value: |
- Platform
- Architecture
- Docker Version
validations:
required: true
- type: textarea
attributes:
label: Your logs
description: Paste the logs from running watchtower with the `--debug` option.
render: text
validations:
required: true
- type: textarea
attributes:
label: Additional context
description: Add any other context about the problem here.
validations:
required: false

View file

@ -1,53 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: 'Priority: Medium, Status: Available, Type: Bug'
assignees: ''
---
<!--
Before submitting your issue, please make sure you're using the containrrr/watchtower:latest image.
If not, switch to this image prior to posting your report. Other forks, or the old `v2tec` image are **not** supported.
-->
**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
**To Reproduce**
<!--
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
-->
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Screenshots**
<!--
If applicable, add screenshots to help explain your problem.
-->
**Environment**
<!--
We want to know:
- Platform
- Architecture
- Docker version
-->
<details>
<summary><b> Logs from running watchtower with the <code>--debug</code> option </b></summary>
```
```
</details>
**Additional context**
<!--
Add any other context about the problem here.
-->

View file

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: 'Priority: Low, Status: Available, Type: Enhancement'
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -0,0 +1,36 @@
name: 💡 Feature request
description: Have a new idea/feature ? Please suggest!
labels: ["Priority: Low, Status: Available, Type: Enhancement"]
body:
- type: textarea
id: description
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: true
- type: textarea
id: extrainfo
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false

View file

@ -1,10 +1,11 @@
name: Greetings
on:
pull_request: {}
# Runs in the context of the target (containrrr/watchtower) repository, and as such has access to GITHUB_TOKEN
pull_request_target:
types: [opened]
issues:
types:
- opened
types: [opened]
jobs:
greeting:

View file

@ -17,13 +17,14 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install mkdocs
- name: Setup python
uses: actions/setup-python@v4
with:
python-version: '3.10'
cache: 'pip'
cache-dependency-path: |
docs-requirements.txt
- name: Install mkdocs
run: |
pip install -r docs-requirements.txt
- name: Generate docs

View file

@ -19,9 +19,10 @@ jobs:
uses: actions/setup-go@v3
with:
go-version: 1.18.x
- uses: dominikh/staticcheck-action@v1.2.0
- uses: dominikh/staticcheck-action@ba605356b4b29a60e87ab9404b712f3461e566dc #v1.3.0
with:
version: "2022.1.1"
install-go: "false" # StaticCheck uses go v1.17 which does not support `any`
test:
name: Test
strategy:
@ -63,7 +64,7 @@ jobs:
with:
go-version: 1.18.x
- name: Build
uses: goreleaser/goreleaser-action@v3
uses: goreleaser/goreleaser-action@f82d6c1c344bcacabba2c841718984797f664a6b #v3
with:
version: v0.155.0
args: --snapshot --skip-publish --debug

View file

@ -39,7 +39,7 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Publish to Docker Hub
uses: jerray/publish-docker-action@master
uses: jerray/publish-docker-action@87d84711629b0dc9f6bb127b568413cc92a2088e #master@2022-10-14
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
@ -47,7 +47,7 @@ jobs:
repository: containrrr/watchtower
tags: latest-dev
- name: Publish to GHCR
uses: jerray/publish-docker-action@master
uses: jerray/publish-docker-action@87d84711629b0dc9f6bb127b568413cc92a2088e #master@2022-10-14
with:
username: ${{ secrets.BOT_USERNAME }}
password: ${{ secrets.BOT_GHCR_PAT }}

View file

@ -22,12 +22,10 @@ jobs:
uses: actions/setup-go@v3
with:
go-version: 1.18.x
- name: Install linter
run: |
go get -u golang.org/x/lint/golint
- name: Lint files
run: |
golint -set_exit_status ./...
- uses: dominikh/staticcheck-action@ba605356b4b29a60e87ab9404b712f3461e566dc #v1.3.0
with:
version: "2022.1.1"
install-go: "false" # StaticCheck uses go v1.17 which does not support `any`
test:
name: Test
@ -72,18 +70,18 @@ jobs:
with:
go-version: 1.18.x
- name: Login to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a #v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v2
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a #v2
with:
username: ${{ secrets.BOT_USERNAME }}
password: ${{ secrets.BOT_GHCR_PAT }}
registry: ghcr.io
- name: Build
uses: goreleaser/goreleaser-action@v3
uses: goreleaser/goreleaser-action@f82d6c1c344bcacabba2c841718984797f664a6b #v3
with:
version: v0.155.0
args: --debug
@ -193,7 +191,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Pull new module version
uses: andrewslotin/go-proxy-pull-action@master
uses: andrewslotin/go-proxy-pull-action@bfc19ec6536e1638181b2ad6a03e16c7ccfb122f #master@2022-10-14

187
README.md
View file

@ -44,123 +44,130 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<table>
<tbody>
<tr>
<td align="center"><a href="http://codelica.com"><img src="https://avatars3.githubusercontent.com/u/386101?v=4?s=100" width="100px;" alt=""/><br /><sub><b>James</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Codelica" title="Tests">⚠️</a> <a href="#ideas-Codelica" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://kopfkrieg.org"><img src="https://avatars2.githubusercontent.com/u/5047813?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Florian</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/pulls?q=is%3Apr+reviewed-by%3AKopfKrieg" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/containrrr/watchtower/commits?author=KopfKrieg" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/bdehamer"><img src="https://avatars1.githubusercontent.com/u/398027?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Brian DeHamer</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=bdehamer" title="Code">💻</a> <a href="#maintenance-bdehamer" title="Maintenance">🚧</a></td>
<td align="center"><a href="https://github.com/rosscado"><img src="https://avatars1.githubusercontent.com/u/16578183?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ross Cadogan</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=rosscado" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/stffabi"><img src="https://avatars0.githubusercontent.com/u/9464631?v=4?s=100" width="100px;" alt=""/><br /><sub><b>stffabi</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=stffabi" title="Code">💻</a> <a href="#maintenance-stffabi" title="Maintenance">🚧</a></td>
<td align="center"><a href="https://github.com/ATCUSA"><img src="https://avatars3.githubusercontent.com/u/3581228?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Austin</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=ATCUSA" title="Documentation">📖</a></td>
<td align="center"><a href="https://labs.ctl.io"><img src="https://avatars2.githubusercontent.com/u/6181487?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Gardner</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/pulls?q=is%3Apr+reviewed-by%3Adavidgardner11" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/containrrr/watchtower/commits?author=davidgardner11" title="Documentation">📖</a></td>
<td align="center"><a href="http://codelica.com"><img src="https://avatars3.githubusercontent.com/u/386101?v=4?s=100" width="100px;" alt="James"/><br /><sub><b>James</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Codelica" title="Tests">⚠️</a> <a href="#ideas-Codelica" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://kopfkrieg.org"><img src="https://avatars2.githubusercontent.com/u/5047813?v=4?s=100" width="100px;" alt="Florian"/><br /><sub><b>Florian</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/pulls?q=is%3Apr+reviewed-by%3AKopfKrieg" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/containrrr/watchtower/commits?author=KopfKrieg" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/bdehamer"><img src="https://avatars1.githubusercontent.com/u/398027?v=4?s=100" width="100px;" alt="Brian DeHamer"/><br /><sub><b>Brian DeHamer</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=bdehamer" title="Code">💻</a> <a href="#maintenance-bdehamer" title="Maintenance">🚧</a></td>
<td align="center"><a href="https://github.com/rosscado"><img src="https://avatars1.githubusercontent.com/u/16578183?v=4?s=100" width="100px;" alt="Ross Cadogan"/><br /><sub><b>Ross Cadogan</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=rosscado" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/stffabi"><img src="https://avatars0.githubusercontent.com/u/9464631?v=4?s=100" width="100px;" alt="stffabi"/><br /><sub><b>stffabi</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=stffabi" title="Code">💻</a> <a href="#maintenance-stffabi" title="Maintenance">🚧</a></td>
<td align="center"><a href="https://github.com/ATCUSA"><img src="https://avatars3.githubusercontent.com/u/3581228?v=4?s=100" width="100px;" alt="Austin"/><br /><sub><b>Austin</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=ATCUSA" title="Documentation">📖</a></td>
<td align="center"><a href="https://labs.ctl.io"><img src="https://avatars2.githubusercontent.com/u/6181487?v=4?s=100" width="100px;" alt="David Gardner"/><br /><sub><b>David Gardner</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/pulls?q=is%3Apr+reviewed-by%3Adavidgardner11" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/containrrr/watchtower/commits?author=davidgardner11" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/dolanor"><img src="https://avatars3.githubusercontent.com/u/928722?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tanguy ⧓ Herrmann</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=dolanor" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/rdamazio"><img src="https://avatars3.githubusercontent.com/u/997641?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rodrigo Damazio Bovendorp</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=rdamazio" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=rdamazio" title="Documentation">📖</a></td>
<td align="center"><a href="https://www.taisun.io/"><img src="https://avatars3.githubusercontent.com/u/1852688?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ryan Kuba</b></sub></a><br /><a href="#infra-thelamer" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/cnrmck"><img src="https://avatars2.githubusercontent.com/u/22061955?v=4?s=100" width="100px;" alt=""/><br /><sub><b>cnrmck</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=cnrmck" title="Documentation">📖</a></td>
<td align="center"><a href="http://harrywalter.co.uk"><img src="https://avatars3.githubusercontent.com/u/338588?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Harry Walter</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=haswalt" title="Code">💻</a></td>
<td align="center"><a href="http://projectsperanza.com"><img src="https://avatars3.githubusercontent.com/u/74515?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Robotex</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Robotex" title="Documentation">📖</a></td>
<td align="center"><a href="http://geraldpape.io"><img src="https://avatars0.githubusercontent.com/u/1494211?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Gerald Pape</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=ubergesundheit" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/dolanor"><img src="https://avatars3.githubusercontent.com/u/928722?v=4?s=100" width="100px;" alt="Tanguy ⧓ Herrmann"/><br /><sub><b>Tanguy ⧓ Herrmann</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=dolanor" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/rdamazio"><img src="https://avatars3.githubusercontent.com/u/997641?v=4?s=100" width="100px;" alt="Rodrigo Damazio Bovendorp"/><br /><sub><b>Rodrigo Damazio Bovendorp</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=rdamazio" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=rdamazio" title="Documentation">📖</a></td>
<td align="center"><a href="https://www.taisun.io/"><img src="https://avatars3.githubusercontent.com/u/1852688?v=4?s=100" width="100px;" alt="Ryan Kuba"/><br /><sub><b>Ryan Kuba</b></sub></a><br /><a href="#infra-thelamer" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/cnrmck"><img src="https://avatars2.githubusercontent.com/u/22061955?v=4?s=100" width="100px;" alt="cnrmck"/><br /><sub><b>cnrmck</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=cnrmck" title="Documentation">📖</a></td>
<td align="center"><a href="http://harrywalter.co.uk"><img src="https://avatars3.githubusercontent.com/u/338588?v=4?s=100" width="100px;" alt="Harry Walter"/><br /><sub><b>Harry Walter</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=haswalt" title="Code">💻</a></td>
<td align="center"><a href="http://projectsperanza.com"><img src="https://avatars3.githubusercontent.com/u/74515?v=4?s=100" width="100px;" alt="Robotex"/><br /><sub><b>Robotex</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Robotex" title="Documentation">📖</a></td>
<td align="center"><a href="http://geraldpape.io"><img src="https://avatars0.githubusercontent.com/u/1494211?v=4?s=100" width="100px;" alt="Gerald Pape"/><br /><sub><b>Gerald Pape</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=ubergesundheit" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/fomk"><img src="https://avatars0.githubusercontent.com/u/17636183?v=4?s=100" width="100px;" alt=""/><br /><sub><b>fomk</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=fomk" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/svengo"><img src="https://avatars3.githubusercontent.com/u/2502366?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sven Gottwald</b></sub></a><br /><a href="#infra-svengo" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://liberapay.com/techknowlogick/"><img src="https://avatars1.githubusercontent.com/u/164197?v=4?s=100" width="100px;" alt=""/><br /><sub><b>techknowlogick</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=techknowlogick" title="Code">💻</a></td>
<td align="center"><a href="http://log.c5t.org/about/"><img src="https://avatars1.githubusercontent.com/u/1449568?v=4?s=100" width="100px;" alt=""/><br /><sub><b>waja</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=waja" title="Documentation">📖</a></td>
<td align="center"><a href="http://scottalbertson.com"><img src="https://avatars2.githubusercontent.com/u/154463?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Scott Albertson</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=salbertson" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/huddlesj"><img src="https://avatars1.githubusercontent.com/u/11966535?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jason Huddleston</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=huddlesj" title="Documentation">📖</a></td>
<td align="center"><a href="https://npstr.space/"><img src="https://avatars3.githubusercontent.com/u/6048348?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Napster</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=napstr" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/fomk"><img src="https://avatars0.githubusercontent.com/u/17636183?v=4?s=100" width="100px;" alt="fomk"/><br /><sub><b>fomk</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=fomk" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/svengo"><img src="https://avatars3.githubusercontent.com/u/2502366?v=4?s=100" width="100px;" alt="Sven Gottwald"/><br /><sub><b>Sven Gottwald</b></sub></a><br /><a href="#infra-svengo" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://liberapay.com/techknowlogick/"><img src="https://avatars1.githubusercontent.com/u/164197?v=4?s=100" width="100px;" alt="techknowlogick"/><br /><sub><b>techknowlogick</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=techknowlogick" title="Code">💻</a></td>
<td align="center"><a href="http://log.c5t.org/about/"><img src="https://avatars1.githubusercontent.com/u/1449568?v=4?s=100" width="100px;" alt="waja"/><br /><sub><b>waja</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=waja" title="Documentation">📖</a></td>
<td align="center"><a href="http://scottalbertson.com"><img src="https://avatars2.githubusercontent.com/u/154463?v=4?s=100" width="100px;" alt="Scott Albertson"/><br /><sub><b>Scott Albertson</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=salbertson" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/huddlesj"><img src="https://avatars1.githubusercontent.com/u/11966535?v=4?s=100" width="100px;" alt="Jason Huddleston"/><br /><sub><b>Jason Huddleston</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=huddlesj" title="Documentation">📖</a></td>
<td align="center"><a href="https://npstr.space/"><img src="https://avatars3.githubusercontent.com/u/6048348?v=4?s=100" width="100px;" alt="Napster"/><br /><sub><b>Napster</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=napstr" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/darknode"><img src="https://avatars1.githubusercontent.com/u/809429?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maxim</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=darknode" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=darknode" title="Documentation">📖</a></td>
<td align="center"><a href="https://schmitt.cat"><img src="https://avatars0.githubusercontent.com/u/17984549?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Max Schmitt</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mxschmitt" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/cron410"><img src="https://avatars1.githubusercontent.com/u/3082899?v=4?s=100" width="100px;" alt=""/><br /><sub><b>cron410</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=cron410" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Cardoso222"><img src="https://avatars3.githubusercontent.com/u/7026517?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Paulo Henrique</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Cardoso222" title="Documentation">📖</a></td>
<td align="center"><a href="https://coded.io"><img src="https://avatars0.githubusercontent.com/u/107097?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kaleb Elwert</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=belak" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/wmbutler"><img src="https://avatars1.githubusercontent.com/u/1254810?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Bill Butler</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=wmbutler" title="Documentation">📖</a></td>
<td align="center"><a href="https://www.mariotacke.io"><img src="https://avatars2.githubusercontent.com/u/4942019?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mario Tacke</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mariotacke" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/darknode"><img src="https://avatars1.githubusercontent.com/u/809429?v=4?s=100" width="100px;" alt="Maxim"/><br /><sub><b>Maxim</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=darknode" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=darknode" title="Documentation">📖</a></td>
<td align="center"><a href="https://schmitt.cat"><img src="https://avatars0.githubusercontent.com/u/17984549?v=4?s=100" width="100px;" alt="Max Schmitt"/><br /><sub><b>Max Schmitt</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mxschmitt" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/cron410"><img src="https://avatars1.githubusercontent.com/u/3082899?v=4?s=100" width="100px;" alt="cron410"/><br /><sub><b>cron410</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=cron410" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Cardoso222"><img src="https://avatars3.githubusercontent.com/u/7026517?v=4?s=100" width="100px;" alt="Paulo Henrique"/><br /><sub><b>Paulo Henrique</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Cardoso222" title="Documentation">📖</a></td>
<td align="center"><a href="https://coded.io"><img src="https://avatars0.githubusercontent.com/u/107097?v=4?s=100" width="100px;" alt="Kaleb Elwert"/><br /><sub><b>Kaleb Elwert</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=belak" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/wmbutler"><img src="https://avatars1.githubusercontent.com/u/1254810?v=4?s=100" width="100px;" alt="Bill Butler"/><br /><sub><b>Bill Butler</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=wmbutler" title="Documentation">📖</a></td>
<td align="center"><a href="https://www.mariotacke.io"><img src="https://avatars2.githubusercontent.com/u/4942019?v=4?s=100" width="100px;" alt="Mario Tacke"/><br /><sub><b>Mario Tacke</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mariotacke" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://markwoodbridge.com"><img src="https://avatars2.githubusercontent.com/u/1101318?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mark Woodbridge</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mrw34" title="Code">💻</a></td>
<td align="center"><a href="http://simme.dev"><img src="https://avatars0.githubusercontent.com/u/1596025?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon Aronsson</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=simskij" title="Code">💻</a> <a href="#maintenance-simskij" title="Maintenance">🚧</a> <a href="https://github.com/containrrr/watchtower/pulls?q=is%3Apr+reviewed-by%3Asimskij" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/containrrr/watchtower/commits?author=simskij" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Ansem93"><img src="https://avatars3.githubusercontent.com/u/6626218?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ansem93</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Ansem93" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/lukapeschke"><img src="https://avatars1.githubusercontent.com/u/17085536?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Luka Peschke</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=lukapeschke" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=lukapeschke" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/zoispag"><img src="https://avatars0.githubusercontent.com/u/21138205?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Zois Pagoulatos</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=zoispag" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/pulls?q=is%3Apr+reviewed-by%3Azoispag" title="Reviewed Pull Requests">👀</a> <a href="#maintenance-zoispag" title="Maintenance">🚧</a></td>
<td align="center"><a href="https://alexandre.menif.name"><img src="https://avatars0.githubusercontent.com/u/16152103?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alexandre Menif</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=alexandremenif" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/chugunov"><img src="https://avatars1.githubusercontent.com/u/4140479?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Andrey</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=chugunov" title="Documentation">📖</a></td>
<td align="center"><a href="https://markwoodbridge.com"><img src="https://avatars2.githubusercontent.com/u/1101318?v=4?s=100" width="100px;" alt="Mark Woodbridge"/><br /><sub><b>Mark Woodbridge</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mrw34" title="Code">💻</a></td>
<td align="center"><a href="http://simme.dev"><img src="https://avatars0.githubusercontent.com/u/1596025?v=4?s=100" width="100px;" alt="Simon Aronsson"/><br /><sub><b>Simon Aronsson</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=simskij" title="Code">💻</a> <a href="#maintenance-simskij" title="Maintenance">🚧</a> <a href="https://github.com/containrrr/watchtower/pulls?q=is%3Apr+reviewed-by%3Asimskij" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/containrrr/watchtower/commits?author=simskij" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Ansem93"><img src="https://avatars3.githubusercontent.com/u/6626218?v=4?s=100" width="100px;" alt="Ansem93"/><br /><sub><b>Ansem93</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Ansem93" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/lukapeschke"><img src="https://avatars1.githubusercontent.com/u/17085536?v=4?s=100" width="100px;" alt="Luka Peschke"/><br /><sub><b>Luka Peschke</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=lukapeschke" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=lukapeschke" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/zoispag"><img src="https://avatars0.githubusercontent.com/u/21138205?v=4?s=100" width="100px;" alt="Zois Pagoulatos"/><br /><sub><b>Zois Pagoulatos</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=zoispag" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/pulls?q=is%3Apr+reviewed-by%3Azoispag" title="Reviewed Pull Requests">👀</a> <a href="#maintenance-zoispag" title="Maintenance">🚧</a></td>
<td align="center"><a href="https://alexandre.menif.name"><img src="https://avatars0.githubusercontent.com/u/16152103?v=4?s=100" width="100px;" alt="Alexandre Menif"/><br /><sub><b>Alexandre Menif</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=alexandremenif" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/chugunov"><img src="https://avatars1.githubusercontent.com/u/4140479?v=4?s=100" width="100px;" alt="Andrey"/><br /><sub><b>Andrey</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=chugunov" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://noplanman.ch"><img src="https://avatars3.githubusercontent.com/u/9423417?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Armando Lüscher</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=noplanman" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/rjbudke"><img src="https://avatars2.githubusercontent.com/u/273485?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ryan Budke</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=rjbudke" title="Documentation">📖</a></td>
<td align="center"><a href="http://kaloyan.raev.name"><img src="https://avatars2.githubusercontent.com/u/468091?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kaloyan Raev</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=kaloyan-raev" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=kaloyan-raev" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/sixth"><img src="https://avatars3.githubusercontent.com/u/11591445?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sixth</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=sixth" title="Documentation">📖</a></td>
<td align="center"><a href="https://foosel.net"><img src="https://avatars0.githubusercontent.com/u/83657?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Gina Häußge</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=foosel" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/8ear"><img src="https://avatars0.githubusercontent.com/u/10329648?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Max H.</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=8ear" title="Code">💻</a></td>
<td align="center"><a href="https://pjknkda.github.io"><img src="https://avatars0.githubusercontent.com/u/4986524?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jungkook Park</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=pjknkda" title="Documentation">📖</a></td>
<td align="center"><a href="https://noplanman.ch"><img src="https://avatars3.githubusercontent.com/u/9423417?v=4?s=100" width="100px;" alt="Armando Lüscher"/><br /><sub><b>Armando Lüscher</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=noplanman" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/rjbudke"><img src="https://avatars2.githubusercontent.com/u/273485?v=4?s=100" width="100px;" alt="Ryan Budke"/><br /><sub><b>Ryan Budke</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=rjbudke" title="Documentation">📖</a></td>
<td align="center"><a href="http://kaloyan.raev.name"><img src="https://avatars2.githubusercontent.com/u/468091?v=4?s=100" width="100px;" alt="Kaloyan Raev"/><br /><sub><b>Kaloyan Raev</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=kaloyan-raev" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=kaloyan-raev" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/sixth"><img src="https://avatars3.githubusercontent.com/u/11591445?v=4?s=100" width="100px;" alt="sixth"/><br /><sub><b>sixth</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=sixth" title="Documentation">📖</a></td>
<td align="center"><a href="https://foosel.net"><img src="https://avatars0.githubusercontent.com/u/83657?v=4?s=100" width="100px;" alt="Gina Häußge"/><br /><sub><b>Gina Häußge</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=foosel" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/8ear"><img src="https://avatars0.githubusercontent.com/u/10329648?v=4?s=100" width="100px;" alt="Max H."/><br /><sub><b>Max H.</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=8ear" title="Code">💻</a></td>
<td align="center"><a href="https://pjknkda.github.io"><img src="https://avatars0.githubusercontent.com/u/4986524?v=4?s=100" width="100px;" alt="Jungkook Park"/><br /><sub><b>Jungkook Park</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=pjknkda" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://achfrag.net"><img src="https://avatars1.githubusercontent.com/u/5753622?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jan Kristof Nidzwetzki</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=jnidzwetzki" title="Documentation">📖</a></td>
<td align="center"><a href="https://www.lukaselsner.de"><img src="https://avatars0.githubusercontent.com/u/1413542?v=4?s=100" width="100px;" alt=""/><br /><sub><b>lukas</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mindrunner" title="Code">💻</a></td>
<td align="center"><a href="https://codingcoffee.dev"><img src="https://avatars3.githubusercontent.com/u/13611153?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ameya Shenoy</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=codingCoffee" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/raymondelooff"><img src="https://avatars0.githubusercontent.com/u/9716806?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Raymon de Looff</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=raymondelooff" title="Code">💻</a></td>
<td align="center"><a href="http://codemonkeylabs.com"><img src="https://avatars2.githubusercontent.com/u/704034?v=4?s=100" width="100px;" alt=""/><br /><sub><b>John Clayton</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=jsclayton" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Germs2004"><img src="https://avatars2.githubusercontent.com/u/5519340?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Germs2004</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Germs2004" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/lukwil"><img src="https://avatars1.githubusercontent.com/u/30203234?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Lukas Willburger</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=lukwil" title="Code">💻</a></td>
<td align="center"><a href="https://achfrag.net"><img src="https://avatars1.githubusercontent.com/u/5753622?v=4?s=100" width="100px;" alt="Jan Kristof Nidzwetzki"/><br /><sub><b>Jan Kristof Nidzwetzki</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=jnidzwetzki" title="Documentation">📖</a></td>
<td align="center"><a href="https://www.lukaselsner.de"><img src="https://avatars0.githubusercontent.com/u/1413542?v=4?s=100" width="100px;" alt="lukas"/><br /><sub><b>lukas</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mindrunner" title="Code">💻</a></td>
<td align="center"><a href="https://codingcoffee.dev"><img src="https://avatars3.githubusercontent.com/u/13611153?v=4?s=100" width="100px;" alt="Ameya Shenoy"/><br /><sub><b>Ameya Shenoy</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=codingCoffee" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/raymondelooff"><img src="https://avatars0.githubusercontent.com/u/9716806?v=4?s=100" width="100px;" alt="Raymon de Looff"/><br /><sub><b>Raymon de Looff</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=raymondelooff" title="Code">💻</a></td>
<td align="center"><a href="http://codemonkeylabs.com"><img src="https://avatars2.githubusercontent.com/u/704034?v=4?s=100" width="100px;" alt="John Clayton"/><br /><sub><b>John Clayton</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=jsclayton" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Germs2004"><img src="https://avatars2.githubusercontent.com/u/5519340?v=4?s=100" width="100px;" alt="Germs2004"/><br /><sub><b>Germs2004</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Germs2004" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/lukwil"><img src="https://avatars1.githubusercontent.com/u/30203234?v=4?s=100" width="100px;" alt="Lukas Willburger"/><br /><sub><b>Lukas Willburger</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=lukwil" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/auanasgheps"><img src="https://avatars2.githubusercontent.com/u/20586878?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Oliver Cervera</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=auanasgheps" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/victorcmoura"><img src="https://avatars1.githubusercontent.com/u/26290053?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Victor Moura</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=victorcmoura" title="Tests">⚠️</a> <a href="https://github.com/containrrr/watchtower/commits?author=victorcmoura" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=victorcmoura" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/mbrandau"><img src="https://avatars3.githubusercontent.com/u/12972798?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maximilian Brandau</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mbrandau" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=mbrandau" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/aneisch"><img src="https://avatars1.githubusercontent.com/u/6991461?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Andrew</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=aneisch" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/sixcorners"><img src="https://avatars0.githubusercontent.com/u/585501?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sixcorners</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=sixcorners" title="Documentation">📖</a></td>
<td align="center"><a href="https://piksel.se"><img src="https://avatars2.githubusercontent.com/u/807383?v=4?s=100" width="100px;" alt=""/><br /><sub><b>nils måsén</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=piksel" title="Documentation">📖</a> <a href="https://github.com/containrrr/watchtower/commits?author=piksel" title="Code">💻</a></td>
<td align="center"><a href="https://arnested.dk"><img src="https://avatars2.githubusercontent.com/u/190005?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Arne Jørgensen</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=arnested" title="Tests">⚠️</a> <a href="https://github.com/containrrr/watchtower/pulls?q=is%3Apr+reviewed-by%3Aarnested" title="Reviewed Pull Requests">👀</a></td>
<td align="center"><a href="https://github.com/auanasgheps"><img src="https://avatars2.githubusercontent.com/u/20586878?v=4?s=100" width="100px;" alt="Oliver Cervera"/><br /><sub><b>Oliver Cervera</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=auanasgheps" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/victorcmoura"><img src="https://avatars1.githubusercontent.com/u/26290053?v=4?s=100" width="100px;" alt="Victor Moura"/><br /><sub><b>Victor Moura</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=victorcmoura" title="Tests">⚠️</a> <a href="https://github.com/containrrr/watchtower/commits?author=victorcmoura" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=victorcmoura" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/mbrandau"><img src="https://avatars3.githubusercontent.com/u/12972798?v=4?s=100" width="100px;" alt="Maximilian Brandau"/><br /><sub><b>Maximilian Brandau</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mbrandau" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=mbrandau" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/aneisch"><img src="https://avatars1.githubusercontent.com/u/6991461?v=4?s=100" width="100px;" alt="Andrew"/><br /><sub><b>Andrew</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=aneisch" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/sixcorners"><img src="https://avatars0.githubusercontent.com/u/585501?v=4?s=100" width="100px;" alt="sixcorners"/><br /><sub><b>sixcorners</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=sixcorners" title="Documentation">📖</a></td>
<td align="center"><a href="https://piksel.se"><img src="https://avatars2.githubusercontent.com/u/807383?v=4?s=100" width="100px;" alt="nils måsén"/><br /><sub><b>nils måsén</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=piksel" title="Documentation">📖</a> <a href="https://github.com/containrrr/watchtower/commits?author=piksel" title="Code">💻</a></td>
<td align="center"><a href="https://arnested.dk"><img src="https://avatars2.githubusercontent.com/u/190005?v=4?s=100" width="100px;" alt="Arne Jørgensen"/><br /><sub><b>Arne Jørgensen</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=arnested" title="Tests">⚠️</a> <a href="https://github.com/containrrr/watchtower/pulls?q=is%3Apr+reviewed-by%3Aarnested" title="Reviewed Pull Requests">👀</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/patski123"><img src="https://avatars1.githubusercontent.com/u/19295295?v=4?s=100" width="100px;" alt=""/><br /><sub><b>PatSki123</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=patski123" title="Documentation">📖</a></td>
<td align="center"><a href="https://rubyroidlabs.com/"><img src="https://avatars2.githubusercontent.com/u/624999?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Valentine Zavadsky</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Saicheg" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=Saicheg" title="Documentation">📖</a> <a href="https://github.com/containrrr/watchtower/commits?author=Saicheg" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/bopoh24"><img src="https://avatars2.githubusercontent.com/u/4086631?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alexander Voronin</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=bopoh24" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/issues?q=author%3Abopoh24" title="Bug reports">🐛</a></td>
<td align="center"><a href="http://www.teqneers.de"><img src="https://avatars0.githubusercontent.com/u/788989?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Oliver Mueller</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=ogmueller" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/tammert"><img src="https://avatars0.githubusercontent.com/u/8885250?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sebastiaan Tammer</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=tammert" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Miosame"><img src="https://avatars1.githubusercontent.com/u/8201077?v=4?s=100" width="100px;" alt=""/><br /><sub><b>miosame</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=miosame" title="Documentation">📖</a></td>
<td align="center"><a href="https://mtz.gr"><img src="https://avatars3.githubusercontent.com/u/590246?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Andrew Metzger</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/issues?q=author%3Aandrewjmetzger" title="Bug reports">🐛</a> <a href="#example-andrewjmetzger" title="Examples">💡</a></td>
<td align="center"><a href="https://github.com/patski123"><img src="https://avatars1.githubusercontent.com/u/19295295?v=4?s=100" width="100px;" alt="PatSki123"/><br /><sub><b>PatSki123</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=patski123" title="Documentation">📖</a></td>
<td align="center"><a href="https://rubyroidlabs.com/"><img src="https://avatars2.githubusercontent.com/u/624999?v=4?s=100" width="100px;" alt="Valentine Zavadsky"/><br /><sub><b>Valentine Zavadsky</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Saicheg" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=Saicheg" title="Documentation">📖</a> <a href="https://github.com/containrrr/watchtower/commits?author=Saicheg" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/bopoh24"><img src="https://avatars2.githubusercontent.com/u/4086631?v=4?s=100" width="100px;" alt="Alexander Voronin"/><br /><sub><b>Alexander Voronin</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=bopoh24" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/issues?q=author%3Abopoh24" title="Bug reports">🐛</a></td>
<td align="center"><a href="http://www.teqneers.de"><img src="https://avatars0.githubusercontent.com/u/788989?v=4?s=100" width="100px;" alt="Oliver Mueller"/><br /><sub><b>Oliver Mueller</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=ogmueller" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/tammert"><img src="https://avatars0.githubusercontent.com/u/8885250?v=4?s=100" width="100px;" alt="Sebastiaan Tammer"/><br /><sub><b>Sebastiaan Tammer</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=tammert" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Miosame"><img src="https://avatars1.githubusercontent.com/u/8201077?v=4?s=100" width="100px;" alt="miosame"/><br /><sub><b>miosame</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=miosame" title="Documentation">📖</a></td>
<td align="center"><a href="https://mtz.gr"><img src="https://avatars3.githubusercontent.com/u/590246?v=4?s=100" width="100px;" alt="Andrew Metzger"/><br /><sub><b>Andrew Metzger</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/issues?q=author%3Aandrewjmetzger" title="Bug reports">🐛</a> <a href="#example-andrewjmetzger" title="Examples">💡</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/pgrimaud"><img src="https://avatars1.githubusercontent.com/u/1866496?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Pierre Grimaud</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=pgrimaud" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/mattdoran"><img src="https://avatars0.githubusercontent.com/u/577779?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Matt Doran</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mattdoran" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/MihailITPlace"><img src="https://avatars2.githubusercontent.com/u/28401551?v=4?s=100" width="100px;" alt=""/><br /><sub><b>MihailITPlace</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=MihailITPlace" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/bugficks"><img src="https://avatars1.githubusercontent.com/u/2992895?v=4?s=100" width="100px;" alt=""/><br /><sub><b>bugficks</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=bugficks" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=bugficks" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/MichaelSp"><img src="https://avatars0.githubusercontent.com/u/448282?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=MichaelSp" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jokay"><img src="https://avatars0.githubusercontent.com/u/18613935?v=4?s=100" width="100px;" alt=""/><br /><sub><b>D. Domig</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=jokay" title="Documentation">📖</a></td>
<td align="center"><a href="https://maxwells-daemon.io"><img src="https://avatars1.githubusercontent.com/u/260084?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ben Osheroff</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=osheroff" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/pgrimaud"><img src="https://avatars1.githubusercontent.com/u/1866496?v=4?s=100" width="100px;" alt="Pierre Grimaud"/><br /><sub><b>Pierre Grimaud</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=pgrimaud" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/mattdoran"><img src="https://avatars0.githubusercontent.com/u/577779?v=4?s=100" width="100px;" alt="Matt Doran"/><br /><sub><b>Matt Doran</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mattdoran" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/MihailITPlace"><img src="https://avatars2.githubusercontent.com/u/28401551?v=4?s=100" width="100px;" alt="MihailITPlace"/><br /><sub><b>MihailITPlace</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=MihailITPlace" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/bugficks"><img src="https://avatars1.githubusercontent.com/u/2992895?v=4?s=100" width="100px;" alt="bugficks"/><br /><sub><b>bugficks</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=bugficks" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=bugficks" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/MichaelSp"><img src="https://avatars0.githubusercontent.com/u/448282?v=4?s=100" width="100px;" alt="Michael"/><br /><sub><b>Michael</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=MichaelSp" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jokay"><img src="https://avatars0.githubusercontent.com/u/18613935?v=4?s=100" width="100px;" alt="D. Domig"/><br /><sub><b>D. Domig</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=jokay" title="Documentation">📖</a></td>
<td align="center"><a href="https://maxwells-daemon.io"><img src="https://avatars1.githubusercontent.com/u/260084?v=4?s=100" width="100px;" alt="Ben Osheroff"/><br /><sub><b>Ben Osheroff</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=osheroff" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/dhet"><img src="https://avatars3.githubusercontent.com/u/2668621?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David H.</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=dhet" title="Code">💻</a></td>
<td align="center"><a href="http://www.gridgeo.com"><img src="https://avatars1.githubusercontent.com/u/671887?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Chander Ganesan</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=chander" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/yrien30"><img src="https://avatars1.githubusercontent.com/u/26816162?v=4?s=100" width="100px;" alt=""/><br /><sub><b>yrien30</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=yrien30" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/ksurl"><img src="https://avatars1.githubusercontent.com/u/1371562?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ksurl</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=ksurl" title="Documentation">📖</a> <a href="https://github.com/containrrr/watchtower/commits?author=ksurl" title="Code">💻</a> <a href="#infra-ksurl" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/rg9400"><img src="https://avatars2.githubusercontent.com/u/39887349?v=4?s=100" width="100px;" alt=""/><br /><sub><b>rg9400</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=rg9400" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/tkalus"><img src="https://avatars2.githubusercontent.com/u/287181?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Turtle Kalus</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=tkalus" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/SrihariThalla"><img src="https://avatars1.githubusercontent.com/u/7479937?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Srihari Thalla</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=SrihariThalla" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/dhet"><img src="https://avatars3.githubusercontent.com/u/2668621?v=4?s=100" width="100px;" alt="David H."/><br /><sub><b>David H.</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=dhet" title="Code">💻</a></td>
<td align="center"><a href="http://www.gridgeo.com"><img src="https://avatars1.githubusercontent.com/u/671887?v=4?s=100" width="100px;" alt="Chander Ganesan"/><br /><sub><b>Chander Ganesan</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=chander" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/yrien30"><img src="https://avatars1.githubusercontent.com/u/26816162?v=4?s=100" width="100px;" alt="yrien30"/><br /><sub><b>yrien30</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=yrien30" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/ksurl"><img src="https://avatars1.githubusercontent.com/u/1371562?v=4?s=100" width="100px;" alt="ksurl"/><br /><sub><b>ksurl</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=ksurl" title="Documentation">📖</a> <a href="https://github.com/containrrr/watchtower/commits?author=ksurl" title="Code">💻</a> <a href="#infra-ksurl" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/rg9400"><img src="https://avatars2.githubusercontent.com/u/39887349?v=4?s=100" width="100px;" alt="rg9400"/><br /><sub><b>rg9400</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=rg9400" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/tkalus"><img src="https://avatars2.githubusercontent.com/u/287181?v=4?s=100" width="100px;" alt="Turtle Kalus"/><br /><sub><b>Turtle Kalus</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=tkalus" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/SrihariThalla"><img src="https://avatars1.githubusercontent.com/u/7479937?v=4?s=100" width="100px;" alt="Srihari Thalla"/><br /><sub><b>Srihari Thalla</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=SrihariThalla" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://nymous.io"><img src="https://avatars1.githubusercontent.com/u/4216559?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Thomas Gaudin</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=nymous" title="Documentation">📖</a></td>
<td align="center"><a href="https://indigo.re/"><img src="https://avatars.githubusercontent.com/u/2804645?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hydrargyrum</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=hydrargyrum" title="Documentation">📖</a></td>
<td align="center"><a href="https://reinout.vanrees.org"><img src="https://avatars.githubusercontent.com/u/121433?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Reinout van Rees</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=reinout" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/DasSkelett"><img src="https://avatars.githubusercontent.com/u/28812678?v=4?s=100" width="100px;" alt=""/><br /><sub><b>DasSkelett</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=DasSkelett" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/zenjabba"><img src="https://avatars.githubusercontent.com/u/679864?v=4?s=100" width="100px;" alt=""/><br /><sub><b>zenjabba</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=zenjabba" title="Documentation">📖</a></td>
<td align="center"><a href="https://quan.io"><img src="https://avatars.githubusercontent.com/u/3526705?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dan Quan</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=djquan" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/modem7"><img src="https://avatars.githubusercontent.com/u/4349962?v=4?s=100" width="100px;" alt=""/><br /><sub><b>modem7</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=modem7" title="Documentation">📖</a></td>
<td align="center"><a href="https://nymous.io"><img src="https://avatars1.githubusercontent.com/u/4216559?v=4?s=100" width="100px;" alt="Thomas Gaudin"/><br /><sub><b>Thomas Gaudin</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=nymous" title="Documentation">📖</a></td>
<td align="center"><a href="https://indigo.re/"><img src="https://avatars.githubusercontent.com/u/2804645?v=4?s=100" width="100px;" alt="hydrargyrum"/><br /><sub><b>hydrargyrum</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=hydrargyrum" title="Documentation">📖</a></td>
<td align="center"><a href="https://reinout.vanrees.org"><img src="https://avatars.githubusercontent.com/u/121433?v=4?s=100" width="100px;" alt="Reinout van Rees"/><br /><sub><b>Reinout van Rees</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=reinout" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/DasSkelett"><img src="https://avatars.githubusercontent.com/u/28812678?v=4?s=100" width="100px;" alt="DasSkelett"/><br /><sub><b>DasSkelett</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=DasSkelett" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/zenjabba"><img src="https://avatars.githubusercontent.com/u/679864?v=4?s=100" width="100px;" alt="zenjabba"/><br /><sub><b>zenjabba</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=zenjabba" title="Documentation">📖</a></td>
<td align="center"><a href="https://quan.io"><img src="https://avatars.githubusercontent.com/u/3526705?v=4?s=100" width="100px;" alt="Dan Quan"/><br /><sub><b>Dan Quan</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=djquan" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/modem7"><img src="https://avatars.githubusercontent.com/u/4349962?v=4?s=100" width="100px;" alt="modem7"/><br /><sub><b>modem7</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=modem7" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/hypnoglow"><img src="https://avatars.githubusercontent.com/u/4853075?v=4?s=100" width="100px;" alt="Igor Zibarev"/><br /><sub><b>Igor Zibarev</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=hypnoglow" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/patricegautier"><img src="https://avatars.githubusercontent.com/u/38435239?v=4?s=100" width="100px;" alt="Patrice"/><br /><sub><b>Patrice</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=patricegautier" title="Code">💻</a></td>
<td align="center"><a href="http://jamesw.link/me"><img src="https://avatars.githubusercontent.com/u/8067792?v=4?s=100" width="100px;" alt="James White"/><br /><sub><b>James White</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=jamesmacwhite" title="Documentation">📖</a></td>
<td align="center"><a href="https://ko-fi.com/foxite"><img src="https://avatars.githubusercontent.com/u/20421657?v=4?s=100" width="100px;" alt="Dirk Kok"/><br /><sub><b>Dirk Kok</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Foxite" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/EDIflyer"><img src="https://avatars.githubusercontent.com/u/13610277?v=4?s=100" width="100px;" alt="EDIflyer"/><br /><sub><b>EDIflyer</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=EDIflyer" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/jauderho"><img src="https://avatars.githubusercontent.com/u/13562?v=4?s=100" width="100px;" alt="Jauder Ho"/><br /><sub><b>Jauder Ho</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=jauderho" title="Code">💻</a></td>
<td align="center"><a href="https://tamal.vercel.app/"><img src="https://avatars.githubusercontent.com/u/72851613?v=4?s=100" width="100px;" alt="Tamal Das "/><br /><sub><b>Tamal Das </b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=IAmTamal" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/hypnoglow"><img src="https://avatars.githubusercontent.com/u/4853075?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Igor Zibarev</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=hypnoglow" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/patricegautier"><img src="https://avatars.githubusercontent.com/u/38435239?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Patrice</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=patricegautier" title="Code">💻</a></td>
<td align="center"><a href="http://jamesw.link/me"><img src="https://avatars.githubusercontent.com/u/8067792?v=4?s=100" width="100px;" alt=""/><br /><sub><b>James White</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=jamesmacwhite" title="Documentation">📖</a></td>
<td align="center"><a href="https://ko-fi.com/foxite"><img src="https://avatars.githubusercontent.com/u/20421657?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dirk Kok</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Foxite" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/EDIflyer"><img src="https://avatars.githubusercontent.com/u/13610277?v=4?s=100" width="100px;" alt=""/><br /><sub><b>EDIflyer</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=EDIflyer" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/jauderho"><img src="https://avatars.githubusercontent.com/u/13562?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jauder Ho</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=jauderho" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/andriibratanin"><img src="https://avatars.githubusercontent.com/u/20169213?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Andrii Bratanin</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=andriibratanin" title="Documentation">📖</a></td>
</tr>
</tbody>
<tfoot>
</tfoot>
</table>
<!-- markdownlint-restore -->

111
cmd/notify-upgrade.go Normal file
View file

@ -0,0 +1,111 @@
// Package cmd contains the watchtower (sub-)commands
package cmd
import (
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/containrrr/watchtower/internal/flags"
"github.com/containrrr/watchtower/pkg/container"
"github.com/containrrr/watchtower/pkg/notifications"
"github.com/spf13/cobra"
)
var notifyUpgradeCommand = NewNotifyUpgradeCommand()
// NewNotifyUpgradeCommand creates the notify upgrade command for watchtower
func NewNotifyUpgradeCommand() *cobra.Command {
return &cobra.Command{
Use: "notify-upgrade",
Short: "Upgrade legacy notification configuration to shoutrrr URLs",
Run: runNotifyUpgrade,
}
}
func runNotifyUpgrade(cmd *cobra.Command, args []string) {
if err := runNotifyUpgradeE(cmd, args); err != nil {
logf("Notification upgrade failed: %v", err)
}
}
func runNotifyUpgradeE(cmd *cobra.Command, _ []string) error {
f := cmd.Flags()
flags.ProcessFlagAliases(f)
notifier = notifications.NewNotifier(cmd)
urls := notifier.GetURLs()
logf("Found notification configurations for: %v", strings.Join(notifier.GetNames(), ", "))
outFile, err := os.CreateTemp("/", "watchtower-notif-urls-*")
if err != nil {
return fmt.Errorf("failed to create output file: %v", err)
}
logf("Writing notification URLs to %v", outFile.Name())
logf("")
sb := strings.Builder{}
sb.WriteString("WATCHTOWER_NOTIFICATION_URL=")
for i, u := range urls {
if i != 0 {
sb.WriteRune(' ')
}
sb.WriteString(u)
}
_, err = fmt.Fprint(outFile, sb.String())
tryOrLog(err, "Failed to write to output file")
tryOrLog(outFile.Sync(), "Failed to sync output file")
tryOrLog(outFile.Close(), "Failed to close output file")
containerID := "<CONTAINER>"
cid, err := container.GetRunningContainerID()
tryOrLog(err, "Failed to get running container ID")
if cid != "" {
containerID = cid.ShortID()
}
logf("To get the environment file, use:")
logf("cp %v:%v ./watchtower-notifications.env", containerID, outFile.Name())
logf("")
logf("Note: This file will be removed in 5 minutes or when this container is stopped!")
signalChannel := make(chan os.Signal, 1)
time.AfterFunc(5*time.Minute, func() {
signalChannel <- syscall.SIGALRM
})
signal.Notify(signalChannel, os.Interrupt)
signal.Notify(signalChannel, syscall.SIGTERM)
switch <-signalChannel {
case syscall.SIGALRM:
logf("Timed out!")
case os.Interrupt, syscall.SIGTERM:
logf("Stopping...")
default:
}
if err := os.Remove(outFile.Name()); err != nil {
logf("Failed to remove file, it may still be present in the container image! Error: %v", err)
} else {
logf("Environment file has been removed.")
}
return nil
}
func tryOrLog(err error, message string) {
if err != nil {
logf("%v: %v\n", message, err)
}
}
func logf(format string, v ...interface{}) {
fmt.Fprintln(os.Stderr, fmt.Sprintf(format, v...))
}

View file

@ -54,6 +54,7 @@ func NewRootCommand() *cobra.Command {
`,
Run: Run,
PreRun: PreRun,
Args: cobra.ArbitraryArgs,
}
}
@ -66,6 +67,7 @@ func init() {
// Execute the root func and exit in case of errors
func Execute() {
rootCmd.AddCommand(notifyUpgradeCommand)
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
@ -87,11 +89,11 @@ func PreRun(cmd *cobra.Command, _ []string) {
})
}
if enabled, _ := f.GetBool("debug"); enabled {
log.SetLevel(log.DebugLevel)
}
if enabled, _ := f.GetBool("trace"); enabled {
log.SetLevel(log.TraceLevel)
rawLogLevel, _ := f.GetString(`log-level`)
if logLevel, err := log.ParseLevel(rawLogLevel); err != nil {
log.Fatalf("Invalid log level: %s", err.Error())
} else {
log.SetLevel(logLevel)
}
scheduleSpec, _ = f.GetString("schedule")
@ -139,6 +141,7 @@ func PreRun(cmd *cobra.Command, _ []string) {
})
notifier = notifications.NewNotifier(cmd)
notifier.AddLogHook()
}
// Run is the main execution flow of the command
@ -179,7 +182,10 @@ func Run(c *cobra.Command, names []string) {
httpAPI := api.New(apiToken)
if enableUpdateAPI {
updateHandler := update.New(func(images []string) { runUpdatesWithNotifications(filters.FilterByImage(images, filter)) }, updateLock)
updateHandler := update.New(func(images []string) {
metric := runUpdatesWithNotifications(filters.FilterByImage(images, filter))
metrics.RegisterScan(metric)
}, updateLock)
httpAPI.RegisterFunc(updateHandler.Path, updateHandler.Handle)
// If polling isn't enabled the scheduler is never started and
// we need to trigger the startup messages manually.

View file

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM alpine:3.16.2 as alpine
FROM --platform=$BUILDPLATFORM alpine:3.17.1 as alpine
RUN apk add --no-cache \
ca-certificates \

View file

@ -71,6 +71,10 @@ Environment Variable: WATCHTOWER_REMOVE_VOLUMES
## Debug
Enable debug mode with verbose logging.
!!! note "Notes"
Alias for `--log-level debug`. See [Maximum log level](#maximum-log-level).
Does _not_ take an argument when used as an argument. Using `--debug true` will **not** work.
```text
Argument: --debug, -d
Environment Variable: WATCHTOWER_DEBUG
@ -81,6 +85,10 @@ Environment Variable: WATCHTOWER_DEBUG
## Trace
Enable trace mode with very verbose logging. Caution: exposes credentials!
!!! note "Notes"
Alias for `--log-level trace`. See [Maximum log level](#maximum-log-level).
Does _not_ take an argument when used as an argument. Using `--trace true` will **not** work.
```text
Argument: --trace
Environment Variable: WATCHTOWER_TRACE
@ -88,6 +96,17 @@ Environment Variable: WATCHTOWER_TRACE
Default: false
```
## Maximum log level
The maximum log level that will be written to STDERR (shown in `docker log` when used in a container).
```text
Argument: --log-level
Environment Variable: WATCHTOWER_LOG_LEVEL
Possible values: panic, fatal, error, warn, info, debug or trace
Default: info
```
## ANSI colors
Disable ANSI color escape codes in log output.
@ -341,4 +360,4 @@ requests and may rate limit pull requests (mainly docker.io).
Environment Variable: WATCHTOWER_WARN_ON_HEAD_FAILURE
Possible values: always, auto, never
Default: auto
```
```

View file

@ -1,15 +1,7 @@
# Notifications
Watchtower can send notifications when containers are updated. Notifications are sent via hooks in the logging
system, [logrus](http://github.com/sirupsen/logrus). The types of notifications to send are set by passing a
comma-separated list of values to the `--notifications` option
(or corresponding environment variable `WATCHTOWER_NOTIFICATIONS`), which has the following valid values:
- `email` to send notifications via e-mail
- `slack` to send notifications through a Slack webhook
- `msteams` to send notifications via MSTeams webhook
- `gotify` to send notifications via Gotify
- `shoutrrr` to send notifications via [containrrr/shoutrrr](https://github.com/containrrr/shoutrrr)
system, [logrus](http://github.com/sirupsen/logrus).
!!! note "Using multiple notifications with environment variables"
There is currently a bug in Viper (https://github.com/spf13/viper/issues/380), which prevents comma-separated slices to
@ -31,7 +23,221 @@ comma-separated list of values to the `--notifications` option
- `notification-title-tag` (env. `WATCHTOWER_NOTIFICATION_TITLE_TAG`): Prefix to include in the title. Useful when running multiple watchtowers.
- `notification-skip-title` (env. `WATCHTOWER_NOTIFICATION_SKIP_TITLE`): Do not pass the title param to notifications. This will not pass a dynamic title override to notification services. If no title is configured for the service, it will remove the title all together.
## Available services
## [shoutrrr](https://github.com/containrrr/shoutrrr) notifications
To send notifications via shoutrrr, the following command-line options, or their corresponding environment variables, can be set:
- `--notification-url` (env. `WATCHTOWER_NOTIFICATION_URL`): The shoutrrr service URL to be used. This option can also reference a file, in which case the contents of the file are used.
Go to [containrrr.dev/shoutrrr/v0.7/services/overview](https://containrrr.dev/shoutrrr/v0.6/services/overview) to
learn more about the different service URLs you can use. You can define multiple services by space separating the
URLs. (See example below)
You can customize the message posted by setting a template.
- `--notification-template` (env. `WATCHTOWER_NOTIFICATION_TEMPLATE`): The template used for the message.
The template is a Go [template](https://golang.org/pkg/text/template/) that either format a list
of [log entries](https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry) or a `notification.Data` struct.
Simple templates are used unless the `notification-report` flag is specified:
- `--notification-report` (env. `WATCHTOWER_NOTIFICATION_REPORT`): Use the session report as the notification template data.
## Simple templates
The default value if not set is `{{range .}}{{.Message}}{{println}}{{end}}`. The example below uses a template that also
outputs timestamp and log level.
!!! tip "Custom date format"
If you want to adjust the date/time format it must show how the
[reference time](https://golang.org/pkg/time/#pkg-constants) (_Mon Jan 2 15:04:05 MST 2006_) would be displayed in your
custom format.
i.e., The day of the year has to be 1, the month has to be 2 (february), the hour 3 (or 15 for 24h time) etc.
Example:
```bash
docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_NOTIFICATION_URL="discord://token@channel slack://watchtower@token-a/token-b/token-c" \
-e WATCHTOWER_NOTIFICATION_TEMPLATE="{{range .}}{{.Time.Format \"2006-01-02 15:04:05\"}} ({{.Level}}): {{.Message}}{{println}}{{end}}" \
containrrr/watchtower
```
## Report templates
The default template for report notifications are the following:
```go
{{- if .Report -}}
{{- with .Report -}}
{{- if ( or .Updated .Failed ) -}}
{{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
{{- range .Updated}}
- {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
{{- end -}}
{{- range .Fresh}}
- {{.Name}} ({{.ImageName}}): {{.State}}
{{- end -}}
{{- range .Skipped}}
- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
{{- end -}}
{{- range .Failed}}
- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- else -}}
{{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
{{- end -}}
```
It will be used to send a summary of every session if there are any containers that were updated or which failed to update.
!!! note "Skipping notifications"
Whenever the result of applying the template results in an empty string, no notifications will
be sent. This is by default used to limit the notifications to only be sent when there something noteworthy occurred.
You can replace `{{- if ( or .Updated .Failed ) -}}` with any logic you want to decide when to send the notifications.
Example using a custom report template that always sends a session report after each run:
=== "docker run"
```bash
docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_NOTIFICATION_REPORT="true"
-e WATCHTOWER_NOTIFICATION_URL="discord://token@channel slack://watchtower@token-a/token-b/token-c" \
-e WATCHTOWER_NOTIFICATION_TEMPLATE="
{{- if .Report -}}
{{- with .Report -}}
{{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
{{- range .Updated}}
- {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
{{- end -}}
{{- range .Fresh}}
- {{.Name}} ({{.ImageName}}): {{.State}}
{{- end -}}
{{- range .Skipped}}
- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
{{- end -}}
{{- range .Failed}}
- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
{{- end -}}
{{- end -}}
{{- else -}}
{{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
{{- end -}}
" \
containrrr/watchtower
```
=== "docker-compose"
``` yaml
version: "3"
services:
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
WATCHTOWER_NOTIFICATION_REPORT: "true"
WATCHTOWER_NOTIFICATION_URL: >
discord://token@channel
slack://watchtower@token-a/token-b/token-c
WATCHTOWER_NOTIFICATION_TEMPLATE: |
{{- if .Report -}}
{{- with .Report -}}
{{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
{{- range .Updated}}
- {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
{{- end -}}
{{- range .Fresh}}
- {{.Name}} ({{.ImageName}}): {{.State}}
{{- end -}}
{{- range .Skipped}}
- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
{{- end -}}
{{- range .Failed}}
- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
{{- end -}}
{{- end -}}
{{- else -}}
{{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
{{- end -}}
```
## Legacy notifications
For backwards compatibility, the notifications can also be configured using legacy notification options. These will automatically be converted to shoutrrr URLs when used.
The types of notifications to send are set by passing a comma-separated list of values to the `--notifications` option
(or corresponding environment variable `WATCHTOWER_NOTIFICATIONS`), which has the following valid values:
- `email` to send notifications via e-mail
- `slack` to send notifications through a Slack webhook
- `msteams` to send notifications via MSTeams webhook
- `gotify` to send notifications via Gotify
### `notify-upgrade`
If watchtower is started with `notify-upgrade` as it's first argument, it will generate a .env file with your current legacy notification options converted to shoutrrr URLs.
=== "docker run"
```bash
$ docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_NOTIFICATIONS=slack \
-e WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL="https://hooks.slack.com/services/xxx/yyyyyyyyyyyyyyy" \
containrrr/watchtower \
notify-upgrade
```
=== "docker-compose.yml"
```yaml
version: "3"
services:
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
WATCHTOWER_NOTIFICATIONS: slack
WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL: https://hooks.slack.com/services/xxx/yyyyyyyyyyyyyyy
command: notify-upgrade
```
You can then copy this file from the container (a message with the full command to do so will be logged) and use it with your current setup:
=== "docker run"
```bash
$ docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
--env-file watchtower-notifications.env \
containrrr/watchtower
```
=== "docker-compose.yml"
```yaml
version: "3"
services:
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env_file:
- watchtower-notifications.env
```
### Email
@ -177,41 +383,3 @@ docker run -d \
If you want to disable TLS verification for the Gotify instance, you can use either `-e WATCHTOWER_NOTIFICATION_GOTIFY_TLS_SKIP_VERIFY=true` or `--notification-gotify-tls-skip-verify`.
### [containrrr/shoutrrr](https://github.com/containrrr/shoutrrr)
To send notifications via shoutrrr, the following command-line options, or their corresponding environment variables, can be set:
- `--notification-url` (env. `WATCHTOWER_NOTIFICATION_URL`): The shoutrrr service URL to be used. This option can also reference a file, in which case the contents of the file are used.
Go to [containrrr.dev/shoutrrr/v0.6/services/overview](https://containrrr.dev/shoutrrr/v0.6/services/overview) to
learn more about the different service URLs you can use. You can define multiple services by space separating the
URLs. (See example below)
You can customize the message posted by setting a template.
- `--notification-template` (env. `WATCHTOWER_NOTIFICATION_TEMPLATE`): The template used for the message.
The template is a Go [template](https://golang.org/pkg/text/template/) and that format a list
of [log entries](https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry).
The default value if not set is `{{range .}}{{.Message}}{{println}}{{end}}`. The example below uses a template that also
outputs timestamp and log level.
!!! tip "Custom date format"
If you want to adjust the date/time format it must show how the
[reference time](https://golang.org/pkg/time/#pkg-constants) (_Mon Jan 2 15:04:05 MST 2006_) would be displayed in your
custom format.
i.e., The day of the year has to be 1, the month has to be 2 (february), the hour 3 (or 15 for 24h time) etc.
Example:
```bash
docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_NOTIFICATIONS=shoutrrr \
-e WATCHTOWER_NOTIFICATION_URL="discord://token@channel slack://watchtower@token-a/token-b/token-c" \
-e WATCHTOWER_NOTIFICATION_TEMPLATE="{{range .}}{{.Time.Format \"2006-01-02 15:04:05\"}} ({{.Level}}): {{.Message}}{{println}}{{end}}" \
containrrr/watchtower
```

View file

@ -1,16 +1,87 @@
[data-md-color-scheme="containrrr"] {
--md-primary-fg-color: #406170;
--md-primary-fg-color--light:#acbfc7;
--md-primary-fg-color--dark: #003343;
--md-accent-fg-color: #003343;
--md-accent-fg-color--transparent: #00334310;
/* Primary and accent */
--md-primary-fg-color: #406170;
--md-primary-fg-color--light:#acbfc7;
--md-primary-fg-color--dark: #003343;
--md-accent-fg-color: #003343;
--md-accent-fg-color--transparent: #00334310;
/* Typeset overrides */
--md-typeset-a-color: var(--md-primary-fg-color);
}
[data-md-color-scheme="containrrr-dark"] {
--md-hue: 199;
/* Primary and accent */
--md-primary-fg-color: hsl(199deg 27% 35% / 100%);
--md-primary-fg-color--link: hsl(199deg 45% 65% / 100%);
--md-primary-fg-color--light: hsl(198deg 19% 73% / 100%);
--md-primary-fg-color--dark: hsl(194deg 100% 13% / 100%);
--md-accent-fg-color: hsl(194deg 45% 50% / 100%);
--md-accent-fg-color--transparent: hsl(194deg 45% 50% / 6.3%);
/* Default */
--md-default-fg-color: hsl(var(--md-hue) 75% 95% / 100%);
--md-default-fg-color--light: hsl(var(--md-hue) 75% 90% / 62%);
--md-default-fg-color--lighter: hsl(var(--md-hue) 75% 90% / 32%);
--md-default-fg-color--lightest: hsl(var(--md-hue) 75% 90% / 12%);
--md-default-bg-color: hsl(var(--md-hue) 15% 21% / 100%);
--md-default-bg-color--light: hsl(var(--md-hue) 15% 21% / 54%);
--md-default-bg-color--lighter: hsl(var(--md-hue) 15% 21% / 26%);
--md-default-bg-color--lightest: hsl(var(--md-hue) 15% 21% / 7%);
/* Code */
--md-code-fg-color: hsl(var(--md-hue) 18% 86% / 100%);
--md-code-bg-color: hsl(var(--md-hue) 15% 15% / 100%);
--md-code-hl-color: hsl(218deg 100% 63% / 15%);
--md-code-hl-number-color: hsl(346deg 74% 63% / 100%);
--md-code-hl-special-color: hsl(320deg 83% 66% / 100%);
--md-code-hl-function-color: hsl(271deg 57% 65% / 100%);
--md-code-hl-constant-color: hsl(230deg 62% 70% / 100%);
--md-code-hl-keyword-color: hsl(199deg 33% 64% / 100%);
--md-code-hl-string-color: hsl( 50deg 34% 74% / 100%);
--md-code-hl-name-color: var(--md-code-fg-color);
--md-code-hl-operator-color: var(--md-default-fg-color--light);
--md-code-hl-punctuation-color: var(--md-default-fg-color--light);
--md-code-hl-comment-color: var(--md-default-fg-color--light);
--md-code-hl-generic-color: var(--md-default-fg-color--light);
--md-code-hl-variable-color: hsl(241deg 22% 60% / 100%);
/* Typeset */
--md-typeset-color: var(--md-default-fg-color);
--md-typeset-a-color: var(--md-primary-fg-color--link);
--md-typeset-mark-color: hsl(218deg 100% 63% / 30%);
--md-typeset-kbd-color: hsl(var(--md-hue) 15% 94% / 12%);
--md-typeset-kbd-accent-color: hsl(var(--md-hue) 15% 94% / 20%);
--md-typeset-kbd-border-color: hsl(var(--md-hue) 15% 14% / 100%);
--md-typeset-table-color: hsl(var(--md-hue) 75% 95% / 12%);
/* Admonition */
--md-admonition-fg-color: var(--md-default-fg-color);
--md-admonition-bg-color: var(--md-default-bg-color);
/* Footer */
--md-footer-bg-color: hsl(var(--md-hue) 15% 12% / 87%);
--md-footer-bg-color--dark: hsl(var(--md-hue) 15% 10% / 100%);
/* Shadows */
--md-shadow-z1:
0 0.2rem 0.50rem rgba(0 0 0 20%),
0 0 0.05rem rgba(0 0 0 10%);
--md-shadow-z2:
0 0.2rem 0.50rem rgba(0 0 0 30%),
0 0 0.05rem rgba(0 0 0 25%);
--md-shadow-z3:
0 0.2rem 0.50rem rgba(0 0 0 40%),
0 0 0.05rem rgba(0 0 0 35%);
}
.md-header-nav__button.md-logo {
padding: 0;
padding: 0;
}
.md-header-nav__button.md-logo img {
width: 1.6rem;
height: 1.6rem;
}
width: 1.6rem;
height: 1.6rem;
}

52
go.mod
View file

@ -3,22 +3,21 @@ module github.com/containrrr/watchtower
go 1.18
require (
github.com/containrrr/shoutrrr v0.6.1
github.com/docker/cli v20.10.17+incompatible
github.com/containrrr/shoutrrr v0.7.1
github.com/docker/cli v20.10.23+incompatible
github.com/docker/distribution v2.8.1+incompatible
github.com/docker/docker v20.10.17+incompatible
github.com/docker/docker v20.10.23+incompatible
github.com/docker/go-connections v0.4.0
github.com/johntdyer/slackrus v0.0.0-20180518184837-f7aae3243a07
github.com/onsi/ginkgo v1.16.5
github.com/onsi/gomega v1.20.2
github.com/prometheus/client_golang v1.13.0
github.com/onsi/gomega v1.26.0
github.com/prometheus/client_golang v1.14.0
github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.5.0
github.com/spf13/cobra v1.6.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.12.0
github.com/stretchr/testify v1.8.0
golang.org/x/net v0.0.0-20220722155237-a158d28d115b
github.com/spf13/viper v1.15.0
github.com/stretchr/testify v1.8.1
golang.org/x/net v0.5.0
)
require (
@ -30,16 +29,15 @@ require (
github.com/docker/docker-credential-helpers v0.6.1 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/johntdyer/slack-go v0.0.0-20180213144715-95fac1160b22 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
@ -47,25 +45,23 @@ require (
github.com/nxadm/tail v1.4.8 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.0.1 // indirect
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/spf13/afero v1.8.2 // indirect
github.com/spf13/afero v1.9.3 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/stretchr/objx v0.4.0 // indirect
github.com/subosito/gotenv v1.3.0 // indirect
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
golang.org/x/text v0.3.7
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/text v0.6.0
golang.org/x/time v0.1.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/ini.v1 v1.66.4 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.0.3 // indirect
)

691
go.sum

File diff suppressed because it is too large Load diff

View file

@ -182,6 +182,10 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
viper.GetString("WATCHTOWER_PORCELAIN"),
`Write session results to stdout using a stable versioned format. Supported values: "v1"`)
flags.String(
"log-level",
viper.GetString("WATCHTOWER_LOG_LEVEL"),
"The maximum log level that will be written to STDERR. Possible values: panic, fatal, error, warn, info, debug or trace")
}
// RegisterNotificationFlags that are used by watchtower to send notifications
@ -374,6 +378,7 @@ func SetDefaults() {
viper.SetDefault("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT", 25)
viper.SetDefault("WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG", "")
viper.SetDefault("WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER", "watchtower")
viper.SetDefault("WATCHTOWER_LOG_LEVEL", "info")
}
// EnvConfig translates the command-line options into environment variables
@ -561,6 +566,23 @@ func ProcessFlagAliases(flags *pflag.FlagSet) {
interval, _ := flags.GetInt(`interval`)
flags.Set(`schedule`, fmt.Sprintf(`@every %ds`, interval))
}
if flagIsEnabled(flags, `debug`) {
flags.Set(`log-level`, `debug`)
}
if flagIsEnabled(flags, `trace`) {
flags.Set(`log-level`, `trace`)
}
}
func flagIsEnabled(flags *pflag.FlagSet, name string) bool {
value, err := flags.GetBool(name)
if err != nil {
log.Fatalf(`The flag %q is not defined`, name)
}
return value
}
func appendFlagValue(flags *pflag.FlagSet, name string, values ...string) error {

View file

@ -129,11 +129,6 @@ func TestIsFile(t *testing.T) {
assert.True(t, isFile(os.Args[0]), "the currently running binary path should always be considered a file")
}
func TestReadFlags(t *testing.T) {
logrus.StandardLogger().ExitFunc = func(_ int) { t.FailNow() }
}
func TestProcessFlagAliases(t *testing.T) {
logrus.StandardLogger().ExitFunc = func(_ int) { t.FailNow() }
cmd := new(cobra.Command)
@ -145,6 +140,7 @@ func TestProcessFlagAliases(t *testing.T) {
require.NoError(t, cmd.ParseFlags([]string{
`--porcelain`, `v1`,
`--interval`, `10`,
`--trace`,
}))
flags := cmd.Flags()
ProcessFlagAliases(flags)
@ -163,6 +159,28 @@ func TestProcessFlagAliases(t *testing.T) {
sched, _ := flags.GetString(`schedule`)
assert.Equal(t, `@every 10s`, sched)
logLevel, _ := flags.GetString(`log-level`)
assert.Equal(t, `trace`, logLevel)
}
func TestProcessFlagAliasesLogLevelFromEnvironment(t *testing.T) {
cmd := new(cobra.Command)
err := os.Setenv("WATCHTOWER_DEBUG", `true`)
require.NoError(t, err)
defer os.Unsetenv("WATCHTOWER_DEBUG")
SetDefaults()
RegisterDockerFlags(cmd)
RegisterSystemFlags(cmd)
RegisterNotificationFlags(cmd)
require.NoError(t, cmd.ParseFlags([]string{}))
flags := cmd.Flags()
ProcessFlagAliases(flags)
logLevel, _ := flags.GetString(`log-level`)
assert.Equal(t, `debug`, logLevel)
}
func TestProcessFlagAliasesSchedAndInterval(t *testing.T) {

View file

@ -5,7 +5,16 @@ edit_uri: edit/main/docs/
theme:
name: 'material'
palette:
scheme: containrrr
- media: "(prefers-color-scheme: light)"
scheme: containrrr
toggle:
icon: material/weather-night
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: containrrr-dark
toggle:
icon: material/weather-sunny
name: Switch to light mode
logo: images/logo-450px.png
favicon: images/favicon.ico
extra_css:
@ -24,7 +33,7 @@ markdown_extensions:
repo: watchtower
- pymdownx.saneheaders
- pymdownx.tabbed:
alternate_style: true
alternate_style: true
nav:
- 'Home': 'index.md'
- 'Introduction': 'introduction.md'

View file

@ -0,0 +1,29 @@
package container
import (
"fmt"
"os"
"regexp"
"github.com/containrrr/watchtower/pkg/types"
)
var dockerContainerPattern = regexp.MustCompile(`[0-9]+:.*:/docker/([a-f|0-9]{64})`)
// GetRunningContainerID tries to resolve the current container ID from the current process cgroup information
func GetRunningContainerID() (cid types.ContainerID, err error) {
file, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", os.Getpid()))
if err != nil {
return
}
return getRunningContainerIDFromString(string(file)), nil
}
func getRunningContainerIDFromString(s string) types.ContainerID {
matches := dockerContainerPattern.FindStringSubmatch(s)
if len(matches) < 2 {
return ""
}
return types.ContainerID(matches[1])
}

View file

@ -0,0 +1,40 @@
package container
import (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("GetRunningContainerID", func() {
When("a matching container ID is found", func() {
It("should return that container ID", func() {
cid := getRunningContainerIDFromString(`
15:name=systemd:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
14:misc:/
13:rdma:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
12:pids:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
11:hugetlb:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
10:net_prio:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
9:perf_event:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
8:net_cls:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
7:freezer:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
6:devices:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
5:blkio:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
4:cpuacct:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
3:cpu:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
2:cpuset:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
1:memory:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
0::/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
`)
Expect(cid).To(BeEquivalentTo(`991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377`))
})
})
When("no matching container ID could be found", func() {
It("should return that container ID", func() {
cid := getRunningContainerIDFromString(`14:misc:/`)
Expect(cid).To(BeEmpty())
})
})
})
//

View file

@ -192,6 +192,10 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
log.Debugf("Removing container %s", shortID)
if err := client.api.ContainerRemove(bg, idStr, types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.RemoveVolumes}); err != nil {
if sdkClient.IsErrNotFound(err) {
log.Debugf("Container %s not found, skipping removal.", shortID)
return nil
}
return err
}
}

View file

@ -1,6 +1,8 @@
package container
import (
"time"
"github.com/containrrr/watchtower/pkg/container/mocks"
"github.com/containrrr/watchtower/pkg/filters"
t "github.com/containrrr/watchtower/pkg/types"
@ -33,8 +35,8 @@ var _ = Describe("the client", func() {
mockServer.Close()
})
Describe("WarnOnHeadPullFailed", func() {
containerUnknown := *mockContainerWithImageName("unknown.repo/prefix/imagename:latest")
containerKnown := *mockContainerWithImageName("docker.io/prefix/imagename:latest")
containerUnknown := *MockContainer(WithImageName("unknown.repo/prefix/imagename:latest"))
containerKnown := *MockContainer(WithImageName("docker.io/prefix/imagename:latest"))
When(`warn on head failure is set to "always"`, func() {
c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnAlways}}
@ -64,11 +66,43 @@ var _ = Describe("the client", func() {
When("the image consist of a pinned hash", func() {
It("should gracefully fail with a useful message", func() {
c := dockerClient{}
pinnedContainer := *mockContainerWithImageName("sha256:fa5269854a5e615e51a72b17ad3fd1e01268f278a6684c8ed3c5f0cdce3f230b")
pinnedContainer := *MockContainer(WithImageName("sha256:fa5269854a5e615e51a72b17ad3fd1e01268f278a6684c8ed3c5f0cdce3f230b"))
c.PullImage(context.Background(), pinnedContainer)
})
})
})
When("removing a running container", func() {
When("the container still exist after stopping", func() {
It("should attempt to remove the container", func() {
container := *MockContainer(WithContainerState(types.ContainerState{Running: true}))
containerStopped := *MockContainer(WithContainerState(types.ContainerState{Running: false}))
cid := container.ContainerInfo().ID
mockServer.AppendHandlers(
mocks.KillContainerHandler(cid, mocks.Found),
mocks.GetContainerHandler(cid, containerStopped.ContainerInfo()),
mocks.RemoveContainerHandler(cid, mocks.Found),
mocks.GetContainerHandler(cid, nil),
)
Expect(dockerClient{api: docker}.StopContainer(container, time.Minute)).To(Succeed())
})
})
When("the container does not exist after stopping", func() {
It("should not cause an error", func() {
container := *MockContainer(WithContainerState(types.ContainerState{Running: true}))
cid := container.ContainerInfo().ID
mockServer.AppendHandlers(
mocks.KillContainerHandler(cid, mocks.Found),
mocks.GetContainerHandler(cid, nil),
mocks.RemoveContainerHandler(cid, mocks.Missing),
)
Expect(dockerClient{api: docker}.StopContainer(container, time.Minute)).To(Succeed())
})
})
})
When("listing containers", func() {
When("no filter is provided", func() {
It("should return all available containers", func() {

View file

@ -1,3 +1,4 @@
// Package container contains code related to dealing with docker containers
package container
import (

View file

@ -0,0 +1,66 @@
package container
import (
"github.com/docker/docker/api/types"
dockerContainer "github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
)
type MockContainerUpdate func(*types.ContainerJSON, *types.ImageInspect)
func MockContainer(updates ...MockContainerUpdate) *Container {
containerInfo := types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
ID: "container_id",
Image: "image",
Name: "test-containrrr",
HostConfig: &dockerContainer.HostConfig{},
},
Config: &dockerContainer.Config{
Labels: map[string]string{},
},
}
image := types.ImageInspect{
ID: "image_id",
}
for _, update := range updates {
update(&containerInfo, &image)
}
return NewContainer(&containerInfo, &image)
}
func WithPortBindings(portBindingSources ...string) MockContainerUpdate {
return func(c *types.ContainerJSON, i *types.ImageInspect) {
portBindings := nat.PortMap{}
for _, pbs := range portBindingSources {
portBindings[nat.Port(pbs)] = []nat.PortBinding{}
}
c.HostConfig.PortBindings = portBindings
}
}
func WithImageName(name string) MockContainerUpdate {
return func(c *types.ContainerJSON, i *types.ImageInspect) {
c.Config.Image = name
i.RepoTags = append(i.RepoTags, name)
}
}
func WithLinks(links []string) MockContainerUpdate {
return func(c *types.ContainerJSON, i *types.ImageInspect) {
c.HostConfig.Links = links
}
}
func WithLabels(labels map[string]string) MockContainerUpdate {
return func(c *types.ContainerJSON, i *types.ImageInspect) {
c.Config.Labels = labels
}
}
func WithContainerState(state types.ContainerState) MockContainerUpdate {
return func(cnt *types.ContainerJSON, img *types.ImageInspect) {
cnt.State = &state
}
}

View file

@ -1,8 +1,6 @@
package container
import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
@ -12,7 +10,7 @@ var _ = Describe("the container", func() {
Describe("VerifyConfiguration", func() {
When("verifying a container with no image info", func() {
It("should return an error", func() {
c := mockContainerWithPortBindings()
c := MockContainer(WithPortBindings())
c.imageInfo = nil
err := c.VerifyConfiguration()
Expect(err).To(Equal(errorNoImageInfo))
@ -20,7 +18,7 @@ var _ = Describe("the container", func() {
})
When("verifying a container with no container info", func() {
It("should return an error", func() {
c := mockContainerWithPortBindings()
c := MockContainer(WithPortBindings())
c.containerInfo = nil
err := c.VerifyConfiguration()
Expect(err).To(Equal(errorNoContainerInfo))
@ -28,7 +26,7 @@ var _ = Describe("the container", func() {
})
When("verifying a container with no config", func() {
It("should return an error", func() {
c := mockContainerWithPortBindings()
c := MockContainer(WithPortBindings())
c.containerInfo.Config = nil
err := c.VerifyConfiguration()
Expect(err).To(Equal(errorInvalidConfig))
@ -36,7 +34,7 @@ var _ = Describe("the container", func() {
})
When("verifying a container with no host config", func() {
It("should return an error", func() {
c := mockContainerWithPortBindings()
c := MockContainer(WithPortBindings())
c.containerInfo.HostConfig = nil
err := c.VerifyConfiguration()
Expect(err).To(Equal(errorInvalidConfig))
@ -44,14 +42,14 @@ var _ = Describe("the container", func() {
})
When("verifying a container with no port bindings", func() {
It("should not return an error", func() {
c := mockContainerWithPortBindings()
c := MockContainer(WithPortBindings())
err := c.VerifyConfiguration()
Expect(err).ToNot(HaveOccurred())
})
})
When("verifying a container with port bindings, but no exposed ports", func() {
It("should make the config compatible with updating", func() {
c := mockContainerWithPortBindings("80/tcp")
c := MockContainer(WithPortBindings("80/tcp"))
c.containerInfo.Config.ExposedPorts = nil
Expect(c.VerifyConfiguration()).To(Succeed())
@ -61,7 +59,7 @@ var _ = Describe("the container", func() {
})
When("verifying a container with port bindings and exposed ports is non-nil", func() {
It("should return an error", func() {
c := mockContainerWithPortBindings("80/tcp")
c := MockContainer(WithPortBindings("80/tcp"))
c.containerInfo.Config.ExposedPorts = map[nat.Port]struct{}{"80/tcp": {}}
err := c.VerifyConfiguration()
Expect(err).ToNot(HaveOccurred())
@ -71,10 +69,10 @@ var _ = Describe("the container", func() {
When("asked for metadata", func() {
var c *Container
BeforeEach(func() {
c = mockContainerWithLabels(map[string]string{
c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.enable": "true",
"com.centurylinklabs.watchtower": "true",
})
}))
})
It("should return its name on calls to .Name()", func() {
name := c.Name()
@ -91,36 +89,28 @@ var _ = Describe("the container", func() {
enabled, exists := c.Enabled()
Expect(enabled).To(BeTrue())
Expect(enabled).NotTo(BeFalse())
Expect(exists).To(BeTrue())
Expect(exists).NotTo(BeFalse())
})
It("should return false, true if present but not true on calls to .Enabled()", func() {
c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "false"})
c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "false"}))
enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse())
Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeTrue())
Expect(exists).NotTo(BeFalse())
})
It("should return false, false if not present on calls to .Enabled()", func() {
c = mockContainerWithLabels(map[string]string{"lol": "false"})
c = MockContainer(WithLabels(map[string]string{"lol": "false"}))
enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse())
Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeFalse())
Expect(exists).NotTo(BeTrue())
})
It("should return false, false if present but not parsable .Enabled()", func() {
c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "falsy"})
c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "falsy"}))
enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse())
Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeFalse())
Expect(exists).NotTo(BeTrue())
})
When("checking if its a watchtower instance", func() {
It("should return true if the label is set to true", func() {
@ -128,31 +118,31 @@ var _ = Describe("the container", func() {
Expect(isWatchtower).To(BeTrue())
})
It("should return false if the label is present but set to false", func() {
c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower": "false"})
c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower": "false"}))
isWatchtower := c.IsWatchtower()
Expect(isWatchtower).To(BeFalse())
})
It("should return false if the label is not present", func() {
c = mockContainerWithLabels(map[string]string{"funny.label": "false"})
c = MockContainer(WithLabels(map[string]string{"funny.label": "false"}))
isWatchtower := c.IsWatchtower()
Expect(isWatchtower).To(BeFalse())
})
It("should return false if there are no labels", func() {
c = mockContainerWithLabels(map[string]string{})
c = MockContainer(WithLabels(map[string]string{}))
isWatchtower := c.IsWatchtower()
Expect(isWatchtower).To(BeFalse())
})
})
When("fetching the custom stop signal", func() {
It("should return the signal if its set", func() {
c = mockContainerWithLabels(map[string]string{
c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.stop-signal": "SIGKILL",
})
}))
stopSignal := c.StopSignal()
Expect(stopSignal).To(Equal("SIGKILL"))
})
It("should return an empty string if its not set", func() {
c = mockContainerWithLabels(map[string]string{})
c = MockContainer(WithLabels(map[string]string{}))
stopSignal := c.StopSignal()
Expect(stopSignal).To(Equal(""))
})
@ -160,22 +150,22 @@ var _ = Describe("the container", func() {
When("fetching the image name", func() {
When("the zodiac label is present", func() {
It("should fetch the image name from it", func() {
c = mockContainerWithLabels(map[string]string{
c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.zodiac.original-image": "the-original-image",
})
}))
imageName := c.ImageName()
Expect(imageName).To(Equal(imageName))
})
})
It("should return the image name", func() {
name := "image-name:3"
c = mockContainerWithImageName(name)
c = MockContainer(WithImageName(name))
imageName := c.ImageName()
Expect(imageName).To(Equal(name))
})
It("should assume latest if no tag is supplied", func() {
name := "image-name"
c = mockContainerWithImageName(name)
c = MockContainer(WithImageName(name))
imageName := c.ImageName()
Expect(imageName).To(Equal(name + ":latest"))
})
@ -184,33 +174,33 @@ var _ = Describe("the container", func() {
When("fetching container links", func() {
When("the depends on label is present", func() {
It("should fetch depending containers from it", func() {
c = mockContainerWithLabels(map[string]string{
c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.depends-on": "postgres",
})
}))
links := c.Links()
Expect(links).To(SatisfyAll(ContainElement("postgres"), HaveLen(1)))
})
It("should fetch depending containers if there are many", func() {
c = mockContainerWithLabels(map[string]string{
c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.depends-on": "postgres,redis",
})
}))
links := c.Links()
Expect(links).To(SatisfyAll(ContainElement("postgres"), ContainElement("redis"), HaveLen(2)))
})
It("should fetch depending containers if label is blank", func() {
c = mockContainerWithLabels(map[string]string{
c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.depends-on": "",
})
}))
links := c.Links()
Expect(links).To(HaveLen(0))
})
})
When("the depends on label is not present", func() {
It("should fetch depending containers from host config links", func() {
c = mockContainerWithLinks([]string{
c = MockContainer(WithLinks([]string{
"redis:test-containrrr",
"postgres:test-containrrr",
})
}))
links := c.Links()
Expect(links).To(SatisfyAll(ContainElement("redis"), ContainElement("postgres"), HaveLen(2)))
})
@ -219,10 +209,10 @@ var _ = Describe("the container", func() {
When("there is a pre or post update timeout", func() {
It("should return minute values", func() {
c = mockContainerWithLabels(map[string]string{
c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "3",
"com.centurylinklabs.watchtower.lifecycle.post-update-timeout": "5",
})
}))
preTimeout := c.PreUpdateTimeout()
Expect(preTimeout).To(Equal(3))
postTimeout := c.PostUpdateTimeout()
@ -232,53 +222,3 @@ var _ = Describe("the container", func() {
})
})
func mockContainerWithPortBindings(portBindingSources ...string) *Container {
mockContainer := mockContainerWithLabels(nil)
mockContainer.imageInfo = &types.ImageInspect{}
hostConfig := &container.HostConfig{
PortBindings: nat.PortMap{},
}
for _, pbs := range portBindingSources {
hostConfig.PortBindings[nat.Port(pbs)] = []nat.PortBinding{}
}
mockContainer.containerInfo.HostConfig = hostConfig
return mockContainer
}
func mockContainerWithImageName(name string) *Container {
mockContainer := mockContainerWithLabels(nil)
mockContainer.containerInfo.Config.Image = name
return mockContainer
}
func mockContainerWithLinks(links []string) *Container {
content := types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
ID: "container_id",
Image: "image",
Name: "test-containrrr",
HostConfig: &container.HostConfig{
Links: links,
},
},
Config: &container.Config{
Labels: map[string]string{},
},
}
return NewContainer(&content, nil)
}
func mockContainerWithLabels(labels map[string]string) *Container {
content := types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
ID: "container_id",
Image: "image",
Name: "test-containrrr",
},
Config: &container.Config{
Labels: labels,
},
}
return NewContainer(&content, nil)
}

View file

@ -3,14 +3,15 @@ package mocks
import (
"encoding/json"
"fmt"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
O "github.com/onsi/gomega"
"github.com/onsi/gomega/ghttp"
"io/ioutil"
"net/http"
"net/url"
"path/filepath"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
O "github.com/onsi/gomega"
"github.com/onsi/gomega/ghttp"
)
func getMockJSONFile(relPath string) ([]byte, error) {
@ -42,14 +43,14 @@ func respondWithJSONFile(relPath string, statusCode int, optionalHeader ...http.
func GetContainerHandlers(containerFiles ...string) []http.HandlerFunc {
handlers := make([]http.HandlerFunc, 0, len(containerFiles)*2)
for _, file := range containerFiles {
handlers = append(handlers, getContainerHandler(file))
handlers = append(handlers, getContainerFileHandler(file))
// Also append the image request since that will be called for every container
if file == "running" {
// The "running" container is the only one using image02
handlers = append(handlers, getImageHandler(1))
handlers = append(handlers, getImageFileHandler(1))
} else {
handlers = append(handlers, getImageHandler(0))
handlers = append(handlers, getImageFileHandler(0))
}
}
return handlers
@ -75,15 +76,36 @@ var imageIds = []string{
"sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd",
}
func getContainerHandler(file string) http.HandlerFunc {
func getContainerFileHandler(file string) http.HandlerFunc {
id, ok := containerFileIds[file]
failTestUnless(ok)
return ghttp.CombineHandlers(
ghttp.VerifyRequest("GET", O.HaveSuffix("/containers/%v/json", id)),
return getContainerHandler(
id,
RespondWithJSONFile(fmt.Sprintf("./mocks/data/container_%v.json", file), http.StatusOK),
)
}
func getContainerHandler(containerId string, responseHandler http.HandlerFunc) http.HandlerFunc {
return ghttp.CombineHandlers(
ghttp.VerifyRequest("GET", O.HaveSuffix("/containers/%v/json", containerId)),
responseHandler,
)
}
// GetContainerHandler mocks the GET containers/{id}/json endpoint
func GetContainerHandler(containerID string, containerInfo *types.ContainerJSON) http.HandlerFunc {
responseHandler := containerNotFoundResponse(containerID)
if containerInfo != nil {
responseHandler = ghttp.RespondWithJSONEncoded(http.StatusOK, containerInfo)
}
return getContainerHandler(containerID, responseHandler)
}
// GetImageHandler mocks the GET images/{id}/json endpoint
func GetImageHandler(imageInfo *types.ImageInspect) http.HandlerFunc {
return getImageHandler(imageInfo.ID, ghttp.RespondWithJSONEncoded(http.StatusOK, imageInfo))
}
// ListContainersHandler mocks the GET containers/json endpoint, filtering the returned containers based on statuses
func ListContainersHandler(statuses ...string) http.HandlerFunc {
filterArgs := createFilterArgs(statuses)
@ -116,9 +138,15 @@ func respondWithFilteredContainers(filters filters.Args) http.HandlerFunc {
return ghttp.RespondWithJSONEncoded(http.StatusOK, filteredContainers)
}
func getImageHandler(index int) http.HandlerFunc {
func getImageHandler(imageId string, responseHandler http.HandlerFunc) http.HandlerFunc {
return ghttp.CombineHandlers(
ghttp.VerifyRequest("GET", O.HaveSuffix("/images/%v/json", imageIds[index])),
ghttp.VerifyRequest("GET", O.HaveSuffix("/images/%s/json", imageId)),
responseHandler,
)
}
func getImageFileHandler(index int) http.HandlerFunc {
return getImageHandler(imageIds[index],
RespondWithJSONFile(fmt.Sprintf("./mocks/data/image%02d.json", index+1), http.StatusOK),
)
}
@ -126,3 +154,40 @@ func getImageHandler(index int) http.HandlerFunc {
func failTestUnless(ok bool) {
O.ExpectWithOffset(2, ok).To(O.BeTrue(), "test setup failed")
}
// KillContainerHandler mocks the POST containers/{id}/kill endpoint
func KillContainerHandler(containerID string, found FoundStatus) http.HandlerFunc {
responseHandler := noContentStatusResponse
if !found {
responseHandler = containerNotFoundResponse(containerID)
}
return ghttp.CombineHandlers(
ghttp.VerifyRequest("POST", O.HaveSuffix("containers/%s/kill", containerID)),
responseHandler,
)
}
// RemoveContainerHandler mocks the DELETE containers/{id} endpoint
func RemoveContainerHandler(containerID string, found FoundStatus) http.HandlerFunc {
responseHandler := noContentStatusResponse
if !found {
responseHandler = containerNotFoundResponse(containerID)
}
return ghttp.CombineHandlers(
ghttp.VerifyRequest("DELETE", O.HaveSuffix("containers/%s", containerID)),
responseHandler,
)
}
func containerNotFoundResponse(containerID string) http.HandlerFunc {
return ghttp.RespondWithJSONEncoded(http.StatusNotFound, struct{ message string }{message: "No such container: " + containerID})
}
var noContentStatusResponse = ghttp.RespondWith(http.StatusNoContent, nil)
type FoundStatus bool
const (
Found FoundStatus = true
Missing FoundStatus = false
)

View file

@ -35,5 +35,6 @@ var commonTemplates = map[string]string{
no containers matched filter
{{- end -}}
{{- end -}}`,
}
`json.v1`: `{{ . | ToJSON }}`,
}

View file

@ -15,17 +15,16 @@ const (
)
type emailTypeNotifier struct {
From, To string
Server, User, Password, SubjectTag string
Port int
tlsSkipVerify bool
entries []*log.Entry
logLevels []log.Level
delay time.Duration
From, To string
Server, User, Password string
Port int
tlsSkipVerify bool
entries []*log.Entry
delay time.Duration
}
func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
flags := c.PersistentFlags()
func newEmailNotifier(c *cobra.Command) t.ConvertibleNotifier {
flags := c.Flags()
from, _ := flags.GetString("notification-email-from")
to, _ := flags.GetString("notification-email-to")
@ -35,7 +34,6 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert
port, _ := flags.GetInt("notification-email-server-port")
tlsSkipVerify, _ := flags.GetBool("notification-email-server-tls-skip-verify")
delay, _ := flags.GetInt("notification-email-delay")
subjecttag, _ := flags.GetString("notification-email-subjecttag")
n := &emailTypeNotifier{
entries: []*log.Entry{},
@ -46,28 +44,26 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert
Password: password,
Port: port,
tlsSkipVerify: tlsSkipVerify,
logLevels: acceptedLogLevels,
delay: time.Duration(delay) * time.Second,
SubjectTag: subjecttag,
}
return n
}
func (e *emailTypeNotifier) GetURL(c *cobra.Command, title string) (string, error) {
func (e *emailTypeNotifier) GetURL(c *cobra.Command) (string, error) {
conf := &shoutrrrSmtp.Config{
FromAddress: e.From,
FromName: "Watchtower",
ToAddresses: []string{e.To},
Port: uint16(e.Port),
Host: e.Server,
Subject: e.getSubject(c, title),
Username: e.User,
Password: e.Password,
UseStartTLS: !e.tlsSkipVerify,
UseHTML: false,
Encryption: shoutrrrSmtp.EncMethods.Auto,
Auth: shoutrrrSmtp.AuthTypes.None,
ClientHost: "localhost",
}
if len(e.User) > 0 {
@ -84,11 +80,3 @@ func (e *emailTypeNotifier) GetURL(c *cobra.Command, title string) (string, erro
func (e *emailTypeNotifier) GetDelay() time.Duration {
return e.delay
}
func (e *emailTypeNotifier) getSubject(_ *cobra.Command, title string) string {
if e.SubjectTag != "" {
return e.SubjectTag + " " + title
}
return title
}

View file

@ -19,11 +19,10 @@ type gotifyTypeNotifier struct {
gotifyURL string
gotifyAppToken string
gotifyInsecureSkipVerify bool
logLevels []log.Level
}
func newGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertibleNotifier {
flags := c.PersistentFlags()
func newGotifyNotifier(c *cobra.Command) t.ConvertibleNotifier {
flags := c.Flags()
apiURL := getGotifyURL(flags)
token := getGotifyToken(flags)
@ -34,7 +33,6 @@ func newGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertibleNotifi
gotifyURL: apiURL,
gotifyAppToken: token,
gotifyInsecureSkipVerify: skipVerify,
logLevels: levels,
}
return n
@ -62,7 +60,7 @@ func getGotifyURL(flags *pflag.FlagSet) string {
return gotifyURL
}
func (n *gotifyTypeNotifier) GetURL(c *cobra.Command, title string) (string, error) {
func (n *gotifyTypeNotifier) GetURL(c *cobra.Command) (string, error) {
apiURL, err := url.Parse(n.gotifyURL)
if err != nil {
return "", err
@ -72,7 +70,6 @@ func (n *gotifyTypeNotifier) GetURL(c *cobra.Command, title string) (string, err
Host: apiURL.Host,
Path: apiURL.Path,
DisableTLS: apiURL.Scheme == "http",
Title: title,
Token: n.gotifyAppToken,
}

71
pkg/notifications/json.go Normal file
View file

@ -0,0 +1,71 @@
package notifications
import (
"encoding/json"
t "github.com/containrrr/watchtower/pkg/types"
)
type jsonMap = map[string]interface{}
// MarshalJSON implements json.Marshaler
func (d Data) MarshalJSON() ([]byte, error) {
var entries = make([]jsonMap, len(d.Entries))
for i, entry := range d.Entries {
entries[i] = jsonMap{
`level`: entry.Level,
`message`: entry.Message,
`data`: entry.Data,
`time`: entry.Time,
}
}
var report jsonMap
if d.Report != nil {
report = jsonMap{
`scanned`: marshalReports(d.Report.Scanned()),
`updated`: marshalReports(d.Report.Updated()),
`failed`: marshalReports(d.Report.Failed()),
`skipped`: marshalReports(d.Report.Skipped()),
`stale`: marshalReports(d.Report.Stale()),
`fresh`: marshalReports(d.Report.Fresh()),
}
}
return json.Marshal(jsonMap{
`report`: report,
`title`: d.Title,
`host`: d.Host,
`entries`: entries,
})
}
func marshalReports(reports []t.ContainerReport) []jsonMap {
jsonReports := make([]jsonMap, len(reports))
for i, report := range reports {
jsonReports[i] = jsonMap{
`id`: report.ID().ShortID(),
`name`: report.Name(),
`currentImageId`: report.CurrentImageID().ShortID(),
`latestImageId`: report.LatestImageID().ShortID(),
`imageName`: report.ImageName(),
`state`: report.State(),
}
if errorMessage := report.Error(); errorMessage != "" {
jsonReports[i][`error`] = errorMessage
}
}
return jsonReports
}
var _ json.Marshaler = &Data{}
func toJSON(v interface{}) string {
var bytes []byte
var err error
if bytes, err = json.MarshalIndent(v, "", " "); err != nil {
LocalLog.Errorf("failed to marshal JSON in notification template: %v", err)
return ""
}
return string(bytes)
}

View file

@ -0,0 +1,118 @@
package notifications
import (
s "github.com/containrrr/watchtower/pkg/session"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("JSON template", func() {
When("using report templates", func() {
When("JSON template is used", func() {
It("should format the messages to the expected format", func() {
expected := `{
"entries": [
{
"data": null,
"level": "info",
"message": "foo Bar",
"time": "0001-01-01T00:00:00Z"
}
],
"host": "Mock",
"report": {
"failed": [
{
"currentImageId": "01d210000000",
"error": "accidentally the whole container",
"id": "c79210000000",
"imageName": "mock/fail1:latest",
"latestImageId": "d0a210000000",
"name": "fail1",
"state": "Failed"
}
],
"fresh": [
{
"currentImageId": "01d310000000",
"id": "c79310000000",
"imageName": "mock/frsh1:latest",
"latestImageId": "01d310000000",
"name": "frsh1",
"state": "Fresh"
}
],
"scanned": [
{
"currentImageId": "01d110000000",
"id": "c79110000000",
"imageName": "mock/updt1:latest",
"latestImageId": "d0a110000000",
"name": "updt1",
"state": "Updated"
},
{
"currentImageId": "01d120000000",
"id": "c79120000000",
"imageName": "mock/updt2:latest",
"latestImageId": "d0a120000000",
"name": "updt2",
"state": "Updated"
},
{
"currentImageId": "01d210000000",
"error": "accidentally the whole container",
"id": "c79210000000",
"imageName": "mock/fail1:latest",
"latestImageId": "d0a210000000",
"name": "fail1",
"state": "Failed"
},
{
"currentImageId": "01d310000000",
"id": "c79310000000",
"imageName": "mock/frsh1:latest",
"latestImageId": "01d310000000",
"name": "frsh1",
"state": "Fresh"
}
],
"skipped": [
{
"currentImageId": "01d410000000",
"error": "unpossible",
"id": "c79410000000",
"imageName": "mock/skip1:latest",
"latestImageId": "01d410000000",
"name": "skip1",
"state": "Skipped"
}
],
"stale": [],
"updated": [
{
"currentImageId": "01d110000000",
"id": "c79110000000",
"imageName": "mock/updt1:latest",
"latestImageId": "d0a110000000",
"name": "updt1",
"state": "Updated"
},
{
"currentImageId": "01d120000000",
"id": "c79120000000",
"imageName": "mock/updt2:latest",
"latestImageId": "d0a120000000",
"name": "updt2",
"state": "Updated"
}
]
},
"title": "Watchtower updates on Mock"
}`
data := mockDataFromStates(s.UpdatedState, s.FreshState, s.FailedState, s.SkippedState, s.UpdatedState)
Expect(getTemplatedResult(`json.v1`, false, data)).To(MatchJSON(expected))
})
})
})
})

View file

@ -0,0 +1,19 @@
package notifications
import (
t "github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
)
// StaticData is the part of the notification template data model set upon initialization
type StaticData struct {
Title string
Host string
}
// Data is the notification template data model
type Data struct {
StaticData
Entries []*log.Entry
Report t.Report
}

View file

@ -15,13 +15,12 @@ const (
type msTeamsTypeNotifier struct {
webHookURL string
levels []log.Level
data bool
}
func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
func newMsTeamsNotifier(cmd *cobra.Command) t.ConvertibleNotifier {
flags := cmd.PersistentFlags()
flags := cmd.Flags()
webHookURL, _ := flags.GetString("notification-msteams-hook")
if len(webHookURL) <= 0 {
@ -30,7 +29,6 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Con
withData, _ := flags.GetBool("notification-msteams-data")
n := &msTeamsTypeNotifier{
levels: acceptedLogLevels,
webHookURL: webHookURL,
data: withData,
}
@ -38,7 +36,7 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Con
return n
}
func (n *msTeamsTypeNotifier) GetURL(c *cobra.Command, title string) (string, error) {
func (n *msTeamsTypeNotifier) GetURL(c *cobra.Command) (string, error) {
webhookURL, err := url.Parse(n.webHookURL)
if err != nil {
return "", err
@ -50,7 +48,6 @@ func (n *msTeamsTypeNotifier) GetURL(c *cobra.Command, title string) (string, er
}
config.Color = ColorHex
config.Title = title
return config.GetURL().String(), nil
}

View file

@ -6,14 +6,13 @@ import (
"time"
ty "github.com/containrrr/watchtower/pkg/types"
"github.com/johntdyer/slackrus"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
// NewNotifier creates and returns a new Notifier, using global configuration.
func NewNotifier(c *cobra.Command) ty.Notifier {
f := c.PersistentFlags()
f := c.Flags()
level, _ := f.GetString("notifications-level")
logLevel, err := log.ParseLevel(level)
@ -21,25 +20,19 @@ func NewNotifier(c *cobra.Command) ty.Notifier {
log.Fatalf("Notifications invalid log level: %s", err.Error())
}
levels := slackrus.LevelThreshold(logLevel)
// slackrus does not allow log level TRACE, even though it's an accepted log level for logrus
if len(levels) == 0 {
log.Fatalf("Unsupported notification log level provided: %s", level)
}
reportTemplate, _ := f.GetBool("notification-report")
stdout, _ := f.GetBool("notification-log-stdout")
tplString, _ := f.GetString("notification-template")
urls, _ := f.GetStringArray("notification-url")
data := GetTemplateData(c)
urls, delay := AppendLegacyUrls(urls, c, data.Title)
urls, delay := AppendLegacyUrls(urls, c)
return newShoutrrrNotifier(tplString, levels, !reportTemplate, data, delay, stdout, urls...)
return createNotifier(urls, logLevel, tplString, !reportTemplate, data, stdout, delay)
}
// AppendLegacyUrls creates shoutrrr equivalent URLs from legacy notification flags
func AppendLegacyUrls(urls []string, cmd *cobra.Command, title string) ([]string, time.Duration) {
func AppendLegacyUrls(urls []string, cmd *cobra.Command) ([]string, time.Duration) {
// Parse types and create notifiers.
types, err := cmd.Flags().GetStringSlice("notifications")
@ -56,13 +49,13 @@ func AppendLegacyUrls(urls []string, cmd *cobra.Command, title string) ([]string
switch t {
case emailType:
legacyNotifier = newEmailNotifier(cmd, []log.Level{})
legacyNotifier = newEmailNotifier(cmd)
case slackType:
legacyNotifier = newSlackNotifier(cmd, []log.Level{})
legacyNotifier = newSlackNotifier(cmd)
case msTeamsType:
legacyNotifier = newMsTeamsNotifier(cmd, []log.Level{})
legacyNotifier = newMsTeamsNotifier(cmd)
case gotifyType:
legacyNotifier = newGotifyNotifier(cmd, []log.Level{})
legacyNotifier = newGotifyNotifier(cmd)
case shoutrrrType:
continue
default:
@ -71,7 +64,7 @@ func AppendLegacyUrls(urls []string, cmd *cobra.Command, title string) ([]string
continue
}
shoutrrrURL, err := legacyNotifier.GetURL(cmd, title)
shoutrrrURL, err := legacyNotifier.GetURL(cmd)
if err != nil {
log.Fatal("failed to create notification config: ", err)
}

View file

@ -3,7 +3,6 @@ package notifications_test
import (
"fmt"
"net/url"
"os"
"time"
"github.com/containrrr/watchtower/cmd"
@ -147,11 +146,9 @@ var _ = Describe("notifications", func() {
channel := "123456789"
token := "abvsihdbau"
color := notifications.ColorInt
data := notifications.GetTemplateData(command)
title := url.QueryEscape(data.Title)
username := "containrrrbot"
iconURL := "https://containrrr.dev/watchtower-sq180.png"
expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&title=%s&username=watchtower", token, channel, color, title)
expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&username=watchtower", token, channel, color)
buildArgs := func(url string) []string {
return []string{
"--notifications",
@ -172,7 +169,7 @@ var _ = Describe("notifications", func() {
When("icon URL and username are specified", func() {
It("should return the expected URL", func() {
hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discord.com", channel, token)
expectedOutput := fmt.Sprintf("discord://%s@%s?avatar=%s&color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&title=%s&username=%s", token, channel, url.QueryEscape(iconURL), color, title, username)
expectedOutput := fmt.Sprintf("discord://%s@%s?avatar=%s&color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&username=%s", token, channel, url.QueryEscape(iconURL), color, username)
expectedDelay := time.Duration(7) * time.Second
args := []string{
"--notifications",
@ -199,8 +196,6 @@ var _ = Describe("notifications", func() {
tokenB := "BBBBBBBBB"
tokenC := "123456789123456789123456"
color := url.QueryEscape(notifications.ColorHex)
data := notifications.GetTemplateData(command)
title := url.QueryEscape(data.Title)
iconURL := "https://containrrr.dev/watchtower-sq180.png"
iconEmoji := "whale"
@ -208,7 +203,7 @@ var _ = Describe("notifications", func() {
It("should return the expected URL", func() {
hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s&title=%s", tokenA, tokenB, tokenC, username, color, url.QueryEscape(iconURL), title)
expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s", tokenA, tokenB, tokenC, username, color, url.QueryEscape(iconURL))
expectedDelay := time.Duration(7) * time.Second
args := []string{
@ -231,7 +226,7 @@ var _ = Describe("notifications", func() {
When("icon emoji is specified", func() {
It("should return the expected URL", func() {
hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s&title=%s", tokenA, tokenB, tokenC, username, color, iconEmoji, title)
expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s", tokenA, tokenB, tokenC, username, color, iconEmoji)
args := []string{
"--notifications",
@ -258,10 +253,8 @@ var _ = Describe("notifications", func() {
token := "aaa"
host := "shoutrrr.local"
data := notifications.GetTemplateData(command)
title := url.QueryEscape(data.Title)
expectedOutput := fmt.Sprintf("gotify://%s/%s?title=%s", host, token, title)
expectedOutput := fmt.Sprintf("gotify://%s/%s?title=", host, token)
args := []string{
"--notifications",
@ -287,11 +280,9 @@ var _ = Describe("notifications", func() {
tokenB := "33333333012222222222333333333344"
tokenC := "44444444-4444-4444-8444-cccccccccccc"
color := url.QueryEscape(notifications.ColorHex)
data := notifications.GetTemplateData(command)
title := url.QueryEscape(data.Title)
hookURL := fmt.Sprintf("https://outlook.office.com/webhook/%s/IncomingWebhook/%s/%s", tokenA, tokenB, tokenC)
expectedOutput := fmt.Sprintf("teams://%s/%s/%s?color=%s&title=%s", tokenA, tokenB, tokenC, color, title)
expectedOutput := fmt.Sprintf("teams://%s/%s/%s?color=%s", tokenA, tokenB, tokenC, color)
args := []string{
"--notifications",
@ -362,18 +353,12 @@ var _ = Describe("notifications", func() {
})
func buildExpectedURL(username string, password string, host string, port int, from string, to string, auth string) string {
hostname, err := os.Hostname()
Expect(err).NotTo(HaveOccurred())
subject := fmt.Sprintf("Watchtower updates on %s", hostname)
var template = "smtp://%s:%s@%s:%d/?auth=%s&fromaddress=%s&fromname=Watchtower&subject=%s&toaddresses=%s"
var template = "smtp://%s:%s@%s:%d/?auth=%s&fromaddress=%s&fromname=Watchtower&subject=&toaddresses=%s"
return fmt.Sprintf(template,
url.QueryEscape(username),
url.QueryEscape(password),
host, port, auth,
url.QueryEscape(from),
url.QueryEscape(subject),
url.QueryEscape(to))
}
@ -385,8 +370,7 @@ func testURL(args []string, expectedURL string, expectedDelay time.Duration) {
Expect(command.ParseFlags(args)).To(Succeed())
data := notifications.GetTemplateData(command)
urls, delay := notifications.AppendLegacyUrls([]string{}, command, data.Title)
urls, delay := notifications.AppendLegacyUrls([]string{}, command)
Expect(urls).To(ContainElement(expectedURL))
Expect(delay).To(Equal(expectedDelay))

View file

@ -32,13 +32,15 @@ type shoutrrrTypeNotifier struct {
Urls []string
Router router
entries []*log.Entry
logLevels []log.Level
logLevel log.Level
template *template.Template
messages chan string
done chan bool
legacyTemplate bool
params *types.Params
data StaticData
receiving bool
delay time.Duration
}
// GetScheme returns the scheme part of a Shoutrrr URL
@ -59,18 +61,24 @@ func (n *shoutrrrTypeNotifier) GetNames() []string {
return names
}
func newShoutrrrNotifier(tplString string, levels []log.Level, legacy bool, data StaticData, delay time.Duration, stdout bool, urls ...string) t.Notifier {
notifier := createNotifier(urls, levels, tplString, legacy, data, stdout)
log.AddHook(notifier)
// Do the sending in a separate goroutine so we don't block the main process.
go sendNotifications(notifier, delay)
return notifier
// GetNames returns a list of URLs for notification services that has been added
func (n *shoutrrrTypeNotifier) GetURLs() []string {
return n.Urls
}
func createNotifier(urls []string, levels []log.Level, tplString string, legacy bool, data StaticData, stdout bool) *shoutrrrTypeNotifier {
// AddLogHook adds the notifier as a receiver of log messages and starts a go func for processing them
func (n *shoutrrrTypeNotifier) AddLogHook() {
if n.receiving {
return
}
n.receiving = true
log.AddHook(n)
// Do the sending in a separate goroutine so we don't block the main process.
go sendNotifications(n)
}
func createNotifier(urls []string, level log.Level, tplString string, legacy bool, data StaticData, stdout bool, delay time.Duration) *shoutrrrTypeNotifier {
tpl, err := getShoutrrrTemplate(tplString, legacy)
if err != nil {
log.Errorf("Could not use configured notification template: %s. Using default template", err)
@ -97,7 +105,7 @@ func createNotifier(urls []string, levels []log.Level, tplString string, legacy
Router: r,
messages: make(chan string, 1),
done: make(chan bool),
logLevels: levels,
logLevel: level,
template: tpl,
legacyTemplate: legacy,
data: data,
@ -105,9 +113,9 @@ func createNotifier(urls []string, levels []log.Level, tplString string, legacy
}
}
func sendNotifications(n *shoutrrrTypeNotifier, delay time.Duration) {
func sendNotifications(n *shoutrrrTypeNotifier) {
for msg := range n.messages {
time.Sleep(delay)
time.Sleep(n.delay)
errs := n.Router.Send(msg, n.params)
for i, err := range errs {
@ -180,7 +188,7 @@ func (n *shoutrrrTypeNotifier) Close() {
// Levels return what log levels trigger notifications
func (n *shoutrrrTypeNotifier) Levels() []log.Level {
return n.logLevels
return log.AllLevels[:n.logLevel+1]
}
// Fire is the hook that logrus calls on a new log message
@ -202,6 +210,7 @@ func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template,
funcs := template.FuncMap{
"ToUpper": strings.ToUpper,
"ToLower": strings.ToLower,
"ToJSON": toJSON,
"Title": cases.Title(language.AmericanEnglish).String,
}
tplBase := template.New("").Funcs(funcs)
@ -232,16 +241,3 @@ func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template,
return
}
// StaticData is the part of the notification template data model set upon initialization
type StaticData struct {
Title string
Host string
}
// Data is the notification template data model
type Data struct {
StaticData
Entries []*log.Entry
Report t.Report
}

View file

@ -14,7 +14,7 @@ import (
"github.com/spf13/cobra"
)
var allButTrace = logrus.AllLevels[0:logrus.TraceLevel]
var allButTrace = logrus.DebugLevel
var legacyMockData = Data{
Entries: []*logrus.Entry{
@ -83,6 +83,30 @@ updt1 (mock/updt1:latest): Updated
})
})
When("adding a log hook", func() {
When("it has not been added before", func() {
It("should be added to the logrus hooks", func() {
level := logrus.TraceLevel
hooksBefore := len(logrus.StandardLogger().Hooks[level])
shoutrrr := createNotifier([]string{}, level, "", true, StaticData{}, false, time.Second)
shoutrrr.AddLogHook()
hooksAfter := len(logrus.StandardLogger().Hooks[level])
Expect(hooksAfter).To(BeNumerically(">", hooksBefore))
})
})
When("it is being added a second time", func() {
It("should not be added to the logrus hooks", func() {
level := logrus.TraceLevel
shoutrrr := createNotifier([]string{}, level, "", true, StaticData{}, false, time.Second)
shoutrrr.AddLogHook()
hooksBefore := len(logrus.StandardLogger().Hooks[level])
shoutrrr.AddLogHook()
hooksAfter := len(logrus.StandardLogger().Hooks[level])
Expect(hooksAfter).To(Equal(hooksBefore))
})
})
})
When("using legacy templates", func() {
When("no custom template is provided", func() {
@ -90,7 +114,7 @@ updt1 (mock/updt1:latest): Updated
cmd := new(cobra.Command)
flags.RegisterNotificationFlags(cmd)
shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true, StaticData{}, false)
shoutrrr := createNotifier([]string{}, logrus.TraceLevel, "", true, StaticData{}, false, time.Second)
entries := []*logrus.Entry{
{
@ -245,7 +269,7 @@ Turns out everything is on fire
When("batching notifications", func() {
When("no messages are queued", func() {
It("should not send any notification", func() {
shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), false, "logger://")
shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{}, false, time.Duration(0))
shoutrrr.StartNotification()
shoutrrr.SendNotification(nil)
Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`))
@ -253,7 +277,8 @@ Turns out everything is on fire
})
When("at least one message is queued", func() {
It("should send a notification", func() {
shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), false, "logger://")
shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{}, false, time.Duration(0))
shoutrrr.AddLogHook()
shoutrrr.StartNotification()
logrus.Info("This log message is sponsored by ContainrrrVPN")
shoutrrr.SendNotification(nil)
@ -267,7 +292,7 @@ Turns out everything is on fire
shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{
Host: "test.host",
Title: "",
}, false)
}, false, time.Second)
_, found := shoutrrr.params.Title()
Expect(found).ToNot(BeTrue())
})
@ -321,13 +346,14 @@ func sendNotificationsWithBlockingRouter(legacy bool) (*shoutrrrTypeNotifier, *b
Router: router,
legacyTemplate: legacy,
params: &types.Params{},
delay: time.Duration(0),
}
entry := &logrus.Entry{
Message: "foo bar",
}
go sendNotifications(shoutrrr, time.Duration(0))
go sendNotifications(shoutrrr)
shoutrrr.StartNotification()
_ = shoutrrr.Fire(entry)

View file

@ -6,7 +6,6 @@ import (
shoutrrrDisco "github.com/containrrr/shoutrrr/pkg/services/discord"
shoutrrrSlack "github.com/containrrr/shoutrrr/pkg/services/slack"
t "github.com/containrrr/watchtower/pkg/types"
"github.com/johntdyer/slackrus"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
@ -16,11 +15,15 @@ const (
)
type slackTypeNotifier struct {
slackrus.SlackrusHook
HookURL string
Username string
Channel string
IconEmoji string
IconURL string
}
func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
flags := c.PersistentFlags()
func newSlackNotifier(c *cobra.Command) t.ConvertibleNotifier {
flags := c.Flags()
hookURL, _ := flags.GetString("notification-slack-hook-url")
userName, _ := flags.GetString("notification-slack-identifier")
@ -29,19 +32,16 @@ func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert
iconURL, _ := flags.GetString("notification-slack-icon-url")
n := &slackTypeNotifier{
SlackrusHook: slackrus.SlackrusHook{
HookURL: hookURL,
Username: userName,
Channel: channel,
IconEmoji: emoji,
IconURL: iconURL,
AcceptedLevels: acceptedLogLevels,
},
HookURL: hookURL,
Username: userName,
Channel: channel,
IconEmoji: emoji,
IconURL: iconURL,
}
return n
}
func (s *slackTypeNotifier) GetURL(c *cobra.Command, title string) (string, error) {
func (s *slackTypeNotifier) GetURL(c *cobra.Command) (string, error) {
trimmedURL := strings.TrimRight(s.HookURL, "/")
trimmedURL = strings.TrimPrefix(trimmedURL, "https://")
parts := strings.Split(trimmedURL, "/")
@ -52,7 +52,6 @@ func (s *slackTypeNotifier) GetURL(c *cobra.Command, title string) (string, erro
WebhookID: parts[len(parts)-3],
Token: parts[len(parts)-2],
Color: ColorInt,
Title: title,
SplitLines: true,
Username: s.Username,
}
@ -70,7 +69,6 @@ func (s *slackTypeNotifier) GetURL(c *cobra.Command, title string) (string, erro
BotName: s.Username,
Color: ColorHex,
Channel: "webhook",
Title: title,
}
if s.IconURL != "" {

View file

@ -103,6 +103,7 @@ func GetDigest(url string, token string) (string, error) {
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json")
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.list.v2+json")
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v1+json")
req.Header.Add("Accept", "application/vnd.oci.image.index.v1+json")
logrus.WithField("url", url).Debug("Doing a HEAD request to fetch a digest")

View file

@ -1,16 +1,17 @@
package types
import (
"github.com/spf13/cobra"
"time"
"github.com/spf13/cobra"
)
// ConvertibleNotifier is a notifier capable of creating a shoutrrr URL
type ConvertibleNotifier interface {
GetURL(c *cobra.Command, title string) (string, error)
GetURL(c *cobra.Command) (string, error)
}
// DelayNotifier is a notifier that might need to be delayed before sending notifications
type DelayNotifier interface {
GetDelay() time.Duration
}
}

View file

@ -4,6 +4,8 @@ package types
type Notifier interface {
StartNotification()
SendNotification(Report)
AddLogHook()
GetNames() []string
GetURLs() []string
Close()
}