diff --git a/.all-contributorsrc b/.all-contributorsrc
index 266a339..a8aa234 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -311,7 +311,8 @@
"profile": "https://github.com/zoispag",
"contributions": [
"code",
- "review"
+ "review",
+ "maintenance"
]
},
{
@@ -641,10 +642,10 @@
]
},
{
- "login": "x-jokay",
+ "login": "jokay",
"name": "D. Domig",
"avatar_url": "https://avatars0.githubusercontent.com/u/18613935?v=4",
- "profile": "https://github.com/x-jokay",
+ "profile": "https://github.com/jokay",
"contributions": [
"doc"
]
@@ -684,6 +685,70 @@
"contributions": [
"code"
]
+ },
+ {
+ "login": "ksurl",
+ "name": "ksurl",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/1371562?v=4",
+ "profile": "https://github.com/ksurl",
+ "contributions": [
+ "doc",
+ "code"
+ ]
+ },
+ {
+ "login": "rg9400",
+ "name": "rg9400",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/39887349?v=4",
+ "profile": "https://github.com/rg9400",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "tkalus",
+ "name": "Turtle Kalus",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/287181?v=4",
+ "profile": "https://github.com/tkalus",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "SrihariThalla",
+ "name": "Srihari Thalla",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/7479937?v=4",
+ "profile": "https://github.com/SrihariThalla",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "nymous",
+ "name": "Thomas Gaudin",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/4216559?v=4",
+ "profile": "https://nymous.io",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "hydrargyrum",
+ "name": "hydrargyrum",
+ "avatar_url": "https://avatars.githubusercontent.com/u/2804645?v=4",
+ "profile": "https://indigo.re/",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "reinout",
+ "name": "Reinout van Rees",
+ "avatar_url": "https://avatars.githubusercontent.com/u/121433?v=4",
+ "profile": "https://reinout.vanrees.org",
+ "contributions": [
+ "doc"
+ ]
}
],
"contributorsPerLine": 7,
diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 82d16b5..0000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,227 +0,0 @@
-version: 2.1
-
-executors:
- py:
- docker:
- - image: circleci/python:latest
- working_directory: ~/repo
- go:
- docker:
- - image: circleci/golang:latest
- working_directory: ~/repo
-
-workflows:
- version: 2
- ci:
- jobs:
- - checkout:
- filters:
- branches:
- only: /.*/
- tags:
- only: /.*/
- - linting:
- requires:
- - checkout
- filters:
- branches:
- only: /.*/
- tags:
- only: /.*/
- - testing:
- requires:
- - checkout
- filters:
- branches:
- only: /.*/
- tags:
- only: /.*/
- - build:
- requires:
- - testing
- - linting
- filters:
- branches:
- only: /.*/
- tags:
- ignore: /^v[0-9]+(\.[0-9]+)*$/
- - publishing:
- requires:
- - testing
- - linting
- filters:
- branches:
- ignore: /.*/
- tags:
- only: /^v[0-9]+(\.[0-9]+)*$/
- - publish-docs:
- requires:
- - testing
- - linting
- filters:
- branches:
- ignore: /.*/
- tags:
- only: /^v[0-9]+(\.[0-9]+)*$/
-jobs:
- checkout:
- executor: go
- steps:
- - checkout
- - persist_to_workspace:
- paths:
- - .
- root: ~/repo
- linting:
- executor: go
- steps:
- - attach_workspace:
- at: .
- - run: go build .
- - run: go get -u golang.org/x/lint/golint
- - run: golint -set_exit_status ./...
- testing:
- executor: go
- steps:
- - attach_workspace:
- at: .
- - run: go build ./...
- - run: go get github.com/schrej/godacov
- - run: go test ./... -coverprofile coverage.out
- # - run: godacov -t $CODACY_TOKEN -r ./coverage.out -c $CIRCLE_SHA1
- build:
- executor: go
- steps:
- - attach_workspace:
- at: .
- - setup_remote_docker
- - run:
- name: Install Goreleaser
- command: |
- cd .. && \
- wget https://github.com/goreleaser/goreleaser/releases/download/v0.104.1/goreleaser_Linux_x86_64.tar.gz && \
- tar -xvf goreleaser_Linux_x86_64.tar.gz && \
- ./goreleaser -v
- - run:
- name: Execute goreleaser
- command: CGO_ENABLED=${CGO_ENABLED:-0} ../goreleaser --snapshot --skip-publish --debug
- publishing:
- executor: go
- steps:
- - attach_workspace:
- at: .
- - setup_remote_docker
- - run:
- name: Install Goreleaser
- command: |
- cd .. && \
- wget https://github.com/goreleaser/goreleaser/releases/download/v0.104.1/goreleaser_Linux_x86_64.tar.gz && \
- tar -xvf goreleaser_Linux_x86_64.tar.gz && \
- ./goreleaser -v
- - run:
- name: Login to docker hub
- command: |
- echo "$DOCKER_PASS" | docker login -u $DOCKER_USER --password-stdin
- - run:
- name: Execute goreleaser
- command: CGO_ENABLED=${CGO_ENABLED:-0} ../goreleaser --debug
- - run:
- name: Enable experimental docker features
- command: |
- mkdir -p ~/.docker/ && \
- echo '{"experimental": "enabled"}' > ~/.docker/config.json
- - run:
- name: Create manifest for version
- command: |
- docker manifest create \
- containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:amd64-$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:i386-$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:armhf-$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:arm64v8-$(echo $CIRCLE_TAG | sed 's/^v*//')
- - run:
- name: Annotate i386 version
- command: |
- docker manifest annotate \
- containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:i386-$(echo $CIRCLE_TAG | sed 's/^v*//') \
- --os linux \
- --arch 386
- - run:
- name: Annotate ARM version
- command: |
- docker manifest annotate \
- containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:armhf-$(echo $CIRCLE_TAG | sed 's/^v*//') \
- --os linux \
- --arch arm
- - run:
- name: Annotate ARM64 version
- command: |
- docker manifest annotate \
- containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:arm64v8-$(echo $CIRCLE_TAG | sed 's/^v*//') \
- --os linux \
- --arch arm64 \
- --variant v8
- - run:
- name: Create manifest for latest
- command: |
- docker manifest create \
- containrrr/watchtower:latest \
- containrrr/watchtower:amd64-latest \
- containrrr/watchtower:i386-latest \
- containrrr/watchtower:armhf-latest \
- containrrr/watchtower:arm64v8-latest
- - run:
- name: Annotate i386 latest
- command: |
- docker manifest annotate \
- containrrr/watchtower:latest \
- containrrr/watchtower:i386-latest \
- --os linux \
- --arch 386
- - run:
- name: Annotate ARM latest
- command: |
- docker manifest annotate \
- containrrr/watchtower:latest \
- containrrr/watchtower:armhf-latest \
- --os linux \
- --arch arm
- - run:
- name: Annotate ARM64 latest
- command: |
- docker manifest annotate \
- containrrr/watchtower:latest \
- containrrr/watchtower:arm64v8-latest \
- --os linux \
- --arch arm64 \
- --variant v8
- - run:
- name: Push manifests to Dockerhub
- command: |
- echo "$DOCKER_PASS" | docker login -u $DOCKER_USER --password-stdin &&
- docker manifest push containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') && \
- docker manifest push containrrr/watchtower:latest
- publish-docs:
- executor: py
- steps:
- - attach_workspace:
- at: .
- - run:
- name: Install prerequisites
- command: |
- sudo pip install \
- mkdocs \
- mkdocs-material \
- md-toc
- - add_ssh_keys:
- fingerprints:
- - '91:75:47:15:b2:8e:85:e5:67:0e:63:7f:22:d2:b4:6e'
- - run:
- name: Generate and publish
- command: |
- mkdir ~/.ssh && touch ~/.ssh/known_hosts;
- ssh-keyscan -H github.com >> ~/.ssh/known_hosts && \
- mkdocs gh-deploy
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 76f46f4..53e1a53 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -6,29 +6,48 @@ labels: 'Priority: Medium, Status: Available, Type: Bug'
assignees: ''
---
+
**Describe the bug**
-A clear and concise description of what the bug is.
+
**To Reproduce**
+
**Expected behavior**
-A clear and concise description of what you expected to happen.
+
**Screenshots**
+
**Environment**
+
-**Logs from running watchtower with the `--debug` option**
+
+ Logs from running watchtower with the --debug
option
+
+```
+
+```
+
+
**Additional context**
-Add any other context about the problem here.
+
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..2437bb2
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,72 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+name: "CodeQL"
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [main]
+ schedule:
+ - cron: '0 1 * * 4'
+ workflow_dispatch:
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ # Override automatic language detection by changing the below list
+ # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
+ language: ['go']
+ # Learn more...
+ # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+ with:
+ # We must fetch at least the immediate parents so that if this is
+ # a pull request then we can checkout the head.
+ fetch-depth: 2
+
+ # If this run was triggered by a pull request event, then checkout
+ # the head of the pull request instead of the merge commit.
+ - run: git checkout HEAD^2
+ if: ${{ github.event_name == 'pull_request' }}
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v1
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+ # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v1
+
+ # βΉοΈ Command-line programs to run using the OS shell.
+ # π https://git.io/JvXDl
+
+ # βοΈ If the Autobuild fails above, remove it and uncomment the following three lines
+ # and modify them (or add more) to build your code if your project
+ # uses a compiled language
+
+ #- run: |
+ # make bootstrap
+ # make release
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v1
diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml
index 899bb52..20302f0 100644
--- a/.github/workflows/greetings.yml
+++ b/.github/workflows/greetings.yml
@@ -11,7 +11,7 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: >
Hi there! ππΌ
- As you're new to this repo, we'd like to suggest that you read our [code of conduct](https://github.com/containrrr/watchtower/blob/master/CODE_OF_CONDUCT.md)
+ As you're new to this repo, we'd like to suggest that you read our [code of conduct](https://github.com/containrrr/.github/blob/master/CODE_OF_CONDUCT.md)
as well as our [contribution guidelines](https://github.com/containrrr/watchtower/blob/master/CONTRIBUTING.md).
Thanks a bunch for opening your first issue! π
pr-message: >
diff --git a/.github/workflows/publish-dev-dockerimage.yaml b/.github/workflows/publish-dev-dockerimage.yaml
deleted file mode 100644
index c02186a..0000000
--- a/.github/workflows/publish-dev-dockerimage.yaml
+++ /dev/null
@@ -1,23 +0,0 @@
-name: Docker image (latest-dev)
-
-on:
- push:
- branches:
- - master
-
-jobs:
-
- build:
-
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v2
-
- - uses: jerray/publish-docker-action@master
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_PASSWORD }}
- file: dockerfiles/Dockerfile.self-contained
- repository: containrrr/watchtower
- tags: latest-dev
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
new file mode 100644
index 0000000..24bfe13
--- /dev/null
+++ b/.github/workflows/pull-request.yml
@@ -0,0 +1,91 @@
+name: Pull Request
+
+on:
+ workflow_dispatch: {}
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.15.x
+ - name: Install linter
+ run: |
+ go get -u golang.org/x/lint/golint
+ - name: Lint files
+ run: |
+ golint -set_exit_status ./...
+ test:
+ name: Test
+ strategy:
+ fail-fast: false
+ matrix:
+ go-version:
+ - 1.15.x
+ platform:
+ - macos-latest
+ - windows-latest
+ runs-on: ${{ matrix.platform }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.15.x
+ - name: Run tests
+ run: |
+ go test -v -coverprofile coverage.out -covermode atomic ./...
+ - name: Publish coverage
+ uses: codecov/codecov-action@v1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ test-ubuntu:
+ name: Test (Ubuntu)
+ runs-on: ubuntu-latest
+ needs: test
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.15.x
+ - name: Run tests
+ run: |
+ go test -v -coverprofile coverage.out -covermode atomic ./...
+ - name: Publish coverage
+ uses: codecov/codecov-action@v1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.15.x
+ - name: Build
+ uses: goreleaser/goreleaser-action@v2
+ with:
+ version: v0.155.0
+ args: --snapshot --skip-publish --debug
diff --git a/.github/workflows/release-dev.yaml b/.github/workflows/release-dev.yaml
new file mode 100644
index 0000000..5c71258
--- /dev/null
+++ b/.github/workflows/release-dev.yaml
@@ -0,0 +1,57 @@
+name: Push to main
+
+on:
+ workflow_dispatch: {}
+ push:
+ branches:
+ - main
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.15
+ - name: Build
+ run: go build -v ./...
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.15
+ - name: Test
+ run: go test -v -coverprofile coverage.out -covermode atomic ./...
+ - name: Publish coverage
+ uses: codecov/codecov-action@v1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ publish:
+ needs:
+ - build
+ - test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: Publish to Docker Hub
+ uses: jerray/publish-docker-action@master
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_PASSWORD }}
+ file: dockerfiles/Dockerfile.self-contained
+ repository: containrrr/watchtower
+ tags: latest-dev
+ - name: Publish to GHCR
+ uses: jerray/publish-docker-action@master
+ with:
+ username: ${{ secrets.BOT_USERNAME }}
+ password: ${{ secrets.BOT_GHCR_PAT }}
+ file: dockerfiles/Dockerfile.self-contained
+ registry: ghcr.io
+ repository: containrrr/watchtower
+ tags: latest-dev
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..871ac9d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,224 @@
+name: Release (Production)
+
+on:
+ workflow_dispatch: {}
+ release:
+ types:
+ - created
+ tags:
+ - 'v[0-9]+.[0-9]+.[0-9]+'
+ - '**/v[0-9]+.[0-9]+.[0-9]+'
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.15.x
+ - name: Install linter
+ run: |
+ go get -u golang.org/x/lint/golint
+ - name: Lint files
+ run: |
+ golint -set_exit_status ./...
+
+ test:
+ name: Test
+ strategy:
+ matrix:
+ go-version:
+ - 1.15.x
+ platform:
+ - ubuntu-latest
+ - macos-latest
+ - windows-latest
+ runs-on: ${{ matrix.platform }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.15.x
+ - name: Run tests
+ run: |
+ go test ./... -coverprofile coverage.out
+
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ needs:
+ - test
+ - lint
+ env:
+ CGO_ENABLED: 0
+ TAG: ${{ github.event.release.tag_name }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.15.x
+ - name: Login to Docker Hub
+ uses: docker/login-action@v1
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@v1
+ with:
+ username: ${{ secrets.BOT_USERNAME }}
+ password: ${{ secrets.BOT_GHCR_PAT }}
+ registry: ghcr.io
+ - name: Build
+ uses: goreleaser/goreleaser-action@v2
+ with:
+ version: v0.155.0
+ args: --debug
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Enable experimental docker features
+ run: |
+ mkdir -p ~/.docker/ && \
+ echo '{"experimental": "enabled"}' > ~/.docker/config.json
+ - name: Create manifest for version
+ run: |
+ export DH_TAG=$(echo $TAG | sed 's/^v*//')
+ docker manifest create \
+ containrrr/watchtower:$DH_TAG \
+ containrrr/watchtower:amd64-$DH_TAG \
+ containrrr/watchtower:i386-$DH_TAG \
+ containrrr/watchtower:armhf-$DH_TAG \
+ containrrr/watchtower:arm64v8-$DH_TAG
+ docker manifest create \
+ ghcr.io/containrrr/watchtower:$DH_TAG \
+ ghcr.io/containrrr/watchtower:amd64-$DH_TAG \
+ ghcr.io/containrrr/watchtower:i386-$DH_TAG \
+ ghcr.io/containrrr/watchtower:armhf-$DH_TAG \
+ ghcr.io/containrrr/watchtower:arm64v8-$DH_TAG
+ - name: Annotate manifest for version
+ run: |
+ for REPO in '' ghcr.io/ ; do
+
+ docker manifest annotate \
+ ${REPO}containrrr/watchtower:$(echo $TAG | sed 's/^v*//') \
+ ${REPO}containrrr/watchtower:i386-$(echo $TAG | sed 's/^v*//') \
+ --os linux \
+ --arch 386
+
+ docker manifest annotate \
+ ${REPO}containrrr/watchtower:$(echo $TAG | sed 's/^v*//') \
+ ${REPO}containrrr/watchtower:armhf-$(echo $TAG | sed 's/^v*//') \
+ --os linux \
+ --arch arm
+
+ docker manifest annotate \
+ ${REPO}containrrr/watchtower:$(echo $TAG | sed 's/^v*//') \
+ ${REPO}containrrr/watchtower:arm64v8-$(echo $TAG | sed 's/^v*//') \
+ --os linux \
+ --arch arm64 \
+ --variant v8
+
+ done
+ - name: Create manifest for latest
+ run: |
+ docker manifest create \
+ containrrr/watchtower:latest \
+ containrrr/watchtower:amd64-latest \
+ containrrr/watchtower:i386-latest \
+ containrrr/watchtower:armhf-latest \
+ containrrr/watchtower:arm64v8-latest
+ docker manifest create \
+ ghcr.io/containrrr/watchtower:latest \
+ ghcr.io/containrrr/watchtower:amd64-latest \
+ ghcr.io/containrrr/watchtower:i386-latest \
+ ghcr.io/containrrr/watchtower:armhf-latest \
+ ghcr.io/containrrr/watchtower:arm64v8-latest
+ - name: Annotate manifest for latest
+ run: |
+ for REPO in '' ghcr.io/ ; do
+
+ docker manifest annotate \
+ ${REPO}containrrr/watchtower:latest \
+ ${REPO}containrrr/watchtower:i386-latest \
+ --os linux \
+ --arch 386
+
+ docker manifest annotate \
+ ${REPO}containrrr/watchtower:latest \
+ ${REPO}containrrr/watchtower:armhf-latest \
+ --os linux \
+ --arch arm
+
+ docker manifest annotate \
+ ${REPO}containrrr/watchtower:latest \
+ ${REPO}containrrr/watchtower:arm64v8-latest \
+ --os linux \
+ --arch arm64 \
+ --variant v8
+
+ done
+ - name: Push manifests to Dockerhub
+ env:
+ DOCKER_USER: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKER_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+ run: |
+ docker login -u $DOCKER_USER -p $DOCKER_TOKEN && \
+ docker manifest push containrrr/watchtower:$(echo $TAG | sed 's/^v*//') && \
+ docker manifest push containrrr/watchtower:latest
+ - name: Push manifests to GitHub Container Registry
+ env:
+ DOCKER_USER: ${{ secrets.BOT_USERNAME }}
+ DOCKER_TOKEN: ${{ secrets.BOT_GHCR_PAT }}
+ run: |
+ docker login -u $DOCKER_USER -p $DOCKER_TOKEN ghcr.io && \
+ docker manifest push ghcr.io/containrrr/watchtower:$(echo $TAG | sed 's/^v*//') && \
+ docker manifest push ghcr.io/containrrr/watchtower:latest
+
+ publish-docs:
+ name: Publish Docs
+ needs: build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+ - name: Install mkdocs
+ run: |
+ pip install \
+ mkdocs \
+ mkdocs-material \
+ md-toc
+ - name: Generate docs
+ run: mkdocs build
+ - name: Publish docs
+ uses: peaceiris/actions-gh-pages@v3
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./site
+
+ renew-docs:
+ name: Refresh pkg.go.dev
+ needs: build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Pull new module version
+ uses: andrewslotin/go-proxy-pull-action@master
+
+
+
+
+
diff --git a/.gitignore b/.gitignore
index fda8d42..50b5c2d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ dist
.idea
.DS_Store
/site
+coverage.out
diff --git a/README.md b/README.md
index e7d78d8..0d9d512 100644
--- a/README.md
+++ b/README.md
@@ -1,49 +1,23 @@
-
+
## Quick Start
@@ -68,104 +42,114 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
-
+
+
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..550f904
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,13 @@
+# Security Policy
+
+## Supported Versions
+
+Security updates will always only be applied to the latest version of Watchtower.
+As the software by default is set to auto-update if you use the `latest` tag, you will get these security updates automatically as soon as they are released.
+
+## Reporting a Vulnerability
+
+Critical vulnerabilities that might open up for external attacks are best reported directly either to simme@arcticbit.se or nils@piksel.se.
+We'll always try to get back to you as swiftly as possible, but keep in mind that since this is a community project, we can't really leave any guarantees about the speed.
+
+Non-critical vulnerabilities may be reported as regular GitHub issues.
diff --git a/build.sh b/build.sh
new file mode 100644
index 0000000..47d2b5c
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+VERSION=$(git describe --tags)
+echo "Building $VERSION..."
+go build -o watchtower -ldflags "-X github.com/containrrr/watchtower/cmd.version=$VERSION"
diff --git a/cmd/root.go b/cmd/root.go
index 1e61308..707c4fd 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -1,17 +1,23 @@
package cmd
import (
+ "math"
"os"
"os/signal"
"strconv"
+ "strings"
"syscall"
"time"
+ apiMetrics "github.com/containrrr/watchtower/pkg/api/metrics"
+ "github.com/containrrr/watchtower/pkg/api/update"
+
"github.com/containrrr/watchtower/internal/actions"
"github.com/containrrr/watchtower/internal/flags"
"github.com/containrrr/watchtower/pkg/api"
"github.com/containrrr/watchtower/pkg/container"
"github.com/containrrr/watchtower/pkg/filters"
+ "github.com/containrrr/watchtower/pkg/metrics"
"github.com/containrrr/watchtower/pkg/notifications"
t "github.com/containrrr/watchtower/pkg/types"
"github.com/robfig/cron"
@@ -32,17 +38,24 @@ var (
lifecycleHooks bool
rollingRestart bool
scope string
+ // Set on build using ldflags
+ version = "v0.0.0-unknown"
)
-var rootCmd = &cobra.Command{
- Use: "watchtower",
- Short: "Automatically updates running Docker containers",
- Long: `
-Watchtower automatically updates running Docker containers whenever a new image is released.
-More information available at https://github.com/containrrr/watchtower/.
-`,
- Run: Run,
- PreRun: PreRun,
+var rootCmd = NewRootCommand()
+
+// NewRootCommand creates the root command for watchtower
+func NewRootCommand() *cobra.Command {
+ return &cobra.Command{
+ Use: "watchtower",
+ Short: "Automatically updates running Docker containers",
+ Long: `
+ Watchtower automatically updates running Docker containers whenever a new image is released.
+ More information available at https://github.com/containrrr/watchtower/.
+ `,
+ Run: Run,
+ PreRun: PreRun,
+ }
}
func init() {
@@ -60,7 +73,7 @@ func Execute() {
}
// PreRun is a lifecycle hook that runs before the command is executed.
-func PreRun(cmd *cobra.Command, args []string) {
+func PreRun(cmd *cobra.Command, _ []string) {
f := cmd.PersistentFlags()
if enabled, _ := f.GetBool("no-color"); enabled {
@@ -119,6 +132,7 @@ func PreRun(cmd *cobra.Command, args []string) {
includeRestarting, _ := f.GetBool("include-restarting")
reviveStopped, _ := f.GetBool("revive-stopped")
removeVolumes, _ := f.GetBool("remove-volumes")
+ warnOnHeadPullFailed, _ := f.GetString("warn-on-head-failure")
if monitorOnly && noPull {
log.Warn("Using `WATCHTOWER_NO_PULL` and `WATCHTOWER_MONITOR_ONLY` simultaneously might lead to no action being taken at all. If this is intentional, you may safely ignore this message.")
@@ -130,6 +144,7 @@ func PreRun(cmd *cobra.Command, args []string) {
reviveStopped,
removeVolumes,
includeRestarting,
+ warnOnHeadPullFailed,
)
notifier = notifications.NewNotifier(cmd)
@@ -137,14 +152,24 @@ func PreRun(cmd *cobra.Command, args []string) {
// Run is the main execution flow of the command
func Run(c *cobra.Command, names []string) {
- filter := filters.BuildFilter(names, enableLabel, scope)
+ filter, filterDesc := filters.BuildFilter(names, enableLabel, scope)
runOnce, _ := c.PersistentFlags().GetBool("run-once")
- httpAPI, _ := c.PersistentFlags().GetBool("http-api")
+ enableUpdateAPI, _ := c.PersistentFlags().GetBool("http-api-update")
+ enableMetricsAPI, _ := c.PersistentFlags().GetBool("http-api-metrics")
+ apiToken, _ := c.PersistentFlags().GetString("http-api-token")
+
+ if rollingRestart && monitorOnly {
+ log.Fatal("Rolling restarts is not compatible with the global monitor only flag")
+ }
+
+ awaitDockerClient()
+
+ if err := actions.CheckForSanity(client, filter, rollingRestart); err != nil {
+ logNotifyExit(err)
+ }
if runOnce {
- if noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message"); !noStartupMessage {
- log.Info("Running a one time update.")
- }
+ writeStartupMessage(c, time.Time{}, filterDesc)
runUpdatesWithNotifications(filter)
notifier.Close()
os.Exit(0)
@@ -152,44 +177,124 @@ func Run(c *cobra.Command, names []string) {
}
if err := actions.CheckForMultipleWatchtowerInstances(client, cleanup, scope); err != nil {
- log.Fatal(err)
+ logNotifyExit(err)
}
- if httpAPI {
- apiToken, _ := c.PersistentFlags().GetString("http-api-token")
+ httpAPI := api.New(apiToken)
- if err := api.SetupHTTPUpdates(apiToken, func() { runUpdatesWithNotifications(filter) }); err != nil {
- log.Fatal(err)
- os.Exit(1)
- }
-
- api.WaitForHTTPUpdates()
+ if enableUpdateAPI {
+ updateHandler := update.New(func() { runUpdatesWithNotifications(filter) })
+ httpAPI.RegisterFunc(updateHandler.Path, updateHandler.Handle)
}
- if err := runUpgradesOnSchedule(c, filter); err != nil {
+ if enableMetricsAPI {
+ metricsHandler := apiMetrics.New()
+ httpAPI.RegisterHandler(metricsHandler.Path, metricsHandler.Handle)
+ }
+
+ if err := httpAPI.Start(enableUpdateAPI); err != nil {
+ log.Error("failed to start API", err)
+ }
+
+ if err := runUpgradesOnSchedule(c, filter, filterDesc); err != nil {
log.Error(err)
}
os.Exit(1)
}
-func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter) error {
+func logNotifyExit(err error) {
+ log.Error(err)
+ notifier.Close()
+ os.Exit(1)
+}
+
+func awaitDockerClient() {
+ log.Debug("Sleeping for a second to ensure the docker api client has been properly initialized.")
+ time.Sleep(1 * time.Second)
+}
+
+func formatDuration(d time.Duration) string {
+ sb := strings.Builder{}
+
+ hours := int64(d.Hours())
+ minutes := int64(math.Mod(d.Minutes(), 60))
+ seconds := int64(math.Mod(d.Seconds(), 60))
+
+ if hours == 1 {
+ sb.WriteString("1 hour")
+ } else if hours != 0 {
+ sb.WriteString(strconv.FormatInt(hours, 10))
+ sb.WriteString(" hours")
+ }
+
+ if hours != 0 && (seconds != 0 || minutes != 0) {
+ sb.WriteString(", ")
+ }
+
+ if minutes == 1 {
+ sb.WriteString("1 minute")
+ } else if minutes != 0 {
+ sb.WriteString(strconv.FormatInt(minutes, 10))
+ sb.WriteString(" minutes")
+ }
+
+ if minutes != 0 && (seconds != 0) {
+ sb.WriteString(", ")
+ }
+
+ if seconds == 1 {
+ sb.WriteString("1 second")
+ } else if seconds != 0 || (hours == 0 && minutes == 0) {
+ sb.WriteString(strconv.FormatInt(seconds, 10))
+ sb.WriteString(" seconds")
+ }
+
+ return sb.String()
+}
+
+func writeStartupMessage(c *cobra.Command, sched time.Time, filtering string) {
+ if noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message"); !noStartupMessage {
+ schedMessage := "Running a one time update."
+ if !sched.IsZero() {
+ until := formatDuration(time.Until(sched))
+ schedMessage = "Scheduling first run: " + sched.Format("2006-01-02 15:04:05 -0700 MST") +
+ "\nNote that the first check will be performed in " + until
+ }
+
+ notifs := "Using no notifications"
+ notifList := notifier.String()
+ if len(notifList) > 0 {
+ notifs = "Using notifications: " + notifList
+ }
+
+ log.Info("Watchtower ", version, "\n", notifs, "\n", filtering, "\n", schedMessage)
+ if log.IsLevelEnabled(log.TraceLevel) {
+ log.Warn("trace level enabled: log will include sensitive information as credentials and tokens")
+ }
+ }
+}
+
+func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter, filtering string) error {
tryLockSem := make(chan bool, 1)
tryLockSem <- true
- cron := cron.New()
- err := cron.AddFunc(
+ scheduler := cron.New()
+ err := scheduler.AddFunc(
scheduleSpec,
func() {
select {
case v := <-tryLockSem:
defer func() { tryLockSem <- v }()
- runUpdatesWithNotifications(filter)
+ metric := runUpdatesWithNotifications(filter)
+ metrics.RegisterScan(metric)
default:
+ // Update was skipped
+ metrics.RegisterScan(nil)
log.Debug("Skipped another update already running.")
}
- nextRuns := cron.Entries()
+ nextRuns := scheduler.Entries()
if len(nextRuns) > 0 {
log.Debug("Scheduled next run: " + nextRuns[0].Next.String())
}
@@ -199,11 +304,9 @@ func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter) error {
return err
}
- if noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message"); !noStartupMessage {
- log.Info("Starting Watchtower and scheduling first run: " + cron.Entries()[0].Schedule.Next(time.Now()).String())
- }
+ writeStartupMessage(c, scheduler.Entries()[0].Schedule.Next(time.Now()), filtering)
- cron.Start()
+ scheduler.Start()
// Graceful shut-down on SIGINT/SIGTERM
interrupt := make(chan os.Signal, 1)
@@ -211,13 +314,13 @@ func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter) error {
signal.Notify(interrupt, syscall.SIGTERM)
<-interrupt
- cron.Stop()
+ scheduler.Stop()
log.Info("Waiting for running update to be finished...")
<-tryLockSem
return nil
}
-func runUpdatesWithNotifications(filter t.Filter) {
+func runUpdatesWithNotifications(filter t.Filter) *metrics.Metric {
notifier.StartNotification()
updateParams := t.UpdateParams{
Filter: filter,
@@ -228,9 +331,12 @@ func runUpdatesWithNotifications(filter t.Filter) {
LifecycleHooks: lifecycleHooks,
RollingRestart: rollingRestart,
}
- err := actions.Update(client, updateParams)
+ metricResults, err := actions.Update(client, updateParams)
if err != nil {
- log.Println(err)
+ log.Error(err)
}
notifier.SendNotification()
+ log.Debugf("Session done: %v scanned, %v updated, %v failed",
+ metricResults.Scanned, metricResults.Updated, metricResults.Failed)
+ return metricResults
}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..b0a3373
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,43 @@
+version: '3.7'
+
+services:
+ watchtower:
+ container_name: watchtower
+ build:
+ context: ./
+ dockerfile: dockerfiles/Dockerfile.dev-self-contained
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ ports:
+ - 8080:8080
+ command: --interval 10 --http-api-metrics --http-api-token demotoken --debug prometheus grafana parent child
+ prometheus:
+ container_name: prometheus
+ image: prom/prometheus
+ volumes:
+ - ./prometheus/:/etc/prometheus/
+ - prometheus:/prometheus/
+ ports:
+ - 9090:9090
+ grafana:
+ container_name: grafana
+ image: grafana/grafana
+ ports:
+ - 3000:3000
+ environment:
+ GF_INSTALL_PLUGINS: grafana-clock-panel,grafana-simple-json-datasource
+ volumes:
+ - grafana:/var/lib/grafana
+ - ./grafana:/etc/grafana/provisioning
+ parent:
+ image: nginx
+ container_name: parent
+ child:
+ image: nginx:alpine
+ labels:
+ com.centurylinklabs.watchtower.depends-on: parent
+ container_name: child
+
+volumes:
+ prometheus: {}
+ grafana: {}
diff --git a/dockerfiles/Dockerfile b/dockerfiles/Dockerfile
index 7e28eb2..345f5c2 100644
--- a/dockerfiles/Dockerfile
+++ b/dockerfiles/Dockerfile
@@ -1,4 +1,4 @@
-FROM alpine:3.11 as alpine
+FROM --platform=$BUILDPLATFORM alpine:3.11 as alpine
RUN apk add --no-cache \
ca-certificates \
diff --git a/dockerfiles/Dockerfile.dev-self-contained b/dockerfiles/Dockerfile.dev-self-contained
index 307ffbe..b22ef13 100644
--- a/dockerfiles/Dockerfile.dev-self-contained
+++ b/dockerfiles/Dockerfile.dev-self-contained
@@ -4,8 +4,8 @@
FROM golang:alpine as builder
-# use version (for example "v0.3.3") or "master"
-ARG WATCHTOWER_VERSION=master
+# use version (for example "v0.3.3") or "main"
+ARG WATCHTOWER_VERSION=main
RUN apk add --no-cache \
alpine-sdk \
@@ -18,7 +18,7 @@ COPY . /watchtower
RUN \
cd /watchtower && \
\
- GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' . && \
+ GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -X github.com/containrrr/watchtower/cmd.version=$(git describe --tags)" . && \
GO111MODULE=on go test ./... -v
diff --git a/dockerfiles/Dockerfile.self-contained b/dockerfiles/Dockerfile.self-contained
index 64d5dc0..f24701f 100644
--- a/dockerfiles/Dockerfile.self-contained
+++ b/dockerfiles/Dockerfile.self-contained
@@ -4,8 +4,8 @@
FROM golang:alpine as builder
-# use version (for example "v0.3.3") or "master"
-ARG WATCHTOWER_VERSION=master
+# use version (for example "v0.3.3") or "main"
+ARG WATCHTOWER_VERSION=main
RUN apk add --no-cache \
alpine-sdk \
@@ -18,7 +18,7 @@ RUN git clone --branch "${WATCHTOWER_VERSION}" https://github.com/containrrr/wat
RUN \
cd watchtower && \
\
- GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' . && \
+ GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -X github.com/containrrr/watchtower/cmd.version=$(git describe --tags)" . && \
GO111MODULE=on go test ./... -v
diff --git a/docs/arguments.md b/docs/arguments.md
index b80257a..70efc21 100644
--- a/docs/arguments.md
+++ b/docs/arguments.md
@@ -39,7 +39,7 @@ Environment Variable: N/A
## Time Zone
Sets the time zone to be used by WatchTower's logs and the optional Cron scheduling argument (--schedule). If this environment variable is not set, Watchtower will use the default time zone: UTC.
-To find out the right value, see [this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), find your location and use the value in _TZ Database Name_, e.g _Europe/Rome_. The timezome can alternatively be set by volume mounting your hosts /etc/timezone file. `-v /etc/timezone:/etc/timezone:ro`
+To find out the right value, see [this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), find your location and use the value in _TZ Database Name_, e.g _Europe/Rome_. The timezone can alternatively be set by volume mounting your hosts /etc/localtime file. `-v /etc/localtime:/etc/localtime:ro`
```
Argument: N/A
@@ -102,7 +102,7 @@ Environment Variable: NO_COLOR
Docker daemon socket to connect to. Can be pointed at a remote Docker host by specifying a TCP endpoint as "tcp://hostname:port".
```
- Argument: --host, -h
+ Argument: --host, -H
Environment Variable: DOCKER_HOST
Type: String
Default: "unix:///var/run/docker.sock"
@@ -118,6 +118,16 @@ Environment Variable: DOCKER_API_VERSION
Default: "1.24"
```
+## Include restarting
+Will also include restarting containers.
+
+```
+ Argument: --include-restarting
+Environment Variable: WATCHTOWER_INCLUDE_RESTARTING
+ Type: Boolean
+ Default: false
+```
+
## Include stopped
Will also include created and exited containers.
@@ -145,7 +155,7 @@ Poll interval (in seconds). This value controls how frequently watchtower will p
Argument: --interval, -i
Environment Variable: WATCHTOWER_POLL_INTERVAL
Type: Integer
- Default: 300
+ Default: 86400 (24 hours)
```
## Filter by enable label
@@ -164,7 +174,7 @@ Environment Variable: WATCHTOWER_LABEL_ENABLE
## Without updating containers
Will only monitor for new images, send notifications and invoke the [pre-check/post-check hooks](https://containrrr.dev/watchtower/lifecycle-hooks/), but will **not** update the containers.
-> ### β οΈ Please note
+> **β οΈ Please note**
>
> Due to Docker API limitations the latest image will still be pulled from the registry.
@@ -221,10 +231,10 @@ Environment Variable: WATCHTOWER_RUN_ONCE
```
## HTTP API Mode
-Runs Watchtower in HTTP API mode, only allowing image updates to be triggered by an HTTP request.
+Runs Watchtower in HTTP API mode, only allowing image updates to be triggered by an HTTP request. For details see [HTTP API](https://containrrr.github.io/watchtower/http-api-mode).
```
- Argument: --http-api
+ Argument: --http-api-update
Environment Variable: WATCHTOWER_HTTP_API
Type: Boolean
Default: false
@@ -250,6 +260,16 @@ Environment Variable: WATCHTOWER_SCOPE
Default: -
```
+## HTTP API Metrics
+Enables a metrics endpoint, exposing prometheus metrics via HTTP. See [Metrics](metrics.md) for details.
+
+```
+ Argument: --http-api-metrics
+Environment Variable: WATCHTOWER_HTTP_API_METRICS
+ Type: Boolean
+ Default: false
+```
+
## Scheduling
[Cron expression](https://pkg.go.dev/github.com/robfig/cron@v1.2.0?tab=doc#hdr-CRON_Expression_Format) in 6 fields (rather than the traditional 5) which defines when and how often to check for new images. Either `--interval` or the schedule expression
can be defined, but not both. An example: `--schedule "0 0 4 * * *"`
diff --git a/docs/assets/grafana-dashboard.png b/docs/assets/grafana-dashboard.png
new file mode 100644
index 0000000..faab422
Binary files /dev/null and b/docs/assets/grafana-dashboard.png differ
diff --git a/docs/http-api-mode.md b/docs/http-api-mode.md
index 7d14d09..5f5b19a 100644
--- a/docs/http-api-mode.md
+++ b/docs/http-api-mode.md
@@ -4,9 +4,9 @@ Watchtower provides an HTTP API mode that enables an HTTP endpoint that can be r
---
-To enable this mode, use the flag `--http-api`. For example, in a Docker Compose config file:
+To enable this mode, use the flag `--http-api-update`. For example, in a Docker Compose config file:
-```json
+```yaml
version: '3'
services:
@@ -19,7 +19,7 @@ services:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- command: --debug --http-api
+ command: --debug --http-api-update
environment:
- WATCHTOWER_HTTP_API_TOKEN=mytoken
labels:
@@ -31,5 +31,5 @@ services:
Notice that there is an environment variable named WATCHTOWER_HTTP_API_TOKEN. To prevent external services from accidentally triggering image updates, all of the requests have to contain a "Token" field, valued as the token defined in WATCHTOWER_HTTP_API_TOKEN, in their headers. In this case, there is a port bind to the host machine, allowing to request localhost:8080 to reach Watchtower. The following `curl` command would trigger an image update:
```bash
-curl -H "Token: mytoken" localhost:8080/v1/update
-```
\ No newline at end of file
+curl -H "Authorization: Bearer mytoken" localhost:8080/v1/update
+```
diff --git a/docs/images/favicon.ico b/docs/images/favicon.ico
new file mode 100644
index 0000000..b9099b4
Binary files /dev/null and b/docs/images/favicon.ico differ
diff --git a/docs/images/logo-450px.png b/docs/images/logo-450px.png
new file mode 100644
index 0000000..3049526
Binary files /dev/null and b/docs/images/logo-450px.png differ
diff --git a/docs/index.md b/docs/index.md
index c0bab4c..e999c03 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,3 +1,6 @@
+
+
+
Watchtower
@@ -8,6 +11,9 @@
+
+
+
@@ -23,17 +29,14 @@
-
-
+
+
-
-
+
+
-
-
-
-
-
+
+
@@ -46,4 +49,4 @@ $ docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower
-```
\ No newline at end of file
+```
diff --git a/docs/metrics.md b/docs/metrics.md
new file mode 100644
index 0000000..d8ea1b4
--- /dev/null
+++ b/docs/metrics.md
@@ -0,0 +1,26 @@
+> **β οΈ Experimental feature**
+>
+> This feature was added in v1.0.4 and is still considered experimental.
+> If you notice any strange behavior, please raise a ticket in the repository issues.
+
+Metrics can be used to track how Watchtower behaves over time.
+
+To use this feature, you have to set an [API token](arguments.md#http-api-token) and [enable the metrics API](arguments.md#http-api-metrics),
+as well as creating a port mapping for your container for port `8080`.
+
+## Available Metrics
+
+| Name | Type | Description |
+| ------------------------------- | ------- | --------------------------------------------------------------------------- |
+| `watchtower_containers_scanned` | Gauge | Number of containers scanned for changes by watchtower during the last scan |
+| `watchtower_containers_updated` | Gauge | Number of containers updated by watchtower during the last scan |
+| `watchtower_containers_failed` | Gauge | Number of containers where update failed during the last scan |
+| `watchtower_scans_total` | Counter | Number of scans since the watchtower started |
+| `watchtower_scans_skipped` | Counter | Number of skipped scans since watchtower started |
+
+## Demo
+
+The repository contains a demo with prometheus and grafana, available through `docker-compose.yml`. This demo
+is preconfigured with a dashboard, which will look something like this:
+
+
\ No newline at end of file
diff --git a/docs/notifications.md b/docs/notifications.md
index afa23bd..57603cb 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -1,4 +1,3 @@
-
# Notifications
Watchtower can send notifications when containers are updated. Notifications are sent via hooks in the logging system, [logrus](http://github.com/sirupsen/logrus).
@@ -12,14 +11,14 @@ The types of notifications to send are set by passing a comma-separated list of
> There is currently a [bug](https://github.com/spf13/viper/issues/380) in Viper, which prevents comma-separated slices to be used when using the environment variable. A workaround is available where we instead put quotes around the environment variable value and replace the commas with spaces, as `WATCHTOWER_NOTIFICATIONS="slack msteams"`
-> If you're a `docker-compose` user, make sure to specify environment variables' values in your `.yml` file without double quotes (`"`).
+> If you're a `docker-compose` user, make sure to specify environment variables' values in your `.yml` file without double quotes (`"`).
>
> This prevents unexpected errors when watchtower starts.
## Settings
- `--notifications-level` (env. `WATCHTOWER_NOTIFICATIONS_LEVEL`): Controls the log level which is used for the notifications. If omitted, the default log level is `info`. Possible values are: `panic`, `fatal`, `error`, `warn`, `info`, `debug` or `trace`.
-- Watchtower will post a notification every time it is started. This behavior [can be changed](https://containrrr.github.io/watchtower/arguments/#without_sending_a_startup_message) with an argument.
+- Watchtower will post a notification every time it is started. This behavior [can be changed](https://containrrr.github.io/watchtower/arguments/#without_sending_a_startup_message) with an argument.
## Available services
@@ -47,7 +46,7 @@ docker run -d \
-e WATCHTOWER_NOTIFICATION_EMAIL_FROM=fromaddress@gmail.com \
-e WATCHTOWER_NOTIFICATION_EMAIL_TO=toaddress@gmail.com \
-e WATCHTOWER_NOTIFICATION_EMAIL_SERVER=smtp.gmail.com \
- -e WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT=587 \
+ -e WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT=587 \
-e WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER=fromaddress@gmail.com \
-e WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD=app_password \
-e WATCHTOWER_NOTIFICATION_EMAIL_DELAY=2 \
@@ -56,19 +55,19 @@ docker run -d \
The previous example assumes, that you already have an SMTP server up and running you can connect to. If you don't or you want to bring up watchtower with your own simple SMTP relay the following `docker-compose.yml` might be a good start for you.
-The following example assumes, that your domain is called `your-domain.com` and that you are going to use a certificate valid for `smtp.your-domain.com`. This hostname has to be used as `WATCHTOWER_NOTIFICATION_EMAIL_SERVER` otherwise the TLS connection is going to fail with `Failed to send notification email` or `connect: connection refused`. We also have to add a network for this setup in order to add an alias to it. If you also want to enable DKIM or other features on the SMTP server, you will find more information at [freinet/postfix-relay](https://hub.docker.com/r/freinet/postfix-relay).
+The following example assumes, that your domain is called `your-domain.com` and that you are going to use a certificate valid for `smtp.your-domain.com`. This hostname has to be used as `WATCHTOWER_NOTIFICATION_EMAIL_SERVER` otherwise the TLS connection is going to fail with `Failed to send notification email` or `connect: connection refused`. We also have to add a network for this setup in order to add an alias to it. If you also want to enable DKIM or other features on the SMTP server, you will find more information at [freinet/postfix-relay](https://hub.docker.com/r/freinet/postfix-relay).
Example including an SMTP relay:
```yaml
---
-version: "3.8"
+version: '3.8'
services:
watchtower:
image: containrrr/watchtower:latest
container_name: watchtower
environment:
- WATCHTOWER_MONITOR_ONLY: "true"
+ WATCHTOWER_MONITOR_ONLY: 'true'
WATCHTOWER_NOTIFICATIONS: email
WATCHTOWER_NOTIFICATION_EMAIL_FROM: from-address@your-domain.com
WATCHTOWER_NOTIFICATION_EMAIL_TO: to-address@your-domain.com
@@ -90,9 +89,9 @@ services:
- 25
environment:
MAILNAME: somename.your-domain.com
- TLS_KEY: "/etc/ssl/domains/your-domain.com/your-domain.com.key"
- TLS_CRT: "/etc/ssl/domains/your-domain.com/your-domain.com.crt"
- TLS_CA: "/etc/ssl/domains/your-domain.com/intermediate.crt"
+ TLS_KEY: '/etc/ssl/domains/your-domain.com/your-domain.com.key'
+ TLS_CRT: '/etc/ssl/domains/your-domain.com/your-domain.com.crt'
+ TLS_CA: '/etc/ssl/domains/your-domain.com/intermediate.crt'
volumes:
- /etc/ssl/domains/your-domain.com/:/etc/ssl/domains/your-domain.com/:ro
networks:
@@ -172,7 +171,7 @@ docker run -d \
`-e WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN` or `--notification-gotify-token` can also reference a file, in which case the contents of the file are used.
-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)
diff --git a/docs/running-multiple-instances.md b/docs/running-multiple-instances.md
index 82cd955..641f4e4 100644
--- a/docs/running-multiple-instances.md
+++ b/docs/running-multiple-instances.md
@@ -8,7 +8,7 @@ To define an instance monitoring scope, use the `--scope` argument or the `WATCH
For example, in a Docker Compose config file:
-```json
+```yaml
version: '3'
services:
diff --git a/docs/stylesheets/theme.css b/docs/stylesheets/theme.css
new file mode 100644
index 0000000..34f507d
--- /dev/null
+++ b/docs/stylesheets/theme.css
@@ -0,0 +1,16 @@
+[data-md-color-scheme="containrrr"] {
+ --md-primary-fg-color: #406170;
+ --md-primary-fg-color--light:#acbfc7;
+ --md-primary-fg-color--dark: #003343;
+ --md-accent-fg-color: #003343;
+ --md-accent-fg-color--transparent: #00334310;
+}
+
+.md-header-nav__button.md-logo {
+ padding: 0;
+}
+
+.md-header-nav__button.md-logo img {
+ width: 1.6rem;
+ height: 1.6rem;
+}
\ No newline at end of file
diff --git a/docs/usage-overview.md b/docs/usage-overview.md
index 2fa8fe7..04178a4 100644
--- a/docs/usage-overview.md
+++ b/docs/usage-overview.md
@@ -39,9 +39,9 @@ docker run -d \
> NOTE: if you mount `config.json` in the manner above, changes from the host system will (generally) not be propagated to the running container. Mounting files into the Docker daemon uses bind mounts, which are based on inodes. Most applications (including `docker login` and `vim`) will not directly edit the file, but instead make a copy and replace the original file, which results in a new inode which in turn *breaks* the bind mount. **As a workaround**, you can create a symlink to your `config.json` file and then mount the symlink in the container. The symlinked file will always have the same inode, which keeps the bind mount intact and will ensure changes to the original file are propagated to the running container (regardless of the inode of the source file!).
-If you mount the config file as described above, be sure to also prepend the URL for the registry when starting up your watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container from a private repo at Docker Hub and monitors it with watchtower. Note the command argument changing the interval to 30s rather than the default 5 minutes.
+If you mount the config file as described above, be sure to also prepend the URL for the registry when starting up your watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container from a private repo at Docker Hub and monitors it with watchtower. Note the command argument changing the interval to 30s rather than the default 24 hours.
-```json
+```yaml
version: "3"
services:
cavo:
@@ -55,4 +55,4 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
- /root/.docker/config.json:/config.json
command: --interval 30
-```
\ No newline at end of file
+```
diff --git a/go.mod b/go.mod
index 300eb31..225915b 100644
--- a/go.mod
+++ b/go.mod
@@ -2,6 +2,8 @@ module github.com/containrrr/watchtower
go 1.12
+replace golang.org/x/sys => golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a
+
require (
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect
github.com/Microsoft/go-winio v0.4.12 // indirect
@@ -15,13 +17,13 @@ require (
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cloudflare/cfssl v0.0.0-20190911221928-1a911ca1b1d6 // indirect
github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 // indirect
- github.com/containrrr/shoutrrr v0.0.0-20200601144753-78bb9685bc2f
+ github.com/containrrr/shoutrrr v0.4.4
github.com/docker/cli v0.0.0-20190327152802-57b27434ea29
github.com/docker/distribution v2.7.1+incompatible
github.com/docker/docker v0.0.0-20190404075923-dbe4a30928d4
github.com/docker/docker-credential-helpers v0.6.1 // indirect
github.com/docker/go v1.5.1-1 // indirect
- github.com/docker/go-connections v0.4.0 // indirect
+ github.com/docker/go-connections v0.4.0
github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82 // indirect
github.com/docker/go-units v0.3.3 // indirect
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
@@ -30,32 +32,33 @@ require (
github.com/gorilla/mux v1.7.0 // indirect
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
github.com/hashicorp/go-version v1.1.0 // indirect
- github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jinzhu/gorm v1.9.11 // indirect
github.com/johntdyer/slack-go v0.0.0-20180213144715-95fac1160b22 // indirect
github.com/johntdyer/slackrus v0.0.0-20180518184837-f7aae3243a07
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
- github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
github.com/lib/pq v1.2.0 // indirect
github.com/miekg/pkcs11 v0.0.0-20190401114359-553cfdd26aaa // indirect
github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c // indirect
- github.com/onsi/ginkgo v1.8.0
- github.com/onsi/gomega v1.5.0
+ github.com/onsi/ginkgo v1.14.2
+ github.com/onsi/gomega v1.10.1
github.com/opencontainers/go-digest v1.0.0-rc1 // indirect
github.com/opencontainers/image-spec v1.0.1 // indirect
- github.com/opencontainers/runc v0.1.1
- github.com/pkg/errors v0.8.1 // indirect
+ github.com/opencontainers/runc v0.1.1 // indirect
+ github.com/prometheus/client_golang v0.9.3
github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967
- github.com/sirupsen/logrus v1.4.1
- github.com/spf13/cobra v0.0.3
- github.com/spf13/pflag v1.0.3
- github.com/spf13/viper v1.4.0
- github.com/stretchr/testify v1.3.0
+ github.com/sirupsen/logrus v1.4.2
+ github.com/spf13/cobra v0.0.7
+ github.com/spf13/pflag v1.0.5
+ github.com/spf13/viper v1.6.3
+ github.com/stretchr/testify v1.4.0
github.com/theupdateframework/notary v0.6.1 // indirect
github.com/zmap/zlint v1.0.2 // indirect
- golang.org/x/net v0.0.0-20190522155817-f3200d17e092
+ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
+ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7
+ golang.org/x/text v0.3.4 // indirect
gopkg.in/dancannon/gorethink.v3 v3.0.5 // indirect
gopkg.in/fatih/pool.v2 v2.0.0 // indirect
gopkg.in/gorethink/gorethink.v3 v3.0.5 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
gotest.tools v2.2.0+incompatible // indirect
)
diff --git a/go.sum b/go.sum
index 7285415..e1d7071 100644
--- a/go.sum
+++ b/go.sum
@@ -15,6 +15,7 @@ github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWX
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI=
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0=
+github.com/agnivade/wasmbrowsertest v0.3.1/go.mod h1:zQt6ZTdl338xxRaMW395qccVE2eQm0SjC/SDz0mPWQI=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
@@ -38,18 +39,26 @@ github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywR
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/chromedp/cdproto v0.0.0-20190614062957-d6d2f92b486d/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw=
+github.com/chromedp/cdproto v0.0.0-20190621002710-8cbd498dd7a0/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw=
+github.com/chromedp/cdproto v0.0.0-20190812224334-39ef923dcb8d/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0=
+github.com/chromedp/cdproto v0.0.0-20190926234355-1b4886c6fad6/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0=
+github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM=
+github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/cfssl v0.0.0-20190911221928-1a911ca1b1d6 h1:A7RURps5t4yDU0zktlgrE3Bdmjfv35nVs+xJdoWgIgY=
github.com/cloudflare/cfssl v0.0.0-20190911221928-1a911ca1b1d6/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 h1:4BX8f882bXEDKfWIf0wa8HRvpnBoPszJJXL+TVbBw4M=
github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
-github.com/containrrr/shoutrrr v0.0.0-20200601144753-78bb9685bc2f h1:Ln4yl+CYjrapeTEzMJQpgBwLjruKHcMosWFB/d1M4RQ=
-github.com/containrrr/shoutrrr v0.0.0-20200601144753-78bb9685bc2f/go.mod h1:eotQeC9bHbsf9eMUnXOU/y5bskegseWNB4PwmxRO7Wc=
+github.com/containrrr/shoutrrr v0.4.4 h1:vHZ4E/76pKVY+Jyn/qhBz3X540Bn8NI5ppPHK4PyILY=
+github.com/containrrr/shoutrrr v0.4.4/go.mod h1:zqL2BvfC1W4FujrT4b3/ZCLxvD+uoeEpBL7rg9Dqpbg=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
+github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -63,7 +72,6 @@ github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BU
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v0.0.0-20190404075923-dbe4a30928d4 h1:34LfsqlE2kEvmGP9qbRoPvOWkmluYGzmlvWVTzwvT0A=
github.com/docker/docker v0.0.0-20190404075923-dbe4a30928d4/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
-github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo=
github.com/docker/docker-credential-helpers v0.6.1 h1:Dq4iIfcM7cNtddhLVWe9h4QDjsi4OER3Z8voPu/I52g=
github.com/docker/docker-credential-helpers v0.6.1/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=
github.com/docker/go v1.5.1-1 h1:hr4w35acWBPhGBXlzPoHpmZ/ygPjnmFVxGxxGnMyP7k=
@@ -79,19 +87,45 @@ github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNE
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
+github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
+github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
+github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
+github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
+github.com/go-interpreter/wagon v0.5.1-0.20190713202023-55a163980b6c/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc=
+github.com/go-interpreter/wagon v0.6.0/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
+github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
+github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
+github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
+github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
+github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
+github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
+github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
+github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
+github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
+github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -106,6 +140,16 @@ github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
@@ -115,14 +159,27 @@ github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.1.5 h1:kxhtnfFVi+rYdOALN0B3k9UT86zVJKfBimRaciULW4I=
+github.com/google/uuid v1.1.5/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U=
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
+github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
+github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
@@ -150,12 +207,20 @@ github.com/johntdyer/slack-go v0.0.0-20180213144715-95fac1160b22/go.mod h1:u0Jo4
github.com/johntdyer/slackrus v0.0.0-20180518184837-f7aae3243a07 h1:+kBG/8rjCa6vxJZbUjAiE4MQmBEBYc8nLEb51frnvBY=
github.com/johntdyer/slackrus v0.0.0-20180518184837-f7aae3243a07/go.mod h1:j1kV/8f3jowErEq4XyeypkCdvg5EeHkf0YCKCcq5Ybo=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
+github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/klauspost/compress v1.11.7 h1:0hzRabrMN4tSTvMfnL3SCv1ZGeAP23ynzodBgaHeMeg=
+github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -165,35 +230,66 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
+github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
+github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
+github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
+github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q=
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/pkcs11 v0.0.0-20190401114359-553cfdd26aaa h1:gOXc1BXmFuxWYmTfoK51YJR7srco3CwbsVHgr+8Y4r0=
github.com/miekg/pkcs11 v0.0.0-20190401114359-553cfdd26aaa/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.2.2 h1:dxe5oCinTXiTIcfgmZecdCzPmAJKd46KsCWc35r0TV4=
+github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
+github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE=
github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/nxadm/tail v1.4.6 h1:11TGpSHY7Esh/i/qnq02Jo5oVrI1Gue8Slbq0ujPZFQ=
+github.com/nxadm/tail v1.4.6/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w=
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.14.2 h1:8mVmC9kjFFmA8H4pKMUhcblgifdkOIXPvbhN1T36q1M=
+github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
-github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo=
-github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ=
github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
@@ -204,6 +300,8 @@ github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59P
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
+github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI=
+github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
@@ -232,34 +330,61 @@ github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqn
github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 h1:x7xEyJDP7Hv3LVgvWhzioQqbC/KtuUhTigKlH/8ehhE=
github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
+github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k=
-github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
+github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
+github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8=
-github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
+github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
+github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/spf13/cobra v0.0.7 h1:FfTH+vuMXOas8jmfb5/M7dzEYx7LpcLb7a0LPe34uOU=
+github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
+github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
+github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
+github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
+github.com/spf13/viper v1.6.3 h1:pDDu1OyEDTKzpJwdq4TiuLyMsUgRa/BT5cn5O62NoHs=
+github.com/spf13/viper v1.6.3/go.mod h1:jUMtyi0/lB5yZH/FjyGAoH7IMNrIhlBf6pXZmbMDvzw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
+github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/theupdateframework/notary v0.6.1 h1:7wshjstgS9x9F5LuB1L5mBI2xNMObWqjz+cjWoom6l0=
github.com/theupdateframework/notary v0.6.1/go.mod h1:MOfgIfmox8s7/7fduvB2xyPPMJCrjRLRizA8OFwpnKY=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
+github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
+github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
+github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
+github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
+github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/weppos/publicsuffix-go v0.4.0 h1:YSnfg3V65LcCFKtIGKGoBhkyKolEd0hlipcXaOjdnQw=
github.com/weppos/publicsuffix-go v0.4.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
@@ -270,24 +395,30 @@ github.com/zmap/zcrypto v0.0.0-20190729165852-9051775e6a2e h1:mvOa4+/DXStR4ZXOks
github.com/zmap/zcrypto v0.0.0-20190729165852-9051775e6a2e/go.mod h1:w7kd3qXHh8FNaczNjslXqvFQiv5mMWRXlL9klTUAHc8=
github.com/zmap/zlint v1.0.2 h1:07+WuC/prlXVlWa1CJx2lCpuCd8biIeBAVnwTN2CPaA=
github.com/zmap/zlint v1.0.2/go.mod h1:29UiAJNsiVdvTBFCJW8e3q6dcDbOoPkhMgttOSCIMMY=
+go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
+golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -297,41 +428,47 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092 h1:4QSRKanuywn15aTZvI/mIDEgPQpswuFndXpOj3rKEco=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20191116160921-f9c825593386 h1:ktbWvQrW08Txdxno1PiDpSxPXG6ndGsfnJjRRtkM0LQ=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 h1:AeiKBIuRw3UomYXSbLy0Mc2dDLfdtbT/IVn4keq83P0=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ=
+golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2 h1:z99zHgr7hKfrUcX/KsoJk5FJfjTceCKIp96+biqP4To=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
+golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
@@ -344,6 +481,14 @@ google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.21.0 h1:G+97AoqBnmZIT91cLG/EkCoK9NSelj64P8bOHHNmGn0=
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -355,8 +500,12 @@ gopkg.in/fatih/pool.v2 v2.0.0 h1:xIFeWtxifuQJGk/IEPKsTduEKcKvPmhoiVDGpC40nKg=
gopkg.in/fatih/pool.v2 v2.0.0/go.mod h1:8xVGeu1/2jr2wm5V9SPuMht2H5AEmf5aFMGSQixtjTY=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/gorethink/gorethink.v3 v3.0.5 h1:e2Uc/Xe+hpcVQFsj6MuHlYog3r0JYpnTzwDj/y2O4MU=
gopkg.in/gorethink/gorethink.v3 v3.0.5/go.mod h1:+3yIIHJUGMBK+wyPH+iN5TP+88ikFDfZdqTlK3Y9q8I=
+gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.55.0 h1:E8yzL5unfpW3M6fz/eB7Cb5MQAYSZ7GKo4Qth+N2sgQ=
+gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
@@ -364,10 +513,21 @@ gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bl
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gosrc.io/xmpp v0.1.1 h1:iMtE9W3fx254+4E6rI34AOPJDqWvpfQR6EYaVMzhJ4s=
-gosrc.io/xmpp v0.1.1/go.mod h1:4JgaXzw4MnEv2sGltONtK3GMhj+h9gpQ7cO8nwbFJLU=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gosrc.io/xmpp v0.5.1 h1:Rgrm5s2rt+npGggJH3HakQxQXR8ZZz3+QRzakRQqaq4=
+gosrc.io/xmpp v0.5.1/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
+gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
+gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+mvdan.cc/sh v2.6.4+incompatible/go.mod h1:IeeQbZq+x2SUGBensq/jge5lLQbS3XT2ktyp3wrt4x8=
+nhooyr.io/websocket v1.6.5/go.mod h1:F259lAzPRAH0htX2y3ehpJe09ih1aSHN7udWki1defY=
+nhooyr.io/websocket v1.8.6 h1:s+C3xAMLwGmlI31Nyn/eAehUlZPwfYZu2JXM621Q5/k=
+nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
diff --git a/goreleaser.yml b/goreleaser.yml
index 927cdcd..3f5e95d 100644
--- a/goreleaser.yml
+++ b/goreleaser.yml
@@ -9,23 +9,28 @@ build:
- 386
- arm
- arm64
-archive:
- name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}"
- format: tar.gz
- replacements:
- arm: armhf
- arm64: arm64v8
- amd64: amd64
- 386: 386
- darwin: macOS
- linux: linux
- format_overrides:
- - goos: windows
- format: zip
- files:
- - LICENSE.md
+ ldflags:
+ - -s -w -X github.com/containrrr/watchtower/cmd.version={{.Version}}
+archives:
+ -
+ name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}"
+ format: tar.gz
+ replacements:
+ arm: armhf
+ arm64: arm64v8
+ amd64: amd64
+ 386: 386
+ darwin: macOS
+ linux: linux
+ format_overrides:
+ - goos: windows
+ format: zip
+ files:
+ - LICENSE.md
dockers:
-
+ use_buildx: true
+ build_flag_templates: [ "--platform=linux/amd64" ]
goos: linux
goarch: amd64
goarm: ''
@@ -33,9 +38,13 @@ dockers:
image_templates:
- containrrr/watchtower:amd64-{{ .Version }}
- containrrr/watchtower:amd64-latest
+ - ghcr.io/containrrr/watchtower:amd64-{{ .Version }}
+ - ghcr.io/containrrr/watchtower:amd64-latest
binaries:
- watchtower
- -
+ -
+ use_buildx: true
+ build_flag_templates: [ "--platform=linux/386" ]
goos: linux
goarch: 386
goarm: ''
@@ -43,9 +52,13 @@ dockers:
image_templates:
- containrrr/watchtower:i386-{{ .Version }}
- containrrr/watchtower:i386-latest
+ - ghcr.io/containrrr/watchtower:i386-{{ .Version }}
+ - ghcr.io/containrrr/watchtower:i386-latest
binaries:
- watchtower
- -
+ -
+ use_buildx: true
+ build_flag_templates: [ "--platform=linux/arm/v6" ]
goos: linux
goarch: arm
goarm: 6
@@ -53,9 +66,13 @@ dockers:
image_templates:
- containrrr/watchtower:armhf-{{ .Version }}
- containrrr/watchtower:armhf-latest
+ - ghcr.io/containrrr/watchtower:armhf-{{ .Version }}
+ - ghcr.io/containrrr/watchtower:armhf-latest
binaries:
- watchtower
- -
+ -
+ use_buildx: true
+ build_flag_templates: [ "--platform=linux/arm64/v8" ]
goos: linux
goarch: arm64
goarm: ''
@@ -63,5 +80,7 @@ dockers:
image_templates:
- containrrr/watchtower:arm64v8-{{ .Version }}
- containrrr/watchtower:arm64v8-latest
+ - ghcr.io/containrrr/watchtower:arm64v8-{{ .Version }}
+ - ghcr.io/containrrr/watchtower:arm64v8-latest
binaries:
- watchtower
diff --git a/grafana/dashboards/dashboard.json b/grafana/dashboards/dashboard.json
new file mode 100644
index 0000000..998485b
--- /dev/null
+++ b/grafana/dashboards/dashboard.json
@@ -0,0 +1,293 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": "-- Grafana --",
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "gnetId": null,
+ "graphTooltip": 0,
+ "id": 1,
+ "links": [],
+ "panels": [
+ {
+ "datasource": "Prometheus",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {},
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 1,
+ "x": 0,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "7.3.6",
+ "targets": [
+ {
+ "expr": "watchtower_scans_total",
+ "interval": "",
+ "legendFormat": "",
+ "refId": "A"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Total Scans",
+ "type": "stat"
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": null,
+ "fieldConfig": {
+ "defaults": {
+ "custom": {}
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "watchtower_containers_scanned{instance=\"watchtower:8080\", job=\"watchtower\"}"
+ },
+ "properties": [
+ {
+ "id": "displayName",
+ "value": "Scanned"
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "watchtower_containers_failed{instance=\"watchtower:8080\", job=\"watchtower\"}"
+ },
+ "properties": [
+ {
+ "id": "displayName",
+ "value": "Faled"
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "watchtower_containers_updated{instance=\"watchtower:8080\", job=\"watchtower\"}"
+ },
+ "properties": [
+ {
+ "id": "displayName",
+ "value": "Updated"
+ }
+ ]
+ }
+ ]
+ },
+ "fill": 1,
+ "fillGradient": 0,
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 1,
+ "y": 0
+ },
+ "hiddenSeries": false,
+ "id": 5,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "nullPointMode": "null as zero",
+ "options": {
+ "alertThreshold": true
+ },
+ "percentage": false,
+ "pluginVersion": "7.3.6",
+ "pointradius": 2,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "expr": "watchtower_containers_scanned",
+ "interval": "",
+ "legendFormat": "",
+ "refId": "A"
+ },
+ {
+ "expr": "watchtower_containers_failed",
+ "interval": "",
+ "legendFormat": "",
+ "refId": "B"
+ },
+ {
+ "expr": "watchtower_containers_updated",
+ "interval": "",
+ "legendFormat": "",
+ "refId": "C"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [],
+ "timeShift": null,
+ "title": "Container Updates",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "decimals": 0,
+ "format": "short",
+ "label": "",
+ "logBase": 1,
+ "max": null,
+ "min": "0",
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "datasource": "Prometheus",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {},
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 1,
+ "x": 0,
+ "y": 4
+ },
+ "id": 3,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "7.3.6",
+ "targets": [
+ {
+ "expr": "watchtower_scans_skipped",
+ "interval": "",
+ "legendFormat": "",
+ "refId": "A"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Skipped Scans",
+ "type": "stat"
+ }
+ ],
+ "refresh": false,
+ "schemaVersion": 26,
+ "style": "dark",
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-1h",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "",
+ "title": "Watchtower",
+ "uid": "d7bdoT-Gz",
+ "version": 1
+}
\ No newline at end of file
diff --git a/grafana/dashboards/dashboard.yml b/grafana/dashboards/dashboard.yml
new file mode 100644
index 0000000..9f7232c
--- /dev/null
+++ b/grafana/dashboards/dashboard.yml
@@ -0,0 +1,11 @@
+apiVersion: 1
+
+providers:
+ - name: 'Prometheus'
+ orgId: 1
+ folder: ''
+ type: file
+ disableDeletion: false
+ editable: true
+ options:
+ path: /etc/grafana/provisioning/dashboards
\ No newline at end of file
diff --git a/grafana/datasources/datasource.yml b/grafana/datasources/datasource.yml
new file mode 100644
index 0000000..8049912
--- /dev/null
+++ b/grafana/datasources/datasource.yml
@@ -0,0 +1,8 @@
+apiVersion: 1
+
+datasources:
+ - name: Prometheus
+ type: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
\ No newline at end of file
diff --git a/internal/actions/actions_suite_test.go b/internal/actions/actions_suite_test.go
index 4d8c0cd..b13cf39 100644
--- a/internal/actions/actions_suite_test.go
+++ b/internal/actions/actions_suite_test.go
@@ -1,10 +1,11 @@
package actions_test
import (
- "github.com/containrrr/watchtower/internal/actions"
"testing"
"time"
+ "github.com/containrrr/watchtower/internal/actions"
+
"github.com/containrrr/watchtower/pkg/container"
"github.com/containrrr/watchtower/pkg/container/mocks"
diff --git a/internal/actions/check.go b/internal/actions/check.go
index 56a9fc4..436931f 100644
--- a/internal/actions/check.go
+++ b/internal/actions/check.go
@@ -1,32 +1,48 @@
package actions
import (
- "errors"
"fmt"
+ "github.com/containrrr/watchtower/pkg/types"
"sort"
- "strings"
"time"
"github.com/containrrr/watchtower/pkg/filters"
"github.com/containrrr/watchtower/pkg/sorter"
- "github.com/opencontainers/runc/Godeps/_workspace/src/github.com/Sirupsen/logrus"
-
log "github.com/sirupsen/logrus"
"github.com/containrrr/watchtower/pkg/container"
)
+// CheckForSanity makes sure everything is sane before starting
+func CheckForSanity(client container.Client, filter types.Filter, rollingRestarts bool) error {
+ log.Debug("Making sure everything is sane before starting")
+
+ if rollingRestarts {
+ containers, err := client.ListContainers(filter)
+ if err != nil {
+ return err
+ }
+ for _, c := range containers {
+ if len(c.Links()) > 0 {
+ return fmt.Errorf(
+ "%q is depending on at least one other container. This is not compatible with rolling restarts",
+ c.Name(),
+ )
+ }
+ }
+ }
+ return nil
+}
+
// CheckForMultipleWatchtowerInstances will ensure that there are not multiple instances of the
// watchtower running simultaneously. If multiple watchtower containers are detected, this function
// will stop and remove all but the most recently started container. This behaviour can be bypassed
// if a scope UID is defined.
func CheckForMultipleWatchtowerInstances(client container.Client, cleanup bool, scope string) error {
- awaitDockerClient()
containers, err := client.ListContainers(filters.FilterByScope(scope, filters.WatchtowerContainersFilter))
if err != nil {
- log.Fatal(err)
return err
}
@@ -40,7 +56,6 @@ func CheckForMultipleWatchtowerInstances(client container.Client, cleanup bool,
}
func cleanupExcessWatchtowers(containers []container.Container, client container.Client, cleanup bool) error {
- var cleanupErrors int
var stopErrors int
sort.Sort(sorter.ByCreated(containers))
@@ -49,40 +64,21 @@ func cleanupExcessWatchtowers(containers []container.Container, client container
for _, c := range allContainersExceptLast {
if err := client.StopContainer(c, 10*time.Minute); err != nil {
// logging the original here as we're just returning a count
- logrus.Error(err)
+ log.WithError(err).Error("Could not stop a previous watchtower instance.")
stopErrors++
continue
}
if cleanup {
if err := client.RemoveImageByID(c.ImageID()); err != nil {
- // logging the original here as we're just returning a count
- logrus.Error(err)
- cleanupErrors++
+ log.WithError(err).Warning("Could not cleanup watchtower images, possibly because of other watchtowers instances in other scopes.")
}
}
}
- return createErrorIfAnyHaveOccurred(stopErrors, cleanupErrors)
-}
-
-func createErrorIfAnyHaveOccurred(c int, i int) error {
- if c == 0 && i == 0 {
- return nil
+ if stopErrors > 0 {
+ return fmt.Errorf("%d errors while stopping watchtower containers", stopErrors)
}
- var output strings.Builder
-
- if c > 0 {
- output.WriteString(fmt.Sprintf("%d errors while stopping containers", c))
- }
- if i > 0 {
- output.WriteString(fmt.Sprintf("%d errors while cleaning up images", c))
- }
- return errors.New(output.String())
-}
-
-func awaitDockerClient() {
- log.Debug("Sleeping for a second to ensure the docker api client has been properly initialized.")
- time.Sleep(1 * time.Second)
+ return nil
}
diff --git a/internal/actions/mocks/client.go b/internal/actions/mocks/client.go
index fb49249..2a5c4f1 100644
--- a/internal/actions/mocks/client.go
+++ b/internal/actions/mocks/client.go
@@ -86,3 +86,8 @@ func (client MockClient) ExecuteCommand(containerID string, command string, time
func (client MockClient) IsContainerStale(c container.Container) (bool, error) {
return true, nil
}
+
+// WarnOnHeadPullFailed is always true for the mock client
+func (client MockClient) WarnOnHeadPullFailed(c container.Container) bool {
+ return true
+}
diff --git a/internal/actions/mocks/container.go b/internal/actions/mocks/container.go
index 92dd0b8..d322904 100644
--- a/internal/actions/mocks/container.go
+++ b/internal/actions/mocks/container.go
@@ -4,6 +4,7 @@ import (
"github.com/containrrr/watchtower/pkg/container"
"github.com/docker/docker/api/types"
container2 "github.com/docker/docker/api/types/container"
+ "github.com/docker/go-connections/nat"
"time"
)
@@ -15,9 +16,14 @@ func CreateMockContainer(id string, name string, image string, created time.Time
Image: image,
Name: name,
Created: created.String(),
+ HostConfig: &container2.HostConfig{
+ PortBindings: map[nat.Port][]nat.PortBinding{},
+ },
},
Config: &container2.Config{
- Labels: make(map[string]string),
+ Image: image,
+ Labels: make(map[string]string),
+ ExposedPorts: map[nat.Port]struct{}{},
},
}
dependencyString := ""
@@ -33,10 +39,40 @@ func CreateMockContainer(id string, name string, image string, created time.Time
&content,
&types.ImageInspect{
ID: image,
+ RepoDigests: []string{
+ image,
+ },
},
)
}
+// CreateMockContainerWithImageInfo should only be used for testing
+func CreateMockContainerWithImageInfo(id string, name string, image string, created time.Time, imageInfo types.ImageInspect) container.Container {
+ content := types.ContainerJSON{
+ ContainerJSONBase: &types.ContainerJSONBase{
+ ID: id,
+ Image: image,
+ Name: name,
+ Created: created.String(),
+ },
+ Config: &container2.Config{
+ Image: image,
+ Labels: make(map[string]string),
+ },
+ }
+ return *container.NewContainer(
+ &content,
+ &imageInfo,
+ )
+}
+
+// CreateMockContainerWithDigest should only be used for testing
+func CreateMockContainerWithDigest(id string, name string, image string, created time.Time, digest string) container.Container {
+ c := CreateMockContainer(id, name, image, created)
+ c.ImageInfo().RepoDigests = []string{digest}
+ return c
+}
+
// CreateMockContainerWithConfig creates a container substitute valid for testing
func CreateMockContainerWithConfig(id string, name string, image string, created time.Time, config *container2.Config) container.Container {
content := types.ContainerJSON{
diff --git a/internal/actions/update.go b/internal/actions/update.go
index 2ef453c..66a28f1 100644
--- a/internal/actions/update.go
+++ b/internal/actions/update.go
@@ -1,174 +1,174 @@
package actions
import (
- "errors"
"github.com/containrrr/watchtower/internal/util"
"github.com/containrrr/watchtower/pkg/container"
"github.com/containrrr/watchtower/pkg/lifecycle"
+ metrics2 "github.com/containrrr/watchtower/pkg/metrics"
"github.com/containrrr/watchtower/pkg/sorter"
"github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
)
-// CreateUndirectedLinks creates a map of undirected links
-// Key: Name of a container
-// Value: List of containers that are linked to the container
-// i.e if Container A depends on B, undirectedNodes['A'] will initially contain B.
-// This function adds 'A' into undirectedNodes['B'] to make the link undirected.
-func CreateUndirectedLinks(containers []container.Container) map[string][]string {
+// Update looks at the running Docker containers to see if any of the images
+// used to start those containers have been updated. If a change is detected in
+// any of the images, the associated containers are stopped and restarted with
+// the new image.
+func Update(client container.Client, params types.UpdateParams) (*metrics2.Metric, error) {
+ log.Debug("Checking containers for updated images")
+ metric := &metrics2.Metric{}
+ staleCount := 0
- undirectedNodes := make(map[string][]string)
- for i:= 0; i < len(containers); i++ {
- undirectedNodes[containers[i].Name()] = containers[i].Links()
+ if params.LifecycleHooks {
+ lifecycle.ExecutePreChecks(client, params)
}
- for i:= 0; i< len(containers); i++ {
- for j:=0; j < len(containers[i].Links()); j++ {
- undirectedNodes[containers[i].Links()[j]] = append(undirectedNodes[containers[i].Links()[j]], containers[i].Name())
- }
- }
-
- return undirectedNodes;
-}
-
-// PrepareContainerList prepares a dependency sorted list of list of containers
-// Each list inside the outer list contains containers that are related by links
-// This method checks for staleness, checks dependencies, sorts the containers and returns the final
-// [][]container.Container
-func PrepareContainerList(client container.Client, params types.UpdateParams) ([]container.Container, error) {
-
containers, err := client.ListContainers(params.Filter)
if err != nil {
return nil, err
}
+ staleCheckFailed := 0
+
for i, targetContainer := range containers {
stale, err := client.IsContainerStale(targetContainer)
- if stale && !params.NoRestart && !params.MonitorOnly && !targetContainer.IsMonitorOnly() && !targetContainer.HasImageInfo() {
- err = errors.New("no available image info")
+ shouldUpdate := stale && !params.NoRestart && !params.MonitorOnly && !targetContainer.IsMonitorOnly()
+ if err == nil && shouldUpdate {
+ // Check to make sure we have all the necessary information for recreating the container
+ err = targetContainer.VerifyConfiguration()
+ // If the image information is incomplete and trace logging is enabled, log it for further diagnosis
+ if err != nil && log.IsLevelEnabled(log.TraceLevel) {
+ imageInfo := targetContainer.ImageInfo()
+ log.Tracef("Image info: %#v", imageInfo)
+ log.Tracef("Container info: %#v", targetContainer.ContainerInfo())
+ if imageInfo != nil {
+ log.Tracef("Image config: %#v", imageInfo.Config)
+ }
+ }
}
+
if err != nil {
- log.Infof("Unable to update container %q: %v. Proceeding to next.", containers[i].Name(), err)
+ log.Infof("Unable to update container %q: %v. Proceeding to next.", targetContainer.Name(), err)
stale = false
+ staleCheckFailed++
+ metric.Failed++
}
containers[i].Stale = stale
+
+ if stale {
+ staleCount++
+ }
+ }
+
+ containers, err = sorter.SortByDependencies(containers)
+
+ metric.Scanned = len(containers)
+ if err != nil {
+ return nil, err
}
checkDependencies(containers)
- return containers, nil
-}
-
-// Update looks at the running Docker containers to see if any of the images
-// used to start those containers have been updated. If a change is detected in
-// any of the images, the associated containers are stopped and restarted with
-// the new image.
-func Update(client container.Client, params types.UpdateParams) error {
- log.Debug("Checking containers for updated images")
-
- if params.LifecycleHooks {
- lifecycle.ExecutePreChecks(client, params)
- }
-
- containers, err := PrepareContainerList(client, params)
- if err != nil {
- return err
- }
-
- containersToUpdate := []container.Container{}
+ var containersToUpdate []container.Container
if !params.MonitorOnly {
- for i := 0; i < len(containers); i++ {
- if !containers[i].IsMonitorOnly() {
- containersToUpdate = append(containersToUpdate, containers[i])
+ for _, c := range containers {
+ if !c.IsMonitorOnly() {
+ containersToUpdate = append(containersToUpdate, c)
}
}
}
- //shared map for independent and linked update
- imageIDs := make(map[string]bool)
-
if params.RollingRestart {
- performRollingRestart(containersToUpdate, client, params)
+ metric.Failed += performRollingRestart(containersToUpdate, client, params)
} else {
- var dependencySortedGraphs [][]container.Container
-
- undirectedNodes := CreateUndirectedLinks(containersToUpdate)
- dependencySortedGraphs, err := sorter.SortByDependencies(containersToUpdate,undirectedNodes)
-
- if err != nil {
- return err
- }
-
- //Use ordered start and stop for each independent set of containers
- for _, dependencyGraph:= range dependencySortedGraphs {
- stopContainersInReversedOrder(dependencyGraph, client, params)
- restartContainersInSortedOrder(dependencyGraph, client, params, imageIDs)
- }
-
- //clean up after containers updated
- if params.Cleanup {
- cleanupImages(client,imageIDs)
- }
+ metric.Failed += stopContainersInReversedOrder(containersToUpdate, client, params)
+ metric.Failed += restartContainersInSortedOrder(containersToUpdate, client, params)
}
+ metric.Updated = staleCount - (metric.Failed - staleCheckFailed)
+
if params.LifecycleHooks {
lifecycle.ExecutePostChecks(client, params)
}
-
- return nil
+ return metric, nil
}
-func performRollingRestart(containers []container.Container, client container.Client, params types.UpdateParams) {
+func performRollingRestart(containers []container.Container, client container.Client, params types.UpdateParams) int {
cleanupImageIDs := make(map[string]bool)
+ failed := 0
for i := len(containers) - 1; i >= 0; i-- {
- if containers[i].Stale {
- stopStaleContainer(containers[i], client, params)
- restartStaleContainer(containers[i], client, params)
+ if containers[i].ToRestart() {
+ if err := stopStaleContainer(containers[i], client, params); err != nil {
+ failed++
+ }
+ if err := restartStaleContainer(containers[i], client, params); err != nil {
+ failed++
+ }
+ cleanupImageIDs[containers[i].ImageID()] = true
}
}
if params.Cleanup {
cleanupImages(client, cleanupImageIDs)
}
+ return failed
}
-func stopContainersInReversedOrder(containers []container.Container, client container.Client, params types.UpdateParams) {
+func stopContainersInReversedOrder(containers []container.Container, client container.Client, params types.UpdateParams) int {
+ failed := 0
for i := len(containers) - 1; i >= 0; i-- {
- stopStaleContainer(containers[i], client, params)
+ if err := stopStaleContainer(containers[i], client, params); err != nil {
+ failed++
+ }
}
+ return failed
}
-func stopStaleContainer(container container.Container, client container.Client, params types.UpdateParams) {
+func stopStaleContainer(container container.Container, client container.Client, params types.UpdateParams) error {
if container.IsWatchtower() {
log.Debugf("This is the watchtower container %s", container.Name())
- return
+ return nil
}
- if !container.Stale {
- return
+ if !container.ToRestart() {
+ return nil
}
if params.LifecycleHooks {
if err := lifecycle.ExecutePreUpdateCommand(client, container); err != nil {
log.Error(err)
log.Info("Skipping container as the pre-update command failed")
- return
+ return err
}
}
if err := client.StopContainer(container, params.Timeout); err != nil {
log.Error(err)
+ return err
}
+ return nil
}
-func restartContainersInSortedOrder(containers []container.Container, client container.Client, params types.UpdateParams, imageIDs map[string]bool) {
- for _, container := range containers {
- if !container.Stale {
+func restartContainersInSortedOrder(containers []container.Container, client container.Client, params types.UpdateParams) int {
+ imageIDs := make(map[string]bool)
+
+ failed := 0
+
+ for _, c := range containers {
+ if !c.ToRestart() {
continue
}
- restartStaleContainer(container, client, params)
- imageIDs[container.ImageID()] = true
+ if err := restartStaleContainer(c, client, params); err != nil {
+ failed++
+ }
+ imageIDs[c.ImageID()] = true
}
+
+ if params.Cleanup {
+ cleanupImages(client, imageIDs)
+ }
+
+ return failed
}
func cleanupImages(client container.Client, imageIDs map[string]bool) {
@@ -179,7 +179,7 @@ func cleanupImages(client container.Client, imageIDs map[string]bool) {
}
}
-func restartStaleContainer(container container.Container, client container.Client, params types.UpdateParams) {
+func restartStaleContainer(container container.Container, client container.Client, params types.UpdateParams) error {
// Since we can't shutdown a watchtower container immediately, we need to
// start the new one while the old one is still running. This prevents us
// from re-using the same container name so we first rename the current
@@ -187,34 +187,39 @@ func restartStaleContainer(container container.Container, client container.Clien
if container.IsWatchtower() {
if err := client.RenameContainer(container, util.RandName()); err != nil {
log.Error(err)
- return
+ return nil
}
}
if !params.NoRestart {
if newContainerID, err := client.StartContainer(container); err != nil {
log.Error(err)
- } else if container.Stale && params.LifecycleHooks {
+ return err
+ } else if container.ToRestart() && params.LifecycleHooks {
lifecycle.ExecutePostUpdateCommand(client, newContainerID)
}
}
+ return nil
}
func checkDependencies(containers []container.Container) {
- for i, parent := range containers {
- if parent.ToRestart() {
+ for _, c := range containers {
+ if c.ToRestart() {
continue
}
LinkLoop:
- for _, linkName := range parent.Links() {
- for _, child := range containers {
- if child.Name() == linkName && child.ToRestart() {
- containers[i].Linked = true
+ for _, linkName := range c.Links() {
+ for _, candidate := range containers {
+ if candidate.Name() != linkName {
+ continue
+ }
+ if candidate.ToRestart() {
+ c.LinkedToRestarting = true
break LinkLoop
}
}
}
}
-}
\ No newline at end of file
+}
diff --git a/internal/actions/update_test.go b/internal/actions/update_test.go
index 6e1947a..515162f 100644
--- a/internal/actions/update_test.go
+++ b/internal/actions/update_test.go
@@ -62,7 +62,7 @@ var _ = Describe("the update action", func() {
When("there are multiple containers using the same image", func() {
It("should only try to remove the image once", func() {
- err := actions.Update(client, types.UpdateParams{Cleanup: true})
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
})
@@ -80,7 +80,7 @@ var _ = Describe("the update action", func() {
nil,
),
)
- err := actions.Update(client, types.UpdateParams{Cleanup: true})
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(2))
})
@@ -230,6 +230,14 @@ var _ = Describe("the update action", func() {
Expect(client.TestData.RestartOrder).To(Equal(ExpectedRestartOutput))
})
})
+ When("performing a rolling restart update", func() {
+ It("should try to remove the image once", func() {
+
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, RollingRestart: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+ })
+ })
})
When("watchtower has been instructed to monitor only", func() {
@@ -264,7 +272,7 @@ var _ = Describe("the update action", func() {
})
It("should not update those containers", func() {
- err := actions.Update(client, types.UpdateParams{Cleanup: true})
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
})
@@ -296,7 +304,7 @@ var _ = Describe("the update action", func() {
})
It("should not update any containers", func() {
- err := actions.Update(client, types.UpdateParams{MonitorOnly: true})
+ _, err := actions.Update(client, types.UpdateParams{MonitorOnly: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
})
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index c7c98b1..80a5a7c 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -105,6 +105,12 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
viper.GetBool("WATCHTOWER_RUN_ONCE"),
"Run once now and exit")
+ flags.BoolP(
+ "include-restarting",
+ "",
+ viper.GetBool("WATCHTOWER_INCLUDE_RESTARTING"),
+ "Will also include restarting containers")
+
flags.BoolP(
"include-stopped",
"S",
@@ -130,10 +136,15 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
"Restart containers one at a time")
flags.BoolP(
- "http-api",
+ "http-api-update",
"",
- viper.GetBool("WATCHTOWER_HTTP_API"),
+ viper.GetBool("WATCHTOWER_HTTP_API_UPDATE"),
"Runs Watchtower in HTTP API mode, so that image updates must to be triggered by a request")
+ flags.BoolP(
+ "http-api-metrics",
+ "",
+ viper.GetBool("WATCHTOWER_HTTP_API_METRICS"),
+ "Runs Watchtower with the Prometheus metrics API enabled")
flags.StringP(
"http-api-token",
@@ -296,14 +307,21 @@ Should only be used for testing.`)
"",
viper.GetStringSlice("WATCHTOWER_NOTIFICATION_URL"),
"The shoutrrr URL to send notifications to")
+
+ flags.String(
+ "warn-on-head-failure",
+ viper.GetString("WATCHTOWER_WARN_ON_HEAD_FAILURE"),
+ "When to warn about HEAD pull requests failing. Possible values: always, auto or never")
+
}
// SetDefaults provides default values for environment variables
func SetDefaults() {
+ day := (time.Hour * 24).Seconds()
viper.AutomaticEnv()
viper.SetDefault("DOCKER_HOST", "unix:///var/run/docker.sock")
viper.SetDefault("DOCKER_API_VERSION", DockerAPIMinVersion)
- viper.SetDefault("WATCHTOWER_POLL_INTERVAL", 300)
+ viper.SetDefault("WATCHTOWER_POLL_INTERVAL", day)
viper.SetDefault("WATCHTOWER_TIMEOUT", time.Second*10)
viper.SetDefault("WATCHTOWER_NOTIFICATIONS", []string{})
viper.SetDefault("WATCHTOWER_NOTIFICATIONS_LEVEL", "info")
diff --git a/mkdocs.yml b/mkdocs.yml
index 696f87d..67529aa 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,12 +1,20 @@
site_name: Watchtower
site_url: http://containrrr.github.io/watchtower/
repo_url: https://github.com/containrrr/watchtower/
+edit_uri: edit/main/docs/
theme:
name: 'material'
+ palette:
+ scheme: containrrr
+ logo: images/logo-450px.png
+ favicon: images/favicon.ico
+extra_css:
+ - stylesheets/theme.css
markdown_extensions:
- toc:
permalink: True
separator: "_"
+ - codehilite
nav:
- 'Home': 'index.md'
- 'Introduction': 'introduction.md'
@@ -21,5 +29,6 @@ nav:
- 'Stop signals': 'stop-signals.md'
- 'Lifecycle hooks': 'lifecycle-hooks.md'
- 'Running multiple instances': 'running-multiple-instances.md'
+ - 'Metrics': 'metrics.md'
plugins:
- search
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 12d12c3..b2279e1 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -1,63 +1,76 @@
package api
import (
- "errors"
- "io"
- "net/http"
- "os"
-
+ "fmt"
log "github.com/sirupsen/logrus"
+ "net/http"
)
-var (
- lock chan bool
-)
+const tokenMissingMsg = "api token is empty or has not been set. exiting"
-func init() {
- lock = make(chan bool, 1)
- lock <- true
+// API is the http server responsible for serving the HTTP API endpoints
+type API struct {
+ Token string
+ hasHandlers bool
}
-// SetupHTTPUpdates configures the endpoint needed for triggering updates via http
-func SetupHTTPUpdates(apiToken string, updateFunction func()) error {
- if apiToken == "" {
- return errors.New("api token is empty or has not been set. not starting api")
+// New is a factory function creating a new API instance
+func New(token string) *API {
+ return &API{
+ Token: token,
+ hasHandlers: false,
+ }
+}
+
+// RequireToken is wrapper around http.HandleFunc that checks token validity
+func (api *API) RequireToken(fn http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", api.Token) {
+ log.Tracef("Invalid token \"%s\"", r.Header.Get("Authorization"))
+ log.Tracef("Expected token to be \"%s\"", api.Token)
+ return
+ }
+ log.Debug("Valid token found.")
+ fn(w, r)
+ }
+}
+
+// RegisterFunc is a wrapper around http.HandleFunc that also sets the flag used to determine whether to launch the API
+func (api *API) RegisterFunc(path string, fn http.HandlerFunc) {
+ api.hasHandlers = true
+ http.HandleFunc(path, api.RequireToken(fn))
+}
+
+// RegisterHandler is a wrapper around http.Handler that also sets the flag used to determine whether to launch the API
+func (api *API) RegisterHandler(path string, handler http.Handler) {
+ api.hasHandlers = true
+ http.Handle(path, api.RequireToken(handler.ServeHTTP))
+}
+
+// Start the API and serve over HTTP. Requires an API Token to be set.
+func (api *API) Start(block bool) error {
+
+ if !api.hasHandlers {
+ log.Debug("Watchtower HTTP API skipped.")
+ return nil
}
- log.Println("Watchtower HTTP API started.")
-
- http.HandleFunc("/v1/update", func(w http.ResponseWriter, r *http.Request) {
- log.Info("Updates triggered by HTTP API request.")
-
- _, err := io.Copy(os.Stdout, r.Body)
- if err != nil {
- log.Println(err)
- return
- }
-
- if r.Header.Get("Token") != apiToken {
- log.Println("Invalid token. Not updating.")
- return
- }
-
- log.Println("Valid token found. Attempting to update.")
-
- select {
- case chanValue := <-lock:
- defer func() { lock <- chanValue }()
- updateFunction()
- default:
- log.Debug("Skipped. Another update already running.")
- }
-
- })
+ if api.Token == "" {
+ log.Fatal(tokenMissingMsg)
+ }
+ log.Info("Watchtower HTTP API started.")
+ if block {
+ runHTTPServer()
+ } else {
+ go func() {
+ runHTTPServer()
+ }()
+ }
return nil
}
-// WaitForHTTPUpdates starts the http server and listens for requests.
-func WaitForHTTPUpdates() error {
+func runHTTPServer() {
+ log.Info("Serving HTTP")
log.Fatal(http.ListenAndServe(":8080", nil))
- os.Exit(0)
- return nil
}
diff --git a/pkg/api/metrics/metrics.go b/pkg/api/metrics/metrics.go
new file mode 100644
index 0000000..4faad4a
--- /dev/null
+++ b/pkg/api/metrics/metrics.go
@@ -0,0 +1,27 @@
+package metrics
+
+import (
+ "github.com/containrrr/watchtower/pkg/metrics"
+ "net/http"
+
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+// Handler is an HTTP handle for serving metric data
+type Handler struct {
+ Path string
+ Handle http.HandlerFunc
+ Metrics *metrics.Metrics
+}
+
+// New is a factory function creating a new Metrics instance
+func New() *Handler {
+ m := metrics.Default()
+ handler := promhttp.Handler()
+
+ return &Handler{
+ Path: "/v1/metrics",
+ Handle: handler.ServeHTTP,
+ Metrics: m,
+ }
+}
diff --git a/pkg/api/metrics/metrics_test.go b/pkg/api/metrics/metrics_test.go
new file mode 100644
index 0000000..44379ee
--- /dev/null
+++ b/pkg/api/metrics/metrics_test.go
@@ -0,0 +1,79 @@
+package metrics_test
+
+import (
+ "fmt"
+ "github.com/containrrr/watchtower/pkg/metrics"
+ "io/ioutil"
+ "net/http"
+ "testing"
+
+ "github.com/containrrr/watchtower/pkg/api"
+ metricsAPI "github.com/containrrr/watchtower/pkg/api/metrics"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+const Token = "123123123"
+
+func TestContainer(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Metrics Suite")
+}
+
+func runTestServer(m *metricsAPI.Handler) {
+ http.Handle(m.Path, m.Handle)
+ go func() {
+ http.ListenAndServe(":8080", nil)
+ }()
+}
+
+func getWithToken(c http.Client, url string) (*http.Response, error) {
+ req, _ := http.NewRequest("GET", url, nil)
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", Token))
+ return c.Do(req)
+}
+
+var _ = Describe("the metrics", func() {
+ httpAPI := api.New(Token)
+ m := metricsAPI.New()
+
+ httpAPI.RegisterHandler(m.Path, m.Handle)
+ httpAPI.Start(false)
+
+ It("should serve metrics", func() {
+ metric := &metrics.Metric{
+ Scanned: 4,
+ Updated: 3,
+ Failed: 1,
+ }
+ metrics.RegisterScan(metric)
+ Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue())
+
+ c := http.Client{}
+
+ res, err := getWithToken(c, "http://localhost:8080/v1/metrics")
+ Expect(err).ToNot(HaveOccurred())
+
+ contents, err := ioutil.ReadAll(res.Body)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(contents)).To(ContainSubstring("watchtower_containers_updated 3"))
+ Expect(string(contents)).To(ContainSubstring("watchtower_containers_failed 1"))
+ Expect(string(contents)).To(ContainSubstring("watchtower_containers_scanned 4"))
+ Expect(string(contents)).To(ContainSubstring("watchtower_scans_total 1"))
+ Expect(string(contents)).To(ContainSubstring("watchtower_scans_skipped 0"))
+
+ for i := 0; i < 3; i++ {
+ metrics.RegisterScan(nil)
+ }
+ Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue())
+
+ res, err = getWithToken(c, "http://localhost:8080/v1/metrics")
+ Expect(err).ToNot(HaveOccurred())
+
+ contents, err = ioutil.ReadAll(res.Body)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(contents)).To(ContainSubstring("watchtower_scans_total 4"))
+ Expect(string(contents)).To(ContainSubstring("watchtower_scans_skipped 3"))
+ })
+})
diff --git a/pkg/api/update/update.go b/pkg/api/update/update.go
new file mode 100644
index 0000000..463b082
--- /dev/null
+++ b/pkg/api/update/update.go
@@ -0,0 +1,50 @@
+package update
+
+import (
+ "io"
+ "net/http"
+ "os"
+
+ log "github.com/sirupsen/logrus"
+)
+
+var (
+ lock chan bool
+)
+
+// New is a factory function creating a new Handler instance
+func New(updateFn func()) *Handler {
+ lock = make(chan bool, 1)
+ lock <- true
+
+ return &Handler{
+ fn: updateFn,
+ Path: "/v1/update",
+ }
+}
+
+// Handler is an API handler used for triggering container update scans
+type Handler struct {
+ fn func()
+ Path string
+}
+
+// Handle is the actual http.Handle function doing all the heavy lifting
+func (handle *Handler) Handle(w http.ResponseWriter, r *http.Request) {
+ log.Info("Updates triggered by HTTP API request.")
+
+ _, err := io.Copy(os.Stdout, r.Body)
+ if err != nil {
+ log.Println(err)
+ return
+ }
+
+ select {
+ case chanValue := <-lock:
+ defer func() { lock <- chanValue }()
+ handle.fn()
+ default:
+ log.Debug("Skipped. Another update already running.")
+ }
+
+}
diff --git a/pkg/container/client.go b/pkg/container/client.go
index a333ea5..93eacb7 100644
--- a/pkg/container/client.go
+++ b/pkg/container/client.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/containrrr/watchtower/pkg/registry"
+ "github.com/containrrr/watchtower/pkg/registry/digest"
t "github.com/containrrr/watchtower/pkg/types"
"github.com/docker/docker/api/types"
@@ -32,6 +33,7 @@ type Client interface {
IsContainerStale(Container) (bool, error)
ExecuteCommand(containerID string, command string, timeout int) error
RemoveImageByID(string) error
+ WarnOnHeadPullFailed(container Container) bool
}
// NewClient returns a new Client instance which can be used to interact with
@@ -40,7 +42,7 @@ type Client interface {
// * DOCKER_HOST the docker-engine host to send api requests to
// * DOCKER_TLS_VERIFY whether to verify tls certificates
// * DOCKER_API_VERSION the minimum docker api version to work with
-func NewClient(pullImages bool, includeStopped bool, reviveStopped bool, removeVolumes bool, includeRestarting bool) Client {
+func NewClient(pullImages, includeStopped, reviveStopped, removeVolumes, includeRestarting bool, warnOnHeadFailed string) Client {
cli, err := sdkClient.NewClientWithOpts(sdkClient.FromEnv)
if err != nil {
@@ -54,6 +56,7 @@ func NewClient(pullImages bool, includeStopped bool, reviveStopped bool, removeV
includeStopped: includeStopped,
reviveStopped: reviveStopped,
includeRestarting: includeRestarting,
+ warnOnHeadFailed: warnOnHeadFailed,
}
}
@@ -64,6 +67,18 @@ type dockerClient struct {
includeStopped bool
reviveStopped bool
includeRestarting bool
+ warnOnHeadFailed string
+}
+
+func (client dockerClient) WarnOnHeadPullFailed(container Container) bool {
+ if client.warnOnHeadFailed == "always" {
+ return true
+ }
+ if client.warnOnHeadFailed == "never" {
+ return false
+ }
+
+ return registry.WarnOnAPIConsumption(container)
}
func (client dockerClient) ListContainers(fn t.Filter) ([]Container, error) {
@@ -146,8 +161,10 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
signal = defaultStopSignal
}
+ shortID := ShortID(c.ID())
+
if c.IsRunning() {
- log.Infof("Stopping %s (%s) with %s", c.Name(), c.ID(), signal)
+ log.Infof("Stopping %s (%s) with %s", c.Name(), shortID, signal)
if err := client.api.ContainerKill(bg, c.ID(), signal); err != nil {
return err
}
@@ -157,9 +174,9 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
_ = client.waitForStopOrTimeout(c, timeout)
if c.containerInfo.HostConfig.AutoRemove {
- log.Debugf("AutoRemove container %s, skipping ContainerRemove call.", c.ID())
+ log.Debugf("AutoRemove container %s, skipping ContainerRemove call.", shortID)
} else {
- log.Debugf("Removing container %s", c.ID())
+ log.Debugf("Removing container %s", shortID)
if err := client.api.ContainerRemove(bg, c.ID(), types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.removeVolumes}); err != nil {
return err
@@ -168,7 +185,7 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
// Wait for container to be removed. In this case an error is a good thing
if err := client.waitForStopOrTimeout(c, timeout); err == nil {
- return fmt.Errorf("container %s (%s) could not be removed", c.Name(), c.ID())
+ return fmt.Errorf("container %s (%s) could not be removed", c.Name(), shortID)
}
return nil
@@ -228,7 +245,7 @@ func (client dockerClient) StartContainer(c Container) (string, error) {
func (client dockerClient) doStartContainer(bg context.Context, c Container, creation container.ContainerCreateCreatedBody) error {
name := c.Name()
- log.Debugf("Starting container %s (%s)", name, creation.ID)
+ log.Debugf("Starting container %s (%s)", name, ShortID(creation.ID))
err := client.api.ContainerStart(bg, creation.ID, types.ContainerStartOptions{})
if err != nil {
return err
@@ -238,7 +255,7 @@ func (client dockerClient) doStartContainer(bg context.Context, c Container, cre
func (client dockerClient) RenameContainer(c Container, newName string) error {
bg := context.Background()
- log.Debugf("Renaming container %s (%s) to %s", c.Name(), c.ID(), newName)
+ log.Debugf("Renaming container %s (%s) to %s", c.Name(), ShortID(c.ID()), newName)
return client.api.ContainerRename(bg, c.ID(), newName)
}
@@ -268,20 +285,48 @@ func (client dockerClient) HasNewImage(ctx context.Context, container Container)
return false, nil
}
- log.Infof("Found new %s image (%s)", imageName, newImageInfo.ID)
+ log.Infof("Found new %s image (%s)", imageName, ShortID(newImageInfo.ID))
return true, nil
}
+// PullImage pulls the latest image for the supplied container, optionally skipping if it's digest can be confirmed
+// to match the one that the registry reports via a HEAD request
func (client dockerClient) PullImage(ctx context.Context, container Container) error {
containerName := container.Name()
imageName := container.ImageName()
- log.Debugf("Pulling %s for %s", imageName, containerName)
+ fields := log.Fields{
+ "image": imageName,
+ "container": containerName,
+ }
+
+ log.WithFields(fields).Debugf("Trying to load authentication credentials.")
opts, err := registry.GetPullOptions(imageName)
if err != nil {
log.Debugf("Error loading authentication credentials %s", err)
return err
}
+ if opts.RegistryAuth != "" {
+ log.Debug("Credentials loaded")
+ }
+
+ log.WithFields(fields).Debugf("Checking if pull is needed")
+
+ if match, err := digest.CompareDigest(container, opts.RegistryAuth); err != nil {
+ headLevel := log.DebugLevel
+ if client.WarnOnHeadPullFailed(container) {
+ headLevel = log.WarnLevel
+ }
+ log.WithFields(fields).Logf(headLevel, "Could not do a head request for %q, falling back to regular pull.", imageName)
+ log.WithFields(fields).Log(headLevel, "Reason: ", err)
+ } else if match {
+ log.Debug("No pull needed. Skipping image.")
+ return nil
+ } else {
+ log.Debug("Digests did not match, doing a pull.")
+ }
+
+ log.WithFields(fields).Debugf("Pulling image")
response, err := client.api.ImagePull(ctx, imageName, opts)
if err != nil {
@@ -299,7 +344,7 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
}
func (client dockerClient) RemoveImageByID(id string) error {
- log.Infof("Removing image %s", id)
+ log.Infof("Removing image %s", ShortID(id))
_, err := client.api.ImageRemove(
context.Background(),
@@ -377,6 +422,7 @@ func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, e
for {
execInspect, err := client.api.ContainerExecInspect(ctx, ID)
+ //goland:noinspection GoNilness
log.WithFields(log.Fields{
"exit-code": execInspect.ExitCode,
"exec-id": execInspect.ExecID,
diff --git a/pkg/container/container.go b/pkg/container/container.go
index 9e339c3..92abec2 100644
--- a/pkg/container/container.go
+++ b/pkg/container/container.go
@@ -22,8 +22,8 @@ func NewContainer(containerInfo *types.ContainerJSON, imageInfo *types.ImageInsp
// Container represents a running Docker container.
type Container struct {
- Linked bool
- Stale bool
+ LinkedToRestarting bool
+ Stale bool
containerInfo *types.ContainerJSON
imageInfo *types.ImageInspect
@@ -142,7 +142,7 @@ func (c Container) Links() []string {
// ToRestart return whether the container should be restarted, either because
// is stale or linked to another stale container.
func (c Container) ToRestart() bool {
- return c.Stale || c.Linked
+ return c.Stale || c.LinkedToRestarting
}
// IsWatchtower returns a boolean flag indicating whether or not the current
@@ -253,3 +253,37 @@ func (c Container) hostConfig() *dockercontainer.HostConfig {
func (c Container) HasImageInfo() bool {
return c.imageInfo != nil
}
+
+// ImageInfo fetches the ImageInspect data of the current container
+func (c Container) ImageInfo() *types.ImageInspect {
+ return c.imageInfo
+}
+
+// VerifyConfiguration checks the container and image configurations for nil references to make sure
+// that the container can be recreated once deleted
+func (c Container) VerifyConfiguration() error {
+ if c.imageInfo == nil {
+ return errorNoImageInfo
+ }
+
+ containerInfo := c.ContainerInfo()
+ if containerInfo == nil {
+ return errorInvalidConfig
+ }
+
+ containerConfig := containerInfo.Config
+ if containerConfig == nil {
+ return errorInvalidConfig
+ }
+
+ hostConfig := containerInfo.HostConfig
+ if hostConfig == nil {
+ return errorInvalidConfig
+ }
+
+ if len(hostConfig.PortBindings) > 0 && containerConfig.ExposedPorts == nil {
+ return errorNoExposedPorts
+ }
+
+ return nil
+}
diff --git a/pkg/container/container_suite_test.go b/pkg/container/container_suite_test.go
new file mode 100644
index 0000000..292a008
--- /dev/null
+++ b/pkg/container/container_suite_test.go
@@ -0,0 +1,13 @@
+package container_test
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+func TestContainer(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Container Suite")
+}
diff --git a/pkg/container/container_test.go b/pkg/container/container_test.go
index 16b8922..8f22044 100644
--- a/pkg/container/container_test.go
+++ b/pkg/container/container_test.go
@@ -1,22 +1,16 @@
package container
import (
- "testing"
-
"github.com/containrrr/watchtower/pkg/container/mocks"
"github.com/containrrr/watchtower/pkg/filters"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
cli "github.com/docker/docker/client"
+ "github.com/docker/go-connections/nat"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
-func TestContainer(t *testing.T) {
- RegisterFailHandler(Fail)
- RunSpecs(t, "Container Suite")
-}
-
var _ = Describe("the container", func() {
Describe("the client", func() {
var docker *cli.Client
@@ -34,6 +28,35 @@ var _ = Describe("the container", func() {
It("should return a client for the api", func() {
Expect(client).NotTo(BeNil())
})
+ Describe("WarnOnHeadPullFailed", func() {
+ containerUnknown := *mockContainerWithImageName("unknown.repo/prefix/imagename:latest")
+ containerKnown := *mockContainerWithImageName("docker.io/prefix/imagename:latest")
+
+ When("warn on head failure is set to \"always\"", func() {
+ c := newClientNoAPI(false, false, false, false, false, "always")
+ It("should always return true", func() {
+ Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeTrue())
+ Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
+ })
+ })
+ When("warn on head failure is set to \"auto\"", func() {
+ c := newClientNoAPI(false, false, false, false, false, "auto")
+ It("should always return true", func() {
+ Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
+ })
+ It("should", func() {
+ Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
+ })
+ })
+ When("warn on head failure is set to \"never\"", func() {
+ c := newClientNoAPI(false, false, false, false, false, "never")
+ It("should never return true", func() {
+ Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
+ Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeFalse())
+ })
+ })
+ })
+
When("listing containers without any filter", func() {
It("should return all available containers", func() {
containers, err := client.ListContainers(filters.NoFilter)
@@ -108,6 +131,63 @@ var _ = Describe("the container", func() {
})
})
})
+ Describe("VerifyConfiguration", func() {
+ When("verifying a container with no image info", func() {
+ It("should return an error", func() {
+ c := mockContainerWithPortBindings()
+ c.imageInfo = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorNoImageInfo))
+ })
+ })
+ When("verifying a container with no container info", func() {
+ It("should return an error", func() {
+ c := mockContainerWithPortBindings()
+ c.containerInfo = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorInvalidConfig))
+ })
+ })
+ When("verifying a container with no config", func() {
+ It("should return an error", func() {
+ c := mockContainerWithPortBindings()
+ c.containerInfo.Config = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorInvalidConfig))
+ })
+ })
+ When("verifying a container with no host config", func() {
+ It("should return an error", func() {
+ c := mockContainerWithPortBindings()
+ c.containerInfo.HostConfig = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorInvalidConfig))
+ })
+ })
+ When("verifying a container with no port bindings", func() {
+ It("should not return an error", func() {
+ c := mockContainerWithPortBindings()
+ err := c.VerifyConfiguration()
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+ When("verifying a container with port bindings, but no exposed ports", func() {
+ It("should return an error", func() {
+ c := mockContainerWithPortBindings("80/tcp")
+ c.containerInfo.Config.ExposedPorts = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorNoExposedPorts))
+ })
+ })
+ When("verifying a container with port bindings and exposed ports is non-nil", func() {
+ It("should return an error", func() {
+ c := mockContainerWithPortBindings("80/tcp")
+ c.containerInfo.Config.ExposedPorts = map[nat.Port]struct{}{"80/tcp": {}}
+ err := c.VerifyConfiguration()
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+ })
When("asked for metadata", func() {
var c *Container
BeforeEach(func() {
@@ -259,10 +339,23 @@ 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 {
- container := mockContainerWithLabels(nil)
- container.containerInfo.Config.Image = name
- return container
+ mockContainer := mockContainerWithLabels(nil)
+ mockContainer.containerInfo.Config.Image = name
+ return mockContainer
}
func mockContainerWithLinks(links []string) *Container {
@@ -295,3 +388,15 @@ func mockContainerWithLabels(labels map[string]string) *Container {
}
return NewContainer(&content, nil)
}
+
+func newClientNoAPI(pullImages, includeStopped, reviveStopped, removeVolumes, includeRestarting bool, warnOnHeadFailed string) Client {
+ return dockerClient{
+ api: nil,
+ pullImages: pullImages,
+ removeVolumes: removeVolumes,
+ includeStopped: includeStopped,
+ reviveStopped: reviveStopped,
+ includeRestarting: includeRestarting,
+ warnOnHeadFailed: warnOnHeadFailed,
+ }
+}
diff --git a/pkg/container/errors.go b/pkg/container/errors.go
new file mode 100644
index 0000000..b927220
--- /dev/null
+++ b/pkg/container/errors.go
@@ -0,0 +1,7 @@
+package container
+
+import "errors"
+
+var errorNoImageInfo = errors.New("no available image info")
+var errorNoExposedPorts = errors.New("exposed ports does not match port bindings")
+var errorInvalidConfig = errors.New("container configuration missing or invalid")
diff --git a/pkg/container/util.go b/pkg/container/util.go
new file mode 100644
index 0000000..261316f
--- /dev/null
+++ b/pkg/container/util.go
@@ -0,0 +1,23 @@
+package container
+
+import "strings"
+
+// ShortID returns the 12-character (hex) short version of an image ID hash, removing any "sha256:" prefix if present
+func ShortID(imageID string) (short string) {
+ prefixSep := strings.IndexRune(imageID, ':')
+ offset := 0
+ length := 12
+ if prefixSep >= 0 {
+ if imageID[0:prefixSep] == "sha256" {
+ offset = prefixSep + 1
+ } else {
+ length += prefixSep + 1
+ }
+ }
+
+ if len(imageID) >= offset+length {
+ return imageID[offset : offset+length]
+ }
+
+ return imageID
+}
diff --git a/pkg/container/util_test.go b/pkg/container/util_test.go
new file mode 100644
index 0000000..8cb0328
--- /dev/null
+++ b/pkg/container/util_test.go
@@ -0,0 +1,46 @@
+package container_test
+
+import (
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+
+ . "github.com/containrrr/watchtower/pkg/container"
+)
+
+var _ = Describe("container utils", func() {
+ Describe("ShortID", func() {
+ When("given a normal image ID", func() {
+ When("it contains a sha256 prefix", func() {
+ It("should return that ID in short version", func() {
+ actual := ShortID("sha256:0123456789abcd00000000001111111111222222222233333333334444444444")
+ Expect(actual).To(Equal("0123456789ab"))
+ })
+ })
+ When("it doesn't contain a prefix", func() {
+ It("should return that ID in short version", func() {
+ actual := ShortID("0123456789abcd00000000001111111111222222222233333333334444444444")
+ Expect(actual).To(Equal("0123456789ab"))
+ })
+ })
+ })
+ When("given a short image ID", func() {
+ When("it contains no prefix", func() {
+ It("should return the same string", func() {
+ Expect(ShortID("0123456789ab")).To(Equal("0123456789ab"))
+ })
+ })
+ When("it contains a the sha256 prefix", func() {
+ It("should return the ID without the prefix", func() {
+ Expect(ShortID("sha256:0123456789ab")).To(Equal("0123456789ab"))
+ })
+ })
+ })
+ When("given an ID with an unknown prefix", func() {
+ It("should return a short version of that ID including the prefix", func() {
+ Expect(ShortID("md5:0123456789ab")).To(Equal("md5:0123456789ab"))
+ Expect(ShortID("md5:0123456789abcdefg")).To(Equal("md5:0123456789ab"))
+ Expect(ShortID("md5:01")).To(Equal("md5:01"))
+ })
+ })
+ })
+})
diff --git a/pkg/filters/filters.go b/pkg/filters/filters.go
index 0e37885..18f39c2 100644
--- a/pkg/filters/filters.go
+++ b/pkg/filters/filters.go
@@ -1,6 +1,9 @@
package filters
-import t "github.com/containrrr/watchtower/pkg/types"
+import (
+ t "github.com/containrrr/watchtower/pkg/types"
+ "strings"
+)
// WatchtowerContainersFilter filters only watchtower containers
func WatchtowerContainersFilter(c t.FilterableContainer) bool { return c.IsWatchtower() }
@@ -68,19 +71,45 @@ func FilterByScope(scope string, baseFilter t.Filter) t.Filter {
}
// BuildFilter creates the needed filter of containers
-func BuildFilter(names []string, enableLabel bool, scope string) t.Filter {
+func BuildFilter(names []string, enableLabel bool, scope string) (t.Filter, string) {
+ sb := strings.Builder{}
filter := NoFilter
filter = FilterByNames(names, filter)
+
+ if len(names) > 0 {
+ sb.WriteString("with name \"")
+ for i, n := range names {
+ sb.WriteString(n)
+ if i < len(names)-1 {
+ sb.WriteString(`" or "`)
+ }
+ }
+ sb.WriteString(`", `)
+ }
+
if enableLabel {
// If label filtering is enabled, containers should only be considered
// if the label is specifically set.
filter = FilterByEnableLabel(filter)
+ sb.WriteString("using enable label, ")
}
if scope != "" {
// If a scope has been defined, containers should only be considered
// if the scope is specifically set.
filter = FilterByScope(scope, filter)
+ sb.WriteString(`in scope "`)
+ sb.WriteString(scope)
+ sb.WriteString(`", `)
}
filter = FilterByDisabledLabel(filter)
- return filter
+
+ filterDesc := "Checking all containers (except explicitly disabled with label)"
+ if sb.Len() > 0 {
+ filterDesc = "Only checking containers " + sb.String()
+
+ // Remove the last ", "
+ filterDesc = filterDesc[:len(filterDesc)-2]
+ }
+
+ return filter, filterDesc
}
diff --git a/pkg/filters/filters_test.go b/pkg/filters/filters_test.go
index 5766b64..3b52b5e 100644
--- a/pkg/filters/filters_test.go
+++ b/pkg/filters/filters_test.go
@@ -114,7 +114,8 @@ func TestBuildFilter(t *testing.T) {
var names []string
names = append(names, "test")
- filter := BuildFilter(names, false, "")
+ filter, desc := BuildFilter(names, false, "")
+ assert.Contains(t, desc, "test")
container := new(mocks.FilterableContainer)
container.On("Name").Return("Invalid")
@@ -150,7 +151,8 @@ func TestBuildFilterEnableLabel(t *testing.T) {
var names []string
names = append(names, "test")
- filter := BuildFilter(names, true, "")
+ filter, desc := BuildFilter(names, true, "")
+ assert.Contains(t, desc, "using enable label")
container := new(mocks.FilterableContainer)
container.On("Enabled").Return(false, false)
diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go
new file mode 100644
index 0000000..d8761ba
--- /dev/null
+++ b/pkg/metrics/metrics.go
@@ -0,0 +1,96 @@
+package metrics
+
+import (
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+var metrics *Metrics
+
+// Metric is the data points of a single scan
+type Metric struct {
+ Scanned int
+ Updated int
+ Failed int
+}
+
+// Metrics is the handler processing all individual scan metrics
+type Metrics struct {
+ channel chan *Metric
+ scanned prometheus.Gauge
+ updated prometheus.Gauge
+ failed prometheus.Gauge
+ total prometheus.Counter
+ skipped prometheus.Counter
+}
+
+// QueueIsEmpty checks whether any messages are enqueued in the channel
+func (metrics *Metrics) QueueIsEmpty() bool {
+ return len(metrics.channel) == 0
+}
+
+// Register registers metrics for an executed scan
+func (metrics *Metrics) Register(metric *Metric) {
+ metrics.channel <- metric
+}
+
+// Default creates a new metrics handler if none exists, otherwise returns the existing one
+func Default() *Metrics {
+ if metrics != nil {
+ return metrics
+ }
+
+ metrics = &Metrics{
+ scanned: promauto.NewGauge(prometheus.GaugeOpts{
+ Name: "watchtower_containers_scanned",
+ Help: "Number of containers scanned for changes by watchtower during the last scan",
+ }),
+ updated: promauto.NewGauge(prometheus.GaugeOpts{
+ Name: "watchtower_containers_updated",
+ Help: "Number of containers updated by watchtower during the last scan",
+ }),
+ failed: promauto.NewGauge(prometheus.GaugeOpts{
+ Name: "watchtower_containers_failed",
+ Help: "Number of containers where update failed during the last scan",
+ }),
+ total: promauto.NewCounter(prometheus.CounterOpts{
+ Name: "watchtower_scans_total",
+ Help: "Number of scans since the watchtower started",
+ }),
+ skipped: promauto.NewCounter(prometheus.CounterOpts{
+ Name: "watchtower_scans_skipped",
+ Help: "Number of skipped scans since watchtower started",
+ }),
+ channel: make(chan *Metric, 10),
+ }
+
+ go metrics.HandleUpdate(metrics.channel)
+
+ return metrics
+}
+
+// RegisterScan fetches a metric handler and enqueues a metric
+func RegisterScan(metric *Metric) {
+ metrics := Default()
+ metrics.Register(metric)
+}
+
+// HandleUpdate dequeue the metric channel and processes it
+func (metrics *Metrics) HandleUpdate(channel <-chan *Metric) {
+ for change := range channel {
+ if change == nil {
+ // Update was skipped and rescheduled
+ metrics.total.Inc()
+ metrics.skipped.Inc()
+ metrics.scanned.Set(0)
+ metrics.updated.Set(0)
+ metrics.failed.Set(0)
+ continue
+ }
+ // Update metrics with the new values
+ metrics.total.Inc()
+ metrics.scanned.Set(float64(change.Scanned))
+ metrics.updated.Set(float64(change.Updated))
+ metrics.failed.Set(float64(change.Failed))
+ }
+}
diff --git a/pkg/notifications/email.go b/pkg/notifications/email.go
index 6079de7..4984139 100644
--- a/pkg/notifications/email.go
+++ b/pkg/notifications/email.go
@@ -1,29 +1,21 @@
package notifications
import (
- "encoding/base64"
- "fmt"
- "github.com/spf13/cobra"
- "net/smtp"
- "os"
- "strings"
"time"
+ "github.com/spf13/cobra"
+
+ shoutrrrSmtp "github.com/containrrr/shoutrrr/pkg/services/smtp"
t "github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
- "strconv"
)
const (
emailType = "email"
)
-// Implements Notifier, logrus.Hook
-// The default logrus email integration would have several issues:
-// - It would send one email per log output
-// - It would only send errors
-// We work around that by holding on to log entries until the update cycle is done.
type emailTypeNotifier struct {
+ url string
From, To string
Server, User, Password, SubjectTag string
Port int
@@ -33,7 +25,12 @@ type emailTypeNotifier struct {
delay time.Duration
}
-func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
+// NewEmailNotifier is a factory method creating a new email notifier instance
+func NewEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
+ return newEmailNotifier(c, acceptedLogLevels)
+}
+
+func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
flags := c.PersistentFlags()
from, _ := flags.GetString("notification-email-from")
@@ -47,6 +44,7 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie
subjecttag, _ := flags.GetString("notification-email-subjecttag")
n := &emailTypeNotifier{
+ entries: []*log.Entry{},
From: from,
To: to,
Server: server,
@@ -59,99 +57,42 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie
SubjectTag: subjecttag,
}
- log.AddHook(n)
-
return n
}
-func (e *emailTypeNotifier) buildMessage(entries []*log.Entry) []byte {
- var emailSubject string
-
- if e.SubjectTag == "" {
- emailSubject = "Watchtower updates"
- } else {
- emailSubject = e.SubjectTag + " Watchtower updates"
- }
- if hostname, err := os.Hostname(); err == nil {
- emailSubject += " on " + hostname
- }
- body := ""
- for _, entry := range entries {
- body += entry.Time.Format("2006-01-02 15:04:05") + " (" + entry.Level.String() + "): " + entry.Message + "\r\n"
- // We don't use fields in watchtower, so don't bother sending them.
+func (e *emailTypeNotifier) GetURL() (string, error) {
+ conf := &shoutrrrSmtp.Config{
+ FromAddress: e.From,
+ FromName: "Watchtower",
+ ToAddresses: []string{e.To},
+ Port: uint16(e.Port),
+ Host: e.Server,
+ Subject: e.getSubject(),
+ Username: e.User,
+ Password: e.Password,
+ UseStartTLS: !e.tlsSkipVerify,
+ UseHTML: false,
+ Encryption: shoutrrrSmtp.EncMethods.Auto,
+ Auth: shoutrrrSmtp.AuthTypes.None,
}
- t := time.Now()
-
- header := make(map[string]string)
- header["From"] = e.From
- header["To"] = e.To
- header["Subject"] = emailSubject
- header["Date"] = t.Format(time.RFC1123Z)
- header["MIME-Version"] = "1.0"
- header["Content-Type"] = "text/plain; charset=\"utf-8\""
- header["Content-Transfer-Encoding"] = "base64"
-
- message := ""
- for k, v := range header {
- message += fmt.Sprintf("%s: %s\r\n", k, v)
+ if len(e.User) > 0 {
+ conf.Auth = shoutrrrSmtp.AuthTypes.Plain
}
- encodedBody := base64.StdEncoding.EncodeToString([]byte(body))
- //RFC 2045 base64 encoding demands line no longer than 76 characters.
- for _, line := range SplitSubN(encodedBody, 76) {
- message += "\r\n" + line
+ if e.tlsSkipVerify {
+ conf.Encryption = shoutrrrSmtp.EncMethods.None
}
- return []byte(message)
+ return conf.GetURL().String(), nil
}
-func (e *emailTypeNotifier) sendEntries(entries []*log.Entry) {
- // Do the sending in a separate goroutine so we don't block the main process.
- msg := e.buildMessage(entries)
- go func() {
- if e.delay > 0 {
- time.Sleep(e.delay)
- }
+func (e *emailTypeNotifier) getSubject() string {
+ subject := GetTitle()
- var auth smtp.Auth
- if e.User != "" {
- auth = smtp.PlainAuth("", e.User, e.Password, e.Server)
- }
- err := SendMail(e.Server+":"+strconv.Itoa(e.Port), e.tlsSkipVerify, auth, e.From, strings.Split(e.To, ","), msg)
- if err != nil {
- // Use fmt so it doesn't trigger another email.
- fmt.Println("Failed to send notification email: ", err)
- }
- }()
-}
-
-func (e *emailTypeNotifier) StartNotification() {
- if e.entries == nil {
- e.entries = make([]*log.Entry, 0, 10)
- }
-}
-
-func (e *emailTypeNotifier) SendNotification() {
- if e.entries == nil || len(e.entries) <= 0 {
- return
+ if e.SubjectTag != "" {
+ subject = e.SubjectTag + " " + subject
}
- e.sendEntries(e.entries)
- e.entries = nil
+ return subject
}
-
-func (e *emailTypeNotifier) Levels() []log.Level {
- return e.logLevels
-}
-
-func (e *emailTypeNotifier) Fire(entry *log.Entry) error {
- if e.entries != nil {
- e.entries = append(e.entries, entry)
- } else {
- e.sendEntries([]*log.Entry{entry})
- }
- return nil
-}
-
-func (e *emailTypeNotifier) Close() {}
diff --git a/pkg/notifications/gotify.go b/pkg/notifications/gotify.go
index 789f778..7a6009b 100644
--- a/pkg/notifications/gotify.go
+++ b/pkg/notifications/gotify.go
@@ -1,16 +1,14 @@
package notifications
import (
- "bytes"
- "crypto/tls"
- "encoding/json"
- "fmt"
- "net/http"
+ "net/url"
"strings"
+ shoutrrrGotify "github.com/containrrr/shoutrrr/pkg/services/gotify"
t "github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
+ "github.com/spf13/pflag"
)
const (
@@ -24,10 +22,40 @@ type gotifyTypeNotifier struct {
logLevels []log.Level
}
-func newGotifyNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
+// NewGotifyNotifier is a factory method creating a new gotify notifier instance
+func NewGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertibleNotifier {
+ return newGotifyNotifier(c, levels)
+}
+
+func newGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertibleNotifier {
flags := c.PersistentFlags()
+ apiURL := getGotifyURL(flags)
+ token := getGotifyToken(flags)
+
+ skipVerify, _ := flags.GetBool("notification-gotify-tls-skip-verify")
+
+ n := &gotifyTypeNotifier{
+ gotifyURL: apiURL,
+ gotifyAppToken: token,
+ gotifyInsecureSkipVerify: skipVerify,
+ logLevels: levels,
+ }
+
+ return n
+}
+
+func getGotifyToken(flags *pflag.FlagSet) string {
+ gotifyToken, _ := flags.GetString("notification-gotify-token")
+ if len(gotifyToken) < 1 {
+ log.Fatal("Required argument --notification-gotify-token(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN(env) is empty.")
+ }
+ return gotifyToken
+}
+
+func getGotifyURL(flags *pflag.FlagSet) string {
gotifyURL, _ := flags.GetString("notification-gotify-url")
+
if len(gotifyURL) < 1 {
log.Fatal("Required argument --notification-gotify-url(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_URL(env) is empty.")
} else if !(strings.HasPrefix(gotifyURL, "http://") || strings.HasPrefix(gotifyURL, "https://")) {
@@ -36,82 +64,22 @@ func newGotifyNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifi
log.Warn("Using an HTTP url for Gotify is insecure")
}
- gotifyToken, _ := flags.GetString("notification-gotify-token")
- if len(gotifyToken) < 1 {
- log.Fatal("Required argument --notification-gotify-token(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN(env) is empty.")
+ return gotifyURL
+}
+
+func (n *gotifyTypeNotifier) GetURL() (string, error) {
+ apiURL, err := url.Parse(n.gotifyURL)
+ if err != nil {
+ return "", err
}
- gotifyInsecureSkipVerify, _ := flags.GetBool("notification-gotify-tls-skip-verify")
-
- n := &gotifyTypeNotifier{
- gotifyURL: gotifyURL,
- gotifyAppToken: gotifyToken,
- gotifyInsecureSkipVerify: gotifyInsecureSkipVerify,
- logLevels: acceptedLogLevels,
+ config := &shoutrrrGotify.Config{
+ Host: apiURL.Host,
+ Path: apiURL.Path,
+ DisableTLS: apiURL.Scheme == "http",
+ Title: GetTitle(),
+ Token: n.gotifyAppToken,
}
- log.AddHook(n)
-
- return n
-}
-
-func (n *gotifyTypeNotifier) StartNotification() {}
-
-func (n *gotifyTypeNotifier) SendNotification() {}
-
-func (n *gotifyTypeNotifier) Close() {}
-
-func (n *gotifyTypeNotifier) Levels() []log.Level {
- return n.logLevels
-}
-
-func (n *gotifyTypeNotifier) getURL() string {
- url := n.gotifyURL
- if !strings.HasSuffix(url, "/") {
- url += "/"
- }
- return url + "message?token=" + n.gotifyAppToken
-}
-
-func (n *gotifyTypeNotifier) Fire(entry *log.Entry) error {
-
- go func() {
- jsonBody, err := json.Marshal(gotifyMessage{
- Message: "(" + entry.Level.String() + "): " + entry.Message,
- Title: "Watchtower",
- Priority: 0,
- })
- if err != nil {
- fmt.Println("Failed to create JSON body for Gotify notification: ", err)
- return
- }
-
- // Explicitly define the client so we can set InsecureSkipVerify to the desired value.
- client := &http.Client{
- Transport: &http.Transport{
- TLSClientConfig: &tls.Config{
- InsecureSkipVerify: n.gotifyInsecureSkipVerify,
- },
- },
- }
- jsonBodyBuffer := bytes.NewBuffer([]byte(jsonBody))
- resp, err := client.Post(n.getURL(), "application/json", jsonBodyBuffer)
- if err != nil {
- fmt.Println("Failed to send Gotify notification: ", err)
- return
- }
- defer resp.Body.Close()
-
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- fmt.Printf("Gotify notification returned %d HTTP status code", resp.StatusCode)
- }
-
- }()
- return nil
-}
-
-type gotifyMessage struct {
- Message string `json:"message"`
- Title string `json:"title"`
- Priority int `json:"priority"`
+ return config.GetURL().String(), nil
}
diff --git a/pkg/notifications/msteams.go b/pkg/notifications/msteams.go
index ab33966..6c47229 100644
--- a/pkg/notifications/msteams.go
+++ b/pkg/notifications/msteams.go
@@ -1,15 +1,11 @@
package notifications
import (
- "bytes"
- "encoding/json"
- "fmt"
- "github.com/spf13/cobra"
- "net/http"
-
+ shoutrrrTeams "github.com/containrrr/shoutrrr/pkg/services/teams"
t "github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
- "io/ioutil"
+ "github.com/spf13/cobra"
+ "net/url"
)
const (
@@ -22,7 +18,12 @@ type msTeamsTypeNotifier struct {
data bool
}
-func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
+// NewMsTeamsNotifier is a factory method creating a new teams notifier instance
+func NewMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
+ return newMsTeamsNotifier(cmd, acceptedLogLevels)
+}
+
+func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
flags := cmd.PersistentFlags()
@@ -38,103 +39,22 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Not
data: withData,
}
- log.AddHook(n)
-
return n
}
-func (n *msTeamsTypeNotifier) StartNotification() {}
+func (n *msTeamsTypeNotifier) GetURL() (string, error) {
+ webhookURL, err := url.Parse(n.webHookURL)
+ if err != nil {
+ return "", err
+ }
-func (n *msTeamsTypeNotifier) SendNotification() {}
+ config, err := shoutrrrTeams.ConfigFromWebhookURL(*webhookURL)
+ if err != nil {
+ return "", err
+ }
-func (n *msTeamsTypeNotifier) Close() {}
+ config.Color = ColorHex
+ config.Title = GetTitle()
-func (n *msTeamsTypeNotifier) Levels() []log.Level {
- return n.levels
-}
-
-func (n *msTeamsTypeNotifier) Fire(entry *log.Entry) error {
-
- message := "(" + entry.Level.String() + "): " + entry.Message
-
- go func() {
- webHookBody := messageCard{
- CardType: "MessageCard",
- Context: "http://schema.org/extensions",
- Markdown: true,
- Text: message,
- }
-
- if n.data && entry.Data != nil && len(entry.Data) > 0 {
- section := messageCardSection{
- Facts: make([]messageCardSectionFact, len(entry.Data)),
- Text: "",
- }
-
- index := 0
- for k, v := range entry.Data {
- section.Facts[index] = messageCardSectionFact{
- Name: k,
- Value: fmt.Sprint(v),
- }
- index++
- }
-
- webHookBody.Sections = []messageCardSection{section}
- }
-
- jsonBody, err := json.Marshal(webHookBody)
- if err != nil {
- fmt.Println("Failed to build JSON body for MSTeams notificattion: ", err)
- return
- }
-
- resp, err := http.Post(n.webHookURL, "application/json", bytes.NewBuffer([]byte(jsonBody)))
- if err != nil {
- fmt.Println("Failed to send MSTeams notificattion: ", err)
- }
-
- defer resp.Body.Close()
-
- if resp.StatusCode < 200 || resp.StatusCode > 299 {
- fmt.Println("Failed to send MSTeams notificattion. HTTP RESPONSE STATUS: ", resp.StatusCode)
- if resp.Body != nil {
- bodyBytes, err := ioutil.ReadAll(resp.Body)
- if err == nil {
- bodyString := string(bodyBytes)
- fmt.Println(bodyString)
- }
- }
- }
- }()
-
- return nil
-}
-
-type messageCard struct {
- CardType string `json:"@type"`
- Context string `json:"@context"`
- CorrelationID string `json:"correlationId,omitempty"`
- ThemeColor string `json:"themeColor,omitempty"`
- Summary string `json:"summary,omitempty"`
- Title string `json:"title,omitempty"`
- Text string `json:"text,omitempty"`
- Markdown bool `json:"markdown,bool"`
- Sections []messageCardSection `json:"sections,omitempty"`
-}
-
-type messageCardSection struct {
- Title string `json:"title,omitempty"`
- Text string `json:"text,omitempty"`
- ActivityTitle string `json:"activityTitle,omitempty"`
- ActivitySubtitle string `json:"activitySubtitle,omitempty"`
- ActivityImage string `json:"activityImage,omitempty"`
- ActivityText string `json:"activityText,omitempty"`
- HeroImage string `json:"heroImage,omitempty"`
- Facts []messageCardSectionFact `json:"facts,omitempty"`
-}
-
-type messageCardSectionFact struct {
- Name string `json:"name,omitempty"`
- Value string `json:"value,omitempty"`
+ return config.GetURL().String(), nil
}
diff --git a/pkg/notifications/notifier.go b/pkg/notifications/notifier.go
index dedb21a..c4e962f 100644
--- a/pkg/notifications/notifier.go
+++ b/pkg/notifications/notifier.go
@@ -5,6 +5,8 @@ import (
"github.com/johntdyer/slackrus"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
+ "os"
+ "strings"
)
// Notifier can send log output as notification to admins, with optional batching.
@@ -25,34 +27,96 @@ func NewNotifier(c *cobra.Command) *Notifier {
}
acceptedLogLevels := slackrus.LevelThreshold(logLevel)
+ // slackrus does not allow log level TRACE, even though it's an accepted log level for logrus
+ if len(acceptedLogLevels) == 0 {
+ log.Fatalf("Unsupported notification log level provided: %s", level)
+ }
// Parse types and create notifiers.
types, err := f.GetStringSlice("notifications")
if err != nil {
log.WithField("could not read notifications argument", log.Fields{"Error": err}).Fatal()
}
- for _, t := range types {
- var tn ty.Notifier
- switch t {
- case emailType:
- tn = newEmailNotifier(c, acceptedLogLevels)
- case slackType:
- tn = newSlackNotifier(c, acceptedLogLevels)
- case msTeamsType:
- tn = newMsTeamsNotifier(c, acceptedLogLevels)
- case gotifyType:
- tn = newGotifyNotifier(c, acceptedLogLevels)
- case shoutrrrType:
- tn = newShoutrrrNotifier(c, acceptedLogLevels)
- default:
- log.Fatalf("Unknown notification type %q", t)
- }
- n.types = append(n.types, tn)
- }
+
+ n.types = n.getNotificationTypes(c, acceptedLogLevels, types)
return n
}
+func (n *Notifier) String() string {
+ if len(n.types) < 1 {
+ return ""
+ }
+
+ sb := strings.Builder{}
+ for _, notif := range n.types {
+ for _, name := range notif.GetNames() {
+ sb.WriteString(name)
+ sb.WriteString(", ")
+ }
+ }
+
+ if sb.Len() < 2 {
+ // No notification services are configured, return early as the separator strip is not applicable
+ return "none"
+ }
+
+ names := sb.String()
+
+ // remove the last separator
+ names = names[:len(names)-2]
+
+ return names
+}
+
+// getNotificationTypes produces an array of notifiers from a list of types
+func (n *Notifier) getNotificationTypes(cmd *cobra.Command, levels []log.Level, types []string) []ty.Notifier {
+ output := make([]ty.Notifier, 0)
+
+ for _, t := range types {
+
+ if t == shoutrrrType {
+ output = append(output, newShoutrrrNotifier(cmd, levels))
+ continue
+ }
+
+ var legacyNotifier ty.ConvertibleNotifier
+ var err error
+
+ switch t {
+ case emailType:
+ legacyNotifier = newEmailNotifier(cmd, []log.Level{})
+ case slackType:
+ legacyNotifier = newSlackNotifier(cmd, []log.Level{})
+ case msTeamsType:
+ legacyNotifier = newMsTeamsNotifier(cmd, levels)
+ case gotifyType:
+ legacyNotifier = newGotifyNotifier(cmd, []log.Level{})
+ default:
+ log.Fatalf("Unknown notification type %q", t)
+ // Not really needed, used for nil checking static analysis
+ continue
+ }
+
+ shoutrrrURL, err := legacyNotifier.GetURL()
+ if err != nil {
+ log.Fatal("failed to create notification config:", err)
+ }
+
+ log.WithField("URL", shoutrrrURL).Trace("created Shoutrrr URL from legacy notifier")
+
+ notifier := newShoutrrrNotifierFromURL(
+ cmd,
+ shoutrrrURL,
+ levels,
+ )
+
+ output = append(output, notifier)
+ }
+
+ return output
+}
+
// StartNotification starts a log batch. Notifications will be accumulated after this point and only sent when SendNotification() is called.
func (n *Notifier) StartNotification() {
for _, t := range n.types {
@@ -73,3 +137,20 @@ func (n *Notifier) Close() {
t.Close()
}
}
+
+// GetTitle returns a common notification title with hostname appended
+func GetTitle() (title string) {
+ title = "Watchtower updates"
+
+ if hostname, err := os.Hostname(); err == nil {
+ title += " on " + hostname
+ }
+
+ return
+}
+
+// ColorHex is the default notification color used for services that support it (formatted as a CSS hex string)
+const ColorHex = "#406170"
+
+// ColorInt is the default notification color used for services that support it (as an int value)
+const ColorInt = 0x406170
diff --git a/pkg/notifications/notifier_test.go b/pkg/notifications/notifier_test.go
new file mode 100644
index 0000000..ba6657a
--- /dev/null
+++ b/pkg/notifications/notifier_test.go
@@ -0,0 +1,223 @@
+package notifications_test
+
+import (
+ "fmt"
+ "net/url"
+ "os"
+ "testing"
+
+ "github.com/containrrr/watchtower/cmd"
+ "github.com/containrrr/watchtower/internal/flags"
+ "github.com/containrrr/watchtower/pkg/notifications"
+ "github.com/containrrr/watchtower/pkg/types"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+func TestActions(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Notifier Suite")
+}
+
+var _ = Describe("notifications", func() {
+ Describe("the notifier", func() {
+ When("only empty notifier types are provided", func() {
+
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ err := command.ParseFlags([]string{
+ "--notifications",
+ "shoutrrr",
+ })
+ Expect(err).NotTo(HaveOccurred())
+ notif := notifications.NewNotifier(command)
+
+ Expect(notif.String()).To(Equal("none"))
+ })
+ })
+ Describe("the slack notifier", func() {
+ builderFn := notifications.NewSlackNotifier
+
+ When("passing a discord url to the slack notifier", func() {
+ channel := "123456789"
+ token := "abvsihdbau"
+ color := notifications.ColorInt
+ title := url.QueryEscape(notifications.GetTitle())
+ expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&splitlines=Yes&title=%s&username=watchtower", token, channel, color, title)
+ buildArgs := func(url string) []string {
+ return []string{
+ "--notifications",
+ "slack",
+ "--notification-slack-hook-url",
+ url,
+ }
+ }
+
+ It("should return a discord url when using a hook url with the domain discord.com", func() {
+ hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discord.com", channel, token)
+ testURL(builderFn, buildArgs(hookURL), expected)
+ })
+ It("should return a discord url when using a hook url with the domain discordapp.com", func() {
+ hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discordapp.com", channel, token)
+ testURL(builderFn, buildArgs(hookURL), expected)
+ })
+ })
+ When("converting a slack service config into a shoutrrr url", func() {
+
+ It("should return the expected URL", func() {
+
+ username := "containrrrbot"
+ tokenA := "aaa"
+ tokenB := "bbb"
+ tokenC := "ccc"
+ color := url.QueryEscape(notifications.ColorHex)
+ title := url.QueryEscape(notifications.GetTitle())
+
+ hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
+ expectedOutput := fmt.Sprintf("slack://%s@%s/%s/%s?color=%s&title=%s", username, tokenA, tokenB, tokenC, color, title)
+
+ args := []string{
+ "--notification-slack-hook-url",
+ hookURL,
+ "--notification-slack-identifier",
+ username,
+ }
+
+ testURL(builderFn, args, expectedOutput)
+ })
+ })
+ })
+
+ Describe("the gotify notifier", func() {
+ When("converting a gotify service config into a shoutrrr url", func() {
+ builderFn := notifications.NewGotifyNotifier
+
+ It("should return the expected URL", func() {
+ token := "aaa"
+ host := "shoutrrr.local"
+ title := url.QueryEscape(notifications.GetTitle())
+
+ expectedOutput := fmt.Sprintf("gotify://%s/%s?title=%s", host, token, title)
+
+ args := []string{
+ "--notification-gotify-url",
+ fmt.Sprintf("https://%s", host),
+ "--notification-gotify-token",
+ token,
+ }
+
+ testURL(builderFn, args, expectedOutput)
+ })
+ })
+ })
+
+ Describe("the teams notifier", func() {
+ When("converting a teams service config into a shoutrrr url", func() {
+ builderFn := notifications.NewMsTeamsNotifier
+
+ It("should return the expected URL", func() {
+
+ tokenA := "11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc"
+ tokenB := "33333333012222222222333333333344"
+ tokenC := "44444444-4444-4444-8444-cccccccccccc"
+ color := url.QueryEscape(notifications.ColorHex)
+ title := url.QueryEscape(notifications.GetTitle())
+
+ 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)
+
+ args := []string{
+ "--notification-msteams-hook",
+ hookURL,
+ }
+
+ testURL(builderFn, args, expectedOutput)
+ })
+ })
+ })
+
+ Describe("the email notifier", func() {
+
+ builderFn := notifications.NewEmailNotifier
+
+ When("converting an email service config into a shoutrrr url", func() {
+ It("should set the from address in the URL", func() {
+ fromAddress := "lala@example.com"
+ expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, "mail@example.com", "Plain")
+ args := []string{
+ "--notification-email-from",
+ fromAddress,
+ "--notification-email-to",
+ "mail@example.com",
+ "--notification-email-server-user",
+ "containrrrbot",
+ "--notification-email-server-password",
+ "secret-password",
+ "--notification-email-server",
+ "mail.containrrr.dev",
+ }
+ testURL(builderFn, args, expectedOutput)
+ })
+
+ It("should return the expected URL", func() {
+
+ fromAddress := "sender@example.com"
+ toAddress := "receiver@example.com"
+ expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, toAddress, "Plain")
+
+ args := []string{
+ "--notification-email-from",
+ fromAddress,
+ "--notification-email-to",
+ toAddress,
+ "--notification-email-server-user",
+ "containrrrbot",
+ "--notification-email-server-password",
+ "secret-password",
+ "--notification-email-server",
+ "mail.containrrr.dev",
+ }
+
+ testURL(builderFn, args, expectedOutput)
+ })
+ })
+ })
+})
+
+func buildExpectedURL(username string, password string, host string, port int, from string, to string, auth string) string {
+ hostname, err := os.Hostname()
+ Expect(err).NotTo(HaveOccurred())
+
+ subject := fmt.Sprintf("Watchtower updates on %s", hostname)
+
+ var template = "smtp://%s:%s@%s:%d/?auth=%s&fromaddress=%s&fromname=Watchtower&subject=%s&toaddresses=%s"
+ return fmt.Sprintf(template,
+ url.QueryEscape(username),
+ url.QueryEscape(password),
+ host, port, auth,
+ url.QueryEscape(from),
+ url.QueryEscape(subject),
+ url.QueryEscape(to))
+}
+
+type builderFn = func(c *cobra.Command, acceptedLogLevels []log.Level) types.ConvertibleNotifier
+
+func testURL(builder builderFn, args []string, expectedURL string) {
+
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ err := command.ParseFlags(args)
+ Expect(err).NotTo(HaveOccurred())
+
+ notifier := builder(command, []log.Level{})
+ actualURL, err := notifier.GetURL()
+
+ Expect(err).NotTo(HaveOccurred())
+
+ Expect(actualURL).To(Equal(expectedURL))
+}
diff --git a/pkg/notifications/shoutrrr.go b/pkg/notifications/shoutrrr.go
index d16808d..087e4d6 100644
--- a/pkg/notifications/shoutrrr.go
+++ b/pkg/notifications/shoutrrr.go
@@ -3,11 +3,12 @@ package notifications
import (
"bytes"
"fmt"
- "github.com/containrrr/shoutrrr/pkg/types"
+ stdlog "log"
"strings"
"text/template"
"github.com/containrrr/shoutrrr"
+ "github.com/containrrr/shoutrrr/pkg/types"
t "github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
@@ -33,11 +34,35 @@ type shoutrrrTypeNotifier struct {
done chan bool
}
+func (n *shoutrrrTypeNotifier) GetNames() []string {
+ names := make([]string, len(n.Urls))
+ for i, u := range n.Urls {
+ schemeEnd := strings.Index(u, ":")
+ if schemeEnd <= 0 {
+ names[i] = "invalid"
+ continue
+ }
+ names[i] = u[:schemeEnd]
+ }
+ return names
+}
+
func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
flags := c.PersistentFlags()
-
urls, _ := flags.GetStringArray("notification-url")
- r, err := shoutrrr.CreateSender(urls...)
+ tpl := getShoutrrrTemplate(c)
+ return createSender(urls, acceptedLogLevels, tpl)
+}
+
+func newShoutrrrNotifierFromURL(c *cobra.Command, url string, levels []log.Level) t.Notifier {
+ tpl := getShoutrrrTemplate(c)
+ return createSender([]string{url}, levels, tpl)
+}
+
+func createSender(urls []string, levels []log.Level, template *template.Template) t.Notifier {
+
+ traceWriter := log.StandardLogger().WriterLevel(log.TraceLevel)
+ r, err := shoutrrr.NewSender(stdlog.New(traceWriter, "Shoutrrr: ", 0), urls...)
if err != nil {
log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error())
}
@@ -45,10 +70,10 @@ func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Noti
n := &shoutrrrTypeNotifier{
Urls: urls,
Router: r,
- logLevels: acceptedLogLevels,
- template: getShoutrrrTemplate(c),
messages: make(chan string, 1),
done: make(chan bool),
+ logLevels: levels,
+ template: template,
}
log.AddHook(n)
@@ -74,54 +99,54 @@ func sendNotifications(n *shoutrrrTypeNotifier) {
n.done <- true
}
-func (e *shoutrrrTypeNotifier) buildMessage(entries []*log.Entry) string {
+func (n *shoutrrrTypeNotifier) buildMessage(entries []*log.Entry) string {
var body bytes.Buffer
- if err := e.template.Execute(&body, entries); err != nil {
+ if err := n.template.Execute(&body, entries); err != nil {
fmt.Printf("Failed to execute Shoutrrrr template: %s\n", err.Error())
}
return body.String()
}
-func (e *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry) {
- msg := e.buildMessage(entries)
- e.messages <- msg
+func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry) {
+ msg := n.buildMessage(entries)
+ n.messages <- msg
}
-func (e *shoutrrrTypeNotifier) StartNotification() {
- if e.entries == nil {
- e.entries = make([]*log.Entry, 0, 10)
+func (n *shoutrrrTypeNotifier) StartNotification() {
+ if n.entries == nil {
+ n.entries = make([]*log.Entry, 0, 10)
}
}
-func (e *shoutrrrTypeNotifier) SendNotification() {
- if e.entries == nil || len(e.entries) <= 0 {
+func (n *shoutrrrTypeNotifier) SendNotification() {
+ if n.entries == nil || len(n.entries) <= 0 {
return
}
- e.sendEntries(e.entries)
- e.entries = nil
+ n.sendEntries(n.entries)
+ n.entries = nil
}
-func (e *shoutrrrTypeNotifier) Close() {
- close(e.messages)
+func (n *shoutrrrTypeNotifier) Close() {
+ close(n.messages)
// Use fmt so it doesn't trigger another notification.
fmt.Println("Waiting for the notification goroutine to finish")
- _ = <-e.done
+ _ = <-n.done
}
-func (e *shoutrrrTypeNotifier) Levels() []log.Level {
- return e.logLevels
+func (n *shoutrrrTypeNotifier) Levels() []log.Level {
+ return n.logLevels
}
-func (e *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
- if e.entries != nil {
- e.entries = append(e.entries, entry)
+func (n *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
+ if n.entries != nil {
+ n.entries = append(n.entries, entry)
} else {
// Log output generated outside a cycle is sent immediately.
- e.sendEntries([]*log.Entry{entry})
+ n.sendEntries([]*log.Entry{entry})
}
return nil
}
diff --git a/pkg/notifications/slack.go b/pkg/notifications/slack.go
index 5f96390..b3df119 100644
--- a/pkg/notifications/slack.go
+++ b/pkg/notifications/slack.go
@@ -1,6 +1,10 @@
package notifications
import (
+ "strings"
+
+ shoutrrrDisco "github.com/containrrr/shoutrrr/pkg/services/discord"
+ shoutrrrSlack "github.com/containrrr/shoutrrr/pkg/services/slack"
t "github.com/containrrr/watchtower/pkg/types"
"github.com/johntdyer/slackrus"
log "github.com/sirupsen/logrus"
@@ -15,7 +19,12 @@ type slackTypeNotifier struct {
slackrus.SlackrusHook
}
-func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
+// NewSlackNotifier is a factory function used to generate new instance of the slack notifier type
+func NewSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
+ return newSlackNotifier(c, acceptedLogLevels)
+}
+
+func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
flags := c.PersistentFlags()
hookURL, _ := flags.GetString("notification-slack-hook-url")
@@ -34,13 +43,36 @@ func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie
AcceptedLevels: acceptedLogLevels,
},
}
-
- log.AddHook(n)
return n
}
-func (s *slackTypeNotifier) StartNotification() {}
+func (s *slackTypeNotifier) GetURL() (string, error) {
+ trimmedURL := strings.TrimRight(s.HookURL, "/")
+ trimmedURL = strings.TrimLeft(trimmedURL, "https://")
+ parts := strings.Split(trimmedURL, "/")
-func (s *slackTypeNotifier) SendNotification() {}
+ if parts[0] == "discord.com" || parts[0] == "discordapp.com" {
+ log.Debug("Detected a discord slack wrapper URL, using shoutrrr discord service")
+ conf := &shoutrrrDisco.Config{
+ Channel: parts[len(parts)-3],
+ Token: parts[len(parts)-2],
+ Color: ColorInt,
+ Title: GetTitle(),
+ SplitLines: true,
+ Username: s.Username,
+ }
+ return conf.GetURL().String(), nil
+ }
-func (s *slackTypeNotifier) Close() {}
+ rawTokens := strings.Replace(s.HookURL, "https://hooks.slack.com/services/", "", 1)
+ tokens := strings.Split(rawTokens, "/")
+
+ conf := &shoutrrrSlack.Config{
+ BotName: s.Username,
+ Token: tokens,
+ Color: ColorHex,
+ Title: GetTitle(),
+ }
+
+ return conf.GetURL().String(), nil
+}
diff --git a/pkg/registry/auth/auth.go b/pkg/registry/auth/auth.go
new file mode 100644
index 0000000..99e307c
--- /dev/null
+++ b/pkg/registry/auth/auth.go
@@ -0,0 +1,192 @@
+package auth
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/containrrr/watchtower/pkg/registry/helpers"
+ "github.com/containrrr/watchtower/pkg/types"
+ "github.com/docker/distribution/reference"
+ "github.com/sirupsen/logrus"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+// ChallengeHeader is the HTTP Header containing challenge instructions
+const ChallengeHeader = "WWW-Authenticate"
+
+// GetToken fetches a token for the registry hosting the provided image
+func GetToken(container types.Container, registryAuth string) (string, error) {
+ var err error
+ var URL url.URL
+
+ if URL, err = GetChallengeURL(container.ImageName()); err != nil {
+ return "", err
+ }
+ logrus.WithField("URL", URL.String()).Debug("Building challenge URL")
+
+ var req *http.Request
+ if req, err = GetChallengeRequest(URL); err != nil {
+ return "", err
+ }
+
+ client := &http.Client{}
+ var res *http.Response
+ if res, err = client.Do(req); err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+ v := res.Header.Get(ChallengeHeader)
+
+ logrus.WithFields(logrus.Fields{
+ "status": res.Status,
+ "header": v,
+ }).Debug("Got response to challenge request")
+
+ challenge := strings.ToLower(v)
+ if strings.HasPrefix(challenge, "basic") {
+ if registryAuth == "" {
+ return "", fmt.Errorf("no credentials available")
+ }
+
+ return fmt.Sprintf("Basic %s", registryAuth), nil
+ }
+ if strings.HasPrefix(challenge, "bearer") {
+ return GetBearerHeader(challenge, container.ImageName(), err, registryAuth)
+ }
+
+ return "", errors.New("unsupported challenge type from registry")
+}
+
+// GetChallengeRequest creates a request for getting challenge instructions
+func GetChallengeRequest(URL url.URL) (*http.Request, error) {
+ req, err := http.NewRequest("GET", URL.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "*/*")
+ req.Header.Set("User-Agent", "Watchtower (Docker)")
+ return req, nil
+}
+
+// GetBearerHeader tries to fetch a bearer token from the registry based on the challenge instructions
+func GetBearerHeader(challenge string, img string, err error, registryAuth string) (string, error) {
+ client := http.Client{}
+ if strings.Contains(img, ":") {
+ img = strings.Split(img, ":")[0]
+ }
+ authURL, err := GetAuthURL(challenge, img)
+
+ if err != nil {
+ return "", err
+ }
+
+ var r *http.Request
+ if r, err = http.NewRequest("GET", authURL.String(), nil); err != nil {
+ return "", err
+ }
+
+ if registryAuth != "" {
+ logrus.Debug("Credentials found.")
+ logrus.Tracef("Credentials: %v", registryAuth)
+ r.Header.Add("Authorization", fmt.Sprintf("Basic %s", registryAuth))
+ } else {
+ logrus.Debug("No credentials found.")
+ }
+
+ var authResponse *http.Response
+ if authResponse, err = client.Do(r); err != nil {
+ return "", err
+ }
+
+ body, _ := ioutil.ReadAll(authResponse.Body)
+ tokenResponse := &types.TokenResponse{}
+
+ err = json.Unmarshal(body, tokenResponse)
+ if err != nil {
+ return "", err
+ }
+
+ return fmt.Sprintf("Bearer %s", tokenResponse.Token), nil
+}
+
+// GetAuthURL from the instructions in the challenge
+func GetAuthURL(challenge string, img string) (*url.URL, error) {
+ loweredChallenge := strings.ToLower(challenge)
+ raw := strings.TrimPrefix(loweredChallenge, "bearer")
+
+ pairs := strings.Split(raw, ",")
+ values := make(map[string]string, len(pairs))
+
+ for _, pair := range pairs {
+ trimmed := strings.Trim(pair, " ")
+ kv := strings.Split(trimmed, "=")
+ key := kv[0]
+ val := strings.Trim(kv[1], "\"")
+ values[key] = val
+ }
+ logrus.WithFields(logrus.Fields{
+ "realm": values["realm"],
+ "service": values["service"],
+ }).Debug("Checking challenge header content")
+ if values["realm"] == "" || values["service"] == "" {
+
+ return nil, fmt.Errorf("challenge header did not include all values needed to construct an auth url")
+ }
+
+ authURL, _ := url.Parse(fmt.Sprintf("%s", values["realm"]))
+ q := authURL.Query()
+ q.Add("service", values["service"])
+
+ scopeImage := GetScopeFromImageName(img, values["service"])
+
+ scope := fmt.Sprintf("repository:%s:pull", scopeImage)
+ logrus.WithFields(logrus.Fields{"scope": scope, "image": img}).Debug("Setting scope for auth token")
+ q.Add("scope", scope)
+
+ authURL.RawQuery = q.Encode()
+ return authURL, nil
+}
+
+// GetScopeFromImageName normalizes an image name for use as scope during auth and head requests
+func GetScopeFromImageName(img, svc string) string {
+ parts := strings.Split(img, "/")
+
+ if len(parts) > 2 {
+ if strings.Contains(svc, "docker.io") {
+ return fmt.Sprintf("%s/%s", parts[1], strings.Join(parts[2:], "/"))
+ }
+ return strings.Join(parts, "/")
+ }
+
+ if len(parts) == 2 {
+ if strings.Contains(parts[0], "docker.io") {
+ return fmt.Sprintf("library/%s", parts[1])
+ }
+ return strings.Replace(img, svc+"/", "", 1)
+ }
+
+ if strings.Contains(svc, "docker.io") {
+ return fmt.Sprintf("library/%s", parts[0])
+ }
+ return img
+}
+
+// GetChallengeURL creates a URL object based on the image info
+func GetChallengeURL(img string) (url.URL, error) {
+
+ normalizedNamed, _ := reference.ParseNormalizedNamed(img)
+ host, err := helpers.NormalizeRegistry(normalizedNamed.String())
+ if err != nil {
+ return url.URL{}, err
+ }
+
+ URL := url.URL{
+ Scheme: "https",
+ Host: host,
+ Path: "/v2/",
+ }
+ return URL, nil
+}
diff --git a/pkg/registry/auth/auth_test.go b/pkg/registry/auth/auth_test.go
new file mode 100644
index 0000000..6ad2307
--- /dev/null
+++ b/pkg/registry/auth/auth_test.go
@@ -0,0 +1,120 @@
+package auth_test
+
+import (
+ "fmt"
+ "github.com/containrrr/watchtower/internal/actions/mocks"
+ "github.com/containrrr/watchtower/pkg/registry/auth"
+ "net/url"
+ "os"
+ "testing"
+ "time"
+
+ wtTypes "github.com/containrrr/watchtower/pkg/types"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+func TestAuth(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Registry Auth Suite")
+}
+func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() {
+ if credentials.Username == "" {
+ return func() {
+ Skip("Username missing. Skipping integration test")
+ }
+ } else if credentials.Password == "" {
+ return func() {
+ Skip("Password missing. Skipping integration test")
+ }
+ } else {
+ return fn
+ }
+}
+
+var GHCRCredentials = &wtTypes.RegistryCredentials{
+ Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"),
+ Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"),
+}
+
+var _ = Describe("the auth module", func() {
+ mockId := "mock-id"
+ mockName := "mock-container"
+ mockImage := "ghcr.io/k6io/operator:latest"
+ mockCreated := time.Now()
+ mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547"
+
+ mockContainer := mocks.CreateMockContainerWithDigest(
+ mockId,
+ mockName,
+ mockImage,
+ mockCreated,
+ mockDigest)
+
+ When("getting an auth url", func() {
+ It("should parse the token from the response",
+ SkipIfCredentialsEmpty(GHCRCredentials, func() {
+ creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
+ token, err := auth.GetToken(mockContainer, creds)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(token).NotTo(Equal(""))
+ }),
+ )
+
+ It("should create a valid auth url object based on the challenge header supplied", func() {
+ input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"`
+ expected := &url.URL{
+ Host: "ghcr.io",
+ Scheme: "https",
+ Path: "/token",
+ RawQuery: "scope=repository%3Acontainrrr%2Fwatchtower%3Apull&service=ghcr.io",
+ }
+ res, err := auth.GetAuthURL(input, "containrrr/watchtower")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(res).To(Equal(expected))
+ })
+ It("should create a valid auth url object based on the challenge header supplied", func() {
+ input := `bearer realm="https://ghcr.io/token"`
+ res, err := auth.GetAuthURL(input, "containrrr/watchtower")
+ Expect(err).To(HaveOccurred())
+ Expect(res).To(BeNil())
+ })
+ })
+ When("getting a challenge url", func() {
+ It("should create a valid challenge url object based on the image ref supplied", func() {
+ expected := url.URL{Host: "ghcr.io", Scheme: "https", Path: "/v2/"}
+ Expect(auth.GetChallengeURL("ghcr.io/containrrr/watchtower:latest")).To(Equal(expected))
+ })
+ It("should assume dockerhub if the image ref is not fully qualified", func() {
+ expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
+ Expect(auth.GetChallengeURL("containrrr/watchtower:latest")).To(Equal(expected))
+ })
+ It("should convert legacy dockerhub hostnames to index.docker.io", func() {
+ expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
+ Expect(auth.GetChallengeURL("docker.io/containrrr/watchtower:latest")).To(Equal(expected))
+ Expect(auth.GetChallengeURL("registry-1.docker.io/containrrr/watchtower:latest")).To(Equal(expected))
+ })
+ })
+ When("getting the auth scope from an image name", func() {
+ It("should prepend official dockerhub images with \"library/\"", func() {
+ Expect(auth.GetScopeFromImageName("docker.io/registry", "index.docker.io")).To(Equal("library/registry"))
+ Expect(auth.GetScopeFromImageName("docker.io/registry", "docker.io")).To(Equal("library/registry"))
+
+ Expect(auth.GetScopeFromImageName("registry", "index.docker.io")).To(Equal("library/registry"))
+ Expect(auth.GetScopeFromImageName("watchtower", "registry-1.docker.io")).To(Equal("library/watchtower"))
+
+ })
+ It("should not include vanity hosts\"", func() {
+ Expect(auth.GetScopeFromImageName("docker.io/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
+ Expect(auth.GetScopeFromImageName("index.docker.io/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
+ })
+ It("should not destroy three segment image names\"", func() {
+ Expect(auth.GetScopeFromImageName("piksel/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
+ Expect(auth.GetScopeFromImageName("piksel/containrrr/watchtower", "ghcr.io")).To(Equal("piksel/containrrr/watchtower"))
+ })
+ It("should not add \"library/\" for one segment image names if they're not on dockerhub", func() {
+ Expect(auth.GetScopeFromImageName("ghcr.io/watchtower", "ghcr.io")).To(Equal("watchtower"))
+ Expect(auth.GetScopeFromImageName("watchtower", "ghcr.io")).To(Equal("watchtower"))
+ })
+ })
+})
diff --git a/pkg/registry/digest/digest.go b/pkg/registry/digest/digest.go
new file mode 100644
index 0000000..4634688
--- /dev/null
+++ b/pkg/registry/digest/digest.go
@@ -0,0 +1,117 @@
+package digest
+
+import (
+ "crypto/tls"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/containrrr/watchtower/pkg/registry/auth"
+ "github.com/containrrr/watchtower/pkg/registry/manifest"
+ "github.com/containrrr/watchtower/pkg/types"
+ "github.com/sirupsen/logrus"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// ContentDigestHeader is the key for the key-value pair containing the digest header
+const ContentDigestHeader = "Docker-Content-Digest"
+
+// CompareDigest ...
+func CompareDigest(container types.Container, registryAuth string) (bool, error) {
+ var digest string
+
+ registryAuth = TransformAuth(registryAuth)
+ token, err := auth.GetToken(container, registryAuth)
+ if err != nil {
+ return false, err
+ }
+
+ digestURL, err := manifest.BuildManifestURL(container)
+ if err != nil {
+ return false, err
+ }
+
+ if digest, err = GetDigest(digestURL, token); err != nil {
+ return false, err
+ }
+
+ logrus.WithField("remote", digest).Debug("Found a remote digest to compare with")
+
+ for _, dig := range container.ImageInfo().RepoDigests {
+ localDigest := strings.Split(dig, "@")[1]
+ fields := logrus.Fields{"local": localDigest, "remote": digest}
+ logrus.WithFields(fields).Debug("Comparing")
+
+ if localDigest == digest {
+ logrus.Debug("Found a match")
+ return true, nil
+ }
+ }
+
+ return false, nil
+}
+
+// TransformAuth from a base64 encoded json object to base64 encoded string
+func TransformAuth(registryAuth string) string {
+ b, _ := base64.StdEncoding.DecodeString(registryAuth)
+ credentials := &types.RegistryCredentials{}
+ _ = json.Unmarshal(b, credentials)
+
+ if credentials.Username != "" && credentials.Password != "" {
+ ba := []byte(fmt.Sprintf("%s:%s", credentials.Username, credentials.Password))
+ registryAuth = base64.StdEncoding.EncodeToString(ba)
+ }
+
+ return registryAuth
+}
+
+// GetDigest from registry using a HEAD request to prevent rate limiting
+func GetDigest(url string, token string) (string, error) {
+ tr := &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ DialContext: (&net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }).DialContext,
+ ForceAttemptHTTP2: true,
+ MaxIdleConns: 100,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ }
+ client := &http.Client{Transport: tr}
+
+ req, _ := http.NewRequest("HEAD", url, nil)
+
+ if token != "" {
+ logrus.WithField("token", token).Trace("Setting request token")
+ } else {
+ return "", errors.New("could not fetch token")
+ }
+
+ req.Header.Add("Authorization", token)
+ req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json")
+ req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.list.v2+json")
+ req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v1+json")
+
+ logrus.WithField("url", url).Debug("Doing a HEAD request to fetch a digest")
+
+ res, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != 200 {
+ wwwAuthHeader := res.Header.Get("www-authenticate")
+ if wwwAuthHeader == "" {
+ wwwAuthHeader = "not present"
+ }
+ return "", fmt.Errorf("registry responded to head request with %q, auth: %q", res.Status, wwwAuthHeader)
+ }
+ return res.Header.Get(ContentDigestHeader), nil
+}
diff --git a/pkg/registry/digest/digest_test.go b/pkg/registry/digest/digest_test.go
new file mode 100644
index 0000000..0de6025
--- /dev/null
+++ b/pkg/registry/digest/digest_test.go
@@ -0,0 +1,87 @@
+package digest_test
+
+import (
+ "fmt"
+ "github.com/containrrr/watchtower/internal/actions/mocks"
+ "github.com/containrrr/watchtower/pkg/registry/digest"
+ wtTypes "github.com/containrrr/watchtower/pkg/types"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+ "os"
+ "testing"
+ "time"
+)
+
+func TestDigest(t *testing.T) {
+
+ RegisterFailHandler(Fail)
+ RunSpecs(GinkgoT(), "Digest Suite")
+}
+
+var DockerHubCredentials = &wtTypes.RegistryCredentials{
+ Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_USERNAME"),
+ Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_PASSWORD"),
+}
+var GHCRCredentials = &wtTypes.RegistryCredentials{
+ Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"),
+ Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"),
+}
+
+func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() {
+ if credentials.Username == "" {
+ return func() {
+ Skip("Username missing. Skipping integration test")
+ }
+ } else if credentials.Password == "" {
+ return func() {
+ Skip("Password missing. Skipping integration test")
+ }
+ } else {
+ return fn
+ }
+}
+
+var _ = Describe("Digests", func() {
+ mockId := "mock-id"
+ mockName := "mock-container"
+ mockImage := "ghcr.io/k6io/operator:latest"
+ mockCreated := time.Now()
+ mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547"
+
+ mockContainer := mocks.CreateMockContainerWithDigest(
+ mockId,
+ mockName,
+ mockImage,
+ mockCreated,
+ mockDigest)
+
+ When("a digest comparison is done", func() {
+ It("should return true if digests match",
+ SkipIfCredentialsEmpty(GHCRCredentials, func() {
+ creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
+ matches, err := digest.CompareDigest(mockContainer, creds)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(matches).To(Equal(true))
+ }),
+ )
+
+ It("should return false if digests differ", func() {
+
+ })
+ It("should return an error if the registry isn't available", func() {
+
+ })
+ })
+ When("using different registries", func() {
+ It("should work with DockerHub",
+ SkipIfCredentialsEmpty(DockerHubCredentials, func() {
+ fmt.Println(DockerHubCredentials != nil) // to avoid crying linters
+ }),
+ )
+ It("should work with GitHub Container Registry",
+ SkipIfCredentialsEmpty(GHCRCredentials, func() {
+ fmt.Println(GHCRCredentials != nil) // to avoid crying linters
+ }),
+ )
+ })
+})
diff --git a/pkg/registry/helpers/helpers.go b/pkg/registry/helpers/helpers.go
new file mode 100644
index 0000000..1469331
--- /dev/null
+++ b/pkg/registry/helpers/helpers.go
@@ -0,0 +1,36 @@
+package helpers
+
+import (
+ "fmt"
+ url2 "net/url"
+)
+
+// ConvertToHostname strips a url from everything but the hostname part
+func ConvertToHostname(url string) (string, string, error) {
+ urlWithSchema := fmt.Sprintf("x://%s", url)
+ u, err := url2.Parse(urlWithSchema)
+ if err != nil {
+ return "", "", err
+ }
+ hostName := u.Hostname()
+ port := u.Port()
+
+ return hostName, port, err
+}
+
+// NormalizeRegistry makes sure variations of DockerHubs registry
+func NormalizeRegistry(registry string) (string, error) {
+ hostName, port, err := ConvertToHostname(registry)
+ if err != nil {
+ return "", err
+ }
+
+ if hostName == "registry-1.docker.io" || hostName == "docker.io" {
+ hostName = "index.docker.io"
+ }
+
+ if port != "" {
+ return fmt.Sprintf("%s:%s", hostName, port), nil
+ }
+ return hostName, nil
+}
diff --git a/pkg/registry/helpers/helpers_test.go b/pkg/registry/helpers/helpers_test.go
new file mode 100644
index 0000000..92e9116
--- /dev/null
+++ b/pkg/registry/helpers/helpers_test.go
@@ -0,0 +1,31 @@
+package helpers
+
+import (
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+ "testing"
+)
+
+func TestHelpers(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Helper Suite")
+}
+
+var _ = Describe("the helpers", func() {
+
+ When("converting an url to a hostname", func() {
+ It("should return docker.io given docker.io/containrrr/watchtower:latest", func() {
+ host, port, err := ConvertToHostname("docker.io/containrrr/watchtower:latest")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(host).To(Equal("docker.io"))
+ Expect(port).To(BeEmpty())
+ })
+ })
+ When("normalizing the registry information", func() {
+ It("should return index.docker.io given docker.io", func() {
+ out, err := NormalizeRegistry("docker.io/containrrr/watchtower:latest")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(out).To(Equal("index.docker.io"))
+ })
+ })
+})
diff --git a/pkg/registry/manifest/manifest.go b/pkg/registry/manifest/manifest.go
new file mode 100644
index 0000000..facbb6c
--- /dev/null
+++ b/pkg/registry/manifest/manifest.go
@@ -0,0 +1,67 @@
+package manifest
+
+import (
+ "fmt"
+ "github.com/containrrr/watchtower/pkg/registry/auth"
+ "github.com/containrrr/watchtower/pkg/registry/helpers"
+ "github.com/containrrr/watchtower/pkg/types"
+ ref "github.com/docker/distribution/reference"
+ "github.com/sirupsen/logrus"
+ url2 "net/url"
+ "strings"
+)
+
+// BuildManifestURL from raw image data
+func BuildManifestURL(container types.Container) (string, error) {
+
+ normalizedName, err := ref.ParseNormalizedNamed(container.ImageName())
+ if err != nil {
+ return "", err
+ }
+
+ host, err := helpers.NormalizeRegistry(normalizedName.String())
+ img, tag := ExtractImageAndTag(strings.TrimPrefix(container.ImageName(), host+"/"))
+
+ logrus.WithFields(logrus.Fields{
+ "image": img,
+ "tag": tag,
+ "normalized": normalizedName,
+ "host": host,
+ }).Debug("Parsing image ref")
+
+ if err != nil {
+ return "", err
+ }
+ img = auth.GetScopeFromImageName(img, host)
+
+ if !strings.Contains(img, "/") {
+ img = "library/" + img
+ }
+ url := url2.URL{
+ Scheme: "https",
+ Host: host,
+ Path: fmt.Sprintf("/v2/%s/manifests/%s", img, tag),
+ }
+ return url.String(), nil
+}
+
+// ExtractImageAndTag from a concatenated string
+func ExtractImageAndTag(imageName string) (string, string) {
+ var img string
+ var tag string
+
+ if strings.Contains(imageName, ":") {
+ parts := strings.Split(imageName, ":")
+ if len(parts) > 2 {
+ img = parts[0]
+ tag = strings.Join(parts[1:], ":")
+ } else {
+ img = parts[0]
+ tag = parts[1]
+ }
+ } else {
+ img = imageName
+ tag = "latest"
+ }
+ return img, tag
+}
diff --git a/pkg/registry/manifest/manifest_test.go b/pkg/registry/manifest/manifest_test.go
new file mode 100644
index 0000000..95f196b
--- /dev/null
+++ b/pkg/registry/manifest/manifest_test.go
@@ -0,0 +1,75 @@
+package manifest_test
+
+import (
+ "github.com/containrrr/watchtower/internal/actions/mocks"
+ "github.com/containrrr/watchtower/pkg/registry/manifest"
+ apiTypes "github.com/docker/docker/api/types"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+ "testing"
+ "time"
+)
+
+func TestManifest(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Manifest Suite")
+}
+
+var _ = Describe("the manifest module", func() {
+ mockId := "mock-id"
+ mockName := "mock-container"
+ mockCreated := time.Now()
+
+ When("building a manifest url", func() {
+ It("should return a valid url given a fully qualified image", func() {
+ expected := "https://ghcr.io/v2/containrrr/watchtower/manifests/latest"
+ imageInfo := apiTypes.ImageInspect{
+ RepoTags: []string{
+ "ghcr.io/k6io/operator:latest",
+ },
+ }
+ mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "ghcr.io/containrrr/watchtower:latest", mockCreated, imageInfo)
+ res, err := manifest.BuildManifestURL(mock)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(res).To(Equal(expected))
+ })
+ It("should assume dockerhub for non-qualified images", func() {
+ expected := "https://index.docker.io/v2/containrrr/watchtower/manifests/latest"
+ imageInfo := apiTypes.ImageInspect{
+ RepoTags: []string{
+ "containrrr/watchtower:latest",
+ },
+ }
+
+ mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "containrrr/watchtower:latest", mockCreated, imageInfo)
+ res, err := manifest.BuildManifestURL(mock)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(res).To(Equal(expected))
+ })
+ It("should assume latest for images that lack an explicit tag", func() {
+ expected := "https://index.docker.io/v2/containrrr/watchtower/manifests/latest"
+ imageInfo := apiTypes.ImageInspect{
+
+ RepoTags: []string{
+ "containrrr/watchtower",
+ },
+ }
+
+ mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "containrrr/watchtower", mockCreated, imageInfo)
+
+ res, err := manifest.BuildManifestURL(mock)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(res).To(Equal(expected))
+ })
+ It("should combine the tag name and digest pinning into one digest, given multiple colons", func() {
+ in := "containrrr/watchtower:latest@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7"
+ image, tag := "containrrr/watchtower", "latest@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7"
+
+ imageOut, tagOut := manifest.ExtractImageAndTag(in)
+
+ Expect(imageOut).To(Equal(image))
+ Expect(tagOut).To(Equal(tag))
+ })
+ })
+
+})
diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go
index 98eab0e..9edd66f 100644
--- a/pkg/registry/registry.go
+++ b/pkg/registry/registry.go
@@ -1,6 +1,9 @@
package registry
import (
+ "github.com/containrrr/watchtower/pkg/registry/helpers"
+ watchtowerTypes "github.com/containrrr/watchtower/pkg/types"
+ ref "github.com/docker/distribution/reference"
"github.com/docker/docker/api/types"
log "github.com/sirupsen/logrus"
)
@@ -31,3 +34,26 @@ func DefaultAuthHandler() (string, error) {
log.Debug("Authentication request was rejected. Trying again without authentication")
return "", nil
}
+
+// WarnOnAPIConsumption will return true if the registry is known-expected
+// to respond well to HTTP HEAD in checking the container digest -- or if there
+// are problems parsing the container hostname.
+// Will return false if behavior for container is unknown.
+func WarnOnAPIConsumption(container watchtowerTypes.Container) bool {
+
+ normalizedName, err := ref.ParseNormalizedNamed(container.ImageName())
+ if err != nil {
+ return true
+ }
+
+ containerHost, err := helpers.NormalizeRegistry(normalizedName.String())
+ if err != nil {
+ return true
+ }
+
+ if containerHost == "index.docker.io" || containerHost == "ghcr.io" {
+ return true
+ }
+
+ return false
+}
diff --git a/pkg/registry/registry_suite_test.go b/pkg/registry/registry_suite_test.go
new file mode 100644
index 0000000..fe31f12
--- /dev/null
+++ b/pkg/registry/registry_suite_test.go
@@ -0,0 +1,13 @@
+package registry_test
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+func TestRegistry(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Registry Suite")
+}
diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go
new file mode 100644
index 0000000..5f3f57f
--- /dev/null
+++ b/pkg/registry/registry_test.go
@@ -0,0 +1,45 @@
+package registry_test
+
+import (
+ "github.com/containrrr/watchtower/internal/actions/mocks"
+ unit "github.com/containrrr/watchtower/pkg/registry"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+
+ "time"
+)
+
+var _ = Describe("Registry", func() {
+ Describe("WarnOnAPIConsumption", func() {
+ When("Given a container with an image from ghcr.io", func() {
+ It("should want to warn", func() {
+ Expect(testContainerWithImage("ghcr.io/containrrr/watchtower")).To(BeTrue())
+ })
+ })
+ When("Given a container with an image implicitly from dockerhub", func() {
+ It("should want to warn", func() {
+ Expect(testContainerWithImage("docker:latest")).To(BeTrue())
+ })
+ })
+ When("Given a container with an image explicitly from dockerhub", func() {
+ It("should want to warn", func() {
+ Expect(testContainerWithImage("registry-1.docker.io/docker:latest")).To(BeTrue())
+ Expect(testContainerWithImage("index.docker.io/docker:latest")).To(BeTrue())
+ Expect(testContainerWithImage("docker.io/docker:latest")).To(BeTrue())
+ })
+
+ })
+ When("Given a container with an image from some other registry", func() {
+ It("should not want to warn", func() {
+ Expect(testContainerWithImage("docker.fsf.org/docker:latest")).To(BeFalse())
+ Expect(testContainerWithImage("altavista.com/docker:latest")).To(BeFalse())
+ Expect(testContainerWithImage("gitlab.com/docker:latest")).To(BeFalse())
+ })
+ })
+ })
+})
+
+func testContainerWithImage(imageName string) bool {
+ container := mocks.CreateMockContainer("", "", imageName, time.Now())
+ return unit.WarnOnAPIConsumption(container)
+}
diff --git a/pkg/registry/trust.go b/pkg/registry/trust.go
index 937d2c3..c2bf7da 100644
--- a/pkg/registry/trust.go
+++ b/pkg/registry/trust.go
@@ -66,7 +66,7 @@ func EncodedConfigAuth(ref string) (string, error) {
auth, _ := credStore.Get(server) // returns (types.AuthConfig{}) if server not in credStore
if auth == (types.AuthConfig{}) {
- log.Debugf("No credentials for %s in %s", server, configFile.Filename)
+ log.WithField("config_file", configFile.Filename).Debugf("No credentials for %s found", server)
return "", nil
}
log.Debugf("Loaded auth credentials for user %s, on registry %s, from file %s", auth.Username, ref, configFile.Filename)
diff --git a/pkg/registry/trust_test.go b/pkg/registry/trust_test.go
index 8ffe1b9..3dab6ad 100644
--- a/pkg/registry/trust_test.go
+++ b/pkg/registry/trust_test.go
@@ -1,59 +1,65 @@
package registry
import (
- "github.com/stretchr/testify/assert"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
"os"
- "testing"
)
-func TestEncodedEnvAuth_ShouldReturnAnErrorIfRepoEnvsAreUnset(t *testing.T) {
- os.Unsetenv("REPO_USER")
- os.Unsetenv("REPO_PASS")
- _, err := EncodedEnvAuth("")
- assert.Error(t, err)
-}
-func TestEncodedEnvAuth_ShouldReturnAuthHashIfRepoEnvsAreSet(t *testing.T) {
- expectedHash := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
+var _ = Describe("Testing with Ginkgo", func() {
+ It("encoded env auth_ should return an error if repo envs are unset", func() {
+ _ = os.Unsetenv("REPO_USER")
+ _ = os.Unsetenv("REPO_PASS")
- os.Setenv("REPO_USER", "containrrr-user")
- os.Setenv("REPO_PASS", "containrrr-pass")
- config, _ := EncodedEnvAuth("")
+ _, err := EncodedEnvAuth("")
+ Expect(err).To(HaveOccurred())
+ })
+ It("encoded env auth_ should return auth hash if repo envs are set", func() {
+ var err error
+ expectedHash := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
- assert.Equal(t, config, expectedHash)
-}
+ err = os.Setenv("REPO_USER", "containrrr-user")
+ Expect(err).NotTo(HaveOccurred())
-func TestEncodedConfigAuth_ShouldReturnAnErrorIfFileIsNotPresent(t *testing.T) {
- os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
- _, err := EncodedConfigAuth("")
- assert.Error(t, err)
-}
+ err = os.Setenv("REPO_PASS", "containrrr-pass")
+ Expect(err).NotTo(HaveOccurred())
-/*
- * TODO:
- * This part only confirms that it still works in the same way as it did
- * with the old version of the docker api client sdk. I'd say that
- * ParseServerAddress likely needs to be elaborated a bit to default to
- * dockerhub in case no server address was provided.
- *
- * ++ @simskij, 2019-04-04
- */
+ config, err := EncodedEnvAuth("")
+ Expect(config).To(Equal(expectedHash))
+ Expect(err).NotTo(HaveOccurred())
+ })
+ It("encoded config auth_ should return an error if file is not present", func() {
+ var err error
-func TestParseServerAddress_ShouldReturnErrorIfPassedEmptyString(t *testing.T) {
- _, err := ParseServerAddress("")
- assert.Error(t, err)
-}
+ err = os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
+ Expect(err).NotTo(HaveOccurred())
-func TestParseServerAddress_ShouldReturnTheRepoNameIfPassedAFullyQualifiedImageName(t *testing.T) {
- val, _ := ParseServerAddress("github.com/containrrrr/config")
- assert.Equal(t, val, "github.com")
-}
+ _, err = EncodedConfigAuth("")
+ Expect(err).To(HaveOccurred())
-func TestParseServerAddress_ShouldReturnTheOrganizationPartIfPassedAnImageNameMissingServerName(t *testing.T) {
- val, _ := ParseServerAddress("containrrr/config")
- assert.Equal(t, val, "containrrr")
-}
+ })
+ /*
+ * TODO:
+ * This part only confirms that it still works in the same way as it did
+ * with the old version of the docker api client sdk. I'd say that
+ * ParseServerAddress likely needs to be elaborated a bit to default to
+ * dockerhub in case no server address was provided.
+ *
+ * ++ @simskij, 2019-04-04
+ */
+ It("parse server address_ should return error if passed empty string", func() {
-func TestParseServerAddress_ShouldReturnTheServerNameIfPassedAFullyQualifiedImageName(t *testing.T) {
- val, _ := ParseServerAddress("github.com/containrrrr/config")
- assert.Equal(t, val, "github.com")
-}
+ _, err := ParseServerAddress("")
+ Expect(err).To(HaveOccurred())
+ })
+ It("parse server address_ should return the organization part if passed an image name missing server name", func() {
+
+ val, _ := ParseServerAddress("containrrr/config")
+ Expect(val).To(Equal("containrrr"))
+ })
+ It("parse server address_ should return the server name if passed a fully qualified image name", func() {
+
+ val, _ := ParseServerAddress("github.com/containrrrr/config")
+ Expect(val).To(Equal("github.com"))
+ })
+})
diff --git a/pkg/types/container.go b/pkg/types/container.go
new file mode 100644
index 0000000..50baac6
--- /dev/null
+++ b/pkg/types/container.go
@@ -0,0 +1,26 @@
+package types
+
+import "github.com/docker/docker/api/types"
+
+// Container is a docker container running an image
+type Container interface {
+ ContainerInfo() *types.ContainerJSON
+ ID() string
+ IsRunning() bool
+ Name() string
+ ImageID() string
+ ImageName() string
+ Enabled() (bool, bool)
+ IsMonitorOnly() bool
+ Scope() (string, bool)
+ Links() []string
+ ToRestart() bool
+ IsWatchtower() bool
+ StopSignal() string
+ HasImageInfo() bool
+ ImageInfo() *types.ImageInspect
+ GetLifecyclePreCheckCommand() string
+ GetLifecyclePostCheckCommand() string
+ GetLifecyclePreUpdateCommand() string
+ GetLifecyclePostUpdateCommand() string
+}
diff --git a/pkg/types/convertible_notifier.go b/pkg/types/convertible_notifier.go
new file mode 100644
index 0000000..2614d12
--- /dev/null
+++ b/pkg/types/convertible_notifier.go
@@ -0,0 +1,6 @@
+package types
+
+// ConvertibleNotifier is a notifier capable of creating a shoutrrr URL
+type ConvertibleNotifier interface {
+ GetURL() (string, error)
+}
diff --git a/pkg/types/notifier.go b/pkg/types/notifier.go
index 27dc483..f72f980 100644
--- a/pkg/types/notifier.go
+++ b/pkg/types/notifier.go
@@ -4,5 +4,6 @@ package types
type Notifier interface {
StartNotification()
SendNotification()
+ GetNames() []string
Close()
}
diff --git a/pkg/types/registry_credentials.go b/pkg/types/registry_credentials.go
new file mode 100644
index 0000000..607fa05
--- /dev/null
+++ b/pkg/types/registry_credentials.go
@@ -0,0 +1,7 @@
+package types
+
+// RegistryCredentials is a credential pair used for basic auth
+type RegistryCredentials struct {
+ Username string
+ Password string // usually a token rather than an actual password
+}
diff --git a/pkg/types/token_response.go b/pkg/types/token_response.go
new file mode 100644
index 0000000..722dde8
--- /dev/null
+++ b/pkg/types/token_response.go
@@ -0,0 +1,6 @@
+package types
+
+// TokenResponse is returned by the registry on successful authentication
+type TokenResponse struct {
+ Token string `json:"token"`
+}
diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml
new file mode 100644
index 0000000..1a30df0
--- /dev/null
+++ b/prometheus/prometheus.yml
@@ -0,0 +1,9 @@
+scrape_configs:
+ - job_name: watchtower
+ scrape_interval: 5s
+ metrics_path: /v1/metrics
+ bearer_token: demotoken
+ static_configs:
+ - targets:
+ - 'watchtower:8080'
+
diff --git a/scripts/codecov.sh b/scripts/codecov.sh
new file mode 100755
index 0000000..a3bc024
--- /dev/null
+++ b/scripts/codecov.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+
+go test -v -coverprofile coverage.out -covermode atomic ./...
+
+# Requires CODECOV_TOKEN to be set
+bash <(curl -s https://codecov.io/bash)
\ No newline at end of file
diff --git a/scripts/dependency-test.sh b/scripts/dependency-test.sh
new file mode 100755
index 0000000..0da0110
--- /dev/null
+++ b/scripts/dependency-test.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+# Simulates a container that will always be updated, checking whether it shuts down it's dependencies correctly.
+
+docker rm -f parent || true
+docker rm -f depending || true
+
+CHANGE=redis:latest
+KEEP=tutum/hello-world
+
+docker tag tutum/hello-world:latest redis:latest
+
+docker run -d --name parent $CHANGE
+docker run -d --name depending --link parent $KEEP
+
+go run . --run-once --debug $@