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", "name": "Andrii Bratanin",
"avatar_url": "https://avatars.githubusercontent.com/u/20169213?v=4", "avatar_url": "https://avatars.githubusercontent.com/u/20169213?v=4",
"profile": "https://github.com/andriibratanin", "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": [ "contributions": [
"doc" "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 name: Greetings
on: 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: issues:
types: types: [opened]
- opened
jobs: jobs:
greeting: greeting:

View file

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

View file

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

View file

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

View file

@ -22,12 +22,10 @@ jobs:
uses: actions/setup-go@v3 uses: actions/setup-go@v3
with: with:
go-version: 1.18.x go-version: 1.18.x
- name: Install linter - uses: dominikh/staticcheck-action@ba605356b4b29a60e87ab9404b712f3461e566dc #v1.3.0
run: | with:
go get -u golang.org/x/lint/golint version: "2022.1.1"
- name: Lint files install-go: "false" # StaticCheck uses go v1.17 which does not support `any`
run: |
golint -set_exit_status ./...
test: test:
name: Test name: Test
@ -72,18 +70,18 @@ jobs:
with: with:
go-version: 1.18.x go-version: 1.18.x
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a #v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@v2 uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a #v2
with: with:
username: ${{ secrets.BOT_USERNAME }} username: ${{ secrets.BOT_USERNAME }}
password: ${{ secrets.BOT_GHCR_PAT }} password: ${{ secrets.BOT_GHCR_PAT }}
registry: ghcr.io registry: ghcr.io
- name: Build - name: Build
uses: goreleaser/goreleaser-action@v3 uses: goreleaser/goreleaser-action@f82d6c1c344bcacabba2c841718984797f664a6b #v3
with: with:
version: v0.155.0 version: v0.155.0
args: --debug args: --debug
@ -193,7 +191,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Pull new module version - 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> <table>
<tbody> <tbody>
<tr> <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="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://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://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/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/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/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/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/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://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://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="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>
<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/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=""/><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://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=""/><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://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=""/><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="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=""/><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://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=""/><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://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=""/><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="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>
<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/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=""/><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://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=""/><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="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=""/><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://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=""/><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="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=""/><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://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=""/><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://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>
<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://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=""/><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://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=""/><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/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=""/><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://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=""/><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://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=""/><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://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=""/><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://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>
<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="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=""/><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="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=""/><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/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=""/><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/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=""/><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://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=""/><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://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=""/><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://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>
<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://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=""/><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="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=""/><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="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=""/><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://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=""/><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://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=""/><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://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=""/><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://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>
<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://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=""/><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://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=""/><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://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=""/><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="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=""/><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="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=""/><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/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=""/><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://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>
<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/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=""/><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/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=""/><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/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=""/><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/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=""/><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://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=""/><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://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=""/><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://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>
<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://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=""/><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://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=""/><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="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=""/><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="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=""/><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/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=""/><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://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=""/><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://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>
<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/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=""/><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/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=""/><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/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=""/><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/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=""/><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/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=""/><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://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=""/><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://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>
<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="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=""/><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="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=""/><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/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=""/><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/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=""/><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/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=""/><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/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=""/><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/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>
<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://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=""/><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://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=""/><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://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=""/><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/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=""/><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://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=""/><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://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=""/><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://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>
<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> <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> </tr>
</tbody> </tbody>
<tfoot>
</tfoot>
</table> </table>
<!-- markdownlint-restore --> <!-- 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, Run: Run,
PreRun: PreRun, PreRun: PreRun,
Args: cobra.ArbitraryArgs,
} }
} }
@ -66,6 +67,7 @@ func init() {
// Execute the root func and exit in case of errors // Execute the root func and exit in case of errors
func Execute() { func Execute() {
rootCmd.AddCommand(notifyUpgradeCommand)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -87,11 +89,11 @@ func PreRun(cmd *cobra.Command, _ []string) {
}) })
} }
if enabled, _ := f.GetBool("debug"); enabled { rawLogLevel, _ := f.GetString(`log-level`)
log.SetLevel(log.DebugLevel) if logLevel, err := log.ParseLevel(rawLogLevel); err != nil {
} log.Fatalf("Invalid log level: %s", err.Error())
if enabled, _ := f.GetBool("trace"); enabled { } else {
log.SetLevel(log.TraceLevel) log.SetLevel(logLevel)
} }
scheduleSpec, _ = f.GetString("schedule") scheduleSpec, _ = f.GetString("schedule")
@ -139,6 +141,7 @@ func PreRun(cmd *cobra.Command, _ []string) {
}) })
notifier = notifications.NewNotifier(cmd) notifier = notifications.NewNotifier(cmd)
notifier.AddLogHook()
} }
// Run is the main execution flow of the command // Run is the main execution flow of the command
@ -179,7 +182,10 @@ func Run(c *cobra.Command, names []string) {
httpAPI := api.New(apiToken) httpAPI := api.New(apiToken)
if enableUpdateAPI { 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) httpAPI.RegisterFunc(updateHandler.Path, updateHandler.Handle)
// If polling isn't enabled the scheduler is never started and // If polling isn't enabled the scheduler is never started and
// we need to trigger the startup messages manually. // 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 \ RUN apk add --no-cache \
ca-certificates \ ca-certificates \

View file

@ -71,6 +71,10 @@ Environment Variable: WATCHTOWER_REMOVE_VOLUMES
## Debug ## Debug
Enable debug mode with verbose logging. 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 ```text
Argument: --debug, -d Argument: --debug, -d
Environment Variable: WATCHTOWER_DEBUG Environment Variable: WATCHTOWER_DEBUG
@ -81,6 +85,10 @@ Environment Variable: WATCHTOWER_DEBUG
## Trace ## Trace
Enable trace mode with very verbose logging. Caution: exposes credentials! 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 ```text
Argument: --trace Argument: --trace
Environment Variable: WATCHTOWER_TRACE Environment Variable: WATCHTOWER_TRACE
@ -88,6 +96,17 @@ Environment Variable: WATCHTOWER_TRACE
Default: false 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 ## ANSI colors
Disable ANSI color escape codes in log output. 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 Environment Variable: WATCHTOWER_WARN_ON_HEAD_FAILURE
Possible values: always, auto, never Possible values: always, auto, never
Default: auto Default: auto
``` ```

View file

@ -1,15 +1,7 @@
# Notifications # Notifications
Watchtower can send notifications when containers are updated. Notifications are sent via hooks in the logging 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 system, [logrus](http://github.com/sirupsen/logrus).
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)
!!! note "Using multiple notifications with environment variables" !!! 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 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-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. - `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 ### 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`. 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"] { [data-md-color-scheme="containrrr"] {
--md-primary-fg-color: #406170; /* Primary and accent */
--md-primary-fg-color--light:#acbfc7; --md-primary-fg-color: #406170;
--md-primary-fg-color--dark: #003343; --md-primary-fg-color--light:#acbfc7;
--md-accent-fg-color: #003343; --md-primary-fg-color--dark: #003343;
--md-accent-fg-color--transparent: #00334310; --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 { .md-header-nav__button.md-logo {
padding: 0; padding: 0;
} }
.md-header-nav__button.md-logo img { .md-header-nav__button.md-logo img {
width: 1.6rem; width: 1.6rem;
height: 1.6rem; height: 1.6rem;
} }

52
go.mod
View file

@ -3,22 +3,21 @@ module github.com/containrrr/watchtower
go 1.18 go 1.18
require ( require (
github.com/containrrr/shoutrrr v0.6.1 github.com/containrrr/shoutrrr v0.7.1
github.com/docker/cli v20.10.17+incompatible github.com/docker/cli v20.10.23+incompatible
github.com/docker/distribution v2.8.1+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/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/ginkgo v1.16.5
github.com/onsi/gomega v1.20.2 github.com/onsi/gomega v1.26.0
github.com/prometheus/client_golang v1.13.0 github.com/prometheus/client_golang v1.14.0
github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967
github.com/sirupsen/logrus v1.9.0 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/pflag v1.0.5
github.com/spf13/viper v1.12.0 github.com/spf13/viper v1.15.0
github.com/stretchr/testify v1.8.0 github.com/stretchr/testify v1.8.1
golang.org/x/net v0.0.0-20220722155237-a158d28d115b golang.org/x/net v0.5.0
) )
require ( require (
@ -30,16 +29,15 @@ require (
github.com/docker/docker-credential-helpers v0.6.1 // indirect github.com/docker/docker-credential-helpers v0.6.1 // indirect
github.com/docker/go-units v0.4.0 // indirect github.com/docker/go-units v0.4.0 // indirect
github.com/fatih/color v1.13.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/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.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/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/johntdyer/slack-go v0.0.0-20180213144715-95fac1160b22 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/magiconair/properties v1.8.6 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // 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/nxadm/tail v1.4.8 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // 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.6 // indirect
github.com/pelletier/go-toml/v2 v2.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // 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/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.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/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/stretchr/objx v0.4.0 // indirect github.com/stretchr/objx v0.5.0 // indirect
github.com/subosito/gotenv v1.3.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect golang.org/x/sys v0.4.0 // indirect
golang.org/x/text v0.3.7 golang.org/x/text v0.6.0
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect golang.org/x/time v0.1.0 // indirect
google.golang.org/protobuf v1.28.1 // 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/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.0.3 // 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"), viper.GetString("WATCHTOWER_PORCELAIN"),
`Write session results to stdout using a stable versioned format. Supported values: "v1"`) `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 // 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_SERVER_PORT", 25)
viper.SetDefault("WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG", "") viper.SetDefault("WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG", "")
viper.SetDefault("WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER", "watchtower") viper.SetDefault("WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER", "watchtower")
viper.SetDefault("WATCHTOWER_LOG_LEVEL", "info")
} }
// EnvConfig translates the command-line options into environment variables // EnvConfig translates the command-line options into environment variables
@ -561,6 +566,23 @@ func ProcessFlagAliases(flags *pflag.FlagSet) {
interval, _ := flags.GetInt(`interval`) interval, _ := flags.GetInt(`interval`)
flags.Set(`schedule`, fmt.Sprintf(`@every %ds`, 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 { 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") 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) { func TestProcessFlagAliases(t *testing.T) {
logrus.StandardLogger().ExitFunc = func(_ int) { t.FailNow() } logrus.StandardLogger().ExitFunc = func(_ int) { t.FailNow() }
cmd := new(cobra.Command) cmd := new(cobra.Command)
@ -145,6 +140,7 @@ func TestProcessFlagAliases(t *testing.T) {
require.NoError(t, cmd.ParseFlags([]string{ require.NoError(t, cmd.ParseFlags([]string{
`--porcelain`, `v1`, `--porcelain`, `v1`,
`--interval`, `10`, `--interval`, `10`,
`--trace`,
})) }))
flags := cmd.Flags() flags := cmd.Flags()
ProcessFlagAliases(flags) ProcessFlagAliases(flags)
@ -163,6 +159,28 @@ func TestProcessFlagAliases(t *testing.T) {
sched, _ := flags.GetString(`schedule`) sched, _ := flags.GetString(`schedule`)
assert.Equal(t, `@every 10s`, sched) 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) { func TestProcessFlagAliasesSchedAndInterval(t *testing.T) {

View file

@ -5,7 +5,16 @@ edit_uri: edit/main/docs/
theme: theme:
name: 'material' name: 'material'
palette: 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 logo: images/logo-450px.png
favicon: images/favicon.ico favicon: images/favicon.ico
extra_css: extra_css:
@ -24,7 +33,7 @@ markdown_extensions:
repo: watchtower repo: watchtower
- pymdownx.saneheaders - pymdownx.saneheaders
- pymdownx.tabbed: - pymdownx.tabbed:
alternate_style: true alternate_style: true
nav: nav:
- 'Home': 'index.md' - 'Home': 'index.md'
- 'Introduction': 'introduction.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) log.Debugf("Removing container %s", shortID)
if err := client.api.ContainerRemove(bg, idStr, types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.RemoveVolumes}); err != nil { 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 return err
} }
} }

View file

@ -1,6 +1,8 @@
package container package container
import ( import (
"time"
"github.com/containrrr/watchtower/pkg/container/mocks" "github.com/containrrr/watchtower/pkg/container/mocks"
"github.com/containrrr/watchtower/pkg/filters" "github.com/containrrr/watchtower/pkg/filters"
t "github.com/containrrr/watchtower/pkg/types" t "github.com/containrrr/watchtower/pkg/types"
@ -33,8 +35,8 @@ var _ = Describe("the client", func() {
mockServer.Close() mockServer.Close()
}) })
Describe("WarnOnHeadPullFailed", func() { Describe("WarnOnHeadPullFailed", func() {
containerUnknown := *mockContainerWithImageName("unknown.repo/prefix/imagename:latest") containerUnknown := *MockContainer(WithImageName("unknown.repo/prefix/imagename:latest"))
containerKnown := *mockContainerWithImageName("docker.io/prefix/imagename:latest") containerKnown := *MockContainer(WithImageName("docker.io/prefix/imagename:latest"))
When(`warn on head failure is set to "always"`, func() { When(`warn on head failure is set to "always"`, func() {
c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnAlways}} c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnAlways}}
@ -64,11 +66,43 @@ var _ = Describe("the client", func() {
When("the image consist of a pinned hash", func() { When("the image consist of a pinned hash", func() {
It("should gracefully fail with a useful message", func() { It("should gracefully fail with a useful message", func() {
c := dockerClient{} c := dockerClient{}
pinnedContainer := *mockContainerWithImageName("sha256:fa5269854a5e615e51a72b17ad3fd1e01268f278a6684c8ed3c5f0cdce3f230b") pinnedContainer := *MockContainer(WithImageName("sha256:fa5269854a5e615e51a72b17ad3fd1e01268f278a6684c8ed3c5f0cdce3f230b"))
c.PullImage(context.Background(), pinnedContainer) 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("listing containers", func() {
When("no filter is provided", func() { When("no filter is provided", func() {
It("should return all available containers", 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 package container
import ( 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 package container
import ( import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
. "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
@ -12,7 +10,7 @@ var _ = Describe("the container", func() {
Describe("VerifyConfiguration", func() { Describe("VerifyConfiguration", func() {
When("verifying a container with no image info", func() { When("verifying a container with no image info", func() {
It("should return an error", func() { It("should return an error", func() {
c := mockContainerWithPortBindings() c := MockContainer(WithPortBindings())
c.imageInfo = nil c.imageInfo = nil
err := c.VerifyConfiguration() err := c.VerifyConfiguration()
Expect(err).To(Equal(errorNoImageInfo)) Expect(err).To(Equal(errorNoImageInfo))
@ -20,7 +18,7 @@ var _ = Describe("the container", func() {
}) })
When("verifying a container with no container info", func() { When("verifying a container with no container info", func() {
It("should return an error", func() { It("should return an error", func() {
c := mockContainerWithPortBindings() c := MockContainer(WithPortBindings())
c.containerInfo = nil c.containerInfo = nil
err := c.VerifyConfiguration() err := c.VerifyConfiguration()
Expect(err).To(Equal(errorNoContainerInfo)) Expect(err).To(Equal(errorNoContainerInfo))
@ -28,7 +26,7 @@ var _ = Describe("the container", func() {
}) })
When("verifying a container with no config", func() { When("verifying a container with no config", func() {
It("should return an error", func() { It("should return an error", func() {
c := mockContainerWithPortBindings() c := MockContainer(WithPortBindings())
c.containerInfo.Config = nil c.containerInfo.Config = nil
err := c.VerifyConfiguration() err := c.VerifyConfiguration()
Expect(err).To(Equal(errorInvalidConfig)) Expect(err).To(Equal(errorInvalidConfig))
@ -36,7 +34,7 @@ var _ = Describe("the container", func() {
}) })
When("verifying a container with no host config", func() { When("verifying a container with no host config", func() {
It("should return an error", func() { It("should return an error", func() {
c := mockContainerWithPortBindings() c := MockContainer(WithPortBindings())
c.containerInfo.HostConfig = nil c.containerInfo.HostConfig = nil
err := c.VerifyConfiguration() err := c.VerifyConfiguration()
Expect(err).To(Equal(errorInvalidConfig)) Expect(err).To(Equal(errorInvalidConfig))
@ -44,14 +42,14 @@ var _ = Describe("the container", func() {
}) })
When("verifying a container with no port bindings", func() { When("verifying a container with no port bindings", func() {
It("should not return an error", func() { It("should not return an error", func() {
c := mockContainerWithPortBindings() c := MockContainer(WithPortBindings())
err := c.VerifyConfiguration() err := c.VerifyConfiguration()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
}) })
}) })
When("verifying a container with port bindings, but no exposed ports", func() { When("verifying a container with port bindings, but no exposed ports", func() {
It("should make the config compatible with updating", func() { It("should make the config compatible with updating", func() {
c := mockContainerWithPortBindings("80/tcp") c := MockContainer(WithPortBindings("80/tcp"))
c.containerInfo.Config.ExposedPorts = nil c.containerInfo.Config.ExposedPorts = nil
Expect(c.VerifyConfiguration()).To(Succeed()) 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() { When("verifying a container with port bindings and exposed ports is non-nil", func() {
It("should return an error", 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": {}} c.containerInfo.Config.ExposedPorts = map[nat.Port]struct{}{"80/tcp": {}}
err := c.VerifyConfiguration() err := c.VerifyConfiguration()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@ -71,10 +69,10 @@ var _ = Describe("the container", func() {
When("asked for metadata", func() { When("asked for metadata", func() {
var c *Container var c *Container
BeforeEach(func() { BeforeEach(func() {
c = mockContainerWithLabels(map[string]string{ c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.enable": "true", "com.centurylinklabs.watchtower.enable": "true",
"com.centurylinklabs.watchtower": "true", "com.centurylinklabs.watchtower": "true",
}) }))
}) })
It("should return its name on calls to .Name()", func() { It("should return its name on calls to .Name()", func() {
name := c.Name() name := c.Name()
@ -91,36 +89,28 @@ var _ = Describe("the container", func() {
enabled, exists := c.Enabled() enabled, exists := c.Enabled()
Expect(enabled).To(BeTrue()) Expect(enabled).To(BeTrue())
Expect(enabled).NotTo(BeFalse())
Expect(exists).To(BeTrue()) Expect(exists).To(BeTrue())
Expect(exists).NotTo(BeFalse())
}) })
It("should return false, true if present but not true on calls to .Enabled()", func() { 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() enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse()) Expect(enabled).To(BeFalse())
Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeTrue()) Expect(exists).To(BeTrue())
Expect(exists).NotTo(BeFalse())
}) })
It("should return false, false if not present on calls to .Enabled()", func() { 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() enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse()) Expect(enabled).To(BeFalse())
Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeFalse()) Expect(exists).To(BeFalse())
Expect(exists).NotTo(BeTrue())
}) })
It("should return false, false if present but not parsable .Enabled()", func() { 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() enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse()) Expect(enabled).To(BeFalse())
Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeFalse()) Expect(exists).To(BeFalse())
Expect(exists).NotTo(BeTrue())
}) })
When("checking if its a watchtower instance", func() { When("checking if its a watchtower instance", func() {
It("should return true if the label is set to true", 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()) Expect(isWatchtower).To(BeTrue())
}) })
It("should return false if the label is present but set to false", func() { 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() isWatchtower := c.IsWatchtower()
Expect(isWatchtower).To(BeFalse()) Expect(isWatchtower).To(BeFalse())
}) })
It("should return false if the label is not present", func() { 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() isWatchtower := c.IsWatchtower()
Expect(isWatchtower).To(BeFalse()) Expect(isWatchtower).To(BeFalse())
}) })
It("should return false if there are no labels", func() { It("should return false if there are no labels", func() {
c = mockContainerWithLabels(map[string]string{}) c = MockContainer(WithLabels(map[string]string{}))
isWatchtower := c.IsWatchtower() isWatchtower := c.IsWatchtower()
Expect(isWatchtower).To(BeFalse()) Expect(isWatchtower).To(BeFalse())
}) })
}) })
When("fetching the custom stop signal", func() { When("fetching the custom stop signal", func() {
It("should return the signal if its set", 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", "com.centurylinklabs.watchtower.stop-signal": "SIGKILL",
}) }))
stopSignal := c.StopSignal() stopSignal := c.StopSignal()
Expect(stopSignal).To(Equal("SIGKILL")) Expect(stopSignal).To(Equal("SIGKILL"))
}) })
It("should return an empty string if its not set", func() { 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() stopSignal := c.StopSignal()
Expect(stopSignal).To(Equal("")) Expect(stopSignal).To(Equal(""))
}) })
@ -160,22 +150,22 @@ var _ = Describe("the container", func() {
When("fetching the image name", func() { When("fetching the image name", func() {
When("the zodiac label is present", func() { When("the zodiac label is present", func() {
It("should fetch the image name from it", 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", "com.centurylinklabs.zodiac.original-image": "the-original-image",
}) }))
imageName := c.ImageName() imageName := c.ImageName()
Expect(imageName).To(Equal(imageName)) Expect(imageName).To(Equal(imageName))
}) })
}) })
It("should return the image name", func() { It("should return the image name", func() {
name := "image-name:3" name := "image-name:3"
c = mockContainerWithImageName(name) c = MockContainer(WithImageName(name))
imageName := c.ImageName() imageName := c.ImageName()
Expect(imageName).To(Equal(name)) Expect(imageName).To(Equal(name))
}) })
It("should assume latest if no tag is supplied", func() { It("should assume latest if no tag is supplied", func() {
name := "image-name" name := "image-name"
c = mockContainerWithImageName(name) c = MockContainer(WithImageName(name))
imageName := c.ImageName() imageName := c.ImageName()
Expect(imageName).To(Equal(name + ":latest")) Expect(imageName).To(Equal(name + ":latest"))
}) })
@ -184,33 +174,33 @@ var _ = Describe("the container", func() {
When("fetching container links", func() { When("fetching container links", func() {
When("the depends on label is present", func() { When("the depends on label is present", func() {
It("should fetch depending containers from it", 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", "com.centurylinklabs.watchtower.depends-on": "postgres",
}) }))
links := c.Links() links := c.Links()
Expect(links).To(SatisfyAll(ContainElement("postgres"), HaveLen(1))) Expect(links).To(SatisfyAll(ContainElement("postgres"), HaveLen(1)))
}) })
It("should fetch depending containers if there are many", func() { 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", "com.centurylinklabs.watchtower.depends-on": "postgres,redis",
}) }))
links := c.Links() links := c.Links()
Expect(links).To(SatisfyAll(ContainElement("postgres"), ContainElement("redis"), HaveLen(2))) Expect(links).To(SatisfyAll(ContainElement("postgres"), ContainElement("redis"), HaveLen(2)))
}) })
It("should fetch depending containers if label is blank", func() { 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": "", "com.centurylinklabs.watchtower.depends-on": "",
}) }))
links := c.Links() links := c.Links()
Expect(links).To(HaveLen(0)) Expect(links).To(HaveLen(0))
}) })
}) })
When("the depends on label is not present", func() { When("the depends on label is not present", func() {
It("should fetch depending containers from host config links", func() { It("should fetch depending containers from host config links", func() {
c = mockContainerWithLinks([]string{ c = MockContainer(WithLinks([]string{
"redis:test-containrrr", "redis:test-containrrr",
"postgres:test-containrrr", "postgres:test-containrrr",
}) }))
links := c.Links() links := c.Links()
Expect(links).To(SatisfyAll(ContainElement("redis"), ContainElement("postgres"), HaveLen(2))) 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() { When("there is a pre or post update timeout", func() {
It("should return minute values", 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.pre-update-timeout": "3",
"com.centurylinklabs.watchtower.lifecycle.post-update-timeout": "5", "com.centurylinklabs.watchtower.lifecycle.post-update-timeout": "5",
}) }))
preTimeout := c.PreUpdateTimeout() preTimeout := c.PreUpdateTimeout()
Expect(preTimeout).To(Equal(3)) Expect(preTimeout).To(Equal(3))
postTimeout := c.PostUpdateTimeout() 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 ( import (
"encoding/json" "encoding/json"
"fmt" "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" "io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
"path/filepath" "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) { func getMockJSONFile(relPath string) ([]byte, error) {
@ -42,14 +43,14 @@ func respondWithJSONFile(relPath string, statusCode int, optionalHeader ...http.
func GetContainerHandlers(containerFiles ...string) []http.HandlerFunc { func GetContainerHandlers(containerFiles ...string) []http.HandlerFunc {
handlers := make([]http.HandlerFunc, 0, len(containerFiles)*2) handlers := make([]http.HandlerFunc, 0, len(containerFiles)*2)
for _, file := range containerFiles { 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 // Also append the image request since that will be called for every container
if file == "running" { if file == "running" {
// The "running" container is the only one using image02 // The "running" container is the only one using image02
handlers = append(handlers, getImageHandler(1)) handlers = append(handlers, getImageFileHandler(1))
} else { } else {
handlers = append(handlers, getImageHandler(0)) handlers = append(handlers, getImageFileHandler(0))
} }
} }
return handlers return handlers
@ -75,15 +76,36 @@ var imageIds = []string{
"sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd", "sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd",
} }
func getContainerHandler(file string) http.HandlerFunc { func getContainerFileHandler(file string) http.HandlerFunc {
id, ok := containerFileIds[file] id, ok := containerFileIds[file]
failTestUnless(ok) failTestUnless(ok)
return ghttp.CombineHandlers( return getContainerHandler(
ghttp.VerifyRequest("GET", O.HaveSuffix("/containers/%v/json", id)), id,
RespondWithJSONFile(fmt.Sprintf("./mocks/data/container_%v.json", file), http.StatusOK), 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 // ListContainersHandler mocks the GET containers/json endpoint, filtering the returned containers based on statuses
func ListContainersHandler(statuses ...string) http.HandlerFunc { func ListContainersHandler(statuses ...string) http.HandlerFunc {
filterArgs := createFilterArgs(statuses) filterArgs := createFilterArgs(statuses)
@ -116,9 +138,15 @@ func respondWithFilteredContainers(filters filters.Args) http.HandlerFunc {
return ghttp.RespondWithJSONEncoded(http.StatusOK, filteredContainers) return ghttp.RespondWithJSONEncoded(http.StatusOK, filteredContainers)
} }
func getImageHandler(index int) http.HandlerFunc { func getImageHandler(imageId string, responseHandler http.HandlerFunc) http.HandlerFunc {
return ghttp.CombineHandlers( 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), 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) { func failTestUnless(ok bool) {
O.ExpectWithOffset(2, ok).To(O.BeTrue(), "test setup failed") 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 no containers matched filter
{{- end -}} {{- end -}}
{{- end -}}`, {{- end -}}`,
}
`json.v1`: `{{ . | ToJSON }}`,
}

View file

@ -15,17 +15,16 @@ const (
) )
type emailTypeNotifier struct { type emailTypeNotifier struct {
From, To string From, To string
Server, User, Password, SubjectTag string Server, User, Password string
Port int Port int
tlsSkipVerify bool tlsSkipVerify bool
entries []*log.Entry entries []*log.Entry
logLevels []log.Level delay time.Duration
delay time.Duration
} }
func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier { func newEmailNotifier(c *cobra.Command) t.ConvertibleNotifier {
flags := c.PersistentFlags() flags := c.Flags()
from, _ := flags.GetString("notification-email-from") from, _ := flags.GetString("notification-email-from")
to, _ := flags.GetString("notification-email-to") 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") port, _ := flags.GetInt("notification-email-server-port")
tlsSkipVerify, _ := flags.GetBool("notification-email-server-tls-skip-verify") tlsSkipVerify, _ := flags.GetBool("notification-email-server-tls-skip-verify")
delay, _ := flags.GetInt("notification-email-delay") delay, _ := flags.GetInt("notification-email-delay")
subjecttag, _ := flags.GetString("notification-email-subjecttag")
n := &emailTypeNotifier{ n := &emailTypeNotifier{
entries: []*log.Entry{}, entries: []*log.Entry{},
@ -46,28 +44,26 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert
Password: password, Password: password,
Port: port, Port: port,
tlsSkipVerify: tlsSkipVerify, tlsSkipVerify: tlsSkipVerify,
logLevels: acceptedLogLevels,
delay: time.Duration(delay) * time.Second, delay: time.Duration(delay) * time.Second,
SubjectTag: subjecttag,
} }
return n 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{ conf := &shoutrrrSmtp.Config{
FromAddress: e.From, FromAddress: e.From,
FromName: "Watchtower", FromName: "Watchtower",
ToAddresses: []string{e.To}, ToAddresses: []string{e.To},
Port: uint16(e.Port), Port: uint16(e.Port),
Host: e.Server, Host: e.Server,
Subject: e.getSubject(c, title),
Username: e.User, Username: e.User,
Password: e.Password, Password: e.Password,
UseStartTLS: !e.tlsSkipVerify, UseStartTLS: !e.tlsSkipVerify,
UseHTML: false, UseHTML: false,
Encryption: shoutrrrSmtp.EncMethods.Auto, Encryption: shoutrrrSmtp.EncMethods.Auto,
Auth: shoutrrrSmtp.AuthTypes.None, Auth: shoutrrrSmtp.AuthTypes.None,
ClientHost: "localhost",
} }
if len(e.User) > 0 { 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 { func (e *emailTypeNotifier) GetDelay() time.Duration {
return e.delay 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 gotifyURL string
gotifyAppToken string gotifyAppToken string
gotifyInsecureSkipVerify bool gotifyInsecureSkipVerify bool
logLevels []log.Level
} }
func newGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertibleNotifier { func newGotifyNotifier(c *cobra.Command) t.ConvertibleNotifier {
flags := c.PersistentFlags() flags := c.Flags()
apiURL := getGotifyURL(flags) apiURL := getGotifyURL(flags)
token := getGotifyToken(flags) token := getGotifyToken(flags)
@ -34,7 +33,6 @@ func newGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertibleNotifi
gotifyURL: apiURL, gotifyURL: apiURL,
gotifyAppToken: token, gotifyAppToken: token,
gotifyInsecureSkipVerify: skipVerify, gotifyInsecureSkipVerify: skipVerify,
logLevels: levels,
} }
return n return n
@ -62,7 +60,7 @@ func getGotifyURL(flags *pflag.FlagSet) string {
return gotifyURL 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) apiURL, err := url.Parse(n.gotifyURL)
if err != nil { if err != nil {
return "", err return "", err
@ -72,7 +70,6 @@ func (n *gotifyTypeNotifier) GetURL(c *cobra.Command, title string) (string, err
Host: apiURL.Host, Host: apiURL.Host,
Path: apiURL.Path, Path: apiURL.Path,
DisableTLS: apiURL.Scheme == "http", DisableTLS: apiURL.Scheme == "http",
Title: title,
Token: n.gotifyAppToken, 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 { type msTeamsTypeNotifier struct {
webHookURL string webHookURL string
levels []log.Level
data bool 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") webHookURL, _ := flags.GetString("notification-msteams-hook")
if len(webHookURL) <= 0 { if len(webHookURL) <= 0 {
@ -30,7 +29,6 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Con
withData, _ := flags.GetBool("notification-msteams-data") withData, _ := flags.GetBool("notification-msteams-data")
n := &msTeamsTypeNotifier{ n := &msTeamsTypeNotifier{
levels: acceptedLogLevels,
webHookURL: webHookURL, webHookURL: webHookURL,
data: withData, data: withData,
} }
@ -38,7 +36,7 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Con
return n 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) webhookURL, err := url.Parse(n.webHookURL)
if err != nil { if err != nil {
return "", err return "", err
@ -50,7 +48,6 @@ func (n *msTeamsTypeNotifier) GetURL(c *cobra.Command, title string) (string, er
} }
config.Color = ColorHex config.Color = ColorHex
config.Title = title
return config.GetURL().String(), nil return config.GetURL().String(), nil
} }

View file

@ -6,14 +6,13 @@ import (
"time" "time"
ty "github.com/containrrr/watchtower/pkg/types" ty "github.com/containrrr/watchtower/pkg/types"
"github.com/johntdyer/slackrus"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// NewNotifier creates and returns a new Notifier, using global configuration. // NewNotifier creates and returns a new Notifier, using global configuration.
func NewNotifier(c *cobra.Command) ty.Notifier { func NewNotifier(c *cobra.Command) ty.Notifier {
f := c.PersistentFlags() f := c.Flags()
level, _ := f.GetString("notifications-level") level, _ := f.GetString("notifications-level")
logLevel, err := log.ParseLevel(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()) 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") reportTemplate, _ := f.GetBool("notification-report")
stdout, _ := f.GetBool("notification-log-stdout") stdout, _ := f.GetBool("notification-log-stdout")
tplString, _ := f.GetString("notification-template") tplString, _ := f.GetString("notification-template")
urls, _ := f.GetStringArray("notification-url") urls, _ := f.GetStringArray("notification-url")
data := GetTemplateData(c) 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 // 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. // Parse types and create notifiers.
types, err := cmd.Flags().GetStringSlice("notifications") types, err := cmd.Flags().GetStringSlice("notifications")
@ -56,13 +49,13 @@ func AppendLegacyUrls(urls []string, cmd *cobra.Command, title string) ([]string
switch t { switch t {
case emailType: case emailType:
legacyNotifier = newEmailNotifier(cmd, []log.Level{}) legacyNotifier = newEmailNotifier(cmd)
case slackType: case slackType:
legacyNotifier = newSlackNotifier(cmd, []log.Level{}) legacyNotifier = newSlackNotifier(cmd)
case msTeamsType: case msTeamsType:
legacyNotifier = newMsTeamsNotifier(cmd, []log.Level{}) legacyNotifier = newMsTeamsNotifier(cmd)
case gotifyType: case gotifyType:
legacyNotifier = newGotifyNotifier(cmd, []log.Level{}) legacyNotifier = newGotifyNotifier(cmd)
case shoutrrrType: case shoutrrrType:
continue continue
default: default:
@ -71,7 +64,7 @@ func AppendLegacyUrls(urls []string, cmd *cobra.Command, title string) ([]string
continue continue
} }
shoutrrrURL, err := legacyNotifier.GetURL(cmd, title) shoutrrrURL, err := legacyNotifier.GetURL(cmd)
if err != nil { if err != nil {
log.Fatal("failed to create notification config: ", err) log.Fatal("failed to create notification config: ", err)
} }

View file

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

View file

@ -32,13 +32,15 @@ type shoutrrrTypeNotifier struct {
Urls []string Urls []string
Router router Router router
entries []*log.Entry entries []*log.Entry
logLevels []log.Level logLevel log.Level
template *template.Template template *template.Template
messages chan string messages chan string
done chan bool done chan bool
legacyTemplate bool legacyTemplate bool
params *types.Params params *types.Params
data StaticData data StaticData
receiving bool
delay time.Duration
} }
// GetScheme returns the scheme part of a Shoutrrr URL // GetScheme returns the scheme part of a Shoutrrr URL
@ -59,18 +61,24 @@ func (n *shoutrrrTypeNotifier) GetNames() []string {
return names return names
} }
func newShoutrrrNotifier(tplString string, levels []log.Level, legacy bool, data StaticData, delay time.Duration, stdout bool, urls ...string) t.Notifier { // GetNames returns a list of URLs for notification services that has been added
func (n *shoutrrrTypeNotifier) GetURLs() []string {
notifier := createNotifier(urls, levels, tplString, legacy, data, stdout) return n.Urls
log.AddHook(notifier)
// Do the sending in a separate goroutine so we don't block the main process.
go sendNotifications(notifier, delay)
return notifier
} }
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) tpl, err := getShoutrrrTemplate(tplString, legacy)
if err != nil { if err != nil {
log.Errorf("Could not use configured notification template: %s. Using default template", err) 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, Router: r,
messages: make(chan string, 1), messages: make(chan string, 1),
done: make(chan bool), done: make(chan bool),
logLevels: levels, logLevel: level,
template: tpl, template: tpl,
legacyTemplate: legacy, legacyTemplate: legacy,
data: data, 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 { for msg := range n.messages {
time.Sleep(delay) time.Sleep(n.delay)
errs := n.Router.Send(msg, n.params) errs := n.Router.Send(msg, n.params)
for i, err := range errs { for i, err := range errs {
@ -180,7 +188,7 @@ func (n *shoutrrrTypeNotifier) Close() {
// Levels return what log levels trigger notifications // Levels return what log levels trigger notifications
func (n *shoutrrrTypeNotifier) Levels() []log.Level { 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 // 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{ funcs := template.FuncMap{
"ToUpper": strings.ToUpper, "ToUpper": strings.ToUpper,
"ToLower": strings.ToLower, "ToLower": strings.ToLower,
"ToJSON": toJSON,
"Title": cases.Title(language.AmericanEnglish).String, "Title": cases.Title(language.AmericanEnglish).String,
} }
tplBase := template.New("").Funcs(funcs) tplBase := template.New("").Funcs(funcs)
@ -232,16 +241,3 @@ func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template,
return 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" "github.com/spf13/cobra"
) )
var allButTrace = logrus.AllLevels[0:logrus.TraceLevel] var allButTrace = logrus.DebugLevel
var legacyMockData = Data{ var legacyMockData = Data{
Entries: []*logrus.Entry{ 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("using legacy templates", func() {
When("no custom template is provided", func() { When("no custom template is provided", func() {
@ -90,7 +114,7 @@ updt1 (mock/updt1:latest): Updated
cmd := new(cobra.Command) cmd := new(cobra.Command)
flags.RegisterNotificationFlags(cmd) flags.RegisterNotificationFlags(cmd)
shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true, StaticData{}, false) shoutrrr := createNotifier([]string{}, logrus.TraceLevel, "", true, StaticData{}, false, time.Second)
entries := []*logrus.Entry{ entries := []*logrus.Entry{
{ {
@ -245,7 +269,7 @@ Turns out everything is on fire
When("batching notifications", func() { When("batching notifications", func() {
When("no messages are queued", func() { When("no messages are queued", func() {
It("should not send any notification", 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.StartNotification()
shoutrrr.SendNotification(nil) shoutrrr.SendNotification(nil)
Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`)) Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`))
@ -253,7 +277,8 @@ Turns out everything is on fire
}) })
When("at least one message is queued", func() { When("at least one message is queued", func() {
It("should send a notification", 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() shoutrrr.StartNotification()
logrus.Info("This log message is sponsored by ContainrrrVPN") logrus.Info("This log message is sponsored by ContainrrrVPN")
shoutrrr.SendNotification(nil) shoutrrr.SendNotification(nil)
@ -267,7 +292,7 @@ Turns out everything is on fire
shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{ shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{
Host: "test.host", Host: "test.host",
Title: "", Title: "",
}, false) }, false, time.Second)
_, found := shoutrrr.params.Title() _, found := shoutrrr.params.Title()
Expect(found).ToNot(BeTrue()) Expect(found).ToNot(BeTrue())
}) })
@ -321,13 +346,14 @@ func sendNotificationsWithBlockingRouter(legacy bool) (*shoutrrrTypeNotifier, *b
Router: router, Router: router,
legacyTemplate: legacy, legacyTemplate: legacy,
params: &types.Params{}, params: &types.Params{},
delay: time.Duration(0),
} }
entry := &logrus.Entry{ entry := &logrus.Entry{
Message: "foo bar", Message: "foo bar",
} }
go sendNotifications(shoutrrr, time.Duration(0)) go sendNotifications(shoutrrr)
shoutrrr.StartNotification() shoutrrr.StartNotification()
_ = shoutrrr.Fire(entry) _ = shoutrrr.Fire(entry)

View file

@ -6,7 +6,6 @@ import (
shoutrrrDisco "github.com/containrrr/shoutrrr/pkg/services/discord" shoutrrrDisco "github.com/containrrr/shoutrrr/pkg/services/discord"
shoutrrrSlack "github.com/containrrr/shoutrrr/pkg/services/slack" shoutrrrSlack "github.com/containrrr/shoutrrr/pkg/services/slack"
t "github.com/containrrr/watchtower/pkg/types" t "github.com/containrrr/watchtower/pkg/types"
"github.com/johntdyer/slackrus"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -16,11 +15,15 @@ const (
) )
type slackTypeNotifier struct { 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 { func newSlackNotifier(c *cobra.Command) t.ConvertibleNotifier {
flags := c.PersistentFlags() flags := c.Flags()
hookURL, _ := flags.GetString("notification-slack-hook-url") hookURL, _ := flags.GetString("notification-slack-hook-url")
userName, _ := flags.GetString("notification-slack-identifier") 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") iconURL, _ := flags.GetString("notification-slack-icon-url")
n := &slackTypeNotifier{ n := &slackTypeNotifier{
SlackrusHook: slackrus.SlackrusHook{ HookURL: hookURL,
HookURL: hookURL, Username: userName,
Username: userName, Channel: channel,
Channel: channel, IconEmoji: emoji,
IconEmoji: emoji, IconURL: iconURL,
IconURL: iconURL,
AcceptedLevels: acceptedLogLevels,
},
} }
return n 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.TrimRight(s.HookURL, "/")
trimmedURL = strings.TrimPrefix(trimmedURL, "https://") trimmedURL = strings.TrimPrefix(trimmedURL, "https://")
parts := strings.Split(trimmedURL, "/") parts := strings.Split(trimmedURL, "/")
@ -52,7 +52,6 @@ func (s *slackTypeNotifier) GetURL(c *cobra.Command, title string) (string, erro
WebhookID: parts[len(parts)-3], WebhookID: parts[len(parts)-3],
Token: parts[len(parts)-2], Token: parts[len(parts)-2],
Color: ColorInt, Color: ColorInt,
Title: title,
SplitLines: true, SplitLines: true,
Username: s.Username, Username: s.Username,
} }
@ -70,7 +69,6 @@ func (s *slackTypeNotifier) GetURL(c *cobra.Command, title string) (string, erro
BotName: s.Username, BotName: s.Username,
Color: ColorHex, Color: ColorHex,
Channel: "webhook", Channel: "webhook",
Title: title,
} }
if s.IconURL != "" { 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.v2+json")
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.list.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.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") logrus.WithField("url", url).Debug("Doing a HEAD request to fetch a digest")

View file

@ -1,16 +1,17 @@
package types package types
import ( import (
"github.com/spf13/cobra"
"time" "time"
"github.com/spf13/cobra"
) )
// ConvertibleNotifier is a notifier capable of creating a shoutrrr URL // ConvertibleNotifier is a notifier capable of creating a shoutrrr URL
type ConvertibleNotifier interface { 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 // DelayNotifier is a notifier that might need to be delayed before sending notifications
type DelayNotifier interface { type DelayNotifier interface {
GetDelay() time.Duration GetDelay() time.Duration
} }

View file

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