diff --git a/.all-contributorsrc b/.all-contributorsrc
index d084e9f..270f462 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -5,6 +5,30 @@
"imageSize": 100,
"commit": false,
"contributors": [
+ {
+ "login": "piksel",
+ "name": "nils måsén",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/807383?v=4",
+ "profile": "https://piksel.se",
+ "contributions": [
+ "code",
+ "doc",
+ "maintenance",
+ "review"
+ ]
+ },
+ {
+ "login": "simskij",
+ "name": "Simon Aronsson",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/1596025?v=4",
+ "profile": "http://simme.dev",
+ "contributions": [
+ "code",
+ "doc",
+ "maintenance",
+ "review"
+ ]
+ },
{
"login": "Codelica",
"name": "James",
@@ -273,18 +297,6 @@
"code"
]
},
- {
- "login": "simskij",
- "name": "Simon Aronsson",
- "avatar_url": "https://avatars0.githubusercontent.com/u/1596025?v=4",
- "profile": "http://simme.dev",
- "contributions": [
- "code",
- "maintenance",
- "review",
- "doc"
- ]
- },
{
"login": "Ansem93",
"name": "Ansem93",
@@ -311,7 +323,8 @@
"profile": "https://github.com/zoispag",
"contributions": [
"code",
- "review"
+ "review",
+ "maintenance"
]
},
{
@@ -507,16 +520,6 @@
"doc"
]
},
- {
- "login": "piksel",
- "name": "nils måsén",
- "avatar_url": "https://avatars2.githubusercontent.com/u/807383?v=4",
- "profile": "https://piksel.se",
- "contributions": [
- "doc",
- "code"
- ]
- },
{
"login": "arnested",
"name": "Arne Jørgensen",
@@ -641,10 +644,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"
]
@@ -691,7 +694,9 @@
"avatar_url": "https://avatars1.githubusercontent.com/u/1371562?v=4",
"profile": "https://github.com/ksurl",
"contributions": [
- "doc"
+ "doc",
+ "code",
+ "infra"
]
},
{
@@ -711,6 +716,166 @@
"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"
+ ]
+ },
+ {
+ "login": "DasSkelett",
+ "name": "DasSkelett",
+ "avatar_url": "https://avatars.githubusercontent.com/u/28812678?v=4",
+ "profile": "https://github.com/DasSkelett",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "zenjabba",
+ "name": "zenjabba",
+ "avatar_url": "https://avatars.githubusercontent.com/u/679864?v=4",
+ "profile": "https://github.com/zenjabba",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "djquan",
+ "name": "Dan Quan",
+ "avatar_url": "https://avatars.githubusercontent.com/u/3526705?v=4",
+ "profile": "https://quan.io",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "modem7",
+ "name": "modem7",
+ "avatar_url": "https://avatars.githubusercontent.com/u/4349962?v=4",
+ "profile": "https://github.com/modem7",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "hypnoglow",
+ "name": "Igor Zibarev",
+ "avatar_url": "https://avatars.githubusercontent.com/u/4853075?v=4",
+ "profile": "https://github.com/hypnoglow",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "patricegautier",
+ "name": "Patrice",
+ "avatar_url": "https://avatars.githubusercontent.com/u/38435239?v=4",
+ "profile": "https://github.com/patricegautier",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "jamesmacwhite",
+ "name": "James White",
+ "avatar_url": "https://avatars.githubusercontent.com/u/8067792?v=4",
+ "profile": "http://jamesw.link/me",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "Foxite",
+ "name": "Dirk Kok",
+ "avatar_url": "https://avatars.githubusercontent.com/u/20421657?v=4",
+ "profile": "https://ko-fi.com/foxite",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "EDIflyer",
+ "name": "EDIflyer",
+ "avatar_url": "https://avatars.githubusercontent.com/u/13610277?v=4",
+ "profile": "https://github.com/EDIflyer",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "jauderho",
+ "name": "Jauder Ho",
+ "avatar_url": "https://avatars.githubusercontent.com/u/13562?v=4",
+ "profile": "https://github.com/jauderho",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "andriibratanin",
+ "name": "Andrii Bratanin",
+ "avatar_url": "https://avatars.githubusercontent.com/u/20169213?v=4",
+ "profile": "https://github.com/andriibratanin"
+ },
+ {
+ "login": "IAmTamal",
+ "name": "Tamal Das ",
+ "avatar_url": "https://avatars.githubusercontent.com/u/72851613?v=4",
+ "profile": "https://tamal.vercel.app/",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "testwill",
+ "name": "guangwu",
+ "avatar_url": "https://avatars.githubusercontent.com/u/8717479?v=4",
+ "profile": "https://github.com/testwill",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "nothub",
+ "name": "Florian Hübner",
+ "avatar_url": "https://avatars.githubusercontent.com/u/48992448?v=4",
+ "profile": "http://hub.lol",
+ "contributions": [
+ "doc",
+ "code"
+ ]
}
],
"contributorsPerLine": 7,
@@ -719,5 +884,6 @@
"repoType": "github",
"repoHost": "https://github.com",
"commitConvention": "none",
- "skipCi": true
+ "skipCi": true,
+ "commitType": "docs"
}
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/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..fa2b0d3
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,14 @@
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+
+[*.css]
+indent_style = space
+indent_size = 2
+
+[{go.mod,go.sum,*.go}]
+indent_style = tab
+indent_size = 4
\ No newline at end of file
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index 419e96c..0000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-custom: https://www.amazon.com/hz/wishlist/ls/F94JJV822VX6
-github: simskij
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
new file mode 100644
index 0000000..d4b87f1
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -0,0 +1,71 @@
+name: 🐛 Bug report
+description: Create a report to help us improve
+labels: ["Priority: Medium, Status: Available, Type: Bug"]
+
+body:
+ - type: markdown
+ attributes:
+ value: Before submitting your issue, please make sure you're using the containrrr/watchtower:latest image. If not, switch to this image prior to posting your report. Other forks, or the old `v2tec` image are **not** supported.
+
+ - type: textarea
+ id: description
+ attributes:
+ label: Describe the bug
+ description: A clear and concise description of what the bug is
+ validations:
+ required: true
+
+ - type: textarea
+ id: reproduce
+ attributes:
+ label: Steps to reproduce
+ description: Steps to reproduce the behavior
+ value: |
+ 1. Go to '...'
+ 2. Click on '....'
+ 3. Scroll down to '....'
+ 4. See error
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ description: A clear and concise description of what you expected to happen.
+ validations:
+ required: true
+
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Screenshots
+ description: Please add screenshots if applicable
+ validations:
+ required: false
+
+ - type: textarea
+ attributes:
+ label: Environment
+ description: We would want to know the following things
+ value: |
+ - Platform
+ - Architecture
+ - Docker Version
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Your logs
+ description: Paste the logs from running watchtower with the `--debug` option.
+ render: text
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Additional context
+ description: Add any other context about the problem here.
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 1d4b1f6..0000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,41 +0,0 @@
----
-name: Bug report
-about: Create a report to help us improve
-title: ''
-labels: 'Priority: Medium, Status: Available, Type: Bug'
-assignees: ''
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behavior:
-1. Go to '...'
-2. Click on '....'
-3. Scroll down to '....'
-4. See error
-
-**Expected behavior**
-A clear and concise description of what you expected to happen.
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
-
-**Environment**
- - Platform
- - Architecture
- - Docker version
-
-
- Logs from running watchtower with the --debug
option
-
-```
-
-```
-
-
-
-**Additional context**
-Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index ca13f1d..0000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-name: Feature request
-about: Suggest an idea for this project
-title: ''
-labels: 'Priority: Low, Status: Available, Type: Enhancement'
-assignees: ''
-
----
-
-**Is your feature request related to a problem? Please describe.**
-A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
-
-**Describe the solution you'd like**
-A clear and concise description of what you want to happen.
-
-**Describe alternatives you've considered**
-A clear and concise description of any alternative solutions or features you've considered.
-
-**Additional context**
-Add any other context or screenshots about the feature request here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..c1cc511
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,36 @@
+name: 💡 Feature request
+description: Have a new idea/feature ? Please suggest!
+labels: ["Priority: Low, Status: Available, Type: Enhancement"]
+body:
+ - type: textarea
+ id: description
+ attributes:
+ label: Is your feature request related to a problem? Please describe.
+ description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+ validations:
+ required: true
+
+ - type: textarea
+ id: solution
+ attributes:
+ label: Describe the solution you'd like
+ description: A clear and concise description of what you want to happen.
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Describe alternatives you've considered
+ description: A clear and concise description of any alternative solutions or features you've considered.
+ validations:
+ required: true
+
+ - type: textarea
+ id: extrainfo
+ attributes:
+ label: Additional context
+ description: Add any other context or screenshots about the feature request here.
+ validations:
+ required: false
+
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..fe53bc9
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,21 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "github-actions" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "gomod" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "docker" # See documentation for possible values
+ directory: "/dockerfiles" # Location of package manifests
+ schedule:
+ interval: "weekly"
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..1c07727
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,17 @@
+
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..c479d05
--- /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@v4
+ 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@v3
+ 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@v3
+
+ # ℹ️ 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@v3
diff --git a/.github/workflows/dependabot-approve.yml b/.github/workflows/dependabot-approve.yml
new file mode 100644
index 0000000..46f9d18
--- /dev/null
+++ b/.github/workflows/dependabot-approve.yml
@@ -0,0 +1,12 @@
+name: Auto approve dependabot PRs
+
+on: pull_request_target
+
+jobs:
+ auto-approve:
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ if: github.actor == 'dependabot[bot]'
+ steps:
+ - uses: hmarr/auto-approve-action@v3
diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml
index 899bb52..83ff8a0 100644
--- a/.github/workflows/greetings.yml
+++ b/.github/workflows/greetings.yml
@@ -1,6 +1,11 @@
name: Greetings
-on: [issues]
+on:
+ # Runs in the context of the target (containrrr/watchtower) repository, and as such has access to GITHUB_TOKEN
+ pull_request_target:
+ types: [opened]
+ issues:
+ types: [opened]
jobs:
greeting:
@@ -11,7 +16,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/post-release.yml b/.github/workflows/post-release.yml
deleted file mode 100644
index fcdeaa1..0000000
--- a/.github/workflows/post-release.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-on:
- release:
- types:
- - created
- tags:
- - 'v[0-9]+.[0-9]+.[0-9]+'
- - '**/v[0-9]+.[0-9]+.[0-9]+'
-
-jobs:
- build:
- name: Renew documentation
- runs-on: ubuntu-latest
- steps:
- - name: Pull new module version
- uses: andrewslotin/go-proxy-pull-action@master
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/publish-docs.yml b/.github/workflows/publish-docs.yml
new file mode 100644
index 0000000..4818e54
--- /dev/null
+++ b/.github/workflows/publish-docs.yml
@@ -0,0 +1,37 @@
+name: Publish Docs
+
+on:
+ workflow_dispatch: { }
+ workflow_run:
+ workflows: [ "Release (Production)" ]
+ branches: [ main ]
+ types:
+ - completed
+
+jobs:
+ publish-docs:
+ name: Publish Docs
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Build tplprev
+ run: scripts/build-tplprev.sh
+ - name: Setup python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.10'
+ cache: 'pip'
+ cache-dependency-path: |
+ docs-requirements.txt
+ - name: Install mkdocs
+ run: |
+ pip install -r docs-requirements.txt
+ - name: Generate docs
+ run: mkdocs gh-deploy --strict
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
new file mode 100644
index 0000000..f6866af
--- /dev/null
+++ b/.github/workflows/pull-request.yml
@@ -0,0 +1,70 @@
+name: Pull Request
+
+on:
+ workflow_dispatch: {}
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - uses: dominikh/staticcheck-action@ba605356b4b29a60e87ab9404b712f3461e566dc #v1.3.0
+ with:
+ version: "2023.1.6"
+ install-go: "false" # StaticCheck uses go v1.17 which does not support `any`
+ test:
+ name: Test
+ strategy:
+ fail-fast: false
+ matrix:
+ go-version:
+ - 1.20.x
+ platform:
+ - macos-latest
+ - windows-latest
+ - ubuntu-latest
+ runs-on: ${{ matrix.platform }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Run tests
+ run: |
+ go test -v -coverprofile coverage.out -covermode atomic ./...
+ - name: Publish coverage
+ uses: codecov/codecov-action@v3
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Build
+ uses: goreleaser/goreleaser-action@7ec5c2b0c6cdda6e8bbb49444bc797dd33d74dd8 #v3
+ 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..95ee68d
--- /dev/null
+++ b/.github/workflows/release-dev.yaml
@@ -0,0 +1,59 @@
+name: Push to main
+
+on:
+ workflow_dispatch: {}
+ push:
+ branches:
+ - main
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Build
+ run: ./build.sh
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Test
+ run: go test -v -coverprofile coverage.out -covermode atomic ./...
+ - name: Publish coverage
+ uses: codecov/codecov-action@v3
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ publish:
+ needs:
+ - build
+ - test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Publish to Docker Hub
+ uses: jerray/publish-docker-action@87d84711629b0dc9f6bb127b568413cc92a2088e #master@2022-10-14
+ 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@87d84711629b0dc9f6bb127b568413cc92a2088e #master@2022-10-14
+ 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..370d395
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,197 @@
+name: Release (Production)
+
+on:
+ workflow_dispatch: {}
+ push:
+ 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@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - uses: dominikh/staticcheck-action@ba605356b4b29a60e87ab9404b712f3461e566dc #v1.3.0
+ with:
+ version: "2022.1.1"
+ install-go: "false" # StaticCheck uses go v1.17 which does not support `any`
+
+ test:
+ name: Test
+ strategy:
+ matrix:
+ go-version:
+ - 1.20.x
+ platform:
+ - ubuntu-latest
+ - macos-latest
+ - windows-latest
+ runs-on: ${{ matrix.platform }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.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.ref_name }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Login to Docker Hub
+ uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc #v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc #v2
+ with:
+ username: ${{ secrets.BOT_USERNAME }}
+ password: ${{ secrets.BOT_GHCR_PAT }}
+ registry: ghcr.io
+ - name: Build
+ uses: goreleaser/goreleaser-action@7ec5c2b0c6cdda6e8bbb49444bc797dd33d74dd8 #v3
+ 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=$(git tag --points-at HEAD | 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
+
+ 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@50fea06a976087614babb9508e5c528b464f4645 #master@2022-10-14
+
+
+
+
+
diff --git a/.gitignore b/.gitignore
index 50b5c2d..9519257 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
watchtower
+watchtower.exe
vendor
.glide
dist
@@ -6,3 +7,7 @@ dist
.DS_Store
/site
coverage.out
+*.coverprofile
+
+docs/assets/wasm_exec.js
+docs/assets/*.wasm
\ No newline at end of file
diff --git a/README.md b/README.md
index cba30b1..f550302 100644
--- a/README.md
+++ b/README.md
@@ -1,61 +1,38 @@
-
+
## Quick Start
-With watchtower you can update the running version of your containerized app simply by pushing a new image to the Docker Hub or your own image registry. Watchtower will pull down your new image, gracefully shut down your existing container and restart it with the same options that were used when it was deployed initially. Run the watchtower container with the following command:
+With watchtower you can update the running version of your containerized app simply by pushing a new image to the Docker Hub or your own image registry.
+
+Watchtower will pull down your new image, gracefully shut down your existing container and restart it with the same options that were used when it was deployed initially. Run the watchtower container with the following command:
```
-$ docker run -d \
+$ docker run --detach \
--name watchtower \
- -v /var/run/docker.sock:/var/run/docker.sock \
+ --volume /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower
```
+Watchtower is intended to be used in homelabs, media centers, local dev environments, and similar. We do **not** recommend using Watchtower in a commercial or production environment. If that is you, you should be looking into using Kubernetes. If that feels like too big a step for you, please look into solutions like [MicroK8s](https://microk8s.io/) and [k3s](https://k3s.io/) that take away a lot of the toil of running a Kubernetes cluster.
+
## Documentation
The full documentation is available at https://containrrr.dev/watchtower.
@@ -67,108 +44,135 @@ 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 100755
index 0000000..78b1bfc
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+BINFILE=watchtower
+if [ -n "$MSYSTEM" ]; then
+ BINFILE=watchtower.exe
+fi
+VERSION=$(git describe --tags)
+echo "Building $VERSION..."
+go build -o $BINFILE -ldflags "-X github.com/containrrr/watchtower/internal/meta.Version=$VERSION"
diff --git a/cmd/notify-upgrade.go b/cmd/notify-upgrade.go
new file mode 100644
index 0000000..9991ee6
--- /dev/null
+++ b/cmd/notify-upgrade.go
@@ -0,0 +1,111 @@
+// Package cmd contains the watchtower (sub-)commands
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/containrrr/watchtower/internal/flags"
+ "github.com/containrrr/watchtower/pkg/container"
+ "github.com/containrrr/watchtower/pkg/notifications"
+ "github.com/spf13/cobra"
+)
+
+var notifyUpgradeCommand = NewNotifyUpgradeCommand()
+
+// NewNotifyUpgradeCommand creates the notify upgrade command for watchtower
+func NewNotifyUpgradeCommand() *cobra.Command {
+ return &cobra.Command{
+ Use: "notify-upgrade",
+ Short: "Upgrade legacy notification configuration to shoutrrr URLs",
+ Run: runNotifyUpgrade,
+ }
+}
+
+func runNotifyUpgrade(cmd *cobra.Command, args []string) {
+ if err := runNotifyUpgradeE(cmd, args); err != nil {
+ logf("Notification upgrade failed: %v", err)
+ }
+}
+
+func runNotifyUpgradeE(cmd *cobra.Command, _ []string) error {
+ f := cmd.Flags()
+ flags.ProcessFlagAliases(f)
+
+ notifier = notifications.NewNotifier(cmd)
+ urls := notifier.GetURLs()
+
+ logf("Found notification configurations for: %v", strings.Join(notifier.GetNames(), ", "))
+
+ outFile, err := os.CreateTemp("/", "watchtower-notif-urls-*")
+ if err != nil {
+ return fmt.Errorf("failed to create output file: %v", err)
+ }
+ logf("Writing notification URLs to %v", outFile.Name())
+ logf("")
+
+ sb := strings.Builder{}
+ sb.WriteString("WATCHTOWER_NOTIFICATION_URL=")
+
+ for i, u := range urls {
+ if i != 0 {
+ sb.WriteRune(' ')
+ }
+ sb.WriteString(u)
+ }
+
+ _, err = fmt.Fprint(outFile, sb.String())
+ tryOrLog(err, "Failed to write to output file")
+
+ tryOrLog(outFile.Sync(), "Failed to sync output file")
+ tryOrLog(outFile.Close(), "Failed to close output file")
+
+ containerID := ""
+ cid, err := container.GetRunningContainerID()
+ tryOrLog(err, "Failed to get running container ID")
+ if cid != "" {
+ containerID = cid.ShortID()
+ }
+ logf("To get the environment file, use:")
+ logf("cp %v:%v ./watchtower-notifications.env", containerID, outFile.Name())
+ logf("")
+ logf("Note: This file will be removed in 5 minutes or when this container is stopped!")
+
+ signalChannel := make(chan os.Signal, 1)
+ time.AfterFunc(5*time.Minute, func() {
+ signalChannel <- syscall.SIGALRM
+ })
+
+ signal.Notify(signalChannel, os.Interrupt)
+ signal.Notify(signalChannel, syscall.SIGTERM)
+
+ switch <-signalChannel {
+ case syscall.SIGALRM:
+ logf("Timed out!")
+ case os.Interrupt, syscall.SIGTERM:
+ logf("Stopping...")
+ default:
+ }
+
+ if err := os.Remove(outFile.Name()); err != nil {
+ logf("Failed to remove file, it may still be present in the container image! Error: %v", err)
+ } else {
+ logf("Environment file has been removed.")
+ }
+
+ return nil
+}
+
+func tryOrLog(err error, message string) {
+ if err != nil {
+ logf("%v: %v\n", message, err)
+ }
+}
+
+func logf(format string, v ...interface{}) {
+ fmt.Fprintln(os.Stderr, fmt.Sprintf(format, v...))
+}
diff --git a/cmd/root.go b/cmd/root.go
index 0aeeac6..eef13ce 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -1,21 +1,25 @@
package cmd
import (
- metrics2 "github.com/containrrr/watchtower/pkg/metrics"
+ "errors"
+ "math"
+ "net/http"
"os"
"os/signal"
"strconv"
+ "strings"
"syscall"
"time"
- "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/internal/meta"
"github.com/containrrr/watchtower/pkg/api"
+ apiMetrics "github.com/containrrr/watchtower/pkg/api/metrics"
+ "github.com/containrrr/watchtower/pkg/api/update"
"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"
@@ -25,17 +29,20 @@ import (
)
var (
- client container.Client
- scheduleSpec string
- cleanup bool
- noRestart bool
- monitorOnly bool
- enableLabel bool
- notifier *notifications.Notifier
- timeout time.Duration
- lifecycleHooks bool
- rollingRestart bool
- scope string
+ client container.Client
+ scheduleSpec string
+ cleanup bool
+ noRestart bool
+ noPull bool
+ monitorOnly bool
+ enableLabel bool
+ disableContainers []string
+ notifier t.Notifier
+ timeout time.Duration
+ lifecycleHooks bool
+ rollingRestart bool
+ scope string
+ labelPrecedence bool
)
var rootCmd = NewRootCommand()
@@ -51,6 +58,7 @@ func NewRootCommand() *cobra.Command {
`,
Run: Run,
PreRun: PreRun,
+ Args: cobra.ArbitraryArgs,
}
}
@@ -63,45 +71,21 @@ func init() {
// Execute the root func and exit in case of errors
func Execute() {
+ rootCmd.AddCommand(notifyUpgradeCommand)
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
// 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 {
- log.SetFormatter(&log.TextFormatter{
- DisableColors: true,
- })
- } else {
- // enable logrus built-in support for https://bixense.com/clicolors/
- log.SetFormatter(&log.TextFormatter{
- EnvironmentOverrideColors: true,
- })
+ flags.ProcessFlagAliases(f)
+ if err := flags.SetupLogging(f); err != nil {
+ log.Fatalf("Failed to initialize logging: %s", err.Error())
}
- if enabled, _ := f.GetBool("debug"); enabled {
- log.SetLevel(log.DebugLevel)
- }
- if enabled, _ := f.GetBool("trace"); enabled {
- log.SetLevel(log.TraceLevel)
- }
-
- pollingSet := f.Changed("interval")
- schedule, _ := f.GetString("schedule")
- cronLen := len(schedule)
-
- if pollingSet && cronLen > 0 {
- log.Fatal("Only schedule or interval can be defined, not both.")
- } else if cronLen > 0 {
- scheduleSpec, _ = f.GetString("schedule")
- } else {
- interval, _ := f.GetInt("interval")
- scheduleSpec = "@every " + strconv.Itoa(interval) + "s"
- }
+ scheduleSpec, _ = f.GetString("schedule")
flags.GetSecretsFromFiles(cmd)
cleanup, noRestart, monitorOnly, timeout = flags.ReadFlags(cmd)
@@ -111,11 +95,15 @@ func PreRun(cmd *cobra.Command, args []string) {
}
enableLabel, _ = f.GetBool("label-enable")
+ disableContainers, _ = f.GetStringSlice("disable-containers")
lifecycleHooks, _ = f.GetBool("enable-lifecycle-hooks")
rollingRestart, _ = f.GetBool("rolling-restart")
scope, _ = f.GetString("scope")
+ labelPrecedence, _ = f.GetBool("label-take-precedence")
- log.Debug(scope)
+ if scope != "" {
+ log.Debugf(`Using scope %q`, scope)
+ }
// configure environment vars for client
err := flags.EnvConfig(cmd)
@@ -123,40 +111,60 @@ func PreRun(cmd *cobra.Command, args []string) {
log.Fatal(err)
}
- noPull, _ := f.GetBool("no-pull")
+ noPull, _ = f.GetBool("no-pull")
includeStopped, _ := f.GetBool("include-stopped")
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.")
}
- client = container.NewClient(
- !noPull,
- includeStopped,
- reviveStopped,
- removeVolumes,
- includeRestarting,
- )
+ client = container.NewClient(container.ClientOptions{
+ IncludeStopped: includeStopped,
+ ReviveStopped: reviveStopped,
+ RemoveVolumes: removeVolumes,
+ IncludeRestarting: includeRestarting,
+ WarnOnHeadFailed: container.WarningStrategy(warnOnHeadPullFailed),
+ })
notifier = notifications.NewNotifier(cmd)
+ notifier.AddLogHook()
}
// 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, disableContainers, enableLabel, scope)
runOnce, _ := c.PersistentFlags().GetBool("run-once")
enableUpdateAPI, _ := c.PersistentFlags().GetBool("http-api-update")
enableMetricsAPI, _ := c.PersistentFlags().GetBool("http-api-metrics")
-
+ unblockHTTPAPI, _ := c.PersistentFlags().GetBool("http-api-periodic-polls")
apiToken, _ := c.PersistentFlags().GetString("http-api-token")
+ healthCheck, _ := c.PersistentFlags().GetBool("health-check")
+
+ if healthCheck {
+ // health check should not have pid 1
+ if os.Getpid() == 1 {
+ time.Sleep(1 * time.Second)
+ log.Fatal("The health check flag should never be passed to the main watchtower container process")
+ }
+ os.Exit(0)
+ }
+
+ 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)
@@ -164,50 +172,165 @@ func Run(c *cobra.Command, names []string) {
}
if err := actions.CheckForMultipleWatchtowerInstances(client, cleanup, scope); err != nil {
- log.Fatal(err)
+ logNotifyExit(err)
}
+ // The lock is shared between the scheduler and the HTTP API. It only allows one update to run at a time.
+ updateLock := make(chan bool, 1)
+ updateLock <- true
+
httpAPI := api.New(apiToken)
if enableUpdateAPI {
- updateHandler := update.New(func() { runUpdatesWithNotifications(filter) })
+ updateHandler := update.New(func(images []string) {
+ metric := runUpdatesWithNotifications(filters.FilterByImage(images, filter))
+ metrics.RegisterScan(metric)
+ }, updateLock)
httpAPI.RegisterFunc(updateHandler.Path, updateHandler.Handle)
+ // If polling isn't enabled the scheduler is never started, and
+ // we need to trigger the startup messages manually.
+ if !unblockHTTPAPI {
+ writeStartupMessage(c, time.Time{}, filterDesc)
+ }
}
if enableMetricsAPI {
- metricsHandler := metrics.New()
+ metricsHandler := apiMetrics.New()
httpAPI.RegisterHandler(metricsHandler.Path, metricsHandler.Handle)
}
- httpAPI.Start(enableUpdateAPI)
+ if err := httpAPI.Start(enableUpdateAPI && !unblockHTTPAPI); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Error("failed to start API", err)
+ }
- if err := runUpgradesOnSchedule(c, filter); err != nil {
+ if err := runUpgradesOnSchedule(c, filter, filterDesc, updateLock); err != nil {
log.Error(err)
}
os.Exit(1)
}
-func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter) error {
- tryLockSem := make(chan bool, 1)
- tryLockSem <- true
+func logNotifyExit(err error) {
+ log.Error(err)
+ notifier.Close()
+ os.Exit(1)
+}
- cron := cron.New()
- err := cron.AddFunc(
+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) {
+ noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message")
+ enableUpdateAPI, _ := c.PersistentFlags().GetBool("http-api-update")
+
+ var startupLog *log.Entry
+ if noStartupMessage {
+ startupLog = notifications.LocalLog
+ } else {
+ startupLog = log.NewEntry(log.StandardLogger())
+ // Batch up startup messages to send them as a single notification
+ notifier.StartNotification()
+ }
+
+ startupLog.Info("Watchtower ", meta.Version)
+
+ notifierNames := notifier.GetNames()
+ if len(notifierNames) > 0 {
+ startupLog.Info("Using notifications: " + strings.Join(notifierNames, ", "))
+ } else {
+ startupLog.Info("Using no notifications")
+ }
+
+ startupLog.Info(filtering)
+
+ if !sched.IsZero() {
+ until := formatDuration(time.Until(sched))
+ startupLog.Info("Scheduling first run: " + sched.Format("2006-01-02 15:04:05 -0700 MST"))
+ startupLog.Info("Note that the first check will be performed in " + until)
+ } else if runOnce, _ := c.PersistentFlags().GetBool("run-once"); runOnce {
+ startupLog.Info("Running a one time update.")
+ } else {
+ startupLog.Info("Periodic runs are not enabled.")
+ }
+
+ if enableUpdateAPI {
+ // TODO: make listen port configurable
+ startupLog.Info("The HTTP API is enabled at :8080.")
+ }
+
+ if !noStartupMessage {
+ // Send the queued up startup messages, not including the trace warning below (to make sure it's noticed)
+ notifier.SendNotification(nil)
+ }
+
+ if log.IsLevelEnabled(log.TraceLevel) {
+ startupLog.Warn("Trace level enabled: log will include sensitive information as credentials and tokens")
+ }
+}
+
+func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter, filtering string, lock chan bool) error {
+ if lock == nil {
+ lock = make(chan bool, 1)
+ lock <- true
+ }
+
+ scheduler := cron.New()
+ err := scheduler.AddFunc(
scheduleSpec,
func() {
select {
- case v := <-tryLockSem:
- defer func() { tryLockSem <- v }()
+ case v := <-lock:
+ defer func() { lock <- v }()
metric := runUpdatesWithNotifications(filter)
- metrics2.RegisterScan(metric)
+ metrics.RegisterScan(metric)
default:
// Update was skipped
- metrics2.RegisterScan(nil)
+ 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())
}
@@ -217,11 +340,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)
@@ -229,28 +350,35 @@ 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
+ <-lock
return nil
}
-func runUpdatesWithNotifications(filter t.Filter) *metrics2.Metric {
-
+func runUpdatesWithNotifications(filter t.Filter) *metrics.Metric {
notifier.StartNotification()
updateParams := t.UpdateParams{
- Filter: filter,
- Cleanup: cleanup,
- NoRestart: noRestart,
- Timeout: timeout,
- MonitorOnly: monitorOnly,
- LifecycleHooks: lifecycleHooks,
- RollingRestart: rollingRestart,
+ Filter: filter,
+ Cleanup: cleanup,
+ NoRestart: noRestart,
+ Timeout: timeout,
+ MonitorOnly: monitorOnly,
+ LifecycleHooks: lifecycleHooks,
+ RollingRestart: rollingRestart,
+ LabelPrecedence: labelPrecedence,
+ NoPull: noPull,
}
- metrics, err := actions.Update(client, updateParams)
+ result, err := actions.Update(client, updateParams)
if err != nil {
- log.Println(err)
+ log.Error(err)
}
- notifier.SendNotification()
- return metrics
+ notifier.SendNotification(result)
+ metricResults := metrics.NewMetric(result)
+ notifications.LocalLog.WithFields(log.Fields{
+ "Scanned": metricResults.Scanned,
+ "Updated": metricResults.Updated,
+ "Failed": metricResults.Failed,
+ }).Info("Session done")
+ return metricResults
}
diff --git a/coverage.out b/coverage.out
deleted file mode 100644
index 40470d1..0000000
--- a/coverage.out
+++ /dev/null
@@ -1,620 +0,0 @@
-mode: set
-github.com/containrrr/watchtower/internal/util/rand_name.go:8.24,10.19 2 0
-github.com/containrrr/watchtower/internal/util/rand_name.go:14.2,14.18 1 0
-github.com/containrrr/watchtower/internal/util/rand_name.go:10.19,12.3 1 0
-github.com/containrrr/watchtower/internal/util/util.go:4.39,5.24 1 1
-github.com/containrrr/watchtower/internal/util/util.go:9.2,9.20 1 1
-github.com/containrrr/watchtower/internal/util/util.go:15.2,15.13 1 1
-github.com/containrrr/watchtower/internal/util/util.go:5.24,7.3 1 1
-github.com/containrrr/watchtower/internal/util/util.go:9.20,10.21 1 1
-github.com/containrrr/watchtower/internal/util/util.go:10.21,12.4 1 1
-github.com/containrrr/watchtower/internal/util/util.go:19.46,22.24 2 1
-github.com/containrrr/watchtower/internal/util/util.go:37.2,37.10 1 1
-github.com/containrrr/watchtower/internal/util/util.go:22.24,25.25 2 1
-github.com/containrrr/watchtower/internal/util/util.go:32.3,32.13 1 1
-github.com/containrrr/watchtower/internal/util/util.go:25.25,26.16 1 1
-github.com/containrrr/watchtower/internal/util/util.go:26.16,28.10 2 1
-github.com/containrrr/watchtower/internal/util/util.go:32.13,34.4 1 1
-github.com/containrrr/watchtower/internal/util/util.go:41.68,44.25 2 1
-github.com/containrrr/watchtower/internal/util/util.go:54.2,54.10 1 1
-github.com/containrrr/watchtower/internal/util/util.go:44.25,45.27 1 1
-github.com/containrrr/watchtower/internal/util/util.go:45.27,46.16 1 1
-github.com/containrrr/watchtower/internal/util/util.go:46.16,48.5 1 1
-github.com/containrrr/watchtower/internal/util/util.go:49.9,51.4 1 1
-github.com/containrrr/watchtower/internal/util/util.go:58.72,61.25 2 1
-github.com/containrrr/watchtower/internal/util/util.go:67.2,67.10 1 1
-github.com/containrrr/watchtower/internal/util/util.go:61.25,62.27 1 1
-github.com/containrrr/watchtower/internal/util/util.go:62.27,64.4 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:6.63,6.90 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:9.43,9.58 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:12.66,13.21 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:17.2,17.44 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:13.21,15.3 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:17.44,18.30 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:23.3,23.15 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:18.30,19.52 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:19.52,21.5 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:28.56,29.44 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:29.44,33.10 2 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:37.3,37.23 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:33.10,35.4 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:42.58,43.44 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:43.44,45.26 2 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:50.3,50.23 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:45.26,48.4 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:55.64,56.17 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:60.2,60.44 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:56.17,58.3 1 0
-github.com/containrrr/watchtower/pkg/filters/filters.go:60.44,62.36 2 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:66.3,66.15 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:62.36,64.4 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:71.75,74.17 3 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:79.2,79.17 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:84.2,85.15 2 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:74.17,78.3 1 1
-github.com/containrrr/watchtower/pkg/filters/filters.go:79.17,83.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:20.50,25.2 4 1
-github.com/containrrr/watchtower/internal/flags/flags.go:28.50,154.2 22 0
-github.com/containrrr/watchtower/internal/flags/flags.go:157.56,299.2 24 1
-github.com/containrrr/watchtower/internal/flags/flags.go:302.20,313.2 10 1
-github.com/containrrr/watchtower/internal/flags/flags.go:317.42,325.53 6 1
-github.com/containrrr/watchtower/internal/flags/flags.go:328.2,328.55 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:331.2,331.63 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:334.2,334.57 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:337.2,337.63 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:340.2,340.67 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:343.2,343.12 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:325.53,327.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:328.55,330.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:331.63,333.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:334.57,336.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:337.63,339.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:340.67,342.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:347.70,356.57 7 0
-github.com/containrrr/watchtower/internal/flags/flags.go:359.2,359.62 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:362.2,362.66 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:365.2,365.66 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:369.2,369.49 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:356.57,358.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:359.62,361.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:362.66,364.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:365.66,367.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:372.49,373.40 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:376.2,377.16 2 1
-github.com/containrrr/watchtower/internal/flags/flags.go:380.2,380.12 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:373.40,375.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:377.16,379.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:383.48,384.9 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:387.2,387.12 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:384.9,386.3 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:392.50,401.33 3 1
-github.com/containrrr/watchtower/internal/flags/flags.go:401.33,403.3 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:407.61,409.16 2 1
-github.com/containrrr/watchtower/internal/flags/flags.go:412.2,412.34 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:409.16,411.3 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:412.34,414.17 2 1
-github.com/containrrr/watchtower/internal/flags/flags.go:417.3,418.17 2 1
-github.com/containrrr/watchtower/internal/flags/flags.go:414.17,416.4 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:418.17,420.4 1 0
-github.com/containrrr/watchtower/internal/flags/flags.go:424.28,426.24 2 1
-github.com/containrrr/watchtower/internal/flags/flags.go:429.2,429.13 1 1
-github.com/containrrr/watchtower/internal/flags/flags.go:426.24,428.3 1 1
-github.com/containrrr/watchtower/pkg/registry/helpers/helpers.go:9.60,12.16 3 1
-github.com/containrrr/watchtower/pkg/registry/helpers/helpers.go:15.2,19.28 4 1
-github.com/containrrr/watchtower/pkg/registry/helpers/helpers.go:12.16,14.3 1 0
-github.com/containrrr/watchtower/pkg/registry/helpers/helpers.go:23.57,25.16 2 1
-github.com/containrrr/watchtower/pkg/registry/helpers/helpers.go:29.2,29.67 1 1
-github.com/containrrr/watchtower/pkg/registry/helpers/helpers.go:33.2,33.16 1 1
-github.com/containrrr/watchtower/pkg/registry/helpers/helpers.go:36.2,36.22 1 1
-github.com/containrrr/watchtower/pkg/registry/helpers/helpers.go:25.16,27.3 1 0
-github.com/containrrr/watchtower/pkg/registry/helpers/helpers.go:29.67,31.3 1 1
-github.com/containrrr/watchtower/pkg/registry/helpers/helpers.go:33.16,35.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:16.46,23.16 5 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:27.2,31.16 3 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:34.2,34.26 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:53.2,53.10 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:23.16,25.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:31.16,33.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:34.26,36.12 2 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:50.3,50.32 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:37.18,38.47 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:39.18,40.47 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:41.20,42.49 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:43.19,44.48 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:45.21,46.50 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:47.11,48.49 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:57.40,58.28 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:58.28,60.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:64.39,65.28 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:65.28,67.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:71.28,72.28 1 0
-github.com/containrrr/watchtower/pkg/notifications/notifier.go:72.28,74.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:36.86,41.16 4 0
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:45.2,59.10 4 0
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:41.16,43.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:62.49,63.30 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:74.2,74.16 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:63.30,66.28 2 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:66.28,67.18 1 0
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:67.18,70.5 1 0
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:77.74,79.59 2 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:83.2,83.22 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:79.59,81.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:86.66,89.2 2 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:91.52,92.22 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:92.22,94.3 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:97.51,98.45 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:102.2,103.17 2 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:98.45,100.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:106.40,113.2 3 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:115.53,117.2 1 0
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:119.61,120.22 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:126.2,126.12 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:120.22,122.3 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:122.8,125.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:129.63,144.35 5 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:152.2,152.16 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:160.2,160.35 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:164.2,164.12 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:144.35,146.3 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:152.16,154.3 1 1
-github.com/containrrr/watchtower/pkg/notifications/shoutrrr.go:160.35,162.3 1 1
-github.com/containrrr/watchtower/pkg/notifications/slack.go:18.83,40.2 9 0
-github.com/containrrr/watchtower/pkg/notifications/slack.go:42.50,42.51 0 0
-github.com/containrrr/watchtower/pkg/notifications/slack.go:44.49,44.50 0 0
-github.com/containrrr/watchtower/pkg/notifications/slack.go:46.38,46.39 0 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:33.110,35.16 2 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:38.2,39.44 2 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:42.2,42.42 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:49.2,49.14 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:56.2,56.36 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:59.2,59.26 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:64.2,65.16 2 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:68.2,69.16 2 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:72.2,73.16 2 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:76.2,76.17 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:35.16,37.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:39.44,41.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:42.42,45.43 3 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:45.43,47.4 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:49.14,50.39 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:50.39,51.35 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:51.35,53.5 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:56.36,58.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:59.26,60.37 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:60.37,62.4 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:65.16,67.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:69.16,71.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/smtp.go:73.16,75.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/util.go:7.42,13.26 5 0
-github.com/containrrr/watchtower/pkg/notifications/util.go:23.2,23.13 1 0
-github.com/containrrr/watchtower/pkg/notifications/util.go:13.26,15.19 2 0
-github.com/containrrr/watchtower/pkg/notifications/util.go:15.19,18.4 2 0
-github.com/containrrr/watchtower/pkg/notifications/util.go:18.9,18.26 1 0
-github.com/containrrr/watchtower/pkg/notifications/util.go:18.26,20.4 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:36.83,65.2 13 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:67.71,70.24 2 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:75.2,75.48 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:78.2,79.32 2 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:84.2,96.27 11 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:100.2,102.50 2 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:106.2,106.24 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:70.24,72.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:72.8,74.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:75.48,77.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:79.32,82.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:96.27,98.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:102.50,104.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:109.63,112.12 2 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:112.12,113.18 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:117.3,118.19 2 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:121.3,122.17 2 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:113.18,115.4 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:118.19,120.4 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:122.17,125.4 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:129.49,130.22 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:130.22,132.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:135.48,136.45 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:140.2,141.17 2 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:136.45,138.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:144.50,146.2 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:148.58,149.22 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:154.2,154.12 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:149.22,151.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:151.8,153.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/email.go:157.38,157.39 0 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:27.84,31.24 3 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:39.2,40.26 2 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:44.2,55.10 4 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:31.24,33.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:33.8,33.99 1 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:33.99,35.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:35.8,35.52 1 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:35.52,37.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:40.26,42.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:58.51,58.52 0 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:60.50,60.51 0 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:62.39,62.40 0 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:64.51,66.2 1 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:68.46,70.34 2 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:73.2,73.50 1 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:70.34,72.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:76.59,78.12 1 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:110.2,110.12 1 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:78.12,84.17 2 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:90.3,99.17 4 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:103.3,105.54 2 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:84.17,87.4 2 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:99.17,102.4 2 0
-github.com/containrrr/watchtower/pkg/notifications/gotify.go:105.54,107.4 1 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:25.87,30.26 3 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:34.2,43.10 4 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:30.26,32.3 1 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:46.52,46.53 0 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:48.51,48.52 0 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:50.40,50.41 0 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:52.52,54.2 1 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:56.60,60.12 2 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:111.2,111.12 1 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:60.12,68.57 2 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:86.3,87.17 2 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:92.3,93.17 2 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:97.3,99.53 2 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:68.57,75.33 3 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:83.4,83.56 1 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:75.33,81.5 2 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:87.17,90.4 2 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:93.17,95.4 1 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:99.53,101.24 2 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:101.24,103.19 2 0
-github.com/containrrr/watchtower/pkg/notifications/msteams.go:103.19,106.6 2 0
-github.com/containrrr/watchtower/pkg/registry/registry.go:9.71,12.16 3 0
-github.com/containrrr/watchtower/pkg/registry/registry.go:16.2,16.16 1 0
-github.com/containrrr/watchtower/pkg/registry/registry.go:19.2,24.8 2 0
-github.com/containrrr/watchtower/pkg/registry/registry.go:12.16,14.3 1 0
-github.com/containrrr/watchtower/pkg/registry/registry.go:16.16,18.3 1 0
-github.com/containrrr/watchtower/pkg/registry/registry.go:30.43,33.2 2 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:20.46,22.16 2 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:25.2,25.18 1 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:22.16,24.3 1 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:31.49,34.38 3 1
-github.com/containrrr/watchtower/pkg/registry/trust.go:43.2,43.93 1 1
-github.com/containrrr/watchtower/pkg/registry/trust.go:34.38,42.3 4 1
-github.com/containrrr/watchtower/pkg/registry/trust.go:50.52,52.16 2 1
-github.com/containrrr/watchtower/pkg/registry/trust.go:56.2,57.21 2 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:60.2,61.16 2 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:65.2,68.34 3 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:72.2,74.25 3 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:52.16,55.3 2 1
-github.com/containrrr/watchtower/pkg/registry/trust.go:57.21,59.3 1 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:61.16,64.3 2 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:68.34,71.3 2 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:78.53,81.16 2 1
-github.com/containrrr/watchtower/pkg/registry/trust.go:85.2,86.22 2 1
-github.com/containrrr/watchtower/pkg/registry/trust.go:81.16,83.3 1 1
-github.com/containrrr/watchtower/pkg/registry/trust.go:91.75,92.39 1 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:95.2,95.46 1 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:92.39,94.3 1 0
-github.com/containrrr/watchtower/pkg/registry/trust.go:99.56,101.2 1 1
-github.com/containrrr/watchtower/pkg/registry/manifest/manifest.go:13.68,17.16 3 1
-github.com/containrrr/watchtower/pkg/registry/manifest/manifest.go:21.2,22.16 2 1
-github.com/containrrr/watchtower/pkg/registry/manifest/manifest.go:25.2,31.26 3 1
-github.com/containrrr/watchtower/pkg/registry/manifest/manifest.go:17.16,19.3 1 0
-github.com/containrrr/watchtower/pkg/registry/manifest/manifest.go:22.16,24.3 1 0
-github.com/containrrr/watchtower/pkg/registry/manifest/manifest.go:34.71,37.46 3 1
-github.com/containrrr/watchtower/pkg/registry/manifest/manifest.go:45.2,45.17 1 1
-github.com/containrrr/watchtower/pkg/registry/manifest/manifest.go:37.46,41.3 3 1
-github.com/containrrr/watchtower/pkg/registry/manifest/manifest.go:41.8,44.3 2 1
-github.com/containrrr/watchtower/pkg/container/container.go:16.97,21.2 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:33.57,35.2 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:38.32,40.2 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:45.37,47.2 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:50.34,52.2 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:56.37,58.2 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:63.39,66.9 2 1
-github.com/containrrr/watchtower/pkg/container/container.go:70.2,70.39 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:74.2,74.18 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:66.9,68.3 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:70.39,72.3 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:79.43,81.9 2 1
-github.com/containrrr/watchtower/pkg/container/container.go:85.2,86.16 2 1
-github.com/containrrr/watchtower/pkg/container/container.go:90.2,90.25 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:81.9,83.3 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:86.16,88.3 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:95.41,97.9 2 0
-github.com/containrrr/watchtower/pkg/container/container.go:101.2,102.16 2 0
-github.com/containrrr/watchtower/pkg/container/container.go:106.2,106.19 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:97.9,99.3 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:102.16,104.3 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:111.43,113.9 2 0
-github.com/containrrr/watchtower/pkg/container/container.go:117.2,117.24 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:113.9,115.3 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:122.37,127.31 3 1
-github.com/containrrr/watchtower/pkg/container/container.go:132.2,132.69 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:139.2,139.14 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:127.31,130.3 2 1
-github.com/containrrr/watchtower/pkg/container/container.go:132.69,133.57 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:133.57,136.4 2 1
-github.com/containrrr/watchtower/pkg/container/container.go:144.37,146.2 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:152.40,154.2 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:161.43,168.29 5 0
-github.com/containrrr/watchtower/pkg/container/container.go:172.2,172.16 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:168.29,170.3 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:178.40,180.2 1 1
-github.com/containrrr/watchtower/pkg/container/container.go:193.60,198.49 4 0
-github.com/containrrr/watchtower/pkg/container/container.go:202.2,202.37 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:206.2,206.42 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:210.2,210.64 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:217.2,224.37 4 0
-github.com/containrrr/watchtower/pkg/container/container.go:229.2,229.57 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:233.2,234.15 2 0
-github.com/containrrr/watchtower/pkg/container/container.go:198.49,200.3 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:202.37,204.3 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:206.42,208.3 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:210.64,212.51 2 0
-github.com/containrrr/watchtower/pkg/container/container.go:212.51,214.4 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:224.37,225.47 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:225.47,227.4 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:229.57,231.3 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:239.61,242.40 2 0
-github.com/containrrr/watchtower/pkg/container/container.go:249.2,249.19 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:242.40,247.3 3 0
-github.com/containrrr/watchtower/pkg/container/container.go:253.40,255.2 1 0
-github.com/containrrr/watchtower/pkg/container/container.go:257.52,259.2 1 0
-github.com/containrrr/watchtower/pkg/container/metadata.go:19.57,21.2 1 0
-github.com/containrrr/watchtower/pkg/container/metadata.go:24.58,26.2 1 0
-github.com/containrrr/watchtower/pkg/container/metadata.go:29.58,31.2 1 0
-github.com/containrrr/watchtower/pkg/container/metadata.go:34.59,36.2 1 0
-github.com/containrrr/watchtower/pkg/container/metadata.go:40.61,43.2 2 1
-github.com/containrrr/watchtower/pkg/container/metadata.go:45.62,46.57 1 1
-github.com/containrrr/watchtower/pkg/container/metadata.go:49.2,49.11 1 1
-github.com/containrrr/watchtower/pkg/container/metadata.go:46.57,48.3 1 1
-github.com/containrrr/watchtower/pkg/container/metadata.go:52.63,55.2 2 1
-github.com/containrrr/watchtower/pkg/container/client.go:43.101,46.16 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:50.2,56.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:46.16,48.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:67.77,71.27 3 1
-github.com/containrrr/watchtower/pkg/container/client.go:77.2,84.16 3 1
-github.com/containrrr/watchtower/pkg/container/client.go:88.2,88.46 1 1
-github.com/containrrr/watchtower/pkg/container/client.go:100.2,100.16 1 1
-github.com/containrrr/watchtower/pkg/container/client.go:71.27,73.3 1 1
-github.com/containrrr/watchtower/pkg/container/client.go:73.8,75.3 1 1
-github.com/containrrr/watchtower/pkg/container/client.go:84.16,86.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:88.46,91.17 2 1
-github.com/containrrr/watchtower/pkg/container/client.go:95.3,95.12 1 1
-github.com/containrrr/watchtower/pkg/container/client.go:91.17,93.4 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:95.12,97.4 1 1
-github.com/containrrr/watchtower/pkg/container/client.go:103.60,107.27 3 1
-github.com/containrrr/watchtower/pkg/container/client.go:112.2,112.19 1 1
-github.com/containrrr/watchtower/pkg/container/client.go:107.27,110.3 2 1
-github.com/containrrr/watchtower/pkg/container/client.go:115.80,119.16 3 1
-github.com/containrrr/watchtower/pkg/container/client.go:123.2,124.16 2 1
-github.com/containrrr/watchtower/pkg/container/client.go:128.2,128.77 1 1
-github.com/containrrr/watchtower/pkg/container/client.go:119.16,121.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:124.16,126.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:131.84,134.18 3 0
-github.com/containrrr/watchtower/pkg/container/client.go:138.2,138.19 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:146.2,148.43 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:159.2,159.64 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:163.2,163.12 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:134.18,136.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:138.19,140.70 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:140.70,142.4 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:148.43,150.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:150.8,153.144 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:153.144,155.4 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:159.64,161.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:166.72,173.58 5 0
-github.com/containrrr/watchtower/pkg/container/client.go:183.2,187.16 4 0
-github.com/containrrr/watchtower/pkg/container/client.go:191.2,191.40 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:209.2,209.45 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:213.2,213.78 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:173.58,175.51 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:180.3,180.65 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:175.51,178.9 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:187.16,189.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:191.40,193.54 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:200.3,200.51 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:193.54,195.18 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:195.18,197.5 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:200.51,202.18 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:202.18,204.5 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:209.45,211.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:217.131,222.16 4 0
-github.com/containrrr/watchtower/pkg/container/client.go:225.2,225.12 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:222.16,224.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:228.79,232.2 3 0
-github.com/containrrr/watchtower/pkg/container/client.go:234.80,237.24 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:243.2,243.43 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:237.24,239.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:239.8,239.64 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:239.64,241.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:246.96,251.16 4 0
-github.com/containrrr/watchtower/pkg/container/client.go:255.2,255.35 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:260.2,261.18 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:251.16,253.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:255.35,258.3 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:264.86,275.16 6 0
-github.com/containrrr/watchtower/pkg/container/client.go:280.2,281.86 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:288.2,291.16 3 0
-github.com/containrrr/watchtower/pkg/container/client.go:296.2,298.51 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:302.2,302.12 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:275.16,278.3 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:281.86,283.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:283.8,283.18 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:283.18,286.3 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:291.16,294.3 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:298.51,301.3 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:305.61,316.2 3 0
-github.com/containrrr/watchtower/pkg/container/client.go:318.98,329.16 4 0
-github.com/containrrr/watchtower/pkg/container/client.go:333.2,337.22 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:342.2,344.16 3 0
-github.com/containrrr/watchtower/pkg/container/client.go:348.2,349.22 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:362.2,363.16 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:367.2,367.12 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:329.16,331.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:337.22,339.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:344.16,346.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:349.22,353.17 4 0
-github.com/containrrr/watchtower/pkg/container/client.go:353.17,355.4 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:355.9,355.25 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:355.25,357.4 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:363.16,365.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:370.118,374.17 3 0
-github.com/containrrr/watchtower/pkg/container/client.go:381.2,381.6 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:406.2,406.12 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:374.17,377.3 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:377.8,379.3 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:381.6,390.17 3 0
-github.com/containrrr/watchtower/pkg/container/client.go:393.3,393.34 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:397.3,397.26 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:400.3,400.31 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:404.3,404.8 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:390.17,392.4 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:393.34,395.12 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:397.26,399.4 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:400.31,403.4 2 0
-github.com/containrrr/watchtower/pkg/container/client.go:409.92,413.6 3 0
-github.com/containrrr/watchtower/pkg/container/client.go:413.6,414.10 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:424.3,424.30 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:415.18,416.14 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:417.11,418.70 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:418.70,420.5 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:420.10,420.32 1 0
-github.com/containrrr/watchtower/pkg/container/client.go:420.32,422.5 1 0
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:24.121,30.49 5 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:34.2,35.53 2 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:39.2,41.43 3 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:45.2,52.43 4 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:55.2,55.44 1 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:60.2,60.67 1 0
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:30.49,32.3 1 0
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:35.53,37.3 1 0
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:41.43,43.3 1 0
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:52.43,54.3 1 0
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:55.44,58.3 2 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:64.63,67.16 2 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:70.2,72.17 3 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:67.16,69.3 1 0
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:76.139,81.16 4 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:85.2,86.72 2 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:90.2,90.62 1 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:97.2,98.50 2 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:102.2,106.16 4 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:110.2,110.33 1 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:81.16,83.3 1 0
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:86.72,88.3 1 0
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:90.62,93.3 2 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:93.8,95.3 1 0
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:98.50,100.3 1 0
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:106.16,108.3 1 0
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:114.66,120.29 5 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:128.2,128.79 1 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:132.2,140.21 8 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:120.29,126.3 5 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:128.79,130.3 1 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:144.52,147.16 3 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:151.2,156.17 2 1
-github.com/containrrr/watchtower/pkg/registry/auth/auth.go:147.16,149.3 1 0
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:24.124,28.16 4 1
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:32.2,33.16 2 1
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:37.2,37.64 1 1
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:41.2,44.24 3 1
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:48.2,48.40 1 1
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:56.2,56.19 1 0
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:28.16,30.3 1 0
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:33.16,35.3 1 0
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:37.64,39.3 1 0
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:44.24,46.3 1 0
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:48.40,51.28 3 1
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:51.28,53.4 1 1
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:60.79,63.17 3 1
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:69.2,75.16 6 1
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:78.2,78.27 1 1
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:81.2,81.49 1 1
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:63.17,65.3 1 1
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:65.8,67.3 1 0
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:75.16,77.3 1 0
-github.com/containrrr/watchtower/pkg/registry/digest/digest.go:78.27,80.3 1 0
-github.com/containrrr/watchtower/internal/actions/check.go:24.101,28.16 3 1
-github.com/containrrr/watchtower/internal/actions/check.go:33.2,33.26 1 1
-github.com/containrrr/watchtower/internal/actions/check.go:38.2,39.62 2 1
-github.com/containrrr/watchtower/internal/actions/check.go:28.16,31.3 2 0
-github.com/containrrr/watchtower/internal/actions/check.go:33.26,36.3 2 1
-github.com/containrrr/watchtower/internal/actions/check.go:42.110,49.44 5 1
-github.com/containrrr/watchtower/internal/actions/check.go:66.2,66.64 1 1
-github.com/containrrr/watchtower/internal/actions/check.go:49.44,50.65 1 1
-github.com/containrrr/watchtower/internal/actions/check.go:57.3,57.14 1 1
-github.com/containrrr/watchtower/internal/actions/check.go:50.65,54.12 3 0
-github.com/containrrr/watchtower/internal/actions/check.go:57.14,58.62 1 1
-github.com/containrrr/watchtower/internal/actions/check.go:58.62,62.5 2 0
-github.com/containrrr/watchtower/internal/actions/check.go:69.55,70.22 1 1
-github.com/containrrr/watchtower/internal/actions/check.go:74.2,76.11 2 0
-github.com/containrrr/watchtower/internal/actions/check.go:79.2,79.11 1 0
-github.com/containrrr/watchtower/internal/actions/check.go:82.2,82.36 1 0
-github.com/containrrr/watchtower/internal/actions/check.go:70.22,72.3 1 1
-github.com/containrrr/watchtower/internal/actions/check.go:76.11,78.3 1 0
-github.com/containrrr/watchtower/internal/actions/check.go:79.11,81.3 1 0
-github.com/containrrr/watchtower/internal/actions/check.go:85.26,88.2 2 1
-github.com/containrrr/watchtower/internal/actions/update.go:17.71,20.27 2 1
-github.com/containrrr/watchtower/internal/actions/update.go:24.2,25.16 2 1
-github.com/containrrr/watchtower/internal/actions/update.go:29.2,29.45 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:41.2,42.16 2 1
-github.com/containrrr/watchtower/internal/actions/update.go:46.2,49.25 3 1
-github.com/containrrr/watchtower/internal/actions/update.go:57.2,57.27 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:63.2,63.27 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:66.2,66.12 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:20.27,22.3 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:25.16,27.3 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:29.45,31.127 2 1
-github.com/containrrr/watchtower/internal/actions/update.go:34.3,34.17 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:38.3,38.30 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:31.127,33.4 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:34.17,37.4 2 0
-github.com/containrrr/watchtower/internal/actions/update.go:42.16,44.3 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:49.25,50.45 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:50.45,51.38 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:51.38,53.5 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:57.27,59.3 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:59.8,62.3 2 1
-github.com/containrrr/watchtower/internal/actions/update.go:63.27,65.3 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:69.114,72.44 2 0
-github.com/containrrr/watchtower/internal/actions/update.go:79.2,79.20 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:72.44,73.26 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:73.26,76.4 2 0
-github.com/containrrr/watchtower/internal/actions/update.go:79.20,81.3 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:84.122,85.44 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:85.44,87.3 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:90.108,91.30 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:96.2,96.22 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:99.2,99.27 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:107.2,107.72 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:91.30,94.3 2 0
-github.com/containrrr/watchtower/internal/actions/update.go:96.22,98.3 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:99.27,100.78 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:100.78,104.4 3 0
-github.com/containrrr/watchtower/internal/actions/update.go:107.72,109.3 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:112.123,115.44 2 1
-github.com/containrrr/watchtower/internal/actions/update.go:123.2,123.20 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:115.44,116.28 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:119.3,120.44 2 1
-github.com/containrrr/watchtower/internal/actions/update.go:116.28,117.12 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:123.20,125.3 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:128.71,129.32 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:129.32,130.57 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:130.57,132.4 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:136.111,141.30 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:148.2,148.23 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:141.30,142.76 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:142.76,145.4 2 0
-github.com/containrrr/watchtower/internal/actions/update.go:148.23,149.74 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:149.74,151.4 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:151.9,151.54 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:151.54,153.4 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:157.58,159.36 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:159.36,160.25 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:164.2,165.43 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:160.25,161.12 1 1
-github.com/containrrr/watchtower/internal/actions/update.go:165.43,166.37 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:166.37,167.54 1 0
-github.com/containrrr/watchtower/internal/actions/update.go:167.54,169.20 2 0
diff --git a/dockerfiles/Dockerfile b/dockerfiles/Dockerfile
index 7e28eb2..2fc571d 100644
--- a/dockerfiles/Dockerfile
+++ b/dockerfiles/Dockerfile
@@ -1,4 +1,4 @@
-FROM alpine:3.11 as alpine
+FROM --platform=$BUILDPLATFORM alpine:3.19.0 as alpine
RUN apk add --no-cache \
ca-certificates \
@@ -17,4 +17,7 @@ COPY --from=alpine \
EXPOSE 8080
COPY watchtower /
+
+HEALTHCHECK CMD [ "/watchtower", "--health-check"]
+
ENTRYPOINT ["/watchtower"]
diff --git a/dockerfiles/Dockerfile.dev-self-contained b/dockerfiles/Dockerfile.dev-self-contained
index 307ffbe..1a39c26 100644
--- a/dockerfiles/Dockerfile.dev-self-contained
+++ b/dockerfiles/Dockerfile.dev-self-contained
@@ -4,8 +4,15 @@
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
+
+# Pre download required modules to avoid redownloading at each build thanks to docker layer caching.
+# Copying go.mod and go.sum ensure to invalid the layer/build cache if there is a change in module requirement
+WORKDIR /watchtower
+COPY go.mod .
+COPY go.sum .
+RUN go mod download
RUN apk add --no-cache \
alpine-sdk \
@@ -18,7 +25,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/internal/meta.Version=$(git describe --tags)" . && \
GO111MODULE=on go test ./... -v
@@ -35,4 +42,6 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certifi
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /watchtower/watchtower /watchtower
+HEALTHCHECK CMD [ "/watchtower", "--health-check"]
+
ENTRYPOINT ["/watchtower"]
diff --git a/dockerfiles/Dockerfile.self-contained b/dockerfiles/Dockerfile.self-contained
index 64d5dc0..04a6047 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/internal/meta.Version=$(git describe --tags)" . && \
GO111MODULE=on go test ./... -v
@@ -35,4 +35,6 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certifi
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /go/watchtower/watchtower /watchtower
+HEALTHCHECK CMD [ "/watchtower", "--health-check"]
+
ENTRYPOINT ["/watchtower"]
diff --git a/dockerfiles/container-networking/docker-compose.yml b/dockerfiles/container-networking/docker-compose.yml
new file mode 100644
index 0000000..24cd00d
--- /dev/null
+++ b/dockerfiles/container-networking/docker-compose.yml
@@ -0,0 +1,17 @@
+services:
+ producer:
+ image: qmcgaw/gluetun:v3.35.0
+ cap_add:
+ - NET_ADMIN
+ environment:
+ - VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
+ - OPENVPN_USER=${OPENVPN_USER}
+ - OPENVPN_PASSWORD=${OPENVPN_PASSWORD}
+ - SERVER_COUNTRIES=${SERVER_COUNTRIES}
+ consumer:
+ depends_on:
+ - producer
+ image: nginx:1.25.1
+ network_mode: "service:producer"
+ labels:
+ - "com.centurylinklabs.watchtower.depends-on=/wt-contnet-producer-1"
diff --git a/docs-requirements.txt b/docs-requirements.txt
new file mode 100644
index 0000000..3a0104e
--- /dev/null
+++ b/docs-requirements.txt
@@ -0,0 +1,3 @@
+mkdocs
+mkdocs-material
+md-toc
diff --git a/docs/arguments.md b/docs/arguments.md
index 9c3d58d..d7ed0b0 100644
--- a/docs/arguments.md
+++ b/docs/arguments.md
@@ -27,10 +27,37 @@ In the example above, watchtower will execute an upgrade attempt on the containe
When no arguments are specified, watchtower will monitor all running containers.
+## Secrets/Files
+
+Some arguments can also reference a file, in which case the contents of the file are used as the value.
+This can be used to avoid putting secrets in the configuration file or command line.
+
+The following arguments are currently supported (including their corresponding `WATCHTOWER_` environment variables):
+ - `notification-url`
+ - `notification-email-server-password`
+ - `notification-slack-hook-url`
+ - `notification-msteams-hook`
+ - `notification-gotify-token`
+ - `http-api-token`
+
+### Example docker-compose usage
+```yaml
+secrets:
+ access_token:
+ file: access_token
+
+services:
+ watchtower:
+ secrets:
+ - access_token
+ environment:
+ - WATCHTOWER_HTTP_API_TOKEN=/run/secrets/access_token
+```
+
## Help
Shows documentation about the supported flags.
-```
+```text
Argument: --help
Environment Variable: N/A
Type: N/A
@@ -39,9 +66,9 @@ 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 timezone 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`
-```
+```text
Argument: N/A
Environment Variable: TZ
Type: String
@@ -51,17 +78,17 @@ Environment Variable: TZ
## Cleanup
Removes old images after updating. When this flag is specified, watchtower will remove the old image after restarting a container with a new image. Use this option to prevent the accumulation of orphaned images on your system as containers are updated.
-```
+```text
Argument: --cleanup
Environment Variable: WATCHTOWER_CLEANUP
Type: Boolean
Default: false
```
-## Remove attached volumes
-Removes attached volumes after updating. When this flag is specified, watchtower will remove all attached volumes from the container before restarting with a new image. Use this option to force new volumes to be populated as containers are updated.
+## Remove anonymous volumes
+Removes anonymous volumes after updating. When this flag is specified, watchtower will remove all anonymous volumes from the container before restarting with a new image. Named volumes will not be removed!
-```
+```text
Argument: --remove-volumes
Environment Variable: WATCHTOWER_REMOVE_VOLUMES
Type: Boolean
@@ -71,7 +98,11 @@ Environment Variable: WATCHTOWER_REMOVE_VOLUMES
## Debug
Enable debug mode with verbose logging.
-```
+!!! note "Notes"
+ Alias for `--log-level debug`. See [Maximum log level](#maximum-log-level).
+ Does _not_ take an argument when used as an argument. Using `--debug true` will **not** work.
+
+```text
Argument: --debug, -d
Environment Variable: WATCHTOWER_DEBUG
Type: Boolean
@@ -81,17 +112,43 @@ Environment Variable: WATCHTOWER_DEBUG
## Trace
Enable trace mode with very verbose logging. Caution: exposes credentials!
-```
+!!! note "Notes"
+ Alias for `--log-level trace`. See [Maximum log level](#maximum-log-level).
+ Does _not_ take an argument when used as an argument. Using `--trace true` will **not** work.
+
+```text
Argument: --trace
Environment Variable: WATCHTOWER_TRACE
Type: Boolean
Default: false
```
+## Maximum log level
+
+The maximum log level that will be written to STDERR (shown in `docker log` when used in a container).
+
+```text
+ Argument: --log-level
+Environment Variable: WATCHTOWER_LOG_LEVEL
+ Possible values: panic, fatal, error, warn, info, debug or trace
+ Default: info
+```
+
+## Logging format
+
+Sets what logging format to use for console output.
+
+```text
+ Argument: --log-format, -l
+Environment Variable: WATCHTOWER_LOG_FORMAT
+ Possible values: Auto, LogFmt, Pretty or JSON
+ Default: Auto
+```
+
## ANSI colors
Disable ANSI color escape codes in log output.
-```
+```text
Argument: --no-color
Environment Variable: NO_COLOR
Type: Boolean
@@ -101,7 +158,7 @@ Environment Variable: NO_COLOR
## Docker host
Docker daemon socket to connect to. Can be pointed at a remote Docker host by specifying a TCP endpoint as "tcp://hostname:port".
-```
+```text
Argument: --host, -H
Environment Variable: DOCKER_HOST
Type: String
@@ -111,7 +168,7 @@ Environment Variable: DOCKER_HOST
## Docker API version
The API version to use by the Docker client for connecting to the Docker daemon. The minimum supported version is 1.24.
-```
+```text
Argument: --api-version, -a
Environment Variable: DOCKER_API_VERSION
Type: String
@@ -121,7 +178,7 @@ Environment Variable: DOCKER_API_VERSION
## Include restarting
Will also include restarting containers.
-```
+```text
Argument: --include-restarting
Environment Variable: WATCHTOWER_INCLUDE_RESTARTING
Type: Boolean
@@ -131,8 +188,8 @@ Environment Variable: WATCHTOWER_INCLUDE_RESTARTING
## Include stopped
Will also include created and exited containers.
-```
- Argument: --include-stopped
+```text
+ Argument: --include-stopped, -S
Environment Variable: WATCHTOWER_INCLUDE_STOPPED
Type: Boolean
Default: false
@@ -141,7 +198,7 @@ Environment Variable: WATCHTOWER_INCLUDE_STOPPED
## Revive stopped
Start any stopped containers that have had their image updated. This argument is only usable with the `--include-stopped` argument.
-```
+```text
Argument: --revive-stopped
Environment Variable: WATCHTOWER_REVIVE_STOPPED
Type: Boolean
@@ -151,7 +208,7 @@ Environment Variable: WATCHTOWER_REVIVE_STOPPED
## Poll interval
Poll interval (in seconds). This value controls how frequently watchtower will poll for new images. Either `--schedule` or a poll interval can be defined, but not both.
-```
+```text
Argument: --interval, -i
Environment Variable: WATCHTOWER_POLL_INTERVAL
Type: Integer
@@ -159,9 +216,9 @@ Environment Variable: WATCHTOWER_POLL_INTERVAL
```
## Filter by enable label
-Update containers that have a `com.centurylinklabs.watchtower.enable` label set to true.
+Monitor and update containers that have a `com.centurylinklabs.watchtower.enable` label set to true.
-```
+```text
Argument: --label-enable
Environment Variable: WATCHTOWER_LABEL_ENABLE
Type: Boolean
@@ -169,16 +226,34 @@ Environment Variable: WATCHTOWER_LABEL_ENABLE
```
## Filter by disable label
-**Do not** update containers that have `com.centurylinklabs.watchtower.enable` label set to false and no `--label-enable` argument is passed. Note that only one or the other (targeting by enable label) can be used at the same time to target containers.
+__Do not__ Monitor and update containers that have `com.centurylinklabs.watchtower.enable` label set to false and
+no `--label-enable` argument is passed. Note that only one or the other (targeting by enable label) can be
+used at the same time to target containers.
+
+## Filter by disabling specific container names
+Monitor and update containers whose names are not in a given set of names.
+
+This can be used to exclude specific containers, when setting labels is not an option.
+The listed containers will be excluded even if they have the enable filter set to true.
+
+```text
+ Argument: --disable-containers, -x
+Environment Variable: WATCHTOWER_DISABLE_CONTAINERS
+ Type: Comma- or space-separated string list
+ Default: ""
+```
## 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.
+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**
->
-> Due to Docker API limitations the latest image will still be pulled from the registry.
+!!! note
+ Due to Docker API limitations the latest image will still be pulled from the registry.
+ The HEAD digest checks allows watchtower to skip pulling when there are no changes, but to know _what_ has changed it
+ will still do a pull whenever the repository digest doesn't match the local image digest.
-```
+```text
Argument: --monitor-only
Environment Variable: WATCHTOWER_MONITOR_ONLY
Type: Boolean
@@ -187,10 +262,23 @@ Environment Variable: WATCHTOWER_MONITOR_ONLY
Note that monitor-only can also be specified on a per-container basis with the `com.centurylinklabs.watchtower.monitor-only` label set on those containers.
+See [With label taking precedence over arguments](#With-label-taking-precedence-over-arguments) for behavior when both argument and label are set
+
+## With label taking precedence over arguments
+
+By default, arguments will take precedence over labels. This means that if you set `WATCHTOWER_MONITOR_ONLY` to true or use `--monitor-only`, a container with `com.centurylinklabs.watchtower.monitor-only` set to false will not be updated. If you set `WATCHTOWER_LABEL_TAKE_PRECEDENCE` to true or use `--label-take-precedence`, then the container will also be updated. This also apply to the no pull option. if you set `WATCHTOWER_NO_PULL` to true or use `--no-pull`, a container with `com.centurylinklabs.watchtower.no-pull` set to false will not pull the new image. If you set `WATCHTOWER_LABEL_TAKE_PRECEDENCE` to true or use `--label-take-precedence`, then the container will pull image
+
+```text
+ Argument: --label-take-precedence
+Environment Variable: WATCHTOWER_LABEL_TAKE_PRECEDENCE
+ Type: Boolean
+ Default: false
+```
+
## Without restarting containers
Do not restart containers after updating. This option can be useful when the start of the containers
is managed by an external system such as systemd.
-```
+```text
Argument: --no-restart
Environment Variable: WATCHTOWER_NO_RESTART
Type: Boolean
@@ -203,17 +291,22 @@ new images from the registry. Instead it will only monitor the local image cache
Use this option if you are building new images directly on the Docker host without pushing
them to a registry.
-```
+```text
Argument: --no-pull
Environment Variable: WATCHTOWER_NO_PULL
Type: Boolean
Default: false
```
+Note that no-pull can also be specified on a per-container basis with the
+`com.centurylinklabs.watchtower.no-pull` label set on those containers.
+
+See [With label taking precedence over arguments](#With-label-taking-precedence-over-arguments) for behavior when both argument and label are set
+
## Without sending a startup message
Do not send a message after watchtower started. Otherwise there will be an info-level notification.
-```
+```text
Argument: --no-startup-message
Environment Variable: WATCHTOWER_NO_STARTUP_MESSAGE
Type: Boolean
@@ -223,35 +316,55 @@ Environment Variable: WATCHTOWER_NO_STARTUP_MESSAGE
## Run once
Run an update attempt against a container name list one time immediately and exit.
-```
- Argument: --run-once
+```text
+ Argument: --run-once, -R
Environment Variable: WATCHTOWER_RUN_ONCE
Type: Boolean
Default: false
```
## HTTP API Mode
-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).
+Runs Watchtower in HTTP API mode, only allowing image updates to be triggered by an HTTP request.
+For details see [HTTP API](https://containrrr.dev/watchtower/http-api-mode).
-```
- Argument: --http-api
-Environment Variable: WATCHTOWER_HTTP_API
+```text
+ Argument: --http-api-update
+Environment Variable: WATCHTOWER_HTTP_API_UPDATE
Type: Boolean
Default: false
```
## HTTP API Token
Sets an authentication token to HTTP API requests.
+Can also reference a file, in which case the contents of the file are used.
-```
+```text
Argument: --http-api-token
Environment Variable: WATCHTOWER_HTTP_API_TOKEN
Type: String
Default: -
-```## Filter by scope
-Update containers that have a `com.centurylinklabs.watchtower.scope` label set with the same value as the given argument. This enables [running multiple instances](https://containrrr.github.io/watchtower/running-multiple-instances).
-
```
+
+## HTTP API periodic polls
+Keep running periodic updates if the HTTP API mode is enabled, otherwise the HTTP API would prevent periodic polls.
+
+```text
+ Argument: --http-api-periodic-polls
+Environment Variable: WATCHTOWER_HTTP_API_PERIODIC_POLLS
+ Type: Boolean
+ Default: false
+```
+
+## Filter by scope
+Update containers that have a `com.centurylinklabs.watchtower.scope` label set with the same value as the given argument.
+This enables [running multiple instances](https://containrrr.dev/watchtower/running-multiple-instances).
+
+!!! note "Filter by lack of scope"
+ If you want other instances of watchtower to ignore the scoped containers, set this argument to `none`.
+ When omitted, watchtower will update all containers regardless of scope.
+
+
+```text
Argument: --scope
Environment Variable: WATCHTOWER_SCOPE
Type: String
@@ -261,7 +374,7 @@ Environment Variable: WATCHTOWER_SCOPE
## HTTP API Metrics
Enables a metrics endpoint, exposing prometheus metrics via HTTP. See [Metrics](metrics.md) for details.
-```
+```text
Argument: --http-api-metrics
Environment Variable: WATCHTOWER_HTTP_API_METRICS
Type: Boolean
@@ -272,7 +385,7 @@ Environment Variable: WATCHTOWER_HTTP_API_METRICS
[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 * * *"`
-```
+```text
Argument: --schedule, -s
Environment Variable: WATCHTOWER_SCHEDULE
Type: String
@@ -283,7 +396,7 @@ Environment Variable: WATCHTOWER_SCHEDULE
Restart one image at time instead of stopping and starting all at once. Useful in conjunction with lifecycle hooks
to implement zero-downtime deploy.
-```
+```text
Argument: --rolling-restart
Environment Variable: WATCHTOWER_ROLLING_RESTART
Type: Boolean
@@ -293,7 +406,7 @@ Environment Variable: WATCHTOWER_ROLLING_RESTART
## Wait until timeout
Timeout before the container is forcefully stopped. When set, this option will change the default (`10s`) wait time to the given value. An example: `--stop-timeout 30s` will set the timeout to 30 seconds.
-```
+```text
Argument: --stop-timeout
Environment Variable: WATCHTOWER_TIMEOUT
Type: Duration
@@ -301,11 +414,54 @@ Environment Variable: WATCHTOWER_TIMEOUT
```
## TLS Verification
-Use TLS when connecting to the Docker socket and verify the server's certificate. See below for options used to configure notifications.
-```
+Use TLS when connecting to the Docker socket and verify the server's certificate. See below for options used to
+configure notifications.
+
+```text
Argument: --tlsverify
Environment Variable: DOCKER_TLS_VERIFY
Type: Boolean
Default: false
```
+
+## HEAD failure warnings
+
+When to warn about HEAD pull requests failing. Auto means that it will warn when the registry is known to handle the
+requests and may rate limit pull requests (mainly docker.io).
+
+```text
+ Argument: --warn-on-head-failure
+Environment Variable: WATCHTOWER_WARN_ON_HEAD_FAILURE
+ Possible values: always, auto, never
+ Default: auto
+```
+
+## Health check
+
+Returns a success exit code to enable usage with docker `HEALTHCHECK`. This check is naive and only returns checks whether there is another process running inside the container, as it is the only known form of failure state for watchtowers container.
+
+!!! note "Only for HEALTHCHECK use"
+ Never put this on the main container executable command line as it is only meant to be run from docker HEALTHCHECK.
+
+```text
+ Argument: --health-check
+```
+
+## Programatic Output (porcelain)
+
+Writes the session results to STDOUT using a stable, machine-readable format (indicated by the argument VERSION).
+
+Alias for:
+
+```text
+ --notification-url logger://
+ --notification-log-stdout
+ --notification-report
+ --notification-template porcelain.VERSION.summary-no-log
+
+ Argument: --porcelain, -P
+Environment Variable: WATCHTOWER_PORCELAIN
+ Possible values: v1
+ Default: -
+```
diff --git a/docs/container-selection.md b/docs/container-selection.md
index 799091f..8327c66 100644
--- a/docs/container-selection.md
+++ b/docs/container-selection.md
@@ -2,40 +2,65 @@ By default, watchtower will watch all containers. However, sometimes only some c
There are two options:
-- **Fully exclude**: You can choose to exclude containers entirely from being watched by watchtower.
-- **Monitor only**: In this mode, watchtower checks for container updates, sends notifications and invokes the [pre-check/post-check hooks](https://containrrr.dev/watchtower/lifecycle-hooks/) on the containers but does **not** perform the update.
+- **Fully exclude**: You can choose to exclude containers entirely from being watched by watchtower.
+- **Monitor only**: In this mode, watchtower checks for container updates, sends notifications and invokes the [pre-check/post-check hooks](https://containrrr.dev/watchtower/lifecycle-hooks/) on the containers but does **not** perform the update.
## Full Exclude
-If you need to exclude some containers, set the _com.centurylinklabs.watchtower.enable_ label to `false`.
+If you need to exclude some containers, set the _com.centurylinklabs.watchtower.enable_ label to `false`. For clarity this should be set **on the container(s)** you wish to be ignored, this is not set on watchtower.
-```docker
-LABEL com.centurylinklabs.watchtower.enable="false"
-```
+=== "dockerfile"
-Or, it can be specified as part of the `docker run` command line:
+ ```docker
+ LABEL com.centurylinklabs.watchtower.enable="false"
+ ```
+=== "docker run"
-```bash
-docker run -d --label=com.centurylinklabs.watchtower.enable=false someimage
-```
+ ```bash
+ docker run -d --label=com.centurylinklabs.watchtower.enable=false someimage
+ ```
-If you need to [include only containers with the enable label](https://containrrr.github.io/watchtower/arguments/#filter_by_enable_label), pass the `--label-enable` flag or the `WATCHTOWER_LABEL_ENABLE` environment variable on startup and set the _com.centurylinklabs.watchtower.enable_ label with a value of `true` for the containers you want to watch.
+=== "docker-compose"
-```docker
-LABEL com.centurylinklabs.watchtower.enable="true"
-```
+ ``` yaml
+ version: "3"
+ services:
+ someimage:
+ container_name: someimage
+ labels:
+ - "com.centurylinklabs.watchtower.enable=false"
+ ```
-Or, it can be specified as part of the `docker run` command line:
+If instead you want to [only include containers with the enable label](https://containrrr.github.io/watchtower/arguments/#filter_by_enable_label), pass the `--label-enable` flag or the `WATCHTOWER_LABEL_ENABLE` environment variable on startup for watchtower and set the _com.centurylinklabs.watchtower.enable_ label with a value of `true` on the containers you want to watch.
-```bash
-docker run -d --label=com.centurylinklabs.watchtower.enable=true someimage
-```
+=== "dockerfile"
+
+ ```docker
+ LABEL com.centurylinklabs.watchtower.enable="true"
+ ```
+=== "docker run"
+
+ ```bash
+ docker run -d --label=com.centurylinklabs.watchtower.enable=true someimage
+ ```
+
+=== "docker-compose"
+
+ ``` yaml
+ version: "3"
+ services:
+ someimage:
+ container_name: someimage
+ labels:
+ - "com.centurylinklabs.watchtower.enable=true"
+ ```
If you wish to create a monitoring scope, you will need to [run multiple instances and set a scope for each of them](https://containrrr.github.io/watchtower/running-multiple-instances).
Watchtower filters running containers by testing them against each configured criteria. A container is monitored if all criteria are met. For example:
-- If a container's name is on the monitoring name list (not empty `--name` argument) but it is not enabled (_centurylinklabs.watchtower.enable=false_), it won't be monitored;
-- If a container's name is not on the monitoring name list (not empty `--name` argument), even if it is enabled (_centurylinklabs.watchtower.enable=true_ and `--label-enable` flag is set), it won't be monitored;
+
+- If a container's name is on the monitoring name list (not empty `--name` argument) but it is not enabled (_centurylinklabs.watchtower.enable=false_), it won't be monitored;
+- If a container's name is not on the monitoring name list (not empty `--name` argument), even if it is enabled (_centurylinklabs.watchtower.enable=true_ and `--label-enable` flag is set), it won't be monitored;
## Monitor Only
@@ -54,5 +79,3 @@ docker run -d --label=com.centurylinklabs.watchtower.monitor-only=true someimage
```
When the label is specified on a container, watchtower treats that container exactly as if [`WATCHTOWER_MONITOR_ONLY`](https://containrrr.dev/watchtower/arguments/#without_updating_containers) was set, but the effect is limited to the individual container.
-
-
diff --git a/docs/http-api-mode.md b/docs/http-api-mode.md
index def90d5..69812bb 100644
--- a/docs/http-api-mode.md
+++ b/docs/http-api-mode.md
@@ -1,10 +1,10 @@
Watchtower provides an HTTP API mode that enables an HTTP endpoint that can be requested to trigger container updating. The current available endpoint list is:
-- `/v1/update` - triggers an update for all of the containers monitored by this Watchtower instance.
+- `/v1/update` - triggers an update for all of the containers monitored by this Watchtower instance.
---
-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:
```yaml
version: '3'
@@ -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:
@@ -28,8 +28,18 @@ services:
- 8080:8080
```
+By default, enabling this mode prevents periodic polls (i.e. what is specified using `--interval` or `--schedule`). To run periodic updates regardless, pass `--http-api-periodic-polls`.
+
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
+```
+
+---
+
+In order to update only certain images, the image names can be provided as URL query parameters. The following `curl` command would trigger an update for the images `foo/bar` and `foo/baz`:
+
+```bash
+curl -H "Authorization: Bearer mytoken" localhost:8080/v1/update?image=foo/bar,foo/baz
+```
diff --git a/docs/index.md b/docs/index.md
index 1b997bf..1d0b2cc 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,5 +1,5 @@
-
+
Watchtower
@@ -11,12 +11,12 @@
+
+
+
-
-
-
@@ -26,27 +26,40 @@
-
-
+
+
-
-
-
-
-
+
+
-
+
## Quick Start
-With watchtower you can update the running version of your containerized app simply by pushing a new image to the Docker Hub or your own image registry. Watchtower will pull down your new image, gracefully shut down your existing container and restart it with the same options that were used when it was deployed initially. Run the watchtower container with the following command:
+With watchtower you can update the running version of your containerized app simply by pushing a new image to the Docker
+Hub or your own image registry. Watchtower will pull down your new image, gracefully shut down your existing container
+and restart it with the same options that were used when it was deployed initially. Run the watchtower container with
+the following command:
-```
-$ docker run -d \
+=== "docker run"
+
+ ```bash
+ $ docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower
-```
+ ```
+
+=== "docker-compose.yml"
+
+ ```yaml
+ version: "3"
+ services:
+ watchtower:
+ image: containrrr/watchtower
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ ```
diff --git a/docs/introduction.md b/docs/introduction.md
index 9e0f5fe..cbbc3a3 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -4,12 +4,12 @@ With watchtower you can update the running version of your containerized app sim
For example, let's say you were running watchtower along with an instance of _centurylink/wetty-cli_ image:
-```bash
+```text
$ docker ps
CONTAINER ID IMAGE STATUS PORTS NAMES
967848166a45 centurylink/wetty-cli Up 10 minutes 0.0.0.0:8080->3000/tcp wetty
6cc4d2a9d1a5 containrrr/watchtower Up 15 minutes watchtower
```
-Every few minutes watchtower will pull the latest _centurylink/wetty-cli_ image and compare it to the one that was used to run the "wetty" container. If it sees that the image has changed it will stop/remove the "wetty" container and then restart it using the new image and the same `docker run` options that were used to start the container initially (in this case, that would include the `-p 8080:3000` port mapping).
+Every day watchtower will pull the latest _centurylink/wetty-cli_ image and compare it to the one that was used to run the "wetty" container. If it sees that the image has changed it will stop/remove the "wetty" container and then restart it using the new image and the same `docker run` options that were used to start the container initially (in this case, that would include the `-p 8080:3000` port mapping).
diff --git a/docs/lifecycle-hooks.md b/docs/lifecycle-hooks.md
index f8bc640..0c1e4e0 100644
--- a/docs/lifecycle-hooks.md
+++ b/docs/lifecycle-hooks.md
@@ -1,15 +1,19 @@
## Executing commands before and after updating
-> **DO NOTE**: These are shell commands executed with `sh`, and therefore require the
-> container to provide the `sh` executable.
+!!! note
+ These are shell commands executed with `sh`, and therefore require the container to provide the `sh`
+ executable.
+
+> **DO NOTE**: If the container is not running then lifecycle hooks can not run and therefore
+> the update is executed without running any lifecycle hooks.
It is possible to execute _pre/post\-check_ and _pre/post\-update_ commands
**inside** every container updated by watchtower.
-- The _pre-check_ command is executed for each container prior to every update cycle.
-- The _pre-update_ command is executed before stopping the container when an update is about to start.
-- The _post-update_ command is executed after restarting the updated container
-- The _post-check_ command is executed for each container post every update cycle.
+- The _pre-check_ command is executed for each container prior to every update cycle.
+- The _pre-update_ command is executed before stopping the container when an update is about to start.
+- The _post-update_ command is executed after restarting the updated container
+- The _post-check_ command is executed for each container post every update cycle.
This feature is disabled by default. To enable it, you need to set the option
`--enable-lifecycle-hooks` on the command line, or set the environment variable
@@ -26,35 +30,35 @@ The commands are specified using docker container labels, the following are curr
| Post Update | `com.centurylinklabs.watchtower.lifecycle.post-update` |
| Post Check | `com.centurylinklabs.watchtower.lifecycle.post-check` |
-These labels can be declared as instructions in a Dockerfile (with some example .sh files):
+These labels can be declared as instructions in a Dockerfile (with some example .sh files) or be specified as part of
+the `docker run` command line:
-```docker
-LABEL com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh"
-LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh"
-LABEL com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh"
-LABEL com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh"
-```
+=== "Dockerfile"
+ ```docker
+ LABEL com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh"
+ LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh"
+ LABEL com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh"
+ LABEL com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh"
+ ```
-Or be specified as part of the `docker run` command line:
-
-```bash
-docker run -d \
- --label=com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh" \
- --label=com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh" \
- --label=com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh" \
- someimage
- --label=com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh" \
-```
+=== "docker run"
+ ```bash
+ docker run -d \
+ --label=com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh" \
+ --label=com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh" \
+ --label=com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh" \
+ someimage --label=com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh" \
+ ```
### Timeouts
The timeout for all lifecycle commands is 60 seconds. After that, a timeout will
occur, forcing Watchtower to continue the update loop.
-#### Pre-update timeouts
+#### Pre- or Post-update timeouts
-For the `pre-update` lifecycle command, it is possible to override this timeout to
+For the `pre-update` or `post-update` lifecycle command, it is possible to override this timeout to
allow the script to finish before forcefully killing it. This is done by adding the
-label `com.centurylinklabs.watchtower.lifecycle.pre-update-timeout` followed by
+label `com.centurylinklabs.watchtower.lifecycle.pre-update-timeout` or post-update-timeout respectively followed by
the timeout expressed in minutes.
If the label value is explicitly set to `0`, the timeout will be disabled.
@@ -62,5 +66,5 @@ If the label value is explicitly set to `0`, the timeout will be disabled.
### Execution failure
The failure of a command to execute, identified by an exit code different than
-0, will not prevent watchtower from updating the container. Only an error
+0 or 75 (EX_TEMPFAIL), will not prevent watchtower from updating the container. Only an error
log statement containing the exit code will be reported.
diff --git a/docs/linked-containers.md b/docs/linked-containers.md
index 240fb97..c7e9be8 100644
--- a/docs/linked-containers.md
+++ b/docs/linked-containers.md
@@ -2,4 +2,6 @@ Watchtower will detect if there are links between any of the running containers
For example, imagine you were running a _mysql_ container and a _wordpress_ container which had been linked to the _mysql_ container. If watchtower were to detect that the _mysql_ container required an update, it would first shut down the linked _wordpress_ container followed by the _mysql_ container. When restarting the containers it would handle _mysql_ first and then _wordpress_ to ensure that the link continued to work.
-If you want to override existing links you can use special `com.centurylinklabs.watchtower.depends-on` label with dependent container names, separated by a comma.
+If you want to override existing links, or if you are not using links, you can use special `com.centurylinklabs.watchtower.depends-on` label with dependent container names, separated by a comma.
+
+When you have a depending container that is using `network_mode: service:container` then watchtower will treat that container as an implicit link.
diff --git a/docs/metrics.md b/docs/metrics.md
index d8ea1b4..480d7c6 100644
--- a/docs/metrics.md
+++ b/docs/metrics.md
@@ -1,13 +1,14 @@
-> **⚠️ 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.
+!!! warning "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),
+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`.
+The metrics API endpoint is `/v1/metrics`.
+
## Available Metrics
| Name | Type | Description |
@@ -18,9 +19,24 @@ as well as creating a port mapping for your container for port `8080`.
| `watchtower_scans_total` | Counter | Number of scans since the watchtower started |
| `watchtower_scans_skipped` | Counter | Number of skipped scans since watchtower started |
+## Example Prometheus `scrape_config`
+
+```yaml
+scrape_configs:
+ - job_name: watchtower
+ scrape_interval: 5s
+ metrics_path: /v1/metrics
+ bearer_token: demotoken
+ static_configs:
+ - targets:
+ - 'watchtower:8080'
+```
+
+Replace `demotoken` with the Bearer token you have set accordingly.
+
## 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 57603cb..d5da4fe 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -1,40 +1,262 @@
# Notifications
-Watchtower can send notifications when containers are updated. Notifications are sent via hooks in the logging system, [logrus](http://github.com/sirupsen/logrus).
-The types of notifications to send are set by passing a comma-separated list of values to the `--notifications` option (or corresponding environment variable `WATCHTOWER_NOTIFICATIONS`), which has the following valid values:
+Watchtower can send notifications when containers are updated. Notifications are sent via hooks in the logging
+system, [logrus](http://github.com/sirupsen/logrus).
-- `email` to send notifications via e-mail
-- `slack` to send notifications through a Slack webhook
-- `msteams` to send notifications via MSTeams webhook
-- `gotify` to send notifications via Gotify
-- `shoutrrr` to send notifications via [containrrr/shoutrrr](https://github.com/containrrr/shoutrrr)
-
-> 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 (`"`).
->
-> This prevents unexpected errors when watchtower starts.
+!!! note "Using multiple notifications with environment variables"
+ There is currently a bug in Viper (https://github.com/spf13/viper/issues/380), which prevents comma-separated slices to
+ 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:
+ ```
+ 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 (`"`). 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.
+- `--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`.
+- `--notifications-hostname` (env. `WATCHTOWER_NOTIFICATIONS_HOSTNAME`): Custom hostname specified in subject/title. Useful to override the operating system hostname.
+- `--notifications-delay` (env. `WATCHTOWER_NOTIFICATIONS_DELAY`): Delay before sending notifications expressed in seconds.
+- 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.
+- `--notification-title-tag` (env. `WATCHTOWER_NOTIFICATION_TITLE_TAG`): Prefix to include in the title. Useful when running multiple watchtowers.
+- `--notification-skip-title` (env. `WATCHTOWER_NOTIFICATION_SKIP_TITLE`): Do not pass the title param to notifications. This will not pass a dynamic title override to notification services. If no title is configured for the service, it will remove the title all together.
+- `--notification-log-stdout` (env. `WATCHTOWER_NOTIFICATION_LOG_STDOUT`): Enable output from `logger://` shoutrrr service to stdout.
-## Available services
+## [Shoutrrr](https://github.com/containrrr/shoutrrr) notifications
+
+To send notifications via shoutrrr, the following command-line options, or their corresponding environment variables, can be set:
+
+- `--notification-url` (env. `WATCHTOWER_NOTIFICATION_URL`): The shoutrrr service URL to be used. This option can also reference a file, in which case the contents of the file are used.
+
+
+Go to [containrrr.dev/shoutrrr/v0.8/services/overview](https://containrrr.dev/shoutrrr/v0.8/services/overview) to
+learn more about the different service URLs you can use. You can define multiple services by space separating the
+URLs. (See example below)
+
+You can customize the message posted by setting a template.
+
+- `--notification-template` (env. `WATCHTOWER_NOTIFICATION_TEMPLATE`): The template used for the message.
+
+The template is a Go [template](https://golang.org/pkg/text/template/) that either format a list
+of [log entries](https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry) or a `notification.Data` struct.
+
+Simple templates are used unless the `notification-report` flag is specified:
+
+- `--notification-report` (env. `WATCHTOWER_NOTIFICATION_REPORT`): Use the session report as the notification template data.
+
+## Simple templates
+
+The default value if not set is `{{range .}}{{.Message}}{{println}}{{end}}`. The example below uses a template that also
+outputs timestamp and log level.
+
+!!! tip "Custom date format"
+ If you want to adjust the date/time format it must show how the
+ [reference time](https://golang.org/pkg/time/#pkg-constants) (_Mon Jan 2 15:04:05 MST 2006_) would be displayed in your
+ custom format.
+ i.e., The day of the year has to be 1, the month has to be 2 (february), the hour 3 (or 15 for 24h time) etc.
+
+!!! note "Skipping notifications"
+ To skip sending notifications that do not contain any information, you can wrap your template with `{{if .}}` and `{{end}}`.
+
+
+Example:
+
+```bash
+docker run -d \
+ --name watchtower \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -e WATCHTOWER_NOTIFICATION_URL="discord://token@channel slack://watchtower@token-a/token-b/token-c" \
+ -e WATCHTOWER_NOTIFICATION_TEMPLATE="{{range .}}{{.Time.Format \"2006-01-02 15:04:05\"}} ({{.Level}}): {{.Message}}{{println}}{{end}}" \
+ containrrr/watchtower
+```
+
+## Report templates
+
+The default template for report notifications are the following:
+```go
+{{- if .Report -}}
+ {{- with .Report -}}
+ {{- if ( or .Updated .Failed ) -}}
+{{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
+ {{- range .Updated}}
+- {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
+ {{- end -}}
+ {{- range .Fresh}}
+- {{.Name}} ({{.ImageName}}): {{.State}}
+ {{- end -}}
+ {{- range .Skipped}}
+- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- range .Failed}}
+- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+{{- else -}}
+ {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
+{{- end -}}
+```
+
+It will be used to send a summary of every session if there are any containers that were updated or which failed to update.
+
+!!! note "Skipping notifications"
+ Whenever the result of applying the template results in an empty string, no notifications will
+ be sent. This is by default used to limit the notifications to only be sent when there something noteworthy occurred.
+
+ You can replace `{{- if ( or .Updated .Failed ) -}}` with any logic you want to decide when to send the notifications.
+
+Example using a custom report template that always sends a session report after each run:
+
+=== "docker run"
+
+ ```bash
+ docker run -d \
+ --name watchtower \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -e WATCHTOWER_NOTIFICATION_REPORT="true" \
+ -e WATCHTOWER_NOTIFICATION_URL="discord://token@channel slack://watchtower@token-a/token-b/token-c" \
+ -e WATCHTOWER_NOTIFICATION_TEMPLATE="
+ {{- if .Report -}}
+ {{- with .Report -}}
+ {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
+ {{- range .Updated}}
+ - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
+ {{- end -}}
+ {{- range .Fresh}}
+ - {{.Name}} ({{.ImageName}}): {{.State}}
+ {{- end -}}
+ {{- range .Skipped}}
+ - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- range .Failed}}
+ - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- end -}}
+ {{- else -}}
+ {{range .Entries -}}{{.Message}}{{\"\n\"}}{{- end -}}
+ {{- end -}}
+ " \
+ containrrr/watchtower
+ ```
+
+=== "docker-compose"
+
+ ``` yaml
+ version: "3"
+ services:
+ watchtower:
+ image: containrrr/watchtower
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ env:
+ WATCHTOWER_NOTIFICATION_REPORT: "true"
+ WATCHTOWER_NOTIFICATION_URL: >
+ discord://token@channel
+ slack://watchtower@token-a/token-b/token-c
+ WATCHTOWER_NOTIFICATION_TEMPLATE: |
+ {{- if .Report -}}
+ {{- with .Report -}}
+ {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
+ {{- range .Updated}}
+ - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
+ {{- end -}}
+ {{- range .Fresh}}
+ - {{.Name}} ({{.ImageName}}): {{.State}}
+ {{- end -}}
+ {{- range .Skipped}}
+ - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- range .Failed}}
+ - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- end -}}
+ {{- else -}}
+ {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
+ {{- end -}}
+ ```
+
+## Legacy notifications
+
+For backwards compatibility, the notifications can also be configured using legacy notification options. These will automatically be converted to shoutrrr URLs when used.
+The types of notifications to send are set by passing a comma-separated list of values to the `--notifications` option
+(or corresponding environment variable `WATCHTOWER_NOTIFICATIONS`), which has the following valid values:
+
+- `email` to send notifications via e-mail
+- `slack` to send notifications through a Slack webhook
+- `msteams` to send notifications via MSTeams webhook
+- `gotify` to send notifications via Gotify
+
+### `notify-upgrade`
+If watchtower is started with `notify-upgrade` as it's first argument, it will generate a .env file with your current legacy notification options converted to shoutrrr URLs.
+
+=== "docker run"
+
+ ```bash
+ $ docker run -d \
+ --name watchtower \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -e WATCHTOWER_NOTIFICATIONS=slack \
+ -e WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL="https://hooks.slack.com/services/xxx/yyyyyyyyyyyyyyy" \
+ containrrr/watchtower \
+ notify-upgrade
+ ```
+
+=== "docker-compose.yml"
+
+ ```yaml
+ version: "3"
+ services:
+ watchtower:
+ image: containrrr/watchtower
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ env:
+ WATCHTOWER_NOTIFICATIONS: slack
+ WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL: https://hooks.slack.com/services/xxx/yyyyyyyyyyyyyyy
+ command: notify-upgrade
+ ```
+
+
+You can then copy this file from the container (a message with the full command to do so will be logged) and use it with your current setup:
+
+=== "docker run"
+
+ ```bash
+ $ docker run -d \
+ --name watchtower \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ --env-file watchtower-notifications.env \
+ containrrr/watchtower
+ ```
+
+=== "docker-compose.yml"
+
+ ```yaml
+ version: "3"
+ services:
+ watchtower:
+ image: containrrr/watchtower
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ env_file:
+ - watchtower-notifications.env
+ ```
### Email
To receive notifications by email, the following command-line options, or their corresponding environment variables, can be set:
-- `--notification-email-from` (env. `WATCHTOWER_NOTIFICATION_EMAIL_FROM`): The e-mail address from which notifications will be sent.
-- `--notification-email-to` (env. `WATCHTOWER_NOTIFICATION_EMAIL_TO`): The e-mail address to which notifications will be sent.
-- `--notification-email-server` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER`): The SMTP server to send e-mails through.
-- `--notification-email-server-tls-skip-verify` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY`): Do not verify the TLS certificate of the mail server. This should be used only for testing.
-- `--notification-email-server-port` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT`): The port used to connect to the SMTP server to send e-mails through. Defaults to `25`.
-- `--notification-email-server-user` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER`): The username to authenticate with the SMTP server with.
-- `--notification-email-server-password` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD`): The password to authenticate with the SMTP server with. Can also reference a file, in which case the contents of the file are used.
-- `--notification-email-delay` (env. `WATCHTOWER_NOTIFICATION_EMAIL_DELAY`): Delay before sending notifications expressed in seconds.
-- `--notification-email-subjecttag` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG`): Prefix to include in the subject tag. Useful when running multiple watchtowers.
+- `--notification-email-from` (env. `WATCHTOWER_NOTIFICATION_EMAIL_FROM`): The e-mail address from which notifications will be sent.
+- `--notification-email-to` (env. `WATCHTOWER_NOTIFICATION_EMAIL_TO`): The e-mail address to which notifications will be sent.
+- `--notification-email-server` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER`): The SMTP server to send e-mails through.
+- `--notification-email-server-tls-skip-verify` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY`): Do not verify the TLS certificate of the mail server. This should be used only for testing.
+- `--notification-email-server-port` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT`): The port used to connect to the SMTP server to send e-mails through. Defaults to `25`.
+- `--notification-email-server-user` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER`): The username to authenticate with the SMTP server with.
+- `--notification-email-server-password` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD`): The password to authenticate with the SMTP server with. Can also reference a file, in which case the contents of the file are used.
+- `--notification-email-delay` (env. `WATCHTOWER_NOTIFICATION_EMAIL_DELAY`): Delay before sending notifications expressed in seconds.
+- `--notification-email-subjecttag` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG`): Prefix to include in the subject tag. Useful when running multiple watchtowers. **NOTE:** This will affect all notification types.
Example:
@@ -60,7 +282,6 @@ The following example assumes, that your domain is called `your-domain.com` and
Example including an SMTP relay:
```yaml
----
version: '3.8'
services:
watchtower:
@@ -106,8 +327,6 @@ networks:
### Slack
-If watchtower is monitoring the same Docker daemon under which the watchtower container itself is running (i.e. if you volume-mounted _/var/run/docker.sock_ into the watchtower container) then it has the ability to update itself. If a new version of the _containrrr/watchtower_ image is pushed to the Docker Hub, your watchtower will pull down the new image and restart itself automatically.
-
To receive notifications in Slack, add `slack` to the `--notifications` option or the `WATCHTOWER_NOTIFICATIONS` environment variable.
Additionally, you should set the Slack webhook URL using the `--notification-slack-hook-url` option or the `WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL` environment variable. This option can also reference a file, in which case the contents of the file are used.
@@ -116,9 +335,7 @@ By default, watchtower will send messages under the name `watchtower`, you can c
Other, optional, variables include:
-- `--notification-slack-channel` (env. `WATCHTOWER_NOTIFICATION_SLACK_CHANNEL`): A string which overrides the webhook's default channel. Example: #my-custom-channel.
-- `--notification-slack-icon-emoji` (env. `WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI`): An [emoji code](https://www.webpagefx.com/tools/emoji-cheat-sheet/) string to use in place of the default icon.
-- `--notification-slack-icon-url` (env. `WATCHTOWER_NOTIFICATION_SLACK_ICON_URL`): An icon image URL string to use in place of the default icon.
+- `--notification-slack-channel` (env. `WATCHTOWER_NOTIFICATION_SLACK_CHANNEL`): A string which overrides the webhook's default channel. Example: #my-custom-channel.
Example:
@@ -130,8 +347,6 @@ docker run -d \
-e WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL="https://hooks.slack.com/services/xxx/yyyyyyyyyyyyyyy" \
-e WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER=watchtower-server-1 \
-e WATCHTOWER_NOTIFICATION_SLACK_CHANNEL=#my-custom-channel \
- -e WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI=:whale: \
- -e WATCHTOWER_NOTIFICATION_SLACK_ICON_URL= \
containrrr/watchtower
```
@@ -173,31 +388,3 @@ docker run -d \
If you want to disable TLS verification for the Gotify instance, you can use either `-e WATCHTOWER_NOTIFICATION_GOTIFY_TLS_SKIP_VERIFY=true` or `--notification-gotify-tls-skip-verify`.
-### [containrrr/shoutrrr](https://github.com/containrrr/shoutrrr)
-
-To send notifications via shoutrrr, the following command-line options, or their corresponding environment variables, can be set:
-
-- `--notification-url` (env. `WATCHTOWER_NOTIFICATION_URL`): The shoutrrr service URL to be used.
-
-Go to [containrrr.github.io/shoutrrr/services/overview](https://containrrr.github.io/shoutrrr/services/overview) to learn more about the different service URLs you can use.
-You can define multiple services by space separating the URLs. (See example below)
-
-You can customize the message posted by setting a template.
-
-- `--notification-template` (env. `WATCHTOWER_NOTIFICATION_TEMPLATE`): The template used for the message.
-
-The template is a Go [template](https://golang.org/pkg/text/template/) and the you format a list of [log entries](https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry).
-
-The default value if not set is `{{range .}}{{.Message}}{{println}}{{end}}`. The example below uses a template that also outputs timestamp and log level.
-
-Example:
-
-```bash
-docker run -d \
- --name watchtower \
- -v /var/run/docker.sock:/var/run/docker.sock \
- -e WATCHTOWER_NOTIFICATIONS=shoutrrr \
- -e WATCHTOWER_NOTIFICATION_URL="discord://token@channel slack://watchtower@token-a/token-b/token-c" \
- -e WATCHTOWER_NOTIFICATION_TEMPLATE="{{range .}}{{.Time.Format \"2006-01-02 15:04:05\"}} ({{.Level}}): {{.Message}}{{println}}{{end}}" \
- containrrr/watchtower
-```
diff --git a/docs/private-registries.md b/docs/private-registries.md
index 535b3e8..5367a8c 100644
--- a/docs/private-registries.md
+++ b/docs/private-registries.md
@@ -5,8 +5,8 @@ environment, watchtower needs to know the credentials to access the registry.
The credentials can be provided to watchtower in a configuration file called `config.json`.
There are two ways to generate this configuration file:
-* The configuration file can be created manually.
-* Call `docker login ` and share the resulting configuration file.
+* The configuration file can be created manually.
+* Call `docker login ` and share the resulting configuration file.
### Create the configuration file manually
Create a new configuration file with the following syntax and a base64 encoded username and
@@ -23,20 +23,42 @@ password `auth` string:
```
`` needs to be replaced by the name of your private registry
-(e.g., `my-private-registry.example.org`)
+(e.g., `my-private-registry.example.org`).
+
+!!! info "Using private images on Docker Hub"
+ To access private repositories on Docker Hub,
+ `` should be `https://index.docker.io/v1/`.
+ In this special case, the registry domain does not have to be specified
+ in `docker run` or `docker-compose`. Like Docker, Watchtower will use the
+ Docker Hub registry and its credentials when no registry domain is specified.
+
+ Watchtower will recognize credentials with `` `index.docker.io`,
+ but the Docker CLI will not.
+
+!!! important "Using a private registry on a local host"
+ To use a private registry hosted locally, make sure to correctly specify the registry host
+ in both `config.json` and the `docker run` command or `docker-compose` file.
+ Valid hosts are `localhost[:PORT]`, `HOST:PORT`,
+ or any multi-part `domain.name` or IP-address with or without a port.
+
+ Examples:
+ * `localhost` -> `localhost/myimage`
+ * `127.0.0.1` -> `127.0.0.1/myimage:mytag`
+ * `host.domain` -> `host.domain/myorganization/myimage`
+ * `other-lan-host:80` -> `other-lan-host:80/imagename:latest`
The required `auth` string can be generated as follows:
+
```bash
echo -n 'username:password' | base64
```
-> ### ℹ️ Username and Password for GCloud
->
-> For gcloud, we'll use `_json_key` as our username and the content
-> of `gcloudauth.json` as the password.
->```bash
-> echo -n "_json_key:$(cat gcloudauth.json)" | base64 -w0
->```
+!!! info "Username and Password for GCloud"
+ For gcloud, we'll use `_json_key` as our username and the content of `gcloudauth.json` as the password.
+ ```
+ bash echo -n "_json_key:$(cat gcloudauth.json)" | base64 -w0
+ ```
+
When the watchtower Docker container is started, the created configuration file
(`/config.json` in this example) needs to be passed to the container:
@@ -45,6 +67,7 @@ docker run [...] -v /config.json:/config.json containrrr/watchtower
```
### Share the Docker configuration file
+
To pull an image from a private registry, `docker login` needs to be called first, to get access
to the registry. The provided credentials are stored in a configuration file called `/.docker/config.json`.
This configuration file can be directly used by watchtower. In this case, the creation of an
@@ -62,7 +85,7 @@ When creating the watchtower container via docker-compose, use the following lin
version: "3.4"
services:
watchtower:
- image: index.docker.io/containrrr/watchtower:latest
+ image: containrrr/watchtower:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /.docker/config.json:/config.json
@@ -86,7 +109,6 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
```
-
## Credential helpers
Some private Docker registries (the most prominent probably being AWS ECR) use non-standard ways of authentication.
To be able to use this together with watchtower, we need to use a credential helper.
@@ -97,91 +119,89 @@ helper in a separate container and mount it using volumes.
### Example
Example implementation for use with [amazon-ecr-credential-helper](https://github.com/awslabs/amazon-ecr-credential-helper):
-
Use the dockerfile below to build the [amazon-ecr-credential-helper](https://github.com/awslabs/amazon-ecr-credential-helper),
in a volume that may be mounted onto your watchtower container.
-1. Create the Dockerfile (contents below):
-
-```Dockerfile
-FROM golang:latest
-
-ENV CGO_ENABLED 0
-ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
-
-RUN go get -u $REPO
-
-RUN rm /go/bin/docker-credential-ecr-login
-
-RUN go build \
- -o /go/bin/docker-credential-ecr-login \
- /go/src/$REPO
-
-WORKDIR /go/bin/
-```
+1. Create the Dockerfile (contents below):
+ ```Dockerfile
+ FROM golang:1.20
+
+ ENV GO111MODULE off
+ ENV CGO_ENABLED 0
+ ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
+
+ RUN go get -u $REPO
+
+ RUN rm /go/bin/docker-credential-ecr-login
+
+ RUN go build \
+ -o /go/bin/docker-credential-ecr-login \
+ /go/src/$REPO
+
+ WORKDIR /go/bin/
+ ```
2. Use the following commands to build the aws-ecr-dock-cred-helper and store it's output in a volume:
-
-```shell script
-# Create a volume to store the command (once built)
-docker volume create helper
-
-# Build the container
-docker build -t aws-ecr-dock-cred-helper .
-
-# Build the command and store it in the new volume in the /go/bin directory.
-docker run -d --rm --name aws-cred-helper --volume helper:/go/bin aws-ecr-dock-cred-helper
-
-```
+ ```bash
+ # Create a volume to store the command (once built)
+ docker volume create helper
+
+ # Build the container
+ docker build -t aws-ecr-dock-cred-helper .
+
+ # Build the command and store it in the new volume in the /go/bin directory.
+ docker run -d --rm --name aws-cred-helper \
+ --volume helper:/go/bin aws-ecr-dock-cred-helper
+ ```
3. Create a configuration file for docker, and store it in $HOME/.docker/config.json (replace the
- placeholders with your AWS Account ID):
-
-```json
-{
- "credsStore" : "ecr-login",
- "HttpHeaders" : {
- "User-Agent" : "Docker-Client/19.03.1 (XXXXXX)"
- },
- "auths" : {
- ".dkr.ecr.us-west-1.amazonaws.com" : {}
- },
- "credHelpers": {
- ".dkr.ecr.us-west-1.amazonaws.com" : "ecr-login"
+ placeholders with your AWS Account ID and with your AWS ECR Region):
+ ```json
+ {
+ "credsStore" : "ecr-login",
+ "HttpHeaders" : {
+ "User-Agent" : "Docker-Client/19.03.1 (XXXXXX)"
+ },
+ "auths" : {
+ ".dkr.ecr..amazonaws.com" : {}
+ },
+ "credHelpers": {
+ ".dkr.ecr..amazonaws.com" : "ecr-login"
+ }
}
-}
-```
+ ```
4. Create a docker-compose file (as an example) to help launch the container:
-
-and the docker-compose definition:
-```yaml
-version: "3.4"
-services:
- # Check for new images and restart things if a new image exists
- # for any of our containers.
- watchtower:
- image: containrrr/watchtower:latest
+ ```yaml
+ version: "3.4"
+ services:
+ # Check for new images and restart things if a new image exists
+ # for any of our containers.
+ watchtower:
+ image: containrrr/watchtower:latest
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - .docker/config.json:/config.json
+ - helper:/go/bin
+ environment:
+ - HOME=/
+ - PATH=$PATH:/go/bin
+ - AWS_REGION=us-west-1
volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - .docker/config.json:/config.json
- - helper:/go/bin
- environment:
- - HOME=/
- - PATH=$PATH:/go/bin
- - AWS_REGION=us-west-1
-volumes:
- helper:
- external: true
-```
+ helper:
+ external: true
+ ```
A few additional notes:
1. With docker-compose the volume (helper, in this case) MUST be set to `external: true`, otherwise docker-compose
will preface it with the directory name.
+
2. Note that "credsStore" : "ecr-login" is needed - and in theory if you have that you can remove the
- credHelpers section
+ credHelpers section
+
3. I have this running on an EC2 instance that has credentials assigned to it - so no keys are needed; however,
you may need to include the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables as well.
+
4. An alternative to adding the various variables is to create a ~/.aws/config and ~/.aws/credentials files and
place the settings there, then mount the ~/.aws directory to / in the container.
diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md
index e08fbd3..22c3f94 100644
--- a/docs/remote-hosts.md
+++ b/docs/remote-hosts.md
@@ -15,4 +15,4 @@ docker run -d \
containrrr/watchtower
```
-Note in both of the examples above that it is unnecessary to mount the _/var/run/docker.sock_ into the watchtower container.
\ No newline at end of file
+Note in both of the examples above that it is unnecessary to mount the _/var/run/docker.sock_ into the watchtower container.
diff --git a/docs/running-multiple-instances.md b/docs/running-multiple-instances.md
index 641f4e4..5a82c80 100644
--- a/docs/running-multiple-instances.md
+++ b/docs/running-multiple-instances.md
@@ -1,10 +1,11 @@
By default, Watchtower will clean up other instances and won't allow multiple instances running on the same Docker host or swarm. It is possible to override this behavior by defining a [scope](https://containrrr.github.io/watchtower/arguments/#filter_by_scope) to each running instance.
-Notice that:
-- Multiple instances can't run with the same scope;
-- An instance without a scope will clean up other running instances, even if they have a defined scope;
+!!! note
+ - Multiple instances can't run with the same scope;
+ - An instance without a scope will clean up other running instances, even if they have a defined scope;
+ - Supplying `none` as the scope will treat `com.centurylinklabs.watchtower.scope=none`, `com.centurylinklabs.watchtower.scope=` and the lack of a `com.centurylinklabs.watchtower.scope` label as the scope `none`. This effectly enables you to run both scoped and unscoped watchtower instances on the same machine.
-To define an instance monitoring scope, use the `--scope` argument or the `WATCHTOWER_SCOPE` environment variable on startup and set the _com.centurylinklabs.watchtower.scope_ label with the same value for the containers you want to include in this instance's scope (including the instance itself).
+To define an instance monitoring scope, use the `--scope` argument or the `WATCHTOWER_SCOPE` environment variable on startup and set the `com.centurylinklabs.watchtower.scope` label with the same value for the containers you want to include in this instance's scope (including the instance itself).
For example, in a Docker Compose config file:
@@ -12,16 +13,29 @@ For example, in a Docker Compose config file:
version: '3'
services:
- app-monitored-by-watchtower:
+ app-with-scope:
image: myapps/monitored-by-watchtower
- labels:
- - "com.centurylinklabs.watchtower.scope=myscope"
+ labels: [ "com.centurylinklabs.watchtower.scope=myscope" ]
- watchtower:
+ scoped-watchtower:
image: containrrr/watchtower
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
+ volumes: [ "/var/run/docker.sock:/var/run/docker.sock" ]
command: --interval 30 --scope myscope
- labels:
- - "com.centurylinklabs.watchtower.scope=myscope"
-```
\ No newline at end of file
+ labels: [ "com.centurylinklabs.watchtower.scope=myscope" ]
+
+ unscoped-app-a:
+ image: myapps/app-a
+
+ unscoped-app-b:
+ image: myapps/app-b
+ labels: [ "com.centurylinklabs.watchtower.scope=none" ]
+
+ unscoped-app-c:
+ image: myapps/app-b
+ labels: [ "com.centurylinklabs.watchtower.scope=" ]
+
+ unscoped-watchtower:
+ image: containrrr/watchtower
+ volumes: [ "/var/run/docker.sock:/var/run/docker.sock" ]
+ command: --interval 30 --scope none
+```
diff --git a/docs/stylesheets/theme.css b/docs/stylesheets/theme.css
index 34f507d..e552129 100644
--- a/docs/stylesheets/theme.css
+++ b/docs/stylesheets/theme.css
@@ -1,16 +1,87 @@
[data-md-color-scheme="containrrr"] {
- --md-primary-fg-color: #406170;
- --md-primary-fg-color--light:#acbfc7;
- --md-primary-fg-color--dark: #003343;
- --md-accent-fg-color: #003343;
- --md-accent-fg-color--transparent: #00334310;
+ /* Primary and accent */
+ --md-primary-fg-color: #406170;
+ --md-primary-fg-color--light:#acbfc7;
+ --md-primary-fg-color--dark: #003343;
+ --md-accent-fg-color: #003343;
+ --md-accent-fg-color--transparent: #00334310;
+
+ /* Typeset overrides */
+ --md-typeset-a-color: var(--md-primary-fg-color);
+}
+
+[data-md-color-scheme="containrrr-dark"] {
+ --md-hue: 199;
+
+ /* Primary and accent */
+ --md-primary-fg-color: hsl(199deg 27% 35% / 100%);
+ --md-primary-fg-color--link: hsl(199deg 45% 65% / 100%);
+ --md-primary-fg-color--light: hsl(198deg 19% 73% / 100%);
+ --md-primary-fg-color--dark: hsl(194deg 100% 13% / 100%);
+ --md-accent-fg-color: hsl(194deg 45% 50% / 100%);
+ --md-accent-fg-color--transparent: hsl(194deg 45% 50% / 6.3%);
+
+ /* Default */
+ --md-default-fg-color: hsl(var(--md-hue) 75% 95% / 100%);
+ --md-default-fg-color--light: hsl(var(--md-hue) 75% 90% / 62%);
+ --md-default-fg-color--lighter: hsl(var(--md-hue) 75% 90% / 32%);
+ --md-default-fg-color--lightest: hsl(var(--md-hue) 75% 90% / 12%);
+ --md-default-bg-color: hsl(var(--md-hue) 15% 21% / 100%);
+ --md-default-bg-color--light: hsl(var(--md-hue) 15% 21% / 54%);
+ --md-default-bg-color--lighter: hsl(var(--md-hue) 15% 21% / 26%);
+ --md-default-bg-color--lightest: hsl(var(--md-hue) 15% 21% / 7%);
+
+ /* Code */
+ --md-code-fg-color: hsl(var(--md-hue) 18% 86% / 100%);
+ --md-code-bg-color: hsl(var(--md-hue) 15% 15% / 100%);
+ --md-code-hl-color: hsl(218deg 100% 63% / 15%);
+ --md-code-hl-number-color: hsl(346deg 74% 63% / 100%);
+ --md-code-hl-special-color: hsl(320deg 83% 66% / 100%);
+ --md-code-hl-function-color: hsl(271deg 57% 65% / 100%);
+ --md-code-hl-constant-color: hsl(230deg 62% 70% / 100%);
+ --md-code-hl-keyword-color: hsl(199deg 33% 64% / 100%);
+ --md-code-hl-string-color: hsl( 50deg 34% 74% / 100%);
+ --md-code-hl-name-color: var(--md-code-fg-color);
+ --md-code-hl-operator-color: var(--md-default-fg-color--light);
+ --md-code-hl-punctuation-color: var(--md-default-fg-color--light);
+ --md-code-hl-comment-color: var(--md-default-fg-color--light);
+ --md-code-hl-generic-color: var(--md-default-fg-color--light);
+ --md-code-hl-variable-color: hsl(241deg 22% 60% / 100%);
+
+ /* Typeset */
+ --md-typeset-color: var(--md-default-fg-color);
+ --md-typeset-a-color: var(--md-primary-fg-color--link);
+ --md-typeset-mark-color: hsl(218deg 100% 63% / 30%);
+ --md-typeset-kbd-color: hsl(var(--md-hue) 15% 94% / 12%);
+ --md-typeset-kbd-accent-color: hsl(var(--md-hue) 15% 94% / 20%);
+ --md-typeset-kbd-border-color: hsl(var(--md-hue) 15% 14% / 100%);
+ --md-typeset-table-color: hsl(var(--md-hue) 75% 95% / 12%);
+
+ /* Admonition */
+ --md-admonition-fg-color: var(--md-default-fg-color);
+ --md-admonition-bg-color: var(--md-default-bg-color);
+
+ /* Footer */
+ --md-footer-bg-color: hsl(var(--md-hue) 15% 12% / 87%);
+ --md-footer-bg-color--dark: hsl(var(--md-hue) 15% 10% / 100%);
+
+ /* Shadows */
+ --md-shadow-z1:
+ 0 0.2rem 0.50rem rgba(0 0 0 20%),
+ 0 0 0.05rem rgba(0 0 0 10%);
+ --md-shadow-z2:
+ 0 0.2rem 0.50rem rgba(0 0 0 30%),
+ 0 0 0.05rem rgba(0 0 0 25%);
+ --md-shadow-z3:
+ 0 0.2rem 0.50rem rgba(0 0 0 40%),
+ 0 0 0.05rem rgba(0 0 0 35%);
}
.md-header-nav__button.md-logo {
- padding: 0;
+ padding: 0;
}
.md-header-nav__button.md-logo img {
- width: 1.6rem;
- height: 1.6rem;
-}
\ No newline at end of file
+ width: 1.6rem;
+ height: 1.6rem;
+}
diff --git a/docs/template-preview.md b/docs/template-preview.md
new file mode 100644
index 0000000..3d99ce9
--- /dev/null
+++ b/docs/template-preview.md
@@ -0,0 +1,251 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/updating.md b/docs/updating.md
new file mode 100644
index 0000000..952a0f9
--- /dev/null
+++ b/docs/updating.md
@@ -0,0 +1,6 @@
+## Updating Watchtower
+
+If watchtower is monitoring the same Docker daemon under which the watchtower container itself is running (i.e. if you
+volume-mounted `/var/run/docker.sock` into the watchtower container) then it has the ability to update itself.
+If a new version of the `containrrr/watchtower` image is pushed to the Docker Hub, your watchtower will pull down the
+new image and restart itself automatically.
diff --git a/docs/usage-overview.md b/docs/usage-overview.md
index b5737c3..1cac352 100644
--- a/docs/usage-overview.md
+++ b/docs/usage-overview.md
@@ -27,25 +27,35 @@ docker run -d \
Also check out [this Stack Overflow answer](https://stackoverflow.com/a/30494145/7872793) for more options on how to pass environment variables.
-Mounting the host's docker config file:
+Alternatively if you 2FA authentication setup on Docker Hub then passing username and password will be insufficient. Instead you can run `docker login` to store your credentials in `$HOME/.docker/config.json` and then mount this config file to make it available to the Watchtower container:
```bash
docker run -d \
--name watchtower \
- -v /home//.docker/config.json:/config.json \
+ -v $HOME/.docker/config.json:/config.json \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower container_to_watch --debug
```
-> 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!).
+!!! note "Changes to config.json while running"
+ 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 on the GitHub Registry and monitors it with watchtower. Note the command argument changing the interval
+to 30s rather than the default 24 hours.
```yaml
version: "3"
services:
cavo:
- image: index.docker.io//:
+ image: ghcr.io//:
ports:
- "443:3443"
- "80:3080"
@@ -55,4 +65,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 87327db..6c20d11 100644
--- a/go.mod
+++ b/go.mod
@@ -1,69 +1,74 @@
module github.com/containrrr/watchtower
-go 1.12
-
-replace golang.org/x/sys => golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a
+go 1.20
require (
- github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect
- github.com/Microsoft/go-winio v0.4.12 // indirect
- github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d // indirect
- github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 // indirect
- github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 // indirect
- github.com/bitly/go-simplejson v0.5.0 // indirect
- github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
- github.com/bugsnag/bugsnag-go v1.5.3 // indirect
- github.com/bugsnag/panicwrap v1.2.0 // indirect
- 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.3.0
- 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-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
- github.com/gofrs/uuid v3.2.0+incompatible // indirect
- github.com/golang/protobuf v1.4.2 // indirect
- github.com/google/certificate-transparency-go v1.0.21 // indirect
- 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.11.0
- github.com/onsi/gomega v1.10.0
- 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 // indirect
- github.com/pkg/errors v0.8.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.7
+ github.com/containrrr/shoutrrr v0.8.0
+ github.com/distribution/reference v0.5.0
+ github.com/docker/cli v24.0.7+incompatible
+ github.com/docker/docker v24.0.7+incompatible
+ github.com/docker/go-connections v0.4.0
+ github.com/onsi/ginkgo v1.16.5
+ github.com/onsi/gomega v1.30.0
+ github.com/prometheus/client_golang v1.18.0
+ github.com/robfig/cron v1.2.0
+ github.com/sirupsen/logrus v1.9.3
+ github.com/spf13/cobra v1.8.0
github.com/spf13/pflag v1.0.5
- github.com/spf13/viper v1.6.3
- github.com/stretchr/testify v1.3.0
- github.com/theupdateframework/notary v0.6.1 // indirect
- github.com/zmap/zlint v1.0.2 // indirect
- golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
- golang.org/x/net v0.0.0-20191004110552-13f9640d40b9
- golang.org/x/text v0.3.4 // indirect
- golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // 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
+ github.com/spf13/viper v1.18.2
+ github.com/stretchr/testify v1.8.4
+ golang.org/x/net v0.19.0
+)
+
+require github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
+
+require (
+ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
+ github.com/Microsoft/go-winio v0.4.17 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.2.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/docker/distribution v2.8.3+incompatible // indirect
+ github.com/docker/docker-credential-helpers v0.6.1 // indirect
+ github.com/docker/go-units v0.4.0 // indirect
+ github.com/fatih/color v1.15.0 // indirect
+ github.com/fsnotify/fsnotify v1.7.0 // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/golang/protobuf v1.5.3 // indirect
+ github.com/google/go-cmp v0.6.0 // indirect
+ github.com/hashicorp/hcl v1.0.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/magiconair/properties v1.8.7 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.17 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
+ github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c // indirect
+ github.com/nxadm/tail v1.4.8 // indirect
+ github.com/opencontainers/go-digest v1.0.0 // indirect
+ github.com/opencontainers/image-spec v1.0.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.1.0 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/prometheus/client_model v0.5.0 // indirect
+ github.com/prometheus/common v0.45.0 // indirect
+ github.com/prometheus/procfs v0.12.0 // indirect
+ github.com/sagikazarmark/locafero v0.4.0 // indirect
+ github.com/sagikazarmark/slog-shim v0.1.0 // indirect
+ github.com/sourcegraph/conc v0.3.0 // indirect
+ github.com/spf13/afero v1.11.0 // indirect
+ github.com/spf13/cast v1.6.0 // indirect
+ github.com/stretchr/objx v0.5.0 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ go.uber.org/atomic v1.9.0 // indirect
+ go.uber.org/multierr v1.9.0 // indirect
+ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/text v0.14.0
+ golang.org/x/time v0.5.0 // indirect
+ google.golang.org/protobuf v1.31.0 // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
+ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ gotest.tools/v3 v3.0.3 // indirect
)
diff --git a/go.sum b/go.sum
index c2e851a..cab338f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,463 +1,240 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.37.4 h1:glPeL3BQJsbF6aIIYfZizMwc5LTYz250bDMjttbBGAU=
-cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
-github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
-github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
-github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/Microsoft/go-winio v0.4.12 h1:xAfWHN1IrQ0NJ9TBC0KBZoqLjzDTr1ML+4MywiUOryc=
-github.com/Microsoft/go-winio v0.4.12/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
-github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs=
-github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
-github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
-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/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=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
-github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
-github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
-github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
-github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
-github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
-github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
-github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
-github.com/bugsnag/bugsnag-go v1.5.3 h1:yeRUT3mUE13jL1tGwvoQsKdVbAsQx9AJ+fqahKveP04=
-github.com/bugsnag/bugsnag-go v1.5.3/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
-github.com/bugsnag/panicwrap v1.2.0 h1:OzrKrRvXis8qEvOkfcxNcYbOd2O7xXS2nnKMEMABFQA=
-github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
-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/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.3.0 h1:2o1BKQUThSDtcidiMUq99CJijSRDa/nIB8kRhLBYmbk=
-github.com/containrrr/shoutrrr v0.3.0/go.mod h1:gqR3sngKPBVaLrmq9Pfw34x/MXxn0ATjY8/dW+rXzrU=
-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/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
+github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Microsoft/go-winio v0.4.17 h1:iT12IBVClFevaf8PuVyi3UmZOVh4OqnaLxDTW2O6j3w=
+github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
+github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
+github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
+github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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=
-github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3 h1:tkum0XDgfR0jcVVXuTsYv/erY2NnEDqwRojbxR1rBYA=
-github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM=
-github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
-github.com/docker/cli v0.0.0-20190327152802-57b27434ea29 h1:ciaXDHaWQda0nvevWqcjtXX/buQY3e0lga1vq8Batq0=
-github.com/docker/cli v0.0.0-20190327152802-57b27434ea29/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
-github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
-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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
+github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/cli v24.0.7+incompatible h1:wa/nIwYFW7BVTGa7SWPVyyXU9lgORqUb1xfI36MSkFg=
+github.com/docker/cli v24.0.7+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
+github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
+github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
+github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
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=
-github.com/docker/go v1.5.1-1/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
-github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82 h1:X0fj836zx99zFu83v/M79DuBn84IL/Syx1SY6Y5ZEMA=
-github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI=
-github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk=
-github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
-github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4=
-github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
-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/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.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
-github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
-github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
-github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
+github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
+github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
+github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
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/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-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/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=
-github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE=
-github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
+github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
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.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=
-github.com/google/certificate-transparency-go v1.0.21 h1:Yf1aXowfZ2nuboBsg7iYGLmwsOARdV86pfH3g95wXmE=
-github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg=
-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/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-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/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=
-github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
-github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
-github.com/hashicorp/go-version v1.1.0 h1:bPIoEKD27tNdebFGGxxYwcL4nepeY4j1QP23PFRGzg0=
-github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/jarcoal/httpmock v1.0.4 h1:jp+dy/+nonJE4g4xbVtl9QdrUNbn6/3hDT5R4nDIZnA=
-github.com/jarcoal/httpmock v1.0.4/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik=
-github.com/jinzhu/gorm v1.9.11 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE=
-github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw=
-github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
-github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
-github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
-github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
-github.com/johntdyer/slack-go v0.0.0-20180213144715-95fac1160b22 h1:jKUP9TQ0c7X3w6+IPyMit07RE42MtTWNd77sN2cHngQ=
-github.com/johntdyer/slack-go v0.0.0-20180213144715-95fac1160b22/go.mod h1:u0Jo4f2dNlTJeeOywkM6bLwxq6gC3pZ9rEFHn3AhTdk=
-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/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/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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-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=
-github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
-github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-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/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/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.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
-github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-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.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM=
-github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
-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/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/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
+github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
+github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw=
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/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
+github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
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.11.0 h1:JAKSXpt1YjtLA7YpPiqO9ss6sNXEsPfSGdwN0UHqzrw=
-github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-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.10.0 h1:Gwkk+PTu/nfOwNMtUB/mRUv0X7ewW5dO4AERT1ThVKo=
-github.com/onsi/gomega v1.10.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
-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=
-github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI=
-github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
-github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y=
-github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
-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=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
+github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
+github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8=
+github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
+github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
+github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
-github.com/prometheus/client_golang v0.9.3 h1:9iH4JKXLzFbOAdtqv/a+j8aewx2Y8lAjAydhbaScPF8=
-github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
-github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
-github.com/prometheus/common v0.2.0 h1:kUZDBDTdBVBYBj5Tmh2NZLlF60mfjA27rM34b+cVwNU=
-github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.4.0 h1:7etb9YClo3a6HjLzfl6rIQaU+FDfi0VSX39io3aQ+DM=
-github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 h1:sofwID9zm4tzrgykg80hfFph1mryUeLRsUfoocVVmRY=
-github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
-github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
-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.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/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
-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/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.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8=
-github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
-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.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
+github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
+github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
+github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
+github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
+github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
+github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
+github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
+github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
+github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
+github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
+github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
+github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
+github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
+github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
+github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
+github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
+github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
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/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
+github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
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/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
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/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/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
-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=
-github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
-github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE=
-github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is=
-github.com/zmap/zcrypto v0.0.0-20190729165852-9051775e6a2e h1:mvOa4+/DXStR4ZXOks/UsjeFdn5O5JpLUtzqk9U8xXw=
-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.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-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
+go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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/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/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
+golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/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=
-golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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-20191004110552-13f9640d40b9 h1:rjwSpXsdiK0dV8/Naq3kAw9ymfAeJIyd0upUIElB+lI=
-golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-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/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
+golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
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/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-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/sys v0.0.0-20190830141801-acfa387b8d69 h1:Wdn4Yb8d5VrsO3jWgaeSZss09x1VLVBMePDh4VW/xSQ=
-golang.org/x/sys v0.0.0-20190830141801-acfa387b8d69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200409092240-59c9f1ba88fa h1:mQTN3ECqfsViCNBgq+A40vdwhkGykrrQlYe3mPj6BoU=
-golang.org/x/sys v0.0.0-20200409092240-59c9f1ba88fa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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/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/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
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/tools v0.0.0-20190328211700-ab21143f2384/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-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107 h1:xtNn7qFlagY2mQNFHMSRPjT2RkOV4OXM7P5TVy9xATo=
-google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
-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/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/dancannon/gorethink.v3 v3.0.5 h1:/g7PWP7zUS6vSNmHSDbjCHQh1Rqn8Jy6zSMQxAsBSMQ=
-gopkg.in/dancannon/gorethink.v3 v3.0.5/go.mod h1:GXsi1e3N2OcKhcP6nsYABTiUejbWMFO4GY5a4pEaeEc=
-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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
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=
-gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
-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=
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.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gosrc.io/xmpp v0.1.1 h1:iMtE9W3fx254+4E6rI34AOPJDqWvpfQR6EYaVMzhJ4s=
-gosrc.io/xmpp v0.1.1/go.mod h1:4JgaXzw4MnEv2sGltONtK3GMhj+h9gpQ7cO8nwbFJLU=
-gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
-gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
-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=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
+gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
+gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
diff --git a/goreleaser.yml b/goreleaser.yml
index 927cdcd..1904d5e 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/internal/meta.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
index 998485b..3b7aa8f 100644
--- a/grafana/dashboards/dashboard.json
+++ b/grafana/dashboards/dashboard.json
@@ -102,7 +102,7 @@
"properties": [
{
"id": "displayName",
- "value": "Faled"
+ "value": "Failed"
}
]
},
@@ -290,4 +290,4 @@
"title": "Watchtower",
"uid": "d7bdoT-Gz",
"version": 1
-}
\ No newline at end of file
+}
diff --git a/internal/actions/actions_suite_test.go b/internal/actions/actions_suite_test.go
index ffa6e2a..c320564 100644
--- a/internal/actions/actions_suite_test.go
+++ b/internal/actions/actions_suite_test.go
@@ -4,13 +4,10 @@ import (
"testing"
"time"
+ "github.com/sirupsen/logrus"
+
"github.com/containrrr/watchtower/internal/actions"
-
- "github.com/containrrr/watchtower/pkg/container"
- "github.com/containrrr/watchtower/pkg/container/mocks"
-
- "github.com/docker/docker/api/types"
- cli "github.com/docker/docker/client"
+ "github.com/containrrr/watchtower/pkg/types"
. "github.com/containrrr/watchtower/internal/actions/mocks"
. "github.com/onsi/ginkgo"
@@ -19,59 +16,51 @@ import (
func TestActions(t *testing.T) {
RegisterFailHandler(Fail)
+ logrus.SetOutput(GinkgoWriter)
RunSpecs(t, "Actions Suite")
}
var _ = Describe("the actions package", func() {
- var dockerClient cli.CommonAPIClient
- var client MockClient
- BeforeSuite(func() {
- server := mocks.NewMockAPIServer()
- dockerClient, _ = cli.NewClientWithOpts(
- cli.WithHost(server.URL),
- cli.WithHTTPClient(server.Client()))
- })
- BeforeEach(func() {
- pullImages := false
- removeVolumes := false
-
- client = CreateMockClient(
- &TestData{},
- dockerClient,
- pullImages,
- removeVolumes,
- )
- })
-
Describe("the check prerequisites method", func() {
When("given an empty array", func() {
It("should not do anything", func() {
- client.TestData.Containers = []container.Container{}
- err := actions.CheckForMultipleWatchtowerInstances(client, false, "")
- Expect(err).NotTo(HaveOccurred())
+ client := CreateMockClient(
+ &TestData{},
+ // pullImages:
+ false,
+ // removeVolumes:
+ false,
+ )
+ Expect(actions.CheckForMultipleWatchtowerInstances(client, false, "")).To(Succeed())
})
})
When("given an array of one", func() {
It("should not do anything", func() {
- client.TestData.Containers = []container.Container{
- CreateMockContainer(
- "test-container",
- "test-container",
- "watchtower",
- time.Now()),
- }
- err := actions.CheckForMultipleWatchtowerInstances(client, false, "")
- Expect(err).NotTo(HaveOccurred())
+ client := CreateMockClient(
+ &TestData{
+ Containers: []types.Container{
+ CreateMockContainer(
+ "test-container",
+ "test-container",
+ "watchtower",
+ time.Now()),
+ },
+ },
+ // pullImages:
+ false,
+ // removeVolumes:
+ false,
+ )
+ Expect(actions.CheckForMultipleWatchtowerInstances(client, false, "")).To(Succeed())
})
})
When("given multiple containers", func() {
+ var client MockClient
BeforeEach(func() {
- pullImages := false
- removeVolumes := false
client = CreateMockClient(
&TestData{
NameOfContainerToKeep: "test-container-02",
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainer(
"test-container-01",
"test-container-01",
@@ -84,9 +73,10 @@ var _ = Describe("the actions package", func() {
time.Now()),
},
},
- dockerClient,
- pullImages,
- removeVolumes,
+ // pullImages:
+ false,
+ // removeVolumes:
+ false,
)
})
@@ -96,13 +86,11 @@ var _ = Describe("the actions package", func() {
})
})
When("deciding whether to cleanup images", func() {
+ var client MockClient
BeforeEach(func() {
- pullImages := false
- removeVolumes := false
-
client = CreateMockClient(
&TestData{
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainer(
"test-container-01",
"test-container-01",
@@ -115,9 +103,10 @@ var _ = Describe("the actions package", func() {
time.Now()),
},
},
- dockerClient,
- pullImages,
- removeVolumes,
+ // pullImages:
+ false,
+ // removeVolumes:
+ false,
)
})
It("should try to delete the image if the cleanup flag is true", func() {
@@ -133,15 +122,3 @@ var _ = Describe("the actions package", func() {
})
})
})
-
-func createMockContainer(id string, name string, image string, created time.Time) container.Container {
- content := types.ContainerJSON{
- ContainerJSONBase: &types.ContainerJSONBase{
- ID: id,
- Image: image,
- Name: name,
- Created: created.String(),
- },
- }
- return *container.NewContainer(&content, nil)
-}
diff --git a/internal/actions/check.go b/internal/actions/check.go
index 87133fc..77a2266 100644
--- a/internal/actions/check.go
+++ b/internal/actions/check.go
@@ -5,25 +5,47 @@ import (
"sort"
"time"
+ "github.com/containrrr/watchtower/pkg/container"
"github.com/containrrr/watchtower/pkg/filters"
"github.com/containrrr/watchtower/pkg/sorter"
- "github.com/sirupsen/logrus"
+ "github.com/containrrr/watchtower/pkg/types"
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))
+ filter := filters.WatchtowerContainersFilter
+ if scope != "" {
+ filter = filters.FilterByScope(scope, filter)
+ }
+ containers, err := client.ListContainers(filter)
if err != nil {
- log.Fatal(err)
return err
}
@@ -36,7 +58,7 @@ func CheckForMultipleWatchtowerInstances(client container.Client, cleanup bool,
return cleanupExcessWatchtowers(containers, client, cleanup)
}
-func cleanupExcessWatchtowers(containers []container.Container, client container.Client, cleanup bool) error {
+func cleanupExcessWatchtowers(containers []types.Container, client container.Client, cleanup bool) error {
var stopErrors int
sort.Sort(sorter.ByCreated(containers))
@@ -45,14 +67,14 @@ 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.WithError(err).Error("Could not stop a previous watchtower instance.")
+ log.WithError(err).Error("Could not stop a previous watchtower instance.")
stopErrors++
continue
}
if cleanup {
if err := client.RemoveImageByID(c.ImageID()); err != nil {
- logrus.WithError(err).Warning("Could not cleanup watchtower images, possibly because of other watchtowers instances in other scopes.")
+ log.WithError(err).Warning("Could not cleanup watchtower images, possibly because of other watchtowers instances in other scopes.")
}
}
}
@@ -63,8 +85,3 @@ func cleanupExcessWatchtowers(containers []container.Container, client container
return nil
}
-
-func awaitDockerClient() {
- log.Debug("Sleeping for a second to ensure the docker api client has been properly initialized.")
- time.Sleep(1 * time.Second)
-}
diff --git a/internal/actions/mocks/client.go b/internal/actions/mocks/client.go
index 33c196d..737404a 100644
--- a/internal/actions/mocks/client.go
+++ b/internal/actions/mocks/client.go
@@ -2,17 +2,15 @@ package mocks
import (
"errors"
- "github.com/containrrr/watchtower/pkg/container"
+ "fmt"
"time"
t "github.com/containrrr/watchtower/pkg/types"
- cli "github.com/docker/docker/client"
)
// MockClient is a mock that passes as a watchtower Client
type MockClient struct {
TestData *TestData
- api cli.CommonAPIClient
pullImages bool
removeVolumes bool
}
@@ -21,7 +19,8 @@ type MockClient struct {
type TestData struct {
TriedToRemoveImageCount int
NameOfContainerToKeep string
- Containers []container.Container
+ Containers []t.Container
+ Staleness map[string]bool
}
// TriedToRemoveImage is a test helper function to check whether RemoveImageByID has been called
@@ -30,22 +29,21 @@ func (testdata *TestData) TriedToRemoveImage() bool {
}
// CreateMockClient creates a mock watchtower Client for usage in tests
-func CreateMockClient(data *TestData, api cli.CommonAPIClient, pullImages bool, removeVolumes bool) MockClient {
+func CreateMockClient(data *TestData, pullImages bool, removeVolumes bool) MockClient {
return MockClient{
data,
- api,
pullImages,
removeVolumes,
}
}
// ListContainers is a mock method returning the provided container testdata
-func (client MockClient) ListContainers(f t.Filter) ([]container.Container, error) {
+func (client MockClient) ListContainers(_ t.Filter) ([]t.Container, error) {
return client.TestData.Containers, nil
}
// StopContainer is a mock method
-func (client MockClient) StopContainer(c container.Container, d time.Duration) error {
+func (client MockClient) StopContainer(c t.Container, _ time.Duration) error {
if c.Name() == client.TestData.NameOfContainerToKeep {
return errors.New("tried to stop the instance we want to keep")
}
@@ -53,32 +51,50 @@ func (client MockClient) StopContainer(c container.Container, d time.Duration) e
}
// StartContainer is a mock method
-func (client MockClient) StartContainer(c container.Container) (string, error) {
+func (client MockClient) StartContainer(_ t.Container) (t.ContainerID, error) {
return "", nil
}
// RenameContainer is a mock method
-func (client MockClient) RenameContainer(c container.Container, s string) error {
+func (client MockClient) RenameContainer(_ t.Container, _ string) error {
return nil
}
// RemoveImageByID increments the TriedToRemoveImageCount on being called
-func (client MockClient) RemoveImageByID(id string) error {
+func (client MockClient) RemoveImageByID(_ t.ImageID) error {
client.TestData.TriedToRemoveImageCount++
return nil
}
// GetContainer is a mock method
-func (client MockClient) GetContainer(containerID string) (container.Container, error) {
- return container.Container{}, nil
+func (client MockClient) GetContainer(_ t.ContainerID) (t.Container, error) {
+ return client.TestData.Containers[0], nil
}
// ExecuteCommand is a mock method
-func (client MockClient) ExecuteCommand(containerID string, command string, timeout int) error {
- return nil
+func (client MockClient) ExecuteCommand(_ t.ContainerID, command string, _ int) (SkipUpdate bool, err error) {
+ switch command {
+ case "/PreUpdateReturn0.sh":
+ return false, nil
+ case "/PreUpdateReturn1.sh":
+ return false, fmt.Errorf("command exited with code 1")
+ case "/PreUpdateReturn75.sh":
+ return true, nil
+ default:
+ return false, nil
+ }
}
-// IsContainerStale is always true for the mock client
-func (client MockClient) IsContainerStale(c container.Container) (bool, error) {
- return true, nil
+// IsContainerStale is true if not explicitly stated in TestData for the mock client
+func (client MockClient) IsContainerStale(cont t.Container, params t.UpdateParams) (bool, t.ImageID, error) {
+ stale, found := client.TestData.Staleness[cont.Name()]
+ if !found {
+ stale = true
+ }
+ return stale, "", nil
+}
+
+// WarnOnHeadPullFailed is always true for the mock client
+func (client MockClient) WarnOnHeadPullFailed(_ t.Container) bool {
+ return true
}
diff --git a/internal/actions/mocks/container.go b/internal/actions/mocks/container.go
index 1db8652..e830587 100644
--- a/internal/actions/mocks/container.go
+++ b/internal/actions/mocks/container.go
@@ -1,39 +1,59 @@
package mocks
import (
- "github.com/containrrr/watchtower/pkg/container"
- "github.com/docker/docker/api/types"
- container2 "github.com/docker/docker/api/types/container"
+ "fmt"
+ "strconv"
+ "strings"
"time"
+
+ "github.com/containrrr/watchtower/pkg/container"
+ wt "github.com/containrrr/watchtower/pkg/types"
+ "github.com/docker/docker/api/types"
+ dockerContainer "github.com/docker/docker/api/types/container"
+ "github.com/docker/go-connections/nat"
)
// CreateMockContainer creates a container substitute valid for testing
-func CreateMockContainer(id string, name string, image string, created time.Time) container.Container {
+func CreateMockContainer(id string, name string, image string, created time.Time) wt.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,
- &types.ImageInspect{
- ID: image,
- RepoDigests: []string{
- image,
+ HostConfig: &dockerContainer.HostConfig{
+ PortBindings: map[nat.Port][]nat.PortBinding{},
},
},
+ Config: &dockerContainer.Config{
+ Image: image,
+ Labels: make(map[string]string),
+ ExposedPorts: map[nat.Port]struct{}{},
+ },
+ }
+ return container.NewContainer(
+ &content,
+ CreateMockImageInfo(image),
)
}
+// CreateMockImageInfo returns a mock image info struct based on the passed image
+func CreateMockImageInfo(image string) *types.ImageInspect {
+ return &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 {
+func CreateMockContainerWithImageInfo(id string, name string, image string, created time.Time, imageInfo types.ImageInspect) wt.Container {
+ return CreateMockContainerWithImageInfoP(id, name, image, created, &imageInfo)
+}
+
+// CreateMockContainerWithImageInfoP should only be used for testing
+func CreateMockContainerWithImageInfoP(id string, name string, image string, created time.Time, imageInfo *types.ImageInspect) wt.Container {
content := types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
ID: id,
@@ -41,39 +61,83 @@ func CreateMockContainerWithImageInfo(id string, name string, image string, crea
Name: name,
Created: created.String(),
},
- Config: &container2.Config{
+ Config: &dockerContainer.Config{
Image: image,
Labels: make(map[string]string),
},
}
- return *container.NewContainer(
+ return container.NewContainer(
&content,
- &imageInfo,
+ imageInfo,
)
}
// CreateMockContainerWithDigest should only be used for testing
-func CreateMockContainerWithDigest(id string, name string, image string, created time.Time, digest string) container.Container {
+func CreateMockContainerWithDigest(id string, name string, image string, created time.Time, digest string) wt.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 {
+func CreateMockContainerWithConfig(id string, name string, image string, running bool, restarting bool, created time.Time, config *dockerContainer.Config) wt.Container {
+ content := types.ContainerJSON{
+ ContainerJSONBase: &types.ContainerJSONBase{
+ ID: id,
+ Image: image,
+ Name: name,
+ State: &types.ContainerState{
+ Running: running,
+ Restarting: restarting,
+ },
+ Created: created.String(),
+ HostConfig: &dockerContainer.HostConfig{
+ PortBindings: map[nat.Port][]nat.PortBinding{},
+ },
+ },
+ Config: config,
+ }
+ return container.NewContainer(
+ &content,
+ CreateMockImageInfo(image),
+ )
+}
+
+// CreateContainerForProgress creates a container substitute for tracking session/update progress
+func CreateContainerForProgress(index int, idPrefix int, nameFormat string) (wt.Container, wt.ImageID) {
+ indexStr := strconv.Itoa(idPrefix + index)
+ mockID := indexStr + strings.Repeat("0", 61-len(indexStr))
+ contID := "c79" + mockID
+ contName := fmt.Sprintf(nameFormat, index+1)
+ oldImgID := "01d" + mockID
+ newImgID := "d0a" + mockID
+ imageName := fmt.Sprintf("mock/%s:latest", contName)
+ config := &dockerContainer.Config{
+ Image: imageName,
+ }
+ c := CreateMockContainerWithConfig(contID, contName, oldImgID, true, false, time.Now(), config)
+ return c, wt.ImageID(newImgID)
+}
+
+// CreateMockContainerWithLinks should only be used for testing
+func CreateMockContainerWithLinks(id string, name string, image string, created time.Time, links []string, imageInfo *types.ImageInspect) wt.Container {
content := types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
ID: id,
Image: image,
Name: name,
Created: created.String(),
+ HostConfig: &dockerContainer.HostConfig{
+ Links: links,
+ },
+ },
+ Config: &dockerContainer.Config{
+ Image: image,
+ Labels: make(map[string]string),
},
- Config: config,
}
- return *container.NewContainer(
+ return container.NewContainer(
&content,
- &types.ImageInspect{
- ID: image,
- },
+ imageInfo,
)
}
diff --git a/internal/actions/mocks/progress.go b/internal/actions/mocks/progress.go
new file mode 100644
index 0000000..23fc441
--- /dev/null
+++ b/internal/actions/mocks/progress.go
@@ -0,0 +1,44 @@
+package mocks
+
+import (
+ "errors"
+
+ "github.com/containrrr/watchtower/pkg/session"
+ wt "github.com/containrrr/watchtower/pkg/types"
+)
+
+// CreateMockProgressReport creates a mock report from a given set of container states
+// All containers will be given a unique ID and name based on its state and index
+func CreateMockProgressReport(states ...session.State) wt.Report {
+
+ stateNums := make(map[session.State]int)
+ progress := session.Progress{}
+ failed := make(map[wt.ContainerID]error)
+
+ for _, state := range states {
+ index := stateNums[state]
+
+ switch state {
+ case session.SkippedState:
+ c, _ := CreateContainerForProgress(index, 41, "skip%d")
+ progress.AddSkipped(c, errors.New("unpossible"))
+ case session.FreshState:
+ c, _ := CreateContainerForProgress(index, 31, "frsh%d")
+ progress.AddScanned(c, c.ImageID())
+ case session.UpdatedState:
+ c, newImage := CreateContainerForProgress(index, 11, "updt%d")
+ progress.AddScanned(c, newImage)
+ progress.MarkForUpdate(c.ID())
+ case session.FailedState:
+ c, newImage := CreateContainerForProgress(index, 21, "fail%d")
+ progress.AddScanned(c, newImage)
+ failed[c.ID()] = errors.New("accidentally the whole container")
+ }
+
+ stateNums[state] = index + 1
+ }
+ progress.UpdateFailed(failed)
+
+ return progress.Report()
+
+}
diff --git a/internal/actions/update.go b/internal/actions/update.go
index 9320d6a..8853c6e 100644
--- a/internal/actions/update.go
+++ b/internal/actions/update.go
@@ -2,10 +2,11 @@ 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/session"
"github.com/containrrr/watchtower/pkg/sorter"
"github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
@@ -15,9 +16,9 @@ import (
// 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) {
+func Update(client container.Client, params types.UpdateParams) (types.Report, error) {
log.Debug("Checking containers for updated images")
- metric := &metrics2.Metric{}
+ progress := &session.Progress{}
staleCount := 0
if params.LifecycleHooks {
@@ -32,17 +33,31 @@ func Update(client container.Client, params types.UpdateParams) (*metrics2.Metri
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")
+ stale, newestImage, err := client.IsContainerStale(targetContainer, params)
+ shouldUpdate := stale && !params.NoRestart && !targetContainer.IsMonitorOnly(params)
+ 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++
+ progress.AddSkipped(targetContainer, err)
+ } else {
+ progress.AddScanned(targetContainer, newestImage)
}
- containers[i].Stale = stale
+ containers[i].SetStale(stale)
if stale {
staleCount++
@@ -50,50 +65,52 @@ func Update(client container.Client, params types.UpdateParams) (*metrics2.Metri
}
containers, err = sorter.SortByDependencies(containers)
- metric.Scanned = len(containers)
if err != nil {
return nil, err
}
- checkDependencies(containers)
+ UpdateImplicitRestart(containers)
- containersToUpdate := []container.Container{}
- if !params.MonitorOnly {
- for i := len(containers) - 1; i >= 0; i-- {
- if !containers[i].IsMonitorOnly() {
- containersToUpdate = append(containersToUpdate, containers[i])
- }
+ var containersToUpdate []types.Container
+ for _, c := range containers {
+ if !c.IsMonitorOnly(params) {
+ containersToUpdate = append(containersToUpdate, c)
+ progress.MarkForUpdate(c.ID())
}
}
if params.RollingRestart {
- metric.Failed += performRollingRestart(containersToUpdate, client, params)
+ progress.UpdateFailed(performRollingRestart(containersToUpdate, client, params))
} else {
- metric.Failed += stopContainersInReversedOrder(containersToUpdate, client, params)
- metric.Failed += restartContainersInSortedOrder(containersToUpdate, client, params)
+ failedStop, stoppedImages := stopContainersInReversedOrder(containersToUpdate, client, params)
+ progress.UpdateFailed(failedStop)
+ failedStart := restartContainersInSortedOrder(containersToUpdate, client, params, stoppedImages)
+ progress.UpdateFailed(failedStart)
}
- metric.Updated = staleCount - (metric.Failed - staleCheckFailed)
-
if params.LifecycleHooks {
lifecycle.ExecutePostChecks(client, params)
}
- return metric, nil
+ return progress.Report(), nil
}
-func performRollingRestart(containers []container.Container, client container.Client, params types.UpdateParams) int {
- cleanupImageIDs := make(map[string]bool)
- failed := 0
+func performRollingRestart(containers []types.Container, client container.Client, params types.UpdateParams) map[types.ContainerID]error {
+ cleanupImageIDs := make(map[types.ImageID]bool, len(containers))
+ failed := make(map[types.ContainerID]error, len(containers))
for i := len(containers) - 1; i >= 0; i-- {
- if containers[i].Stale {
- if err := stopStaleContainer(containers[i], client, params); err != nil {
- failed++
+ if containers[i].ToRestart() {
+ err := stopStaleContainer(containers[i], client, params)
+ if err != nil {
+ failed[containers[i].ID()] = err
+ } else {
+ if err := restartStaleContainer(containers[i], client, params); err != nil {
+ failed[containers[i].ID()] = err
+ } else if containers[i].IsStale() {
+ // Only add (previously) stale containers' images to cleanup
+ cleanupImageIDs[containers[i].ImageID()] = true
+ }
}
- if err := restartStaleContainer(containers[i], client, params); err != nil {
- failed++
- }
- cleanupImageIDs[containers[i].ImageID()] = true
}
}
@@ -103,31 +120,49 @@ func performRollingRestart(containers []container.Container, client container.Cl
return failed
}
-func stopContainersInReversedOrder(containers []container.Container, client container.Client, params types.UpdateParams) int {
- failed := 0
+func stopContainersInReversedOrder(containers []types.Container, client container.Client, params types.UpdateParams) (failed map[types.ContainerID]error, stopped map[types.ImageID]bool) {
+ failed = make(map[types.ContainerID]error, len(containers))
+ stopped = make(map[types.ImageID]bool, len(containers))
for i := len(containers) - 1; i >= 0; i-- {
if err := stopStaleContainer(containers[i], client, params); err != nil {
- failed++
+ failed[containers[i].ID()] = err
+ } else {
+ // NOTE: If a container is restarted due to a dependency this might be empty
+ stopped[containers[i].SafeImageID()] = true
}
+
}
- return failed
+ return
}
-func stopStaleContainer(container container.Container, client container.Client, params types.UpdateParams) error {
+func stopStaleContainer(container types.Container, client container.Client, params types.UpdateParams) error {
if container.IsWatchtower() {
log.Debugf("This is the watchtower container %s", container.Name())
return nil
}
- if !container.Stale {
+ if !container.ToRestart() {
return nil
}
+
+ // Perform an additional check here to prevent us from stopping a linked container we cannot restart
+ if container.IsLinkedToRestarting() {
+ if err := container.VerifyConfiguration(); err != nil {
+ return err
+ }
+ }
+
if params.LifecycleHooks {
- if err := lifecycle.ExecutePreUpdateCommand(client, container); err != nil {
+ skipUpdate, err := lifecycle.ExecutePreUpdateCommand(client, container)
+ if err != nil {
log.Error(err)
log.Info("Skipping container as the pre-update command failed")
return err
}
+ if skipUpdate {
+ log.Debug("Skipping container as the pre-update command returned exit code 75 (EX_TEMPFAIL)")
+ return errors.New("skipping container as the pre-update command returned exit code 75 (EX_TEMPFAIL)")
+ }
}
if err := client.StopContainer(container, params.Timeout); err != nil {
@@ -137,37 +172,43 @@ func stopStaleContainer(container container.Container, client container.Client,
return nil
}
-func restartContainersInSortedOrder(containers []container.Container, client container.Client, params types.UpdateParams) int {
- imageIDs := make(map[string]bool)
-
- failed := 0
+func restartContainersInSortedOrder(containers []types.Container, client container.Client, params types.UpdateParams, stoppedImages map[types.ImageID]bool) map[types.ContainerID]error {
+ cleanupImageIDs := make(map[types.ImageID]bool, len(containers))
+ failed := make(map[types.ContainerID]error, len(containers))
for _, c := range containers {
- if !c.Stale {
+ if !c.ToRestart() {
continue
}
- if err := restartStaleContainer(c, client, params); err != nil {
- failed++
+ if stoppedImages[c.SafeImageID()] {
+ if err := restartStaleContainer(c, client, params); err != nil {
+ failed[c.ID()] = err
+ } else if c.IsStale() {
+ // Only add (previously) stale containers' images to cleanup
+ cleanupImageIDs[c.ImageID()] = true
+ }
}
- imageIDs[c.ImageID()] = true
}
if params.Cleanup {
- cleanupImages(client, imageIDs)
+ cleanupImages(client, cleanupImageIDs)
}
return failed
}
-func cleanupImages(client container.Client, imageIDs map[string]bool) {
+func cleanupImages(client container.Client, imageIDs map[types.ImageID]bool) {
for imageID := range imageIDs {
+ if imageID == "" {
+ continue
+ }
if err := client.RemoveImageByID(imageID); err != nil {
log.Error(err)
}
}
}
-func restartStaleContainer(container container.Container, client container.Client, params types.UpdateParams) error {
+func restartStaleContainer(container types.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
@@ -183,28 +224,44 @@ func restartStaleContainer(container container.Container, client container.Clien
if newContainerID, err := client.StartContainer(container); err != nil {
log.Error(err)
return err
- } else if container.Stale && params.LifecycleHooks {
+ } else if container.ToRestart() && params.LifecycleHooks {
lifecycle.ExecutePostUpdateCommand(client, newContainerID)
}
}
return nil
}
-func checkDependencies(containers []container.Container) {
+// UpdateImplicitRestart iterates through the passed containers, setting the
+// `LinkedToRestarting` flag if any of it's linked containers are marked for restart
+func UpdateImplicitRestart(containers []types.Container) {
- for i, parent := range containers {
- if parent.ToRestart() {
+ for ci, c := range containers {
+ if c.ToRestart() {
+ // The container is already marked for restart, no need to check
continue
}
- LinkLoop:
- for _, linkName := range parent.Links() {
- for _, child := range containers {
- if child.Name() == linkName && child.ToRestart() {
- containers[i].Linked = true
- break LinkLoop
- }
+ if link := linkedContainerMarkedForRestart(c.Links(), containers); link != "" {
+ log.WithFields(log.Fields{
+ "restarting": link,
+ "linked": c.Name(),
+ }).Debug("container is linked to restarting")
+ // NOTE: To mutate the array, the `c` variable cannot be used as it's a copy
+ containers[ci].SetLinkedToRestarting(true)
+ }
+
+ }
+}
+
+// linkedContainerMarkedForRestart returns the name of the first link that matches a
+// container marked for restart
+func linkedContainerMarkedForRestart(links []string, containers []types.Container) string {
+ for _, linkName := range links {
+ for _, candidate := range containers {
+ if candidate.Name() == linkName && candidate.ToRestart() {
+ return linkName
}
}
}
+ return ""
}
diff --git a/internal/actions/update_test.go b/internal/actions/update_test.go
index f1b8e85..9209dcd 100644
--- a/internal/actions/update_test.go
+++ b/internal/actions/update_test.go
@@ -1,64 +1,75 @@
package actions_test
import (
- "github.com/containrrr/watchtower/internal/actions"
- "github.com/containrrr/watchtower/pkg/container"
- "github.com/containrrr/watchtower/pkg/container/mocks"
- "github.com/containrrr/watchtower/pkg/types"
- container2 "github.com/docker/docker/api/types/container"
- cli "github.com/docker/docker/client"
"time"
+ "github.com/containrrr/watchtower/internal/actions"
+ "github.com/containrrr/watchtower/pkg/types"
+ dockerTypes "github.com/docker/docker/api/types"
+ dockerContainer "github.com/docker/docker/api/types/container"
+ "github.com/docker/go-connections/nat"
+
. "github.com/containrrr/watchtower/internal/actions/mocks"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
+func getCommonTestData(keepContainer string) *TestData {
+ return &TestData{
+ NameOfContainerToKeep: keepContainer,
+ Containers: []types.Container{
+ CreateMockContainer(
+ "test-container-01",
+ "test-container-01",
+ "fake-image:latest",
+ time.Now().AddDate(0, 0, -1)),
+ CreateMockContainer(
+ "test-container-02",
+ "test-container-02",
+ "fake-image:latest",
+ time.Now()),
+ CreateMockContainer(
+ "test-container-02",
+ "test-container-02",
+ "fake-image:latest",
+ time.Now()),
+ },
+ }
+}
+
+func getLinkedTestData(withImageInfo bool) *TestData {
+ staleContainer := CreateMockContainer(
+ "test-container-01",
+ "/test-container-01",
+ "fake-image1:latest",
+ time.Now().AddDate(0, 0, -1))
+
+ var imageInfo *dockerTypes.ImageInspect
+ if withImageInfo {
+ imageInfo = CreateMockImageInfo("test-container-02")
+ }
+ linkingContainer := CreateMockContainerWithLinks(
+ "test-container-02",
+ "/test-container-02",
+ "fake-image2:latest",
+ time.Now(),
+ []string{staleContainer.Name()},
+ imageInfo)
+
+ return &TestData{
+ Staleness: map[string]bool{linkingContainer.Name(): false},
+ Containers: []types.Container{
+ staleContainer,
+ linkingContainer,
+ },
+ }
+}
+
var _ = Describe("the update action", func() {
- var dockerClient cli.CommonAPIClient
- var client MockClient
-
- BeforeEach(func() {
- server := mocks.NewMockAPIServer()
- dockerClient, _ = cli.NewClientWithOpts(
- cli.WithHost(server.URL),
- cli.WithHTTPClient(server.Client()))
- })
-
When("watchtower has been instructed to clean up", func() {
- BeforeEach(func() {
- pullImages := false
- removeVolumes := false
- client = CreateMockClient(
- &TestData{
- NameOfContainerToKeep: "test-container-02",
- Containers: []container.Container{
- CreateMockContainer(
- "test-container-01",
- "test-container-01",
- "fake-image:latest",
- time.Now().AddDate(0, 0, -1)),
- CreateMockContainer(
- "test-container-02",
- "test-container-02",
- "fake-image:latest",
- time.Now()),
- CreateMockContainer(
- "test-container-02",
- "test-container-02",
- "fake-image:latest",
- time.Now()),
- },
- },
- dockerClient,
- pullImages,
- removeVolumes,
- )
- })
-
When("there are multiple containers using the same image", func() {
It("should only try to remove the image once", func() {
-
+ client := CreateMockClient(getCommonTestData(""), false, false)
_, err := actions.Update(client, types.UpdateParams{Cleanup: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
@@ -66,8 +77,9 @@ var _ = Describe("the update action", func() {
})
When("there are multiple containers using different images", func() {
It("should try to remove each of them", func() {
- client.TestData.Containers = append(
- client.TestData.Containers,
+ testData := getCommonTestData("")
+ testData.Containers = append(
+ testData.Containers,
CreateMockContainer(
"unique-test-container",
"unique-test-container",
@@ -75,28 +87,49 @@ var _ = Describe("the update action", func() {
time.Now(),
),
)
+ client := CreateMockClient(testData, false, false)
_, err := actions.Update(client, types.UpdateParams{Cleanup: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(2))
})
})
+ When("there are linked containers being updated", func() {
+ It("should not try to remove their images", func() {
+ client := CreateMockClient(getLinkedTestData(true), false, false)
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+ })
+ })
When("performing a rolling restart update", func() {
It("should try to remove the image once", func() {
-
+ client := CreateMockClient(getCommonTestData(""), false, false)
_, err := actions.Update(client, types.UpdateParams{Cleanup: true, RollingRestart: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
})
})
+ When("updating a linked container with missing image info", func() {
+ It("should gracefully fail", func() {
+ client := CreateMockClient(getLinkedTestData(false), false, false)
+
+ report, err := actions.Update(client, types.UpdateParams{})
+ Expect(err).NotTo(HaveOccurred())
+ // Note: Linked containers that were skipped for recreation is not counted in Failed
+ // If this happens, an error is emitted to the logs, so a notification should still be sent.
+ Expect(report.Updated()).To(HaveLen(1))
+ Expect(report.Fresh()).To(HaveLen(1))
+ })
+ })
})
When("watchtower has been instructed to monitor only", func() {
When("certain containers are set to monitor only", func() {
- BeforeEach(func() {
- client = CreateMockClient(
+ It("should not update those containers", func() {
+ client := CreateMockClient(
&TestData{
NameOfContainerToKeep: "test-container-02",
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainer(
"test-container-01",
"test-container-01",
@@ -106,21 +139,19 @@ var _ = Describe("the update action", func() {
"test-container-02",
"test-container-02",
"fake-image2:latest",
+ false,
+ false,
time.Now(),
- &container2.Config{
+ &dockerContainer.Config{
Labels: map[string]string{
"com.centurylinklabs.watchtower.monitor-only": "true",
},
}),
},
},
- dockerClient,
false,
false,
)
- })
-
- It("should not update those containers", func() {
_, err := actions.Update(client, types.UpdateParams{Cleanup: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
@@ -128,10 +159,10 @@ var _ = Describe("the update action", func() {
})
When("monitor only is set globally", func() {
- BeforeEach(func() {
- client = CreateMockClient(
+ It("should not update any containers", func() {
+ client := CreateMockClient(
&TestData{
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainer(
"test-container-01",
"test-container-01",
@@ -144,17 +175,297 @@ var _ = Describe("the update action", func() {
time.Now()),
},
},
- dockerClient,
false,
false,
)
- })
-
- It("should not update any containers", func() {
- _, err := actions.Update(client, types.UpdateParams{MonitorOnly: true})
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, MonitorOnly: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
})
+ When("watchtower has been instructed to have label take precedence", func() {
+ It("it should update containers when monitor only is set to false", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ false,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.monitor-only": "false",
+ },
+ }),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, MonitorOnly: true, LabelPrecedence: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+ })
+ It("it should update not containers when monitor only is set to true", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ false,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.monitor-only": "true",
+ },
+ }),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, MonitorOnly: true, LabelPrecedence: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
+ })
+ It("it should update not containers when monitor only is not set", func() {
+ client := CreateMockClient(
+ &TestData{
+ Containers: []types.Container{
+ CreateMockContainer(
+ "test-container-01",
+ "test-container-01",
+ "fake-image:latest",
+ time.Now()),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, MonitorOnly: true, LabelPrecedence: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
+ })
+
+ })
+ })
+ })
+
+ When("watchtower has been instructed to run lifecycle hooks", func() {
+
+ When("pre-update script returns 1", func() {
+ It("should not update those containers", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ true,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+ "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn1.sh",
+ },
+ ExposedPorts: map[nat.Port]struct{}{},
+ }),
+ },
+ },
+ false,
+ false,
+ )
+
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
+ })
+
+ })
+
+ When("prupddate script returns 75", func() {
+ It("should not update those containers", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ true,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+ "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn75.sh",
+ },
+ ExposedPorts: map[nat.Port]struct{}{},
+ }),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
+ })
+
+ })
+
+ When("prupddate script returns 0", func() {
+ It("should update those containers", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ true,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+ "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn0.sh",
+ },
+ ExposedPorts: map[nat.Port]struct{}{},
+ }),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+ })
+ })
+
+ When("container is linked to restarting containers", func() {
+ It("should be marked for restart", func() {
+
+ provider := CreateMockContainerWithConfig(
+ "test-container-provider",
+ "/test-container-provider",
+ "fake-image2:latest",
+ true,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{},
+ ExposedPorts: map[nat.Port]struct{}{},
+ })
+
+ provider.SetStale(true)
+
+ consumer := CreateMockContainerWithConfig(
+ "test-container-consumer",
+ "/test-container-consumer",
+ "fake-image3:latest",
+ true,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.depends-on": "test-container-provider",
+ },
+ ExposedPorts: map[nat.Port]struct{}{},
+ })
+
+ containers := []types.Container{
+ provider,
+ consumer,
+ }
+
+ Expect(provider.ToRestart()).To(BeTrue())
+ Expect(consumer.ToRestart()).To(BeFalse())
+
+ actions.UpdateImplicitRestart(containers)
+
+ Expect(containers[0].ToRestart()).To(BeTrue())
+ Expect(containers[1].ToRestart()).To(BeTrue())
+
+ })
+
+ })
+
+ When("container is not running", func() {
+ It("skip running preupdate", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ false,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+ "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn1.sh",
+ },
+ ExposedPorts: map[nat.Port]struct{}{},
+ }),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+ })
+
+ })
+
+ When("container is restarting", func() {
+ It("skip running preupdate", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ false,
+ true,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+ "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn1.sh",
+ },
+ ExposedPorts: map[nat.Port]struct{}{},
+ }),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+ })
+
})
})
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index c2dc8ad..c11cdae 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -1,8 +1,11 @@
package flags
import (
- "io/ioutil"
+ "bufio"
+ "errors"
+ "fmt"
"os"
+ "regexp"
"strings"
"time"
@@ -16,12 +19,14 @@ import (
// use watchtower
const DockerAPIMinVersion string = "1.25"
+var defaultInterval = int((time.Hour * 24).Seconds())
+
// RegisterDockerFlags that are used directly by the docker api client
func RegisterDockerFlags(rootCmd *cobra.Command) {
flags := rootCmd.PersistentFlags()
- flags.StringP("host", "H", viper.GetString("DOCKER_HOST"), "daemon socket to connect to")
- flags.BoolP("tlsverify", "v", viper.GetBool("DOCKER_TLS_VERIFY"), "use TLS and verify the remote")
- flags.StringP("api-version", "a", viper.GetString("DOCKER_API_VERSION"), "api version to use by docker client")
+ flags.StringP("host", "H", envString("DOCKER_HOST"), "daemon socket to connect to")
+ flags.BoolP("tlsverify", "v", envBool("DOCKER_TLS_VERIFY"), "use TLS and verify the remote")
+ flags.StringP("api-version", "a", envString("DOCKER_API_VERSION"), "api version to use by docker client")
}
// RegisterSystemFlags that are used by watchtower to modify the program flow
@@ -30,138 +35,182 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
flags.IntP(
"interval",
"i",
- viper.GetInt("WATCHTOWER_POLL_INTERVAL"),
- "poll interval (in seconds)")
+ envInt("WATCHTOWER_POLL_INTERVAL"),
+ "Poll interval (in seconds)")
flags.StringP(
"schedule",
"s",
- viper.GetString("WATCHTOWER_SCHEDULE"),
- "the cron expression which defines when to update")
+ envString("WATCHTOWER_SCHEDULE"),
+ "The cron expression which defines when to update")
flags.DurationP(
"stop-timeout",
"t",
- viper.GetDuration("WATCHTOWER_TIMEOUT"),
- "timeout before a container is forcefully stopped")
+ envDuration("WATCHTOWER_TIMEOUT"),
+ "Timeout before a container is forcefully stopped")
flags.BoolP(
"no-pull",
"",
- viper.GetBool("WATCHTOWER_NO_PULL"),
- "do not pull any new images")
+ envBool("WATCHTOWER_NO_PULL"),
+ "Do not pull any new images")
flags.BoolP(
"no-restart",
"",
- viper.GetBool("WATCHTOWER_NO_RESTART"),
- "do not restart any containers")
+ envBool("WATCHTOWER_NO_RESTART"),
+ "Do not restart any containers")
flags.BoolP(
"no-startup-message",
"",
- viper.GetBool("WATCHTOWER_NO_STARTUP_MESSAGE"),
+ envBool("WATCHTOWER_NO_STARTUP_MESSAGE"),
"Prevents watchtower from sending a startup message")
flags.BoolP(
"cleanup",
"c",
- viper.GetBool("WATCHTOWER_CLEANUP"),
- "remove previously used images after updating")
+ envBool("WATCHTOWER_CLEANUP"),
+ "Remove previously used images after updating")
flags.BoolP(
"remove-volumes",
"",
- viper.GetBool("WATCHTOWER_REMOVE_VOLUMES"),
- "remove attached volumes before updating")
+ envBool("WATCHTOWER_REMOVE_VOLUMES"),
+ "Remove attached volumes before updating")
flags.BoolP(
"label-enable",
"e",
- viper.GetBool("WATCHTOWER_LABEL_ENABLE"),
- "watch containers where the com.centurylinklabs.watchtower.enable label is true")
+ envBool("WATCHTOWER_LABEL_ENABLE"),
+ "Watch containers where the com.centurylinklabs.watchtower.enable label is true")
+
+ flags.StringSliceP(
+ "disable-containers",
+ "x",
+ // Due to issue spf13/viper#380, can't use viper.GetStringSlice:
+ regexp.MustCompile("[, ]+").Split(envString("WATCHTOWER_DISABLE_CONTAINERS"), -1),
+ "Comma-separated list of containers to explicitly exclude from watching.")
+
+ flags.StringP(
+ "log-format",
+ "l",
+ viper.GetString("WATCHTOWER_LOG_FORMAT"),
+ "Sets what logging format to use for console output. Possible values: Auto, LogFmt, Pretty, JSON")
flags.BoolP(
"debug",
"d",
- viper.GetBool("WATCHTOWER_DEBUG"),
- "enable debug mode with verbose logging")
+ envBool("WATCHTOWER_DEBUG"),
+ "Enable debug mode with verbose logging")
flags.BoolP(
"trace",
"",
- viper.GetBool("WATCHTOWER_TRACE"),
- "enable trace mode with very verbose logging - caution, exposes credentials")
+ envBool("WATCHTOWER_TRACE"),
+ "Enable trace mode with very verbose logging - caution, exposes credentials")
flags.BoolP(
"monitor-only",
"m",
- viper.GetBool("WATCHTOWER_MONITOR_ONLY"),
+ envBool("WATCHTOWER_MONITOR_ONLY"),
"Will only monitor for new images, not update the containers")
flags.BoolP(
"run-once",
"R",
- viper.GetBool("WATCHTOWER_RUN_ONCE"),
+ envBool("WATCHTOWER_RUN_ONCE"),
"Run once now and exit")
flags.BoolP(
"include-restarting",
"",
- viper.GetBool("WATCHTOWER_INCLUDE_RESTARTING"),
+ envBool("WATCHTOWER_INCLUDE_RESTARTING"),
"Will also include restarting containers")
flags.BoolP(
"include-stopped",
"S",
- viper.GetBool("WATCHTOWER_INCLUDE_STOPPED"),
+ envBool("WATCHTOWER_INCLUDE_STOPPED"),
"Will also include created and exited containers")
flags.BoolP(
"revive-stopped",
"",
- viper.GetBool("WATCHTOWER_REVIVE_STOPPED"),
+ envBool("WATCHTOWER_REVIVE_STOPPED"),
"Will also start stopped containers that were updated, if include-stopped is active")
flags.BoolP(
"enable-lifecycle-hooks",
"",
- viper.GetBool("WATCHTOWER_LIFECYCLE_HOOKS"),
+ envBool("WATCHTOWER_LIFECYCLE_HOOKS"),
"Enable the execution of commands triggered by pre- and post-update lifecycle hooks")
flags.BoolP(
"rolling-restart",
"",
- viper.GetBool("WATCHTOWER_ROLLING_RESTART"),
+ envBool("WATCHTOWER_ROLLING_RESTART"),
"Restart containers one at a time")
flags.BoolP(
"http-api-update",
"",
- viper.GetBool("WATCHTOWER_HTTP_API_UPDATE"),
+ envBool("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"),
+ envBool("WATCHTOWER_HTTP_API_METRICS"),
"Runs Watchtower with the Prometheus metrics API enabled")
flags.StringP(
"http-api-token",
"",
- viper.GetString("WATCHTOWER_HTTP_API_TOKEN"),
+ envString("WATCHTOWER_HTTP_API_TOKEN"),
"Sets an authentication token to HTTP API requests.")
+
+ flags.BoolP(
+ "http-api-periodic-polls",
+ "",
+ envBool("WATCHTOWER_HTTP_API_PERIODIC_POLLS"),
+ "Also run periodic updates (specified with --interval and --schedule) if HTTP API is enabled")
+
// https://no-color.org/
flags.BoolP(
"no-color",
"",
viper.IsSet("NO_COLOR"),
"Disable ANSI color escape codes in log output")
+
flags.StringP(
"scope",
"",
- viper.GetString("WATCHTOWER_SCOPE"),
+ envString("WATCHTOWER_SCOPE"),
"Defines a monitoring scope for the Watchtower instance.")
+
+ flags.StringP(
+ "porcelain",
+ "P",
+ envString("WATCHTOWER_PORCELAIN"),
+ `Write session results to stdout using a stable versioned format. Supported values: "v1"`)
+
+ flags.String(
+ "log-level",
+ envString("WATCHTOWER_LOG_LEVEL"),
+ "The maximum log level that will be written to STDERR. Possible values: panic, fatal, error, warn, info, debug or trace")
+
+ flags.BoolP(
+ "health-check",
+ "",
+ false,
+ "Do health check and exit")
+
+ flags.BoolP(
+ "label-take-precedence",
+ "",
+ envBool("WATCHTOWER_LABEL_TAKE_PRECEDENCE"),
+ "Label applied to containers take precedence over arguments")
}
// RegisterNotificationFlags that are used by watchtower to send notifications
@@ -171,157 +220,216 @@ func RegisterNotificationFlags(rootCmd *cobra.Command) {
flags.StringSliceP(
"notifications",
"n",
- viper.GetStringSlice("WATCHTOWER_NOTIFICATIONS"),
- " notification types to send (valid: email, slack, msteams, gotify, shoutrrr)")
+ envStringSlice("WATCHTOWER_NOTIFICATIONS"),
+ " Notification types to send (valid: email, slack, msteams, gotify, shoutrrr)")
+
+ flags.String(
+ "notifications-level",
+ envString("WATCHTOWER_NOTIFICATIONS_LEVEL"),
+ "The log level used for sending notifications. Possible values: panic, fatal, error, warn, info or debug")
+
+ flags.IntP(
+ "notifications-delay",
+ "",
+ envInt("WATCHTOWER_NOTIFICATIONS_DELAY"),
+ "Delay before sending notifications, expressed in seconds")
flags.StringP(
- "notifications-level",
+ "notifications-hostname",
"",
- viper.GetString("WATCHTOWER_NOTIFICATIONS_LEVEL"),
- "The log level used for sending notifications. Possible values: panic, fatal, error, warn, info or debug")
+ envString("WATCHTOWER_NOTIFICATIONS_HOSTNAME"),
+ "Custom hostname for notification titles")
flags.StringP(
"notification-email-from",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_FROM"),
+ envString("WATCHTOWER_NOTIFICATION_EMAIL_FROM"),
"Address to send notification emails from")
flags.StringP(
"notification-email-to",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_TO"),
+ envString("WATCHTOWER_NOTIFICATION_EMAIL_TO"),
"Address to send notification emails to")
flags.IntP(
"notification-email-delay",
"",
- viper.GetInt("WATCHTOWER_NOTIFICATION_EMAIL_DELAY"),
+ envInt("WATCHTOWER_NOTIFICATION_EMAIL_DELAY"),
"Delay before sending notifications, expressed in seconds")
flags.StringP(
"notification-email-server",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER"),
+ envString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER"),
"SMTP server to send notification emails through")
flags.IntP(
"notification-email-server-port",
"",
- viper.GetInt("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT"),
+ envInt("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT"),
"SMTP server port to send notification emails through")
flags.BoolP(
"notification-email-server-tls-skip-verify",
"",
- viper.GetBool("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY"),
+ envBool("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY"),
`Controls whether watchtower verifies the SMTP server's certificate chain and host name.
Should only be used for testing.`)
flags.StringP(
"notification-email-server-user",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER"),
+ envString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER"),
"SMTP server user for sending notifications")
flags.StringP(
"notification-email-server-password",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD"),
+ envString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD"),
"SMTP server password for sending notifications")
flags.StringP(
"notification-email-subjecttag",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG"),
+ envString("WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG"),
"Subject prefix tag for notifications via mail")
flags.StringP(
"notification-slack-hook-url",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL"),
+ envString("WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL"),
"The Slack Hook URL to send notifications to")
flags.StringP(
"notification-slack-identifier",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER"),
+ envString("WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER"),
"A string which will be used to identify the messages coming from this watchtower instance")
flags.StringP(
"notification-slack-channel",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_SLACK_CHANNEL"),
+ envString("WATCHTOWER_NOTIFICATION_SLACK_CHANNEL"),
"A string which overrides the webhook's default channel. Example: #my-custom-channel")
flags.StringP(
"notification-slack-icon-emoji",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI"),
+ envString("WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI"),
"An emoji code string to use in place of the default icon")
flags.StringP(
"notification-slack-icon-url",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_SLACK_ICON_URL"),
+ envString("WATCHTOWER_NOTIFICATION_SLACK_ICON_URL"),
"An icon image URL string to use in place of the default icon")
flags.StringP(
"notification-msteams-hook",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL"),
+ envString("WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL"),
"The MSTeams WebHook URL to send notifications to")
flags.BoolP(
"notification-msteams-data",
"",
- viper.GetBool("WATCHTOWER_NOTIFICATION_MSTEAMS_USE_LOG_DATA"),
+ envBool("WATCHTOWER_NOTIFICATION_MSTEAMS_USE_LOG_DATA"),
"The MSTeams notifier will try to extract log entry fields as MSTeams message facts")
flags.StringP(
"notification-gotify-url",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_GOTIFY_URL"),
+ envString("WATCHTOWER_NOTIFICATION_GOTIFY_URL"),
"The Gotify URL to send notifications to")
flags.StringP(
"notification-gotify-token",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN"),
+ envString("WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN"),
"The Gotify Application required to query the Gotify API")
flags.BoolP(
"notification-gotify-tls-skip-verify",
"",
- viper.GetBool("WATCHTOWER_NOTIFICATION_GOTIFY_TLS_SKIP_VERIFY"),
+ envBool("WATCHTOWER_NOTIFICATION_GOTIFY_TLS_SKIP_VERIFY"),
`Controls whether watchtower verifies the Gotify server's certificate chain and host name.
Should only be used for testing.`)
- flags.StringP(
+ flags.String(
"notification-template",
- "",
- viper.GetString("WATCHTOWER_NOTIFICATION_TEMPLATE"),
+ envString("WATCHTOWER_NOTIFICATION_TEMPLATE"),
"The shoutrrr text/template for the messages")
- flags.StringArrayP(
+ flags.StringArray(
"notification-url",
- "",
- viper.GetStringSlice("WATCHTOWER_NOTIFICATION_URL"),
+ envStringSlice("WATCHTOWER_NOTIFICATION_URL"),
"The shoutrrr URL to send notifications to")
+
+ flags.Bool("notification-report",
+ envBool("WATCHTOWER_NOTIFICATION_REPORT"),
+ "Use the session report as the notification template data")
+
+ flags.StringP(
+ "notification-title-tag",
+ "",
+ envString("WATCHTOWER_NOTIFICATION_TITLE_TAG"),
+ "Title prefix tag for notifications")
+
+ flags.Bool("notification-skip-title",
+ envBool("WATCHTOWER_NOTIFICATION_SKIP_TITLE"),
+ "Do not pass the title param to notifications")
+
+ flags.String(
+ "warn-on-head-failure",
+ envString("WATCHTOWER_WARN_ON_HEAD_FAILURE"),
+ "When to warn about HEAD pull requests failing. Possible values: always, auto or never")
+
+ flags.Bool(
+ "notification-log-stdout",
+ envBool("WATCHTOWER_NOTIFICATION_LOG_STDOUT"),
+ "Write notification logs to stdout instead of logging (to stderr)")
+}
+
+func envString(key string) string {
+ viper.MustBindEnv(key)
+ return viper.GetString(key)
+}
+
+func envStringSlice(key string) []string {
+ viper.MustBindEnv(key)
+ return viper.GetStringSlice(key)
+}
+
+func envInt(key string) int {
+ viper.MustBindEnv(key)
+ return viper.GetInt(key)
+}
+
+func envBool(key string) bool {
+ viper.MustBindEnv(key)
+ return viper.GetBool(key)
+}
+
+func envDuration(key string) time.Duration {
+ viper.MustBindEnv(key)
+ return viper.GetDuration(key)
}
// 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", day)
+ viper.SetDefault("WATCHTOWER_POLL_INTERVAL", defaultInterval)
viper.SetDefault("WATCHTOWER_TIMEOUT", time.Second*10)
viper.SetDefault("WATCHTOWER_NOTIFICATIONS", []string{})
viper.SetDefault("WATCHTOWER_NOTIFICATIONS_LEVEL", "info")
viper.SetDefault("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT", 25)
viper.SetDefault("WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG", "")
viper.SetDefault("WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER", "watchtower")
+ viper.SetDefault("WATCHTOWER_LOG_LEVEL", "info")
+ viper.SetDefault("WATCHTOWER_LOG_FORMAT", "auto")
}
// EnvConfig translates the command-line options into environment variables
@@ -409,34 +517,191 @@ func GetSecretsFromFiles(rootCmd *cobra.Command) {
"notification-slack-hook-url",
"notification-msteams-hook",
"notification-gotify-token",
+ "notification-url",
+ "http-api-token",
}
for _, secret := range secrets {
- getSecretFromFile(flags, secret)
+ if err := getSecretFromFile(flags, secret); err != nil {
+ log.Fatalf("failed to get secret from flag %v: %s", secret, err)
+ }
}
}
// getSecretFromFile will check if the flag contains a reference to a file; if it does, replaces the value of the flag with the contents of the file.
-func getSecretFromFile(flags *pflag.FlagSet, secret string) {
- value, err := flags.GetString(secret)
- if err != nil {
- log.Error(err)
+func getSecretFromFile(flags *pflag.FlagSet, secret string) error {
+ flag := flags.Lookup(secret)
+ if sliceValue, ok := flag.Value.(pflag.SliceValue); ok {
+ oldValues := sliceValue.GetSlice()
+ values := make([]string, 0, len(oldValues))
+ for _, value := range oldValues {
+ if value != "" && isFile(value) {
+ file, err := os.Open(value)
+ if err != nil {
+ return err
+ }
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if line == "" {
+ continue
+ }
+ values = append(values, line)
+ }
+ if err := file.Close(); err != nil {
+ return err
+ }
+ } else {
+ values = append(values, value)
+ }
+ }
+ return sliceValue.Replace(values)
}
+
+ value := flag.Value.String()
if value != "" && isFile(value) {
- file, err := ioutil.ReadFile(value)
+ content, err := os.ReadFile(value)
if err != nil {
- log.Fatal(err)
- }
- err = flags.Set(secret, strings.TrimSpace(string(file)))
- if err != nil {
- log.Error(err)
+ return err
}
+ return flags.Set(secret, strings.TrimSpace(string(content)))
}
+
+ return nil
}
func isFile(s string) bool {
- _, err := os.Stat(s)
- if os.IsNotExist(err) {
+ firstColon := strings.IndexRune(s, ':')
+ if firstColon != 1 && firstColon != -1 {
+ // If the string contains a ':', but it's not the second character, it's probably not a file
+ // and will cause a fatal error on windows if stat'ed
+ // This still allows for paths that start with 'c:\' etc.
return false
}
- return true
+ _, err := os.Stat(s)
+ return !errors.Is(err, os.ErrNotExist)
+}
+
+// ProcessFlagAliases updates the value of flags that are being set by helper flags
+func ProcessFlagAliases(flags *pflag.FlagSet) {
+
+ porcelain, err := flags.GetString(`porcelain`)
+ if err != nil {
+ log.Fatalf(`Failed to get flag: %v`, err)
+ }
+ if porcelain != "" {
+ if porcelain != "v1" {
+ log.Fatalf(`Unknown porcelain version %q. Supported values: "v1"`, porcelain)
+ }
+ if err = appendFlagValue(flags, `notification-url`, `logger://`); err != nil {
+ log.Errorf(`Failed to set flag: %v`, err)
+ }
+ setFlagIfDefault(flags, `notification-log-stdout`, `true`)
+ setFlagIfDefault(flags, `notification-report`, `true`)
+ tpl := fmt.Sprintf(`porcelain.%s.summary-no-log`, porcelain)
+ setFlagIfDefault(flags, `notification-template`, tpl)
+ }
+
+ scheduleChanged := flags.Changed(`schedule`)
+ intervalChanged := flags.Changed(`interval`)
+ // FIXME: snakeswap
+ // due to how viper is integrated by swapping the defaults for the flags, we need this hack:
+ if val, _ := flags.GetString(`schedule`); val != `` {
+ scheduleChanged = true
+ }
+ if val, _ := flags.GetInt(`interval`); val != defaultInterval {
+ intervalChanged = true
+ }
+
+ if intervalChanged && scheduleChanged {
+ log.Fatal(`Only schedule or interval can be defined, not both.`)
+ }
+
+ // update schedule flag to match interval if it's set, or to the default if none of them are
+ if intervalChanged || !scheduleChanged {
+ interval, _ := flags.GetInt(`interval`)
+ _ = flags.Set(`schedule`, fmt.Sprintf(`@every %ds`, interval))
+ }
+
+ if flagIsEnabled(flags, `debug`) {
+ _ = flags.Set(`log-level`, `debug`)
+ }
+
+ if flagIsEnabled(flags, `trace`) {
+ _ = flags.Set(`log-level`, `trace`)
+ }
+
+}
+
+// SetupLogging reads only the flags that is needed to set up logging and applies them to the global logger
+func SetupLogging(f *pflag.FlagSet) error {
+ logFormat, _ := f.GetString(`log-format`)
+ noColor, _ := f.GetBool("no-color")
+
+ switch strings.ToLower(logFormat) {
+ case "auto":
+ // This will either use the "pretty" or "logfmt" format, based on whether the standard out is connected to a TTY
+ log.SetFormatter(&log.TextFormatter{
+ DisableColors: noColor,
+ // enable logrus built-in support for https://bixense.com/clicolors/
+ EnvironmentOverrideColors: true,
+ })
+ case "json":
+ log.SetFormatter(&log.JSONFormatter{})
+ case "logfmt":
+ log.SetFormatter(&log.TextFormatter{
+ DisableColors: true,
+ FullTimestamp: true,
+ })
+ case "pretty":
+ log.SetFormatter(&log.TextFormatter{
+ // "Pretty" format combined with `--no-color` will only change the timestamp to the time since start
+ ForceColors: !noColor,
+ FullTimestamp: false,
+ })
+ default:
+ return fmt.Errorf("invalid log format: %s", logFormat)
+ }
+
+ rawLogLevel, _ := f.GetString(`log-level`)
+ if logLevel, err := log.ParseLevel(rawLogLevel); err != nil {
+ return fmt.Errorf("invalid log level: %e", err)
+ } else {
+ log.SetLevel(logLevel)
+ }
+
+ return nil
+}
+
+func flagIsEnabled(flags *pflag.FlagSet, name string) bool {
+ value, err := flags.GetBool(name)
+ if err != nil {
+ log.Fatalf(`The flag %q is not defined`, name)
+ }
+ return value
+}
+
+func appendFlagValue(flags *pflag.FlagSet, name string, values ...string) error {
+ flag := flags.Lookup(name)
+ if flag == nil {
+ return fmt.Errorf(`invalid flag name %q`, name)
+ }
+
+ if flagValues, ok := flag.Value.(pflag.SliceValue); ok {
+ for _, value := range values {
+ _ = flagValues.Append(value)
+ }
+ } else {
+ return fmt.Errorf(`the value for flag %q is not a slice value`, name)
+ }
+
+ return nil
+}
+
+func setFlagIfDefault(flags *pflag.FlagSet, name string, value string) {
+ if flags.Changed(name) {
+ return
+ }
+ if err := flags.Set(name, value); err != nil {
+ log.Errorf(`Failed to set flag: %v`, err)
+ }
}
diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go
index b659a96..2856456 100644
--- a/internal/flags/flags_test.go
+++ b/internal/flags/flags_test.go
@@ -1,16 +1,23 @@
package flags
import (
- "io/ioutil"
"os"
+ "strings"
"testing"
+ "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+ "github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEnvConfig_Defaults(t *testing.T) {
+ // Unset testing environments own variables, since those are not what is under test
+ _ = os.Unsetenv("DOCKER_TLS_VERIFY")
+ _ = os.Unsetenv("DOCKER_HOST")
+
cmd := new(cobra.Command)
SetDefaults()
RegisterDockerFlags(cmd)
@@ -43,9 +50,7 @@ func TestEnvConfig_Custom(t *testing.T) {
func TestGetSecretsFromFilesWithString(t *testing.T) {
value := "supersecretstring"
-
- err := os.Setenv("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD", value)
- require.NoError(t, err)
+ t.Setenv("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD", value)
testGetSecretsFromFiles(t, "notification-email-server-password", value)
}
@@ -54,28 +59,281 @@ func TestGetSecretsFromFilesWithFile(t *testing.T) {
value := "megasecretstring"
// Create the temporary file which will contain a secret.
- file, err := ioutil.TempFile(os.TempDir(), "watchtower-")
+ file, err := os.CreateTemp(t.TempDir(), "watchtower-")
require.NoError(t, err)
- defer os.Remove(file.Name()) // Make sure to remove the temporary file later.
// Write the secret to the temporary file.
- secret := []byte(value)
- _, err = file.Write(secret)
+ _, err = file.Write([]byte(value))
require.NoError(t, err)
+ require.NoError(t, file.Close())
- err = os.Setenv("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD", file.Name())
- require.NoError(t, err)
+ t.Setenv("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD", file.Name())
testGetSecretsFromFiles(t, "notification-email-server-password", value)
}
-func testGetSecretsFromFiles(t *testing.T, flagName string, expected string) {
+func TestGetSliceSecretsFromFiles(t *testing.T) {
+ values := []string{"entry2", "", "entry3"}
+
+ // Create the temporary file which will contain a secret.
+ file, err := os.CreateTemp(t.TempDir(), "watchtower-")
+ require.NoError(t, err)
+
+ // Write the secret to the temporary file.
+ for _, value := range values {
+ _, err = file.WriteString("\n" + value)
+ require.NoError(t, err)
+ }
+ require.NoError(t, file.Close())
+
+ testGetSecretsFromFiles(t, "notification-url", `[entry1,entry2,entry3]`,
+ `--notification-url`, "entry1",
+ `--notification-url`, file.Name())
+}
+
+func testGetSecretsFromFiles(t *testing.T, flagName string, expected string, args ...string) {
cmd := new(cobra.Command)
SetDefaults()
+ RegisterSystemFlags(cmd)
RegisterNotificationFlags(cmd)
+ require.NoError(t, cmd.ParseFlags(args))
GetSecretsFromFiles(cmd)
- value, err := cmd.PersistentFlags().GetString(flagName)
- require.NoError(t, err)
+ flag := cmd.PersistentFlags().Lookup(flagName)
+ require.NotNil(t, flag)
+ value := flag.Value.String()
assert.Equal(t, expected, value)
}
+
+func TestHTTPAPIPeriodicPollsFlag(t *testing.T) {
+ cmd := new(cobra.Command)
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+
+ err := cmd.ParseFlags([]string{"--http-api-periodic-polls"})
+ require.NoError(t, err)
+
+ periodicPolls, err := cmd.PersistentFlags().GetBool("http-api-periodic-polls")
+ require.NoError(t, err)
+
+ assert.Equal(t, true, periodicPolls)
+}
+
+func TestIsFile(t *testing.T) {
+ assert.False(t, isFile("https://google.com"), "an URL should never be considered a file")
+ assert.True(t, isFile(os.Args[0]), "the currently running binary path should always be considered a file")
+}
+
+func TestProcessFlagAliases(t *testing.T) {
+ logrus.StandardLogger().ExitFunc = func(_ int) { t.FailNow() }
+ cmd := new(cobra.Command)
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+ RegisterNotificationFlags(cmd)
+
+ require.NoError(t, cmd.ParseFlags([]string{
+ `--porcelain`, `v1`,
+ `--interval`, `10`,
+ `--trace`,
+ }))
+ flags := cmd.Flags()
+ ProcessFlagAliases(flags)
+
+ urls, _ := flags.GetStringArray(`notification-url`)
+ assert.Contains(t, urls, `logger://`)
+
+ logStdout, _ := flags.GetBool(`notification-log-stdout`)
+ assert.True(t, logStdout)
+
+ report, _ := flags.GetBool(`notification-report`)
+ assert.True(t, report)
+
+ template, _ := flags.GetString(`notification-template`)
+ assert.Equal(t, `porcelain.v1.summary-no-log`, template)
+
+ sched, _ := flags.GetString(`schedule`)
+ assert.Equal(t, `@every 10s`, sched)
+
+ logLevel, _ := flags.GetString(`log-level`)
+ assert.Equal(t, `trace`, logLevel)
+}
+
+func TestProcessFlagAliasesLogLevelFromEnvironment(t *testing.T) {
+ cmd := new(cobra.Command)
+ t.Setenv("WATCHTOWER_DEBUG", `true`)
+
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+ RegisterNotificationFlags(cmd)
+
+ require.NoError(t, cmd.ParseFlags([]string{}))
+ flags := cmd.Flags()
+ ProcessFlagAliases(flags)
+
+ logLevel, _ := flags.GetString(`log-level`)
+ assert.Equal(t, `debug`, logLevel)
+}
+
+func TestLogFormatFlag(t *testing.T) {
+ cmd := new(cobra.Command)
+
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+
+ // Ensure the default value is Auto
+ require.NoError(t, cmd.ParseFlags([]string{}))
+ require.NoError(t, SetupLogging(cmd.Flags()))
+ assert.IsType(t, &logrus.TextFormatter{}, logrus.StandardLogger().Formatter)
+
+ // Test JSON format
+ require.NoError(t, cmd.ParseFlags([]string{`--log-format`, `JSON`}))
+ require.NoError(t, SetupLogging(cmd.Flags()))
+ assert.IsType(t, &logrus.JSONFormatter{}, logrus.StandardLogger().Formatter)
+
+ // Test Pretty format
+ require.NoError(t, cmd.ParseFlags([]string{`--log-format`, `pretty`}))
+ require.NoError(t, SetupLogging(cmd.Flags()))
+ assert.IsType(t, &logrus.TextFormatter{}, logrus.StandardLogger().Formatter)
+ textFormatter, ok := (logrus.StandardLogger().Formatter).(*logrus.TextFormatter)
+ assert.True(t, ok)
+ assert.True(t, textFormatter.ForceColors)
+ assert.False(t, textFormatter.FullTimestamp)
+
+ // Test LogFmt format
+ require.NoError(t, cmd.ParseFlags([]string{`--log-format`, `logfmt`}))
+ require.NoError(t, SetupLogging(cmd.Flags()))
+ textFormatter, ok = (logrus.StandardLogger().Formatter).(*logrus.TextFormatter)
+ assert.True(t, ok)
+ assert.True(t, textFormatter.DisableColors)
+ assert.True(t, textFormatter.FullTimestamp)
+
+ // Test invalid format
+ require.NoError(t, cmd.ParseFlags([]string{`--log-format`, `cowsay`}))
+ require.Error(t, SetupLogging(cmd.Flags()))
+}
+
+func TestLogLevelFlag(t *testing.T) {
+ cmd := new(cobra.Command)
+
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+
+ // Test invalid format
+ require.NoError(t, cmd.ParseFlags([]string{`--log-level`, `gossip`}))
+ require.Error(t, SetupLogging(cmd.Flags()))
+}
+
+func TestProcessFlagAliasesSchedAndInterval(t *testing.T) {
+ logrus.StandardLogger().ExitFunc = func(_ int) { panic(`FATAL`) }
+ cmd := new(cobra.Command)
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+ RegisterNotificationFlags(cmd)
+
+ require.NoError(t, cmd.ParseFlags([]string{`--schedule`, `@hourly`, `--interval`, `10`}))
+ flags := cmd.Flags()
+
+ assert.PanicsWithValue(t, `FATAL`, func() {
+ ProcessFlagAliases(flags)
+ })
+}
+
+func TestProcessFlagAliasesScheduleFromEnvironment(t *testing.T) {
+ cmd := new(cobra.Command)
+
+ t.Setenv("WATCHTOWER_SCHEDULE", `@hourly`)
+
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+ RegisterNotificationFlags(cmd)
+
+ require.NoError(t, cmd.ParseFlags([]string{}))
+ flags := cmd.Flags()
+ ProcessFlagAliases(flags)
+
+ sched, _ := flags.GetString(`schedule`)
+ assert.Equal(t, `@hourly`, sched)
+}
+
+func TestProcessFlagAliasesInvalidPorcelaineVersion(t *testing.T) {
+ logrus.StandardLogger().ExitFunc = func(_ int) { panic(`FATAL`) }
+ cmd := new(cobra.Command)
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+ RegisterNotificationFlags(cmd)
+
+ require.NoError(t, cmd.ParseFlags([]string{`--porcelain`, `cowboy`}))
+ flags := cmd.Flags()
+
+ assert.PanicsWithValue(t, `FATAL`, func() {
+ ProcessFlagAliases(flags)
+ })
+}
+
+func TestFlagsArePrecentInDocumentation(t *testing.T) {
+
+ // Legacy notifcations are ignored, since they are (soft) deprecated
+ ignoredEnvs := map[string]string{
+ "WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI": "legacy",
+ "WATCHTOWER_NOTIFICATION_SLACK_ICON_URL": "legacy",
+ }
+
+ ignoredFlags := map[string]string{
+ "notification-gotify-url": "legacy",
+ "notification-slack-icon-emoji": "legacy",
+ "notification-slack-icon-url": "legacy",
+ }
+
+ cmd := new(cobra.Command)
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+ RegisterNotificationFlags(cmd)
+
+ flags := cmd.PersistentFlags()
+
+ docFiles := []string{
+ "../../docs/arguments.md",
+ "../../docs/lifecycle-hooks.md",
+ "../../docs/notifications.md",
+ }
+ allDocs := ""
+ for _, f := range docFiles {
+ bytes, err := os.ReadFile(f)
+ if err != nil {
+ t.Fatalf("Could not load docs file %q: %v", f, err)
+ }
+ allDocs += string(bytes)
+ }
+
+ flags.VisitAll(func(f *pflag.Flag) {
+ if !strings.Contains(allDocs, "--"+f.Name) {
+ if _, found := ignoredFlags[f.Name]; !found {
+ t.Logf("Docs does not mention flag long name %q", f.Name)
+ t.Fail()
+ }
+ }
+ if !strings.Contains(allDocs, "-"+f.Shorthand) {
+ t.Logf("Docs does not mention flag shorthand %q (%q)", f.Shorthand, f.Name)
+ t.Fail()
+ }
+ })
+
+ for _, key := range viper.AllKeys() {
+ envKey := strings.ToUpper(key)
+ if !strings.Contains(allDocs, envKey) {
+ if _, found := ignoredEnvs[envKey]; !found {
+ t.Logf("Docs does not mention environment variable %q", envKey)
+ t.Fail()
+ }
+ }
+ }
+}
diff --git a/internal/meta/meta.go b/internal/meta/meta.go
new file mode 100644
index 0000000..1571291
--- /dev/null
+++ b/internal/meta/meta.go
@@ -0,0 +1,13 @@
+package meta
+
+var (
+ // Version is the compile-time set version of Watchtower
+ Version = "v0.0.0-unknown"
+
+ // UserAgent is the http client identifier derived from Version
+ UserAgent string
+)
+
+func init() {
+ UserAgent = "Watchtower/" + Version
+}
diff --git a/internal/util/rand_sha256.go b/internal/util/rand_sha256.go
new file mode 100644
index 0000000..38a3736
--- /dev/null
+++ b/internal/util/rand_sha256.go
@@ -0,0 +1,24 @@
+package util
+
+import (
+ "bytes"
+ "crypto/rand"
+ "fmt"
+)
+
+// GenerateRandomSHA256 generates a random 64 character SHA 256 hash string
+func GenerateRandomSHA256() string {
+ return GenerateRandomPrefixedSHA256()[7:]
+}
+
+// GenerateRandomPrefixedSHA256 generates a random 64 character SHA 256 hash string, prefixed with `sha256:`
+func GenerateRandomPrefixedSHA256() string {
+ hash := make([]byte, 32)
+ _, _ = rand.Read(hash)
+ sb := bytes.NewBufferString("sha256:")
+ sb.Grow(64)
+ for _, h := range hash {
+ _, _ = fmt.Fprintf(sb, "%02x", h)
+ }
+ return sb.String()
+}
diff --git a/internal/util/util_test.go b/internal/util/util_test.go
index a6dd657..0b2c36c 100644
--- a/internal/util/util_test.go
+++ b/internal/util/util_test.go
@@ -1,8 +1,10 @@
package util
import (
- "github.com/stretchr/testify/assert"
+ "regexp"
"testing"
+
+ "github.com/stretchr/testify/assert"
)
func TestSliceEqual_True(t *testing.T) {
@@ -62,3 +64,15 @@ func TestStructMapSubtract(t *testing.T) {
assert.Equal(t, map[string]struct{}{"a": x, "b": x, "c": x}, m1)
assert.Equal(t, map[string]struct{}{"a": x, "c": x}, m2)
}
+
+// GenerateRandomSHA256 generates a random 64 character SHA 256 hash string
+func TestGenerateRandomSHA256(t *testing.T) {
+ res := GenerateRandomSHA256()
+ assert.Len(t, res, 64)
+ assert.NotContains(t, res, "sha256:")
+}
+
+func TestGenerateRandomPrefixedSHA256(t *testing.T) {
+ res := GenerateRandomPrefixedSHA256()
+ assert.Regexp(t, regexp.MustCompile("sha256:[0-9|a-f]{64}"), res)
+}
diff --git a/mkdocs.yml b/mkdocs.yml
index f628fbc..5227004 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,10 +1,20 @@
site_name: Watchtower
-site_url: http://containrrr.github.io/watchtower/
+site_url: https://containrrr.dev/watchtower/
repo_url: https://github.com/containrrr/watchtower/
+edit_uri: edit/main/docs/
theme:
name: 'material'
palette:
- scheme: containrrr
+ - media: "(prefers-color-scheme: light)"
+ scheme: containrrr
+ toggle:
+ icon: material/weather-night
+ name: Switch to dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: containrrr-dark
+ toggle:
+ icon: material/weather-sunny
+ name: Switch to light mode
logo: images/logo-450px.png
favicon: images/favicon.ico
extra_css:
@@ -13,7 +23,17 @@ markdown_extensions:
- toc:
permalink: True
separator: "_"
- - codehilite
+ - admonition
+ - pymdownx.highlight
+ - pymdownx.superfences
+ - pymdownx.magiclink:
+ repo_url_shortener: True
+ provider: github
+ user: containrrr
+ repo: watchtower
+ - pymdownx.saneheaders
+ - pymdownx.tabbed:
+ alternate_style: true
nav:
- 'Home': 'index.md'
- 'Introduction': 'introduction.md'
@@ -28,6 +48,7 @@ nav:
- 'Stop signals': 'stop-signals.md'
- 'Lifecycle hooks': 'lifecycle-hooks.md'
- 'Running multiple instances': 'running-multiple-instances.md'
+ - 'HTTP API Mode': 'http-api-mode.md'
- 'Metrics': 'metrics.md'
plugins:
- search
diff --git a/oryxBuildBinary b/oryxBuildBinary
new file mode 100755
index 0000000..86cbe57
Binary files /dev/null and b/oryxBuildBinary differ
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 987e4bd..2ceaea8 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -2,8 +2,9 @@ package api
import (
"fmt"
- log "github.com/sirupsen/logrus"
"net/http"
+
+ log "github.com/sirupsen/logrus"
)
const tokenMissingMsg = "api token is empty or has not been set. exiting"
@@ -25,12 +26,13 @@ func New(token string) *API {
// 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.Errorf("Invalid token \"%s\"", r.Header.Get("Authorization"))
- log.Debugf("Expected token to be \"%s\"", api.Token)
+ auth := r.Header.Get("Authorization")
+ want := fmt.Sprintf("Bearer %s", api.Token)
+ if auth != want {
+ w.WriteHeader(http.StatusUnauthorized)
return
}
- log.Println("Valid token found.")
+ log.Debug("Valid token found.")
fn(w, r)
}
}
@@ -59,7 +61,6 @@ func (api *API) Start(block bool) error {
log.Fatal(tokenMissingMsg)
}
- log.Info("Watchtower HTTP API started.")
if block {
runHTTPServer()
} else {
@@ -71,6 +72,5 @@ func (api *API) Start(block bool) error {
}
func runHTTPServer() {
- log.Info("Serving HTTP")
log.Fatal(http.ListenAndServe(":8080", nil))
}
diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go
new file mode 100644
index 0000000..4e9110b
--- /dev/null
+++ b/pkg/api/api_test.go
@@ -0,0 +1,65 @@
+package api
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+const (
+ token = "123123123"
+)
+
+func TestAPI(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "API Suite")
+}
+
+var _ = Describe("API", func() {
+ api := New(token)
+
+ Describe("RequireToken middleware", func() {
+ It("should return 401 Unauthorized when token is not provided", func() {
+ handlerFunc := api.RequireToken(testHandler)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/hello", nil)
+
+ handlerFunc(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("should return 401 Unauthorized when token is invalid", func() {
+ handlerFunc := api.RequireToken(testHandler)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/hello", nil)
+ req.Header.Set("Authorization", "Bearer 123")
+
+ handlerFunc(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("should return 200 OK when token is valid", func() {
+ handlerFunc := api.RequireToken(testHandler)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/hello", nil)
+ req.Header.Set("Authorization", "Bearer " + token)
+
+ handlerFunc(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ })
+ })
+})
+
+func testHandler(w http.ResponseWriter, req *http.Request) {
+ _, _ = io.WriteString(w, "Hello!")
+}
diff --git a/pkg/api/metrics/metrics_test.go b/pkg/api/metrics/metrics_test.go
index 156601f..48b6dd7 100644
--- a/pkg/api/metrics/metrics_test.go
+++ b/pkg/api/metrics/metrics_test.go
@@ -2,75 +2,89 @@ package metrics_test
import (
"fmt"
- "github.com/containrrr/watchtower/pkg/metrics"
- "io/ioutil"
+ "io"
"net/http"
+ "net/http/httptest"
+ "strings"
"testing"
- "github.com/containrrr/watchtower/pkg/api"
- metricsAPI "github.com/containrrr/watchtower/pkg/api/metrics"
-
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
+
+ "github.com/containrrr/watchtower/pkg/api"
+ metricsAPI "github.com/containrrr/watchtower/pkg/api/metrics"
+ "github.com/containrrr/watchtower/pkg/metrics"
)
-const Token = "123123123"
+const (
+ token = "123123123"
+ getURL = "http://localhost:8080/v1/metrics"
+)
-func TestContainer(t *testing.T) {
+func TestMetrics(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(handler http.Handler) map[string]string {
+ metricMap := map[string]string{}
+ respWriter := httptest.NewRecorder()
+
+ req := httptest.NewRequest("GET", getURL, nil)
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
+
+ handler.ServeHTTP(respWriter, req)
+
+ res := respWriter.Result()
+ body, _ := io.ReadAll(res.Body)
+
+ for _, line := range strings.Split(string(body), "\n") {
+ if len(line) < 1 || line[0] == '#' {
+ continue
+ }
+ parts := strings.Split(line, " ")
+ metricMap[parts[0]] = parts[1]
+ }
+
+ return metricMap
}
-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)
+var _ = Describe("the metrics API", func() {
+ httpAPI := api.New(token)
m := metricsAPI.New()
- httpAPI.RegisterHandler(m.Path, m.Handle)
- httpAPI.Start(false)
- // We should likely split this into multiple tests, but as prometheus requires a restart of the binary
- // to reset the metrics and gauges, we'll just do it all at once.
+ handleReq := httpAPI.RequireToken(m.Handle)
+ tryGetMetrics := func() map[string]string { return getWithToken(handleReq) }
It("should serve metrics", func() {
+
+ Expect(tryGetMetrics()).To(HaveKeyWithValue("watchtower_containers_updated", "0"))
+
metric := &metrics.Metric{
Scanned: 4,
Updated: 3,
Failed: 1,
}
+
metrics.RegisterScan(metric)
- c := http.Client{}
- res, err := getWithToken(c, "http://localhost:8080/v1/metrics")
+ Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue())
- Expect(err).NotTo(HaveOccurred())
- contents, err := ioutil.ReadAll(res.Body)
-
- 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"))
+ Eventually(tryGetMetrics).Should(SatisfyAll(
+ HaveKeyWithValue("watchtower_containers_updated", "3"),
+ HaveKeyWithValue("watchtower_containers_failed", "1"),
+ HaveKeyWithValue("watchtower_containers_scanned", "4"),
+ HaveKeyWithValue("watchtower_scans_total", "1"),
+ HaveKeyWithValue("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).NotTo(HaveOccurred())
- contents, err = ioutil.ReadAll(res.Body)
-
- Expect(string(contents)).To(ContainSubstring("watchtower_scans_total 4"))
- Expect(string(contents)).To(ContainSubstring("watchtower_scans_skipped 3"))
+ Eventually(tryGetMetrics).Should(SatisfyAll(
+ HaveKeyWithValue("watchtower_scans_total", "4"),
+ HaveKeyWithValue("watchtower_scans_skipped", "3"),
+ ))
})
})
diff --git a/pkg/api/update/update.go b/pkg/api/update/update.go
index 463b082..ba044ab 100644
--- a/pkg/api/update/update.go
+++ b/pkg/api/update/update.go
@@ -4,6 +4,7 @@ import (
"io"
"net/http"
"os"
+ "strings"
log "github.com/sirupsen/logrus"
)
@@ -13,9 +14,13 @@ var (
)
// New is a factory function creating a new Handler instance
-func New(updateFn func()) *Handler {
- lock = make(chan bool, 1)
- lock <- true
+func New(updateFn func(images []string), updateLock chan bool) *Handler {
+ if updateLock != nil {
+ lock = updateLock
+ } else {
+ lock = make(chan bool, 1)
+ lock <- true
+ }
return &Handler{
fn: updateFn,
@@ -25,7 +30,7 @@ func New(updateFn func()) *Handler {
// Handler is an API handler used for triggering container update scans
type Handler struct {
- fn func()
+ fn func(images []string)
Path string
}
@@ -39,12 +44,29 @@ func (handle *Handler) Handle(w http.ResponseWriter, r *http.Request) {
return
}
- select {
- case chanValue := <-lock:
+ var images []string
+ imageQueries, found := r.URL.Query()["image"]
+ if found {
+ for _, image := range imageQueries {
+ images = append(images, strings.Split(image, ",")...)
+ }
+
+ } else {
+ images = nil
+ }
+
+ if len(images) > 0 {
+ chanValue := <-lock
defer func() { lock <- chanValue }()
- handle.fn()
- default:
- log.Debug("Skipped. Another update already running.")
+ handle.fn(images)
+ } else {
+ select {
+ case chanValue := <-lock:
+ defer func() { lock <- chanValue }()
+ handle.fn(images)
+ default:
+ log.Debug("Skipped. Another update already running.")
+ }
}
}
diff --git a/pkg/container/cgroup_id.go b/pkg/container/cgroup_id.go
new file mode 100644
index 0000000..1da1dfe
--- /dev/null
+++ b/pkg/container/cgroup_id.go
@@ -0,0 +1,29 @@
+package container
+
+import (
+ "fmt"
+ "os"
+ "regexp"
+
+ "github.com/containrrr/watchtower/pkg/types"
+)
+
+var dockerContainerPattern = regexp.MustCompile(`[0-9]+:.*:/docker/([a-f|0-9]{64})`)
+
+// GetRunningContainerID tries to resolve the current container ID from the current process cgroup information
+func GetRunningContainerID() (cid types.ContainerID, err error) {
+ file, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", os.Getpid()))
+ if err != nil {
+ return
+ }
+
+ return getRunningContainerIDFromString(string(file)), nil
+}
+
+func getRunningContainerIDFromString(s string) types.ContainerID {
+ matches := dockerContainerPattern.FindStringSubmatch(s)
+ if len(matches) < 2 {
+ return ""
+ }
+ return types.ContainerID(matches[1])
+}
diff --git a/pkg/container/cgroup_id_test.go b/pkg/container/cgroup_id_test.go
new file mode 100644
index 0000000..5f694e3
--- /dev/null
+++ b/pkg/container/cgroup_id_test.go
@@ -0,0 +1,40 @@
+package container
+
+import (
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("GetRunningContainerID", func() {
+ When("a matching container ID is found", func() {
+ It("should return that container ID", func() {
+ cid := getRunningContainerIDFromString(`
+15:name=systemd:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+14:misc:/
+13:rdma:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+12:pids:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+11:hugetlb:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+10:net_prio:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+9:perf_event:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+8:net_cls:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+7:freezer:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+6:devices:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+5:blkio:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+4:cpuacct:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+3:cpu:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+2:cpuset:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+1:memory:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+0::/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377
+ `)
+ Expect(cid).To(BeEquivalentTo(`991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377`))
+ })
+ })
+ When("no matching container ID could be found", func() {
+ It("should return that container ID", func() {
+ cid := getRunningContainerIDFromString(`14:misc:/`)
+ Expect(cid).To(BeEmpty())
+ })
+ })
+})
+
+//
diff --git a/pkg/container/client.go b/pkg/container/client.go
index 635aa3e..c6c37de 100644
--- a/pkg/container/client.go
+++ b/pkg/container/client.go
@@ -3,14 +3,10 @@ package container
import (
"bytes"
"fmt"
- "io/ioutil"
+ "io"
"strings"
"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"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
@@ -18,6 +14,10 @@ import (
sdkClient "github.com/docker/docker/client"
log "github.com/sirupsen/logrus"
"golang.org/x/net/context"
+
+ "github.com/containrrr/watchtower/pkg/registry"
+ "github.com/containrrr/watchtower/pkg/registry/digest"
+ t "github.com/containrrr/watchtower/pkg/types"
)
const defaultStopSignal = "SIGTERM"
@@ -25,23 +25,24 @@ const defaultStopSignal = "SIGTERM"
// A Client is the interface through which watchtower interacts with the
// Docker API.
type Client interface {
- ListContainers(t.Filter) ([]Container, error)
- GetContainer(containerID string) (Container, error)
- StopContainer(Container, time.Duration) error
- StartContainer(Container) (string, error)
- RenameContainer(Container, string) error
- IsContainerStale(Container) (bool, error)
- ExecuteCommand(containerID string, command string, timeout int) error
- RemoveImageByID(string) error
+ ListContainers(t.Filter) ([]t.Container, error)
+ GetContainer(containerID t.ContainerID) (t.Container, error)
+ StopContainer(t.Container, time.Duration) error
+ StartContainer(t.Container) (t.ContainerID, error)
+ RenameContainer(t.Container, string) error
+ IsContainerStale(t.Container, t.UpdateParams) (stale bool, latestImage t.ImageID, err error)
+ ExecuteCommand(containerID t.ContainerID, command string, timeout int) (SkipUpdate bool, err error)
+ RemoveImageByID(t.ImageID) error
+ WarnOnHeadPullFailed(container t.Container) bool
}
// NewClient returns a new Client instance which can be used to interact with
// the Docker API.
// The client reads its configuration from the following environment variables:
-// * 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 {
+// - 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(opts ClientOptions) Client {
cli, err := sdkClient.NewClientWithOpts(sdkClient.FromEnv)
if err != nil {
@@ -49,33 +50,57 @@ func NewClient(pullImages bool, includeStopped bool, reviveStopped bool, removeV
}
return dockerClient{
- api: cli,
- pullImages: pullImages,
- removeVolumes: removeVolumes,
- includeStopped: includeStopped,
- reviveStopped: reviveStopped,
- includeRestarting: includeRestarting,
+ api: cli,
+ ClientOptions: opts,
}
}
-type dockerClient struct {
- api sdkClient.CommonAPIClient
- pullImages bool
- removeVolumes bool
- includeStopped bool
- reviveStopped bool
- includeRestarting bool
+// ClientOptions contains the options for how the docker client wrapper should behave
+type ClientOptions struct {
+ RemoveVolumes bool
+ IncludeStopped bool
+ ReviveStopped bool
+ IncludeRestarting bool
+ WarnOnHeadFailed WarningStrategy
}
-func (client dockerClient) ListContainers(fn t.Filter) ([]Container, error) {
- cs := []Container{}
+// WarningStrategy is a value determining when to show warnings
+type WarningStrategy string
+
+const (
+ // WarnAlways warns whenever the problem occurs
+ WarnAlways WarningStrategy = "always"
+ // WarnNever never warns when the problem occurs
+ WarnNever WarningStrategy = "never"
+ // WarnAuto skips warning when the problem was expected
+ WarnAuto WarningStrategy = "auto"
+)
+
+type dockerClient struct {
+ api sdkClient.CommonAPIClient
+ ClientOptions
+}
+
+func (client dockerClient) WarnOnHeadPullFailed(container t.Container) bool {
+ if client.WarnOnHeadFailed == WarnAlways {
+ return true
+ }
+ if client.WarnOnHeadFailed == WarnNever {
+ return false
+ }
+
+ return registry.WarnOnAPIConsumption(container)
+}
+
+func (client dockerClient) ListContainers(fn t.Filter) ([]t.Container, error) {
+ cs := []t.Container{}
bg := context.Background()
- if client.includeStopped && client.includeRestarting {
+ if client.IncludeStopped && client.IncludeRestarting {
log.Debug("Retrieving running, stopped, restarting and exited containers")
- } else if client.includeStopped {
+ } else if client.IncludeStopped {
log.Debug("Retrieving running, stopped and exited containers")
- } else if client.includeRestarting {
+ } else if client.IncludeRestarting {
log.Debug("Retrieving running and restarting containers")
} else {
log.Debug("Retrieving running containers")
@@ -94,7 +119,7 @@ func (client dockerClient) ListContainers(fn t.Filter) ([]Container, error) {
for _, runningContainer := range containers {
- c, err := client.GetContainer(runningContainer.ID)
+ c, err := client.GetContainer(t.ContainerID(runningContainer.ID))
if err != nil {
return nil, err
}
@@ -111,45 +136,64 @@ func (client dockerClient) createListFilter() filters.Args {
filterArgs := filters.NewArgs()
filterArgs.Add("status", "running")
- if client.includeStopped {
+ if client.IncludeStopped {
filterArgs.Add("status", "created")
filterArgs.Add("status", "exited")
}
- if client.includeRestarting {
+ if client.IncludeRestarting {
filterArgs.Add("status", "restarting")
}
return filterArgs
}
-func (client dockerClient) GetContainer(containerID string) (Container, error) {
+func (client dockerClient) GetContainer(containerID t.ContainerID) (t.Container, error) {
bg := context.Background()
- containerInfo, err := client.api.ContainerInspect(bg, containerID)
+ containerInfo, err := client.api.ContainerInspect(bg, string(containerID))
if err != nil {
- return Container{}, err
+ return &Container{}, err
+ }
+
+ netType, netContainerId, found := strings.Cut(string(containerInfo.HostConfig.NetworkMode), ":")
+ if found && netType == "container" {
+ parentContainer, err := client.api.ContainerInspect(bg, netContainerId)
+ if err != nil {
+ log.WithFields(map[string]interface{}{
+ "container": containerInfo.Name,
+ "error": err,
+ "network-container": netContainerId,
+ }).Warnf("Unable to resolve network container: %v", err)
+
+ } else {
+ // Replace the container ID with a container name to allow it to reference the re-created network container
+ containerInfo.HostConfig.NetworkMode = container.NetworkMode(fmt.Sprintf("container:%s", parentContainer.Name))
+ }
}
imageInfo, _, err := client.api.ImageInspectWithRaw(bg, containerInfo.Image)
if err != nil {
log.Warnf("Failed to retrieve container image info: %v", err)
- return Container{containerInfo: &containerInfo, imageInfo: nil}, nil
+ return &Container{containerInfo: &containerInfo, imageInfo: nil}, nil
}
- return Container{containerInfo: &containerInfo, imageInfo: &imageInfo}, nil
+ return &Container{containerInfo: &containerInfo, imageInfo: &imageInfo}, nil
}
-func (client dockerClient) StopContainer(c Container, timeout time.Duration) error {
+func (client dockerClient) StopContainer(c t.Container, timeout time.Duration) error {
bg := context.Background()
signal := c.StopSignal()
if signal == "" {
signal = defaultStopSignal
}
+ idStr := string(c.ID())
+ shortID := c.ID().ShortID()
+
if c.IsRunning() {
- log.Infof("Stopping %s (%s) with %s", c.Name(), c.ID(), signal)
- if err := client.api.ContainerKill(bg, c.ID(), signal); err != nil {
+ log.Infof("Stopping %s (%s) with %s", c.Name(), shortID, signal)
+ if err := client.api.ContainerKill(bg, idStr, signal); err != nil {
return err
}
}
@@ -157,29 +201,56 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
// TODO: This should probably be checked.
_ = client.waitForStopOrTimeout(c, timeout)
- if c.containerInfo.HostConfig.AutoRemove {
- log.Debugf("AutoRemove container %s, skipping ContainerRemove call.", c.ID())
+ if c.ContainerInfo().HostConfig.AutoRemove {
+ 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 {
+ if err := client.api.ContainerRemove(bg, idStr, types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.RemoveVolumes}); err != nil {
+ if sdkClient.IsErrNotFound(err) {
+ log.Debugf("Container %s not found, skipping removal.", shortID)
+ return nil
+ }
return err
}
}
// 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
}
-func (client dockerClient) StartContainer(c Container) (string, error) {
+func (client dockerClient) GetNetworkConfig(c t.Container) *network.NetworkingConfig {
+ config := &network.NetworkingConfig{
+ EndpointsConfig: c.ContainerInfo().NetworkSettings.Networks,
+ }
+
+ for _, ep := range config.EndpointsConfig {
+ aliases := make([]string, 0, len(ep.Aliases))
+ cidAlias := c.ID().ShortID()
+
+ // Remove the old container ID alias from the network aliases, as it would accumulate across updates otherwise
+ for _, alias := range ep.Aliases {
+ if alias == cidAlias {
+ continue
+ }
+ aliases = append(aliases, alias)
+ }
+
+ ep.Aliases = aliases
+ }
+ return config
+}
+
+func (client dockerClient) StartContainer(c t.Container) (t.ContainerID, error) {
bg := context.Background()
- config := c.runtimeConfig()
- hostConfig := c.hostConfig()
- networkConfig := &network.NetworkingConfig{EndpointsConfig: c.containerInfo.NetworkSettings.Networks}
+ config := c.GetCreateConfig()
+ hostConfig := c.GetCreateHostConfig()
+ networkConfig := client.GetNetworkConfig(c)
+
// simpleNetworkConfig is a networkConfig with only 1 network.
// see: https://github.com/docker/docker/issues/29265
simpleNetworkConfig := func() *network.NetworkingConfig {
@@ -195,7 +266,8 @@ func (client dockerClient) StartContainer(c Container) (string, error) {
name := c.Name()
log.Infof("Creating %s", name)
- createdContainer, err := client.api.ContainerCreate(bg, config, hostConfig, simpleNetworkConfig, name)
+
+ createdContainer, err := client.api.ContainerCreate(bg, config, hostConfig, simpleNetworkConfig, nil, name)
if err != nil {
return "", err
}
@@ -218,18 +290,19 @@ func (client dockerClient) StartContainer(c Container) (string, error) {
}
- if !c.IsRunning() && !client.reviveStopped {
- return createdContainer.ID, nil
+ createdContainerID := t.ContainerID(createdContainer.ID)
+ if !c.IsRunning() && !client.ReviveStopped {
+ return createdContainerID, nil
}
- return createdContainer.ID, client.doStartContainer(bg, c, createdContainer)
+ return createdContainerID, client.doStartContainer(bg, c, createdContainer)
}
-func (client dockerClient) doStartContainer(bg context.Context, c Container, creation container.ContainerCreateCreatedBody) error {
+func (client dockerClient) doStartContainer(bg context.Context, c t.Container, creation container.CreateResponse) error {
name := c.Name()
- log.Debugf("Starting container %s (%s)", name, creation.ID)
+ log.Debugf("Starting container %s (%s)", name, t.ContainerID(creation.ID).ShortID())
err := client.api.ContainerStart(bg, creation.ID, types.ContainerStartOptions{})
if err != nil {
return err
@@ -237,43 +310,46 @@ func (client dockerClient) doStartContainer(bg context.Context, c Container, cre
return nil
}
-func (client dockerClient) RenameContainer(c Container, newName string) error {
+func (client dockerClient) RenameContainer(c t.Container, newName string) error {
bg := context.Background()
- log.Debugf("Renaming container %s (%s) to %s", c.Name(), c.ID(), newName)
- return client.api.ContainerRename(bg, c.ID(), newName)
+ log.Debugf("Renaming container %s (%s) to %s", c.Name(), c.ID().ShortID(), newName)
+ return client.api.ContainerRename(bg, string(c.ID()), newName)
}
-func (client dockerClient) IsContainerStale(container Container) (bool, error) {
+func (client dockerClient) IsContainerStale(container t.Container, params t.UpdateParams) (stale bool, latestImage t.ImageID, err error) {
ctx := context.Background()
- if !client.pullImages {
+ if container.IsNoPull(params) {
log.Debugf("Skipping image pull.")
} else if err := client.PullImage(ctx, container); err != nil {
- return false, err
+ return false, container.SafeImageID(), err
}
return client.HasNewImage(ctx, container)
}
-func (client dockerClient) HasNewImage(ctx context.Context, container Container) (bool, error) {
- oldImageID := container.containerInfo.ContainerJSONBase.Image
+func (client dockerClient) HasNewImage(ctx context.Context, container t.Container) (hasNew bool, latestImage t.ImageID, err error) {
+ currentImageID := t.ImageID(container.ContainerInfo().ContainerJSONBase.Image)
imageName := container.ImageName()
newImageInfo, _, err := client.api.ImageInspectWithRaw(ctx, imageName)
if err != nil {
- return false, err
+ return false, currentImageID, err
}
- if newImageInfo.ID == oldImageID {
+ newImageID := t.ImageID(newImageInfo.ID)
+ if newImageID == currentImageID {
log.Debugf("No new images found for %s", container.Name())
- return false, nil
+ return false, currentImageID, nil
}
- log.Infof("Found new %s image (%s)", imageName, newImageInfo.ID)
- return true, nil
+ log.Infof("Found new %s image (%s)", imageName, newImageID.ShortID())
+ return true, newImageID, nil
}
-func (client dockerClient) PullImage(ctx context.Context, container Container) error {
+// 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 t.Container) error {
containerName := container.Name()
imageName := container.ImageName()
@@ -282,25 +358,29 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
"container": containerName,
}
+ if strings.HasPrefix(imageName, "sha256:") {
+ return fmt.Errorf("container uses a pinned image, and cannot be updated by watchtower")
+ }
+
log.WithFields(fields).Debugf("Trying to load authentication credentials.")
opts, err := registry.GetPullOptions(imageName)
- if opts.RegistryAuth != "" {
- log.Debug("Credentials loaded")
- }
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 {
- if registry.WarnOnAPIConsumption(container) {
- log.WithFields(fields).Warning("Could not do a head request, falling back to regular pull.")
- } else {
- log.Debug("Could not do a head request, falling back to regular pull.")
+ headLevel := log.DebugLevel
+ if client.WarnOnHeadPullFailed(container) {
+ headLevel = log.WarnLevel
}
- log.Debugf("Reason: %s", err.Error())
+ 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
@@ -318,28 +398,50 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
defer response.Close()
// the pull request will be aborted prematurely unless the response is read
- if _, err = ioutil.ReadAll(response); err != nil {
+ if _, err = io.ReadAll(response); err != nil {
log.Error(err)
return err
}
return nil
}
-func (client dockerClient) RemoveImageByID(id string) error {
- log.Infof("Removing image %s", id)
+func (client dockerClient) RemoveImageByID(id t.ImageID) error {
+ log.Infof("Removing image %s", id.ShortID())
- _, err := client.api.ImageRemove(
+ items, err := client.api.ImageRemove(
context.Background(),
- id,
+ string(id),
types.ImageRemoveOptions{
Force: true,
})
+ if log.IsLevelEnabled(log.DebugLevel) {
+ deleted := strings.Builder{}
+ untagged := strings.Builder{}
+ for _, item := range items {
+ if item.Deleted != "" {
+ if deleted.Len() > 0 {
+ deleted.WriteString(`, `)
+ }
+ deleted.WriteString(t.ImageID(item.Deleted).ShortID())
+ }
+ if item.Untagged != "" {
+ if untagged.Len() > 0 {
+ untagged.WriteString(`, `)
+ }
+ untagged.WriteString(t.ImageID(item.Untagged).ShortID())
+ }
+ }
+ fields := log.Fields{`deleted`: deleted.String(), `untagged`: untagged.String()}
+ log.WithFields(fields).Debug("Image removal completed")
+ }
+
return err
}
-func (client dockerClient) ExecuteCommand(containerID string, command string, timeout int) error {
+func (client dockerClient) ExecuteCommand(containerID t.ContainerID, command string, timeout int) (SkipUpdate bool, err error) {
bg := context.Background()
+ clog := log.WithField("containerID", containerID)
// Create the exec
execConfig := types.ExecConfig{
@@ -348,9 +450,9 @@ func (client dockerClient) ExecuteCommand(containerID string, command string, ti
Cmd: []string{"sh", "-c", command},
}
- exec, err := client.api.ContainerExecCreate(bg, containerID, execConfig)
+ exec, err := client.api.ContainerExecCreate(bg, string(containerID), execConfig)
if err != nil {
- return err
+ return false, err
}
response, attachErr := client.api.ContainerExecAttach(bg, exec.ID, types.ExecStartCheck{
@@ -358,14 +460,14 @@ func (client dockerClient) ExecuteCommand(containerID string, command string, ti
Detach: false,
})
if attachErr != nil {
- log.Errorf("Failed to extract command exec logs: %v", attachErr)
+ clog.Errorf("Failed to extract command exec logs: %v", attachErr)
}
// Run the exec
execStartCheck := types.ExecStartCheck{Detach: false, Tty: true}
err = client.api.ContainerExecStart(bg, exec.ID, execStartCheck)
if err != nil {
- return err
+ return false, err
}
var output string
@@ -374,7 +476,7 @@ func (client dockerClient) ExecuteCommand(containerID string, command string, ti
var writer bytes.Buffer
written, err := writer.ReadFrom(response.Reader)
if err != nil {
- log.Error(err)
+ clog.Error(err)
} else if written > 0 {
output = strings.TrimSpace(writer.String())
}
@@ -382,15 +484,16 @@ func (client dockerClient) ExecuteCommand(containerID string, command string, ti
// Inspect the exec to get the exit code and print a message if the
// exit code is not success.
- err = client.waitForExecOrTimeout(bg, exec.ID, output, timeout)
+ skipUpdate, err := client.waitForExecOrTimeout(bg, exec.ID, output, timeout)
if err != nil {
- return err
+ return true, err
}
- return nil
+ return skipUpdate, nil
}
-func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, execOutput string, timeout int) error {
+func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, execOutput string, timeout int) (SkipUpdate bool, err error) {
+ const ExTempFail = 75
var ctx context.Context
var cancel context.CancelFunc
@@ -404,32 +507,38 @@ 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,
- "running": execInspect.Running,
+ "exit-code": execInspect.ExitCode,
+ "exec-id": execInspect.ExecID,
+ "running": execInspect.Running,
+ "container-id": execInspect.ContainerID,
}).Debug("Awaiting timeout or completion")
if err != nil {
- return err
+ return false, err
}
- if execInspect.Running == true {
+ if execInspect.Running {
time.Sleep(1 * time.Second)
continue
}
if len(execOutput) > 0 {
log.Infof("Command output:\n%v", execOutput)
}
+
+ if execInspect.ExitCode == ExTempFail {
+ return true, nil
+ }
+
if execInspect.ExitCode > 0 {
- log.Errorf("Command exited with code %v.", execInspect.ExitCode)
- log.Error(execOutput)
+ return false, fmt.Errorf("command exited with code %v %s", execInspect.ExitCode, execOutput)
}
break
}
- return nil
+ return false, nil
}
-func (client dockerClient) waitForStopOrTimeout(c Container, waitTime time.Duration) error {
+func (client dockerClient) waitForStopOrTimeout(c t.Container, waitTime time.Duration) error {
bg := context.Background()
timeout := time.After(waitTime)
@@ -438,7 +547,7 @@ func (client dockerClient) waitForStopOrTimeout(c Container, waitTime time.Durat
case <-timeout:
return nil
default:
- if ci, err := client.api.ContainerInspect(bg, c.ID()); err != nil {
+ if ci, err := client.api.ContainerInspect(bg, string(c.ID())); err != nil {
return err
} else if !ci.State.Running {
return nil
diff --git a/pkg/container/client_test.go b/pkg/container/client_test.go
new file mode 100644
index 0000000..4e75409
--- /dev/null
+++ b/pkg/container/client_test.go
@@ -0,0 +1,377 @@
+package container
+
+import (
+ "github.com/docker/docker/api/types/network"
+ "time"
+
+ "github.com/containrrr/watchtower/internal/util"
+ "github.com/containrrr/watchtower/pkg/container/mocks"
+ "github.com/containrrr/watchtower/pkg/filters"
+ t "github.com/containrrr/watchtower/pkg/types"
+
+ "github.com/docker/docker/api/types"
+ "github.com/docker/docker/api/types/backend"
+ cli "github.com/docker/docker/client"
+ "github.com/docker/docker/errdefs"
+ "github.com/onsi/gomega/gbytes"
+ "github.com/onsi/gomega/ghttp"
+ "github.com/sirupsen/logrus"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+ gt "github.com/onsi/gomega/types"
+
+ "context"
+ "net/http"
+)
+
+var _ = Describe("the client", func() {
+ var docker *cli.Client
+ var mockServer *ghttp.Server
+ BeforeEach(func() {
+ mockServer = ghttp.NewServer()
+ docker, _ = cli.NewClientWithOpts(
+ cli.WithHost(mockServer.URL()),
+ cli.WithHTTPClient(mockServer.HTTPTestServer.Client()))
+ })
+ AfterEach(func() {
+ mockServer.Close()
+ })
+ Describe("WarnOnHeadPullFailed", func() {
+ containerUnknown := MockContainer(WithImageName("unknown.repo/prefix/imagename:latest"))
+ containerKnown := MockContainer(WithImageName("docker.io/prefix/imagename:latest"))
+
+ When(`warn on head failure is set to "always"`, func() {
+ c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnAlways}}
+ 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 := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnAuto}}
+ It("should return false for unknown repos", func() {
+ Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
+ })
+ It("should return true for known repos", func() {
+ Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
+ })
+ })
+ When(`warn on head failure is set to "never"`, func() {
+ c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnNever}}
+ It("should never return true", func() {
+ Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
+ Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeFalse())
+ })
+ })
+ })
+ When("pulling the latest image", func() {
+ When("the image consist of a pinned hash", func() {
+ It("should gracefully fail with a useful message", func() {
+ c := dockerClient{}
+ pinnedContainer := MockContainer(WithImageName("sha256:fa5269854a5e615e51a72b17ad3fd1e01268f278a6684c8ed3c5f0cdce3f230b"))
+ err := c.PullImage(context.Background(), pinnedContainer)
+ Expect(err).To(MatchError(`container uses a pinned image, and cannot be updated by watchtower`))
+ })
+ })
+ })
+ When("removing a running container", func() {
+ When("the container still exist after stopping", func() {
+ It("should attempt to remove the container", func() {
+ container := MockContainer(WithContainerState(types.ContainerState{Running: true}))
+ containerStopped := MockContainer(WithContainerState(types.ContainerState{Running: false}))
+
+ cid := container.ContainerInfo().ID
+ mockServer.AppendHandlers(
+ mocks.KillContainerHandler(cid, mocks.Found),
+ mocks.GetContainerHandler(cid, containerStopped.ContainerInfo()),
+ mocks.RemoveContainerHandler(cid, mocks.Found),
+ mocks.GetContainerHandler(cid, nil),
+ )
+
+ Expect(dockerClient{api: docker}.StopContainer(container, time.Minute)).To(Succeed())
+ })
+ })
+ When("the container does not exist after stopping", func() {
+ It("should not cause an error", func() {
+ container := MockContainer(WithContainerState(types.ContainerState{Running: true}))
+
+ cid := container.ContainerInfo().ID
+ mockServer.AppendHandlers(
+ mocks.KillContainerHandler(cid, mocks.Found),
+ mocks.GetContainerHandler(cid, nil),
+ mocks.RemoveContainerHandler(cid, mocks.Missing),
+ )
+
+ Expect(dockerClient{api: docker}.StopContainer(container, time.Minute)).To(Succeed())
+ })
+ })
+ })
+ When("removing a image", func() {
+ When("debug logging is enabled", func() {
+ It("should log removed and untagged images", func() {
+ imageA := util.GenerateRandomSHA256()
+ imageAParent := util.GenerateRandomSHA256()
+ images := map[string][]string{imageA: {imageAParent}}
+ mockServer.AppendHandlers(mocks.RemoveImageHandler(images))
+ c := dockerClient{api: docker}
+
+ resetLogrus, logbuf := captureLogrus(logrus.DebugLevel)
+ defer resetLogrus()
+
+ Expect(c.RemoveImageByID(t.ImageID(imageA))).To(Succeed())
+
+ shortA := t.ImageID(imageA).ShortID()
+ shortAParent := t.ImageID(imageAParent).ShortID()
+
+ Eventually(logbuf).Should(gbytes.Say(`deleted="%v, %v" untagged="?%v"?`, shortA, shortAParent, shortA))
+ })
+ })
+ When("image is not found", func() {
+ It("should return an error", func() {
+ image := util.GenerateRandomSHA256()
+ mockServer.AppendHandlers(mocks.RemoveImageHandler(nil))
+ c := dockerClient{api: docker}
+
+ err := c.RemoveImageByID(t.ImageID(image))
+ Expect(errdefs.IsNotFound(err)).To(BeTrue())
+ })
+ })
+ })
+ When("listing containers", func() {
+ When("no filter is provided", func() {
+ It("should return all available containers", func() {
+ mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{},
+ }
+ containers, err := client.ListContainers(filters.NoFilter)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(containers).To(HaveLen(2))
+ })
+ })
+ When("a filter matching nothing", func() {
+ It("should return an empty array", func() {
+ mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
+ filter := filters.FilterByNames([]string{"lollercoaster"}, filters.NoFilter)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{},
+ }
+ containers, err := client.ListContainers(filter)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(containers).To(BeEmpty())
+ })
+ })
+ When("a watchtower filter is provided", func() {
+ It("should return only the watchtower container", func() {
+ mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{},
+ }
+ containers, err := client.ListContainers(filters.WatchtowerContainersFilter)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(containers).To(ConsistOf(withContainerImageName(Equal("containrrr/watchtower:latest"))))
+ })
+ })
+ When(`include stopped is enabled`, func() {
+ It("should return both stopped and running containers", func() {
+ mockServer.AppendHandlers(mocks.ListContainersHandler("running", "exited", "created"))
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Stopped, &mocks.Watchtower, &mocks.Running)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{IncludeStopped: true},
+ }
+ containers, err := client.ListContainers(filters.NoFilter)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(containers).To(ContainElement(havingRunningState(false)))
+ })
+ })
+ When(`include restarting is enabled`, func() {
+ It("should return both restarting and running containers", func() {
+ mockServer.AppendHandlers(mocks.ListContainersHandler("running", "restarting"))
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running, &mocks.Restarting)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{IncludeRestarting: true},
+ }
+ containers, err := client.ListContainers(filters.NoFilter)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(containers).To(ContainElement(havingRestartingState(true)))
+ })
+ })
+ When(`include restarting is disabled`, func() {
+ It("should not return restarting containers", func() {
+ mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{IncludeRestarting: false},
+ }
+ containers, err := client.ListContainers(filters.NoFilter)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(containers).NotTo(ContainElement(havingRestartingState(true)))
+ })
+ })
+ When(`a container uses container network mode`, func() {
+ When(`the network container can be resolved`, func() {
+ It("should return the container name instead of the ID", func() {
+ consumerContainerRef := mocks.NetConsumerOK
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&consumerContainerRef)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{},
+ }
+ container, err := client.GetContainer(consumerContainerRef.ContainerID())
+ Expect(err).NotTo(HaveOccurred())
+ networkMode := container.ContainerInfo().HostConfig.NetworkMode
+ Expect(networkMode.ConnectedContainer()).To(Equal(mocks.NetSupplierContainerName))
+ })
+ })
+ When(`the network container cannot be resolved`, func() {
+ It("should still return the container ID", func() {
+ consumerContainerRef := mocks.NetConsumerInvalidSupplier
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&consumerContainerRef)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{},
+ }
+ container, err := client.GetContainer(consumerContainerRef.ContainerID())
+ Expect(err).NotTo(HaveOccurred())
+ networkMode := container.ContainerInfo().HostConfig.NetworkMode
+ Expect(networkMode.ConnectedContainer()).To(Equal(mocks.NetSupplierNotFoundID))
+ })
+ })
+ })
+ })
+ Describe(`ExecuteCommand`, func() {
+ When(`logging`, func() {
+ It("should include container id field", func() {
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{},
+ }
+
+ // Capture logrus output in buffer
+ resetLogrus, logbuf := captureLogrus(logrus.DebugLevel)
+ defer resetLogrus()
+
+ user := ""
+ containerID := t.ContainerID("ex-cont-id")
+ execID := "ex-exec-id"
+ cmd := "exec-cmd"
+
+ mockServer.AppendHandlers(
+ // API.ContainerExecCreate
+ ghttp.CombineHandlers(
+ ghttp.VerifyRequest("POST", HaveSuffix("containers/%v/exec", containerID)),
+ ghttp.VerifyJSONRepresenting(types.ExecConfig{
+ User: user,
+ Detach: false,
+ Tty: true,
+ Cmd: []string{
+ "sh",
+ "-c",
+ cmd,
+ },
+ }),
+ ghttp.RespondWithJSONEncoded(http.StatusOK, types.IDResponse{ID: execID}),
+ ),
+ // API.ContainerExecStart
+ ghttp.CombineHandlers(
+ ghttp.VerifyRequest("POST", HaveSuffix("exec/%v/start", execID)),
+ ghttp.VerifyJSONRepresenting(types.ExecStartCheck{
+ Detach: false,
+ Tty: true,
+ }),
+ ghttp.RespondWith(http.StatusOK, nil),
+ ),
+ // API.ContainerExecInspect
+ ghttp.CombineHandlers(
+ ghttp.VerifyRequest("GET", HaveSuffix("exec/ex-exec-id/json")),
+ ghttp.RespondWithJSONEncoded(http.StatusOK, backend.ExecInspect{
+ ID: execID,
+ Running: false,
+ ExitCode: nil,
+ ProcessConfig: &backend.ExecProcessConfig{
+ Entrypoint: "sh",
+ Arguments: []string{"-c", cmd},
+ User: user,
+ },
+ ContainerID: string(containerID),
+ }),
+ ),
+ )
+
+ _, err := client.ExecuteCommand(containerID, cmd, 1)
+ Expect(err).NotTo(HaveOccurred())
+ // Note: Since Execute requires opening up a raw TCP stream to the daemon for the output, this will fail
+ // when using the mock API server. Regardless of the outcome, the log should include the container ID
+ Eventually(logbuf).Should(gbytes.Say(`containerID="?ex-cont-id"?`))
+ })
+ })
+ })
+ Describe(`GetNetworkConfig`, func() {
+ When(`providing a container with network aliases`, func() {
+ It(`should omit the container ID alias`, func() {
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{IncludeRestarting: false},
+ }
+ container := MockContainer(WithImageName("docker.io/prefix/imagename:latest"))
+
+ aliases := []string{"One", "Two", container.ID().ShortID(), "Four"}
+ endpoints := map[string]*network.EndpointSettings{
+ `test`: {Aliases: aliases},
+ }
+ container.containerInfo.NetworkSettings = &types.NetworkSettings{Networks: endpoints}
+ Expect(container.ContainerInfo().NetworkSettings.Networks[`test`].Aliases).To(Equal(aliases))
+ Expect(client.GetNetworkConfig(container).EndpointsConfig[`test`].Aliases).To(Equal([]string{"One", "Two", "Four"}))
+ })
+ })
+ })
+})
+
+// Capture logrus output in buffer
+func captureLogrus(level logrus.Level) (func(), *gbytes.Buffer) {
+
+ logbuf := gbytes.NewBuffer()
+
+ origOut := logrus.StandardLogger().Out
+ logrus.SetOutput(logbuf)
+
+ origLev := logrus.StandardLogger().Level
+ logrus.SetLevel(level)
+
+ return func() {
+ logrus.SetOutput(origOut)
+ logrus.SetLevel(origLev)
+ }, logbuf
+}
+
+// Gomega matcher helpers
+
+func withContainerImageName(matcher gt.GomegaMatcher) gt.GomegaMatcher {
+ return WithTransform(containerImageName, matcher)
+}
+
+func containerImageName(container t.Container) string {
+ return container.ImageName()
+}
+
+func havingRestartingState(expected bool) gt.GomegaMatcher {
+ return WithTransform(func(container t.Container) bool {
+ return container.ContainerInfo().State.Restarting
+ }, Equal(expected))
+}
+
+func havingRunningState(expected bool) gt.GomegaMatcher {
+ return WithTransform(func(container t.Container) bool {
+ return container.ContainerInfo().State.Running
+ }, Equal(expected))
+}
diff --git a/pkg/container/container.go b/pkg/container/container.go
index 8a9d39e..10ed677 100644
--- a/pkg/container/container.go
+++ b/pkg/container/container.go
@@ -1,14 +1,19 @@
+// Package container contains code related to dealing with docker containers
package container
import (
+ "errors"
"fmt"
"strconv"
"strings"
"github.com/containrrr/watchtower/internal/util"
+ wt "github.com/containrrr/watchtower/pkg/types"
+ "github.com/sirupsen/logrus"
"github.com/docker/docker/api/types"
dockercontainer "github.com/docker/docker/api/types/container"
+ "github.com/docker/go-connections/nat"
)
// NewContainer returns a new Container instance instantiated with the
@@ -22,21 +27,41 @@ 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
}
+// IsLinkedToRestarting returns the current value of the LinkedToRestarting field for the container
+func (c *Container) IsLinkedToRestarting() bool {
+ return c.LinkedToRestarting
+}
+
+// IsStale returns the current value of the Stale field for the container
+func (c *Container) IsStale() bool {
+ return c.Stale
+}
+
+// SetLinkedToRestarting sets the LinkedToRestarting field for the container
+func (c *Container) SetLinkedToRestarting(value bool) {
+ c.LinkedToRestarting = value
+}
+
+// SetStale implements sets the Stale field for the container
+func (c *Container) SetStale(value bool) {
+ c.Stale = value
+}
+
// ContainerInfo fetches JSON info for the container
func (c Container) ContainerInfo() *types.ContainerJSON {
return c.containerInfo
}
// ID returns the Docker container ID.
-func (c Container) ID() string {
- return c.containerInfo.ID
+func (c Container) ID() wt.ContainerID {
+ return wt.ContainerID(c.containerInfo.ID)
}
// IsRunning returns a boolean flag indicating whether or not the current
@@ -46,15 +71,31 @@ func (c Container) IsRunning() bool {
return c.containerInfo.State.Running
}
+// IsRestarting returns a boolean flag indicating whether or not the current
+// container is restarting. The status is determined by the value of the
+// container's "State.Restarting" property.
+func (c Container) IsRestarting() bool {
+ return c.containerInfo.State.Restarting
+}
+
// Name returns the Docker container name.
func (c Container) Name() string {
return c.containerInfo.Name
}
// ImageID returns the ID of the Docker image that was used to start the
-// container.
-func (c Container) ImageID() string {
- return c.imageInfo.ID
+// container. May cause nil dereference if imageInfo is not set!
+func (c Container) ImageID() wt.ImageID {
+ return wt.ImageID(c.imageInfo.ID)
+}
+
+// SafeImageID returns the ID of the Docker image that was used to start the container if available,
+// otherwise returns an empty string
+func (c Container) SafeImageID() wt.ImageID {
+ if c.imageInfo == nil {
+ return ""
+ }
+ return wt.ImageID(c.imageInfo.ID)
}
// ImageName returns the name of the Docker image that was used to start the
@@ -90,20 +131,31 @@ func (c Container) Enabled() (bool, bool) {
return parsedBool, true
}
-// IsMonitorOnly returns the value of the monitor-only label. If the label
-// is not set then false is returned.
-func (c Container) IsMonitorOnly() bool {
- rawBool, ok := c.getLabelValue(monitorOnlyLabel)
- if !ok {
- return false
- }
+// IsMonitorOnly returns whether the container should only be monitored based on values of
+// the monitor-only label, the monitor-only argument and the label-take-precedence argument.
+func (c Container) IsMonitorOnly(params wt.UpdateParams) bool {
+ return c.getContainerOrGlobalBool(params.MonitorOnly, monitorOnlyLabel, params.LabelPrecedence)
+}
- parsedBool, err := strconv.ParseBool(rawBool)
- if err != nil {
- return false
- }
+// IsNoPull returns whether the image should be pulled based on values of
+// the no-pull label, the no-pull argument and the label-take-precedence argument.
+func (c Container) IsNoPull(params wt.UpdateParams) bool {
+ return c.getContainerOrGlobalBool(params.NoPull, noPullLabel, params.LabelPrecedence)
+}
- return parsedBool
+func (c Container) getContainerOrGlobalBool(globalVal bool, label string, contPrecedence bool) bool {
+ if contVal, err := c.getBoolLabelValue(label); err != nil {
+ if !errors.Is(err, errorLabelNotFound) {
+ logrus.WithField("error", err).WithField("label", label).Warn("Failed to parse label value")
+ }
+ return globalVal
+ } else {
+ if contPrecedence {
+ return contVal
+ } else {
+ return contVal || globalVal
+ }
+ }
}
// Scope returns the value of the scope UID label and if the label
@@ -125,7 +177,14 @@ func (c Container) Links() []string {
dependsOnLabelValue := c.getLabelValueOrEmpty(dependsOnLabel)
if dependsOnLabelValue != "" {
- links := strings.Split(dependsOnLabelValue, ",")
+ for _, link := range strings.Split(dependsOnLabelValue, ",") {
+ // Since the container names need to start with '/', let's prepend it if it's missing
+ if !strings.HasPrefix(link, "/") {
+ link = "/" + link
+ }
+ links = append(links, link)
+ }
+
return links
}
@@ -134,6 +193,13 @@ func (c Container) Links() []string {
name := strings.Split(link, ":")[0]
links = append(links, name)
}
+
+ // If the container uses another container for networking, it can be considered an implicit link
+ // since the container would stop working if the network supplier were to be recreated
+ networkMode := c.containerInfo.HostConfig.NetworkMode
+ if networkMode.IsContainer() {
+ links = append(links, networkMode.ConnectedContainer())
+ }
}
return links
@@ -142,7 +208,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
@@ -172,6 +238,25 @@ func (c Container) PreUpdateTimeout() int {
return minutes
}
+// PostUpdateTimeout checks whether a container has a specific timeout set
+// for how long the post-update command is allowed to run. This value is expressed
+// either as an integer, in minutes, or as 0 which will allow the command/script
+// to run indefinitely. Users should be cautious with the 0 option, as that
+// could result in watchtower waiting forever.
+func (c Container) PostUpdateTimeout() int {
+ var minutes int
+ var err error
+
+ val := c.getLabelValueOrEmpty(postUpdateTimeoutLabel)
+
+ minutes, err = strconv.Atoi(val)
+ if err != nil || val == "" {
+ return 1
+ }
+
+ return minutes
+}
+
// StopSignal returns the custom stop signal (if any) that is encoded in the
// container's metadata. If the container has not specified a custom stop
// signal, the empty string "" is returned.
@@ -179,18 +264,23 @@ func (c Container) StopSignal() string {
return c.getLabelValueOrEmpty(signalLabel)
}
+// GetCreateConfig returns the container's current Config converted into a format
+// that can be re-submitted to the Docker create API.
+//
// Ideally, we'd just be able to take the ContainerConfig from the old container
// and use it as the starting point for creating the new container; however,
// the ContainerConfig that comes back from the Inspect call merges the default
// configuration (the stuff specified in the metadata for the image itself)
// with the overridden configuration (the stuff that you might specify as part
-// of the "docker run"). In order to avoid unintentionally overriding the
+// of the "docker run").
+//
+// In order to avoid unintentionally overriding the
// defaults in the new image we need to separate the override options from the
// default options. To do this we have to compare the ContainerConfig for the
// running container with the ContainerConfig from the image that container was
// started from. This function returns a ContainerConfig which contains just
// the options overridden at runtime.
-func (c Container) runtimeConfig() *dockercontainer.Config {
+func (c Container) GetCreateConfig() *dockercontainer.Config {
config := c.containerInfo.Config
hostConfig := c.containerInfo.HostConfig
imageConfig := c.imageInfo.Config
@@ -214,6 +304,29 @@ func (c Container) runtimeConfig() *dockercontainer.Config {
}
}
+ // Clear HEALTHCHECK configuration (if default)
+ if config.Healthcheck != nil && imageConfig.Healthcheck != nil {
+ if util.SliceEqual(config.Healthcheck.Test, imageConfig.Healthcheck.Test) {
+ config.Healthcheck.Test = nil
+ }
+
+ if config.Healthcheck.Retries == imageConfig.Healthcheck.Retries {
+ config.Healthcheck.Retries = 0
+ }
+
+ if config.Healthcheck.Interval == imageConfig.Healthcheck.Interval {
+ config.Healthcheck.Interval = 0
+ }
+
+ if config.Healthcheck.Timeout == imageConfig.Healthcheck.Timeout {
+ config.Healthcheck.Timeout = 0
+ }
+
+ if config.Healthcheck.StartPeriod == imageConfig.Healthcheck.StartPeriod {
+ config.Healthcheck.StartPeriod = 0
+ }
+ }
+
config.Env = util.SliceSubtract(config.Env, imageConfig.Env)
config.Labels = util.StringMapSubtract(config.Labels, imageConfig.Labels)
@@ -234,9 +347,9 @@ func (c Container) runtimeConfig() *dockercontainer.Config {
return config
}
-// Any links in the HostConfig need to be re-written before they can be
-// re-submitted to the Docker create API.
-func (c Container) hostConfig() *dockercontainer.HostConfig {
+// GetCreateHostConfig returns the container's current HostConfig with any links
+// re-written so that they can be re-submitted to the Docker create API.
+func (c Container) GetCreateHostConfig() *dockercontainer.HostConfig {
hostConfig := c.containerInfo.HostConfig
for i, link := range hostConfig.Links {
@@ -258,3 +371,34 @@ func (c Container) HasImageInfo() bool {
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 errorNoContainerInfo
+ }
+
+ containerConfig := containerInfo.Config
+ if containerConfig == nil {
+ return errorInvalidConfig
+ }
+
+ hostConfig := containerInfo.HostConfig
+ if hostConfig == nil {
+ return errorInvalidConfig
+ }
+
+ // Instead of returning an error here, we just create an empty map
+ // This should allow for updating containers where the exposed ports are missing
+ if len(hostConfig.PortBindings) > 0 && containerConfig.ExposedPorts == nil {
+ containerConfig.ExposedPorts = make(map[nat.Port]struct{})
+ }
+
+ return nil
+}
diff --git a/pkg/container/container_mock_test.go b/pkg/container/container_mock_test.go
new file mode 100644
index 0000000..8aa1470
--- /dev/null
+++ b/pkg/container/container_mock_test.go
@@ -0,0 +1,79 @@
+package container
+
+import (
+ "github.com/docker/docker/api/types"
+ dockerContainer "github.com/docker/docker/api/types/container"
+ "github.com/docker/go-connections/nat"
+)
+
+type MockContainerUpdate func(*types.ContainerJSON, *types.ImageInspect)
+
+func MockContainer(updates ...MockContainerUpdate) *Container {
+ containerInfo := types.ContainerJSON{
+ ContainerJSONBase: &types.ContainerJSONBase{
+ ID: "container_id",
+ Image: "image",
+ Name: "test-containrrr",
+ HostConfig: &dockerContainer.HostConfig{},
+ },
+ Config: &dockerContainer.Config{
+ Labels: map[string]string{},
+ },
+ }
+ image := types.ImageInspect{
+ ID: "image_id",
+ Config: &dockerContainer.Config{},
+ }
+
+ for _, update := range updates {
+ update(&containerInfo, &image)
+ }
+ return NewContainer(&containerInfo, &image)
+}
+
+func WithPortBindings(portBindingSources ...string) MockContainerUpdate {
+ return func(c *types.ContainerJSON, i *types.ImageInspect) {
+ portBindings := nat.PortMap{}
+ for _, pbs := range portBindingSources {
+ portBindings[nat.Port(pbs)] = []nat.PortBinding{}
+ }
+ c.HostConfig.PortBindings = portBindings
+ }
+}
+
+func WithImageName(name string) MockContainerUpdate {
+ return func(c *types.ContainerJSON, i *types.ImageInspect) {
+ c.Config.Image = name
+ i.RepoTags = append(i.RepoTags, name)
+ }
+}
+
+func WithLinks(links []string) MockContainerUpdate {
+ return func(c *types.ContainerJSON, i *types.ImageInspect) {
+ c.HostConfig.Links = links
+ }
+}
+
+func WithLabels(labels map[string]string) MockContainerUpdate {
+ return func(c *types.ContainerJSON, i *types.ImageInspect) {
+ c.Config.Labels = labels
+ }
+}
+
+func WithContainerState(state types.ContainerState) MockContainerUpdate {
+ return func(cnt *types.ContainerJSON, img *types.ImageInspect) {
+ cnt.State = &state
+ }
+}
+
+func WithHealthcheck(healthConfig dockerContainer.HealthConfig) MockContainerUpdate {
+ return func(cnt *types.ContainerJSON, img *types.ImageInspect) {
+ cnt.Config.Healthcheck = &healthConfig
+ }
+}
+
+func WithImageHealthcheck(healthConfig dockerContainer.HealthConfig) MockContainerUpdate {
+ return func(cnt *types.ContainerJSON, img *types.ImageInspect) {
+ img.Config.Healthcheck = &healthConfig
+ }
+}
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..a129afe 100644
--- a/pkg/container/container_test.go
+++ b/pkg/container/container_test.go
@@ -1,120 +1,167 @@
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/containrrr/watchtower/pkg/types"
+ dc "github.com/docker/docker/api/types/container"
+ "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
- var client Client
- BeforeSuite(func() {
- server := mocks.NewMockAPIServer()
- docker, _ = cli.NewClientWithOpts(
- cli.WithHost(server.URL),
- cli.WithHTTPClient(server.Client()))
- client = dockerClient{
- api: docker,
- pullImages: false,
- }
- })
- It("should return a client for the api", func() {
- Expect(client).NotTo(BeNil())
- })
- When("listing containers without any filter", func() {
- It("should return all available containers", func() {
- containers, err := client.ListContainers(filters.NoFilter)
- Expect(err).NotTo(HaveOccurred())
- Expect(len(containers) == 2).To(BeTrue())
+ Describe("VerifyConfiguration", func() {
+ When("verifying a container with no image info", func() {
+ It("should return an error", func() {
+ c := MockContainer(WithPortBindings())
+ c.imageInfo = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorNoImageInfo))
})
})
- When("listing containers with a filter matching nothing", func() {
- It("should return an empty array", func() {
- filter := filters.FilterByNames([]string{"lollercoaster"}, filters.NoFilter)
- containers, err := client.ListContainers(filter)
- Expect(err).NotTo(HaveOccurred())
- Expect(len(containers) == 0).To(BeTrue())
+ When("verifying a container with no container info", func() {
+ It("should return an error", func() {
+ c := MockContainer(WithPortBindings())
+ c.containerInfo = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorNoContainerInfo))
})
})
- When("listing containers with a watchtower filter", func() {
- It("should return only the watchtower container", func() {
- containers, err := client.ListContainers(filters.WatchtowerContainersFilter)
- Expect(err).NotTo(HaveOccurred())
- Expect(len(containers) == 1).To(BeTrue())
- Expect(containers[0].ImageName()).To(Equal("containrrr/watchtower:latest"))
+ When("verifying a container with no config", func() {
+ It("should return an error", func() {
+ c := MockContainer(WithPortBindings())
+ c.containerInfo.Config = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorInvalidConfig))
})
})
- When(`listing containers with the "include stopped" option`, func() {
- It("should return both stopped and running containers", func() {
- client = dockerClient{
- api: docker,
- pullImages: false,
- includeStopped: true,
- }
- containers, err := client.ListContainers(filters.NoFilter)
- Expect(err).NotTo(HaveOccurred())
- Expect(len(containers) > 0).To(BeTrue())
+ When("verifying a container with no host config", func() {
+ It("should return an error", func() {
+ c := MockContainer(WithPortBindings())
+ c.containerInfo.HostConfig = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorInvalidConfig))
})
})
- When(`listing containers with the "include restart" option`, func() {
- It("should return both stopped, restarting and running containers", func() {
- client = dockerClient{
- api: docker,
- pullImages: false,
- includeRestarting: true,
- }
- containers, err := client.ListContainers(filters.NoFilter)
- Expect(err).NotTo(HaveOccurred())
- RestartingContainerFound := false
- for _, ContainerRunning := range containers {
- if ContainerRunning.containerInfo.State.Restarting {
- RestartingContainerFound = true
- }
- }
- Expect(RestartingContainerFound).To(BeTrue())
- Expect(RestartingContainerFound).NotTo(BeFalse())
+ When("verifying a container with no port bindings", func() {
+ It("should not return an error", func() {
+ c := MockContainer(WithPortBindings())
+ err := c.VerifyConfiguration()
+ Expect(err).ToNot(HaveOccurred())
})
})
- When(`listing containers without restarting ones`, func() {
- It("should not return restarting containers", func() {
- client = dockerClient{
- api: docker,
- pullImages: false,
- includeRestarting: false,
- }
- containers, err := client.ListContainers(filters.NoFilter)
- Expect(err).NotTo(HaveOccurred())
- RestartingContainerFound := false
- for _, ContainerRunning := range containers {
- if ContainerRunning.containerInfo.State.Restarting {
- RestartingContainerFound = true
- }
- }
- Expect(RestartingContainerFound).To(BeFalse())
- Expect(RestartingContainerFound).NotTo(BeTrue())
+ When("verifying a container with port bindings, but no exposed ports", func() {
+ It("should make the config compatible with updating", func() {
+ c := MockContainer(WithPortBindings("80/tcp"))
+ c.containerInfo.Config.ExposedPorts = nil
+ Expect(c.VerifyConfiguration()).To(Succeed())
+
+ Expect(c.containerInfo.Config.ExposedPorts).ToNot(BeNil())
+ Expect(c.containerInfo.Config.ExposedPorts).To(BeEmpty())
+ })
+ })
+ When("verifying a container with port bindings and exposed ports is non-nil", func() {
+ It("should return an error", func() {
+ c := MockContainer(WithPortBindings("80/tcp"))
+ c.containerInfo.Config.ExposedPorts = map[nat.Port]struct{}{"80/tcp": {}}
+ err := c.VerifyConfiguration()
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+ })
+ Describe("GetCreateConfig", func() {
+ When("container healthcheck config is equal to image config", func() {
+ It("should return empty healthcheck values", func() {
+ c := MockContainer(WithHealthcheck(dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "1s"},
+ }), WithImageHealthcheck(dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "1s"},
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{}))
+
+ c = MockContainer(WithHealthcheck(dc.HealthConfig{
+ Timeout: 30,
+ }), WithImageHealthcheck(dc.HealthConfig{
+ Timeout: 30,
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{}))
+
+ c = MockContainer(WithHealthcheck(dc.HealthConfig{
+ StartPeriod: 30,
+ }), WithImageHealthcheck(dc.HealthConfig{
+ StartPeriod: 30,
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{}))
+
+ c = MockContainer(WithHealthcheck(dc.HealthConfig{
+ Retries: 30,
+ }), WithImageHealthcheck(dc.HealthConfig{
+ Retries: 30,
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{}))
+ })
+ })
+ When("container healthcheck config is different to image config", func() {
+ It("should return the container healthcheck values", func() {
+ c := MockContainer(WithHealthcheck(dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "1s"},
+ Interval: 30,
+ Timeout: 30,
+ StartPeriod: 10,
+ Retries: 2,
+ }), WithImageHealthcheck(dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "10s"},
+ Interval: 10,
+ Timeout: 60,
+ StartPeriod: 30,
+ Retries: 10,
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "1s"},
+ Interval: 30,
+ Timeout: 30,
+ StartPeriod: 10,
+ Retries: 2,
+ }))
+ })
+ })
+ When("container healthcheck config is empty", func() {
+ It("should not panic", func() {
+ c := MockContainer(WithImageHealthcheck(dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "10s"},
+ Interval: 10,
+ Timeout: 60,
+ StartPeriod: 30,
+ Retries: 10,
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(BeNil())
+ })
+ })
+ When("container image healthcheck config is empty", func() {
+ It("should not panic", func() {
+ c := MockContainer(WithHealthcheck(dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "1s"},
+ Interval: 30,
+ Timeout: 30,
+ StartPeriod: 10,
+ Retries: 2,
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "1s"},
+ Interval: 30,
+ Timeout: 30,
+ StartPeriod: 10,
+ Retries: 2,
+ }))
})
})
})
When("asked for metadata", func() {
var c *Container
BeforeEach(func() {
- c = mockContainerWithLabels(map[string]string{
+ c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.enable": "true",
"com.centurylinklabs.watchtower": "true",
- })
+ }))
})
It("should return its name on calls to .Name()", func() {
name := c.Name()
@@ -124,43 +171,35 @@ var _ = Describe("the container", func() {
It("should return its ID on calls to .ID()", func() {
id := c.ID()
- Expect(id).To(Equal("container_id"))
- Expect(id).NotTo(Equal("wrong-id"))
+ Expect(id).To(BeEquivalentTo("container_id"))
+ Expect(id).NotTo(BeEquivalentTo("wrong-id"))
})
It("should return true, true if enabled on calls to .Enabled()", func() {
enabled, exists := c.Enabled()
Expect(enabled).To(BeTrue())
- Expect(enabled).NotTo(BeFalse())
Expect(exists).To(BeTrue())
- Expect(exists).NotTo(BeFalse())
})
It("should return false, true if present but not true on calls to .Enabled()", func() {
- c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "false"})
+ c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "false"}))
enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse())
- Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeTrue())
- Expect(exists).NotTo(BeFalse())
})
It("should return false, false if not present on calls to .Enabled()", func() {
- c = mockContainerWithLabels(map[string]string{"lol": "false"})
+ c = MockContainer(WithLabels(map[string]string{"lol": "false"}))
enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse())
- Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeFalse())
- Expect(exists).NotTo(BeTrue())
})
It("should return false, false if present but not parsable .Enabled()", func() {
- c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "falsy"})
+ c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "falsy"}))
enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse())
- Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeFalse())
- Expect(exists).NotTo(BeTrue())
})
When("checking if its a watchtower instance", func() {
It("should return true if the label is set to true", func() {
@@ -168,31 +207,31 @@ var _ = Describe("the container", func() {
Expect(isWatchtower).To(BeTrue())
})
It("should return false if the label is present but set to false", func() {
- c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower": "false"})
+ c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower": "false"}))
isWatchtower := c.IsWatchtower()
Expect(isWatchtower).To(BeFalse())
})
It("should return false if the label is not present", func() {
- c = mockContainerWithLabels(map[string]string{"funny.label": "false"})
+ c = MockContainer(WithLabels(map[string]string{"funny.label": "false"}))
isWatchtower := c.IsWatchtower()
Expect(isWatchtower).To(BeFalse())
})
It("should return false if there are no labels", func() {
- c = mockContainerWithLabels(map[string]string{})
+ c = MockContainer(WithLabels(map[string]string{}))
isWatchtower := c.IsWatchtower()
Expect(isWatchtower).To(BeFalse())
})
})
When("fetching the custom stop signal", func() {
It("should return the signal if its set", func() {
- c = mockContainerWithLabels(map[string]string{
+ c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.stop-signal": "SIGKILL",
- })
+ }))
stopSignal := c.StopSignal()
Expect(stopSignal).To(Equal("SIGKILL"))
})
It("should return an empty string if its not set", func() {
- c = mockContainerWithLabels(map[string]string{})
+ c = MockContainer(WithLabels(map[string]string{}))
stopSignal := c.StopSignal()
Expect(stopSignal).To(Equal(""))
})
@@ -200,22 +239,22 @@ var _ = Describe("the container", func() {
When("fetching the image name", func() {
When("the zodiac label is present", func() {
It("should fetch the image name from it", func() {
- c = mockContainerWithLabels(map[string]string{
+ c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.zodiac.original-image": "the-original-image",
- })
+ }))
imageName := c.ImageName()
Expect(imageName).To(Equal(imageName))
})
})
It("should return the image name", func() {
name := "image-name:3"
- c = mockContainerWithImageName(name)
+ c = MockContainer(WithImageName(name))
imageName := c.ImageName()
Expect(imageName).To(Equal(name))
})
It("should assume latest if no tag is supplied", func() {
name := "image-name"
- c = mockContainerWithImageName(name)
+ c = MockContainer(WithImageName(name))
imageName := c.ImageName()
Expect(imageName).To(Equal(name + ":latest"))
})
@@ -224,74 +263,129 @@ var _ = Describe("the container", func() {
When("fetching container links", func() {
When("the depends on label is present", func() {
It("should fetch depending containers from it", func() {
- c = mockContainerWithLabels(map[string]string{
+ c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.depends-on": "postgres",
- })
+ }))
links := c.Links()
- Expect(links).To(SatisfyAll(ContainElement("postgres"), HaveLen(1)))
+ Expect(links).To(SatisfyAll(ContainElement("/postgres"), HaveLen(1)))
})
It("should fetch depending containers if there are many", func() {
- c = mockContainerWithLabels(map[string]string{
+ c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.depends-on": "postgres,redis",
- })
+ }))
links := c.Links()
- Expect(links).To(SatisfyAll(ContainElement("postgres"), ContainElement("redis"), HaveLen(2)))
+ Expect(links).To(SatisfyAll(ContainElement("/postgres"), ContainElement("/redis"), HaveLen(2)))
+ })
+ It("should only add slashes to names when they are missing", func() {
+ c = MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.depends-on": "/postgres,redis",
+ }))
+ links := c.Links()
+ Expect(links).To(SatisfyAll(ContainElement("/postgres"), ContainElement("/redis")))
})
It("should fetch depending containers if label is blank", func() {
- c = mockContainerWithLabels(map[string]string{
+ c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.depends-on": "",
- })
+ }))
links := c.Links()
Expect(links).To(HaveLen(0))
})
})
When("the depends on label is not present", func() {
It("should fetch depending containers from host config links", func() {
- c = mockContainerWithLinks([]string{
+ c = MockContainer(WithLinks([]string{
"redis:test-containrrr",
"postgres:test-containrrr",
- })
+ }))
links := c.Links()
Expect(links).To(SatisfyAll(ContainElement("redis"), ContainElement("postgres"), HaveLen(2)))
})
})
})
+
+ When("checking no-pull label", func() {
+ When("no-pull argument is not set", func() {
+ When("no-pull label is true", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "true",
+ }))
+ It("should return true", func() {
+ Expect(c.IsNoPull(types.UpdateParams{})).To(Equal(true))
+ })
+ })
+ When("no-pull label is false", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "false",
+ }))
+ It("should return false", func() {
+ Expect(c.IsNoPull(types.UpdateParams{})).To(Equal(false))
+ })
+ })
+ When("no-pull label is set to an invalid value", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "maybe",
+ }))
+ It("should return false", func() {
+ Expect(c.IsNoPull(types.UpdateParams{})).To(Equal(false))
+ })
+ })
+ When("no-pull label is unset", func() {
+ c = MockContainer(WithLabels(map[string]string{}))
+ It("should return false", func() {
+ Expect(c.IsNoPull(types.UpdateParams{})).To(Equal(false))
+ })
+ })
+ })
+ When("no-pull argument is set to true", func() {
+ When("no-pull label is true", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "true",
+ }))
+ It("should return true", func() {
+ Expect(c.IsNoPull(types.UpdateParams{NoPull: true})).To(Equal(true))
+ })
+ })
+ When("no-pull label is false", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "false",
+ }))
+ It("should return true", func() {
+ Expect(c.IsNoPull(types.UpdateParams{NoPull: true})).To(Equal(true))
+ })
+ })
+ When("label-take-precedence argument is set to true", func() {
+ When("no-pull label is true", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "true",
+ }))
+ It("should return true", func() {
+ Expect(c.IsNoPull(types.UpdateParams{LabelPrecedence: true, NoPull: true})).To(Equal(true))
+ })
+ })
+ When("no-pull label is false", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "false",
+ }))
+ It("should return false", func() {
+ Expect(c.IsNoPull(types.UpdateParams{LabelPrecedence: true, NoPull: true})).To(Equal(false))
+ })
+ })
+ })
+ })
+ })
+
+ When("there is a pre or post update timeout", func() {
+ It("should return minute values", func() {
+ c = MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "3",
+ "com.centurylinklabs.watchtower.lifecycle.post-update-timeout": "5",
+ }))
+ preTimeout := c.PreUpdateTimeout()
+ Expect(preTimeout).To(Equal(3))
+ postTimeout := c.PostUpdateTimeout()
+ Expect(postTimeout).To(Equal(5))
+ })
+ })
+
})
})
-
-func mockContainerWithImageName(name string) *Container {
- container := mockContainerWithLabels(nil)
- container.containerInfo.Config.Image = name
- return container
-}
-
-func mockContainerWithLinks(links []string) *Container {
- content := types.ContainerJSON{
- ContainerJSONBase: &types.ContainerJSONBase{
- ID: "container_id",
- Image: "image",
- Name: "test-containrrr",
- HostConfig: &container.HostConfig{
- Links: links,
- },
- },
- Config: &container.Config{
- Labels: map[string]string{},
- },
- }
- return NewContainer(&content, nil)
-}
-
-func mockContainerWithLabels(labels map[string]string) *Container {
- content := types.ContainerJSON{
- ContainerJSONBase: &types.ContainerJSONBase{
- ID: "container_id",
- Image: "image",
- Name: "test-containrrr",
- },
- Config: &container.Config{
- Labels: labels,
- },
- }
- return NewContainer(&content, nil)
-}
diff --git a/pkg/container/errors.go b/pkg/container/errors.go
new file mode 100644
index 0000000..05dc722
--- /dev/null
+++ b/pkg/container/errors.go
@@ -0,0 +1,8 @@
+package container
+
+import "errors"
+
+var errorNoImageInfo = errors.New("no available image info")
+var errorNoContainerInfo = errors.New("no available container info")
+var errorInvalidConfig = errors.New("container configuration missing or invalid")
+var errorLabelNotFound = errors.New("label was not found in container")
diff --git a/pkg/container/metadata.go b/pkg/container/metadata.go
index 215cccb..8ac5f34 100644
--- a/pkg/container/metadata.go
+++ b/pkg/container/metadata.go
@@ -1,18 +1,22 @@
package container
+import "strconv"
+
const (
- watchtowerLabel = "com.centurylinklabs.watchtower"
- signalLabel = "com.centurylinklabs.watchtower.stop-signal"
- enableLabel = "com.centurylinklabs.watchtower.enable"
- monitorOnlyLabel = "com.centurylinklabs.watchtower.monitor-only"
- dependsOnLabel = "com.centurylinklabs.watchtower.depends-on"
- zodiacLabel = "com.centurylinklabs.zodiac.original-image"
- scope = "com.centurylinklabs.watchtower.scope"
- preCheckLabel = "com.centurylinklabs.watchtower.lifecycle.pre-check"
- postCheckLabel = "com.centurylinklabs.watchtower.lifecycle.post-check"
- preUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.pre-update"
- postUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.post-update"
- preUpdateTimeoutLabel = "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout"
+ watchtowerLabel = "com.centurylinklabs.watchtower"
+ signalLabel = "com.centurylinklabs.watchtower.stop-signal"
+ enableLabel = "com.centurylinklabs.watchtower.enable"
+ monitorOnlyLabel = "com.centurylinklabs.watchtower.monitor-only"
+ noPullLabel = "com.centurylinklabs.watchtower.no-pull"
+ dependsOnLabel = "com.centurylinklabs.watchtower.depends-on"
+ zodiacLabel = "com.centurylinklabs.zodiac.original-image"
+ scope = "com.centurylinklabs.watchtower.scope"
+ preCheckLabel = "com.centurylinklabs.watchtower.lifecycle.pre-check"
+ postCheckLabel = "com.centurylinklabs.watchtower.lifecycle.post-check"
+ preUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.pre-update"
+ postUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.post-update"
+ preUpdateTimeoutLabel = "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout"
+ postUpdateTimeoutLabel = "com.centurylinklabs.watchtower.lifecycle.post-update-timeout"
)
// GetLifecyclePreCheckCommand returns the pre-check command set in the container metadata or an empty string
@@ -53,3 +57,11 @@ func (c Container) getLabelValue(label string) (string, bool) {
val, ok := c.containerInfo.Config.Labels[label]
return val, ok
}
+
+func (c Container) getBoolLabelValue(label string) (bool, error) {
+ if strVal, ok := c.containerInfo.Config.Labels[label]; ok {
+ value, err := strconv.ParseBool(strVal)
+ return value, err
+ }
+ return false, errorLabelNotFound
+}
diff --git a/pkg/container/mocks/ApiServer.go b/pkg/container/mocks/ApiServer.go
index 35b52e2..84756f0 100644
--- a/pkg/container/mocks/ApiServer.go
+++ b/pkg/container/mocks/ApiServer.go
@@ -3,75 +3,278 @@ package mocks
import (
"encoding/json"
"fmt"
- "io/ioutil"
+ "github.com/onsi/ginkgo"
"net/http"
- "net/http/httptest"
+ "net/url"
+ "os"
"path/filepath"
"strings"
+ t "github.com/containrrr/watchtower/pkg/types"
+
"github.com/docker/docker/api/types"
- "github.com/sirupsen/logrus"
+ "github.com/docker/docker/api/types/filters"
+ O "github.com/onsi/gomega"
+ "github.com/onsi/gomega/ghttp"
)
-// NewMockAPIServer returns a mocked docker api server that responds to some fixed requests
-// used in the test suite.
-func NewMockAPIServer() *httptest.Server {
- return httptest.NewServer(http.HandlerFunc(
- func(w http.ResponseWriter, r *http.Request) {
- logrus.Debug("Mock server has received a HTTP call on ", r.URL)
- var response = ""
-
- if isRequestFor("filters=", r) {
-
- Filters := r.URL.Query().Get("filters")
- var result map[string]interface{}
- json.Unmarshal([]byte(Filters), &result)
- status := result["status"].(map[string]interface{})
-
- response = getMockJSONFromDisk("./mocks/data/containers.json")
- var x2 []types.Container
- var containers []types.Container
- json.Unmarshal([]byte(response), &containers)
- for _, v := range containers {
- for key := range status {
- if v.State == key {
- x2 = append(x2, v)
- }
- }
- }
-
- b, _ := json.Marshal(x2)
- response = string(b)
-
- } else if isRequestFor("containers/json?limit=0", r) {
- response = getMockJSONFromDisk("./mocks/data/containers.json")
- } else if isRequestFor("ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65", r) {
- response = getMockJSONFromDisk("./mocks/data/container_stopped.json")
- } else if isRequestFor("b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008", r) {
- response = getMockJSONFromDisk("./mocks/data/container_running.json")
- } else if isRequestFor("ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67", r) {
- response = getMockJSONFromDisk("./mocks/data/container_restarting.json")
- } else if isRequestFor("sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd", r) {
- response = getMockJSONFromDisk("./mocks/data/image01.json")
- } else if isRequestFor("sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa", r) {
- response = getMockJSONFromDisk("./mocks/data/image02.json")
- }
- fmt.Fprintln(w, response)
- },
- ))
-}
-
-func isRequestFor(urlPart string, r *http.Request) bool {
- return strings.Contains(r.URL.String(), urlPart)
-}
-
-func getMockJSONFromDisk(relPath string) string {
+func getMockJSONFile(relPath string) ([]byte, error) {
absPath, _ := filepath.Abs(relPath)
- logrus.Error(absPath)
- buf, err := ioutil.ReadFile(absPath)
+ buf, err := os.ReadFile(absPath)
if err != nil {
- logrus.Error(err)
- return ""
+ return nil, fmt.Errorf("mock JSON file %q not found: %e", absPath, err)
}
- return string(buf)
+ return buf, nil
+}
+
+// RespondWithJSONFile handles a request by returning the contents of the supplied file
+func RespondWithJSONFile(relPath string, statusCode int, optionalHeader ...http.Header) http.HandlerFunc {
+ handler, err := respondWithJSONFile(relPath, statusCode, optionalHeader...)
+ O.ExpectWithOffset(1, err).ShouldNot(O.HaveOccurred())
+ return handler
+}
+
+func respondWithJSONFile(relPath string, statusCode int, optionalHeader ...http.Header) (http.HandlerFunc, error) {
+ buf, err := getMockJSONFile(relPath)
+ if err != nil {
+ return nil, err
+ }
+ return ghttp.RespondWith(statusCode, buf, optionalHeader...), nil
+}
+
+// GetContainerHandlers returns the handlers serving lookups for the supplied container mock files
+func GetContainerHandlers(containerRefs ...*ContainerRef) []http.HandlerFunc {
+ handlers := make([]http.HandlerFunc, 0, len(containerRefs)*3)
+ for _, containerRef := range containerRefs {
+ handlers = append(handlers, getContainerFileHandler(containerRef))
+
+ // Also append any containers that the container references, if any
+ for _, ref := range containerRef.references {
+ handlers = append(handlers, getContainerFileHandler(ref))
+ }
+
+ // Also append the image request since that will be called for every container
+ handlers = append(handlers, getImageHandler(containerRef.image.id,
+ RespondWithJSONFile(containerRef.image.getFileName(), http.StatusOK),
+ ))
+ }
+
+ return handlers
+}
+
+func createFilterArgs(statuses []string) filters.Args {
+ args := filters.NewArgs()
+ for _, status := range statuses {
+ args.Add("status", status)
+ }
+ return args
+}
+
+var defaultImage = imageRef{
+ // watchtower
+ id: t.ImageID("sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa"),
+ file: "default",
+}
+
+var Watchtower = ContainerRef{
+ name: "watchtower",
+ id: "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134",
+ image: &defaultImage,
+}
+var Stopped = ContainerRef{
+ name: "stopped",
+ id: "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65",
+ image: &defaultImage,
+}
+var Running = ContainerRef{
+ name: "running",
+ id: "b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008",
+ image: &imageRef{
+ // portainer
+ id: t.ImageID("sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd"),
+ file: "running",
+ },
+}
+var Restarting = ContainerRef{
+ name: "restarting",
+ id: "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67",
+ image: &defaultImage,
+}
+
+var netSupplierOK = ContainerRef{
+ id: "25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2",
+ name: "net_supplier",
+ image: &imageRef{
+ // gluetun
+ id: t.ImageID("sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51"),
+ file: "net_producer",
+ },
+}
+var netSupplierNotFound = ContainerRef{
+ id: NetSupplierNotFoundID,
+ name: netSupplierOK.name,
+ isMissing: true,
+}
+
+// NetConsumerOK is used for testing `container` networking mode
+// returns a container that consumes an existing supplier container
+var NetConsumerOK = ContainerRef{
+ id: "1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6",
+ name: "net_consumer",
+ image: &imageRef{
+ id: t.ImageID("sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8"), // nginx
+ file: "net_consumer",
+ },
+ references: []*ContainerRef{&netSupplierOK},
+}
+
+// NetConsumerInvalidSupplier is used for testing `container` networking mode
+// returns a container that references a supplying container that does not exist
+var NetConsumerInvalidSupplier = ContainerRef{
+ id: NetConsumerOK.id,
+ name: "net_consumer-missing_supplier",
+ image: NetConsumerOK.image,
+ references: []*ContainerRef{&netSupplierNotFound},
+}
+
+const NetSupplierNotFoundID = "badc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc"
+const NetSupplierContainerName = "/wt-contnet-producer-1"
+
+func getContainerFileHandler(cr *ContainerRef) http.HandlerFunc {
+
+ if cr.isMissing {
+ return containerNotFoundResponse(string(cr.id))
+ }
+
+ containerFile, err := cr.getContainerFile()
+ if err != nil {
+ ginkgo.Fail(fmt.Sprintf("Failed to get container mock file: %v", err))
+ }
+
+ return getContainerHandler(
+ string(cr.id),
+ RespondWithJSONFile(containerFile, http.StatusOK),
+ )
+}
+
+func getContainerHandler(containerId string, responseHandler http.HandlerFunc) http.HandlerFunc {
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("GET", O.HaveSuffix("/containers/%v/json", containerId)),
+ responseHandler,
+ )
+}
+
+// GetContainerHandler mocks the GET containers/{id}/json endpoint
+func GetContainerHandler(containerID string, containerInfo *types.ContainerJSON) http.HandlerFunc {
+ responseHandler := containerNotFoundResponse(containerID)
+ if containerInfo != nil {
+ responseHandler = ghttp.RespondWithJSONEncoded(http.StatusOK, containerInfo)
+ }
+ return getContainerHandler(containerID, responseHandler)
+}
+
+// GetImageHandler mocks the GET images/{id}/json endpoint
+func GetImageHandler(imageInfo *types.ImageInspect) http.HandlerFunc {
+ return getImageHandler(t.ImageID(imageInfo.ID), ghttp.RespondWithJSONEncoded(http.StatusOK, imageInfo))
+}
+
+// ListContainersHandler mocks the GET containers/json endpoint, filtering the returned containers based on statuses
+func ListContainersHandler(statuses ...string) http.HandlerFunc {
+ filterArgs := createFilterArgs(statuses)
+ bytes, err := filterArgs.MarshalJSON()
+ O.ExpectWithOffset(1, err).ShouldNot(O.HaveOccurred())
+ query := url.Values{
+ "filters": []string{string(bytes)},
+ }
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("GET", O.HaveSuffix("containers/json"), query.Encode()),
+ respondWithFilteredContainers(filterArgs),
+ )
+}
+
+func respondWithFilteredContainers(filters filters.Args) http.HandlerFunc {
+ containersJSON, err := getMockJSONFile("./mocks/data/containers.json")
+ O.ExpectWithOffset(2, err).ShouldNot(O.HaveOccurred())
+ var filteredContainers []types.Container
+ var containers []types.Container
+ O.ExpectWithOffset(2, json.Unmarshal(containersJSON, &containers)).To(O.Succeed())
+ for _, v := range containers {
+ for _, key := range filters.Get("status") {
+ if v.State == key {
+ filteredContainers = append(filteredContainers, v)
+ }
+ }
+ }
+
+ return ghttp.RespondWithJSONEncoded(http.StatusOK, filteredContainers)
+}
+
+func getImageHandler(imageId t.ImageID, responseHandler http.HandlerFunc) http.HandlerFunc {
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("GET", O.HaveSuffix("/images/%s/json", imageId)),
+ responseHandler,
+ )
+}
+
+// KillContainerHandler mocks the POST containers/{id}/kill endpoint
+func KillContainerHandler(containerID string, found FoundStatus) http.HandlerFunc {
+ responseHandler := noContentStatusResponse
+ if !found {
+ responseHandler = containerNotFoundResponse(containerID)
+ }
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("POST", O.HaveSuffix("containers/%s/kill", containerID)),
+ responseHandler,
+ )
+}
+
+// RemoveContainerHandler mocks the DELETE containers/{id} endpoint
+func RemoveContainerHandler(containerID string, found FoundStatus) http.HandlerFunc {
+ responseHandler := noContentStatusResponse
+ if !found {
+ responseHandler = containerNotFoundResponse(containerID)
+ }
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("DELETE", O.HaveSuffix("containers/%s", containerID)),
+ responseHandler,
+ )
+}
+
+func containerNotFoundResponse(containerID string) http.HandlerFunc {
+ return ghttp.RespondWithJSONEncoded(http.StatusNotFound, struct{ message string }{message: "No such container: " + string(containerID)})
+}
+
+var noContentStatusResponse = ghttp.RespondWith(http.StatusNoContent, nil)
+
+type FoundStatus bool
+
+const (
+ Found FoundStatus = true
+ Missing FoundStatus = false
+)
+
+// RemoveImageHandler mocks the DELETE images/ID endpoint, simulating removal of the given imagesWithParents
+func RemoveImageHandler(imagesWithParents map[string][]string) http.HandlerFunc {
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("DELETE", O.MatchRegexp("/images/.*")),
+ func(w http.ResponseWriter, r *http.Request) {
+ parts := strings.Split(r.URL.Path, `/`)
+ image := parts[len(parts)-1]
+
+ if parents, found := imagesWithParents[image]; found {
+ items := []types.ImageDeleteResponseItem{
+ {Untagged: image},
+ {Deleted: image},
+ }
+ for _, parent := range parents {
+ items = append(items, types.ImageDeleteResponseItem{Deleted: parent})
+ }
+ ghttp.RespondWithJSONEncoded(http.StatusOK, items)(w, r)
+ } else {
+ ghttp.RespondWithJSONEncoded(http.StatusNotFound, struct{ message string }{
+ message: "Something went wrong.",
+ })(w, r)
+ }
+ },
+ )
}
diff --git a/pkg/container/mocks/FilterableContainer.go b/pkg/container/mocks/FilterableContainer.go
index 1ae8125..fa863b5 100644
--- a/pkg/container/mocks/FilterableContainer.go
+++ b/pkg/container/mocks/FilterableContainer.go
@@ -78,3 +78,17 @@ func (_m *FilterableContainer) Scope() (string, bool) {
return r0, r1
}
+
+// ImageName provides a mock function with given fields:
+func (_m *FilterableContainer) ImageName() string {
+ ret := _m.Called()
+
+ var r0 string
+ if rf, ok := ret.Get(0).(func() string); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(string)
+ }
+
+ return r0
+}
diff --git a/pkg/container/mocks/container_ref.go b/pkg/container/mocks/container_ref.go
new file mode 100644
index 0000000..c46eb93
--- /dev/null
+++ b/pkg/container/mocks/container_ref.go
@@ -0,0 +1,42 @@
+package mocks
+
+import (
+ "fmt"
+ "os"
+
+ t "github.com/containrrr/watchtower/pkg/types"
+)
+
+type imageRef struct {
+ id t.ImageID
+ file string
+}
+
+func (ir *imageRef) getFileName() string {
+ return fmt.Sprintf("./mocks/data/image_%v.json", ir.file)
+}
+
+type ContainerRef struct {
+ name string
+ id t.ContainerID
+ image *imageRef
+ file string
+ references []*ContainerRef
+ isMissing bool
+}
+
+func (cr *ContainerRef) getContainerFile() (containerFile string, err error) {
+ file := cr.file
+ if file == "" {
+ file = cr.name
+ }
+
+ containerFile = fmt.Sprintf("./mocks/data/container_%v.json", file)
+ _, err = os.Stat(containerFile)
+
+ return containerFile, err
+}
+
+func (cr *ContainerRef) ContainerID() t.ContainerID {
+ return cr.id
+}
diff --git a/pkg/container/mocks/data/container_net_consumer-missing_supplier.json b/pkg/container/mocks/data/container_net_consumer-missing_supplier.json
new file mode 100644
index 0000000..c1a233b
--- /dev/null
+++ b/pkg/container/mocks/data/container_net_consumer-missing_supplier.json
@@ -0,0 +1,205 @@
+{
+ "Id": "1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6",
+ "Created": "2023-07-25T14:55:14.69155887Z",
+ "Path": "/docker-entrypoint.sh",
+ "Args": [
+ "nginx",
+ "-g",
+ "daemon off;"
+ ],
+ "State": {
+ "Status": "running",
+ "Running": true,
+ "Paused": false,
+ "Restarting": false,
+ "OOMKilled": false,
+ "Dead": false,
+ "Pid": 3743,
+ "ExitCode": 0,
+ "Error": "",
+ "StartedAt": "2023-07-25T14:55:15.299654437Z",
+ "FinishedAt": "0001-01-01T00:00:00Z"
+ },
+ "Image": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8",
+ "ResolvConfPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/resolv.conf",
+ "HostnamePath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hostname",
+ "HostsPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hosts",
+ "LogPath": "/var/lib/docker/containers/1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6/1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6-json.log",
+ "Name": "/wt-contnet-consumer-1",
+ "RestartCount": 0,
+ "Driver": "overlay2",
+ "Platform": "linux",
+ "MountLabel": "",
+ "ProcessLabel": "",
+ "AppArmorProfile": "",
+ "ExecIDs": null,
+ "HostConfig": {
+ "Binds": null,
+ "ContainerIDFile": "",
+ "LogConfig": {
+ "Type": "json-file",
+ "Config": {}
+ },
+ "NetworkMode": "container:badc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc",
+ "PortBindings": {},
+ "RestartPolicy": {
+ "Name": "",
+ "MaximumRetryCount": 0
+ },
+ "AutoRemove": false,
+ "VolumeDriver": "",
+ "VolumesFrom": null,
+ "ConsoleSize": [
+ 0,
+ 0
+ ],
+ "CapAdd": null,
+ "CapDrop": null,
+ "CgroupnsMode": "host",
+ "Dns": null,
+ "DnsOptions": null,
+ "DnsSearch": null,
+ "ExtraHosts": [],
+ "GroupAdd": null,
+ "IpcMode": "private",
+ "Cgroup": "",
+ "Links": null,
+ "OomScoreAdj": 0,
+ "PidMode": "",
+ "Privileged": false,
+ "PublishAllPorts": false,
+ "ReadonlyRootfs": false,
+ "SecurityOpt": null,
+ "UTSMode": "",
+ "UsernsMode": "",
+ "ShmSize": 67108864,
+ "Runtime": "runc",
+ "Isolation": "",
+ "CpuShares": 0,
+ "Memory": 0,
+ "NanoCpus": 0,
+ "CgroupParent": "",
+ "BlkioWeight": 0,
+ "BlkioWeightDevice": null,
+ "BlkioDeviceReadBps": null,
+ "BlkioDeviceWriteBps": null,
+ "BlkioDeviceReadIOps": null,
+ "BlkioDeviceWriteIOps": null,
+ "CpuPeriod": 0,
+ "CpuQuota": 0,
+ "CpuRealtimePeriod": 0,
+ "CpuRealtimeRuntime": 0,
+ "CpusetCpus": "",
+ "CpusetMems": "",
+ "Devices": null,
+ "DeviceCgroupRules": null,
+ "DeviceRequests": null,
+ "MemoryReservation": 0,
+ "MemorySwap": 0,
+ "MemorySwappiness": null,
+ "OomKillDisable": false,
+ "PidsLimit": null,
+ "Ulimits": null,
+ "CpuCount": 0,
+ "CpuPercent": 0,
+ "IOMaximumIOps": 0,
+ "IOMaximumBandwidth": 0,
+ "MaskedPaths": [
+ "/proc/asound",
+ "/proc/acpi",
+ "/proc/kcore",
+ "/proc/keys",
+ "/proc/latency_stats",
+ "/proc/timer_list",
+ "/proc/timer_stats",
+ "/proc/sched_debug",
+ "/proc/scsi",
+ "/sys/firmware"
+ ],
+ "ReadonlyPaths": [
+ "/proc/bus",
+ "/proc/fs",
+ "/proc/irq",
+ "/proc/sys",
+ "/proc/sysrq-trigger"
+ ]
+ },
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2-init/diff:/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/diff:/var/lib/docker/overlay2/09785ba17f27c783ef8b44f369f9aac0ca936000b57abf22b3c54d1e6eb8e27b/diff:/var/lib/docker/overlay2/6f8acd64ae44fd4d14bcb90c105eceba46854aa3985b5b6b317bcc5692cfc286/diff:/var/lib/docker/overlay2/73d41c15edb21c5f12cf53e313f48b5da55283aafc77d35b7bc662241879d7e7/diff:/var/lib/docker/overlay2/d97b55f3d966ae031492369a98e9e00d2bd31e520290fe2034e0a2b1ed77c91e/diff:/var/lib/docker/overlay2/053e9ca65c6b64cb9d98a812ff7488c7e77938b4fb8e0c4d2ad7f8ec235f0f20/diff",
+ "MergedDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/merged",
+ "UpperDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/diff",
+ "WorkDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/work"
+ },
+ "Name": "overlay2"
+ },
+ "Mounts": [],
+ "Config": {
+ "Hostname": "25e75393800b",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": true,
+ "AttachStderr": true,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "NGINX_VERSION=1.23.3",
+ "NJS_VERSION=0.7.9",
+ "PKG_RELEASE=1~bullseye"
+ ],
+ "Cmd": [
+ "nginx",
+ "-g",
+ "daemon off;"
+ ],
+ "Image": "nginx",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/docker-entrypoint.sh"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "com.docker.compose.config-hash": "8bb0e1c8c61f6d495840ba9133ebfb1e4ffda3e1adb701a011b03951848bb9fa",
+ "com.docker.compose.container-number": "1",
+ "com.docker.compose.depends_on": "producer:service_started:false",
+ "com.docker.compose.image": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8",
+ "com.docker.compose.oneoff": "False",
+ "com.docker.compose.project": "wt-contnet",
+ "com.docker.compose.project.config_files": "/tmp/wt-contnet/docker-compose.yaml",
+ "com.docker.compose.project.working_dir": "/tmp/wt-contnet",
+ "com.docker.compose.replace": "07bb70608f96f577aa02b9f317500e23e691c94eb099f6fb52301dfb031d0668",
+ "com.docker.compose.service": "consumer",
+ "com.docker.compose.version": "2.19.1",
+ "desktop.docker.io/wsl-distro": "Ubuntu",
+ "maintainer": "NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e"
+ },
+ "StopSignal": "SIGQUIT"
+ },
+ "NetworkSettings": {
+ "Bridge": "",
+ "SandboxID": "",
+ "HairpinMode": false,
+ "LinkLocalIPv6Address": "",
+ "LinkLocalIPv6PrefixLen": 0,
+ "Ports": {},
+ "SandboxKey": "",
+ "SecondaryIPAddresses": null,
+ "SecondaryIPv6Addresses": null,
+ "EndpointID": "",
+ "Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "MacAddress": "",
+ "Networks": {}
+ }
+}
diff --git a/pkg/container/mocks/data/container_net_consumer.json b/pkg/container/mocks/data/container_net_consumer.json
new file mode 100644
index 0000000..2e64f89
--- /dev/null
+++ b/pkg/container/mocks/data/container_net_consumer.json
@@ -0,0 +1,205 @@
+{
+ "Id": "1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6",
+ "Created": "2023-07-25T14:55:14.69155887Z",
+ "Path": "/docker-entrypoint.sh",
+ "Args": [
+ "nginx",
+ "-g",
+ "daemon off;"
+ ],
+ "State": {
+ "Status": "running",
+ "Running": true,
+ "Paused": false,
+ "Restarting": false,
+ "OOMKilled": false,
+ "Dead": false,
+ "Pid": 3743,
+ "ExitCode": 0,
+ "Error": "",
+ "StartedAt": "2023-07-25T14:55:15.299654437Z",
+ "FinishedAt": "0001-01-01T00:00:00Z"
+ },
+ "Image": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8",
+ "ResolvConfPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/resolv.conf",
+ "HostnamePath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hostname",
+ "HostsPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hosts",
+ "LogPath": "/var/lib/docker/containers/1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6/1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6-json.log",
+ "Name": "/wt-contnet-consumer-1",
+ "RestartCount": 0,
+ "Driver": "overlay2",
+ "Platform": "linux",
+ "MountLabel": "",
+ "ProcessLabel": "",
+ "AppArmorProfile": "",
+ "ExecIDs": null,
+ "HostConfig": {
+ "Binds": null,
+ "ContainerIDFile": "",
+ "LogConfig": {
+ "Type": "json-file",
+ "Config": {}
+ },
+ "NetworkMode": "container:25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2",
+ "PortBindings": {},
+ "RestartPolicy": {
+ "Name": "",
+ "MaximumRetryCount": 0
+ },
+ "AutoRemove": false,
+ "VolumeDriver": "",
+ "VolumesFrom": null,
+ "ConsoleSize": [
+ 0,
+ 0
+ ],
+ "CapAdd": null,
+ "CapDrop": null,
+ "CgroupnsMode": "host",
+ "Dns": null,
+ "DnsOptions": null,
+ "DnsSearch": null,
+ "ExtraHosts": [],
+ "GroupAdd": null,
+ "IpcMode": "private",
+ "Cgroup": "",
+ "Links": null,
+ "OomScoreAdj": 0,
+ "PidMode": "",
+ "Privileged": false,
+ "PublishAllPorts": false,
+ "ReadonlyRootfs": false,
+ "SecurityOpt": null,
+ "UTSMode": "",
+ "UsernsMode": "",
+ "ShmSize": 67108864,
+ "Runtime": "runc",
+ "Isolation": "",
+ "CpuShares": 0,
+ "Memory": 0,
+ "NanoCpus": 0,
+ "CgroupParent": "",
+ "BlkioWeight": 0,
+ "BlkioWeightDevice": null,
+ "BlkioDeviceReadBps": null,
+ "BlkioDeviceWriteBps": null,
+ "BlkioDeviceReadIOps": null,
+ "BlkioDeviceWriteIOps": null,
+ "CpuPeriod": 0,
+ "CpuQuota": 0,
+ "CpuRealtimePeriod": 0,
+ "CpuRealtimeRuntime": 0,
+ "CpusetCpus": "",
+ "CpusetMems": "",
+ "Devices": null,
+ "DeviceCgroupRules": null,
+ "DeviceRequests": null,
+ "MemoryReservation": 0,
+ "MemorySwap": 0,
+ "MemorySwappiness": null,
+ "OomKillDisable": false,
+ "PidsLimit": null,
+ "Ulimits": null,
+ "CpuCount": 0,
+ "CpuPercent": 0,
+ "IOMaximumIOps": 0,
+ "IOMaximumBandwidth": 0,
+ "MaskedPaths": [
+ "/proc/asound",
+ "/proc/acpi",
+ "/proc/kcore",
+ "/proc/keys",
+ "/proc/latency_stats",
+ "/proc/timer_list",
+ "/proc/timer_stats",
+ "/proc/sched_debug",
+ "/proc/scsi",
+ "/sys/firmware"
+ ],
+ "ReadonlyPaths": [
+ "/proc/bus",
+ "/proc/fs",
+ "/proc/irq",
+ "/proc/sys",
+ "/proc/sysrq-trigger"
+ ]
+ },
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2-init/diff:/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/diff:/var/lib/docker/overlay2/09785ba17f27c783ef8b44f369f9aac0ca936000b57abf22b3c54d1e6eb8e27b/diff:/var/lib/docker/overlay2/6f8acd64ae44fd4d14bcb90c105eceba46854aa3985b5b6b317bcc5692cfc286/diff:/var/lib/docker/overlay2/73d41c15edb21c5f12cf53e313f48b5da55283aafc77d35b7bc662241879d7e7/diff:/var/lib/docker/overlay2/d97b55f3d966ae031492369a98e9e00d2bd31e520290fe2034e0a2b1ed77c91e/diff:/var/lib/docker/overlay2/053e9ca65c6b64cb9d98a812ff7488c7e77938b4fb8e0c4d2ad7f8ec235f0f20/diff",
+ "MergedDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/merged",
+ "UpperDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/diff",
+ "WorkDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/work"
+ },
+ "Name": "overlay2"
+ },
+ "Mounts": [],
+ "Config": {
+ "Hostname": "25e75393800b",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": true,
+ "AttachStderr": true,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "NGINX_VERSION=1.23.3",
+ "NJS_VERSION=0.7.9",
+ "PKG_RELEASE=1~bullseye"
+ ],
+ "Cmd": [
+ "nginx",
+ "-g",
+ "daemon off;"
+ ],
+ "Image": "nginx",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/docker-entrypoint.sh"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "com.docker.compose.config-hash": "8bb0e1c8c61f6d495840ba9133ebfb1e4ffda3e1adb701a011b03951848bb9fa",
+ "com.docker.compose.container-number": "1",
+ "com.docker.compose.depends_on": "producer:service_started:false",
+ "com.docker.compose.image": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8",
+ "com.docker.compose.oneoff": "False",
+ "com.docker.compose.project": "wt-contnet",
+ "com.docker.compose.project.config_files": "/tmp/wt-contnet/docker-compose.yaml",
+ "com.docker.compose.project.working_dir": "/tmp/wt-contnet",
+ "com.docker.compose.replace": "07bb70608f96f577aa02b9f317500e23e691c94eb099f6fb52301dfb031d0668",
+ "com.docker.compose.service": "consumer",
+ "com.docker.compose.version": "2.19.1",
+ "desktop.docker.io/wsl-distro": "Ubuntu",
+ "maintainer": "NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e"
+ },
+ "StopSignal": "SIGQUIT"
+ },
+ "NetworkSettings": {
+ "Bridge": "",
+ "SandboxID": "",
+ "HairpinMode": false,
+ "LinkLocalIPv6Address": "",
+ "LinkLocalIPv6PrefixLen": 0,
+ "Ports": {},
+ "SandboxKey": "",
+ "SecondaryIPAddresses": null,
+ "SecondaryIPv6Addresses": null,
+ "EndpointID": "",
+ "Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "MacAddress": "",
+ "Networks": {}
+ }
+}
diff --git a/pkg/container/mocks/data/container_net_supplier.json b/pkg/container/mocks/data/container_net_supplier.json
new file mode 100644
index 0000000..24db841
--- /dev/null
+++ b/pkg/container/mocks/data/container_net_supplier.json
@@ -0,0 +1,380 @@
+{
+ "Id": "25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2",
+ "Created": "2023-07-25T14:55:14.595662628Z",
+ "Path": "/gluetun-entrypoint",
+ "Args": [],
+ "State": {
+ "Status": "running",
+ "Running": true,
+ "Paused": false,
+ "Restarting": false,
+ "OOMKilled": false,
+ "Dead": false,
+ "Pid": 3648,
+ "ExitCode": 0,
+ "Error": "",
+ "StartedAt": "2023-07-25T14:55:15.193430103Z",
+ "FinishedAt": "0001-01-01T00:00:00Z",
+ "Health": {
+ "Status": "healthy",
+ "FailingStreak": 0,
+ "Log": [
+ {
+ "Start": "2023-07-25T15:00:32.078491228Z",
+ "End": "2023-07-25T15:00:32.194554876Z",
+ "ExitCode": 0,
+ "Output": ""
+ },
+ {
+ "Start": "2023-07-25T15:00:37.199245496Z",
+ "End": "2023-07-25T15:00:37.294845687Z",
+ "ExitCode": 0,
+ "Output": ""
+ },
+ {
+ "Start": "2023-07-25T15:00:42.299676089Z",
+ "End": "2023-07-25T15:00:42.384213818Z",
+ "ExitCode": 0,
+ "Output": ""
+ },
+ {
+ "Start": "2023-07-25T15:00:47.389142447Z",
+ "End": "2023-07-25T15:00:47.514483294Z",
+ "ExitCode": 0,
+ "Output": ""
+ },
+ {
+ "Start": "2023-07-25T15:00:52.518770886Z",
+ "End": "2023-07-25T15:00:52.644288742Z",
+ "ExitCode": 0,
+ "Output": ""
+ }
+ ]
+ }
+ },
+ "Image": "sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51",
+ "ResolvConfPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/resolv.conf",
+ "HostnamePath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hostname",
+ "HostsPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hosts",
+ "LogPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2-json.log",
+ "Name": "/wt-contnet-producer-1",
+ "RestartCount": 0,
+ "Driver": "overlay2",
+ "Platform": "linux",
+ "MountLabel": "",
+ "ProcessLabel": "",
+ "AppArmorProfile": "",
+ "ExecIDs": null,
+ "HostConfig": {
+ "Binds": null,
+ "ContainerIDFile": "",
+ "LogConfig": {
+ "Type": "json-file",
+ "Config": {}
+ },
+ "NetworkMode": "wt-contnet_default",
+ "PortBindings": {},
+ "RestartPolicy": {
+ "Name": "",
+ "MaximumRetryCount": 0
+ },
+ "AutoRemove": false,
+ "VolumeDriver": "",
+ "VolumesFrom": null,
+ "ConsoleSize": [
+ 0,
+ 0
+ ],
+ "CapAdd": [
+ "NET_ADMIN"
+ ],
+ "CapDrop": null,
+ "CgroupnsMode": "host",
+ "Dns": null,
+ "DnsOptions": null,
+ "DnsSearch": null,
+ "ExtraHosts": [],
+ "GroupAdd": null,
+ "IpcMode": "private",
+ "Cgroup": "",
+ "Links": null,
+ "OomScoreAdj": 0,
+ "PidMode": "",
+ "Privileged": false,
+ "PublishAllPorts": false,
+ "ReadonlyRootfs": false,
+ "SecurityOpt": null,
+ "UTSMode": "",
+ "UsernsMode": "",
+ "ShmSize": 67108864,
+ "Runtime": "runc",
+ "Isolation": "",
+ "CpuShares": 0,
+ "Memory": 0,
+ "NanoCpus": 0,
+ "CgroupParent": "",
+ "BlkioWeight": 0,
+ "BlkioWeightDevice": null,
+ "BlkioDeviceReadBps": null,
+ "BlkioDeviceWriteBps": null,
+ "BlkioDeviceReadIOps": null,
+ "BlkioDeviceWriteIOps": null,
+ "CpuPeriod": 0,
+ "CpuQuota": 0,
+ "CpuRealtimePeriod": 0,
+ "CpuRealtimeRuntime": 0,
+ "CpusetCpus": "",
+ "CpusetMems": "",
+ "Devices": null,
+ "DeviceCgroupRules": null,
+ "DeviceRequests": null,
+ "MemoryReservation": 0,
+ "MemorySwap": 0,
+ "MemorySwappiness": null,
+ "OomKillDisable": false,
+ "PidsLimit": null,
+ "Ulimits": null,
+ "CpuCount": 0,
+ "CpuPercent": 0,
+ "IOMaximumIOps": 0,
+ "IOMaximumBandwidth": 0,
+ "MaskedPaths": [
+ "/proc/asound",
+ "/proc/acpi",
+ "/proc/kcore",
+ "/proc/keys",
+ "/proc/latency_stats",
+ "/proc/timer_list",
+ "/proc/timer_stats",
+ "/proc/sched_debug",
+ "/proc/scsi",
+ "/sys/firmware"
+ ],
+ "ReadonlyPaths": [
+ "/proc/bus",
+ "/proc/fs",
+ "/proc/irq",
+ "/proc/sys",
+ "/proc/sysrq-trigger"
+ ]
+ },
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/618bd1e7a13880c07ec7f5bfc45012a9f81d5de452f942b49d8f49b3c67a19a2-init/diff:/var/lib/docker/overlay2/0d222a3aa067159831c4111a408e40325be1085b935c98d39c2e9a01ff50b224/diff:/var/lib/docker/overlay2/a20c9490a23ee8af51898892d9bf32258d44e0e07f3799475be8e8f273a50f73/diff:/var/lib/docker/overlay2/d4c97f367c37c6ada9de57f438a3e19cc714be2a54a6f582a03de9e42d88b344/diff",
+ "MergedDir": "/var/lib/docker/overlay2/618bd1e7a13880c07ec7f5bfc45012a9f81d5de452f942b49d8f49b3c67a19a2/merged",
+ "UpperDir": "/var/lib/docker/overlay2/618bd1e7a13880c07ec7f5bfc45012a9f81d5de452f942b49d8f49b3c67a19a2/diff",
+ "WorkDir": "/var/lib/docker/overlay2/618bd1e7a13880c07ec7f5bfc45012a9f81d5de452f942b49d8f49b3c67a19a2/work"
+ },
+ "Name": "overlay2"
+ },
+ "Mounts": [],
+ "Config": {
+ "Hostname": "25e75393800b",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": true,
+ "AttachStderr": true,
+ "ExposedPorts": {
+ "8000/tcp": {},
+ "8388/tcp": {},
+ "8388/udp": {},
+ "8888/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "OPENVPN_PASSWORD=",
+ "SERVER_COUNTRIES=Sweden",
+ "VPN_SERVICE_PROVIDER=nordvpn",
+ "OPENVPN_USER=",
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "VPN_TYPE=openvpn",
+ "VPN_ENDPOINT_IP=",
+ "VPN_ENDPOINT_PORT=",
+ "VPN_INTERFACE=tun0",
+ "OPENVPN_PROTOCOL=udp",
+ "OPENVPN_USER_SECRETFILE=/run/secrets/openvpn_user",
+ "OPENVPN_PASSWORD_SECRETFILE=/run/secrets/openvpn_password",
+ "OPENVPN_VERSION=2.5",
+ "OPENVPN_VERBOSITY=1",
+ "OPENVPN_FLAGS=",
+ "OPENVPN_CIPHERS=",
+ "OPENVPN_AUTH=",
+ "OPENVPN_PROCESS_USER=root",
+ "OPENVPN_CUSTOM_CONFIG=",
+ "WIREGUARD_PRIVATE_KEY=",
+ "WIREGUARD_PRESHARED_KEY=",
+ "WIREGUARD_PUBLIC_KEY=",
+ "WIREGUARD_ALLOWED_IPS=",
+ "WIREGUARD_ADDRESSES=",
+ "WIREGUARD_MTU=1400",
+ "WIREGUARD_IMPLEMENTATION=auto",
+ "SERVER_REGIONS=",
+ "SERVER_CITIES=",
+ "SERVER_HOSTNAMES=",
+ "ISP=",
+ "OWNED_ONLY=no",
+ "PRIVATE_INTERNET_ACCESS_OPENVPN_ENCRYPTION_PRESET=",
+ "VPN_PORT_FORWARDING=off",
+ "VPN_PORT_FORWARDING_PROVIDER=",
+ "VPN_PORT_FORWARDING_STATUS_FILE=/tmp/gluetun/forwarded_port",
+ "OPENVPN_CERT=",
+ "OPENVPN_KEY=",
+ "OPENVPN_CLIENTCRT_SECRETFILE=/run/secrets/openvpn_clientcrt",
+ "OPENVPN_CLIENTKEY_SECRETFILE=/run/secrets/openvpn_clientkey",
+ "OPENVPN_ENCRYPTED_KEY=",
+ "OPENVPN_ENCRYPTED_KEY_SECRETFILE=/run/secrets/openvpn_encrypted_key",
+ "OPENVPN_KEY_PASSPHRASE=",
+ "OPENVPN_KEY_PASSPHRASE_SECRETFILE=/run/secrets/openvpn_key_passphrase",
+ "SERVER_NUMBER=",
+ "SERVER_NAMES=",
+ "FREE_ONLY=",
+ "MULTIHOP_ONLY=",
+ "PREMIUM_ONLY=",
+ "FIREWALL=on",
+ "FIREWALL_VPN_INPUT_PORTS=",
+ "FIREWALL_INPUT_PORTS=",
+ "FIREWALL_OUTBOUND_SUBNETS=",
+ "FIREWALL_DEBUG=off",
+ "LOG_LEVEL=info",
+ "HEALTH_SERVER_ADDRESS=127.0.0.1:9999",
+ "HEALTH_TARGET_ADDRESS=cloudflare.com:443",
+ "HEALTH_SUCCESS_WAIT_DURATION=5s",
+ "HEALTH_VPN_DURATION_INITIAL=6s",
+ "HEALTH_VPN_DURATION_ADDITION=5s",
+ "DOT=on",
+ "DOT_PROVIDERS=cloudflare",
+ "DOT_PRIVATE_ADDRESS=127.0.0.1/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,::1/128,fc00::/7,fe80::/10,::ffff:7f00:1/104,::ffff:a00:0/104,::ffff:a9fe:0/112,::ffff:ac10:0/108,::ffff:c0a8:0/112",
+ "DOT_VERBOSITY=1",
+ "DOT_VERBOSITY_DETAILS=0",
+ "DOT_VALIDATION_LOGLEVEL=0",
+ "DOT_CACHING=on",
+ "DOT_IPV6=off",
+ "BLOCK_MALICIOUS=on",
+ "BLOCK_SURVEILLANCE=off",
+ "BLOCK_ADS=off",
+ "UNBLOCK=",
+ "DNS_UPDATE_PERIOD=24h",
+ "DNS_ADDRESS=127.0.0.1",
+ "DNS_KEEP_NAMESERVER=off",
+ "HTTPPROXY=",
+ "HTTPPROXY_LOG=off",
+ "HTTPPROXY_LISTENING_ADDRESS=:8888",
+ "HTTPPROXY_STEALTH=off",
+ "HTTPPROXY_USER=",
+ "HTTPPROXY_PASSWORD=",
+ "HTTPPROXY_USER_SECRETFILE=/run/secrets/httpproxy_user",
+ "HTTPPROXY_PASSWORD_SECRETFILE=/run/secrets/httpproxy_password",
+ "SHADOWSOCKS=off",
+ "SHADOWSOCKS_LOG=off",
+ "SHADOWSOCKS_LISTENING_ADDRESS=:8388",
+ "SHADOWSOCKS_PASSWORD=",
+ "SHADOWSOCKS_PASSWORD_SECRETFILE=/run/secrets/shadowsocks_password",
+ "SHADOWSOCKS_CIPHER=chacha20-ietf-poly1305",
+ "HTTP_CONTROL_SERVER_LOG=on",
+ "HTTP_CONTROL_SERVER_ADDRESS=:8000",
+ "UPDATER_PERIOD=0",
+ "UPDATER_MIN_RATIO=0.8",
+ "UPDATER_VPN_SERVICE_PROVIDERS=",
+ "PUBLICIP_FILE=/tmp/gluetun/ip",
+ "PUBLICIP_PERIOD=12h",
+ "PPROF_ENABLED=no",
+ "PPROF_BLOCK_PROFILE_RATE=0",
+ "PPROF_MUTEX_PROFILE_RATE=0",
+ "PPROF_HTTP_SERVER_ADDRESS=:6060",
+ "VERSION_INFORMATION=on",
+ "TZ=",
+ "PUID=",
+ "PGID="
+ ],
+ "Cmd": null,
+ "Healthcheck": {
+ "Test": [
+ "CMD-SHELL",
+ "/gluetun-entrypoint healthcheck"
+ ],
+ "Interval": 5000000000,
+ "Timeout": 5000000000,
+ "StartPeriod": 10000000000,
+ "Retries": 1
+ },
+ "Image": "qmcgaw/gluetun",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/gluetun-entrypoint"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "com.docker.compose.config-hash": "6dc7dc42a86edb47039de3650a9cb9bdcf4866c113b8f9d797722c9dfd20428b",
+ "com.docker.compose.container-number": "1",
+ "com.docker.compose.depends_on": "",
+ "com.docker.compose.image": "sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51",
+ "com.docker.compose.oneoff": "False",
+ "com.docker.compose.project": "wt-contnet",
+ "com.docker.compose.project.config_files": "/tmp/wt-contnet/docker-compose.yaml",
+ "com.docker.compose.project.working_dir": "/tmp/wt-contnet",
+ "com.docker.compose.replace": "9bd1ce000be81819fc915aa60a1674c7573b59a26ac4643ecf427a5732b9785f",
+ "com.docker.compose.service": "producer",
+ "com.docker.compose.version": "2.19.1",
+ "desktop.docker.io/wsl-distro": "Ubuntu",
+ "org.opencontainers.image.authors": "quentin.mcgaw@gmail.com",
+ "org.opencontainers.image.created": "2023-07-22T16:07:05.641Z",
+ "org.opencontainers.image.description": "VPN client in a thin Docker container for multiple VPN providers, written in Go, and using OpenVPN or Wireguard, DNS over TLS, with a few proxy servers built-in.",
+ "org.opencontainers.image.documentation": "https://github.com/qdm12/gluetun",
+ "org.opencontainers.image.licenses": "MIT",
+ "org.opencontainers.image.revision": "eecfb3952f202c0de3867d88e96d80c6b0f48359",
+ "org.opencontainers.image.source": "https://github.com/qdm12/gluetun",
+ "org.opencontainers.image.title": "gluetun",
+ "org.opencontainers.image.url": "https://github.com/qdm12/gluetun",
+ "org.opencontainers.image.version": "latest"
+ }
+ },
+ "NetworkSettings": {
+ "Bridge": "",
+ "SandboxID": "34a321b64bb1b15f994dfccff0e235f881504f240c2028876ff6683962eaa10e",
+ "HairpinMode": false,
+ "LinkLocalIPv6Address": "",
+ "LinkLocalIPv6PrefixLen": 0,
+ "Ports": {
+ "8000/tcp": null,
+ "8388/tcp": null,
+ "8388/udp": null,
+ "8888/tcp": null
+ },
+ "SandboxKey": "/var/run/docker/netns/34a321b64bb1",
+ "SecondaryIPAddresses": null,
+ "SecondaryIPv6Addresses": null,
+ "EndpointID": "",
+ "Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "MacAddress": "",
+ "Networks": {
+ "wt-contnet_default": {
+ "IPAMConfig": null,
+ "Links": null,
+ "Aliases": [
+ "wt-contnet-producer-1",
+ "producer",
+ "25e75393800b"
+ ],
+ "NetworkID": "f0f652a79efc54bcad52aafb4cbcc3b5dce1acaf11b172d8678d25f665faf63d",
+ "EndpointID": "2429c2b5d08db6c986bbd419a52ca4dd352715d80c5aeae04742efb84b0356fc",
+ "Gateway": "172.19.0.1",
+ "IPAddress": "172.19.0.2",
+ "IPPrefixLen": 16,
+ "IPv6Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "MacAddress": "02:42:ac:13:00:02",
+ "DriverOpts": null
+ }
+ }
+ }
+}
diff --git a/pkg/container/mocks/data/container_stopped.json b/pkg/container/mocks/data/container_stopped.json
index a4519d7..0043c62 100644
--- a/pkg/container/mocks/data/container_stopped.json
+++ b/pkg/container/mocks/data/container_stopped.json
@@ -21,7 +21,7 @@
"HostnamePath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hostname",
"HostsPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hosts",
"LogPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65-json.log",
- "Name": "/watchtower-test",
+ "Name": "/watchtower-stopped",
"RestartCount": 0,
"Driver": "overlay2",
"Platform": "linux",
diff --git a/pkg/container/mocks/data/container_watchtower.json b/pkg/container/mocks/data/container_watchtower.json
new file mode 100644
index 0000000..14737b5
--- /dev/null
+++ b/pkg/container/mocks/data/container_watchtower.json
@@ -0,0 +1,205 @@
+{
+ "Id": "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134",
+ "Created": "2020-04-10T19:51:22.245041005Z",
+ "Path": "/watchtower",
+ "Args": [],
+ "State": {
+ "Status": "running",
+ "Running": true,
+ "Paused": false,
+ "Restarting": false,
+ "OOMKilled": false,
+ "Dead": false,
+ "Pid": 3854,
+ "ExitCode": 0,
+ "Error": "",
+ "StartedAt": "2019-04-13T22:38:24.498745809Z",
+ "FinishedAt": "2019-04-13T22:38:18.486292076Z"
+ },
+ "Image": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa",
+ "ResolvConfPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/resolv.conf",
+ "HostnamePath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hostname",
+ "HostsPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hosts",
+ "LogPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65-json.log",
+ "Name": "/watchtower-running",
+ "RestartCount": 0,
+ "Driver": "overlay2",
+ "Platform": "linux",
+ "MountLabel": "",
+ "ProcessLabel": "",
+ "AppArmorProfile": "",
+ "ExecIDs": null,
+ "HostConfig": {
+ "Binds": [
+ "/var/run/docker.sock:/var/run/docker.sock"
+ ],
+ "ContainerIDFile": "",
+ "LogConfig": {
+ "Type": "json-file",
+ "Config": {}
+ },
+ "NetworkMode": "default",
+ "PortBindings": {},
+ "RestartPolicy": {
+ "Name": "no",
+ "MaximumRetryCount": 0
+ },
+ "AutoRemove": false,
+ "VolumeDriver": "",
+ "VolumesFrom": null,
+ "CapAdd": null,
+ "CapDrop": null,
+ "Dns": [],
+ "DnsOptions": [],
+ "DnsSearch": [],
+ "ExtraHosts": null,
+ "GroupAdd": null,
+ "IpcMode": "shareable",
+ "Cgroup": "",
+ "Links": null,
+ "OomScoreAdj": 0,
+ "PidMode": "",
+ "Privileged": false,
+ "PublishAllPorts": false,
+ "ReadonlyRootfs": false,
+ "SecurityOpt": null,
+ "UTSMode": "",
+ "UsernsMode": "",
+ "ShmSize": 67108864,
+ "Runtime": "runc",
+ "ConsoleSize": [
+ 0,
+ 0
+ ],
+ "Isolation": "",
+ "CpuShares": 0,
+ "Memory": 0,
+ "NanoCpus": 0,
+ "CgroupParent": "",
+ "BlkioWeight": 0,
+ "BlkioWeightDevice": [],
+ "BlkioDeviceReadBps": null,
+ "BlkioDeviceWriteBps": null,
+ "BlkioDeviceReadIOps": null,
+ "BlkioDeviceWriteIOps": null,
+ "CpuPeriod": 0,
+ "CpuQuota": 0,
+ "CpuRealtimePeriod": 0,
+ "CpuRealtimeRuntime": 0,
+ "CpusetCpus": "",
+ "CpusetMems": "",
+ "Devices": [],
+ "DeviceCgroupRules": null,
+ "DiskQuota": 0,
+ "KernelMemory": 0,
+ "MemoryReservation": 0,
+ "MemorySwap": 0,
+ "MemorySwappiness": null,
+ "OomKillDisable": false,
+ "PidsLimit": 0,
+ "Ulimits": null,
+ "CpuCount": 0,
+ "CpuPercent": 0,
+ "IOMaximumIOps": 0,
+ "IOMaximumBandwidth": 0,
+ "MaskedPaths": [
+ "/proc/asound",
+ "/proc/acpi",
+ "/proc/kcore",
+ "/proc/keys",
+ "/proc/latency_stats",
+ "/proc/timer_list",
+ "/proc/timer_stats",
+ "/proc/sched_debug",
+ "/proc/scsi",
+ "/sys/firmware"
+ ],
+ "ReadonlyPaths": [
+ "/proc/bus",
+ "/proc/fs",
+ "/proc/irq",
+ "/proc/sys",
+ "/proc/sysrq-trigger"
+ ]
+ },
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc-init/diff:/var/lib/docker/overlay2/cdf82f50bc49177d0c17c24f3eaa29eba607b70cc6a081f77781b21c59a13eb8/diff:/var/lib/docker/overlay2/8108325ee844603c9b08d2772cf6e65dccf31dd5171f265078e5ed79a0ba3c0f/diff:/var/lib/docker/overlay2/e5e0cce6bf91b829a308424d99d7e56a33be3a11414ff5cdc48e762a1342b20f/diff",
+ "MergedDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/merged",
+ "UpperDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/diff",
+ "WorkDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/work"
+ },
+ "Name": "overlay2"
+ },
+ "Mounts": [
+ {
+ "Type": "bind",
+ "Source": "/var/run/docker.sock",
+ "Destination": "/var/run/docker.sock",
+ "Mode": "",
+ "RW": true,
+ "Propagation": "rprivate"
+ }
+ ],
+ "Config": {
+ "Hostname": "ae8964ba86c7",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": true,
+ "AttachStderr": true,
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+ ],
+ "Cmd": null,
+ "Image": "containrrr/watchtower:latest",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/watchtower"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "com.centurylinklabs.watchtower": "true"
+ }
+ },
+ "NetworkSettings": {
+ "Bridge": "",
+ "SandboxID": "05627d36c08ed994eebc44a2a8c9365a511756b55c500fb03fd5a14477cd4bf3",
+ "HairpinMode": false,
+ "LinkLocalIPv6Address": "",
+ "LinkLocalIPv6PrefixLen": 0,
+ "Ports": {},
+ "SandboxKey": "/var/run/docker/netns/05627d36c08e",
+ "SecondaryIPAddresses": null,
+ "SecondaryIPv6Addresses": null,
+ "EndpointID": "",
+ "Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "MacAddress": "",
+ "Networks": {
+ "bridge": {
+ "IPAMConfig": null,
+ "Links": null,
+ "Aliases": null,
+ "NetworkID": "8fcfd56fa9203bafa98510abb08bff66ad05bef5b6e97d158cbae3397e1e065e",
+ "EndpointID": "",
+ "Gateway": "",
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "MacAddress": "",
+ "DriverOpts": null
+ }
+ }
+ }
+}
diff --git a/pkg/container/mocks/data/containers.json b/pkg/container/mocks/data/containers.json
index 4acd7e2..439cc51 100644
--- a/pkg/container/mocks/data/containers.json
+++ b/pkg/container/mocks/data/containers.json
@@ -2,7 +2,55 @@
{
"Id": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65",
"Names": [
- "/watchtower-test"
+ "/watchtower-stopped"
+ ],
+ "Image": "containrrr/watchtower:latest",
+ "ImageID": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa",
+ "Command": "/watchtower",
+ "Created": 1554925882,
+ "Ports": [],
+ "Labels": {
+ "com.centurylinklabs.watchtower": "true"
+ },
+ "State": "exited",
+ "Status": "Exited (1) 6 days ago",
+ "HostConfig": {
+ "NetworkMode": "default"
+ },
+ "NetworkSettings": {
+ "Networks": {
+ "bridge": {
+ "IPAMConfig": null,
+ "Links": null,
+ "Aliases": null,
+ "NetworkID": "8fcfd56fa9203bafa98510abb08bff66ad05bef5b6e97d158cbae3397e1e065e",
+ "EndpointID": "",
+ "Gateway": "",
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "MacAddress": "",
+ "DriverOpts": null
+ }
+ }
+ },
+ "Mounts": [
+ {
+ "Type": "bind",
+ "Source": "/var/run/docker.sock",
+ "Destination": "/var/run/docker.sock",
+ "Mode": "",
+ "RW": true,
+ "Propagation": "rprivate"
+ }
+ ]
+ },
+ {
+ "Id": "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134",
+ "Names": [
+ "/watchtower-running"
],
"Image": "containrrr/watchtower:latest",
"ImageID": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa",
@@ -13,7 +61,7 @@
"com.centurylinklabs.watchtower": "true"
},
"State": "running",
- "Status": "Exited (1) 6 days ago",
+ "Status": "Up 3 days",
"HostConfig": {
"NetworkMode": "default"
},
diff --git a/pkg/container/mocks/data/image01.json b/pkg/container/mocks/data/image_default.json
similarity index 100%
rename from pkg/container/mocks/data/image01.json
rename to pkg/container/mocks/data/image_default.json
diff --git a/pkg/container/mocks/data/image_net_consumer.json b/pkg/container/mocks/data/image_net_consumer.json
new file mode 100644
index 0000000..add6edf
--- /dev/null
+++ b/pkg/container/mocks/data/image_net_consumer.json
@@ -0,0 +1,115 @@
+{
+ "Id": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8",
+ "RepoTags": [
+ "nginx:latest"
+ ],
+ "RepoDigests": [
+ "nginx@sha256:aa0afebbb3cfa473099a62c4b32e9b3fb73ed23f2a75a65ce1d4b4f55a5c2ef2"
+ ],
+ "Parent": "",
+ "Comment": "",
+ "Created": "2023-03-01T18:43:12.914398123Z",
+ "Container": "71a4c9a59d252d7c54812429bfe5df477e54e91ebfff1939ae39ecdf055d445c",
+ "ContainerConfig": {
+ "Hostname": "71a4c9a59d25",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "NGINX_VERSION=1.23.3",
+ "NJS_VERSION=0.7.9",
+ "PKG_RELEASE=1~bullseye"
+ ],
+ "Cmd": [
+ "/bin/sh",
+ "-c",
+ "#(nop) ",
+ "CMD [\"nginx\" \"-g\" \"daemon off;\"]"
+ ],
+ "Image": "sha256:6716b8a33f73b21e193bb63424ea1105eaaa6a8237fefe75570bea18c87a1711",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/docker-entrypoint.sh"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "maintainer": "NGINX Docker Maintainers "
+ },
+ "StopSignal": "SIGQUIT"
+ },
+ "DockerVersion": "20.10.23",
+ "Author": "",
+ "Config": {
+ "Hostname": "",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "NGINX_VERSION=1.23.3",
+ "NJS_VERSION=0.7.9",
+ "PKG_RELEASE=1~bullseye"
+ ],
+ "Cmd": [
+ "nginx",
+ "-g",
+ "daemon off;"
+ ],
+ "Image": "sha256:6716b8a33f73b21e193bb63424ea1105eaaa6a8237fefe75570bea18c87a1711",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/docker-entrypoint.sh"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "maintainer": "NGINX Docker Maintainers "
+ },
+ "StopSignal": "SIGQUIT"
+ },
+ "Architecture": "amd64",
+ "Os": "linux",
+ "Size": 141838643,
+ "VirtualSize": 141838643,
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/09785ba17f27c783ef8b44f369f9aac0ca936000b57abf22b3c54d1e6eb8e27b/diff:/var/lib/docker/overlay2/6f8acd64ae44fd4d14bcb90c105eceba46854aa3985b5b6b317bcc5692cfc286/diff:/var/lib/docker/overlay2/73d41c15edb21c5f12cf53e313f48b5da55283aafc77d35b7bc662241879d7e7/diff:/var/lib/docker/overlay2/d97b55f3d966ae031492369a98e9e00d2bd31e520290fe2034e0a2b1ed77c91e/diff:/var/lib/docker/overlay2/053e9ca65c6b64cb9d98a812ff7488c7e77938b4fb8e0c4d2ad7f8ec235f0f20/diff",
+ "MergedDir": "/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/merged",
+ "UpperDir": "/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/diff",
+ "WorkDir": "/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/work"
+ },
+ "Name": "overlay2"
+ },
+ "RootFS": {
+ "Type": "layers",
+ "Layers": [
+ "sha256:650abce4b096b06ac8bec2046d821d66d801af34f1f1d4c5e272ad030c7873db",
+ "sha256:4dc5cd799a08ff49a603870c8378ea93083bfc2a4176f56e5531997e94c195d0",
+ "sha256:e161c82b34d21179db1f546c1cd84153d28a17d865ccaf2dedeb06a903fec12c",
+ "sha256:83ba6d8ffb8c2974174c02d3ba549e7e0656ebb1bc075a6b6ee89b6c609c6a71",
+ "sha256:d8466e142d8710abf5b495ebb536478f7e19d9d03b151b5d5bd09df4cfb49248",
+ "sha256:101af4ba983b04be266217ecee414e88b23e394f62e9801c7c1bdb37cb37bcaa"
+ ]
+ },
+ "Metadata": {
+ "LastTagTime": "0001-01-01T00:00:00Z"
+ }
+}
diff --git a/pkg/container/mocks/data/image_net_producer.json b/pkg/container/mocks/data/image_net_producer.json
new file mode 100644
index 0000000..563ad95
--- /dev/null
+++ b/pkg/container/mocks/data/image_net_producer.json
@@ -0,0 +1,210 @@
+{
+ "Id": "sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51",
+ "RepoTags": [
+ "qmcgaw/gluetun:latest"
+ ],
+ "RepoDigests": [
+ "qmcgaw/gluetun@sha256:cd532bf4ef88a348a915c6dc62a9867a2eca89aa70559b0b4a1ea15cc0e595d1"
+ ],
+ "Parent": "",
+ "Comment": "buildkit.dockerfile.v0",
+ "Created": "2023-07-22T16:10:29.457146856Z",
+ "Container": "",
+ "ContainerConfig": {
+ "Hostname": "",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": null,
+ "Cmd": null,
+ "Image": "",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": null,
+ "OnBuild": null,
+ "Labels": null
+ },
+ "DockerVersion": "",
+ "Author": "",
+ "Config": {
+ "Hostname": "",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "ExposedPorts": {
+ "8000/tcp": {},
+ "8388/tcp": {},
+ "8388/udp": {},
+ "8888/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "VPN_SERVICE_PROVIDER=pia",
+ "VPN_TYPE=openvpn",
+ "VPN_ENDPOINT_IP=",
+ "VPN_ENDPOINT_PORT=",
+ "VPN_INTERFACE=tun0",
+ "OPENVPN_PROTOCOL=udp",
+ "OPENVPN_USER=",
+ "OPENVPN_PASSWORD=",
+ "OPENVPN_USER_SECRETFILE=/run/secrets/openvpn_user",
+ "OPENVPN_PASSWORD_SECRETFILE=/run/secrets/openvpn_password",
+ "OPENVPN_VERSION=2.5",
+ "OPENVPN_VERBOSITY=1",
+ "OPENVPN_FLAGS=",
+ "OPENVPN_CIPHERS=",
+ "OPENVPN_AUTH=",
+ "OPENVPN_PROCESS_USER=root",
+ "OPENVPN_CUSTOM_CONFIG=",
+ "WIREGUARD_PRIVATE_KEY=",
+ "WIREGUARD_PRESHARED_KEY=",
+ "WIREGUARD_PUBLIC_KEY=",
+ "WIREGUARD_ALLOWED_IPS=",
+ "WIREGUARD_ADDRESSES=",
+ "WIREGUARD_MTU=1400",
+ "WIREGUARD_IMPLEMENTATION=auto",
+ "SERVER_REGIONS=",
+ "SERVER_COUNTRIES=",
+ "SERVER_CITIES=",
+ "SERVER_HOSTNAMES=",
+ "ISP=",
+ "OWNED_ONLY=no",
+ "PRIVATE_INTERNET_ACCESS_OPENVPN_ENCRYPTION_PRESET=",
+ "VPN_PORT_FORWARDING=off",
+ "VPN_PORT_FORWARDING_PROVIDER=",
+ "VPN_PORT_FORWARDING_STATUS_FILE=/tmp/gluetun/forwarded_port",
+ "OPENVPN_CERT=",
+ "OPENVPN_KEY=",
+ "OPENVPN_CLIENTCRT_SECRETFILE=/run/secrets/openvpn_clientcrt",
+ "OPENVPN_CLIENTKEY_SECRETFILE=/run/secrets/openvpn_clientkey",
+ "OPENVPN_ENCRYPTED_KEY=",
+ "OPENVPN_ENCRYPTED_KEY_SECRETFILE=/run/secrets/openvpn_encrypted_key",
+ "OPENVPN_KEY_PASSPHRASE=",
+ "OPENVPN_KEY_PASSPHRASE_SECRETFILE=/run/secrets/openvpn_key_passphrase",
+ "SERVER_NUMBER=",
+ "SERVER_NAMES=",
+ "FREE_ONLY=",
+ "MULTIHOP_ONLY=",
+ "PREMIUM_ONLY=",
+ "FIREWALL=on",
+ "FIREWALL_VPN_INPUT_PORTS=",
+ "FIREWALL_INPUT_PORTS=",
+ "FIREWALL_OUTBOUND_SUBNETS=",
+ "FIREWALL_DEBUG=off",
+ "LOG_LEVEL=info",
+ "HEALTH_SERVER_ADDRESS=127.0.0.1:9999",
+ "HEALTH_TARGET_ADDRESS=cloudflare.com:443",
+ "HEALTH_SUCCESS_WAIT_DURATION=5s",
+ "HEALTH_VPN_DURATION_INITIAL=6s",
+ "HEALTH_VPN_DURATION_ADDITION=5s",
+ "DOT=on",
+ "DOT_PROVIDERS=cloudflare",
+ "DOT_PRIVATE_ADDRESS=127.0.0.1/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,::1/128,fc00::/7,fe80::/10,::ffff:7f00:1/104,::ffff:a00:0/104,::ffff:a9fe:0/112,::ffff:ac10:0/108,::ffff:c0a8:0/112",
+ "DOT_VERBOSITY=1",
+ "DOT_VERBOSITY_DETAILS=0",
+ "DOT_VALIDATION_LOGLEVEL=0",
+ "DOT_CACHING=on",
+ "DOT_IPV6=off",
+ "BLOCK_MALICIOUS=on",
+ "BLOCK_SURVEILLANCE=off",
+ "BLOCK_ADS=off",
+ "UNBLOCK=",
+ "DNS_UPDATE_PERIOD=24h",
+ "DNS_ADDRESS=127.0.0.1",
+ "DNS_KEEP_NAMESERVER=off",
+ "HTTPPROXY=",
+ "HTTPPROXY_LOG=off",
+ "HTTPPROXY_LISTENING_ADDRESS=:8888",
+ "HTTPPROXY_STEALTH=off",
+ "HTTPPROXY_USER=",
+ "HTTPPROXY_PASSWORD=",
+ "HTTPPROXY_USER_SECRETFILE=/run/secrets/httpproxy_user",
+ "HTTPPROXY_PASSWORD_SECRETFILE=/run/secrets/httpproxy_password",
+ "SHADOWSOCKS=off",
+ "SHADOWSOCKS_LOG=off",
+ "SHADOWSOCKS_LISTENING_ADDRESS=:8388",
+ "SHADOWSOCKS_PASSWORD=",
+ "SHADOWSOCKS_PASSWORD_SECRETFILE=/run/secrets/shadowsocks_password",
+ "SHADOWSOCKS_CIPHER=chacha20-ietf-poly1305",
+ "HTTP_CONTROL_SERVER_LOG=on",
+ "HTTP_CONTROL_SERVER_ADDRESS=:8000",
+ "UPDATER_PERIOD=0",
+ "UPDATER_MIN_RATIO=0.8",
+ "UPDATER_VPN_SERVICE_PROVIDERS=",
+ "PUBLICIP_FILE=/tmp/gluetun/ip",
+ "PUBLICIP_PERIOD=12h",
+ "PPROF_ENABLED=no",
+ "PPROF_BLOCK_PROFILE_RATE=0",
+ "PPROF_MUTEX_PROFILE_RATE=0",
+ "PPROF_HTTP_SERVER_ADDRESS=:6060",
+ "VERSION_INFORMATION=on",
+ "TZ=",
+ "PUID=",
+ "PGID="
+ ],
+ "Cmd": null,
+ "Healthcheck": {
+ "Test": [
+ "CMD-SHELL",
+ "/gluetun-entrypoint healthcheck"
+ ],
+ "Interval": 5000000000,
+ "Timeout": 5000000000,
+ "StartPeriod": 10000000000,
+ "Retries": 1
+ },
+ "Image": "",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/gluetun-entrypoint"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "org.opencontainers.image.authors": "quentin.mcgaw@gmail.com",
+ "org.opencontainers.image.created": "2023-07-22T16:07:05.641Z",
+ "org.opencontainers.image.description": "VPN client in a thin Docker container for multiple VPN providers, written in Go, and using OpenVPN or Wireguard, DNS over TLS, with a few proxy servers built-in.",
+ "org.opencontainers.image.documentation": "https://github.com/qdm12/gluetun",
+ "org.opencontainers.image.licenses": "MIT",
+ "org.opencontainers.image.revision": "eecfb3952f202c0de3867d88e96d80c6b0f48359",
+ "org.opencontainers.image.source": "https://github.com/qdm12/gluetun",
+ "org.opencontainers.image.title": "gluetun",
+ "org.opencontainers.image.url": "https://github.com/qdm12/gluetun",
+ "org.opencontainers.image.version": "latest"
+ }
+ },
+ "Architecture": "amd64",
+ "Os": "linux",
+ "Size": 42602255,
+ "VirtualSize": 42602255,
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/a20c9490a23ee8af51898892d9bf32258d44e0e07f3799475be8e8f273a50f73/diff:/var/lib/docker/overlay2/d4c97f367c37c6ada9de57f438a3e19cc714be2a54a6f582a03de9e42d88b344/diff",
+ "MergedDir": "/var/lib/docker/overlay2/0d222a3aa067159831c4111a408e40325be1085b935c98d39c2e9a01ff50b224/merged",
+ "UpperDir": "/var/lib/docker/overlay2/0d222a3aa067159831c4111a408e40325be1085b935c98d39c2e9a01ff50b224/diff",
+ "WorkDir": "/var/lib/docker/overlay2/0d222a3aa067159831c4111a408e40325be1085b935c98d39c2e9a01ff50b224/work"
+ },
+ "Name": "overlay2"
+ },
+ "RootFS": {
+ "Type": "layers",
+ "Layers": [
+ "sha256:78a822fe2a2d2c84f3de4a403188c45f623017d6a4521d23047c9fbb0801794c",
+ "sha256:122dbeefc08382d88b3fe57ad81c1e2428af5b81c172d112723a33e2a20fe880",
+ "sha256:3d215e55b88a99dcd7cf4349618326ab129771e12fdf6c6ef5cbb71a265dbb6c"
+ ]
+ },
+ "Metadata": {
+ "LastTagTime": "0001-01-01T00:00:00Z"
+ }
+}
diff --git a/pkg/container/mocks/data/image02.json b/pkg/container/mocks/data/image_running.json
similarity index 100%
rename from pkg/container/mocks/data/image02.json
rename to pkg/container/mocks/data/image_running.json
diff --git a/pkg/container/util_test.go b/pkg/container/util_test.go
new file mode 100644
index 0000000..00912ba
--- /dev/null
+++ b/pkg/container/util_test.go
@@ -0,0 +1,50 @@
+package container_test
+
+import (
+ wt "github.com/containrrr/watchtower/pkg/types"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+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"))
+ })
+ })
+ })
+})
+
+func shortID(id string) string {
+ // Proxy to the types implementation, relocated due to package dependency resolution
+ return wt.ImageID(id).ShortID()
+}
diff --git a/pkg/filters/filters.go b/pkg/filters/filters.go
index 0e37885..4fa0bcd 100644
--- a/pkg/filters/filters.go
+++ b/pkg/filters/filters.go
@@ -1,6 +1,11 @@
package filters
-import t "github.com/containrrr/watchtower/pkg/types"
+import (
+ "regexp"
+ "strings"
+
+ t "github.com/containrrr/watchtower/pkg/types"
+)
// WatchtowerContainersFilter filters only watchtower containers
func WatchtowerContainersFilter(c t.FilterableContainer) bool { return c.IsWatchtower() }
@@ -8,7 +13,7 @@ func WatchtowerContainersFilter(c t.FilterableContainer) bool { return c.IsWatch
// NoFilter will not filter out any containers
func NoFilter(t.FilterableContainer) bool { return true }
-// FilterByNames returns all containers that match the specified name
+// FilterByNames returns all containers that match one of the specified names
func FilterByNames(names []string, baseFilter t.Filter) t.Filter {
if len(names) == 0 {
return baseFilter
@@ -16,14 +21,42 @@ func FilterByNames(names []string, baseFilter t.Filter) t.Filter {
return func(c t.FilterableContainer) bool {
for _, name := range names {
- if (name == c.Name()) || (name == c.Name()[1:]) {
+ if name == c.Name() || name == c.Name()[1:] {
return baseFilter(c)
}
+
+ if re, err := regexp.Compile(name); err == nil {
+ indices := re.FindStringIndex(c.Name())
+ if indices == nil {
+ continue
+ }
+ start := indices[0]
+ end := indices[1]
+ if start <= 1 && end >= len(c.Name())-1 {
+ return baseFilter(c)
+ }
+ }
}
return false
}
}
+// FilterByDisableNames returns all containers that don't match any of the specified names
+func FilterByDisableNames(disableNames []string, baseFilter t.Filter) t.Filter {
+ if len(disableNames) == 0 {
+ return baseFilter
+ }
+
+ return func(c t.FilterableContainer) bool {
+ for _, name := range disableNames {
+ if name == c.Name() || name == c.Name()[1:] {
+ return false
+ }
+ }
+ return baseFilter(c)
+ }
+}
+
// FilterByEnableLabel returns all containers that have the enabled label set
func FilterByEnableLabel(baseFilter t.Filter) t.Filter {
return func(c t.FilterableContainer) bool {
@@ -53,13 +86,14 @@ func FilterByDisabledLabel(baseFilter t.Filter) t.Filter {
// FilterByScope returns all containers that belongs to a specific scope
func FilterByScope(scope string, baseFilter t.Filter) t.Filter {
- if scope == "" {
- return baseFilter
- }
-
return func(c t.FilterableContainer) bool {
- containerScope, ok := c.Scope()
- if ok && containerScope == scope {
+ containerScope, containerHasScope := c.Scope()
+
+ if !containerHasScope || containerScope == "" {
+ containerScope = "none"
+ }
+
+ if containerScope == scope {
return baseFilter(c)
}
@@ -67,20 +101,81 @@ func FilterByScope(scope string, baseFilter t.Filter) t.Filter {
}
}
+// FilterByImage returns all containers that have a specific image
+func FilterByImage(images []string, baseFilter t.Filter) t.Filter {
+ if images == nil {
+ return baseFilter
+ }
+
+ return func(c t.FilterableContainer) bool {
+ image := strings.Split(c.ImageName(), ":")[0]
+ for _, targetImage := range images {
+ if image == targetImage {
+ return baseFilter(c)
+ }
+ }
+
+ return false
+ }
+}
+
// BuildFilter creates the needed filter of containers
-func BuildFilter(names []string, enableLabel bool, scope string) t.Filter {
+func BuildFilter(names []string, disableNames []string, enableLabel bool, scope string) (t.Filter, string) {
+ sb := strings.Builder{}
filter := NoFilter
filter = FilterByNames(names, filter)
+ filter = FilterByDisableNames(disableNames, filter)
+
+ if len(names) > 0 {
+ sb.WriteString("which name matches \"")
+ for i, n := range names {
+ sb.WriteString(n)
+ if i < len(names)-1 {
+ sb.WriteString(`" or "`)
+ }
+ }
+ sb.WriteString(`", `)
+ }
+ if len(disableNames) > 0 {
+ sb.WriteString("not named one of \"")
+ for i, n := range disableNames {
+ sb.WriteString(n)
+ if i < len(disableNames)-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 scope == "none" {
+ // If a scope has explicitly defined as "none", containers should only be considered
+ // if they do not have a scope defined, or if it's explicitly set to "none".
+ filter = FilterByScope(scope, filter)
+ sb.WriteString(`without a scope, "`)
+ } else 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..2b5cb5e 100644
--- a/pkg/filters/filters_test.go
+++ b/pkg/filters/filters_test.go
@@ -47,6 +47,28 @@ func TestFilterByNames(t *testing.T) {
container.AssertExpectations(t)
}
+func TestFilterByNamesRegex(t *testing.T) {
+ names := []string{`ba(b|ll)oon`}
+
+ filter := FilterByNames(names, NoFilter)
+ assert.NotNil(t, filter)
+
+ container := new(mocks.FilterableContainer)
+ container.On("Name").Return("balloon")
+ assert.True(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Name").Return("spoon")
+ assert.False(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Name").Return("baboonious")
+ assert.False(t, filter(container))
+ container.AssertExpectations(t)
+}
+
func TestFilterByEnableLabel(t *testing.T) {
filter := FilterByEnableLabel(NoFilter)
assert.NotNil(t, filter)
@@ -68,8 +90,7 @@ func TestFilterByEnableLabel(t *testing.T) {
}
func TestFilterByScope(t *testing.T) {
- var scope string
- scope = "testscope"
+ scope := "testscope"
filter := FilterByScope(scope, NoFilter)
assert.NotNil(t, filter)
@@ -90,6 +111,53 @@ func TestFilterByScope(t *testing.T) {
container.AssertExpectations(t)
}
+func TestFilterByNoneScope(t *testing.T) {
+ scope := "none"
+
+ filter := FilterByScope(scope, NoFilter)
+ assert.NotNil(t, filter)
+
+ container := new(mocks.FilterableContainer)
+ container.On("Scope").Return("anyscope", true)
+ assert.False(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Scope").Return("", false)
+ assert.True(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Scope").Return("", true)
+ assert.True(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Scope").Return("none", true)
+ assert.True(t, filter(container))
+ container.AssertExpectations(t)
+}
+
+func TestBuildFilterNoneScope(t *testing.T) {
+ filter, desc := BuildFilter(nil, nil, false, "none")
+
+ assert.Contains(t, desc, "without a scope")
+
+ scoped := new(mocks.FilterableContainer)
+ scoped.On("Enabled").Return(false, false)
+ scoped.On("Scope").Return("anyscope", true)
+
+ unscoped := new(mocks.FilterableContainer)
+ unscoped.On("Enabled").Return(false, false)
+ unscoped.On("Scope").Return("", false)
+
+ assert.False(t, filter(scoped))
+ assert.True(t, filter(unscoped))
+
+ scoped.AssertExpectations(t)
+ unscoped.AssertExpectations(t)
+}
+
func TestFilterByDisabledLabel(t *testing.T) {
filter := FilterByDisabledLabel(NoFilter)
assert.NotNil(t, filter)
@@ -110,11 +178,50 @@ func TestFilterByDisabledLabel(t *testing.T) {
container.AssertExpectations(t)
}
-func TestBuildFilter(t *testing.T) {
- var names []string
- names = append(names, "test")
+func TestFilterByImage(t *testing.T) {
+ filterEmpty := FilterByImage(nil, NoFilter)
+ filterSingle := FilterByImage([]string{"registry"}, NoFilter)
+ filterMultiple := FilterByImage([]string{"registry", "bla"}, NoFilter)
+ assert.NotNil(t, filterSingle)
+ assert.NotNil(t, filterMultiple)
- filter := BuildFilter(names, false, "")
+ container := new(mocks.FilterableContainer)
+ container.On("ImageName").Return("registry:2")
+ assert.True(t, filterEmpty(container))
+ assert.True(t, filterSingle(container))
+ assert.True(t, filterMultiple(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("ImageName").Return("registry:latest")
+ assert.True(t, filterEmpty(container))
+ assert.True(t, filterSingle(container))
+ assert.True(t, filterMultiple(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("ImageName").Return("abcdef1234")
+ assert.True(t, filterEmpty(container))
+ assert.False(t, filterSingle(container))
+ assert.False(t, filterMultiple(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("ImageName").Return("bla:latest")
+ assert.True(t, filterEmpty(container))
+ assert.False(t, filterSingle(container))
+ assert.True(t, filterMultiple(container))
+ container.AssertExpectations(t)
+
+}
+
+func TestBuildFilter(t *testing.T) {
+ names := []string{"test", "valid"}
+
+ filter, desc := BuildFilter(names, []string{}, false, "")
+ assert.Contains(t, desc, "test")
+ assert.Contains(t, desc, "or")
+ assert.Contains(t, desc, "valid")
container := new(mocks.FilterableContainer)
container.On("Name").Return("Invalid")
@@ -150,7 +257,8 @@ func TestBuildFilterEnableLabel(t *testing.T) {
var names []string
names = append(names, "test")
- filter := BuildFilter(names, true, "")
+ filter, desc := BuildFilter(names, []string{}, true, "")
+ assert.Contains(t, desc, "using enable label")
container := new(mocks.FilterableContainer)
container.On("Enabled").Return(false, false)
@@ -174,3 +282,52 @@ func TestBuildFilterEnableLabel(t *testing.T) {
assert.False(t, filter(container))
container.AssertExpectations(t)
}
+
+func TestBuildFilterDisableContainer(t *testing.T) {
+ filter, desc := BuildFilter([]string{}, []string{"excluded", "notfound"}, false, "")
+ assert.Contains(t, desc, "not named")
+ assert.Contains(t, desc, "excluded")
+ assert.Contains(t, desc, "or")
+ assert.Contains(t, desc, "notfound")
+
+ container := new(mocks.FilterableContainer)
+ container.On("Name").Return("Another")
+ container.On("Enabled").Return(false, false)
+ assert.True(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Name").Return("AnotherOne")
+ container.On("Enabled").Return(true, true)
+ assert.True(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Name").Return("test")
+ container.On("Enabled").Return(false, false)
+ assert.True(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Name").Return("excluded")
+ container.On("Enabled").Return(true, true)
+ assert.False(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Name").Return("excludedAsSubstring")
+ container.On("Enabled").Return(true, true)
+ assert.True(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Name").Return("notfound")
+ container.On("Enabled").Return(true, true)
+ assert.False(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Enabled").Return(false, true)
+ assert.False(t, filter(container))
+ container.AssertExpectations(t)
+}
diff --git a/pkg/lifecycle/lifecycle.go b/pkg/lifecycle/lifecycle.go
index df639d7..c0f962e 100644
--- a/pkg/lifecycle/lifecycle.go
+++ b/pkg/lifecycle/lifecycle.go
@@ -12,8 +12,8 @@ func ExecutePreChecks(client container.Client, params types.UpdateParams) {
if err != nil {
return
}
- for _, container := range containers {
- ExecutePreCheckCommand(client, container)
+ for _, currentContainer := range containers {
+ ExecutePreCheckCommand(client, currentContainer)
}
}
@@ -23,68 +23,84 @@ func ExecutePostChecks(client container.Client, params types.UpdateParams) {
if err != nil {
return
}
- for _, container := range containers {
- ExecutePostCheckCommand(client, container)
+ for _, currentContainer := range containers {
+ ExecutePostCheckCommand(client, currentContainer)
}
}
// ExecutePreCheckCommand tries to run the pre-check lifecycle hook for a single container.
-func ExecutePreCheckCommand(client container.Client, container container.Container) {
+func ExecutePreCheckCommand(client container.Client, container types.Container) {
+ clog := log.WithField("container", container.Name())
command := container.GetLifecyclePreCheckCommand()
if len(command) == 0 {
- log.Debug("No pre-check command supplied. Skipping")
+ clog.Debug("No pre-check command supplied. Skipping")
return
}
- log.Debug("Executing pre-check command.")
- if err := client.ExecuteCommand(container.ID(), command, 1); err != nil {
- log.Error(err)
+ clog.Debug("Executing pre-check command.")
+ _, err := client.ExecuteCommand(container.ID(), command, 1)
+ if err != nil {
+ clog.Error(err)
}
}
// ExecutePostCheckCommand tries to run the post-check lifecycle hook for a single container.
-func ExecutePostCheckCommand(client container.Client, container container.Container) {
+func ExecutePostCheckCommand(client container.Client, container types.Container) {
+ clog := log.WithField("container", container.Name())
command := container.GetLifecyclePostCheckCommand()
if len(command) == 0 {
- log.Debug("No post-check command supplied. Skipping")
+ clog.Debug("No post-check command supplied. Skipping")
return
}
- log.Debug("Executing post-check command.")
- if err := client.ExecuteCommand(container.ID(), command, 1); err != nil {
- log.Error(err)
+ clog.Debug("Executing post-check command.")
+ _, err := client.ExecuteCommand(container.ID(), command, 1)
+ if err != nil {
+ clog.Error(err)
}
}
// ExecutePreUpdateCommand tries to run the pre-update lifecycle hook for a single container.
-func ExecutePreUpdateCommand(client container.Client, container container.Container) error {
+func ExecutePreUpdateCommand(client container.Client, container types.Container) (SkipUpdate bool, err error) {
timeout := container.PreUpdateTimeout()
command := container.GetLifecyclePreUpdateCommand()
+ clog := log.WithField("container", container.Name())
+
if len(command) == 0 {
- log.Debug("No pre-update command supplied. Skipping")
- return nil
+ clog.Debug("No pre-update command supplied. Skipping")
+ return false, nil
}
- log.Debug("Executing pre-update command.")
+ if !container.IsRunning() || container.IsRestarting() {
+ clog.Debug("Container is not running. Skipping pre-update command.")
+ return false, nil
+ }
+
+ clog.Debug("Executing pre-update command.")
return client.ExecuteCommand(container.ID(), command, timeout)
}
// ExecutePostUpdateCommand tries to run the post-update lifecycle hook for a single container.
-func ExecutePostUpdateCommand(client container.Client, newContainerID string) {
+func ExecutePostUpdateCommand(client container.Client, newContainerID types.ContainerID) {
newContainer, err := client.GetContainer(newContainerID)
+ timeout := newContainer.PostUpdateTimeout()
+
if err != nil {
- log.Error(err)
+ log.WithField("containerID", newContainerID.ShortID()).Error(err)
return
}
+ clog := log.WithField("container", newContainer.Name())
command := newContainer.GetLifecyclePostUpdateCommand()
if len(command) == 0 {
- log.Debug("No post-update command supplied. Skipping")
+ clog.Debug("No post-update command supplied. Skipping")
return
}
- log.Debug("Executing post-update command.")
- if err := client.ExecuteCommand(newContainerID, command, 1); err != nil {
- log.Error(err)
+ clog.Debug("Executing post-update command.")
+ _, err = client.ExecuteCommand(newContainerID, command, timeout)
+
+ if err != nil {
+ clog.Error(err)
}
}
diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go
index 3a235af..b681733 100644
--- a/pkg/metrics/metrics.go
+++ b/pkg/metrics/metrics.go
@@ -1,6 +1,7 @@
package metrics
import (
+ "github.com/containrrr/watchtower/pkg/types"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
@@ -24,6 +25,21 @@ type Metrics struct {
skipped prometheus.Counter
}
+// NewMetric returns a Metric with the counts taken from the appropriate types.Report fields
+func NewMetric(report types.Report) *Metric {
+ return &Metric{
+ Scanned: len(report.Scanned()),
+ // Note: This is for backwards compatibility. ideally, stale containers should be counted separately
+ Updated: len(report.Updated()) + len(report.Stale()),
+ Failed: len(report.Failed()),
+ }
+}
+
+// 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
diff --git a/pkg/notifications/common_templates.go b/pkg/notifications/common_templates.go
new file mode 100644
index 0000000..84c0f54
--- /dev/null
+++ b/pkg/notifications/common_templates.go
@@ -0,0 +1,40 @@
+package notifications
+
+var commonTemplates = map[string]string{
+ `default-legacy`: "{{range .}}{{.Message}}{{println}}{{end}}",
+
+ `default`: `
+{{- if .Report -}}
+ {{- with .Report -}}
+ {{- if ( or .Updated .Failed ) -}}
+{{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
+ {{- range .Updated}}
+- {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
+ {{- end -}}
+ {{- range .Fresh}}
+- {{.Name}} ({{.ImageName}}): {{.State}}
+ {{- end -}}
+ {{- range .Skipped}}
+- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- range .Failed}}
+- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+{{- else -}}
+ {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
+{{- end -}}`,
+
+ `porcelain.v1.summary-no-log`: `
+{{- if .Report -}}
+ {{- range .Report.All }}
+ {{- .Name}} ({{.ImageName}}): {{.State -}}
+ {{- with .Error}} Error: {{.}}{{end}}{{ println }}
+ {{- else -}}
+ no containers matched filter
+ {{- end -}}
+{{- end -}}`,
+
+ `json.v1`: `{{ . | ToJSON }}`,
+}
diff --git a/pkg/notifications/email.go b/pkg/notifications/email.go
index 184bf84..9103d38 100644
--- a/pkg/notifications/email.go
+++ b/pkg/notifications/email.go
@@ -1,11 +1,8 @@
package notifications
import (
- "fmt"
- "os"
"time"
- "github.com/containrrr/shoutrrr/pkg/format"
"github.com/spf13/cobra"
shoutrrrSmtp "github.com/containrrr/shoutrrr/pkg/services/smtp"
@@ -18,23 +15,16 @@ const (
)
type emailTypeNotifier struct {
- url string
- From, To string
- Server, User, Password, SubjectTag string
- Port int
- tlsSkipVerify bool
- entries []*log.Entry
- logLevels []log.Level
- delay time.Duration
+ From, To string
+ Server, User, Password string
+ Port int
+ tlsSkipVerify bool
+ entries []*log.Entry
+ delay time.Duration
}
-// NewEmailNotifier is a factory method creating a new email notifier instance
-func NewEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertableNotifier {
- return newEmailNotifier(c, acceptedLogLevels)
-}
-
-func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertableNotifier {
- flags := c.PersistentFlags()
+func newEmailNotifier(c *cobra.Command) t.ConvertibleNotifier {
+ flags := c.Flags()
from, _ := flags.GetString("notification-email-from")
to, _ := flags.GetString("notification-email-to")
@@ -44,7 +34,6 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert
port, _ := flags.GetInt("notification-email-server-port")
tlsSkipVerify, _ := flags.GetBool("notification-email-server-tls-skip-verify")
delay, _ := flags.GetInt("notification-email-delay")
- subjecttag, _ := flags.GetString("notification-email-subjecttag")
n := &emailTypeNotifier{
entries: []*log.Entry{},
@@ -55,61 +44,39 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert
Password: password,
Port: port,
tlsSkipVerify: tlsSkipVerify,
- logLevels: acceptedLogLevels,
delay: time.Duration(delay) * time.Second,
- SubjectTag: subjecttag,
}
return n
}
-func (e *emailTypeNotifier) GetURL() string {
+func (e *emailTypeNotifier) GetURL(c *cobra.Command) (string, error) {
conf := &shoutrrrSmtp.Config{
FromAddress: e.From,
FromName: "Watchtower",
ToAddresses: []string{e.To},
Port: uint16(e.Port),
Host: e.Server,
- Subject: e.getSubject(),
Username: e.User,
Password: e.Password,
- UseStartTLS: true,
+ UseStartTLS: !e.tlsSkipVerify,
UseHTML: false,
+ Encryption: shoutrrrSmtp.EncMethods.Auto,
+ Auth: shoutrrrSmtp.AuthTypes.None,
+ ClientHost: "localhost",
}
- pkr := format.NewPropKeyResolver(conf)
- var err error
if len(e.User) > 0 {
- err = pkr.Set("auth", "Plain")
- } else {
- err = pkr.Set("auth", "None")
+ conf.Auth = shoutrrrSmtp.AuthTypes.Plain
}
- if err != nil {
- fmt.Printf("Could not set auth type for email notifier: %v", err)
+ if e.tlsSkipVerify {
+ conf.Encryption = shoutrrrSmtp.EncMethods.None
}
- return conf.GetURL().String()
+ return conf.GetURL().String(), nil
}
-func (e *emailTypeNotifier) getSubject() string {
- 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
- }
- return emailSubject
+func (e *emailTypeNotifier) GetDelay() time.Duration {
+ return e.delay
}
-
-// TODO: Delete these once all notifiers have been converted to shoutrrr
-func (e *emailTypeNotifier) StartNotification() {}
-func (e *emailTypeNotifier) SendNotification() {}
-func (e *emailTypeNotifier) Levels() []log.Level { return nil }
-func (e *emailTypeNotifier) Fire(entry *log.Entry) error { return nil }
-
-func (e *emailTypeNotifier) Close() {}
diff --git a/pkg/notifications/gotify.go b/pkg/notifications/gotify.go
index bb475bf..c36eb4b 100644
--- a/pkg/notifications/gotify.go
+++ b/pkg/notifications/gotify.go
@@ -1,6 +1,7 @@
package notifications
import (
+ "net/url"
"strings"
shoutrrrGotify "github.com/containrrr/shoutrrr/pkg/services/gotify"
@@ -18,27 +19,20 @@ type gotifyTypeNotifier struct {
gotifyURL string
gotifyAppToken string
gotifyInsecureSkipVerify bool
- logLevels []log.Level
}
-// NewGotifyNotifier is a factory method creating a new gotify notifier instance
-func NewGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertableNotifier {
- return newGotifyNotifier(c, levels)
-}
+func newGotifyNotifier(c *cobra.Command) t.ConvertibleNotifier {
+ flags := c.Flags()
-func newGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertableNotifier {
- flags := c.PersistentFlags()
-
- url := getGotifyURL(flags)
+ apiURL := getGotifyURL(flags)
token := getGotifyToken(flags)
skipVerify, _ := flags.GetBool("notification-gotify-tls-skip-verify")
n := &gotifyTypeNotifier{
- gotifyURL: url,
+ gotifyURL: apiURL,
gotifyAppToken: token,
gotifyInsecureSkipVerify: skipVerify,
- logLevels: levels,
}
return n
@@ -66,26 +60,18 @@ func getGotifyURL(flags *pflag.FlagSet) string {
return gotifyURL
}
-func (n *gotifyTypeNotifier) GetURL() string {
- url := n.gotifyURL
-
- if strings.HasPrefix(url, "https://") {
- url = strings.TrimPrefix(url, "https://")
- } else {
- url = strings.TrimPrefix(url, "http://")
+func (n *gotifyTypeNotifier) GetURL(c *cobra.Command) (string, error) {
+ apiURL, err := url.Parse(n.gotifyURL)
+ if err != nil {
+ return "", err
}
- url = strings.TrimSuffix(url, "/")
-
config := &shoutrrrGotify.Config{
- Host: url,
- Token: n.gotifyAppToken,
+ Host: apiURL.Host,
+ Path: apiURL.Path,
+ DisableTLS: apiURL.Scheme == "http",
+ Token: n.gotifyAppToken,
}
- return config.GetURL().String()
+ return config.GetURL().String(), nil
}
-
-func (n *gotifyTypeNotifier) StartNotification() {}
-func (n *gotifyTypeNotifier) SendNotification() {}
-func (n *gotifyTypeNotifier) Close() {}
-func (n *gotifyTypeNotifier) Levels() []log.Level { return nil }
diff --git a/pkg/notifications/json.go b/pkg/notifications/json.go
new file mode 100644
index 0000000..20da92b
--- /dev/null
+++ b/pkg/notifications/json.go
@@ -0,0 +1,61 @@
+package notifications
+
+import (
+ "encoding/json"
+
+ t "github.com/containrrr/watchtower/pkg/types"
+)
+
+type jsonMap = map[string]interface{}
+
+// MarshalJSON implements json.Marshaler
+func (d Data) MarshalJSON() ([]byte, error) {
+ var entries = make([]jsonMap, len(d.Entries))
+ for i, entry := range d.Entries {
+ entries[i] = jsonMap{
+ `level`: entry.Level,
+ `message`: entry.Message,
+ `data`: entry.Data,
+ `time`: entry.Time,
+ }
+ }
+
+ var report jsonMap
+ if d.Report != nil {
+ report = jsonMap{
+ `scanned`: marshalReports(d.Report.Scanned()),
+ `updated`: marshalReports(d.Report.Updated()),
+ `failed`: marshalReports(d.Report.Failed()),
+ `skipped`: marshalReports(d.Report.Skipped()),
+ `stale`: marshalReports(d.Report.Stale()),
+ `fresh`: marshalReports(d.Report.Fresh()),
+ }
+ }
+
+ return json.Marshal(jsonMap{
+ `report`: report,
+ `title`: d.Title,
+ `host`: d.Host,
+ `entries`: entries,
+ })
+}
+
+func marshalReports(reports []t.ContainerReport) []jsonMap {
+ jsonReports := make([]jsonMap, len(reports))
+ for i, report := range reports {
+ jsonReports[i] = jsonMap{
+ `id`: report.ID().ShortID(),
+ `name`: report.Name(),
+ `currentImageId`: report.CurrentImageID().ShortID(),
+ `latestImageId`: report.LatestImageID().ShortID(),
+ `imageName`: report.ImageName(),
+ `state`: report.State(),
+ }
+ if errorMessage := report.Error(); errorMessage != "" {
+ jsonReports[i][`error`] = errorMessage
+ }
+ }
+ return jsonReports
+}
+
+var _ json.Marshaler = &Data{}
diff --git a/pkg/notifications/json_test.go b/pkg/notifications/json_test.go
new file mode 100644
index 0000000..ef30c59
--- /dev/null
+++ b/pkg/notifications/json_test.go
@@ -0,0 +1,118 @@
+package notifications
+
+import (
+ s "github.com/containrrr/watchtower/pkg/session"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("JSON template", func() {
+ When("using report templates", func() {
+ When("JSON template is used", func() {
+ It("should format the messages to the expected format", func() {
+ expected := `{
+ "entries": [
+ {
+ "data": null,
+ "level": "info",
+ "message": "foo Bar",
+ "time": "0001-01-01T00:00:00Z"
+ }
+ ],
+ "host": "Mock",
+ "report": {
+ "failed": [
+ {
+ "currentImageId": "01d210000000",
+ "error": "accidentally the whole container",
+ "id": "c79210000000",
+ "imageName": "mock/fail1:latest",
+ "latestImageId": "d0a210000000",
+ "name": "fail1",
+ "state": "Failed"
+ }
+ ],
+ "fresh": [
+ {
+ "currentImageId": "01d310000000",
+ "id": "c79310000000",
+ "imageName": "mock/frsh1:latest",
+ "latestImageId": "01d310000000",
+ "name": "frsh1",
+ "state": "Fresh"
+ }
+ ],
+ "scanned": [
+ {
+ "currentImageId": "01d110000000",
+ "id": "c79110000000",
+ "imageName": "mock/updt1:latest",
+ "latestImageId": "d0a110000000",
+ "name": "updt1",
+ "state": "Updated"
+ },
+ {
+ "currentImageId": "01d120000000",
+ "id": "c79120000000",
+ "imageName": "mock/updt2:latest",
+ "latestImageId": "d0a120000000",
+ "name": "updt2",
+ "state": "Updated"
+ },
+ {
+ "currentImageId": "01d210000000",
+ "error": "accidentally the whole container",
+ "id": "c79210000000",
+ "imageName": "mock/fail1:latest",
+ "latestImageId": "d0a210000000",
+ "name": "fail1",
+ "state": "Failed"
+ },
+ {
+ "currentImageId": "01d310000000",
+ "id": "c79310000000",
+ "imageName": "mock/frsh1:latest",
+ "latestImageId": "01d310000000",
+ "name": "frsh1",
+ "state": "Fresh"
+ }
+ ],
+ "skipped": [
+ {
+ "currentImageId": "01d410000000",
+ "error": "unpossible",
+ "id": "c79410000000",
+ "imageName": "mock/skip1:latest",
+ "latestImageId": "01d410000000",
+ "name": "skip1",
+ "state": "Skipped"
+ }
+ ],
+ "stale": [],
+ "updated": [
+ {
+ "currentImageId": "01d110000000",
+ "id": "c79110000000",
+ "imageName": "mock/updt1:latest",
+ "latestImageId": "d0a110000000",
+ "name": "updt1",
+ "state": "Updated"
+ },
+ {
+ "currentImageId": "01d120000000",
+ "id": "c79120000000",
+ "imageName": "mock/updt2:latest",
+ "latestImageId": "d0a120000000",
+ "name": "updt2",
+ "state": "Updated"
+ }
+ ]
+ },
+ "title": "Watchtower updates on Mock"
+}`
+ data := mockDataFromStates(s.UpdatedState, s.FreshState, s.FailedState, s.SkippedState, s.UpdatedState)
+ Expect(getTemplatedResult(`json.v1`, false, data)).To(MatchJSON(expected))
+ })
+ })
+ })
+})
diff --git a/pkg/notifications/model.go b/pkg/notifications/model.go
new file mode 100644
index 0000000..83c97ba
--- /dev/null
+++ b/pkg/notifications/model.go
@@ -0,0 +1,19 @@
+package notifications
+
+import (
+ t "github.com/containrrr/watchtower/pkg/types"
+ log "github.com/sirupsen/logrus"
+)
+
+// StaticData is the part of the notification template data model set upon initialization
+type StaticData struct {
+ Title string
+ Host string
+}
+
+// Data is the notification template data model
+type Data struct {
+ StaticData
+ Entries []*log.Entry
+ Report t.Report
+}
diff --git a/pkg/notifications/msteams.go b/pkg/notifications/msteams.go
index 63c6aaa..cfca30e 100644
--- a/pkg/notifications/msteams.go
+++ b/pkg/notifications/msteams.go
@@ -1,7 +1,7 @@
package notifications
import (
- "strings"
+ "net/url"
shoutrrrTeams "github.com/containrrr/shoutrrr/pkg/services/teams"
t "github.com/containrrr/watchtower/pkg/types"
@@ -15,18 +15,12 @@ const (
type msTeamsTypeNotifier struct {
webHookURL string
- levels []log.Level
data bool
}
-// NewMsTeamsNotifier is a factory method creating a new teams notifier instance
-func NewMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.ConvertableNotifier {
- return newMsTeamsNotifier(cmd, acceptedLogLevels)
-}
+func newMsTeamsNotifier(cmd *cobra.Command) t.ConvertibleNotifier {
-func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.ConvertableNotifier {
-
- flags := cmd.PersistentFlags()
+ flags := cmd.Flags()
webHookURL, _ := flags.GetString("notification-msteams-hook")
if len(webHookURL) <= 0 {
@@ -35,7 +29,6 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Con
withData, _ := flags.GetBool("notification-msteams-data")
n := &msTeamsTypeNotifier{
- levels: acceptedLogLevels,
webHookURL: webHookURL,
data: withData,
}
@@ -43,26 +36,18 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Con
return n
}
-func (n *msTeamsTypeNotifier) GetURL() string {
-
- baseURL := "https://outlook.office.com/webhook/"
-
- path := strings.Replace(n.webHookURL, baseURL, "", 1)
- rawToken := strings.Replace(path, "/IncomingWebhook", "", 1)
- token := strings.Split(rawToken, "/")
- config := &shoutrrrTeams.Config{
- Token: shoutrrrTeams.Token{
- A: token[0],
- B: token[1],
- C: token[2],
- },
+func (n *msTeamsTypeNotifier) GetURL(c *cobra.Command) (string, error) {
+ webhookURL, err := url.Parse(n.webHookURL)
+ if err != nil {
+ return "", err
}
- return config.GetURL().String()
-}
+ config, err := shoutrrrTeams.ConfigFromWebhookURL(*webhookURL)
+ if err != nil {
+ return "", err
+ }
-func (n *msTeamsTypeNotifier) StartNotification() {}
-func (n *msTeamsTypeNotifier) SendNotification() {}
-func (n *msTeamsTypeNotifier) Close() {}
-func (n *msTeamsTypeNotifier) Levels() []log.Level { return nil }
-func (n *msTeamsTypeNotifier) Fire(entry *log.Entry) error { return nil }
+ config.Color = ColorHex
+
+ return config.GetURL().String(), nil
+}
diff --git a/pkg/notifications/notifications_suite_test.go b/pkg/notifications/notifications_suite_test.go
new file mode 100644
index 0000000..19d286e
--- /dev/null
+++ b/pkg/notifications/notifications_suite_test.go
@@ -0,0 +1,15 @@
+package notifications_test
+
+import (
+ "github.com/onsi/gomega/format"
+ "testing"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+func TestNotifications(t *testing.T) {
+ RegisterFailHandler(Fail)
+ format.CharactersAroundMismatchToInclude = 20
+ RunSpecs(t, "Notifications Suite")
+}
diff --git a/pkg/notifications/notifier.go b/pkg/notifications/notifier.go
index dea0fc8..ff7b6b5 100644
--- a/pkg/notifications/notifier.go
+++ b/pkg/notifications/notifier.go
@@ -1,22 +1,18 @@
package notifications
import (
+ "os"
+ "strings"
+ "time"
+
ty "github.com/containrrr/watchtower/pkg/types"
- "github.com/johntdyer/slackrus"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
-// Notifier can send log output as notification to admins, with optional batching.
-type Notifier struct {
- types []ty.Notifier
-}
-
// NewNotifier creates and returns a new Notifier, using global configuration.
-func NewNotifier(c *cobra.Command) *Notifier {
- n := &Notifier{}
-
- f := c.PersistentFlags()
+func NewNotifier(c *cobra.Command) ty.Notifier {
+ f := c.Flags()
level, _ := f.GetString("notifications-level")
logLevel, err := log.ParseLevel(level)
@@ -24,74 +20,128 @@ func NewNotifier(c *cobra.Command) *Notifier {
log.Fatalf("Notifications invalid log level: %s", err.Error())
}
- acceptedLogLevels := slackrus.LevelThreshold(logLevel)
+ reportTemplate, _ := f.GetBool("notification-report")
+ stdout, _ := f.GetBool("notification-log-stdout")
+ tplString, _ := f.GetString("notification-template")
+ urls, _ := f.GetStringArray("notification-url")
- // 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()
- }
+ data := GetTemplateData(c)
+ urls, delay := AppendLegacyUrls(urls, c)
- n.types = n.GetNotificationTypes(c, acceptedLogLevels, types)
-
- return n
+ return createNotifier(urls, logLevel, tplString, !reportTemplate, data, stdout, delay)
}
-// 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)
+// AppendLegacyUrls creates shoutrrr equivalent URLs from legacy notification flags
+func AppendLegacyUrls(urls []string, cmd *cobra.Command) ([]string, time.Duration) {
+
+ // Parse types and create notifiers.
+ types, err := cmd.Flags().GetStringSlice("notifications")
+ if err != nil {
+ log.WithError(err).Fatal("could not read notifications argument")
+ }
+
+ legacyDelay := time.Duration(0)
for _, t := range types {
- if t == shoutrrrType {
- output = append(output, newShoutrrrNotifier(cmd, levels))
- continue
- }
-
- var legacyNotifier ty.ConvertableNotifier
+ var legacyNotifier ty.ConvertibleNotifier
+ var err error
switch t {
case emailType:
- legacyNotifier = newEmailNotifier(cmd, []log.Level{})
+ legacyNotifier = newEmailNotifier(cmd)
case slackType:
- legacyNotifier = newSlackNotifier(cmd, []log.Level{})
+ legacyNotifier = newSlackNotifier(cmd)
case msTeamsType:
- legacyNotifier = newMsTeamsNotifier(cmd, levels)
+ legacyNotifier = newMsTeamsNotifier(cmd)
case gotifyType:
- legacyNotifier = newGotifyNotifier(cmd, []log.Level{})
+ legacyNotifier = newGotifyNotifier(cmd)
+ case shoutrrrType:
+ continue
default:
log.Fatalf("Unknown notification type %q", t)
+ // Not really needed, used for nil checking static analysis
+ continue
}
- notifier := newShoutrrrNotifierFromURL(
- cmd,
- legacyNotifier.GetURL(),
- levels,
- )
+ shoutrrrURL, err := legacyNotifier.GetURL(cmd)
+ if err != nil {
+ log.Fatal("failed to create notification config: ", err)
+ }
+ urls = append(urls, shoutrrrURL)
- output = append(output, notifier)
+ if delayNotifier, ok := legacyNotifier.(ty.DelayNotifier); ok {
+ legacyDelay = delayNotifier.GetDelay()
+ }
+
+ log.WithField("URL", shoutrrrURL).Trace("created Shoutrrr URL from legacy notifier")
}
- return output
+ delay := GetDelay(cmd, legacyDelay)
+ return urls, delay
}
-// 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 {
- t.StartNotification()
+// GetDelay returns the legacy delay if defined, otherwise the delay as set by args is returned
+func GetDelay(c *cobra.Command, legacyDelay time.Duration) time.Duration {
+ if legacyDelay > 0 {
+ return legacyDelay
+ }
+
+ delay, _ := c.PersistentFlags().GetInt("notifications-delay")
+ if delay > 0 {
+ return time.Duration(delay) * time.Second
+ }
+ return time.Duration(0)
+}
+
+// GetTitle formats the title based on the passed hostname and tag
+func GetTitle(hostname string, tag string) string {
+ tb := strings.Builder{}
+
+ if tag != "" {
+ tb.WriteRune('[')
+ tb.WriteString(tag)
+ tb.WriteRune(']')
+ tb.WriteRune(' ')
+ }
+
+ tb.WriteString("Watchtower updates")
+
+ if hostname != "" {
+ tb.WriteString(" on ")
+ tb.WriteString(hostname)
+ }
+
+ return tb.String()
+}
+
+// GetTemplateData populates the static notification data from flags and environment
+func GetTemplateData(c *cobra.Command) StaticData {
+ f := c.PersistentFlags()
+
+ hostname, _ := f.GetString("notifications-hostname")
+ if hostname == "" {
+ hostname, _ = os.Hostname()
+ }
+
+ title := ""
+ if skip, _ := f.GetBool("notification-skip-title"); !skip {
+ tag, _ := f.GetString("notification-title-tag")
+ if tag == "" {
+ // For legacy email support
+ tag, _ = f.GetString("notification-email-subjecttag")
+ }
+ title = GetTitle(hostname, tag)
+ }
+
+ return StaticData{
+ Host: hostname,
+ Title: title,
}
}
-// SendNotification sends any notifications accumulated since StartNotification() was called.
-func (n *Notifier) SendNotification() {
- for _, t := range n.types {
- t.SendNotification()
- }
-}
+// ColorHex is the default notification color used for services that support it (formatted as a CSS hex string)
+const ColorHex = "#406170"
-// Close closes all notifiers.
-func (n *Notifier) Close() {
- for _, t := range n.types {
- t.Close()
- }
-}
+// 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
index 57132ac..96d513c 100644
--- a/pkg/notifications/notifier_test.go
+++ b/pkg/notifications/notifier_test.go
@@ -2,33 +2,153 @@ package notifications_test
import (
"fmt"
- "os"
- "testing"
+ "net/url"
+ "time"
"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.GetNames()).To(BeEmpty())
+ })
+ When("title is overriden in flag", func() {
+ It("should use the specified hostname in the title", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ err := command.ParseFlags([]string{
+ "--notifications-hostname",
+ "test.host",
+ })
+ Expect(err).NotTo(HaveOccurred())
+ data := notifications.GetTemplateData(command)
+ title := data.Title
+ Expect(title).To(Equal("Watchtower updates on test.host"))
+ })
+ })
+ When("no hostname can be resolved", func() {
+ It("should use the default simple title", func() {
+ title := notifications.GetTitle("", "")
+ Expect(title).To(Equal("Watchtower updates"))
+ })
+ })
+ When("title tag is set", func() {
+ It("should use the prefix in the title", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ Expect(command.ParseFlags([]string{
+ "--notification-title-tag",
+ "PREFIX",
+ })).To(Succeed())
+
+ data := notifications.GetTemplateData(command)
+ Expect(data.Title).To(HavePrefix("[PREFIX]"))
+ })
+ })
+ When("legacy email tag is set", func() {
+ It("should use the prefix in the title", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ Expect(command.ParseFlags([]string{
+ "--notification-email-subjecttag",
+ "PREFIX",
+ })).To(Succeed())
+
+ data := notifications.GetTemplateData(command)
+ Expect(data.Title).To(HavePrefix("[PREFIX]"))
+ })
+ })
+ When("the skip title flag is set", func() {
+ It("should return an empty title", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ Expect(command.ParseFlags([]string{
+ "--notification-skip-title",
+ })).To(Succeed())
+
+ data := notifications.GetTemplateData(command)
+ Expect(data.Title).To(BeEmpty())
+ })
+ })
+ When("no delay is defined", func() {
+ It("should use the default delay", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ delay := notifications.GetDelay(command, time.Duration(0))
+ Expect(delay).To(Equal(time.Duration(0)))
+ })
+ })
+ When("delay is defined", func() {
+ It("should use the specified delay", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ err := command.ParseFlags([]string{
+ "--notifications-delay",
+ "5",
+ })
+ Expect(err).NotTo(HaveOccurred())
+ delay := notifications.GetDelay(command, time.Duration(0))
+ Expect(delay).To(Equal(time.Duration(5) * time.Second))
+ })
+ })
+ When("legacy delay is defined", func() {
+ It("should use the specified legacy delay", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+ delay := notifications.GetDelay(command, time.Duration(5)*time.Second)
+ Expect(delay).To(Equal(time.Duration(5) * time.Second))
+ })
+ })
+ When("legacy delay and delay is defined", func() {
+ It("should use the specified legacy delay and ignore the specified delay", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ err := command.ParseFlags([]string{
+ "--notifications-delay",
+ "0",
+ })
+ Expect(err).NotTo(HaveOccurred())
+ delay := notifications.GetDelay(command, time.Duration(7)*time.Second)
+ Expect(delay).To(Equal(time.Duration(7) * time.Second))
+ })
+ })
+ })
Describe("the slack notifier", func() {
- builderFn := notifications.NewSlackNotifier
+ // builderFn := notifications.NewSlackNotifier
When("passing a discord url to the slack notifier", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
channel := "123456789"
token := "abvsihdbau"
- expected := fmt.Sprintf("discord://%s@%s", token, channel)
+ color := notifications.ColorInt
+ username := "containrrrbot"
+ iconURL := "https://containrrr.dev/watchtower-sq180.png"
+ expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&username=watchtower", token, channel, color)
buildArgs := func(url string) []string {
return []string{
"--notifications",
@@ -40,136 +160,218 @@ var _ = Describe("notifications", func() {
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)
+ testURL(buildArgs(hookURL), expected, time.Duration(0))
})
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)
+ testURL(buildArgs(hookURL), expected, time.Duration(0))
+ })
+ When("icon URL and username are specified", func() {
+ It("should return the expected URL", func() {
+ hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discord.com", channel, token)
+ expectedOutput := fmt.Sprintf("discord://%s@%s?avatar=%s&color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&username=%s", token, channel, url.QueryEscape(iconURL), color, username)
+ expectedDelay := time.Duration(7) * time.Second
+ args := []string{
+ "--notifications",
+ "slack",
+ "--notification-slack-hook-url",
+ hookURL,
+ "--notification-slack-identifier",
+ username,
+ "--notification-slack-icon-url",
+ iconURL,
+ "--notifications-delay",
+ fmt.Sprint(expectedDelay.Seconds()),
+ }
+
+ testURL(args, expectedOutput, expectedDelay)
+ })
})
})
When("converting a slack service config into a shoutrrr url", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+ username := "containrrrbot"
+ tokenA := "AAAAAAAAA"
+ tokenB := "BBBBBBBBB"
+ tokenC := "123456789123456789123456"
+ color := url.QueryEscape(notifications.ColorHex)
+ iconURL := "https://containrrr.dev/watchtower-sq180.png"
+ iconEmoji := "whale"
- It("should return the expected URL", func() {
+ When("icon URL is specified", func() {
+ It("should return the expected URL", func() {
- username := "containrrrbot"
- tokenA := "aaa"
- tokenB := "bbb"
- tokenC := "ccc"
+ hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
+ expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s", tokenA, tokenB, tokenC, username, color, url.QueryEscape(iconURL))
+ expectedDelay := time.Duration(7) * time.Second
- hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
- expectedOutput := fmt.Sprintf("slack://%s@%s/%s/%s", username, tokenA, tokenB, tokenC)
+ args := []string{
+ "--notifications",
+ "slack",
+ "--notification-slack-hook-url",
+ hookURL,
+ "--notification-slack-identifier",
+ username,
+ "--notification-slack-icon-url",
+ iconURL,
+ "--notifications-delay",
+ fmt.Sprint(expectedDelay.Seconds()),
+ }
- args := []string{
- "--notification-slack-hook-url",
- hookURL,
- "--notification-slack-identifier",
- username,
- }
+ testURL(args, expectedOutput, expectedDelay)
+ })
+ })
- testURL(builderFn, args, expectedOutput)
+ When("icon emoji is specified", func() {
+ It("should return the expected URL", func() {
+ hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
+ expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s", tokenA, tokenB, tokenC, username, color, iconEmoji)
+
+ args := []string{
+ "--notifications",
+ "slack",
+ "--notification-slack-hook-url",
+ hookURL,
+ "--notification-slack-identifier",
+ username,
+ "--notification-slack-icon-emoji",
+ iconEmoji,
+ }
+
+ testURL(args, expectedOutput, time.Duration(0))
+ })
})
})
})
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() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
token := "aaa"
host := "shoutrrr.local"
- expectedOutput := fmt.Sprintf("gotify://%s/%s", host, token)
+ expectedOutput := fmt.Sprintf("gotify://%s/%s?title=", host, token)
args := []string{
+ "--notifications",
+ "gotify",
"--notification-gotify-url",
fmt.Sprintf("https://%s", host),
"--notification-gotify-token",
token,
}
- testURL(builderFn, args, expectedOutput)
+ testURL(args, expectedOutput, time.Duration(0))
})
})
})
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() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
- tokenA := "aaa"
- tokenB := "bbb"
- tokenC := "ccc"
+ tokenA := "11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc"
+ tokenB := "33333333012222222222333333333344"
+ tokenC := "44444444-4444-4444-8444-cccccccccccc"
+ color := url.QueryEscape(notifications.ColorHex)
hookURL := fmt.Sprintf("https://outlook.office.com/webhook/%s/IncomingWebhook/%s/%s", tokenA, tokenB, tokenC)
- expectedOutput := fmt.Sprintf("teams://%s:%s@%s", tokenA, tokenB, tokenC)
+ expectedOutput := fmt.Sprintf("teams://%s/%s/%s?color=%s", tokenA, tokenB, tokenC, color)
args := []string{
+ "--notifications",
+ "msteams",
"--notification-msteams-hook",
hookURL,
}
- testURL(builderFn, args, expectedOutput)
+ testURL(args, expectedOutput, time.Duration(0))
})
})
})
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("", "", "", 25, fromAddress, "", "None")
+ expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, "mail@example.com", "Plain")
+ expectedDelay := time.Duration(7) * time.Second
+
args := []string{
+ "--notifications",
+ "email",
"--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",
+ "--notifications-delay",
+ fmt.Sprint(expectedDelay.Seconds()),
}
- testURL(builderFn, args, expectedOutput)
+ testURL(args, expectedOutput, expectedDelay)
})
It("should return the expected URL", func() {
fromAddress := "sender@example.com"
toAddress := "receiver@example.com"
- expectedOutput := buildExpectedURL("", "", "", 25, fromAddress, toAddress, "None")
+ expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, toAddress, "Plain")
+ expectedDelay := time.Duration(7) * time.Second
args := []string{
+ "--notifications",
+ "email",
"--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",
+ "--notification-email-delay",
+ fmt.Sprint(expectedDelay.Seconds()),
}
- testURL(builderFn, args, expectedOutput)
+ testURL(args, expectedOutput, expectedDelay)
})
})
})
})
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&encryption=None&fromaddress=%s&fromname=Watchtower&starttls=Yes&subject=%s&toaddresses=%s&usehtml=No"
- return fmt.Sprintf(template, username, password, host, port, auth, from, subject, to)
+ var template = "smtp://%s:%s@%s:%d/?auth=%s&fromaddress=%s&fromname=Watchtower&subject=&toaddresses=%s"
+ return fmt.Sprintf(template,
+ url.QueryEscape(username),
+ url.QueryEscape(password),
+ host, port, auth,
+ url.QueryEscape(from),
+ url.QueryEscape(to))
}
-type builderFn = func(c *cobra.Command, acceptedLogLevels []log.Level) types.ConvertableNotifier
-
-func testURL(builder builderFn, args []string, expectedURL string) {
+func testURL(args []string, expectedURL string, expectedDelay time.Duration) {
+ defer GinkgoRecover()
command := cmd.NewRootCommand()
flags.RegisterNotificationFlags(command)
- command.ParseFlags(args)
- notifier := builder(command, []log.Level{})
- actualURL := notifier.GetURL()
+ Expect(command.ParseFlags(args)).To(Succeed())
- Expect(actualURL).To(Equal(expectedURL))
+ urls, delay := notifications.AppendLegacyUrls([]string{}, command)
+
+ Expect(urls).To(ContainElement(expectedURL))
+ Expect(delay).To(Equal(expectedDelay))
}
diff --git a/pkg/notifications/preview/data/data.go b/pkg/notifications/preview/data/data.go
new file mode 100644
index 0000000..4a002ed
--- /dev/null
+++ b/pkg/notifications/preview/data/data.go
@@ -0,0 +1,143 @@
+package data
+
+import (
+ "encoding/hex"
+ "errors"
+ "math/rand"
+ "strconv"
+ "time"
+
+ "github.com/containrrr/watchtower/pkg/types"
+)
+
+type previewData struct {
+ rand *rand.Rand
+ lastTime time.Time
+ report *report
+ containerCount int
+ Entries []*logEntry
+ StaticData staticData
+}
+
+type staticData struct {
+ Title string
+ Host string
+}
+
+// New initializes a new preview data struct
+func New() *previewData {
+ return &previewData{
+ rand: rand.New(rand.NewSource(1)),
+ lastTime: time.Now().Add(-30 * time.Minute),
+ report: nil,
+ containerCount: 0,
+ Entries: []*logEntry{},
+ StaticData: staticData{
+ Title: "Title",
+ Host: "Host",
+ },
+ }
+}
+
+// AddFromState adds a container status entry to the report with the given state
+func (pb *previewData) AddFromState(state State) {
+ cid := types.ContainerID(pb.generateID())
+ old := types.ImageID(pb.generateID())
+ new := types.ImageID(pb.generateID())
+ name := pb.generateName()
+ image := pb.generateImageName(name)
+ var err error
+ if state == FailedState {
+ err = errors.New(pb.randomEntry(errorMessages))
+ } else if state == SkippedState {
+ err = errors.New(pb.randomEntry(skippedMessages))
+ }
+ pb.addContainer(containerStatus{
+ containerID: cid,
+ oldImage: old,
+ newImage: new,
+ containerName: name,
+ imageName: image,
+ error: err,
+ state: state,
+ })
+}
+
+func (pb *previewData) addContainer(c containerStatus) {
+ if pb.report == nil {
+ pb.report = &report{}
+ }
+ switch c.state {
+ case ScannedState:
+ pb.report.scanned = append(pb.report.scanned, &c)
+ case UpdatedState:
+ pb.report.updated = append(pb.report.updated, &c)
+ case FailedState:
+ pb.report.failed = append(pb.report.failed, &c)
+ case SkippedState:
+ pb.report.skipped = append(pb.report.skipped, &c)
+ case StaleState:
+ pb.report.stale = append(pb.report.stale, &c)
+ case FreshState:
+ pb.report.fresh = append(pb.report.fresh, &c)
+ default:
+ return
+ }
+ pb.containerCount += 1
+}
+
+// AddLogEntry adds a preview log entry of the given level
+func (pd *previewData) AddLogEntry(level LogLevel) {
+ var msg string
+ switch level {
+ case FatalLevel:
+ fallthrough
+ case ErrorLevel:
+ fallthrough
+ case WarnLevel:
+ msg = pd.randomEntry(logErrors)
+ default:
+ msg = pd.randomEntry(logMessages)
+ }
+ pd.Entries = append(pd.Entries, &logEntry{
+ Message: msg,
+ Data: map[string]any{},
+ Time: pd.generateTime(),
+ Level: level,
+ })
+}
+
+// Report returns a preview report
+func (pb *previewData) Report() types.Report {
+ return pb.report
+}
+
+func (pb *previewData) generateID() string {
+ buf := make([]byte, 32)
+ _, _ = pb.rand.Read(buf)
+ return hex.EncodeToString(buf)
+}
+
+func (pb *previewData) generateTime() time.Time {
+ pb.lastTime = pb.lastTime.Add(time.Duration(pb.rand.Intn(30)) * time.Second)
+ return pb.lastTime
+}
+
+func (pb *previewData) randomEntry(arr []string) string {
+ return arr[pb.rand.Intn(len(arr))]
+}
+
+func (pb *previewData) generateName() string {
+ index := pb.containerCount
+ if index <= len(containerNames) {
+ return "/" + containerNames[index]
+ }
+ suffix := index / len(containerNames)
+ index %= len(containerNames)
+ return "/" + containerNames[index] + strconv.FormatInt(int64(suffix), 10)
+}
+
+func (pb *previewData) generateImageName(name string) string {
+ index := pb.containerCount % len(organizationNames)
+ return organizationNames[index] + name + ":latest"
+}
diff --git a/pkg/notifications/preview/data/logs.go b/pkg/notifications/preview/data/logs.go
new file mode 100644
index 0000000..3ca7710
--- /dev/null
+++ b/pkg/notifications/preview/data/logs.go
@@ -0,0 +1,56 @@
+package data
+
+import (
+ "time"
+)
+
+type logEntry struct {
+ Message string
+ Data map[string]any
+ Time time.Time
+ Level LogLevel
+}
+
+// LogLevel is the analog of logrus.Level
+type LogLevel string
+
+const (
+ TraceLevel LogLevel = "trace"
+ DebugLevel LogLevel = "debug"
+ InfoLevel LogLevel = "info"
+ WarnLevel LogLevel = "warning"
+ ErrorLevel LogLevel = "error"
+ FatalLevel LogLevel = "fatal"
+ PanicLevel LogLevel = "panic"
+)
+
+// LevelsFromString parses a string of level characters and returns a slice of the corresponding log levels
+func LevelsFromString(str string) []LogLevel {
+ levels := make([]LogLevel, 0, len(str))
+ for _, c := range str {
+ switch c {
+ case 'p':
+ levels = append(levels, PanicLevel)
+ case 'f':
+ levels = append(levels, FatalLevel)
+ case 'e':
+ levels = append(levels, ErrorLevel)
+ case 'w':
+ levels = append(levels, WarnLevel)
+ case 'i':
+ levels = append(levels, InfoLevel)
+ case 'd':
+ levels = append(levels, DebugLevel)
+ case 't':
+ levels = append(levels, TraceLevel)
+ default:
+ continue
+ }
+ }
+ return levels
+}
+
+// String returns the log level as a string
+func (level LogLevel) String() string {
+ return string(level)
+}
diff --git a/pkg/notifications/preview/data/preview_strings.go b/pkg/notifications/preview/data/preview_strings.go
new file mode 100644
index 0000000..9212a71
--- /dev/null
+++ b/pkg/notifications/preview/data/preview_strings.go
@@ -0,0 +1,178 @@
+package data
+
+var containerNames = []string{
+ "cyberscribe",
+ "datamatrix",
+ "nexasync",
+ "quantumquill",
+ "aerosphere",
+ "virtuos",
+ "fusionflow",
+ "neuralink",
+ "pixelpulse",
+ "synthwave",
+ "codecraft",
+ "zapzone",
+ "robologic",
+ "dreamstream",
+ "infinisync",
+ "megamesh",
+ "novalink",
+ "xenogenius",
+ "ecosim",
+ "innovault",
+ "techtracer",
+ "fusionforge",
+ "quantumquest",
+ "neuronest",
+ "codefusion",
+ "datadyno",
+ "pixelpioneer",
+ "vortexvision",
+ "cybercraft",
+ "synthsphere",
+ "infinitescript",
+ "roborhythm",
+ "dreamengine",
+ "aquasync",
+ "geniusgrid",
+ "megamind",
+ "novasync-pro",
+ "xenonwave",
+ "ecologic",
+ "innoscan",
+}
+
+var organizationNames = []string{
+ "techwave",
+ "codecrafters",
+ "innotechlabs",
+ "fusionsoft",
+ "cyberpulse",
+ "quantumscribe",
+ "datadynamo",
+ "neuralink",
+ "pixelpro",
+ "synthwizards",
+ "virtucorplabs",
+ "robologic",
+ "dreamstream",
+ "novanest",
+ "megamind",
+ "xenonwave",
+ "ecologic",
+ "innosync",
+ "techgenius",
+ "nexasoft",
+ "codewave",
+ "zapzone",
+ "techsphere",
+ "aquatech",
+ "quantumcraft",
+ "neuronest",
+ "datafusion",
+ "pixelpioneer",
+ "synthsphere",
+ "infinitescribe",
+ "roborhythm",
+ "dreamengine",
+ "vortexvision",
+ "geniusgrid",
+ "megamesh",
+ "novasync",
+ "xenogeniuslabs",
+ "ecosim",
+ "innovault",
+}
+
+var errorMessages = []string{
+ "Error 404: Resource not found",
+ "Critical Error: System meltdown imminent",
+ "Error 500: Internal server error",
+ "Invalid input: Please check your data",
+ "Access denied: Unauthorized access detected",
+ "Network connection lost: Please check your connection",
+ "Error 403: Forbidden access",
+ "Fatal error: System crash imminent",
+ "File not found: Check the file path",
+ "Invalid credentials: Authentication failed",
+ "Error 502: Bad Gateway",
+ "Database connection failed: Please try again later",
+ "Security breach detected: Take immediate action",
+ "Error 400: Bad request",
+ "Out of memory: Close unnecessary applications",
+ "Invalid configuration: Check your settings",
+ "Error 503: Service unavailable",
+ "File is read-only: Cannot modify",
+ "Data corruption detected: Backup your data",
+ "Error 401: Unauthorized",
+ "Disk space full: Free up disk space",
+ "Connection timeout: Retry your request",
+ "Error 504: Gateway timeout",
+ "File access denied: Permission denied",
+ "Unexpected error: Please contact support",
+ "Error 429: Too many requests",
+ "Invalid URL: Check the URL format",
+ "Database query failed: Try again later",
+ "Error 408: Request timeout",
+ "File is in use: Close the file and try again",
+ "Invalid parameter: Check your input",
+ "Error 502: Proxy error",
+ "Database connection lost: Reconnect and try again",
+ "File size exceeds limit: Reduce the file size",
+ "Error 503: Overloaded server",
+ "Operation aborted: Try again",
+ "Invalid API key: Check your API key",
+ "Error 507: Insufficient storage",
+ "Database deadlock: Retry your transaction",
+ "Error 405: Method not allowed",
+ "File format not supported: Choose a different format",
+ "Unknown error: Contact system administrator",
+}
+
+var skippedMessages = []string{
+ "Fear of introducing new bugs",
+ "Don't have time for the update process",
+ "Current version works fine for my needs",
+ "Concerns about compatibility with other software",
+ "Limited bandwidth for downloading updates",
+ "Worries about losing custom settings or configurations",
+ "Lack of trust in the software developer's updates",
+ "Dislike changes to the user interface",
+ "Avoiding potential subscription fees",
+ "Suspicion of hidden data collection in updates",
+ "Apprehension about changes in privacy policies",
+ "Prefer the older version's features or design",
+ "Worry about software becoming more resource-intensive",
+ "Avoiding potential changes in licensing terms",
+ "Waiting for initial bugs to be resolved in the update",
+ "Concerns about update breaking third-party plugins or extensions",
+ "Belief that the software is already secure enough",
+ "Don't want to relearn how to use the software",
+ "Fear of losing access to older file formats",
+ "Avoiding the hassle of having to update multiple devices",
+}
+
+var logMessages = []string{
+ "Checking for available updates...",
+ "Downloading update package...",
+ "Verifying update integrity...",
+ "Preparing to install update...",
+ "Backing up existing configuration...",
+ "Installing update...",
+ "Update installation complete.",
+ "Applying configuration settings...",
+ "Cleaning up temporary files...",
+ "Update successful! Software is now up-to-date.",
+ "Restarting the application...",
+ "Restart complete. Enjoy the latest features!",
+ "Update rollback complete. Your software remains at the previous version.",
+}
+
+var logErrors = []string{
+ "Unable to check for updates. Please check your internet connection.",
+ "Update package download failed. Try again later.",
+ "Update verification failed. Please contact support.",
+ "Update installation failed. Rolling back to the previous version...",
+ "Your configuration settings may have been reset to defaults.",
+}
diff --git a/pkg/notifications/preview/data/report.go b/pkg/notifications/preview/data/report.go
new file mode 100644
index 0000000..2c8627f
--- /dev/null
+++ b/pkg/notifications/preview/data/report.go
@@ -0,0 +1,110 @@
+package data
+
+import (
+ "sort"
+
+ "github.com/containrrr/watchtower/pkg/types"
+)
+
+// State is the outcome of a container in a session report
+type State string
+
+const (
+ ScannedState State = "scanned"
+ UpdatedState State = "updated"
+ FailedState State = "failed"
+ SkippedState State = "skipped"
+ StaleState State = "stale"
+ FreshState State = "fresh"
+)
+
+// StatesFromString parses a string of state characters and returns a slice of the corresponding report states
+func StatesFromString(str string) []State {
+ states := make([]State, 0, len(str))
+ for _, c := range str {
+ switch c {
+ case 'c':
+ states = append(states, ScannedState)
+ case 'u':
+ states = append(states, UpdatedState)
+ case 'e':
+ states = append(states, FailedState)
+ case 'k':
+ states = append(states, SkippedState)
+ case 't':
+ states = append(states, StaleState)
+ case 'f':
+ states = append(states, FreshState)
+ default:
+ continue
+ }
+ }
+ return states
+}
+
+type report struct {
+ scanned []types.ContainerReport
+ updated []types.ContainerReport
+ failed []types.ContainerReport
+ skipped []types.ContainerReport
+ stale []types.ContainerReport
+ fresh []types.ContainerReport
+}
+
+func (r *report) Scanned() []types.ContainerReport {
+ return r.scanned
+}
+func (r *report) Updated() []types.ContainerReport {
+ return r.updated
+}
+func (r *report) Failed() []types.ContainerReport {
+ return r.failed
+}
+func (r *report) Skipped() []types.ContainerReport {
+ return r.skipped
+}
+func (r *report) Stale() []types.ContainerReport {
+ return r.stale
+}
+func (r *report) Fresh() []types.ContainerReport {
+ return r.fresh
+}
+
+func (r *report) All() []types.ContainerReport {
+ allLen := len(r.scanned) + len(r.updated) + len(r.failed) + len(r.skipped) + len(r.stale) + len(r.fresh)
+ all := make([]types.ContainerReport, 0, allLen)
+
+ presentIds := map[types.ContainerID][]string{}
+
+ appendUnique := func(reports []types.ContainerReport) {
+ for _, cr := range reports {
+ if _, found := presentIds[cr.ID()]; found {
+ continue
+ }
+ all = append(all, cr)
+ presentIds[cr.ID()] = nil
+ }
+ }
+
+ appendUnique(r.updated)
+ appendUnique(r.failed)
+ appendUnique(r.skipped)
+ appendUnique(r.stale)
+ appendUnique(r.fresh)
+ appendUnique(r.scanned)
+
+ sort.Sort(sortableContainers(all))
+
+ return all
+}
+
+type sortableContainers []types.ContainerReport
+
+// Len implements sort.Interface.Len
+func (s sortableContainers) Len() int { return len(s) }
+
+// Less implements sort.Interface.Less
+func (s sortableContainers) Less(i, j int) bool { return s[i].ID() < s[j].ID() }
+
+// Swap implements sort.Interface.Swap
+func (s sortableContainers) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
diff --git a/pkg/notifications/preview/data/status.go b/pkg/notifications/preview/data/status.go
new file mode 100644
index 0000000..33f9bec
--- /dev/null
+++ b/pkg/notifications/preview/data/status.go
@@ -0,0 +1,44 @@
+package data
+
+import wt "github.com/containrrr/watchtower/pkg/types"
+
+type containerStatus struct {
+ containerID wt.ContainerID
+ oldImage wt.ImageID
+ newImage wt.ImageID
+ containerName string
+ imageName string
+ error
+ state State
+}
+
+func (u *containerStatus) ID() wt.ContainerID {
+ return u.containerID
+}
+
+func (u *containerStatus) Name() string {
+ return u.containerName
+}
+
+func (u *containerStatus) CurrentImageID() wt.ImageID {
+ return u.oldImage
+}
+
+func (u *containerStatus) LatestImageID() wt.ImageID {
+ return u.newImage
+}
+
+func (u *containerStatus) ImageName() string {
+ return u.imageName
+}
+
+func (u *containerStatus) Error() string {
+ if u.error == nil {
+ return ""
+ }
+ return u.error.Error()
+}
+
+func (u *containerStatus) State() string {
+ return string(u.state)
+}
diff --git a/pkg/notifications/preview/tplprev.go b/pkg/notifications/preview/tplprev.go
new file mode 100644
index 0000000..8855416
--- /dev/null
+++ b/pkg/notifications/preview/tplprev.go
@@ -0,0 +1,36 @@
+package preview
+
+import (
+ "fmt"
+ "strings"
+ "text/template"
+
+ "github.com/containrrr/watchtower/pkg/notifications/preview/data"
+ "github.com/containrrr/watchtower/pkg/notifications/templates"
+)
+
+func Render(input string, states []data.State, loglevels []data.LogLevel) (string, error) {
+
+ data := data.New()
+
+ tpl, err := template.New("").Funcs(templates.Funcs).Parse(input)
+ if err != nil {
+ return "", fmt.Errorf("failed to parse %v", err)
+ }
+
+ for _, state := range states {
+ data.AddFromState(state)
+ }
+
+ for _, level := range loglevels {
+ data.AddLogEntry(level)
+ }
+
+ var buf strings.Builder
+ err = tpl.Execute(&buf, data)
+ if err != nil {
+ return "", fmt.Errorf("failed to execute template: %v", err)
+ }
+
+ return buf.String(), nil
+}
diff --git a/pkg/notifications/shoutrrr.go b/pkg/notifications/shoutrrr.go
index 2715711..cc3a931 100644
--- a/pkg/notifications/shoutrrr.go
+++ b/pkg/notifications/shoutrrr.go
@@ -2,20 +2,24 @@ package notifications
import (
"bytes"
- "fmt"
- "github.com/containrrr/shoutrrr/pkg/types"
+ stdlog "log"
+ "os"
"strings"
"text/template"
+ "time"
"github.com/containrrr/shoutrrr"
+ "github.com/containrrr/shoutrrr/pkg/types"
+ "github.com/containrrr/watchtower/pkg/notifications/templates"
t "github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
)
+// LocalLog is a logrus logger that does not send entries as notifications
+var LocalLog = log.WithField("notify", "no")
+
const (
- shoutrrrDefaultTemplate = "{{range .}}{{.Message}}{{println}}{{end}}"
- shoutrrrType = "shoutrrr"
+ shoutrrrType = "shoutrrr"
)
type router interface {
@@ -24,58 +28,104 @@ type router interface {
// Implements Notifier, logrus.Hook
type shoutrrrTypeNotifier struct {
- Urls []string
- Router router
- entries []*log.Entry
- logLevels []log.Level
- template *template.Template
- messages chan string
- done chan bool
+ Urls []string
+ Router router
+ entries []*log.Entry
+ logLevel log.Level
+ template *template.Template
+ messages chan string
+ done chan bool
+ legacyTemplate bool
+ params *types.Params
+ data StaticData
+ receiving bool
+ delay time.Duration
}
-func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
- flags := c.PersistentFlags()
- urls, _ := flags.GetStringArray("notification-url")
- template := getShoutrrrTemplate(c)
- return createSender(urls, acceptedLogLevels, template)
+// GetScheme returns the scheme part of a Shoutrrr URL
+func GetScheme(url string) string {
+ schemeEnd := strings.Index(url, ":")
+ if schemeEnd <= 0 {
+ return "invalid"
+ }
+ return url[:schemeEnd]
}
-func newShoutrrrNotifierFromURL(c *cobra.Command, url string, levels []log.Level) t.Notifier {
- template := getShoutrrrTemplate(c)
- return createSender([]string{url}, levels, template)
+// GetNames returns a list of notification services that has been added
+func (n *shoutrrrTypeNotifier) GetNames() []string {
+ names := make([]string, len(n.Urls))
+ for i, u := range n.Urls {
+ names[i] = GetScheme(u)
+ }
+ return names
}
-func createSender(urls []string, levels []log.Level, template *template.Template) t.Notifier {
- r, err := shoutrrr.CreateSender(urls...)
+// GetURLs returns a list of URLs for notification services that has been added
+func (n *shoutrrrTypeNotifier) GetURLs() []string {
+ return n.Urls
+}
+
+// AddLogHook adds the notifier as a receiver of log messages and starts a go func for processing them
+func (n *shoutrrrTypeNotifier) AddLogHook() {
+ if n.receiving {
+ return
+ }
+ n.receiving = true
+ log.AddHook(n)
+
+ // Do the sending in a separate goroutine, so we don't block the main process.
+ go sendNotifications(n)
+}
+
+func createNotifier(urls []string, level log.Level, tplString string, legacy bool, data StaticData, stdout bool, delay time.Duration) *shoutrrrTypeNotifier {
+ tpl, err := getShoutrrrTemplate(tplString, legacy)
+ if err != nil {
+ log.Errorf("Could not use configured notification template: %s. Using default template", err)
+ }
+
+ var logger types.StdLogger
+ if stdout {
+ logger = stdlog.New(os.Stdout, ``, 0)
+ } else {
+ logger = stdlog.New(log.StandardLogger().WriterLevel(log.TraceLevel), "Shoutrrr: ", 0)
+ }
+ r, err := shoutrrr.NewSender(logger, urls...)
if err != nil {
log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error())
}
- n := &shoutrrrTypeNotifier{
- Urls: urls,
- Router: r,
- messages: make(chan string, 1),
- done: make(chan bool),
- logLevels: levels,
- template: template,
+ params := &types.Params{}
+ if data.Title != "" {
+ params.SetTitle(data.Title)
}
- log.AddHook(n)
-
- // Do the sending in a separate goroutine so we don't block the main process.
- go sendNotifications(n)
-
- return n
+ return &shoutrrrTypeNotifier{
+ Urls: urls,
+ Router: r,
+ messages: make(chan string, 1),
+ done: make(chan bool),
+ logLevel: level,
+ template: tpl,
+ legacyTemplate: legacy,
+ data: data,
+ params: params,
+ delay: delay,
+ }
}
func sendNotifications(n *shoutrrrTypeNotifier) {
for msg := range n.messages {
- errs := n.Router.Send(msg, nil)
+ time.Sleep(n.delay)
+ errs := n.Router.Send(msg, n.params)
for i, err := range errs {
if err != nil {
+ scheme := GetScheme(n.Urls[i])
// Use fmt so it doesn't trigger another notification.
- fmt.Println("Failed to send notification via shoutrrr (url="+n.Urls[i]+"): ", err)
+ LocalLog.WithFields(log.Fields{
+ "service": scheme,
+ "index": i,
+ }).WithError(err).Error("Failed to send shoutrrr notification")
}
}
}
@@ -83,92 +133,106 @@ func sendNotifications(n *shoutrrrTypeNotifier) {
n.done <- true
}
-func (e *shoutrrrTypeNotifier) buildMessage(entries []*log.Entry) string {
+func (n *shoutrrrTypeNotifier) buildMessage(data Data) (string, error) {
var body bytes.Buffer
- if err := e.template.Execute(&body, entries); err != nil {
- fmt.Printf("Failed to execute Shoutrrrr template: %s\n", err.Error())
+ var templateData interface{} = data
+ if n.legacyTemplate {
+ templateData = data.Entries
+ }
+ if err := n.template.Execute(&body, templateData); err != nil {
+ return "", err
}
- return body.String()
+ return body.String(), nil
}
-func (e *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry) {
- msg := e.buildMessage(entries)
- e.messages <- msg
-}
+func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry, report t.Report) {
+ msg, err := n.buildMessage(Data{n.data, entries, report})
-func (e *shoutrrrTypeNotifier) StartNotification() {
- if e.entries == nil {
- e.entries = make([]*log.Entry, 0, 10)
- }
-}
-
-func (e *shoutrrrTypeNotifier) SendNotification() {
- if e.entries == nil || len(e.entries) <= 0 {
+ if msg == "" {
+ // Log in go func in case we entered from Fire to avoid stalling
+ go func() {
+ if err != nil {
+ LocalLog.WithError(err).Fatal("Notification template error")
+ } else if len(n.Urls) > 1 {
+ LocalLog.Info("Skipping notification due to empty message")
+ }
+ }()
return
}
-
- e.sendEntries(e.entries)
- e.entries = nil
+ n.messages <- msg
}
-func (e *shoutrrrTypeNotifier) Close() {
- close(e.messages)
+// StartNotification begins queueing up messages to send them as a batch
+func (n *shoutrrrTypeNotifier) StartNotification() {
+ if n.entries == nil {
+ n.entries = make([]*log.Entry, 0, 10)
+ }
+}
+
+// SendNotification sends the queued up messages as a notification
+func (n *shoutrrrTypeNotifier) SendNotification(report t.Report) {
+ n.sendEntries(n.entries, report)
+ n.entries = nil
+}
+
+// Close prevents further messages from being queued and waits until all the currently queued up messages have been sent
+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")
+ LocalLog.Info("Waiting for the notification goroutine to finish")
- _ = <-e.done
+ <-n.done
}
-func (e *shoutrrrTypeNotifier) Levels() []log.Level {
- return e.logLevels
+// Levels return what log levels trigger notifications
+func (n *shoutrrrTypeNotifier) Levels() []log.Level {
+ return log.AllLevels[:n.logLevel+1]
}
-func (e *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
- if e.entries != nil {
- e.entries = append(e.entries, entry)
+// Fire is the hook that logrus calls on a new log message
+func (n *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
+ if entry.Data["notify"] == "no" {
+ // Skip logging if explicitly tagged as non-notify
+ return nil
+ }
+ 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}, nil)
}
return nil
}
-func getShoutrrrTemplate(c *cobra.Command) *template.Template {
- var tpl *template.Template
+func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template, err error) {
- flags := c.PersistentFlags()
+ tplBase := template.New("").Funcs(templates.Funcs)
- tplString, err := flags.GetString("notification-template")
-
- funcs := template.FuncMap{
- "ToUpper": strings.ToUpper,
- "ToLower": strings.ToLower,
- "Title": strings.Title,
+ if builtin, found := commonTemplates[tplString]; found {
+ log.WithField(`template`, tplString).Debug(`Using common template`)
+ tplString = builtin
}
// If we succeed in getting a non-empty template configuration
// try to parse the template string.
- if tplString != "" && err == nil {
- tpl, err = template.New("").Funcs(funcs).Parse(tplString)
- }
-
- // In case of errors (either from parsing the template string
- // or from getting the template configuration) log an error
- // message about this and the fact that we'll use the default
- // template instead.
- if err != nil {
- log.Errorf("Could not use configured notification template: %s. Using default template", err)
+ if tplString != "" {
+ tpl, err = tplBase.Parse(tplString)
}
// If we had an error (either from parsing the template string
- // or from getting the template configuration) or we a
+ // or from getting the template configuration) or a
// template wasn't configured (the empty template string)
// fallback to using the default template.
if err != nil || tplString == "" {
- tpl = template.Must(template.New("").Funcs(funcs).Parse(shoutrrrDefaultTemplate))
+ defaultKey := `default`
+ if legacy {
+ defaultKey = `default-legacy`
+ }
+
+ tpl = template.Must(tplBase.Parse(commonTemplates[defaultKey]))
}
- return tpl
+ return
}
diff --git a/pkg/notifications/shoutrrr_test.go b/pkg/notifications/shoutrrr_test.go
index 47334af..703958b 100644
--- a/pkg/notifications/shoutrrr_test.go
+++ b/pkg/notifications/shoutrrr_test.go
@@ -1,170 +1,381 @@
package notifications
import (
- "github.com/containrrr/shoutrrr/pkg/types"
- "testing"
- "text/template"
+ "time"
+ "github.com/containrrr/shoutrrr/pkg/types"
+ "github.com/containrrr/watchtower/internal/actions/mocks"
"github.com/containrrr/watchtower/internal/flags"
- log "github.com/sirupsen/logrus"
+ s "github.com/containrrr/watchtower/pkg/session"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+ "github.com/onsi/gomega/gbytes"
+ "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
- "github.com/stretchr/testify/require"
)
-func TestShoutrrrDefaultTemplate(t *testing.T) {
- cmd := new(cobra.Command)
+var allButTrace = logrus.DebugLevel
- shoutrrr := &shoutrrrTypeNotifier{
- template: getShoutrrrTemplate(cmd),
- }
-
- entries := []*log.Entry{
+var legacyMockData = Data{
+ Entries: []*logrus.Entry{
{
- Message: "foo bar",
- },
- }
-
- s := shoutrrr.buildMessage(entries)
-
- require.Equal(t, "foo bar\n", s)
-}
-
-func TestShoutrrrTemplate(t *testing.T) {
- cmd := new(cobra.Command)
- flags.RegisterNotificationFlags(cmd)
- err := cmd.ParseFlags([]string{"--notification-template={{range .}}{{.Level}}: {{.Message}}{{println}}{{end}}"})
-
- require.NoError(t, err)
-
- shoutrrr := &shoutrrrTypeNotifier{
- template: getShoutrrrTemplate(cmd),
- }
-
- entries := []*log.Entry{
- {
- Level: log.InfoLevel,
- Message: "foo bar",
- },
- }
-
- s := shoutrrr.buildMessage(entries)
-
- require.Equal(t, "info: foo bar\n", s)
-}
-
-func TestShoutrrrStringFunctions(t *testing.T) {
- cmd := new(cobra.Command)
- flags.RegisterNotificationFlags(cmd)
- err := cmd.ParseFlags([]string{"--notification-template={{range .}}{{.Level | printf \"%v\" | ToUpper }}: {{.Message | ToLower }} {{.Message | Title }}{{println}}{{end}}"})
-
- require.NoError(t, err)
-
- shoutrrr := &shoutrrrTypeNotifier{
- template: getShoutrrrTemplate(cmd),
- }
-
- entries := []*log.Entry{
- {
- Level: log.InfoLevel,
+ Level: logrus.InfoLevel,
Message: "foo Bar",
},
- }
-
- s := shoutrrr.buildMessage(entries)
-
- require.Equal(t, "INFO: foo bar Foo Bar\n", s)
+ },
}
-func TestShoutrrrInvalidTemplateUsesTemplate(t *testing.T) {
- cmd := new(cobra.Command)
-
- flags.RegisterNotificationFlags(cmd)
- err := cmd.ParseFlags([]string{"--notification-template={{"})
-
- require.NoError(t, err)
-
- shoutrrr := &shoutrrrTypeNotifier{
- template: getShoutrrrTemplate(cmd),
- }
-
- shoutrrrDefault := &shoutrrrTypeNotifier{
- template: template.Must(template.New("").Parse(shoutrrrDefaultTemplate)),
- }
-
- entries := []*log.Entry{
+var mockDataMultipleEntries = Data{
+ Entries: []*logrus.Entry{
{
- Message: "foo bar",
+ Level: logrus.InfoLevel,
+ Message: "The situation is under control",
+ },
+ {
+ Level: logrus.WarnLevel,
+ Message: "All the smoke might be covering up some problems",
+ },
+ {
+ Level: logrus.ErrorLevel,
+ Message: "Turns out everything is on fire",
+ },
+ },
+}
+
+var mockDataAllFresh = Data{
+ Entries: []*logrus.Entry{},
+ Report: mocks.CreateMockProgressReport(s.FreshState),
+}
+
+func mockDataFromStates(states ...s.State) Data {
+ hostname := "Mock"
+ prefix := ""
+ return Data{
+ Entries: legacyMockData.Entries,
+ Report: mocks.CreateMockProgressReport(states...),
+ StaticData: StaticData{
+ Title: GetTitle(hostname, prefix),
+ Host: hostname,
},
}
-
- s := shoutrrr.buildMessage(entries)
- sd := shoutrrrDefault.buildMessage(entries)
-
- require.Equal(t, sd, s)
}
+var _ = Describe("Shoutrrr", func() {
+ var logBuffer *gbytes.Buffer
+
+ BeforeEach(func() {
+ logBuffer = gbytes.NewBuffer()
+ logrus.SetOutput(logBuffer)
+ logrus.SetLevel(logrus.TraceLevel)
+ logrus.SetFormatter(&logrus.TextFormatter{
+ DisableColors: true,
+ DisableTimestamp: true,
+ })
+ })
+
+ When("passing a common template name", func() {
+ It("should format using that template", func() {
+ expected := `
+updt1 (mock/updt1:latest): Updated
+`[1:]
+ data := mockDataFromStates(s.UpdatedState)
+ Expect(getTemplatedResult(`porcelain.v1.summary-no-log`, false, data)).To(Equal(expected))
+ })
+ })
+
+ When("adding a log hook", func() {
+ When("it has not been added before", func() {
+ It("should be added to the logrus hooks", func() {
+ level := logrus.TraceLevel
+ hooksBefore := len(logrus.StandardLogger().Hooks[level])
+ shoutrrr := createNotifier([]string{}, level, "", true, StaticData{}, false, time.Second)
+ shoutrrr.AddLogHook()
+ hooksAfter := len(logrus.StandardLogger().Hooks[level])
+ Expect(hooksAfter).To(BeNumerically(">", hooksBefore))
+ })
+ })
+ When("it is being added a second time", func() {
+ It("should not be added to the logrus hooks", func() {
+ level := logrus.TraceLevel
+ shoutrrr := createNotifier([]string{}, level, "", true, StaticData{}, false, time.Second)
+ shoutrrr.AddLogHook()
+ hooksBefore := len(logrus.StandardLogger().Hooks[level])
+ shoutrrr.AddLogHook()
+ hooksAfter := len(logrus.StandardLogger().Hooks[level])
+ Expect(hooksAfter).To(Equal(hooksBefore))
+ })
+ })
+ })
+
+ When("using legacy templates", func() {
+
+ When("no custom template is provided", func() {
+ It("should format the messages using the default template", func() {
+ cmd := new(cobra.Command)
+ flags.RegisterNotificationFlags(cmd)
+
+ shoutrrr := createNotifier([]string{}, logrus.TraceLevel, "", true, StaticData{}, false, time.Second)
+
+ entries := []*logrus.Entry{
+ {
+ Message: "foo bar",
+ },
+ }
+
+ s, err := shoutrrr.buildMessage(Data{Entries: entries})
+ Expect(err).NotTo(HaveOccurred())
+
+ Expect(s).To(Equal("foo bar\n"))
+ })
+ })
+ When("given a valid custom template", func() {
+ It("should format the messages using the custom template", func() {
+
+ tplString := `{{range .}}{{.Level}}: {{.Message}}{{println}}{{end}}`
+ tpl, err := getShoutrrrTemplate(tplString, true)
+ Expect(err).ToNot(HaveOccurred())
+
+ shoutrrr := &shoutrrrTypeNotifier{
+ template: tpl,
+ legacyTemplate: true,
+ }
+
+ entries := []*logrus.Entry{
+ {
+ Level: logrus.InfoLevel,
+ Message: "foo bar",
+ },
+ }
+
+ s, err := shoutrrr.buildMessage(Data{Entries: entries})
+ Expect(err).NotTo(HaveOccurred())
+
+ Expect(s).To(Equal("info: foo bar\n"))
+ })
+ })
+
+ Describe("the default template", func() {
+ When("all containers are fresh", func() {
+ It("should return an empty string", func() {
+ Expect(getTemplatedResult(``, true, mockDataAllFresh)).To(Equal(""))
+ })
+ })
+ })
+
+ When("given an invalid custom template", func() {
+ It("should format the messages using the default template", func() {
+ invNotif, err := createNotifierWithTemplate(`{{ intentionalSyntaxError`, true)
+ Expect(err).To(HaveOccurred())
+ invMsg, err := invNotif.buildMessage(legacyMockData)
+ Expect(err).NotTo(HaveOccurred())
+
+ defNotif, err := createNotifierWithTemplate(``, true)
+ Expect(err).ToNot(HaveOccurred())
+ defMsg, err := defNotif.buildMessage(legacyMockData)
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(invMsg).To(Equal(defMsg))
+ })
+ })
+
+ When("given a template that is using ToUpper function", func() {
+ It("should return the text in UPPER CASE", func() {
+ tplString := `{{range .}}{{ .Message | ToUpper }}{{end}}`
+ Expect(getTemplatedResult(tplString, true, legacyMockData)).To(Equal("FOO BAR"))
+ })
+ })
+
+ When("given a template that is using ToLower function", func() {
+ It("should return the text in lower case", func() {
+ tplString := `{{range .}}{{ .Message | ToLower }}{{end}}`
+ Expect(getTemplatedResult(tplString, true, legacyMockData)).To(Equal("foo bar"))
+ })
+ })
+
+ When("given a template that is using Title function", func() {
+ It("should return the text in Title Case", func() {
+ tplString := `{{range .}}{{ .Message | Title }}{{end}}`
+ Expect(getTemplatedResult(tplString, true, legacyMockData)).To(Equal("Foo Bar"))
+ })
+ })
+
+ })
+
+ When("using report templates", func() {
+ When("no custom template is provided", func() {
+ It("should format the messages using the default template", func() {
+ expected := `4 Scanned, 2 Updated, 1 Failed
+- updt1 (mock/updt1:latest): 01d110000000 updated to d0a110000000
+- updt2 (mock/updt2:latest): 01d120000000 updated to d0a120000000
+- frsh1 (mock/frsh1:latest): Fresh
+- skip1 (mock/skip1:latest): Skipped: unpossible
+- fail1 (mock/fail1:latest): Failed: accidentally the whole container`
+ data := mockDataFromStates(s.UpdatedState, s.FreshState, s.FailedState, s.SkippedState, s.UpdatedState)
+ Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+ })
+
+ })
+
+ When("using a template referencing Title", func() {
+ It("should contain the title in the output", func() {
+ expected := `Watchtower updates on Mock`
+ data := mockDataFromStates(s.UpdatedState)
+ Expect(getTemplatedResult(`{{ .Title }}`, false, data)).To(Equal(expected))
+ })
+ })
+
+ When("using a template referencing Host", func() {
+ It("should contain the hostname in the output", func() {
+ expected := `Mock`
+ data := mockDataFromStates(s.UpdatedState)
+ Expect(getTemplatedResult(`{{ .Host }}`, false, data)).To(Equal(expected))
+ })
+ })
+
+ Describe("the default template", func() {
+ When("all containers are fresh", func() {
+ It("should return an empty string", func() {
+ Expect(getTemplatedResult(``, false, mockDataAllFresh)).To(Equal(""))
+ })
+ })
+ When("at least one container was updated", func() {
+ It("should send a report", func() {
+ expected := `1 Scanned, 1 Updated, 0 Failed
+- updt1 (mock/updt1:latest): 01d110000000 updated to d0a110000000`
+ data := mockDataFromStates(s.UpdatedState)
+ Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+ })
+ })
+ When("at least one container failed to update", func() {
+ It("should send a report", func() {
+ expected := `1 Scanned, 0 Updated, 1 Failed
+- fail1 (mock/fail1:latest): Failed: accidentally the whole container`
+ data := mockDataFromStates(s.FailedState)
+ Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+ })
+ })
+ When("the report is nil", func() {
+ It("should return the logged entries", func() {
+ expected := `The situation is under control
+All the smoke might be covering up some problems
+Turns out everything is on fire
+`
+ Expect(getTemplatedResult(``, false, mockDataMultipleEntries)).To(Equal(expected))
+ })
+ })
+ })
+ })
+
+ When("batching notifications", func() {
+ When("no messages are queued", func() {
+ It("should not send any notification", func() {
+ shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{}, false, time.Duration(0))
+ shoutrrr.StartNotification()
+ shoutrrr.SendNotification(nil)
+ Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`))
+ })
+ })
+ When("at least one message is queued", func() {
+ It("should send a notification", func() {
+ shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{}, false, time.Duration(0))
+ shoutrrr.AddLogHook()
+ shoutrrr.StartNotification()
+ logrus.Info("This log message is sponsored by ContainrrrVPN")
+ shoutrrr.SendNotification(nil)
+ Eventually(logBuffer).Should(gbytes.Say(`Shoutrrr: This log message is sponsored by ContainrrrVPN`))
+ })
+ })
+ })
+
+ When("the title data field is empty", func() {
+ It("should not have set the title param", func() {
+ shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{
+ Host: "test.host",
+ Title: "",
+ }, false, time.Second)
+ _, found := shoutrrr.params.Title()
+ Expect(found).ToNot(BeTrue())
+ })
+ })
+
+ When("sending notifications", func() {
+
+ It("SlowNotificationNotSent", func() {
+ _, blockingRouter := sendNotificationsWithBlockingRouter(true)
+
+ Eventually(blockingRouter.sent).Should(Not(Receive()))
+
+ })
+
+ It("SlowNotificationSent", func() {
+ shoutrrr, blockingRouter := sendNotificationsWithBlockingRouter(true)
+
+ blockingRouter.unlock <- true
+ shoutrrr.Close()
+
+ Eventually(blockingRouter.sent).Should(Receive(BeTrue()))
+ })
+ })
+})
+
type blockingRouter struct {
unlock chan bool
sent chan bool
}
-func (b blockingRouter) Send(message string, params *types.Params) []error {
- _ = <-b.unlock
+func (b blockingRouter) Send(_ string, _ *types.Params) []error {
+ <-b.unlock
b.sent <- true
return nil
}
-func TestSlowNotificationNotSent(t *testing.T) {
- _, blockingRouter := sendNotificationsWithBlockingRouter()
-
- notifSent := false
- select {
- case notifSent = <-blockingRouter.sent:
- default:
- }
-
- require.Equal(t, false, notifSent)
-}
-
-func TestSlowNotificationSent(t *testing.T) {
- shoutrrr, blockingRouter := sendNotificationsWithBlockingRouter()
-
- blockingRouter.unlock <- true
- shoutrrr.Close()
-
- notifSent := false
- select {
- case notifSent = <-blockingRouter.sent:
- default:
- }
- require.Equal(t, true, notifSent)
-}
-
-func sendNotificationsWithBlockingRouter() (*shoutrrrTypeNotifier, *blockingRouter) {
- cmd := new(cobra.Command)
+func sendNotificationsWithBlockingRouter(legacy bool) (*shoutrrrTypeNotifier, *blockingRouter) {
router := &blockingRouter{
unlock: make(chan bool, 1),
sent: make(chan bool, 1),
}
+ tpl, err := getShoutrrrTemplate("", legacy)
+ Expect(err).NotTo(HaveOccurred())
+
shoutrrr := &shoutrrrTypeNotifier{
- template: getShoutrrrTemplate(cmd),
- messages: make(chan string, 1),
- done: make(chan bool),
- Router: router,
+ template: tpl,
+ messages: make(chan string, 1),
+ done: make(chan bool),
+ Router: router,
+ legacyTemplate: legacy,
+ params: &types.Params{},
+ delay: time.Duration(0),
}
- entry := &log.Entry{
+ entry := &logrus.Entry{
Message: "foo bar",
}
go sendNotifications(shoutrrr)
shoutrrr.StartNotification()
- shoutrrr.Fire(entry)
+ _ = shoutrrr.Fire(entry)
- shoutrrr.SendNotification()
+ shoutrrr.SendNotification(nil)
return shoutrrr, router
}
+
+func createNotifierWithTemplate(tplString string, legacy bool) (*shoutrrrTypeNotifier, error) {
+ tpl, err := getShoutrrrTemplate(tplString, legacy)
+
+ return &shoutrrrTypeNotifier{
+ template: tpl,
+ legacyTemplate: legacy,
+ }, err
+}
+
+func getTemplatedResult(tplString string, legacy bool, data Data) (msg string) {
+ notifier, err := createNotifierWithTemplate(tplString, legacy)
+ ExpectWithOffset(1, err).NotTo(HaveOccurred())
+ msg, err = notifier.buildMessage(data)
+ ExpectWithOffset(1, err).NotTo(HaveOccurred())
+ return msg
+}
diff --git a/pkg/notifications/slack.go b/pkg/notifications/slack.go
index ede7141..9118527 100644
--- a/pkg/notifications/slack.go
+++ b/pkg/notifications/slack.go
@@ -6,7 +6,6 @@ import (
shoutrrrDisco "github.com/containrrr/shoutrrr/pkg/services/discord"
shoutrrrSlack "github.com/containrrr/shoutrrr/pkg/services/slack"
t "github.com/containrrr/watchtower/pkg/types"
- "github.com/johntdyer/slackrus"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
@@ -16,16 +15,15 @@ const (
)
type slackTypeNotifier struct {
- slackrus.SlackrusHook
+ HookURL string
+ Username string
+ Channel string
+ IconEmoji string
+ IconURL string
}
-// NewSlackNotifier is a factory function used to generate new instance of the slack notifier type
-func NewSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertableNotifier {
- return newSlackNotifier(c, acceptedLogLevels)
-}
-
-func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertableNotifier {
- flags := c.PersistentFlags()
+func newSlackNotifier(c *cobra.Command) t.ConvertibleNotifier {
+ flags := c.Flags()
hookURL, _ := flags.GetString("notification-slack-hook-url")
userName, _ := flags.GetString("notification-slack-identifier")
@@ -34,46 +32,54 @@ func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert
iconURL, _ := flags.GetString("notification-slack-icon-url")
n := &slackTypeNotifier{
- SlackrusHook: slackrus.SlackrusHook{
- HookURL: hookURL,
- Username: userName,
- Channel: channel,
- IconEmoji: emoji,
- IconURL: iconURL,
- AcceptedLevels: acceptedLogLevels,
- },
+ HookURL: hookURL,
+ Username: userName,
+ Channel: channel,
+ IconEmoji: emoji,
+ IconURL: iconURL,
}
return n
}
-func (s *slackTypeNotifier) GetURL() string {
+func (s *slackTypeNotifier) GetURL(c *cobra.Command) (string, error) {
trimmedURL := strings.TrimRight(s.HookURL, "/")
- trimmedURL = strings.TrimLeft(trimmedURL, "https://")
+ trimmedURL = strings.TrimPrefix(trimmedURL, "https://")
parts := strings.Split(trimmedURL, "/")
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],
+ WebhookID: parts[len(parts)-3],
+ Token: parts[len(parts)-2],
+ Color: ColorInt,
+ SplitLines: true,
+ Username: s.Username,
}
- return conf.GetURL().String()
+
+ if s.IconURL != "" {
+ conf.Avatar = s.IconURL
+ }
+
+ return conf.GetURL().String(), nil
}
- rawTokens := strings.Replace(s.HookURL, "https://hooks.slack.com/services/", "", 1)
- tokens := strings.Split(rawTokens, "/")
+ webhookToken := strings.Replace(s.HookURL, "https://hooks.slack.com/services/", "", 1)
conf := &shoutrrrSlack.Config{
BotName: s.Username,
- Token: tokens,
+ Color: ColorHex,
+ Channel: "webhook",
}
- return conf.GetURL().String()
+ if s.IconURL != "" {
+ conf.Icon = s.IconURL
+ } else if s.IconEmoji != "" {
+ conf.Icon = s.IconEmoji
+ }
+
+ if err := conf.Token.SetFromProp(webhookToken); err != nil {
+ return "", err
+ }
+
+ return conf.GetURL().String(), nil
}
-
-func (s *slackTypeNotifier) StartNotification() {
-}
-
-func (s *slackTypeNotifier) SendNotification() {}
-
-func (s *slackTypeNotifier) Close() {}
diff --git a/pkg/notifications/smtp.go b/pkg/notifications/smtp.go
deleted file mode 100644
index 82954bc..0000000
--- a/pkg/notifications/smtp.go
+++ /dev/null
@@ -1,77 +0,0 @@
-// Package notifications ...
-// Copyright 2010 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license.
-package notifications
-
-import (
- "crypto/tls"
- "net"
- "net/smtp"
-)
-
-// SendMail connects to the server at addr, switches to TLS if
-// possible, authenticates with the optional mechanism a if possible,
-// and then sends an email from address from, to addresses to, with
-// message msg.
-// The addr must include a port, as in "mail.example.com:smtp".
-//
-// The addresses in the to parameter are the SMTP RCPT addresses.
-//
-// The msg parameter should be an RFC 822-style email with headers
-// first, a blank line, and then the message body. The lines of msg
-// should be CRLF terminated. The msg headers should usually include
-// fields such as "From", "To", "Subject", and "Cc". Sending "Bcc"
-// messages is accomplished by including an email address in the to
-// parameter but not including it in the msg headers.
-//
-// The SendMail function and the net/smtp package are low-level
-// mechanisms and provide no support for DKIM signing, MIME
-// attachments (see the mime/multipart package), or other mail
-// functionality. Higher-level packages exist outside of the standard
-// library.
-func SendMail(addr string, insecureSkipVerify bool, a smtp.Auth, from string, to []string, msg []byte) error {
- c, err := smtp.Dial(addr)
- if err != nil {
- return err
- }
- defer c.Close()
- if err = c.Hello("localHost"); err != nil {
- return err
- }
- if ok, _ := c.Extension("STARTTLS"); ok {
- serverName, _, _ := net.SplitHostPort(addr)
- config := &tls.Config{ServerName: serverName, InsecureSkipVerify: insecureSkipVerify}
- if err = c.StartTLS(config); err != nil {
- return err
- }
- }
- if a != nil {
- if ok, _ := c.Extension("AUTH"); ok {
- if err = c.Auth(a); err != nil {
- return err
- }
- }
- }
- if err = c.Mail(from); err != nil {
- return err
- }
- for _, addr := range to {
- if err = c.Rcpt(addr); err != nil {
- return err
- }
- }
- w, err := c.Data()
- if err != nil {
- return err
- }
- _, err = w.Write(msg)
- if err != nil {
- return err
- }
- err = w.Close()
- if err != nil {
- return err
- }
- return c.Quit()
-}
diff --git a/pkg/notifications/templates/funcs.go b/pkg/notifications/templates/funcs.go
new file mode 100644
index 0000000..6958c1a
--- /dev/null
+++ b/pkg/notifications/templates/funcs.go
@@ -0,0 +1,27 @@
+package templates
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "text/template"
+
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
+)
+
+var Funcs = template.FuncMap{
+ "ToUpper": strings.ToUpper,
+ "ToLower": strings.ToLower,
+ "ToJSON": toJSON,
+ "Title": cases.Title(language.AmericanEnglish).String,
+}
+
+func toJSON(v interface{}) string {
+ var bytes []byte
+ var err error
+ if bytes, err = json.MarshalIndent(v, "", " "); err != nil {
+ return fmt.Sprintf("failed to marshal JSON in notification template: %v", err)
+ }
+ return string(bytes)
+}
diff --git a/pkg/notifications/util.go b/pkg/notifications/util.go
deleted file mode 100644
index 5764341..0000000
--- a/pkg/notifications/util.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package notifications
-
-import "bytes"
-
-// SplitSubN splits a string into a list of string with each having
-// a maximum number of characters n
-func SplitSubN(s string, n int) []string {
- sub := ""
- subs := []string{}
-
- runes := bytes.Runes([]byte(s))
- l := len(runes)
- for i, r := range runes {
- sub = sub + string(r)
- if (i+1)%n == 0 {
- subs = append(subs, sub)
- sub = ""
- } else if (i + 1) == l {
- subs = append(subs, sub)
- }
- }
-
- return subs
-}
diff --git a/pkg/registry/auth/auth.go b/pkg/registry/auth/auth.go
index 99e307c..99b05c9 100644
--- a/pkg/registry/auth/auth.go
+++ b/pkg/registry/auth/auth.go
@@ -4,14 +4,15 @@ 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"
+ "io"
"net/http"
"net/url"
"strings"
+
+ "github.com/containrrr/watchtower/pkg/registry/helpers"
+ "github.com/containrrr/watchtower/pkg/types"
+ ref "github.com/distribution/reference"
+ "github.com/sirupsen/logrus"
)
// ChallengeHeader is the HTTP Header containing challenge instructions
@@ -19,13 +20,13 @@ 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 {
+ normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName())
+ if err != nil {
return "", err
}
- logrus.WithField("URL", URL.String()).Debug("Building challenge URL")
+
+ URL := GetChallengeURL(normalizedRef)
+ logrus.WithField("URL", URL.String()).Debug("Built challenge URL")
var req *http.Request
if req, err = GetChallengeRequest(URL); err != nil {
@@ -54,7 +55,7 @@ func GetToken(container types.Container, registryAuth string) (string, error) {
return fmt.Sprintf("Basic %s", registryAuth), nil
}
if strings.HasPrefix(challenge, "bearer") {
- return GetBearerHeader(challenge, container.ImageName(), err, registryAuth)
+ return GetBearerHeader(challenge, normalizedRef, registryAuth)
}
return "", errors.New("unsupported challenge type from registry")
@@ -72,12 +73,9 @@ func GetChallengeRequest(URL url.URL) (*http.Request, error) {
}
// 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) {
+func GetBearerHeader(challenge string, imageRef ref.Named, registryAuth string) (string, error) {
client := http.Client{}
- if strings.Contains(img, ":") {
- img = strings.Split(img, ":")[0]
- }
- authURL, err := GetAuthURL(challenge, img)
+ authURL, err := GetAuthURL(challenge, imageRef)
if err != nil {
return "", err
@@ -90,7 +88,8 @@ func GetBearerHeader(challenge string, img string, err error, registryAuth strin
if registryAuth != "" {
logrus.Debug("Credentials found.")
- logrus.Tracef("Credentials: %v", registryAuth)
+ // CREDENTIAL: Uncomment to log registry credentials
+ // logrus.Tracef("Credentials: %v", registryAuth)
r.Header.Add("Authorization", fmt.Sprintf("Basic %s", registryAuth))
} else {
logrus.Debug("No credentials found.")
@@ -101,7 +100,7 @@ func GetBearerHeader(challenge string, img string, err error, registryAuth strin
return "", err
}
- body, _ := ioutil.ReadAll(authResponse.Body)
+ body, _ := io.ReadAll(authResponse.Body)
tokenResponse := &types.TokenResponse{}
err = json.Unmarshal(body, tokenResponse)
@@ -113,7 +112,7 @@ func GetBearerHeader(challenge string, img string, err error, registryAuth strin
}
// GetAuthURL from the instructions in the challenge
-func GetAuthURL(challenge string, img string) (*url.URL, error) {
+func GetAuthURL(challenge string, imageRef ref.Named) (*url.URL, error) {
loweredChallenge := strings.ToLower(challenge)
raw := strings.TrimPrefix(loweredChallenge, "bearer")
@@ -122,10 +121,9 @@ func GetAuthURL(challenge string, img string) (*url.URL, error) {
for _, pair := range pairs {
trimmed := strings.Trim(pair, " ")
- kv := strings.Split(trimmed, "=")
- key := kv[0]
- val := strings.Trim(kv[1], "\"")
- values[key] = val
+ if key, val, ok := strings.Cut(trimmed, "="); ok {
+ values[key] = strings.Trim(val, `"`)
+ }
}
logrus.WithFields(logrus.Fields{
"realm": values["realm"],
@@ -136,57 +134,29 @@ func GetAuthURL(challenge string, img string) (*url.URL, error) {
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"]))
+ authURL, _ := url.Parse(values["realm"])
q := authURL.Query()
q.Add("service", values["service"])
- scopeImage := GetScopeFromImageName(img, values["service"])
+ scopeImage := ref.Path(imageRef)
scope := fmt.Sprintf("repository:%s:pull", scopeImage)
- logrus.WithFields(logrus.Fields{"scope": scope, "image": img}).Debug("Setting scope for auth token")
+ logrus.WithFields(logrus.Fields{"scope": scope, "image": imageRef.Name()}).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
- }
+// GetChallengeURL returns the URL to check auth requirements
+// for access to a given image
+func GetChallengeURL(imageRef ref.Named) url.URL {
+ host, _ := helpers.GetRegistryAddress(imageRef.Name())
URL := url.URL{
Scheme: "https",
Host: host,
Path: "/v2/",
}
- return URL, nil
+ return URL
}
diff --git a/pkg/registry/auth/auth_test.go b/pkg/registry/auth/auth_test.go
index 6ad2307..d295310 100644
--- a/pkg/registry/auth/auth_test.go
+++ b/pkg/registry/auth/auth_test.go
@@ -2,14 +2,17 @@ package auth_test
import (
"fmt"
- "github.com/containrrr/watchtower/internal/actions/mocks"
- "github.com/containrrr/watchtower/pkg/registry/auth"
"net/url"
"os"
+ "strings"
"testing"
"time"
+ "github.com/containrrr/watchtower/internal/actions/mocks"
+ "github.com/containrrr/watchtower/pkg/registry/auth"
+
wtTypes "github.com/containrrr/watchtower/pkg/types"
+ ref "github.com/distribution/reference"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
@@ -51,7 +54,7 @@ var _ = Describe("the auth module", func() {
mockCreated,
mockDigest)
- When("getting an auth url", func() {
+ Describe("GetToken", func() {
It("should parse the token from the response",
SkipIfCredentialsEmpty(GHCRCredentials, func() {
creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
@@ -60,61 +63,100 @@ var _ = Describe("the auth module", func() {
Expect(token).NotTo(Equal(""))
}),
)
+ })
+ Describe("GetAuthURL", func() {
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"`
+ challenge := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"`
+ imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower")
+ Expect(err).NotTo(HaveOccurred())
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")
+
+ URL, err := auth.GetAuthURL(challenge, imageRef)
Expect(err).NotTo(HaveOccurred())
- Expect(res).To(Equal(expected))
+ Expect(URL).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("given an invalid challenge header", func() {
+ It("should return an error", func() {
+ challenge := `bearer realm="https://ghcr.io/token"`
+ imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower")
+ Expect(err).NotTo(HaveOccurred())
+ URL, err := auth.GetAuthURL(challenge, imageRef)
+ Expect(err).To(HaveOccurred())
+ Expect(URL).To(BeNil())
+ })
+ })
+
+ When("deriving the auth scope from an image name", func() {
+ It("should prepend official dockerhub images with \"library/\"", func() {
+ Expect(getScopeFromImageAuthURL("registry")).To(Equal("library/registry"))
+ Expect(getScopeFromImageAuthURL("docker.io/registry")).To(Equal("library/registry"))
+ Expect(getScopeFromImageAuthURL("index.docker.io/registry")).To(Equal("library/registry"))
+ })
+ It("should not include vanity hosts\"", func() {
+ Expect(getScopeFromImageAuthURL("docker.io/containrrr/watchtower")).To(Equal("containrrr/watchtower"))
+ Expect(getScopeFromImageAuthURL("index.docker.io/containrrr/watchtower")).To(Equal("containrrr/watchtower"))
+ })
+ It("should not destroy three segment image names\"", func() {
+ Expect(getScopeFromImageAuthURL("piksel/containrrr/watchtower")).To(Equal("piksel/containrrr/watchtower"))
+ Expect(getScopeFromImageAuthURL("ghcr.io/piksel/containrrr/watchtower")).To(Equal("piksel/containrrr/watchtower"))
+ })
+ It("should not prepend library/ to image names if they're not on dockerhub", func() {
+ Expect(getScopeFromImageAuthURL("ghcr.io/watchtower")).To(Equal("watchtower"))
+ Expect(getScopeFromImageAuthURL("ghcr.io/containrrr/watchtower")).To(Equal("containrrr/watchtower"))
+ })
+ })
+ It("should not crash when an empty field is received", func() {
+ input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull",`
+ imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower")
+ Expect(err).NotTo(HaveOccurred())
+ res, err := auth.GetAuthURL(input, imageRef)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(res).NotTo(BeNil())
+ })
+ It("should not crash when a field without a value is received", func() {
+ input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull",valuelesskey`
+ imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower")
+ Expect(err).NotTo(HaveOccurred())
+ res, err := auth.GetAuthURL(input, imageRef)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(res).NotTo(BeNil())
})
})
- When("getting a challenge url", func() {
+
+ Describe("GetChallengeURL", 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))
+ imageRef, _ := ref.ParseNormalizedNamed("ghcr.io/containrrr/watchtower:latest")
+ Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected))
})
- It("should assume dockerhub if the image ref is not fully qualified", func() {
+ It("should assume Docker Hub for image refs with no explicit registry", func() {
expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
- Expect(auth.GetChallengeURL("containrrr/watchtower:latest")).To(Equal(expected))
+ imageRef, _ := ref.ParseNormalizedNamed("containrrr/watchtower:latest")
+ Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected))
})
- It("should convert legacy dockerhub hostnames to index.docker.io", func() {
+ It("should use index.docker.io if the image ref specifies 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"))
+ imageRef, _ := ref.ParseNormalizedNamed("docker.io/containrrr/watchtower:latest")
+ Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected))
})
})
})
+
+var scopeImageRegexp = MatchRegexp("^repository:[a-z0-9]+(/[a-z0-9]+)*:pull$")
+
+func getScopeFromImageAuthURL(imageName string) string {
+ normalizedRef, _ := ref.ParseNormalizedNamed(imageName)
+ challenge := `bearer realm="https://dummy.host/token",service="dummy.host",scope="repository:user/image:pull"`
+ URL, _ := auth.GetAuthURL(challenge, normalizedRef)
+
+ scope := URL.Query().Get("scope")
+ Expect(scopeImageRegexp.Match(scope)).To(BeTrue())
+ return strings.Replace(scope[11:], ":pull", "", 1)
+}
diff --git a/pkg/registry/digest/digest.go b/pkg/registry/digest/digest.go
index 59f4d9b..e569599 100644
--- a/pkg/registry/digest/digest.go
+++ b/pkg/registry/digest/digest.go
@@ -6,12 +6,16 @@ import (
"encoding/json"
"errors"
"fmt"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/containrrr/watchtower/internal/meta"
"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/http"
- "strings"
)
// ContentDigestHeader is the key for the key-value pair containing the digest header
@@ -19,6 +23,10 @@ const ContentDigestHeader = "Docker-Content-Digest"
// CompareDigest ...
func CompareDigest(container types.Container, registryAuth string) (bool, error) {
+ if !container.HasImageInfo() {
+ return false, errors.New("container image info missing")
+ }
+
var digest string
registryAuth = TransformAuth(registryAuth)
@@ -69,22 +77,35 @@ func TransformAuth(registryAuth string) string {
// GetDigest from registry using a HEAD request to prevent rate limiting
func GetDigest(url string, token string) (string, error) {
tr := &http.Transport{
- TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ 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)
+ req.Header.Set("User-Agent", meta.UserAgent)
- if token != "" {
- logrus.WithField("token", token).Trace("Setting request token")
- } else {
+ if token == "" {
return "", errors.New("could not fetch token")
}
+ // CREDENTIAL: Uncomment to log the request token
+ // logrus.WithField("token", token).Trace("Setting request 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")
+ req.Header.Add("Accept", "application/vnd.oci.image.index.v1+json")
logrus.WithField("url", url).Debug("Doing a HEAD request to fetch a digest")
@@ -95,7 +116,11 @@ func GetDigest(url string, token string) (string, error) {
defer res.Body.Close()
if res.StatusCode != 200 {
- return "", fmt.Errorf("registry responded to head request with %v", res)
+ 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
index 0de6025..a6e6650 100644
--- a/pkg/registry/digest/digest_test.go
+++ b/pkg/registry/digest/digest_test.go
@@ -7,6 +7,8 @@ import (
wtTypes "github.com/containrrr/watchtower/pkg/types"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
+ "github.com/onsi/gomega/ghttp"
+ "net/http"
"os"
"testing"
"time"
@@ -18,14 +20,16 @@ func TestDigest(t *testing.T) {
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"),
-}
+var (
+ DockerHubCredentials = &wtTypes.RegistryCredentials{
+ Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_USERNAME"),
+ Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_PASSWORD"),
+ }
+ 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 == "" {
@@ -55,6 +59,8 @@ var _ = Describe("Digests", func() {
mockCreated,
mockDigest)
+ mockContainerNoImage := mocks.CreateMockContainerWithImageInfoP(mockId, mockName, mockImage, mockCreated, nil)
+
When("a digest comparison is done", func() {
It("should return true if digests match",
SkipIfCredentialsEmpty(GHCRCredentials, func() {
@@ -71,6 +77,11 @@ var _ = Describe("Digests", func() {
It("should return an error if the registry isn't available", func() {
})
+ It("should return an error when container contains no image info", func() {
+ matches, err := digest.CompareDigest(mockContainerNoImage, `user:pass`)
+ Expect(err).To(HaveOccurred())
+ Expect(matches).To(Equal(false))
+ })
})
When("using different registries", func() {
It("should work with DockerHub",
@@ -84,4 +95,31 @@ var _ = Describe("Digests", func() {
}),
)
})
+ When("sending a HEAD request", func() {
+ var server *ghttp.Server
+ BeforeEach(func() {
+ server = ghttp.NewServer()
+ })
+ AfterEach(func() {
+ server.Close()
+ })
+ It("should use a custom user-agent", func() {
+ server.AppendHandlers(
+ ghttp.CombineHandlers(
+ ghttp.VerifyHeader(http.Header{
+ "User-Agent": []string{"Watchtower/v0.0.0-unknown"},
+ }),
+ ghttp.RespondWith(http.StatusOK, "", http.Header{
+ digest.ContentDigestHeader: []string{
+ mockDigest,
+ },
+ }),
+ ),
+ )
+ dig, err := digest.GetDigest(server.URL(), "token")
+ Expect(server.ReceivedRequests()).Should(HaveLen(1))
+ Expect(err).NotTo(HaveOccurred())
+ Expect(dig).To(Equal(mockDigest))
+ })
+ })
})
diff --git a/pkg/registry/helpers/helpers.go b/pkg/registry/helpers/helpers.go
index 1469331..35d6ca3 100644
--- a/pkg/registry/helpers/helpers.go
+++ b/pkg/registry/helpers/helpers.go
@@ -1,36 +1,28 @@
package helpers
import (
- "fmt"
- url2 "net/url"
+ "github.com/distribution/reference"
)
-// 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()
+// domains for Docker Hub, the default registry
+const (
+ DefaultRegistryDomain = "docker.io"
+ DefaultRegistryHost = "index.docker.io"
+ LegacyDefaultRegistryDomain = "index.docker.io"
+)
- return hostName, port, err
-}
-
-// NormalizeRegistry makes sure variations of DockerHubs registry
-func NormalizeRegistry(registry string) (string, error) {
- hostName, port, err := ConvertToHostname(registry)
+// GetRegistryAddress parses an image name
+// and returns the address of the specified registry
+func GetRegistryAddress(imageRef string) (string, error) {
+ normalizedRef, err := reference.ParseNormalizedNamed(imageRef)
if err != nil {
return "", err
}
- if hostName == "registry-1.docker.io" || hostName == "docker.io" {
- hostName = "index.docker.io"
- }
+ address := reference.Domain(normalizedRef)
- if port != "" {
- return fmt.Sprintf("%s:%s", hostName, port), nil
+ if address == DefaultRegistryDomain {
+ address = DefaultRegistryHost
}
- return hostName, nil
+ return address, nil
}
diff --git a/pkg/registry/helpers/helpers_test.go b/pkg/registry/helpers/helpers_test.go
index 92e9116..a561c2c 100644
--- a/pkg/registry/helpers/helpers_test.go
+++ b/pkg/registry/helpers/helpers_test.go
@@ -1,9 +1,10 @@
package helpers
import (
+ "testing"
+
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
- "testing"
)
func TestHelpers(t *testing.T) {
@@ -12,20 +13,25 @@ func TestHelpers(t *testing.T) {
}
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())
+ Describe("GetRegistryAddress", func() {
+ It("should return error if passed empty string", func() {
+ _, err := GetRegistryAddress("")
+ Expect(err).To(HaveOccurred())
})
- })
- 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"))
+ It("should return index.docker.io for image refs with no explicit registry", func() {
+ Expect(GetRegistryAddress("watchtower")).To(Equal("index.docker.io"))
+ Expect(GetRegistryAddress("containrrr/watchtower")).To(Equal("index.docker.io"))
+ })
+ It("should return index.docker.io for image refs with docker.io domain", func() {
+ Expect(GetRegistryAddress("docker.io/watchtower")).To(Equal("index.docker.io"))
+ Expect(GetRegistryAddress("docker.io/containrrr/watchtower")).To(Equal("index.docker.io"))
+ })
+ It("should return the host if passed an image name containing a local host", func() {
+ Expect(GetRegistryAddress("henk:80/watchtower")).To(Equal("henk:80"))
+ Expect(GetRegistryAddress("localhost/watchtower")).To(Equal("localhost"))
+ })
+ It("should return the server address if passed a fully qualified image name", func() {
+ Expect(GetRegistryAddress("github.com/containrrr/config")).To(Equal("github.com"))
})
})
})
diff --git a/pkg/registry/manifest/manifest.go b/pkg/registry/manifest/manifest.go
index facbb6c..c732bae 100644
--- a/pkg/registry/manifest/manifest.go
+++ b/pkg/registry/manifest/manifest.go
@@ -1,42 +1,41 @@
package manifest
import (
+ "errors"
"fmt"
- "github.com/containrrr/watchtower/pkg/registry/auth"
+ url2 "net/url"
+
"github.com/containrrr/watchtower/pkg/registry/helpers"
"github.com/containrrr/watchtower/pkg/types"
- ref "github.com/docker/distribution/reference"
+ ref "github.com/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())
+ normalizedRef, err := ref.ParseDockerRef(container.ImageName())
if err != nil {
return "", err
}
+ normalizedTaggedRef, isTagged := normalizedRef.(ref.NamedTagged)
+ if !isTagged {
+ return "", errors.New("Parsed container image ref has no tag: " + normalizedRef.String())
+ }
- host, err := helpers.NormalizeRegistry(normalizedName.String())
- img, tag := ExtractImageAndTag(strings.TrimPrefix(container.ImageName(), host+"/"))
+ host, _ := helpers.GetRegistryAddress(normalizedTaggedRef.Name())
+ img, tag := ref.Path(normalizedTaggedRef), normalizedTaggedRef.Tag()
logrus.WithFields(logrus.Fields{
"image": img,
"tag": tag,
- "normalized": normalizedName,
+ "normalized": normalizedTaggedRef.Name(),
"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,
@@ -44,24 +43,3 @@ func BuildManifestURL(container types.Container) (string, error) {
}
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
index 95f196b..b24d9bc 100644
--- a/pkg/registry/manifest/manifest_test.go
+++ b/pkg/registry/manifest/manifest_test.go
@@ -1,13 +1,14 @@
package manifest_test
import (
+ "testing"
+ "time"
+
"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) {
@@ -16,60 +17,58 @@ func TestManifest(t *testing.T) {
}
var _ = Describe("the manifest module", func() {
- mockId := "mock-id"
- mockName := "mock-container"
- mockCreated := time.Now()
-
- When("building a manifest url", func() {
+ Describe("BuildManifestURL", 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)
+ imageRef := "ghcr.io/containrrr/watchtower:mytag"
+ expected := "https://ghcr.io/v2/containrrr/watchtower/manifests/mytag"
+
+ URL, err := buildMockContainerManifestURL(imageRef)
Expect(err).NotTo(HaveOccurred())
- Expect(res).To(Equal(expected))
+ Expect(URL).To(Equal(expected))
})
- It("should assume dockerhub for non-qualified images", func() {
+ It("should assume Docker Hub for image refs with no explicit registry", func() {
+ imageRef := "containrrr/watchtower:latest"
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)
+ URL, err := buildMockContainerManifestURL(imageRef)
Expect(err).NotTo(HaveOccurred())
- Expect(res).To(Equal(expected))
+ Expect(URL).To(Equal(expected))
})
- It("should assume latest for images that lack an explicit tag", func() {
+ It("should assume latest for image refs with no explicit tag", func() {
+ imageRef := "containrrr/watchtower"
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)
+ URL, err := buildMockContainerManifestURL(imageRef)
Expect(err).NotTo(HaveOccurred())
- Expect(res).To(Equal(expected))
+ Expect(URL).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"
+ It("should not prepend library/ for single-part container names in registries other than Docker Hub", func() {
+ imageRef := "docker-registry.domain/imagename:latest"
+ expected := "https://docker-registry.domain/v2/imagename/manifests/latest"
- imageOut, tagOut := manifest.ExtractImageAndTag(in)
-
- Expect(imageOut).To(Equal(image))
- Expect(tagOut).To(Equal(tag))
+ URL, err := buildMockContainerManifestURL(imageRef)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(URL).To(Equal(expected))
+ })
+ It("should throw an error on pinned images", func() {
+ imageRef := "docker-registry.domain/imagename@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7"
+ URL, err := buildMockContainerManifestURL(imageRef)
+ Expect(err).To(HaveOccurred())
+ Expect(URL).To(BeEmpty())
})
})
-
})
+
+func buildMockContainerManifestURL(imageRef string) (string, error) {
+ imageInfo := apiTypes.ImageInspect{
+ RepoTags: []string{
+ imageRef,
+ },
+ }
+ mockID := "mock-id"
+ mockName := "mock-container"
+ mockCreated := time.Now()
+ mock := mocks.CreateMockContainerWithImageInfo(mockID, mockName, imageRef, mockCreated, imageInfo)
+
+ return manifest.BuildManifestURL(mock)
+}
diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go
index 9edd66f..430b401 100644
--- a/pkg/registry/registry.go
+++ b/pkg/registry/registry.go
@@ -3,7 +3,7 @@ package registry
import (
"github.com/containrrr/watchtower/pkg/registry/helpers"
watchtowerTypes "github.com/containrrr/watchtower/pkg/types"
- ref "github.com/docker/distribution/reference"
+ ref "github.com/distribution/reference"
"github.com/docker/docker/api/types"
log "github.com/sirupsen/logrus"
)
@@ -19,7 +19,9 @@ func GetPullOptions(imageName string) (types.ImagePullOptions, error) {
if auth == "" {
return types.ImagePullOptions{}, nil
}
- log.Tracef("Got auth value: %s", auth)
+
+ // CREDENTIAL: Uncomment to log docker config auth
+ // log.Tracef("Got auth value: %s", auth)
return types.ImagePullOptions{
RegistryAuth: auth,
@@ -41,17 +43,17 @@ func DefaultAuthHandler() (string, error) {
// Will return false if behavior for container is unknown.
func WarnOnAPIConsumption(container watchtowerTypes.Container) bool {
- normalizedName, err := ref.ParseNormalizedNamed(container.ImageName())
+ normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName())
if err != nil {
return true
}
- containerHost, err := helpers.NormalizeRegistry(normalizedName.String())
+ containerHost, err := helpers.GetRegistryAddress(normalizedRef.Name())
if err != nil {
return true
}
- if containerHost == "index.docker.io" || containerHost == "ghcr.io" {
+ if containerHost == helpers.DefaultRegistryHost || containerHost == "ghcr.io" {
return true
}
diff --git a/pkg/registry/registry_suite_test.go b/pkg/registry/registry_suite_test.go
new file mode 100644
index 0000000..4dcbea6
--- /dev/null
+++ b/pkg/registry/registry_suite_test.go
@@ -0,0 +1,15 @@
+package registry_test
+
+import (
+ "github.com/sirupsen/logrus"
+ "testing"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+func TestRegistry(t *testing.T) {
+ RegisterFailHandler(Fail)
+ logrus.SetOutput(GinkgoWriter)
+ RunSpecs(t, "Registry Suite")
+}
diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go
new file mode 100644
index 0000000..481c91d
--- /dev/null
+++ b/pkg/registry/registry_test.go
@@ -0,0 +1,43 @@
+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("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 c2bf7da..0b20248 100644
--- a/pkg/registry/trust.go
+++ b/pkg/registry/trust.go
@@ -1,16 +1,16 @@
package registry
import (
+ "encoding/base64"
+ "encoding/json"
"errors"
"os"
- "strings"
- "github.com/docker/cli/cli/command"
+ "github.com/containrrr/watchtower/pkg/registry/helpers"
cliconfig "github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/cli/config/credentials"
- "github.com/docker/distribution/reference"
- "github.com/docker/docker/api/types"
+ "github.com/docker/cli/cli/config/types"
log "github.com/sirupsen/logrus"
)
@@ -18,7 +18,7 @@ import (
// loaded from environment variables or docker config
// as available in that order
func EncodedAuth(ref string) (string, error) {
- auth, err := EncodedEnvAuth(ref)
+ auth, err := EncodedEnvAuth()
if err != nil {
auth, err = EncodedConfigAuth(ref)
}
@@ -28,7 +28,7 @@ func EncodedAuth(ref string) (string, error) {
// EncodedEnvAuth returns an encoded auth config for the given registry
// loaded from environment variables
// Returns an error if authentication environment variables have not been set
-func EncodedEnvAuth(ref string) (string, error) {
+func EncodedEnvAuth() (string, error) {
username := os.Getenv("REPO_USER")
password := os.Getenv("REPO_PASS")
if username != "" && password != "" {
@@ -36,30 +36,34 @@ func EncodedEnvAuth(ref string) (string, error) {
Username: username,
Password: password,
}
- log.Debugf("Loaded auth credentials for user %s on registry %s", auth.Username, ref)
- log.Tracef("Using auth password %s", auth.Password)
+
+ log.Debugf("Loaded auth credentials for registry user %s from environment", auth.Username)
+ // CREDENTIAL: Uncomment to log REPO_PASS environment variable
+ // log.Tracef("Using auth password %s", auth.Password)
+
return EncodeAuth(auth)
}
- return "", errors.New("Registry auth environment variables (REPO_USER, REPO_PASS) not set")
+ return "", errors.New("registry auth environment variables (REPO_USER, REPO_PASS) not set")
}
// EncodedConfigAuth returns an encoded auth config for the given registry
// loaded from the docker config
// Returns an empty string if credentials cannot be found for the referenced server
// The docker config must be mounted on the container
-func EncodedConfigAuth(ref string) (string, error) {
- server, err := ParseServerAddress(ref)
+func EncodedConfigAuth(imageRef string) (string, error) {
+ server, err := helpers.GetRegistryAddress(imageRef)
if err != nil {
- log.Errorf("Unable to parse the image ref %s", err)
+ log.Errorf("Could not get registry from image ref %s", imageRef)
return "", err
}
+
configDir := os.Getenv("DOCKER_CONFIG")
if configDir == "" {
configDir = "/"
}
configFile, err := cliconfig.Load(configDir)
if err != nil {
- log.Errorf("Unable to find default config file %s", err)
+ log.Errorf("Unable to find default config file: %s", err)
return "", err
}
credStore := CredentialsStore(*configFile)
@@ -69,23 +73,12 @@ func EncodedConfigAuth(ref string) (string, error) {
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)
- log.Tracef("Using auth password %s", auth.Password)
+ log.Debugf("Loaded auth credentials for user %s, on registry %s, from file %s", auth.Username, server, configFile.Filename)
+ // CREDENTIAL: Uncomment to log docker config password
+ // log.Tracef("Using auth password %s", auth.Password)
return EncodeAuth(auth)
}
-// ParseServerAddress extracts the server part from a container image ref
-func ParseServerAddress(ref string) (string, error) {
-
- parsedRef, err := reference.Parse(ref)
- if err != nil {
- return ref, err
- }
-
- parts := strings.Split(parsedRef.String(), "/")
- return parts[0], nil
-}
-
// CredentialsStore returns a new credentials store based
// on the settings provided in the configuration file.
func CredentialsStore(configFile configfile.ConfigFile) credentials.Store {
@@ -96,6 +89,10 @@ func CredentialsStore(configFile configfile.ConfigFile) credentials.Store {
}
// EncodeAuth Base64 encode an AuthConfig struct for transmission over HTTP
-func EncodeAuth(auth types.AuthConfig) (string, error) {
- return command.EncodeAuthToBase64(auth)
+func EncodeAuth(authConfig types.AuthConfig) (string, error) {
+ buf, err := json.Marshal(authConfig)
+ if err != nil {
+ return "", err
+ }
+ return base64.URLEncoding.EncodeToString(buf), nil
}
diff --git a/pkg/registry/trust_test.go b/pkg/registry/trust_test.go
index 7d4d48d..00fc8a7 100644
--- a/pkg/registry/trust_test.go
+++ b/pkg/registry/trust_test.go
@@ -1,71 +1,49 @@
package registry
import (
+ "os"
+
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
- "os"
- "testing"
)
-func TestTrust(t *testing.T) {
- RegisterFailHandler(Fail)
- RunSpecs(t, "Trust Suite")
-}
+var _ = Describe("Registry credential helpers", func() {
+ Describe("EncodedAuth", func() {
+ It("should return repo credentials from env when set", func() {
+ var err error
+ expected := "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")
+ err = os.Setenv("REPO_USER", "containrrr-user")
+ Expect(err).NotTo(HaveOccurred())
- _, err := EncodedEnvAuth("")
- Expect(err).To(HaveOccurred())
+ err = os.Setenv("REPO_PASS", "containrrr-pass")
+ Expect(err).NotTo(HaveOccurred())
+
+ config, err := EncodedEnvAuth()
+ Expect(config).To(Equal(expected))
+ Expect(err).NotTo(HaveOccurred())
+ })
})
- It("encoded env auth_ should return auth hash if repo envs are set", func() {
- var err error
- expectedHash := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
- err = os.Setenv("REPO_USER", "containrrr-user")
- Expect(err).NotTo(HaveOccurred())
+ Describe("EncodedEnvAuth", func() {
+ It("should return an error if repo envs are unset", func() {
+ _ = os.Unsetenv("REPO_USER")
+ _ = os.Unsetenv("REPO_PASS")
- err = os.Setenv("REPO_PASS", "containrrr-pass")
- Expect(err).NotTo(HaveOccurred())
-
- config, err := EncodedEnvAuth("")
- Expect(config).To(Equal(expectedHash))
- Expect(err).NotTo(HaveOccurred())
+ _, err := EncodedEnvAuth()
+ Expect(err).To(HaveOccurred())
+ })
})
- It("encoded config auth_ should return an error if file is not present", func() {
- var err error
- err = os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
- Expect(err).NotTo(HaveOccurred())
+ Describe("EncodedConfigAuth", func() {
+ It("should return an error if file is not present", func() {
+ var err error
- _, err = EncodedConfigAuth("")
- Expect(err).To(HaveOccurred())
+ err = os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
+ 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
- */
- It("parse server address_ should return error if passed empty string", func() {
-
- _, 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"))
+ _, err = EncodedConfigAuth("")
+ Expect(err).To(HaveOccurred())
+ })
})
})
diff --git a/pkg/session/container_status.go b/pkg/session/container_status.go
new file mode 100644
index 0000000..8313da1
--- /dev/null
+++ b/pkg/session/container_status.go
@@ -0,0 +1,82 @@
+package session
+
+import wt "github.com/containrrr/watchtower/pkg/types"
+
+// State indicates what the current state is of the container
+type State int
+
+// State enum values
+const (
+ // UnknownState is only used to represent an uninitialized State value
+ UnknownState State = iota
+ SkippedState
+ ScannedState
+ UpdatedState
+ FailedState
+ FreshState
+ StaleState
+)
+
+// ContainerStatus contains the container state during a session
+type ContainerStatus struct {
+ containerID wt.ContainerID
+ oldImage wt.ImageID
+ newImage wt.ImageID
+ containerName string
+ imageName string
+ error
+ state State
+}
+
+// ID returns the container ID
+func (u *ContainerStatus) ID() wt.ContainerID {
+ return u.containerID
+}
+
+// Name returns the container name
+func (u *ContainerStatus) Name() string {
+ return u.containerName
+}
+
+// CurrentImageID returns the image ID that the container used when the session started
+func (u *ContainerStatus) CurrentImageID() wt.ImageID {
+ return u.oldImage
+}
+
+// LatestImageID returns the newest image ID found during the session
+func (u *ContainerStatus) LatestImageID() wt.ImageID {
+ return u.newImage
+}
+
+// ImageName returns the name:tag that the container uses
+func (u *ContainerStatus) ImageName() string {
+ return u.imageName
+}
+
+// Error returns the error (if any) that was encountered for the container during a session
+func (u *ContainerStatus) Error() string {
+ if u.error == nil {
+ return ""
+ }
+ return u.error.Error()
+}
+
+// State returns the current State that the container is in
+func (u *ContainerStatus) State() string {
+ switch u.state {
+ case SkippedState:
+ return "Skipped"
+ case ScannedState:
+ return "Scanned"
+ case UpdatedState:
+ return "Updated"
+ case FailedState:
+ return "Failed"
+ case FreshState:
+ return "Fresh"
+ case StaleState:
+ return "Stale"
+ default:
+ return "Unknown"
+ }
+}
diff --git a/pkg/session/progress.go b/pkg/session/progress.go
new file mode 100644
index 0000000..57069be
--- /dev/null
+++ b/pkg/session/progress.go
@@ -0,0 +1,56 @@
+package session
+
+import (
+ "github.com/containrrr/watchtower/pkg/types"
+)
+
+// Progress contains the current session container status
+type Progress map[types.ContainerID]*ContainerStatus
+
+// UpdateFromContainer sets various status fields from their corresponding container equivalents
+func UpdateFromContainer(cont types.Container, newImage types.ImageID, state State) *ContainerStatus {
+ return &ContainerStatus{
+ containerID: cont.ID(),
+ containerName: cont.Name(),
+ imageName: cont.ImageName(),
+ oldImage: cont.SafeImageID(),
+ newImage: newImage,
+ state: state,
+ }
+}
+
+// AddSkipped adds a container to the Progress with the state set as skipped
+func (m Progress) AddSkipped(cont types.Container, err error) {
+ update := UpdateFromContainer(cont, cont.SafeImageID(), SkippedState)
+ update.error = err
+ m.Add(update)
+}
+
+// AddScanned adds a container to the Progress with the state set as scanned
+func (m Progress) AddScanned(cont types.Container, newImage types.ImageID) {
+ m.Add(UpdateFromContainer(cont, newImage, ScannedState))
+}
+
+// UpdateFailed updates the containers passed, setting their state as failed with the supplied error
+func (m Progress) UpdateFailed(failures map[types.ContainerID]error) {
+ for id, err := range failures {
+ update := m[id]
+ update.error = err
+ update.state = FailedState
+ }
+}
+
+// Add a container to the map using container ID as the key
+func (m Progress) Add(update *ContainerStatus) {
+ m[update.containerID] = update
+}
+
+// MarkForUpdate marks the container identified by containerID for update
+func (m Progress) MarkForUpdate(containerID types.ContainerID) {
+ m[containerID].state = UpdatedState
+}
+
+// Report creates a new Report from a Progress instance
+func (m Progress) Report() types.Report {
+ return NewReport(m)
+}
diff --git a/pkg/session/report.go b/pkg/session/report.go
new file mode 100644
index 0000000..707eb91
--- /dev/null
+++ b/pkg/session/report.go
@@ -0,0 +1,118 @@
+package session
+
+import (
+ "sort"
+
+ "github.com/containrrr/watchtower/pkg/types"
+)
+
+type report struct {
+ scanned []types.ContainerReport
+ updated []types.ContainerReport
+ failed []types.ContainerReport
+ skipped []types.ContainerReport
+ stale []types.ContainerReport
+ fresh []types.ContainerReport
+}
+
+func (r *report) Scanned() []types.ContainerReport {
+ return r.scanned
+}
+func (r *report) Updated() []types.ContainerReport {
+ return r.updated
+}
+func (r *report) Failed() []types.ContainerReport {
+ return r.failed
+}
+func (r *report) Skipped() []types.ContainerReport {
+ return r.skipped
+}
+func (r *report) Stale() []types.ContainerReport {
+ return r.stale
+}
+func (r *report) Fresh() []types.ContainerReport {
+ return r.fresh
+}
+func (r *report) All() []types.ContainerReport {
+ allLen := len(r.scanned) + len(r.updated) + len(r.failed) + len(r.skipped) + len(r.stale) + len(r.fresh)
+ all := make([]types.ContainerReport, 0, allLen)
+
+ presentIds := map[types.ContainerID][]string{}
+
+ appendUnique := func(reports []types.ContainerReport) {
+ for _, cr := range reports {
+ if _, found := presentIds[cr.ID()]; found {
+ continue
+ }
+ all = append(all, cr)
+ presentIds[cr.ID()] = nil
+ }
+ }
+
+ appendUnique(r.updated)
+ appendUnique(r.failed)
+ appendUnique(r.skipped)
+ appendUnique(r.stale)
+ appendUnique(r.fresh)
+ appendUnique(r.scanned)
+
+ sort.Sort(sortableContainers(all))
+
+ return all
+}
+
+// NewReport creates a types.Report from the supplied Progress
+func NewReport(progress Progress) types.Report {
+ report := &report{
+ scanned: []types.ContainerReport{},
+ updated: []types.ContainerReport{},
+ failed: []types.ContainerReport{},
+ skipped: []types.ContainerReport{},
+ stale: []types.ContainerReport{},
+ fresh: []types.ContainerReport{},
+ }
+
+ for _, update := range progress {
+ if update.state == SkippedState {
+ report.skipped = append(report.skipped, update)
+ continue
+ }
+
+ report.scanned = append(report.scanned, update)
+ if update.newImage == update.oldImage {
+ update.state = FreshState
+ report.fresh = append(report.fresh, update)
+ continue
+ }
+
+ switch update.state {
+ case UpdatedState:
+ report.updated = append(report.updated, update)
+ case FailedState:
+ report.failed = append(report.failed, update)
+ default:
+ update.state = StaleState
+ report.stale = append(report.stale, update)
+ }
+ }
+
+ sort.Sort(sortableContainers(report.scanned))
+ sort.Sort(sortableContainers(report.updated))
+ sort.Sort(sortableContainers(report.failed))
+ sort.Sort(sortableContainers(report.skipped))
+ sort.Sort(sortableContainers(report.stale))
+ sort.Sort(sortableContainers(report.fresh))
+
+ return report
+}
+
+type sortableContainers []types.ContainerReport
+
+// Len implements sort.Interface.Len
+func (s sortableContainers) Len() int { return len(s) }
+
+// Less implements sort.Interface.Less
+func (s sortableContainers) Less(i, j int) bool { return s[i].ID() < s[j].ID() }
+
+// Swap implements sort.Interface.Swap
+func (s sortableContainers) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
diff --git a/pkg/sorter/sort.go b/pkg/sorter/sort.go
index 1e27f1b..b9d1e12 100644
--- a/pkg/sorter/sort.go
+++ b/pkg/sorter/sort.go
@@ -2,13 +2,14 @@ package sorter
import (
"fmt"
- "github.com/containrrr/watchtower/pkg/container"
"time"
+
+ "github.com/containrrr/watchtower/pkg/types"
)
// ByCreated allows a list of Container structs to be sorted by the container's
// created date.
-type ByCreated []container.Container
+type ByCreated []types.Container
func (c ByCreated) Len() int { return len(c) }
func (c ByCreated) Swap(i, j int) { c[i], c[j] = c[j], c[i] }
@@ -34,18 +35,18 @@ func (c ByCreated) Less(i, j int) bool {
// the front of the list while containers with links will be sorted after all
// of their dependencies. This sort order ensures that linked containers can
// be started in the correct order.
-func SortByDependencies(containers []container.Container) ([]container.Container, error) {
+func SortByDependencies(containers []types.Container) ([]types.Container, error) {
sorter := dependencySorter{}
return sorter.Sort(containers)
}
type dependencySorter struct {
- unvisited []container.Container
+ unvisited []types.Container
marked map[string]bool
- sorted []container.Container
+ sorted []types.Container
}
-func (ds *dependencySorter) Sort(containers []container.Container) ([]container.Container, error) {
+func (ds *dependencySorter) Sort(containers []types.Container) ([]types.Container, error) {
ds.unvisited = containers
ds.marked = map[string]bool{}
@@ -58,7 +59,7 @@ func (ds *dependencySorter) Sort(containers []container.Container) ([]container.
return ds.sorted, nil
}
-func (ds *dependencySorter) visit(c container.Container) error {
+func (ds *dependencySorter) visit(c types.Container) error {
if _, ok := ds.marked[c.Name()]; ok {
return fmt.Errorf("circular reference to %s", c.Name())
@@ -84,7 +85,7 @@ func (ds *dependencySorter) visit(c container.Container) error {
return nil
}
-func (ds *dependencySorter) findUnvisited(name string) *container.Container {
+func (ds *dependencySorter) findUnvisited(name string) *types.Container {
for _, c := range ds.unvisited {
if c.Name() == name {
return &c
@@ -94,7 +95,7 @@ func (ds *dependencySorter) findUnvisited(name string) *container.Container {
return nil
}
-func (ds *dependencySorter) removeUnvisited(c container.Container) {
+func (ds *dependencySorter) removeUnvisited(c types.Container) {
var idx int
for i := range ds.unvisited {
if ds.unvisited[i].Name() == c.Name() {
diff --git a/pkg/types/container.go b/pkg/types/container.go
index 50baac6..8a22f44 100644
--- a/pkg/types/container.go
+++ b/pkg/types/container.go
@@ -1,17 +1,58 @@
package types
-import "github.com/docker/docker/api/types"
+import (
+ "strings"
+
+ "github.com/docker/docker/api/types"
+ dc "github.com/docker/docker/api/types/container"
+)
+
+// ImageID is a hash string representing a container image
+type ImageID string
+
+// ContainerID is a hash string representing a container instance
+type ContainerID string
+
+// ShortID returns the 12-character (hex) short version of an image ID hash, removing any "sha256:" prefix if present
+func (id ImageID) ShortID() (short string) {
+ return shortID(string(id))
+}
+
+// ShortID returns the 12-character (hex) short version of a container ID hash, removing any "sha256:" prefix if present
+func (id ContainerID) ShortID() (short string) {
+ return shortID(string(id))
+}
+
+func shortID(longID string) string {
+ prefixSep := strings.IndexRune(longID, ':')
+ offset := 0
+ length := 12
+ if prefixSep >= 0 {
+ if longID[0:prefixSep] == "sha256" {
+ offset = prefixSep + 1
+ } else {
+ length += prefixSep + 1
+ }
+ }
+
+ if len(longID) >= offset+length {
+ return longID[offset : offset+length]
+ }
+
+ return longID
+}
// Container is a docker container running an image
type Container interface {
ContainerInfo() *types.ContainerJSON
- ID() string
+ ID() ContainerID
IsRunning() bool
Name() string
- ImageID() string
+ ImageID() ImageID
+ SafeImageID() ImageID
ImageName() string
Enabled() (bool, bool)
- IsMonitorOnly() bool
+ IsMonitorOnly(UpdateParams) bool
Scope() (string, bool)
Links() []string
ToRestart() bool
@@ -23,4 +64,15 @@ type Container interface {
GetLifecyclePostCheckCommand() string
GetLifecyclePreUpdateCommand() string
GetLifecyclePostUpdateCommand() string
+ VerifyConfiguration() error
+ SetStale(bool)
+ IsStale() bool
+ IsNoPull(UpdateParams) bool
+ SetLinkedToRestarting(bool)
+ IsLinkedToRestarting() bool
+ PreUpdateTimeout() int
+ PostUpdateTimeout() int
+ IsRestarting() bool
+ GetCreateConfig() *dc.Config
+ GetCreateHostConfig() *dc.HostConfig
}
diff --git a/pkg/types/convertable_notifier.go b/pkg/types/convertable_notifier.go
deleted file mode 100644
index 3d7ac82..0000000
--- a/pkg/types/convertable_notifier.go
+++ /dev/null
@@ -1,7 +0,0 @@
-package types
-
-// ConvertableNotifier is a notifier capable of creating a shoutrrr URL
-type ConvertableNotifier interface {
- Notifier
- GetURL() string
-}
diff --git a/pkg/types/convertible_notifier.go b/pkg/types/convertible_notifier.go
new file mode 100644
index 0000000..82d7b7b
--- /dev/null
+++ b/pkg/types/convertible_notifier.go
@@ -0,0 +1,17 @@
+package types
+
+import (
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+// ConvertibleNotifier is a notifier capable of creating a shoutrrr URL
+type ConvertibleNotifier interface {
+ GetURL(c *cobra.Command) (string, error)
+}
+
+// DelayNotifier is a notifier that might need to be delayed before sending notifications
+type DelayNotifier interface {
+ GetDelay() time.Duration
+}
diff --git a/pkg/types/filterable_container.go b/pkg/types/filterable_container.go
index 3c46295..b410b1c 100644
--- a/pkg/types/filterable_container.go
+++ b/pkg/types/filterable_container.go
@@ -7,4 +7,5 @@ type FilterableContainer interface {
IsWatchtower() bool
Enabled() (bool, bool)
Scope() (string, bool)
+ ImageName() string
}
diff --git a/pkg/types/notifier.go b/pkg/types/notifier.go
index 27dc483..478a4c4 100644
--- a/pkg/types/notifier.go
+++ b/pkg/types/notifier.go
@@ -3,6 +3,9 @@ package types
// Notifier is the interface that all notification services have in common
type Notifier interface {
StartNotification()
- SendNotification()
+ SendNotification(Report)
+ AddLogHook()
+ GetNames() []string
+ GetURLs() []string
Close()
}
diff --git a/pkg/types/report.go b/pkg/types/report.go
new file mode 100644
index 0000000..f454fc6
--- /dev/null
+++ b/pkg/types/report.go
@@ -0,0 +1,23 @@
+package types
+
+// Report contains reports for all the containers processed during a session
+type Report interface {
+ Scanned() []ContainerReport
+ Updated() []ContainerReport
+ Failed() []ContainerReport
+ Skipped() []ContainerReport
+ Stale() []ContainerReport
+ Fresh() []ContainerReport
+ All() []ContainerReport
+}
+
+// ContainerReport represents a container that was included in watchtower session
+type ContainerReport interface {
+ ID() ContainerID
+ Name() string
+ CurrentImageID() ImageID
+ LatestImageID() ImageID
+ ImageName() string
+ Error() string
+ State() string
+}
diff --git a/pkg/types/update_params.go b/pkg/types/update_params.go
index 611cc70..2b6d3c4 100644
--- a/pkg/types/update_params.go
+++ b/pkg/types/update_params.go
@@ -6,11 +6,13 @@ import (
// UpdateParams contains all different options available to alter the behavior of the Update func
type UpdateParams struct {
- Filter Filter
- Cleanup bool
- NoRestart bool
- Timeout time.Duration
- MonitorOnly bool
- LifecycleHooks bool
- RollingRestart bool
+ Filter Filter
+ Cleanup bool
+ NoRestart bool
+ Timeout time.Duration
+ MonitorOnly bool
+ NoPull bool
+ LifecycleHooks bool
+ RollingRestart bool
+ LabelPrecedence bool
}
diff --git a/scripts/build-tplprev.sh b/scripts/build-tplprev.sh
new file mode 100755
index 0000000..293710c
--- /dev/null
+++ b/scripts/build-tplprev.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+
+cd $(git rev-parse --show-toplevel)
+
+cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./docs/assets/
+
+GOARCH=wasm GOOS=js go build -o ./docs/assets/tplprev.wasm ./tplprev
\ No newline at end of file
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/contnet-tests.sh b/scripts/contnet-tests.sh
new file mode 100755
index 0000000..25269dc
--- /dev/null
+++ b/scripts/contnet-tests.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+
+set -e
+
+function exit_env_err() {
+ >&2 echo "Required environment variable not set: $1"
+ exit 1
+}
+
+if [ -z "$VPN_SERVICE_PROVIDER" ]; then exit_env_err "VPN_SERVICE_PROVIDER"; fi
+if [ -z "$OPENVPN_USER" ]; then exit_env_err "OPENVPN_USER"; fi
+if [ -z "$OPENVPN_PASSWORD" ]; then exit_env_err "OPENVPN_PASSWORD"; fi
+# if [ -z "$SERVER_COUNTRIES" ]; then exit_env_err "SERVER_COUNTRIES"; fi
+
+
+export SERVER_COUNTRIES=${SERVER_COUNTRIES:"Sweden"}
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+COMPOSE_FILE="$REPO_ROOT/dockerfiles/container-networking/docker-compose.yml"
+DEFAULT_WATCHTOWER="$REPO_ROOT/watchtower"
+WATCHTOWER="$*"
+WATCHTOWER=${WATCHTOWER:-$DEFAULT_WATCHTOWER}
+echo "repo root path is $REPO_ROOT"
+echo "watchtower path is $WATCHTOWER"
+echo "compose file path is $COMPOSE_FILE"
+
+echo; echo "=== Forcing network container producer update..."
+
+echo "Pull previous version of gluetun..."
+docker pull qmcgaw/gluetun:v3.34.3
+echo "Fake new version of gluetun by retagging v3.34.4 as v3.35.0..."
+docker tag qmcgaw/gluetun:v3.34.3 qmcgaw/gluetun:v3.35.0
+
+echo; echo "=== Creating containers..."
+
+docker compose -p "wt-contnet" -f "$COMPOSE_FILE" up -d
+
+echo; echo "=== Running watchtower"
+$WATCHTOWER --run-once
+
+echo; echo "=== Removing containers..."
+
+docker compose -p "wt-contnet" -f "$COMPOSE_FILE" down
diff --git a/scripts/dependency-test.sh b/scripts/dependency-test.sh
new file mode 100755
index 0000000..49c672b
--- /dev/null
+++ b/scripts/dependency-test.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+
+# Simulates a container that will always be updated, checking whether it shuts down it's dependencies correctly.
+# Note that this test does not verify the results in any way
+
+set -e
+SCRIPT_ROOT=$(dirname "$(readlink -m "$(type -p "$0")")")
+source "$SCRIPT_ROOT/docker-util.sh"
+
+DepArgs=""
+if [ -z "$1" ] || [ "$1" == "depends-on" ]; then
+ DepArgs="--label com.centurylinklabs.watchtower.depends-on=parent"
+elif [ "$1" == "linked" ]; then
+ DepArgs="--link parent"
+else
+ DepArgs=$1
+fi
+
+WatchArgs="${*:2}"
+if [ -z "$WatchArgs" ]; then
+ WatchArgs="--debug"
+fi
+
+try-remove-container parent
+try-remove-container depending
+
+REPO=$(registry-host)
+
+create-dummy-image deptest/parent
+create-dummy-image deptest/depending
+
+echo ""
+
+echo -en "Starting \e[94mparent\e[0m container... "
+CmdParent="docker run -d -p 9090 --name parent $REPO/deptest/parent"
+$CmdParent
+PARENT_REV_BEFORE=$(query-rev parent)
+PARENT_START_BEFORE=$(container-started parent)
+echo -e "Rev: \e[92m$PARENT_REV_BEFORE\e[0m"
+echo -e "Started: \e[96m$PARENT_START_BEFORE\e[0m"
+echo -e "Command: \e[37m$CmdParent\e[0m"
+
+echo ""
+
+echo -en "Starting \e[94mdepending\e[0m container... "
+CmdDepend="docker run -d -p 9090 --name depending $DepArgs $REPO/deptest/depending"
+$CmdDepend
+DEPEND_REV_BEFORE=$(query-rev depending)
+DEPEND_START_BEFORE=$(container-started depending)
+echo -e "Rev: \e[92m$DEPEND_REV_BEFORE\e[0m"
+echo -e "Started: \e[96m$DEPEND_START_BEFORE\e[0m"
+echo -e "Command: \e[37m$CmdDepend\e[0m"
+
+echo -e ""
+
+create-dummy-image deptest/parent
+
+echo -e "\nRunning watchtower..."
+
+if [ -z "$WATCHTOWER_TAG" ]; then
+ ## Windows support:
+ #export DOCKER_HOST=tcp://localhost:2375
+ #export CLICOLOR=1
+ go run . --run-once $WatchArgs
+else
+ docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower:"$WATCHTOWER_TAG" --run-once $WatchArgs
+fi
+
+echo -e "\nSession results:"
+
+PARENT_REV_AFTER=$(query-rev parent)
+PARENT_START_AFTER=$(container-started parent)
+echo -en " Parent image: \e[95m$PARENT_REV_BEFORE\e[0m => \e[94m$PARENT_REV_AFTER\e[0m "
+if [ "$PARENT_REV_AFTER" == "$PARENT_REV_BEFORE" ]; then
+ echo -e "(\e[91mSame\e[0m)"
+else
+ echo -e "(\e[92mUpdated\e[0m)"
+fi
+echo -en " Parent container: \e[95m$PARENT_START_BEFORE\e[0m => \e[94m$PARENT_START_AFTER\e[0m "
+if [ "$PARENT_START_AFTER" == "$PARENT_START_BEFORE" ]; then
+ echo -e "(\e[91mSame\e[0m)"
+else
+ echo -e "(\e[92mRestarted\e[0m)"
+fi
+
+echo ""
+
+DEPEND_REV_AFTER=$(query-rev depending)
+DEPEND_START_AFTER=$(container-started depending)
+echo -en " Depend image: \e[95m$DEPEND_REV_BEFORE\e[0m => \e[94m$DEPEND_REV_AFTER\e[0m "
+if [ "$DEPEND_REV_BEFORE" == "$DEPEND_REV_AFTER" ]; then
+ echo -e "(\e[92mSame\e[0m)"
+else
+ echo -e "(\e[91mUpdated\e[0m)"
+fi
+echo -en " Depend container: \e[95m$DEPEND_START_BEFORE\e[0m => \e[94m$DEPEND_START_AFTER\e[0m "
+if [ "$DEPEND_START_BEFORE" == "$DEPEND_START_AFTER" ]; then
+ echo -e "(\e[91mSame\e[0m)"
+else
+ echo -e "(\e[92mRestarted\e[0m)"
+fi
+
+echo ""
\ No newline at end of file
diff --git a/scripts/docker-util.sh b/scripts/docker-util.sh
new file mode 100644
index 0000000..bd0dbda
--- /dev/null
+++ b/scripts/docker-util.sh
@@ -0,0 +1,186 @@
+#!/usr/bin/env bash
+# This file is meant to be sourced into other scripts and contain some utility functions for docker e2e testing
+
+
+CONTAINER_PREFIX=${CONTAINER_PREFIX:-du}
+
+function get-port() {
+ Container=$1
+ Port=$2
+
+ if [ -z "$Container" ]; then
+ echo "CONTAINER missing" 1>&2
+ return 1
+ fi
+
+ if [ -z "$Port" ]; then
+ echo "PORT missing" 1>&2
+ return 1
+ fi
+
+ Query=".[].NetworkSettings.Ports[\"$Port/tcp\"] | .[0].HostPort"
+ docker container inspect "$Container" | jq -r "$Query"
+}
+
+function start-registry() {
+ local Name="$CONTAINER_PREFIX-registry"
+ echo -en "Starting \e[94m$Name\e[0m container... "
+ local Port="${1:-5000}"
+ docker run -d -p 5000:"$Port" --restart=unless-stopped --name "$Name" registry:2
+}
+
+function stop-registry() {
+ try-remove-container "$CONTAINER_PREFIX-registry"
+}
+
+function registry-host() {
+ echo "localhost:$(get-port "$CONTAINER_PREFIX"-registry 5000)"
+}
+
+function try-remove-container() {
+ echo -en "Looking for container \e[95m$1\e[0m... "
+ local Found
+ Found=$(container-id "$1")
+ if [ -n "$Found" ]; then
+ echo "$Found"
+ echo -n " Stopping... "
+ docker stop "$1"
+ echo -n " Removing... "
+ docker rm "$1"
+ else
+ echo "Not found"
+ fi
+}
+
+function create-dummy-image() {
+ if [ -z "$1" ]; then
+ echo "TAG missing"
+ return 1
+ fi
+ local Tag="$1"
+ local Repo
+ Repo="$(registry-host)"
+ local Revision=${2:-$(("$(date +%s)" - "$(date --date='2021-10-21' +%s)"))}
+
+ echo -e "Creating new image \e[95m$Tag\e[0m revision: \e[94m$Revision\e[0m"
+
+ local BuildDir="/tmp/docker-dummy-$Tag-$Revision"
+
+ mkdir -p "$BuildDir"
+
+ cat > "$BuildDir/Dockerfile" << END
+FROM alpine
+
+RUN echo "Tag: $Tag"
+RUN echo "Revision: $Revision"
+ENTRYPOINT ["nc", "-lk", "-v", "-l", "-p", "9090", "-e", "echo", "-e", "HTTP/1.1 200 OK\n\n$Tag $Revision"]
+END
+
+ docker build -t "$Repo/$Tag:latest" -t "$Repo/$Tag:r$Revision" "$BuildDir"
+
+ echo -e "Pushing images...\e[93m"
+ docker push -q "$Repo/$Tag:latest"
+ docker push -q "$Repo/$Tag:r$Revision"
+ echo -en "\e[0m"
+
+ rm -r "$BuildDir"
+}
+
+function query-rev() {
+ local Name=$1
+ if [ -z "$Name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+ curl -s "localhost:$(get-port "$Name" 9090)"
+}
+
+function latest-image-rev() {
+ local Tag=$1
+ if [ -z "$Tag" ]; then
+ echo "TAG missing"
+ return 1
+ fi
+ local ID
+ ID=$(docker image ls "$(registry-host)"/"$Tag":latest -q)
+ docker image inspect "$ID" | jq -r '.[].RepoTags | .[]' | grep -v latest
+}
+
+function container-id() {
+ local Name=$1
+ if [ -z "$Name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+ docker container ls -f name="$Name" -q
+}
+
+function container-started() {
+ local Name=$1
+ if [ -z "$Name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+ docker container inspect "$Name" | jq -r .[].State.StartedAt
+}
+
+
+function container-exists() {
+ local Name=$1
+ if [ -z "$Name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+
+ docker container inspect "$Name" 1> /dev/null 2> /dev/null
+}
+
+function registry-exists() {
+ container-exists "$CONTAINER_PREFIX-registry"
+}
+
+function create-container() {
+ local container_name=$1
+ if [ -z "$container_name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+ local image_name="${2:-$container_name}"
+
+ echo -en "Creating \e[94m$container_name\e[0m container... "
+ local result
+ result=$(docker run -d --name "$container_name" "$(registry-host)/$image_name" 2>&1)
+ if [ "${#result}" -eq 64 ]; then
+ echo -e "\e[92m${result:0:12}\e[0m"
+ return 0
+ else
+ echo -e "\e[91mFailed!\n\e[97m$result\e[0m"
+ return 1
+ fi
+}
+
+function remove-images() {
+ local image_name=$1
+ if [ -z "$image_name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+
+ local images
+ mapfile -t images < <(docker images -q "$image_name" | uniq)
+ if [ -n "${images[*]}" ]; then
+ docker image rm "${images[@]}"
+ else
+ echo "No images matched \"$image_name\""
+ fi
+}
+
+function remove-repo-images() {
+ local image_name=$1
+ if [ -z "$image_name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+
+ remove-images "$(registry-host)/images/$image_name"
+}
\ No newline at end of file
diff --git a/scripts/du-cli.sh b/scripts/du-cli.sh
new file mode 100755
index 0000000..611f720
--- /dev/null
+++ b/scripts/du-cli.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+
+SCRIPT_ROOT=$(dirname "$(readlink -m "$(type -p "$0")")")
+source "$SCRIPT_ROOT/docker-util.sh"
+
+case $1 in
+ registry | reg | r)
+ case $2 in
+ start)
+ start-registry
+ ;;
+ stop)
+ stop-registry
+ ;;
+ host)
+ registry-host
+ ;;
+ *)
+ echo "Unknown registry action \"$2\""
+ ;;
+ esac
+ ;;
+ image | img | i)
+ case $2 in
+ rev)
+ create-dummy-image "${@:3:2}"
+ ;;
+ latest)
+ latest-image-rev "$3"
+ ;;
+ rm)
+ remove-repo-images "$3"
+ ;;
+ *)
+ echo "Unknown image action \"$2\""
+ ;;
+ esac
+ ;;
+ container | cnt | c)
+ case $2 in
+ query)
+ query-rev "$3"
+ ;;
+ rm)
+ try-remove-container "$3"
+ ;;
+ id)
+ container-id "$3"
+ ;;
+ started)
+ container-started "$3"
+ ;;
+ create)
+ create-container "${@:3:2}"
+ ;;
+ create-stale)
+ if [ -z "$3" ]; then
+ echo "NAME missing"
+ exit 1
+ fi
+ if ! registry-exists; then
+ echo "Registry container missing! Creating..."
+ start-registry || exit 1
+ fi
+ image_name="images/$3"
+ container_name=$3
+ $0 image rev "$image_name" || exit 1
+ $0 container create "$container_name" "$image_name" || exit 1
+ $0 image rev "$image_name" || exit 1
+ ;;
+ *)
+ echo "Unknown container action \"$2\""
+ ;;
+ esac
+ ;;
+ *)
+ echo "Unknown keyword \"$1\""
+ ;;
+esac
\ No newline at end of file
diff --git a/tplprev/main.go b/tplprev/main.go
new file mode 100644
index 0000000..120f968
--- /dev/null
+++ b/tplprev/main.go
@@ -0,0 +1,49 @@
+//go:build !wasm
+
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ "github.com/containrrr/watchtower/internal/meta"
+ "github.com/containrrr/watchtower/pkg/notifications/preview"
+ "github.com/containrrr/watchtower/pkg/notifications/preview/data"
+)
+
+func main() {
+ fmt.Fprintf(os.Stderr, "watchtower/tplprev %v\n\n", meta.Version)
+
+ var states string
+ var entries string
+
+ flag.StringVar(&states, "states", "cccuuueeekkktttfff", "sCanned, Updated, failEd, sKipped, sTale, Fresh")
+ flag.StringVar(&entries, "entries", "ewwiiidddd", "Fatal,Error,Warn,Info,Debug,Trace")
+
+ flag.Parse()
+
+ if len(flag.Args()) < 1 {
+ fmt.Fprintln(os.Stderr, "Missing required argument TEMPLATE")
+ flag.Usage()
+ os.Exit(1)
+ return
+ }
+
+ input, err := os.ReadFile(flag.Arg(0))
+ if err != nil {
+
+ fmt.Fprintf(os.Stderr, "Failed to read template file %q: %v\n", flag.Arg(0), err)
+ os.Exit(1)
+ return
+ }
+
+ result, err := preview.Render(string(input), data.StatesFromString(states), data.LevelsFromString(entries))
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Failed to read template file %q: %v\n", flag.Arg(0), err)
+ os.Exit(1)
+ return
+ }
+
+ fmt.Println(result)
+}
diff --git a/tplprev/main_wasm.go b/tplprev/main_wasm.go
new file mode 100644
index 0000000..5e2ce6a
--- /dev/null
+++ b/tplprev/main_wasm.go
@@ -0,0 +1,62 @@
+//go:build wasm
+
+package main
+
+import (
+ "fmt"
+
+ "github.com/containrrr/watchtower/internal/meta"
+ "github.com/containrrr/watchtower/pkg/notifications/preview"
+ "github.com/containrrr/watchtower/pkg/notifications/preview/data"
+
+ "syscall/js"
+)
+
+func main() {
+ fmt.Println("watchtower/tplprev v" + meta.Version)
+
+ js.Global().Set("WATCHTOWER", js.ValueOf(map[string]any{
+ "tplprev": js.FuncOf(jsTplPrev),
+ }))
+ <-make(chan bool)
+
+}
+
+func jsTplPrev(this js.Value, args []js.Value) any {
+
+ if len(args) < 3 {
+ return "Requires 3 arguments passed"
+ }
+
+ input := args[0].String()
+
+ statesArg := args[1]
+ var states []data.State
+
+ if statesArg.Type() == js.TypeString {
+ states = data.StatesFromString(statesArg.String())
+ } else {
+ for i := 0; i < statesArg.Length(); i++ {
+ state := data.State(statesArg.Index(i).String())
+ states = append(states, state)
+ }
+ }
+
+ levelsArg := args[2]
+ var levels []data.LogLevel
+
+ if levelsArg.Type() == js.TypeString {
+ levels = data.LevelsFromString(statesArg.String())
+ } else {
+ for i := 0; i < levelsArg.Length(); i++ {
+ level := data.LogLevel(levelsArg.Index(i).String())
+ levels = append(levels, level)
+ }
+ }
+
+ result, err := preview.Render(input, states, levels)
+ if err != nil {
+ return "Error: " + err.Error()
+ }
+ return result
+}