diff --git a/.all-contributorsrc b/.all-contributorsrc
index e98384d..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",
@@ -508,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",
@@ -693,7 +695,8 @@
"profile": "https://github.com/ksurl",
"contributions": [
"doc",
- "code"
+ "code",
+ "infra"
]
},
{
@@ -812,6 +815,67 @@
"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,
@@ -820,5 +884,6 @@
"repoType": "github",
"repoHost": "https://github.com",
"commitConvention": "none",
- "skipCi": true
+ "skipCi": true,
+ "commitType": "docs"
}
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 53e1a53..0000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,53 +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**
-
-
-**To Reproduce**
-
-
-**Expected behavior**
-
-
-**Screenshots**
-
-**Environment**
-
-
-
- Logs from running watchtower with the --debug
option
-
-```
-
-```
-
-
-
-**Additional context**
-
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/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 2437bb2..c479d05 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v2
+ 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.
@@ -44,7 +44,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v1
+ 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.
@@ -55,7 +55,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v1
+ uses: github/codeql-action/autobuild@v3
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -69,4 +69,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v1
+ 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 20302f0..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:
diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml
index 6247c69..4818e54 100644
--- a/.github/workflows/publish-docs.yml
+++ b/.github/workflows/publish-docs.yml
@@ -14,14 +14,24 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v2
+ 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 \
- mkdocs \
- mkdocs-material \
- md-toc
+ pip install -r docs-requirements.txt
- name: Generate docs
- run: mkdocs gh-deploy --strict
\ No newline at end of file
+ run: mkdocs gh-deploy --strict
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 80eb3e3..f6866af 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -12,26 +12,24 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
- uses: actions/setup-go@v2
+ uses: actions/setup-go@v4
with:
- go-version: 1.15.x
- - name: Install linter
- run: |
- go get -u golang.org/x/lint/golint
- - name: Lint files
- run: |
- golint -set_exit_status ./...
+ 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.15.x
+ - 1.20.x
platform:
- macos-latest
- windows-latest
@@ -39,18 +37,18 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
- uses: actions/setup-go@v2
+ uses: actions/setup-go@v4
with:
- go-version: 1.15.x
+ go-version: 1.20.x
- name: Run tests
run: |
go test -v -coverprofile coverage.out -covermode atomic ./...
- name: Publish coverage
- uses: codecov/codecov-action@v1
+ uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
@@ -58,15 +56,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
- uses: actions/setup-go@v2
+ uses: actions/setup-go@v4
with:
- go-version: 1.15.x
+ go-version: 1.20.x
- name: Build
- uses: goreleaser/goreleaser-action@v2
+ 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
index 4b8f238..95ee68d 100644
--- a/.github/workflows/release-dev.yaml
+++ b/.github/workflows/release-dev.yaml
@@ -10,25 +10,27 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
- - name: Set up Go
- uses: actions/setup-go@v2
+ - uses: actions/checkout@v4
with:
- go-version: 1.15
+ 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@v2
+ - uses: actions/checkout@v4
- name: Set up Go
- uses: actions/setup-go@v2
+ uses: actions/setup-go@v4
with:
- go-version: 1.15
+ go-version: 1.20.x
- name: Test
run: go test -v -coverprofile coverage.out -covermode atomic ./...
- name: Publish coverage
- uses: codecov/codecov-action@v1
+ uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
publish:
@@ -37,9 +39,9 @@ jobs:
- test
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
- name: Publish to Docker Hub
- uses: jerray/publish-docker-action@master
+ uses: jerray/publish-docker-action@87d84711629b0dc9f6bb127b568413cc92a2088e #master@2022-10-14
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
@@ -47,7 +49,7 @@ jobs:
repository: containrrr/watchtower
tags: latest-dev
- name: Publish to GHCR
- uses: jerray/publish-docker-action@master
+ uses: jerray/publish-docker-action@87d84711629b0dc9f6bb127b568413cc92a2088e #master@2022-10-14
with:
username: ${{ secrets.BOT_USERNAME }}
password: ${{ secrets.BOT_GHCR_PAT }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 919783b..370d395 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,9 +2,7 @@ name: Release (Production)
on:
workflow_dispatch: {}
- release:
- types:
- - created
+ push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- '**/v[0-9]+.[0-9]+.[0-9]+'
@@ -15,26 +13,24 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
- uses: actions/setup-go@v2
+ uses: actions/setup-go@v4
with:
- go-version: 1.15.x
- - name: Install linter
- run: |
- go get -u golang.org/x/lint/golint
- - name: Lint files
- run: |
- golint -set_exit_status ./...
+ 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.15.x
+ - 1.20.x
platform:
- ubuntu-latest
- macos-latest
@@ -42,13 +38,13 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
- uses: actions/setup-go@v2
+ uses: actions/setup-go@v4
with:
- go-version: 1.15.x
+ go-version: 1.20.x
- name: Run tests
run: |
go test ./... -coverprofile coverage.out
@@ -61,29 +57,29 @@ jobs:
- lint
env:
CGO_ENABLED: 0
- TAG: ${{ github.event.release.tag_name }}
+ TAG: ${{ github.ref_name }}
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
- uses: actions/setup-go@v2
+ uses: actions/setup-go@v4
with:
- go-version: 1.15.x
+ go-version: 1.20.x
- name: Login to Docker Hub
- uses: docker/login-action@v1
+ uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc #v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v1
+ 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@v2
+ uses: goreleaser/goreleaser-action@7ec5c2b0c6cdda6e8bbb49444bc797dd33d74dd8 #v3
with:
version: v0.155.0
args: --debug
@@ -95,7 +91,7 @@ jobs:
echo '{"experimental": "enabled"}' > ~/.docker/config.json
- name: Create manifest for version
run: |
- export DH_TAG=$(echo $TAG | sed 's/^v*//')
+ export DH_TAG=$(git tag --points-at HEAD | sed 's/^v*//')
docker manifest create \
containrrr/watchtower:$DH_TAG \
containrrr/watchtower:amd64-$DH_TAG \
@@ -193,7 +189,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Pull new module version
- uses: andrewslotin/go-proxy-pull-action@master
+ uses: andrewslotin/go-proxy-pull-action@50fea06a976087614babb9508e5c528b464f4645 #master@2022-10-14
diff --git a/.gitignore b/.gitignore
index c371f41..9519257 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,7 @@ dist
.DS_Store
/site
coverage.out
-*.coverprofile
\ No newline at end of file
+*.coverprofile
+
+docs/assets/wasm_exec.js
+docs/assets/*.wasm
\ No newline at end of file
diff --git a/README.md b/README.md
index e6754b0..f550302 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,8 @@ $ docker run --detach \
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.
@@ -42,119 +44,130 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
diff --git a/build.sh b/build.sh
index 304786d..78b1bfc 100755
--- a/build.sh
+++ b/build.sh
@@ -1,5 +1,9 @@
#!/bin/bash
+BINFILE=watchtower
+if [ -n "$MSYSTEM" ]; then
+ BINFILE=watchtower.exe
+fi
VERSION=$(git describe --tags)
echo "Building $VERSION..."
-go build -o watchtower -ldflags "-X github.com/containrrr/watchtower/internal/meta.Version=$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 1be52a8..eef13ce 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -1,7 +1,7 @@
package cmd
import (
- "github.com/containrrr/watchtower/internal/meta"
+ "errors"
"math"
"net/http"
"os"
@@ -11,12 +11,12 @@ import (
"syscall"
"time"
- apiMetrics "github.com/containrrr/watchtower/pkg/api/metrics"
- "github.com/containrrr/watchtower/pkg/api/update"
-
"github.com/containrrr/watchtower/internal/actions"
"github.com/containrrr/watchtower/internal/flags"
+ "github.com/containrrr/watchtower/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"
@@ -29,18 +29,20 @@ import (
)
var (
- client container.Client
- scheduleSpec string
- cleanup bool
- noRestart bool
- monitorOnly bool
- enableLabel bool
- notifier t.Notifier
- timeout time.Duration
- lifecycleHooks bool
- rollingRestart bool
- scope string
- // Set on build using ldflags
+ 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()
@@ -56,6 +58,7 @@ func NewRootCommand() *cobra.Command {
`,
Run: Run,
PreRun: PreRun,
+ Args: cobra.ArbitraryArgs,
}
}
@@ -68,6 +71,7 @@ func init() {
// Execute the root func and exit in case of errors
func Execute() {
+ rootCmd.AddCommand(notifyUpgradeCommand)
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
@@ -76,37 +80,12 @@ func Execute() {
// PreRun is a lifecycle hook that runs before the command is executed.
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)
@@ -116,11 +95,15 @@ func PreRun(cmd *cobra.Command, _ []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)
@@ -128,7 +111,7 @@ func PreRun(cmd *cobra.Command, _ []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")
@@ -139,26 +122,36 @@ func PreRun(cmd *cobra.Command, _ []string) {
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,
- warnOnHeadPullFailed,
- )
+ 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, filterDesc := 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")
@@ -189,9 +182,12 @@ func Run(c *cobra.Command, names []string) {
httpAPI := api.New(apiToken)
if enableUpdateAPI {
- updateHandler := update.New(func() { runUpdatesWithNotifications(filter) }, updateLock)
+ updateHandler := update.New(func(images []string) {
+ metric := runUpdatesWithNotifications(filters.FilterByImage(images, filter))
+ metrics.RegisterScan(metric)
+ }, updateLock)
httpAPI.RegisterFunc(updateHandler.Path, updateHandler.Handle)
- // If polling isn't enabled the scheduler is never started and
+ // 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)
@@ -203,7 +199,7 @@ func Run(c *cobra.Command, names []string) {
httpAPI.RegisterHandler(metricsHandler.Path, metricsHandler.Handle)
}
- if err := httpAPI.Start(enableUpdateAPI && !unblockHTTPAPI); err != nil && err != http.ErrServerClosed {
+ if err := httpAPI.Start(enableUpdateAPI && !unblockHTTPAPI); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("failed to start API", err)
}
@@ -293,7 +289,7 @@ func writeStartupMessage(c *cobra.Command, sched time.Time, filtering string) {
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.")
+ startupLog.Info("Running a one time update.")
} else {
startupLog.Info("Periodic runs are not enabled.")
}
@@ -363,13 +359,15 @@ func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter, filtering string,
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,
}
result, err := actions.Update(client, updateParams)
if err != nil {
diff --git a/dockerfiles/Dockerfile b/dockerfiles/Dockerfile
index c3ed14b..2fc571d 100644
--- a/dockerfiles/Dockerfile
+++ b/dockerfiles/Dockerfile
@@ -1,4 +1,4 @@
-FROM --platform=$BUILDPLATFORM alpine:3.15 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 79dbe39..1a39c26 100644
--- a/dockerfiles/Dockerfile.dev-self-contained
+++ b/dockerfiles/Dockerfile.dev-self-contained
@@ -7,6 +7,13 @@ FROM golang:alpine as builder
# 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 \
ca-certificates \
@@ -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 303fc53..04a6047 100644
--- a/dockerfiles/Dockerfile.self-contained
+++ b/dockerfiles/Dockerfile.self-contained
@@ -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 7280631..d7ed0b0 100644
--- a/docs/arguments.md
+++ b/docs/arguments.md
@@ -27,6 +27,33 @@ 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.
@@ -58,8 +85,8 @@ Environment Variable: WATCHTOWER_CLEANUP
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
@@ -71,6 +98,10 @@ Environment Variable: WATCHTOWER_REMOVE_VOLUMES
## Debug
Enable debug mode with verbose logging.
+!!! note "Notes"
+ Alias for `--log-level debug`. See [Maximum log level](#maximum-log-level).
+ Does _not_ take an argument when used as an argument. Using `--debug true` will **not** work.
+
```text
Argument: --debug, -d
Environment Variable: WATCHTOWER_DEBUG
@@ -81,6 +112,10 @@ Environment Variable: WATCHTOWER_DEBUG
## Trace
Enable trace mode with very verbose logging. Caution: exposes credentials!
+!!! note "Notes"
+ Alias for `--log-level trace`. See [Maximum log level](#maximum-log-level).
+ Does _not_ take an argument when used as an argument. Using `--trace true` will **not** work.
+
```text
Argument: --trace
Environment Variable: WATCHTOWER_TRACE
@@ -88,6 +123,28 @@ Environment Variable: WATCHTOWER_TRACE
Default: false
```
+## Maximum log level
+
+The maximum log level that will be written to STDERR (shown in `docker log` when used in a container).
+
+```text
+ Argument: --log-level
+Environment Variable: WATCHTOWER_LOG_LEVEL
+ Possible values: panic, fatal, error, warn, info, debug or trace
+ Default: info
+```
+
+## 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.
@@ -132,7 +189,7 @@ Environment Variable: WATCHTOWER_INCLUDE_RESTARTING
Will also include created and exited containers.
```text
- Argument: --include-stopped
+ Argument: --include-stopped, -S
Environment Variable: WATCHTOWER_INCLUDE_STOPPED
Type: Boolean
Default: false
@@ -159,7 +216,7 @@ 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
@@ -169,10 +226,23 @@ Environment Variable: WATCHTOWER_LABEL_ENABLE
```
## Filter by disable label
-__Do not__ update containers that have `com.centurylinklabs.watchtower.enable` label set to false and
+__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
@@ -192,6 +262,19 @@ 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.
@@ -215,6 +298,11 @@ Environment Variable: WATCHTOWER_NO_PULL
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.
@@ -229,7 +317,7 @@ Environment Variable: WATCHTOWER_NO_STARTUP_MESSAGE
Run an update attempt against a container name list one time immediately and exit.
```text
- Argument: --run-once
+ Argument: --run-once, -R
Environment Variable: WATCHTOWER_RUN_ONCE
Type: Boolean
Default: false
@@ -248,6 +336,7 @@ Environment Variable: WATCHTOWER_HTTP_API_UPDATE
## 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
@@ -270,6 +359,11 @@ Environment Variable: WATCHTOWER_HTTP_API_PERIODIC_POLLS
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
@@ -342,3 +436,32 @@ 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 b5ccafb..8327c66 100644
--- a/docs/container-selection.md
+++ b/docs/container-selection.md
@@ -7,33 +7,58 @@ There are two options:
## 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;
diff --git a/docs/http-api-mode.md b/docs/http-api-mode.md
index 2cf082a..69812bb 100644
--- a/docs/http-api-mode.md
+++ b/docs/http-api-mode.md
@@ -35,3 +35,11 @@ Notice that there is an environment variable named WATCHTOWER_HTTP_API_TOKEN. To
```bash
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/introduction.md b/docs/introduction.md
index ded074f..cbbc3a3 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -11,5 +11,5 @@ CONTAINER ID IMAGE STATUS PORTS
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/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 7bb6383..480d7c6 100644
--- a/docs/metrics.md
+++ b/docs/metrics.md
@@ -4,7 +4,7 @@
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`.
diff --git a/docs/notifications.md b/docs/notifications.md
index faa4e4a..d5da4fe 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -1,15 +1,7 @@
# Notifications
Watchtower can send notifications when containers are updated. Notifications are sent via hooks in the logging
-system, [logrus](http://github.com/sirupsen/logrus). The types of notifications to send are set by passing a
-comma-separated list of values to the `--notifications` option
-(or corresponding environment variable `WATCHTOWER_NOTIFICATIONS`), which has the following valid values:
-
-- `email` to send notifications via e-mail
-- `slack` to send notifications through a Slack webhook
-- `msteams` to send notifications via MSTeams webhook
-- `gotify` to send notifications via Gotify
-- `shoutrrr` to send notifications via [containrrr/shoutrrr](https://github.com/containrrr/shoutrrr)
+system, [logrus](http://github.com/sirupsen/logrus).
!!! note "Using multiple notifications with environment variables"
There is currently a bug in Viper (https://github.com/spf13/viper/issues/380), which prevents comma-separated slices to
@@ -26,9 +18,231 @@ comma-separated list of values to the `--notifications` option
- `--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
@@ -42,7 +256,7 @@ To receive notifications by email, the following command-line options, or their
- `--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-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:
@@ -174,40 +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.dev/shoutrrr/v0.5/services/overview](https://containrrr.dev/shoutrrr/v0.5/services/overview) to
-learn more about the different service URLs you can use. You can define multiple services by space separating the
-URLs. (See example below)
-
-You can customize the message posted by setting a template.
-
-- `--notification-template` (env. `WATCHTOWER_NOTIFICATION_TEMPLATE`): The template used for the message.
-
-The template is a Go [template](https://golang.org/pkg/text/template/) and that format a list
-of [log entries](https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry).
-
-The default value if not set is `{{range .}}{{.Message}}{{println}}{{end}}`. The example below uses a template that also
-outputs timestamp and log level.
-
-!!! tip "Custom date format"
- If you want to adjust the date/time format it must show how the
- [reference time](https://golang.org/pkg/time/#pkg-constants) (_Mon Jan 2 15:04:05 MST 2006_) would be displayed in your
- custom format.
- i.e., The day of the year has to be 1, the month has to be 2 (february), the hour 3 (or 15 for 24h time) etc.
-
-Example:
-
-```bash
-docker run -d \
- --name watchtower \
- -v /var/run/docker.sock:/var/run/docker.sock \
- -e WATCHTOWER_NOTIFICATIONS=shoutrrr \
- -e WATCHTOWER_NOTIFICATION_URL="discord://token@channel slack://watchtower@token-a/token-b/token-c" \
- -e WATCHTOWER_NOTIFICATION_TEMPLATE="{{range .}}{{.Time.Format \"2006-01-02 15:04:05\"}} ({{.Level}}): {{.Message}}{{println}}{{end}}" \
- containrrr/watchtower
-```
diff --git a/docs/private-registries.md b/docs/private-registries.md
index 94f9a81..5367a8c 100644
--- a/docs/private-registries.md
+++ b/docs/private-registries.md
@@ -23,19 +23,29 @@ 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`).
-!!! important "Using private images on docker hub"
- When using private images on docker hub, the containers beeing watched needs to use the full image name, including the repository prefix `index.docker.io`.
- So instead of
- ```
- docker run -d myuser/myimage
- ```
- you would run it as
- ```
- docker run -d index.docker.io/myuser/myimage
- ```
+!!! 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:
@@ -75,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
@@ -114,7 +124,7 @@ in a volume that may be mounted onto your watchtower container.
1. Create the Dockerfile (contents below):
```Dockerfile
- FROM golang:1.16
+ FROM golang:1.20
ENV GO111MODULE off
ENV CGO_ENABLED 0
@@ -145,7 +155,7 @@ in a volume that may be mounted onto your watchtower container.
```
3. Create a configuration file for docker, and store it in $HOME/.docker/config.json (replace the
- placeholders with your AWS Account ID):
+ placeholders with your AWS Account ID and with your AWS ECR Region):
```json
{
"credsStore" : "ecr-login",
@@ -153,10 +163,10 @@ in a volume that may be mounted onto your watchtower container.
"User-Agent" : "Docker-Client/19.03.1 (XXXXXX)"
},
"auths" : {
- ".dkr.ecr.us-west-1.amazonaws.com" : {}
+ ".dkr.ecr..amazonaws.com" : {}
},
"credHelpers": {
- ".dkr.ecr.us-west-1.amazonaws.com" : "ecr-login"
+ ".dkr.ecr..amazonaws.com" : "ecr-login"
}
}
```
diff --git a/docs/running-multiple-instances.md b/docs/running-multiple-instances.md
index 3899095..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"
+ 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/usage-overview.md b/docs/usage-overview.md
index 1462ba6..1cac352 100644
--- a/docs/usage-overview.md
+++ b/docs/usage-overview.md
@@ -27,12 +27,12 @@ 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
```
@@ -48,14 +48,14 @@ docker run -d \
If you mount the config file as described above, be sure to also prepend the URL for the registry when starting up your
watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container
-from a private repo at Docker Hub and monitors it with watchtower. Note the command argument changing the interval to
-30s rather than the default 24 hours.
+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"
diff --git a/go.mod b/go.mod
index b730648..6c20d11 100644
--- a/go.mod
+++ b/go.mod
@@ -1,31 +1,74 @@
module github.com/containrrr/watchtower
-go 1.12
-
-// Use non-vulnerable runc (until github.com/containerd/containerd v1.6.0 is stable)
-replace github.com/opencontainers/runc => github.com/opencontainers/runc v1.0.3
+go 1.20
require (
- github.com/containerd/containerd v1.5.9 // indirect
- github.com/containrrr/shoutrrr v0.5.2
- github.com/docker/cli v20.10.8+incompatible
- github.com/docker/distribution v2.7.1+incompatible
- github.com/docker/docker v20.10.8+incompatible
- github.com/docker/docker-credential-helpers v0.6.1 // indirect
+ 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/johntdyer/slack-go v0.0.0-20180213144715-95fac1160b22 // indirect
- github.com/johntdyer/slackrus v0.0.0-20180518184837-f7aae3243a07
+ 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.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/onsi/ginkgo v1.14.2
- github.com/onsi/gomega v1.10.3
- github.com/prometheus/client_golang v1.7.1
- github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967
- github.com/sirupsen/logrus v1.8.1
- github.com/spf13/cobra v1.0.0
- github.com/spf13/pflag v1.0.5
- github.com/spf13/viper v1.6.3
- github.com/stretchr/testify v1.6.1
- golang.org/x/net v0.0.0-20210226172049-e18ecbb05110
- golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e // 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 b09128a..cab338f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,1077 +1,240 @@
-bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8=
-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.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
-cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
-cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
-cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
-cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
-cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
-cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
-cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
-cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
-cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
-cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
-cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
-cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
-cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
-cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
-cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
-cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
-cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
-dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
-github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
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/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
-github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
-github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
-github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg=
-github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A=
-github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
-github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
-github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
-github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
-github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
-github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
-github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
-github.com/Microsoft/go-winio v0.4.16-0.20201130162521-d1ffc52c7331/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
-github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
-github.com/Microsoft/go-winio v0.4.17-0.20210211115548-6eac466e5fa3/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
-github.com/Microsoft/go-winio v0.4.17-0.20210324224401-5516f17a5958/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
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/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
-github.com/Microsoft/hcsshim v0.8.7-0.20190325164909-8abdbb8205e4/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
-github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ=
-github.com/Microsoft/hcsshim v0.8.9/go.mod h1:5692vkUqntj1idxauYlpoINNKeqCiG6Sg38RRsjT5y8=
-github.com/Microsoft/hcsshim v0.8.14/go.mod h1:NtVKoYxQuTLx6gEq0L96c9Ju4JbRJ4nY2ow3VK6a9Lg=
-github.com/Microsoft/hcsshim v0.8.15/go.mod h1:x38A4YbHbdxJtc0sF6oIz+RG0npwSCAvn69iY6URG00=
-github.com/Microsoft/hcsshim v0.8.16/go.mod h1:o5/SZqmR7x9JNKsW3pu+nqHm0MF8vbA+VxGOoXdC600=
-github.com/Microsoft/hcsshim v0.8.23/go.mod h1:4zegtUJth7lAvFyc6cH2gGQ5B3OFQim01nnU2M8jKDg=
-github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5hlzMzRKMLyo42nCZ9oml8AdTlq/0cvIaBv6tK1RehU=
-github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY=
-github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
-github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
-github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
-github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
-github.com/agnivade/wasmbrowsertest v0.3.1/go.mod h1:zQt6ZTdl338xxRaMW395qccVE2eQm0SjC/SDz0mPWQI=
-github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0=
-github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
-github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
-github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
-github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
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/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
-github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
-github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
-github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
-github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
-github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
-github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
-github.com/buger/jsonparser v0.0.0-20180808090653-f4dd9f5a6b44/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
-github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
-github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50=
-github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
-github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
-github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
-github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
-github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M=
-github.com/chromedp/cdproto v0.0.0-20190614062957-d6d2f92b486d/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw=
-github.com/chromedp/cdproto v0.0.0-20190621002710-8cbd498dd7a0/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw=
-github.com/chromedp/cdproto v0.0.0-20190812224334-39ef923dcb8d/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0=
-github.com/chromedp/cdproto v0.0.0-20190926234355-1b4886c6fad6/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0=
-github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM=
-github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4=
-github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
-github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
-github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmEg9bt0VpxxWqJlO4iwu3FBdHUzV7wQVg=
-github.com/cilium/ebpf v0.0.0-20200702112145-1c8d4c9ef775/go.mod h1:7cR51M8ViRLIdUjrmSXlK9pkrsDlLHbO8jiB8X8JnOc=
-github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX2Qs=
-github.com/cilium/ebpf v0.4.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
-github.com/cilium/ebpf v0.6.2/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
-github.com/containerd/aufs v0.0.0-20200908144142-dab0cbea06f4/go.mod h1:nukgQABAEopAHvB6j7cnP5zJ+/3aVcE7hCYqvIwAHyE=
-github.com/containerd/aufs v0.0.0-20201003224125-76a6863f2989/go.mod h1:AkGGQs9NM2vtYHaUen+NljV0/baGCAPELGm2q9ZXpWU=
-github.com/containerd/aufs v0.0.0-20210316121734-20793ff83c97/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU=
-github.com/containerd/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU=
-github.com/containerd/btrfs v0.0.0-20201111183144-404b9149801e/go.mod h1:jg2QkJcsabfHugurUvvPhS3E08Oxiuh5W/g1ybB4e0E=
-github.com/containerd/btrfs v0.0.0-20210316141732-918d888fb676/go.mod h1:zMcX3qkXTAi9GI50+0HOeuV8LU2ryCE/V2vG/ZBiTss=
-github.com/containerd/btrfs v1.0.0/go.mod h1:zMcX3qkXTAi9GI50+0HOeuV8LU2ryCE/V2vG/ZBiTss=
-github.com/containerd/cgroups v0.0.0-20190717030353-c4b9ac5c7601/go.mod h1:X9rLEHIqSf/wfK8NsPqxJmeZgW4pcfzdXITDrUSJ6uI=
-github.com/containerd/cgroups v0.0.0-20190919134610-bf292b21730f/go.mod h1:OApqhQ4XNSNC13gXIwDjhOQxjWa/NxkwZXJ1EvqT0ko=
-github.com/containerd/cgroups v0.0.0-20200531161412-0dbf7f05ba59/go.mod h1:pA0z1pT8KYB3TCXK/ocprsh7MAkoW8bZVzPdih9snmM=
-github.com/containerd/cgroups v0.0.0-20200710171044-318312a37340/go.mod h1:s5q4SojHctfxANBDvMeIaIovkq29IP48TKAxnhYRxvo=
-github.com/containerd/cgroups v0.0.0-20200824123100-0b889c03f102/go.mod h1:s5q4SojHctfxANBDvMeIaIovkq29IP48TKAxnhYRxvo=
-github.com/containerd/cgroups v0.0.0-20210114181951-8a68de567b68/go.mod h1:ZJeTFisyysqgcCdecO57Dj79RfL0LNeGiFUqLYQRYLE=
-github.com/containerd/cgroups v1.0.1/go.mod h1:0SJrPIenamHDcZhEcJMNBB85rHcUsw4f25ZfBiPYRkU=
-github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=
-github.com/containerd/console v0.0.0-20181022165439-0650fd9eeb50/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=
-github.com/containerd/console v0.0.0-20191206165004-02ecf6a7291e/go.mod h1:8Pf4gM6VEbTNRIT26AyyU7hxdQU3MvAvxVI0sc00XBE=
-github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
-github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
-github.com/containerd/containerd v1.2.10/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.3.1-0.20191213020239-082f7e3aed57/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.4.0-beta.2.0.20200729163537-40b22ef07410/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.4.3/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.4.9/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
-github.com/containerd/containerd v1.5.0-beta.1/go.mod h1:5HfvG1V2FsKesEGQ17k5/T7V960Tmcumvqn8Mc+pCYQ=
-github.com/containerd/containerd v1.5.0-beta.3/go.mod h1:/wr9AVtEM7x9c+n0+stptlo/uBBoBORwEx6ardVcmKU=
-github.com/containerd/containerd v1.5.0-beta.4/go.mod h1:GmdgZd2zA2GYIBZ0w09ZvgqEq8EfBp/m3lcVZIvPHhI=
-github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoTJseu1FGOKuoA4nNb2s=
-github.com/containerd/containerd v1.5.9 h1:rs6Xg1gtIxaeyG+Smsb/0xaSDu1VgFhOCKBXxMxbsF4=
-github.com/containerd/containerd v1.5.9/go.mod h1:fvQqCfadDGga5HZyn3j4+dx56qj2I9YwBrlSdalvJYQ=
-github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
-github.com/containerd/continuity v0.0.0-20190815185530-f2a389ac0a02/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
-github.com/containerd/continuity v0.0.0-20191127005431-f65d91d395eb/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
-github.com/containerd/continuity v0.0.0-20200710164510-efbc4488d8fe/go.mod h1:cECdGN1O8G9bgKTlLhuPJimka6Xb/Gg7vYzCTNVxhvo=
-github.com/containerd/continuity v0.0.0-20201208142359-180525291bb7/go.mod h1:kR3BEg7bDFaEddKm54WSmrol1fKWDU1nKYkgrcgZT7Y=
-github.com/containerd/continuity v0.0.0-20210208174643-50096c924a4e/go.mod h1:EXlVlkqNba9rJe3j7w3Xa924itAMLgZH4UD/Q4PExuQ=
-github.com/containerd/continuity v0.1.0/go.mod h1:ICJu0PwR54nI0yPEnJ6jcS+J7CZAUXrLh8lPo2knzsM=
-github.com/containerd/fifo v0.0.0-20180307165137-3d5202aec260/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=
-github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=
-github.com/containerd/fifo v0.0.0-20200410184934-f15a3290365b/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0=
-github.com/containerd/fifo v0.0.0-20201026212402-0724c46b320c/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0=
-github.com/containerd/fifo v0.0.0-20210316144830-115abcc95a1d/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4=
-github.com/containerd/fifo v1.0.0/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4=
-github.com/containerd/go-cni v1.0.1/go.mod h1:+vUpYxKvAF72G9i1WoDOiPGRtQpqsNW/ZHtSlv++smU=
-github.com/containerd/go-cni v1.0.2/go.mod h1:nrNABBHzu0ZwCug9Ije8hL2xBCYh/pjfMb1aZGrrohk=
-github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0=
-github.com/containerd/go-runc v0.0.0-20190911050354-e029b79d8cda/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0=
-github.com/containerd/go-runc v0.0.0-20200220073739-7016d3ce2328/go.mod h1:PpyHrqVs8FTi9vpyHwPwiNEGaACDxT/N/pLcvMSRA9g=
-github.com/containerd/go-runc v0.0.0-20201020171139-16b287bc67d0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok=
-github.com/containerd/go-runc v1.0.0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok=
-github.com/containerd/imgcrypt v1.0.1/go.mod h1:mdd8cEPW7TPgNG4FpuP3sGBiQ7Yi/zak9TYCG3juvb0=
-github.com/containerd/imgcrypt v1.0.4-0.20210301171431-0ae5c75f59ba/go.mod h1:6TNsg0ctmizkrOgXRNQjAPFWpMYRWuiB6dSF4Pfa5SA=
-github.com/containerd/imgcrypt v1.1.1-0.20210312161619-7ed62a527887/go.mod h1:5AZJNI6sLHJljKuI9IHnw1pWqo/F0nGDOuR9zgTs7ow=
-github.com/containerd/imgcrypt v1.1.1/go.mod h1:xpLnwiQmEUJPvQoAapeb2SNCxz7Xr6PJrXQb0Dpc4ms=
-github.com/containerd/nri v0.0.0-20201007170849-eb1350a75164/go.mod h1:+2wGSDGFYfE5+So4M5syatU0N0f0LbWpuqyMi4/BE8c=
-github.com/containerd/nri v0.0.0-20210316161719-dbaa18c31c14/go.mod h1:lmxnXF6oMkbqs39FiCt1s0R2HSMhcLel9vNL3m4AaeY=
-github.com/containerd/nri v0.1.0/go.mod h1:lmxnXF6oMkbqs39FiCt1s0R2HSMhcLel9vNL3m4AaeY=
-github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
-github.com/containerd/ttrpc v0.0.0-20190828172938-92c8520ef9f8/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
-github.com/containerd/ttrpc v0.0.0-20191028202541-4f1b8fe65a5c/go.mod h1:LPm1u0xBw8r8NOKoOdNMeVHSawSsltak+Ihv+etqsE8=
-github.com/containerd/ttrpc v1.0.1/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y=
-github.com/containerd/ttrpc v1.0.2/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y=
-github.com/containerd/ttrpc v1.1.0/go.mod h1:XX4ZTnoOId4HklF4edwc4DcqskFZuvXB1Evzy5KFQpQ=
-github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc=
-github.com/containerd/typeurl v0.0.0-20190911142611-5eb25027c9fd/go.mod h1:GeKYzf2pQcqv7tJ0AoCuuhtnqhva5LNU3U+OyKxxJpk=
-github.com/containerd/typeurl v1.0.1/go.mod h1:TB1hUtrpaiO88KEK56ijojHS1+NeF0izUACaJW2mdXg=
-github.com/containerd/typeurl v1.0.2/go.mod h1:9trJWW2sRlGub4wZJRTW83VtbOLS6hwcDZXTn6oPz9s=
-github.com/containerd/zfs v0.0.0-20200918131355-0a33824f23a2/go.mod h1:8IgZOBdv8fAgXddBT4dBXJPtxyRsejFIpXoklgxgEjw=
-github.com/containerd/zfs v0.0.0-20210301145711-11e8f1707f62/go.mod h1:A9zfAbMlQwE+/is6hi0Xw8ktpL+6glmqZYtevJgaB8Y=
-github.com/containerd/zfs v0.0.0-20210315114300-dde8f0fda960/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY=
-github.com/containerd/zfs v0.0.0-20210324211415-d5c4544f0433/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY=
-github.com/containerd/zfs v1.0.0/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY=
-github.com/containernetworking/cni v0.7.1/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY=
-github.com/containernetworking/cni v0.8.0/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY=
-github.com/containernetworking/cni v0.8.1/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY=
-github.com/containernetworking/plugins v0.8.6/go.mod h1:qnw5mN19D8fIwkqW7oHHYDHVlzhJpcY6TQxn/fUyDDM=
-github.com/containernetworking/plugins v0.9.1/go.mod h1:xP/idU2ldlzN6m4p5LmGiwRDjeJr6FLK6vuiUwoH7P8=
-github.com/containers/ocicrypt v1.0.1/go.mod h1:MeJDzk1RJHv89LjsH0Sp5KTY3ZYkjXO/C+bKAeWFIrc=
-github.com/containers/ocicrypt v1.1.0/go.mod h1:b8AOe0YR67uU8OqfVNcznfFpAzu3rdgUV4GP9qXPfu4=
-github.com/containers/ocicrypt v1.1.1/go.mod h1:Dm55fwWm1YZAjYRaJ94z2mfZikIyIN4B0oB3dj3jFxY=
-github.com/containrrr/shoutrrr v0.5.2 h1:97P+wZDpN2gzBKLt4PDP1ZUTLz+k8AJVs40WOu9NNw8=
-github.com/containrrr/shoutrrr v0.5.2/go.mod h1:XSU8tOIZ1JG8m6OuPozfGLpj6Ed+S8ZrRJaEodQhbzw=
-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-iptables v0.4.5/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU=
-github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU=
-github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
-github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
-github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
-github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
-github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
-github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
+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/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4=
-github.com/d2g/dhcp4 v0.0.0-20170904100407-a1d1b6c41b1c/go.mod h1:Ct2BUK8SB0YC1SMSibvLzxjeJLnrYEVLULFNiHY9YfQ=
-github.com/d2g/dhcp4client v1.0.0/go.mod h1:j0hNfjhrt2SxUOw55nL0ATM/z4Yt3t2Kd1mW34z5W5s=
-github.com/d2g/dhcp4server v0.0.0-20181031114812-7d4a0a7f59a5/go.mod h1:Eo87+Kg/IX2hfWJfwxMzLyuSZyxSoAug2nGa1G2QAi8=
-github.com/d2g/hardwareaddr v0.0.0-20190221164911-e7d9fbe030e4/go.mod h1:bMl4RjIciD2oAxI7DmWRx6gbeqrkoLqv3MV0vzNad+I=
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/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0=
-github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-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/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
-github.com/docker/cli v20.10.8+incompatible h1:/zO/6y9IOpcehE49yMRTV9ea0nBpb8OeqSskXLNfH1E=
-github.com/docker/cli v20.10.8+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
-github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY=
-github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-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 v20.10.8+incompatible h1:RVqD337BgQicVCzYrrlhLDWhq6OAD2PJDUg2LsEUvKM=
-github.com/docker/docker v20.10.8+incompatible/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-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
-github.com/docker/go-events v0.0.0-20170721190031-9461782956ad/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
-github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
-github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI=
-github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
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/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
-github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
-github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
-github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
-github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
-github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
-github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
-github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
-github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
-github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
-github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
-github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
-github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
-github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
+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/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
-github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
-github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
-github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
-github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
-github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
-github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
-github.com/go-interpreter/wagon v0.5.1-0.20190713202023-55a163980b6c/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc=
-github.com/go-interpreter/wagon v0.6.0/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc=
-github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/kit v0.9.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-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
-github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
-github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
-github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
-github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
-github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
-github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
-github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
-github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
-github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
-github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
-github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
-github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
-github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
-github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
-github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
-github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
-github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
-github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
-github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gogo/googleapis v1.2.0/go.mod h1:Njal3psf3qN6dwBtQfUmBZh2ybovJ0tlu3o/AC7HYjU=
-github.com/gogo/googleapis v1.4.0/go.mod h1:5YRNX2z1oM5gXdAkurHa942MDgEJyk02w4OecKY87+c=
-github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
-github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
-github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
-github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
+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/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-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/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
-github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-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/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+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/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
-github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.1.5/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
-github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
-github.com/gorilla/mux v1.7.2 h1:zoNxOV7WjqXptQOVngLmcSQgXmgk4NMz1HibBchjl/I=
-github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
-github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
-github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
-github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
-github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
-github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/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/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
-github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I=
-github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+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/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
-github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
-github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
-github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
-github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
-github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA=
-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/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
-github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
-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.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
-github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
-github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
-github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
-github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
-github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
-github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
-github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
+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/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/konsorten/go-windows-terminal-sequences v1.0.3/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/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
-github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
-github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
-github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
-github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
-github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
-github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
-github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
-github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho=
-github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
-github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
-github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
-github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
-github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
-github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI=
-github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
-github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
-github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-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/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A=
-github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
-github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A=
-github.com/moby/sys/mountinfo v0.4.1/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A=
-github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGqsZeMYowQ=
-github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo=
+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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/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/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/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ=
-github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
-github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
-github.com/nxadm/tail v1.4.6 h1:11TGpSHY7Esh/i/qnq02Jo5oVrI1Gue8Slbq0ujPZFQ=
-github.com/nxadm/tail v1.4.6/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
-github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
-github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
-github.com/onsi/ginkgo v0.0.0-20151202141238-7f8ab55aaf3b/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+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.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
-github.com/onsi/ginkgo v1.14.2 h1:8mVmC9kjFFmA8H4pKMUhcblgifdkOIXPvbhN1T36q1M=
-github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
-github.com/onsi/gomega v0.0.0-20151007035656-2152b45fa28a/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
-github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
-github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
-github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+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.10.3 h1:gph6h/qe9GSUw1NhH1gp+qb+h8rXD8Cy60Z32Qw3ELA=
-github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc=
-github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
-github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
-github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
-github.com/opencontainers/go-digest v1.0.0-rc1.0.20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
+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.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
-github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
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/opencontainers/runc v1.0.3/go.mod h1:aTaHFFwQXuA71CiyxOdFFIorAoemI04suvGRQFzWTD0=
-github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/runtime-spec v1.0.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/runtime-spec v1.0.2-0.20190207185410-29686dbc5559/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/runtime-spec v1.0.3-0.20200929063507-e6143ca7d51d/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs=
-github.com/opencontainers/selinux v1.6.0/go.mod h1:VVGKuOLlE7v4PJyT6h7mNWvq1rzqiriPsEqVhc+svHE=
-github.com/opencontainers/selinux v1.8.0/go.mod h1:RScLhm78qiWa2gbVCcGkC7tCGdgk3ogry1nUQF8Evvo=
-github.com/opencontainers/selinux v1.8.2/go.mod h1:MUIHuUEvKB1wtJjQdOyYRgOnLD2xAPP8dBsCoU0KuF8=
-github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
-github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
-github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM=
-github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
-github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
-github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
-github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
-github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
-github.com/prometheus/client_golang v1.7.1 h1:NTGy1Ja9pByO+xAeH/qiWnLrKtr3hJPNjaVUwnjpdpA=
-github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
-github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
-github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
-github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
-github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
-github.com/prometheus/common v0.10.0 h1:RyRA7RzGXQZiW+tGMr7sxa85G1z0yOpM1qq5c8lNawc=
-github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
-github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.0-20190522114515-bc1a522cf7b1/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
-github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
-github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
-github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
-github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
-github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4=
-github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
-github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
-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/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
-github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
-github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
-github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
-github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
-github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
-github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
-github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
-github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+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.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
-github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
-github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
-github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
-github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
-github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
-github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
-github.com/spf13/afero v1.1.2/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/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.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
-github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
-github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
-github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
-github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
-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 v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+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/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/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8=
-github.com/stretchr/objx v0.0.0-20180129172003-8a3f7159479f/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+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/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
-github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
-github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+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/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-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/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
-github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
-github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
-github.com/tchap/go-patricia v2.2.6+incompatible/go.mod h1:bmLyhP68RS6kStMGxByiQ23RP/odRBOTVjwp2cDyi6I=
-github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A=
-github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
-github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
-github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
-github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
-github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
-github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
-github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
-github.com/vishvananda/netlink v0.0.0-20181108222139-023a6dafdcdf/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk=
-github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
-github.com/vishvananda/netlink v1.1.1-0.20201029203352-d40f9887b852/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
-github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc/go.mod h1:ZjcWmFBXmLKZu9Nxj3WKYEafiSqer2rnvPr0en9UNpI=
-github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
-github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
-github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
-github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
-github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=
-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/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=
-github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
-github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA=
-github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=
-go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw=
-go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
-go.etcd.io/etcd v0.5.0-alpha.5.0.20200910180754-dd1b699fc489/go.mod h1:yVHk9ub3CSBatqGNg7GRmsnfLWtoW60w4eDYfh7vHDg=
-go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
-go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
-go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
-go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
-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-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181009213950-7c1a557ab941/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
-golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
-golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
-golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
-golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
-golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
-golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
-golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
-golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
-golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
-golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+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-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-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-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/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-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-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-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+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/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-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-20190306220234-b354f8bf4d9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190522044717-8097e1b27ff5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190618155005-516e3c20635f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190812073006-9eafafc0a87e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200120151820-655fe14d7479/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200817155316-9781c653f443/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200922070232-aee5d888a860/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201117170446-d9b008d0a637/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/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-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/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-20210831042530-f4d43177bf5e h1:XMgFehsDnnLGtjvjOfqWSUzt0alpTR1RSEuznObga2c=
-golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+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/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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-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-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s=
-golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+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-20181030221726-6c7e314b6563/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-20190312151545-0bb0c0a6e846/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/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
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-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.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
-google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
-google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk=
-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-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190522204451-c2c4e71fbf69/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
-google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
-google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200117163144-32f20d992d24/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
-google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a h1:pOwg4OoaRYScjmR4LlLgdtnyoHYTSAVhhqe5uPdpII8=
-google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA=
-google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.33.2 h1:EQyQC3sa8M+p6Ulc8yy9SWSS2GVwyRc83gAbG8lrl4o=
-google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
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.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
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.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
-google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+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-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+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/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
-gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
-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/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
-gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
-gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
-gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
-gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
+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/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.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gosrc.io/xmpp v0.5.1/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
-gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
-gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
-gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
-gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY=
+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=
-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=
-honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
-honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-k8s.io/api v0.20.1/go.mod h1:KqwcCVogGxQY3nBlRpwt+wpAMF/KjaCc7RpywacvqUo=
-k8s.io/api v0.20.4/go.mod h1:++lNL1AJMkDymriNniQsWRkMDzRaX2Y/POTUi8yvqYQ=
-k8s.io/api v0.20.6/go.mod h1:X9e8Qag6JV/bL5G6bU8sdVRltWKmdHsFUGS3eVndqE8=
-k8s.io/apimachinery v0.20.1/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
-k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
-k8s.io/apimachinery v0.20.6/go.mod h1:ejZXtW1Ra6V1O5H8xPBGz+T3+4gfkTCeExAHKU57MAc=
-k8s.io/apiserver v0.20.1/go.mod h1:ro5QHeQkgMS7ZGpvf4tSMx6bBOgPfE+f52KwvXfScaU=
-k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM=
-k8s.io/apiserver v0.20.6/go.mod h1:QIJXNt6i6JB+0YQRNcS0hdRHJlMhflFmsBDeSgT1r8Q=
-k8s.io/client-go v0.20.1/go.mod h1:/zcHdt1TeWSd5HoUe6elJmHSQ6uLLgp4bIJHVEuy+/Y=
-k8s.io/client-go v0.20.4/go.mod h1:LiMv25ND1gLUdBeYxBIwKpkSC5IsozMMmOOeSJboP+k=
-k8s.io/client-go v0.20.6/go.mod h1:nNQMnOvEUEsOzRRFIIkdmYOjAZrC8bgq0ExboWSU1I0=
-k8s.io/component-base v0.20.1/go.mod h1:guxkoJnNoh8LNrbtiQOlyp2Y2XFCZQmrcg2n/DeYNLk=
-k8s.io/component-base v0.20.4/go.mod h1:t4p9EdiagbVCJKrQ1RsA5/V4rFQNDfRlevJajlGwgjI=
-k8s.io/component-base v0.20.6/go.mod h1:6f1MPBAeI+mvuts3sIdtpjljHWBQ2cIy38oBIWMYnrM=
-k8s.io/cri-api v0.17.3/go.mod h1:X1sbHmuXhwaHs9xxYffLqJogVsnI+f6cPRcgPel7ywM=
-k8s.io/cri-api v0.20.1/go.mod h1:2JRbKt+BFLTjtrILYVqQK5jqhI+XNdF6UiGMgczeBCI=
-k8s.io/cri-api v0.20.4/go.mod h1:2JRbKt+BFLTjtrILYVqQK5jqhI+XNdF6UiGMgczeBCI=
-k8s.io/cri-api v0.20.6/go.mod h1:ew44AjNXwyn1s0U4xCKGodU7J1HzBeZ1MpGrpa5r8Yc=
-k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
-k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE=
-k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y=
-k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM=
-k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk=
-k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
-mvdan.cc/sh v2.6.4+incompatible/go.mod h1:IeeQbZq+x2SUGBensq/jge5lLQbS3XT2ktyp3wrt4x8=
-nhooyr.io/websocket v1.6.5/go.mod h1:F259lAzPRAH0htX2y3ehpJe09ih1aSHN7udWki1defY=
-nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
-rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
-rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
-rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
-sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.14/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg=
-sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.15/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg=
-sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw=
-sigs.k8s.io/structured-merge-diff/v4 v4.0.3/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw=
-sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
-sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
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 5110fea..c320564 100644
--- a/internal/actions/actions_suite_test.go
+++ b/internal/actions/actions_suite_test.go
@@ -1,12 +1,13 @@
package actions_test
import (
- "github.com/sirupsen/logrus"
"testing"
"time"
+ "github.com/sirupsen/logrus"
+
"github.com/containrrr/watchtower/internal/actions"
- "github.com/containrrr/watchtower/pkg/container"
+ "github.com/containrrr/watchtower/pkg/types"
. "github.com/containrrr/watchtower/internal/actions/mocks"
. "github.com/onsi/ginkgo"
@@ -37,7 +38,7 @@ var _ = Describe("the actions package", func() {
It("should not do anything", func() {
client := CreateMockClient(
&TestData{
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainer(
"test-container",
"test-container",
@@ -59,7 +60,7 @@ var _ = Describe("the actions package", func() {
client = CreateMockClient(
&TestData{
NameOfContainerToKeep: "test-container-02",
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainer(
"test-container-01",
"test-container-01",
@@ -89,7 +90,7 @@ var _ = Describe("the actions package", func() {
BeforeEach(func() {
client = CreateMockClient(
&TestData{
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainer(
"test-container-01",
"test-container-01",
diff --git a/internal/actions/check.go b/internal/actions/check.go
index 436931f..77a2266 100644
--- a/internal/actions/check.go
+++ b/internal/actions/check.go
@@ -2,16 +2,15 @@ package actions
import (
"fmt"
- "github.com/containrrr/watchtower/pkg/types"
"sort"
"time"
+ "github.com/containrrr/watchtower/pkg/container"
"github.com/containrrr/watchtower/pkg/filters"
"github.com/containrrr/watchtower/pkg/sorter"
+ "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
@@ -40,7 +39,11 @@ func CheckForSanity(client container.Client, filter types.Filter, rollingRestart
// 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 {
- 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 {
return err
@@ -55,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))
diff --git a/internal/actions/mocks/client.go b/internal/actions/mocks/client.go
index 4844a55..737404a 100644
--- a/internal/actions/mocks/client.go
+++ b/internal/actions/mocks/client.go
@@ -3,7 +3,6 @@ package mocks
import (
"errors"
"fmt"
- "github.com/containrrr/watchtower/pkg/container"
"time"
t "github.com/containrrr/watchtower/pkg/types"
@@ -20,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
@@ -38,12 +38,12 @@ func CreateMockClient(data *TestData, pullImages bool, removeVolumes bool) MockC
}
// ListContainers is a mock method returning the provided container testdata
-func (client MockClient) ListContainers(_ 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, _ 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")
}
@@ -51,12 +51,12 @@ func (client MockClient) StopContainer(c container.Container, _ time.Duration) e
}
// StartContainer is a mock method
-func (client MockClient) StartContainer(_ container.Container) (t.ContainerID, error) {
+func (client MockClient) StartContainer(_ t.Container) (t.ContainerID, error) {
return "", nil
}
// RenameContainer is a mock method
-func (client MockClient) RenameContainer(_ container.Container, _ string) error {
+func (client MockClient) RenameContainer(_ t.Container, _ string) error {
return nil
}
@@ -67,7 +67,7 @@ func (client MockClient) RemoveImageByID(_ t.ImageID) error {
}
// GetContainer is a mock method
-func (client MockClient) GetContainer(_ t.ContainerID) (container.Container, error) {
+func (client MockClient) GetContainer(_ t.ContainerID) (t.Container, error) {
return client.TestData.Containers[0], nil
}
@@ -85,12 +85,16 @@ func (client MockClient) ExecuteCommand(_ t.ContainerID, command string, _ int)
}
}
-// IsContainerStale is always true for the mock client
-func (client MockClient) IsContainerStale(_ container.Container) (bool, t.ImageID, 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(_ container.Container) bool {
+func (client MockClient) WarnOnHeadPullFailed(_ t.Container) bool {
return true
}
diff --git a/internal/actions/mocks/container.go b/internal/actions/mocks/container.go
index 167d571..e830587 100644
--- a/internal/actions/mocks/container.go
+++ b/internal/actions/mocks/container.go
@@ -2,18 +2,19 @@ package mocks
import (
"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"
- "strconv"
- "strings"
- "time"
)
// 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,
@@ -30,24 +31,29 @@ func CreateMockContainer(id string, name string, image string, created time.Time
ExposedPorts: map[nat.Port]struct{}{},
},
}
- return *container.NewContainer(
+ return container.NewContainer(
&content,
- &types.ImageInspect{
- ID: image,
- RepoDigests: []string{
- image,
- },
- },
+ 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) container.Container {
+func CreateMockContainerWithImageInfoP(id string, name string, image string, created time.Time, imageInfo *types.ImageInspect) wt.Container {
content := types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
ID: id,
@@ -60,21 +66,21 @@ func CreateMockContainerWithImageInfoP(id string, name string, image string, cre
Labels: make(map[string]string),
},
}
- return *container.NewContainer(
+ return container.NewContainer(
&content,
imageInfo,
)
}
// CreateMockContainerWithDigest should only be used for testing
-func CreateMockContainerWithDigest(id string, name string, image string, created time.Time, digest string) container.Container {
+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, running bool, restarting bool, created time.Time, config *dockerContainer.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,
@@ -91,16 +97,14 @@ func CreateMockContainerWithConfig(id string, name string, image string, running
},
Config: config,
}
- return *container.NewContainer(
+ return container.NewContainer(
&content,
- &types.ImageInspect{
- ID: image,
- },
+ CreateMockImageInfo(image),
)
}
// CreateContainerForProgress creates a container substitute for tracking session/update progress
-func CreateContainerForProgress(index int, idPrefix int, nameFormat string) (container.Container, wt.ImageID) {
+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
@@ -114,3 +118,26 @@ func CreateContainerForProgress(index int, idPrefix int, nameFormat string) (con
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),
+ },
+ }
+ return container.NewContainer(
+ &content,
+ imageInfo,
+ )
+}
diff --git a/internal/actions/mocks/progress.go b/internal/actions/mocks/progress.go
index 6883b48..23fc441 100644
--- a/internal/actions/mocks/progress.go
+++ b/internal/actions/mocks/progress.go
@@ -2,6 +2,7 @@ package mocks
import (
"errors"
+
"github.com/containrrr/watchtower/pkg/session"
wt "github.com/containrrr/watchtower/pkg/types"
)
@@ -21,16 +22,13 @@ func CreateMockProgressReport(states ...session.State) wt.Report {
case session.SkippedState:
c, _ := CreateContainerForProgress(index, 41, "skip%d")
progress.AddSkipped(c, errors.New("unpossible"))
- break
case session.FreshState:
c, _ := CreateContainerForProgress(index, 31, "frsh%d")
progress.AddScanned(c, c.ImageID())
- break
case session.UpdatedState:
c, newImage := CreateContainerForProgress(index, 11, "updt%d")
progress.AddScanned(c, newImage)
progress.MarkForUpdate(c.ID())
- break
case session.FailedState:
c, newImage := CreateContainerForProgress(index, 21, "fail%d")
progress.AddScanned(c, newImage)
diff --git a/internal/actions/update.go b/internal/actions/update.go
index e0f7065..8853c6e 100644
--- a/internal/actions/update.go
+++ b/internal/actions/update.go
@@ -2,6 +2,7 @@ package actions
import (
"errors"
+
"github.com/containrrr/watchtower/internal/util"
"github.com/containrrr/watchtower/pkg/container"
"github.com/containrrr/watchtower/pkg/lifecycle"
@@ -9,7 +10,6 @@ import (
"github.com/containrrr/watchtower/pkg/sorter"
"github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
- "strings"
)
// Update looks at the running Docker containers to see if any of the images
@@ -33,8 +33,8 @@ func Update(client container.Client, params types.UpdateParams) (types.Report, e
staleCheckFailed := 0
for i, targetContainer := range containers {
- stale, newestImage, err := client.IsContainerStale(targetContainer)
- shouldUpdate := stale && !params.NoRestart && !params.MonitorOnly && !targetContainer.IsMonitorOnly()
+ 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()
@@ -57,7 +57,7 @@ func Update(client container.Client, params types.UpdateParams) (types.Report, e
} else {
progress.AddScanned(targetContainer, newestImage)
}
- containers[i].Stale = stale
+ containers[i].SetStale(stale)
if stale {
staleCount++
@@ -71,13 +71,11 @@ func Update(client container.Client, params types.UpdateParams) (types.Report, e
UpdateImplicitRestart(containers)
- var containersToUpdate []container.Container
- if !params.MonitorOnly {
- for _, c := range containers {
- if !c.IsMonitorOnly() {
- containersToUpdate = append(containersToUpdate, c)
- progress.MarkForUpdate(c.ID())
- }
+ var containersToUpdate []types.Container
+ for _, c := range containers {
+ if !c.IsMonitorOnly(params) {
+ containersToUpdate = append(containersToUpdate, c)
+ progress.MarkForUpdate(c.ID())
}
}
@@ -96,7 +94,7 @@ func Update(client container.Client, params types.UpdateParams) (types.Report, e
return progress.Report(), nil
}
-func performRollingRestart(containers []container.Container, client container.Client, params types.UpdateParams) map[types.ContainerID]error {
+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))
@@ -108,8 +106,10 @@ func performRollingRestart(containers []container.Container, client container.Cl
} 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
}
- cleanupImageIDs[containers[i].ImageID()] = true
}
}
}
@@ -120,21 +120,22 @@ func performRollingRestart(containers []container.Container, client container.Cl
return failed
}
-func stopContainersInReversedOrder(containers []container.Container, client container.Client, params types.UpdateParams) (failed map[types.ContainerID]error, stopped map[types.ImageID]bool) {
+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[containers[i].ID()] = err
} else {
- stopped[containers[i].ImageID()] = true
+ // NOTE: If a container is restarted due to a dependency this might be empty
+ stopped[containers[i].SafeImageID()] = true
}
}
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
@@ -143,6 +144,14 @@ func stopStaleContainer(container container.Container, client container.Client,
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 {
skipUpdate, err := lifecycle.ExecutePreUpdateCommand(client, container)
if err != nil {
@@ -163,7 +172,7 @@ func stopStaleContainer(container container.Container, client container.Client,
return nil
}
-func restartContainersInSortedOrder(containers []container.Container, client container.Client, params types.UpdateParams, stoppedImages map[types.ImageID]bool) map[types.ContainerID]error {
+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))
@@ -171,11 +180,13 @@ func restartContainersInSortedOrder(containers []container.Container, client con
if !c.ToRestart() {
continue
}
- if stoppedImages[c.ImageID()] {
+ 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
}
- cleanupImageIDs[c.ImageID()] = true
}
}
@@ -188,13 +199,16 @@ func restartContainersInSortedOrder(containers []container.Container, client con
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
@@ -219,7 +233,7 @@ func restartStaleContainer(container container.Container, client container.Clien
// UpdateImplicitRestart iterates through the passed containers, setting the
// `LinkedToRestarting` flag if any of it's linked containers are marked for restart
-func UpdateImplicitRestart(containers []container.Container) {
+func UpdateImplicitRestart(containers []types.Container) {
for ci, c := range containers {
if c.ToRestart() {
@@ -233,7 +247,7 @@ func UpdateImplicitRestart(containers []container.Container) {
"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].LinkedToRestarting = true
+ containers[ci].SetLinkedToRestarting(true)
}
}
@@ -241,12 +255,8 @@ func UpdateImplicitRestart(containers []container.Container) {
// linkedContainerMarkedForRestart returns the name of the first link that matches a
// container marked for restart
-func linkedContainerMarkedForRestart(links []string, containers []container.Container) string {
+func linkedContainerMarkedForRestart(links []string, containers []types.Container) string {
for _, linkName := range links {
- // Since the container names need to start with '/', let's prepend it if it's missing
- if !strings.HasPrefix(linkName, "/") {
- linkName = "/" + linkName
- }
for _, candidate := range containers {
if candidate.Name() == linkName && candidate.ToRestart() {
return linkName
diff --git a/internal/actions/update_test.go b/internal/actions/update_test.go
index 7d392d7..9209dcd 100644
--- a/internal/actions/update_test.go
+++ b/internal/actions/update_test.go
@@ -1,55 +1,75 @@
package actions_test
import (
+ "time"
+
"github.com/containrrr/watchtower/internal/actions"
- "github.com/containrrr/watchtower/pkg/container"
"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"
- "time"
. "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 client MockClient
-
When("watchtower has been instructed to clean up", func() {
- BeforeEach(func() {
- pullImages := false
- removeVolumes := false
- //goland:noinspection GoBoolExpressions
- 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()),
- },
- },
- 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))
@@ -57,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",
@@ -66,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",
@@ -110,9 +152,6 @@ var _ = Describe("the update action", func() {
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))
@@ -120,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",
@@ -139,25 +178,94 @@ var _ = Describe("the update action", func() {
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("prupddate script returns 1", func() {
- BeforeEach(func() {
- client = CreateMockClient(
+ When("pre-update script returns 1", func() {
+ It("should not update those containers", func() {
+ client := CreateMockClient(
&TestData{
//NameOfContainerToKeep: "test-container-02",
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainerWithConfig(
"test-container-02",
"test-container-02",
@@ -177,9 +285,7 @@ var _ = Describe("the update action", func() {
false,
false,
)
- })
- It("should not update those containers", func() {
_, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
@@ -188,11 +294,11 @@ var _ = Describe("the update action", func() {
})
When("prupddate script returns 75", 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{
CreateMockContainerWithConfig(
"test-container-02",
"test-container-02",
@@ -212,9 +318,6 @@ var _ = Describe("the update action", func() {
false,
false,
)
- })
-
- It("should not update those containers", func() {
_, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
@@ -223,11 +326,11 @@ var _ = Describe("the update action", func() {
})
When("prupddate script returns 0", func() {
- BeforeEach(func() {
- client = CreateMockClient(
+ It("should update those containers", func() {
+ client := CreateMockClient(
&TestData{
//NameOfContainerToKeep: "test-container-02",
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainerWithConfig(
"test-container-02",
"test-container-02",
@@ -247,9 +350,6 @@ var _ = Describe("the update action", func() {
false,
false,
)
- })
-
- It("should update those containers", func() {
_, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
@@ -271,7 +371,7 @@ var _ = Describe("the update action", func() {
ExposedPorts: map[nat.Port]struct{}{},
})
- provider.Stale = true
+ provider.SetStale(true)
consumer := CreateMockContainerWithConfig(
"test-container-consumer",
@@ -287,7 +387,7 @@ var _ = Describe("the update action", func() {
ExposedPorts: map[nat.Port]struct{}{},
})
- containers := []container.Container{
+ containers := []types.Container{
provider,
consumer,
}
@@ -305,11 +405,11 @@ var _ = Describe("the update action", func() {
})
When("container is not running", func() {
- BeforeEach(func() {
- client = CreateMockClient(
+ It("skip running preupdate", func() {
+ client := CreateMockClient(
&TestData{
//NameOfContainerToKeep: "test-container-02",
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainerWithConfig(
"test-container-02",
"test-container-02",
@@ -329,9 +429,6 @@ var _ = Describe("the update action", func() {
false,
false,
)
- })
-
- It("skip running preupdate", func() {
_, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
@@ -340,11 +437,11 @@ var _ = Describe("the update action", func() {
})
When("container is restarting", func() {
- BeforeEach(func() {
- client = CreateMockClient(
+ It("skip running preupdate", func() {
+ client := CreateMockClient(
&TestData{
//NameOfContainerToKeep: "test-container-02",
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainerWithConfig(
"test-container-02",
"test-container-02",
@@ -364,9 +461,6 @@ var _ = Describe("the update action", func() {
false,
false,
)
- })
-
- It("skip running preupdate", func() {
_, 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 a02dbd7..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,143 +35,182 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
flags.IntP(
"interval",
"i",
- viper.GetInt("WATCHTOWER_POLL_INTERVAL"),
+ envInt("WATCHTOWER_POLL_INTERVAL"),
"Poll interval (in seconds)")
flags.StringP(
"schedule",
"s",
- viper.GetString("WATCHTOWER_SCHEDULE"),
+ envString("WATCHTOWER_SCHEDULE"),
"The cron expression which defines when to update")
flags.DurationP(
"stop-timeout",
"t",
- viper.GetDuration("WATCHTOWER_TIMEOUT"),
+ envDuration("WATCHTOWER_TIMEOUT"),
"Timeout before a container is forcefully stopped")
flags.BoolP(
"no-pull",
"",
- viper.GetBool("WATCHTOWER_NO_PULL"),
+ envBool("WATCHTOWER_NO_PULL"),
"Do not pull any new images")
flags.BoolP(
"no-restart",
"",
- viper.GetBool("WATCHTOWER_NO_RESTART"),
+ 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"),
+ envBool("WATCHTOWER_CLEANUP"),
"Remove previously used images after updating")
flags.BoolP(
"remove-volumes",
"",
- viper.GetBool("WATCHTOWER_REMOVE_VOLUMES"),
+ envBool("WATCHTOWER_REMOVE_VOLUMES"),
"Remove attached volumes before updating")
flags.BoolP(
"label-enable",
"e",
- viper.GetBool("WATCHTOWER_LABEL_ENABLE"),
+ 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"),
+ envBool("WATCHTOWER_DEBUG"),
"Enable debug mode with verbose logging")
flags.BoolP(
"trace",
"",
- viper.GetBool("WATCHTOWER_TRACE"),
+ 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",
"",
- viper.GetBool("WATCHTOWER_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
@@ -176,170 +220,216 @@ func RegisterNotificationFlags(rootCmd *cobra.Command) {
flags.StringSliceP(
"notifications",
"n",
- viper.GetStringSlice("WATCHTOWER_NOTIFICATIONS"),
+ envStringSlice("WATCHTOWER_NOTIFICATIONS"),
" Notification types to send (valid: email, slack, msteams, gotify, shoutrrr)")
flags.String(
"notifications-level",
- viper.GetString("WATCHTOWER_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-hostname",
"",
- viper.GetString("WATCHTOWER_NOTIFICATIONS_HOSTNAME"),
+ 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.String(
"notification-template",
- viper.GetString("WATCHTOWER_NOTIFICATION_TEMPLATE"),
+ envString("WATCHTOWER_NOTIFICATION_TEMPLATE"),
"The shoutrrr text/template for the messages")
flags.StringArray(
"notification-url",
- viper.GetStringSlice("WATCHTOWER_NOTIFICATION_URL"),
+ envStringSlice("WATCHTOWER_NOTIFICATION_URL"),
"The shoutrrr URL to send notifications to")
flags.Bool("notification-report",
- viper.GetBool("WATCHTOWER_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",
- viper.GetString("WATCHTOWER_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
@@ -427,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 e298622..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,48 @@ 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)
}
@@ -94,3 +119,221 @@ func TestHTTPAPIPeriodicPollsFlag(t *testing.T) {
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/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 57b2f7b..5227004 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -5,7 +5,16 @@ edit_uri: edit/main/docs/
theme:
name: 'material'
palette:
- scheme: containrrr
+ - media: "(prefers-color-scheme: light)"
+ scheme: containrrr
+ toggle:
+ icon: material/weather-night
+ name: Switch to dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: containrrr-dark
+ toggle:
+ icon: material/weather-sunny
+ name: Switch to light mode
logo: images/logo-450px.png
favicon: images/favicon.ico
extra_css:
@@ -24,7 +33,7 @@ markdown_extensions:
repo: watchtower
- pymdownx.saneheaders
- pymdownx.tabbed:
- alternate_style: true
+ alternate_style: true
nav:
- 'Home': 'index.md'
- 'Introduction': 'introduction.md'
@@ -39,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/metrics/metrics_test.go b/pkg/api/metrics/metrics_test.go
index 5120f8d..48b6dd7 100644
--- a/pkg/api/metrics/metrics_test.go
+++ b/pkg/api/metrics/metrics_test.go
@@ -2,18 +2,18 @@ package metrics_test
import (
"fmt"
- "io/ioutil"
+ "io"
"net/http"
"net/http/httptest"
"strings"
"testing"
+ . "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"
-
- . "github.com/onsi/ginkgo"
- . "github.com/onsi/gomega"
)
const (
@@ -36,7 +36,7 @@ func getWithToken(handler http.Handler) map[string]string {
handler.ServeHTTP(respWriter, req)
res := respWriter.Result()
- body, _ := ioutil.ReadAll(res.Body)
+ body, _ := io.ReadAll(res.Body)
for _, line := range strings.Split(string(body), "\n") {
if len(line) < 1 || line[0] == '#' {
diff --git a/pkg/api/update/update.go b/pkg/api/update/update.go
index 4721e3e..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,7 +14,7 @@ var (
)
// New is a factory function creating a new Handler instance
-func New(updateFn func(), updateLock chan bool) *Handler {
+func New(updateFn func(images []string), updateLock chan bool) *Handler {
if updateLock != nil {
lock = updateLock
} else {
@@ -29,7 +30,7 @@ func New(updateFn func(), updateLock chan bool) *Handler {
// Handler is an API handler used for triggering container update scans
type Handler struct {
- fn func()
+ fn func(images []string)
Path string
}
@@ -43,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 371206b..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,24 +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 t.ContainerID) (Container, error)
- StopContainer(Container, time.Duration) error
- StartContainer(Container) (t.ContainerID, error)
- RenameContainer(Container, string) error
- IsContainerStale(Container) (stale bool, latestImage t.ImageID, err 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 Container) bool
+ 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, includeStopped, reviveStopped, removeVolumes, includeRestarting bool, warnOnHeadFailed string) 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 {
@@ -50,46 +50,57 @@ func NewClient(pullImages, includeStopped, reviveStopped, removeVolumes, include
}
return dockerClient{
- api: cli,
- pullImages: pullImages,
- removeVolumes: removeVolumes,
- includeStopped: includeStopped,
- reviveStopped: reviveStopped,
- includeRestarting: includeRestarting,
- warnOnHeadFailed: warnOnHeadFailed,
+ api: cli,
+ ClientOptions: opts,
}
}
+// 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
+}
+
+// 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
- pullImages bool
- removeVolumes bool
- includeStopped bool
- reviveStopped bool
- includeRestarting bool
- warnOnHeadFailed string
+ api sdkClient.CommonAPIClient
+ ClientOptions
}
-func (client dockerClient) WarnOnHeadPullFailed(container Container) bool {
- if client.warnOnHeadFailed == "always" {
+func (client dockerClient) WarnOnHeadPullFailed(container t.Container) bool {
+ if client.WarnOnHeadFailed == WarnAlways {
return true
}
- if client.warnOnHeadFailed == "never" {
+ if client.WarnOnHeadFailed == WarnNever {
return false
}
return registry.WarnOnAPIConsumption(container)
}
-func (client dockerClient) ListContainers(fn t.Filter) ([]Container, error) {
- cs := []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")
@@ -125,36 +136,52 @@ 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 t.ContainerID) (Container, error) {
+func (client dockerClient) GetContainer(containerID t.ContainerID) (t.Container, error) {
bg := context.Background()
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 == "" {
@@ -174,12 +201,16 @@ 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 {
+ if c.ContainerInfo().HostConfig.AutoRemove {
log.Debugf("AutoRemove container %s, skipping ContainerRemove call.", shortID)
} else {
log.Debugf("Removing container %s", shortID)
- if err := client.api.ContainerRemove(bg, idStr, types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.removeVolumes}); err != nil {
+ if err := client.api.ContainerRemove(bg, idStr, types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.RemoveVolumes}); err != nil {
+ if sdkClient.IsErrNotFound(err) {
+ log.Debugf("Container %s not found, skipping removal.", shortID)
+ return nil
+ }
return err
}
}
@@ -192,11 +223,34 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
return nil
}
-func (client dockerClient) StartContainer(c Container) (t.ContainerID, 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 {
@@ -212,6 +266,7 @@ func (client dockerClient) StartContainer(c Container) (t.ContainerID, error) {
name := c.Name()
log.Infof("Creating %s", name)
+
createdContainer, err := client.api.ContainerCreate(bg, config, hostConfig, simpleNetworkConfig, nil, name)
if err != nil {
return "", err
@@ -236,7 +291,7 @@ func (client dockerClient) StartContainer(c Container) (t.ContainerID, error) {
}
createdContainerID := t.ContainerID(createdContainer.ID)
- if !c.IsRunning() && !client.reviveStopped {
+ if !c.IsRunning() && !client.ReviveStopped {
return createdContainerID, nil
}
@@ -244,7 +299,7 @@ func (client dockerClient) StartContainer(c Container) (t.ContainerID, error) {
}
-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, t.ContainerID(creation.ID).ShortID())
@@ -255,16 +310,16 @@ 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().ShortID(), newName)
return client.api.ContainerRename(bg, string(c.ID()), newName)
}
-func (client dockerClient) IsContainerStale(container Container) (stale bool, latestImage t.ImageID, err 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, container.SafeImageID(), err
@@ -273,8 +328,8 @@ func (client dockerClient) IsContainerStale(container Container) (stale bool, la
return client.HasNewImage(ctx, container)
}
-func (client dockerClient) HasNewImage(ctx context.Context, container Container) (hasNew bool, latestImage t.ImageID, err error) {
- currentImageID := t.ImageID(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)
@@ -294,7 +349,7 @@ func (client dockerClient) HasNewImage(ctx context.Context, container Container)
// PullImage pulls the latest image for the supplied container, optionally skipping if it's digest can be confirmed
// to match the one that the registry reports via a HEAD request
-func (client dockerClient) PullImage(ctx context.Context, container Container) error {
+func (client dockerClient) PullImage(ctx context.Context, container t.Container) error {
containerName := container.Name()
imageName := container.ImageName()
@@ -303,6 +358,10 @@ 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 err != nil {
@@ -339,7 +398,7 @@ 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
}
@@ -349,13 +408,34 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
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(),
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
}
@@ -438,7 +518,7 @@ func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, e
if err != nil {
return false, err
}
- if execInspect.Running == true {
+ if execInspect.Running {
time.Sleep(1 * time.Second)
continue
}
@@ -451,14 +531,14 @@ func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, e
}
if execInspect.ExitCode > 0 {
- return false, fmt.Errorf("Command exited with code %v %s", execInspect.ExitCode, execOutput)
+ return false, fmt.Errorf("command exited with code %v %s", execInspect.ExitCode, execOutput)
}
break
}
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)
diff --git a/pkg/container/client_test.go b/pkg/container/client_test.go
index 2f0157d..4e75409 100644
--- a/pkg/container/client_test.go
+++ b/pkg/container/client_test.go
@@ -1,6 +1,10 @@
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"
@@ -8,14 +12,16 @@ import (
"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"
- . "github.com/onsi/gomega/types"
+ gt "github.com/onsi/gomega/types"
+ "context"
"net/http"
)
@@ -32,41 +38,114 @@ var _ = Describe("the client", func() {
mockServer.Close()
})
Describe("WarnOnHeadPullFailed", func() {
- containerUnknown := *mockContainerWithImageName("unknown.repo/prefix/imagename:latest")
- containerKnown := *mockContainerWithImageName("docker.io/prefix/imagename:latest")
+ containerUnknown := MockContainer(WithImageName("unknown.repo/prefix/imagename:latest"))
+ containerKnown := MockContainer(WithImageName("docker.io/prefix/imagename:latest"))
- When("warn on head failure is set to \"always\"", func() {
- c := newClientNoAPI(false, false, false, false, false, "always")
+ 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 := newClientNoAPI(false, false, false, false, false, "auto")
- It("should always return true", func() {
+ 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", func() {
+ 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 := newClientNoAPI(false, false, false, false, false, "never")
+ 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("watchtower", "running")...)
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
client := dockerClient{
- api: docker,
- pullImages: false,
+ api: docker,
+ ClientOptions: ClientOptions{},
}
containers, err := client.ListContainers(filters.NoFilter)
Expect(err).NotTo(HaveOccurred())
@@ -76,11 +155,11 @@ var _ = Describe("the client", func() {
When("a filter matching nothing", func() {
It("should return an empty array", func() {
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
- mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
filter := filters.FilterByNames([]string{"lollercoaster"}, filters.NoFilter)
client := dockerClient{
- api: docker,
- pullImages: false,
+ api: docker,
+ ClientOptions: ClientOptions{},
}
containers, err := client.ListContainers(filter)
Expect(err).NotTo(HaveOccurred())
@@ -90,10 +169,10 @@ var _ = Describe("the client", func() {
When("a watchtower filter is provided", func() {
It("should return only the watchtower container", func() {
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
- mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
client := dockerClient{
- api: docker,
- pullImages: false,
+ api: docker,
+ ClientOptions: ClientOptions{},
}
containers, err := client.ListContainers(filters.WatchtowerContainersFilter)
Expect(err).NotTo(HaveOccurred())
@@ -103,11 +182,10 @@ var _ = Describe("the client", func() {
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("stopped", "watchtower", "running")...)
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Stopped, &mocks.Watchtower, &mocks.Running)...)
client := dockerClient{
- api: docker,
- pullImages: false,
- includeStopped: true,
+ api: docker,
+ ClientOptions: ClientOptions{IncludeStopped: true},
}
containers, err := client.ListContainers(filters.NoFilter)
Expect(err).NotTo(HaveOccurred())
@@ -117,11 +195,10 @@ var _ = Describe("the client", func() {
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("watchtower", "running", "restarting")...)
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running, &mocks.Restarting)...)
client := dockerClient{
- api: docker,
- pullImages: false,
- includeRestarting: true,
+ api: docker,
+ ClientOptions: ClientOptions{IncludeRestarting: true},
}
containers, err := client.ListContainers(filters.NoFilter)
Expect(err).NotTo(HaveOccurred())
@@ -131,31 +208,58 @@ var _ = Describe("the client", func() {
When(`include restarting is disabled`, func() {
It("should not return restarting containers", func() {
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
- mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
client := dockerClient{
- api: docker,
- pullImages: false,
- includeRestarting: false,
+ 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,
- pullImages: false,
+ api: docker,
+ ClientOptions: ClientOptions{},
}
// Capture logrus output in buffer
- logbuf := gbytes.NewBuffer()
- origOut := logrus.StandardLogger().Out
- defer logrus.SetOutput(origOut)
- logrus.SetOutput(logbuf)
+ resetLogrus, logbuf := captureLogrus(logrus.DebugLevel)
+ defer resetLogrus()
user := ""
containerID := t.ContainerID("ex-cont-id")
@@ -212,26 +316,62 @@ var _ = Describe("the client", func() {
})
})
})
+ 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 GomegaMatcher) GomegaMatcher {
+func withContainerImageName(matcher gt.GomegaMatcher) gt.GomegaMatcher {
return WithTransform(containerImageName, matcher)
}
-func containerImageName(container Container) string {
+func containerImageName(container t.Container) string {
return container.ImageName()
}
-func havingRestartingState(expected bool) GomegaMatcher {
- return WithTransform(func(container Container) bool {
- return container.containerInfo.State.Restarting
+func havingRestartingState(expected bool) gt.GomegaMatcher {
+ return WithTransform(func(container t.Container) bool {
+ return container.ContainerInfo().State.Restarting
}, Equal(expected))
}
-func havingRunningState(expected bool) GomegaMatcher {
- return WithTransform(func(container Container) bool {
- return container.containerInfo.State.Running
+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 82ae205..10ed677 100644
--- a/pkg/container/container.go
+++ b/pkg/container/container.go
@@ -1,12 +1,15 @@
+// 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"
@@ -31,6 +34,26 @@ type Container struct {
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
@@ -108,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
@@ -143,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
}
@@ -152,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
@@ -216,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
@@ -251,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)
@@ -271,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 {
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_test.go b/pkg/container/container_test.go
index 1a4e956..a129afe 100644
--- a/pkg/container/container_test.go
+++ b/pkg/container/container_test.go
@@ -1,8 +1,8 @@
package container
import (
- "github.com/docker/docker/api/types"
- "github.com/docker/docker/api/types/container"
+ "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"
@@ -12,7 +12,7 @@ var _ = Describe("the container", func() {
Describe("VerifyConfiguration", func() {
When("verifying a container with no image info", func() {
It("should return an error", func() {
- c := mockContainerWithPortBindings()
+ c := MockContainer(WithPortBindings())
c.imageInfo = nil
err := c.VerifyConfiguration()
Expect(err).To(Equal(errorNoImageInfo))
@@ -20,7 +20,7 @@ var _ = Describe("the container", func() {
})
When("verifying a container with no container info", func() {
It("should return an error", func() {
- c := mockContainerWithPortBindings()
+ c := MockContainer(WithPortBindings())
c.containerInfo = nil
err := c.VerifyConfiguration()
Expect(err).To(Equal(errorNoContainerInfo))
@@ -28,7 +28,7 @@ var _ = Describe("the container", func() {
})
When("verifying a container with no config", func() {
It("should return an error", func() {
- c := mockContainerWithPortBindings()
+ c := MockContainer(WithPortBindings())
c.containerInfo.Config = nil
err := c.VerifyConfiguration()
Expect(err).To(Equal(errorInvalidConfig))
@@ -36,7 +36,7 @@ var _ = Describe("the container", func() {
})
When("verifying a container with no host config", func() {
It("should return an error", func() {
- c := mockContainerWithPortBindings()
+ c := MockContainer(WithPortBindings())
c.containerInfo.HostConfig = nil
err := c.VerifyConfiguration()
Expect(err).To(Equal(errorInvalidConfig))
@@ -44,14 +44,14 @@ var _ = Describe("the container", func() {
})
When("verifying a container with no port bindings", func() {
It("should not return an error", func() {
- c := mockContainerWithPortBindings()
+ c := MockContainer(WithPortBindings())
err := c.VerifyConfiguration()
Expect(err).ToNot(HaveOccurred())
})
})
When("verifying a container with port bindings, but no exposed ports", func() {
It("should make the config compatible with updating", func() {
- c := mockContainerWithPortBindings("80/tcp")
+ c := MockContainer(WithPortBindings("80/tcp"))
c.containerInfo.Config.ExposedPorts = nil
Expect(c.VerifyConfiguration()).To(Succeed())
@@ -61,20 +61,107 @@ var _ = Describe("the container", func() {
})
When("verifying a container with port bindings and exposed ports is non-nil", func() {
It("should return an error", func() {
- c := mockContainerWithPortBindings("80/tcp")
+ c := MockContainer(WithPortBindings("80/tcp"))
c.containerInfo.Config.ExposedPorts = map[nat.Port]struct{}{"80/tcp": {}}
err := c.VerifyConfiguration()
Expect(err).ToNot(HaveOccurred())
})
})
})
+ 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()
@@ -91,36 +178,28 @@ var _ = Describe("the container", func() {
enabled, exists := c.Enabled()
Expect(enabled).To(BeTrue())
- Expect(enabled).NotTo(BeFalse())
Expect(exists).To(BeTrue())
- Expect(exists).NotTo(BeFalse())
})
It("should return false, true if present but not true on calls to .Enabled()", func() {
- c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "false"})
+ c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "false"}))
enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse())
- Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeTrue())
- Expect(exists).NotTo(BeFalse())
})
It("should return false, false if not present on calls to .Enabled()", func() {
- c = mockContainerWithLabels(map[string]string{"lol": "false"})
+ c = MockContainer(WithLabels(map[string]string{"lol": "false"}))
enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse())
- Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeFalse())
- Expect(exists).NotTo(BeTrue())
})
It("should return false, false if present but not parsable .Enabled()", func() {
- c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "falsy"})
+ c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "falsy"}))
enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse())
- Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeFalse())
- Expect(exists).NotTo(BeTrue())
})
When("checking if its a watchtower instance", func() {
It("should return true if the label is set to true", func() {
@@ -128,31 +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(""))
})
@@ -160,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"))
})
@@ -184,113 +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 = mockContainerWithLabels(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))
- })
- })
-
+ 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 mockContainerWithPortBindings(portBindingSources ...string) *Container {
- mockContainer := mockContainerWithLabels(nil)
- mockContainer.imageInfo = &types.ImageInspect{}
- hostConfig := &container.HostConfig{
- PortBindings: nat.PortMap{},
- }
- for _, pbs := range portBindingSources {
- hostConfig.PortBindings[nat.Port(pbs)] = []nat.PortBinding{}
- }
- mockContainer.containerInfo.HostConfig = hostConfig
- return mockContainer
-}
-
-func mockContainerWithImageName(name string) *Container {
- mockContainer := mockContainerWithLabels(nil)
- mockContainer.containerInfo.Config.Image = name
- return mockContainer
-}
-
-func mockContainerWithLinks(links []string) *Container {
- content := types.ContainerJSON{
- ContainerJSONBase: &types.ContainerJSONBase{
- ID: "container_id",
- Image: "image",
- Name: "test-containrrr",
- HostConfig: &container.HostConfig{
- Links: links,
- },
- },
- Config: &container.Config{
- Labels: map[string]string{},
- },
- }
- return NewContainer(&content, nil)
-}
-
-func mockContainerWithLabels(labels map[string]string) *Container {
- content := types.ContainerJSON{
- ContainerJSONBase: &types.ContainerJSONBase{
- ID: "container_id",
- Image: "image",
- Name: "test-containrrr",
- },
- Config: &container.Config{
- Labels: labels,
- },
- }
- return NewContainer(&content, nil)
-}
-
-func newClientNoAPI(pullImages, includeStopped, reviveStopped, removeVolumes, includeRestarting bool, warnOnHeadFailed string) Client {
- return dockerClient{
- api: nil,
- pullImages: pullImages,
- removeVolumes: removeVolumes,
- includeStopped: includeStopped,
- reviveStopped: reviveStopped,
- includeRestarting: includeRestarting,
- warnOnHeadFailed: warnOnHeadFailed,
- }
-}
diff --git a/pkg/container/errors.go b/pkg/container/errors.go
index 1937430..05dc722 100644
--- a/pkg/container/errors.go
+++ b/pkg/container/errors.go
@@ -4,5 +4,5 @@ import "errors"
var errorNoImageInfo = errors.New("no available image info")
var errorNoContainerInfo = errors.New("no available container info")
-var errorNoExposedPorts = errors.New("exposed ports does not match port bindings")
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 ee9fddf..8ac5f34 100644
--- a/pkg/container/metadata.go
+++ b/pkg/container/metadata.go
@@ -1,18 +1,21 @@
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"
)
@@ -54,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 20610cd..84756f0 100644
--- a/pkg/container/mocks/ApiServer.go
+++ b/pkg/container/mocks/ApiServer.go
@@ -3,22 +3,26 @@ package mocks
import (
"encoding/json"
"fmt"
+ "github.com/onsi/ginkgo"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+
+ t "github.com/containrrr/watchtower/pkg/types"
+
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
O "github.com/onsi/gomega"
"github.com/onsi/gomega/ghttp"
- "io/ioutil"
- "net/http"
- "net/url"
- "path/filepath"
)
func getMockJSONFile(relPath string) ([]byte, error) {
absPath, _ := filepath.Abs(relPath)
- buf, err := ioutil.ReadFile(absPath)
+ buf, err := os.ReadFile(absPath)
if err != nil {
- // logrus.WithError(err).WithField("file", absPath).Error(err)
- return nil, err
+ return nil, fmt.Errorf("mock JSON file %q not found: %e", absPath, err)
}
return buf, nil
}
@@ -39,19 +43,22 @@ func respondWithJSONFile(relPath string, statusCode int, optionalHeader ...http.
}
// GetContainerHandlers returns the handlers serving lookups for the supplied container mock files
-func GetContainerHandlers(containerFiles ...string) []http.HandlerFunc {
- handlers := make([]http.HandlerFunc, 0, len(containerFiles)*2)
- for _, file := range containerFiles {
- handlers = append(handlers, getContainerHandler(file))
+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
- if file == "running" {
- // The "running" container is the only one using image02
- handlers = append(handlers, getImageHandler(1))
- } else {
- handlers = append(handlers, getImageHandler(0))
- }
+ handlers = append(handlers, getImageHandler(containerRef.image.id,
+ RespondWithJSONFile(containerRef.image.getFileName(), http.StatusOK),
+ ))
}
+
return handlers
}
@@ -63,34 +70,120 @@ func createFilterArgs(statuses []string) filters.Args {
return args
}
-var containerFileIds = map[string]string{
- "stopped": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65",
- "watchtower": "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134",
- "running": "b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008",
- "restarting": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67",
+var defaultImage = imageRef{
+ // watchtower
+ id: t.ImageID("sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa"),
+ file: "default",
}
-var imageIds = []string{
- "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa",
- "sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd",
+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,
}
-func getContainerHandler(file string) http.HandlerFunc {
- id, ok := containerFileIds[file]
- failTestUnless(ok)
- return ghttp.CombineHandlers(
- ghttp.VerifyRequest("GET", O.HaveSuffix("/containers/%v/json", id)),
- RespondWithJSONFile(fmt.Sprintf("./mocks/data/container_%v.json", file), http.StatusOK),
+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{
- "limit": []string{"0"},
"filters": []string{string(bytes)},
}
return ghttp.CombineHandlers(
@@ -116,13 +209,72 @@ func respondWithFilteredContainers(filters filters.Args) http.HandlerFunc {
return ghttp.RespondWithJSONEncoded(http.StatusOK, filteredContainers)
}
-func getImageHandler(index int) http.HandlerFunc {
+func getImageHandler(imageId t.ImageID, responseHandler http.HandlerFunc) http.HandlerFunc {
return ghttp.CombineHandlers(
- ghttp.VerifyRequest("GET", O.HaveSuffix("/images/%v/json", imageIds[index])),
- RespondWithJSONFile(fmt.Sprintf("./mocks/data/image%02d.json", index+1), http.StatusOK),
+ ghttp.VerifyRequest("GET", O.HaveSuffix("/images/%s/json", imageId)),
+ responseHandler,
)
}
-func failTestUnless(ok bool) {
- O.ExpectWithOffset(2, ok).To(O.BeTrue(), "test setup failed")
+// KillContainerHandler mocks the POST containers/{id}/kill endpoint
+func KillContainerHandler(containerID string, found FoundStatus) http.HandlerFunc {
+ responseHandler := noContentStatusResponse
+ if !found {
+ responseHandler = containerNotFoundResponse(containerID)
+ }
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("POST", O.HaveSuffix("containers/%s/kill", containerID)),
+ responseHandler,
+ )
+}
+
+// RemoveContainerHandler mocks the DELETE containers/{id} endpoint
+func RemoveContainerHandler(containerID string, found FoundStatus) http.HandlerFunc {
+ responseHandler := noContentStatusResponse
+ if !found {
+ responseHandler = containerNotFoundResponse(containerID)
+ }
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("DELETE", O.HaveSuffix("containers/%s", containerID)),
+ responseHandler,
+ )
+}
+
+func containerNotFoundResponse(containerID string) http.HandlerFunc {
+ return ghttp.RespondWithJSONEncoded(http.StatusNotFound, struct{ message string }{message: "No such container: " + 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/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/filters/filters.go b/pkg/filters/filters.go
index 18f39c2..4fa0bcd 100644
--- a/pkg/filters/filters.go
+++ b/pkg/filters/filters.go
@@ -1,8 +1,10 @@
package filters
import (
- t "github.com/containrrr/watchtower/pkg/types"
+ "regexp"
"strings"
+
+ t "github.com/containrrr/watchtower/pkg/types"
)
// WatchtowerContainersFilter filters only watchtower containers
@@ -11,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
@@ -19,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 {
@@ -56,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)
}
@@ -70,14 +101,33 @@ 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, string) {
+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("with name \"")
+ sb.WriteString("which name matches \"")
for i, n := range names {
sb.WriteString(n)
if i < len(names)-1 {
@@ -86,6 +136,16 @@ func BuildFilter(names []string, enableLabel bool, scope string) (t.Filter, stri
}
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
@@ -93,7 +153,13 @@ func BuildFilter(names []string, enableLabel bool, scope string) (t.Filter, stri
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)
diff --git a/pkg/filters/filters_test.go b/pkg/filters/filters_test.go
index 3b52b5e..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,12 +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, desc := 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")
@@ -151,7 +257,7 @@ func TestBuildFilterEnableLabel(t *testing.T) {
var names []string
names = append(names, "test")
- filter, desc := BuildFilter(names, true, "")
+ filter, desc := BuildFilter(names, []string{}, true, "")
assert.Contains(t, desc, "using enable label")
container := new(mocks.FilterableContainer)
@@ -176,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 ed4ac20..c0f962e 100644
--- a/pkg/lifecycle/lifecycle.go
+++ b/pkg/lifecycle/lifecycle.go
@@ -29,7 +29,7 @@ func ExecutePostChecks(client container.Client, params types.UpdateParams) {
}
// 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 {
@@ -45,7 +45,7 @@ func ExecutePreCheckCommand(client container.Client, container container.Contain
}
// 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 {
@@ -61,7 +61,7 @@ func ExecutePostCheckCommand(client container.Client, container container.Contai
}
// ExecutePreUpdateCommand tries to run the pre-update lifecycle hook for a single container.
-func ExecutePreUpdateCommand(client container.Client, container container.Container) (SkipUpdate bool, err 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())
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 e162209..9103d38 100644
--- a/pkg/notifications/email.go
+++ b/pkg/notifications/email.go
@@ -15,18 +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
}
-func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
- flags := c.PersistentFlags()
+func newEmailNotifier(c *cobra.Command) t.ConvertibleNotifier {
+ flags := c.Flags()
from, _ := flags.GetString("notification-email-from")
to, _ := flags.GetString("notification-email-to")
@@ -36,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{},
@@ -47,28 +44,26 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert
Password: password,
Port: port,
tlsSkipVerify: tlsSkipVerify,
- logLevels: acceptedLogLevels,
delay: time.Duration(delay) * time.Second,
- SubjectTag: subjecttag,
}
return n
}
-func (e *emailTypeNotifier) GetURL(c *cobra.Command, title string) (string, error) {
+func (e *emailTypeNotifier) GetURL(c *cobra.Command) (string, error) {
conf := &shoutrrrSmtp.Config{
FromAddress: e.From,
FromName: "Watchtower",
ToAddresses: []string{e.To},
Port: uint16(e.Port),
Host: e.Server,
- Subject: e.getSubject(c, title),
Username: e.User,
Password: e.Password,
UseStartTLS: !e.tlsSkipVerify,
UseHTML: false,
Encryption: shoutrrrSmtp.EncMethods.Auto,
Auth: shoutrrrSmtp.AuthTypes.None,
+ ClientHost: "localhost",
}
if len(e.User) > 0 {
@@ -85,11 +80,3 @@ func (e *emailTypeNotifier) GetURL(c *cobra.Command, title string) (string, erro
func (e *emailTypeNotifier) GetDelay() time.Duration {
return e.delay
}
-
-func (e *emailTypeNotifier) getSubject(_ *cobra.Command, title string) string {
- if e.SubjectTag != "" {
- return e.SubjectTag + " " + title
- }
-
- return title
-}
diff --git a/pkg/notifications/gotify.go b/pkg/notifications/gotify.go
index a8c9ac4..c36eb4b 100644
--- a/pkg/notifications/gotify.go
+++ b/pkg/notifications/gotify.go
@@ -19,11 +19,10 @@ type gotifyTypeNotifier struct {
gotifyURL string
gotifyAppToken string
gotifyInsecureSkipVerify bool
- logLevels []log.Level
}
-func newGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertibleNotifier {
- flags := c.PersistentFlags()
+func newGotifyNotifier(c *cobra.Command) t.ConvertibleNotifier {
+ flags := c.Flags()
apiURL := getGotifyURL(flags)
token := getGotifyToken(flags)
@@ -34,7 +33,6 @@ func newGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertibleNotifi
gotifyURL: apiURL,
gotifyAppToken: token,
gotifyInsecureSkipVerify: skipVerify,
- logLevels: levels,
}
return n
@@ -62,7 +60,7 @@ func getGotifyURL(flags *pflag.FlagSet) string {
return gotifyURL
}
-func (n *gotifyTypeNotifier) GetURL(c *cobra.Command, title string) (string, error) {
+func (n *gotifyTypeNotifier) GetURL(c *cobra.Command) (string, error) {
apiURL, err := url.Parse(n.gotifyURL)
if err != nil {
return "", err
@@ -72,7 +70,6 @@ func (n *gotifyTypeNotifier) GetURL(c *cobra.Command, title string) (string, err
Host: apiURL.Host,
Path: apiURL.Path,
DisableTLS: apiURL.Scheme == "http",
- Title: title,
Token: n.gotifyAppToken,
}
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 be67d3b..cfca30e 100644
--- a/pkg/notifications/msteams.go
+++ b/pkg/notifications/msteams.go
@@ -15,13 +15,12 @@ const (
type msTeamsTypeNotifier struct {
webHookURL string
- levels []log.Level
data bool
}
-func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
+func newMsTeamsNotifier(cmd *cobra.Command) t.ConvertibleNotifier {
- flags := cmd.PersistentFlags()
+ flags := cmd.Flags()
webHookURL, _ := flags.GetString("notification-msteams-hook")
if len(webHookURL) <= 0 {
@@ -30,7 +29,6 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Con
withData, _ := flags.GetBool("notification-msteams-data")
n := &msTeamsTypeNotifier{
- levels: acceptedLogLevels,
webHookURL: webHookURL,
data: withData,
}
@@ -38,7 +36,7 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Con
return n
}
-func (n *msTeamsTypeNotifier) GetURL(c *cobra.Command, title string) (string, error) {
+func (n *msTeamsTypeNotifier) GetURL(c *cobra.Command) (string, error) {
webhookURL, err := url.Parse(n.webHookURL)
if err != nil {
return "", err
@@ -50,7 +48,6 @@ func (n *msTeamsTypeNotifier) GetURL(c *cobra.Command, title string) (string, er
}
config.Color = ColorHex
- config.Title = title
return config.GetURL().String(), nil
}
diff --git a/pkg/notifications/notifier.go b/pkg/notifications/notifier.go
index 61861fb..ff7b6b5 100644
--- a/pkg/notifications/notifier.go
+++ b/pkg/notifications/notifier.go
@@ -2,17 +2,17 @@ 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"
)
// NewNotifier creates and returns a new Notifier, using global configuration.
func NewNotifier(c *cobra.Command) ty.Notifier {
- f := c.PersistentFlags()
+ f := c.Flags()
level, _ := f.GetString("notifications-level")
logLevel, err := log.ParseLevel(level)
@@ -20,24 +20,19 @@ func NewNotifier(c *cobra.Command) ty.Notifier {
log.Fatalf("Notifications invalid log level: %s", err.Error())
}
- acceptedLogLevels := slackrus.LevelThreshold(logLevel)
- // slackrus does not allow log level TRACE, even though it's an accepted log level for logrus
- if len(acceptedLogLevels) == 0 {
- log.Fatalf("Unsupported notification log level provided: %s", level)
- }
-
reportTemplate, _ := f.GetBool("notification-report")
+ stdout, _ := f.GetBool("notification-log-stdout")
tplString, _ := f.GetString("notification-template")
urls, _ := f.GetStringArray("notification-url")
- hostname := GetHostname(c)
- urls, delay := AppendLegacyUrls(urls, c, GetTitle(hostname))
+ data := GetTemplateData(c)
+ urls, delay := AppendLegacyUrls(urls, c)
- return newShoutrrrNotifier(tplString, acceptedLogLevels, !reportTemplate, hostname, delay, urls...)
+ return createNotifier(urls, logLevel, tplString, !reportTemplate, data, stdout, delay)
}
// AppendLegacyUrls creates shoutrrr equivalent URLs from legacy notification flags
-func AppendLegacyUrls(urls []string, cmd *cobra.Command, title string) ([]string, time.Duration) {
+func AppendLegacyUrls(urls []string, cmd *cobra.Command) ([]string, time.Duration) {
// Parse types and create notifiers.
types, err := cmd.Flags().GetStringSlice("notifications")
@@ -45,7 +40,7 @@ func AppendLegacyUrls(urls []string, cmd *cobra.Command, title string) ([]string
log.WithError(err).Fatal("could not read notifications argument")
}
- delay := time.Duration(0)
+ legacyDelay := time.Duration(0)
for _, t := range types {
@@ -54,13 +49,13 @@ func AppendLegacyUrls(urls []string, cmd *cobra.Command, title string) ([]string
switch t {
case emailType:
- legacyNotifier = newEmailNotifier(cmd, []log.Level{})
+ legacyNotifier = newEmailNotifier(cmd)
case slackType:
- legacyNotifier = newSlackNotifier(cmd, []log.Level{})
+ legacyNotifier = newSlackNotifier(cmd)
case msTeamsType:
- legacyNotifier = newMsTeamsNotifier(cmd, []log.Level{})
+ legacyNotifier = newMsTeamsNotifier(cmd)
case gotifyType:
- legacyNotifier = newGotifyNotifier(cmd, []log.Level{})
+ legacyNotifier = newGotifyNotifier(cmd)
case shoutrrrType:
continue
default:
@@ -69,43 +64,80 @@ func AppendLegacyUrls(urls []string, cmd *cobra.Command, title string) ([]string
continue
}
- shoutrrrURL, err := legacyNotifier.GetURL(cmd, title)
+ shoutrrrURL, err := legacyNotifier.GetURL(cmd)
if err != nil {
log.Fatal("failed to create notification config: ", err)
}
urls = append(urls, shoutrrrURL)
if delayNotifier, ok := legacyNotifier.(ty.DelayNotifier); ok {
- delay = delayNotifier.GetDelay()
+ legacyDelay = delayNotifier.GetDelay()
}
log.WithField("URL", shoutrrrURL).Trace("created Shoutrrr URL from legacy notifier")
}
+
+ delay := GetDelay(cmd, legacyDelay)
return urls, delay
}
-// GetTitle returns a common notification title with hostname appended
-func GetTitle(hostname string) string {
- title := "Watchtower updates"
- if hostname != "" {
- title += " on " + hostname
+// 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
}
- return title
+
+ delay, _ := c.PersistentFlags().GetInt("notifications-delay")
+ if delay > 0 {
+ return time.Duration(delay) * time.Second
+ }
+ return time.Duration(0)
}
-// GetHostname returns the hostname as set by args or resolved from OS
-func GetHostname(c *cobra.Command) string {
+// GetTitle formats the title based on the passed hostname and tag
+func GetTitle(hostname string, tag string) string {
+ tb := strings.Builder{}
- f := c.PersistentFlags()
- hostname, _ := f.GetString("notifications-hostname")
-
- if hostname != "" {
- return hostname
- } else if hostname, err := os.Hostname(); err == nil {
- return hostname
+ if tag != "" {
+ tb.WriteRune('[')
+ tb.WriteString(tag)
+ tb.WriteRune(']')
+ tb.WriteRune(' ')
}
- return ""
+ 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,
+ }
}
// ColorHex is the default notification color used for services that support it (formatted as a CSS hex string)
diff --git a/pkg/notifications/notifier_test.go b/pkg/notifications/notifier_test.go
index 44b4dad..96d513c 100644
--- a/pkg/notifications/notifier_test.go
+++ b/pkg/notifications/notifier_test.go
@@ -3,7 +3,7 @@ package notifications_test
import (
"fmt"
"net/url"
- "os"
+ "time"
"github.com/containrrr/watchtower/cmd"
"github.com/containrrr/watchtower/internal/flags"
@@ -38,17 +38,103 @@ var _ = Describe("notifications", func() {
"test.host",
})
Expect(err).NotTo(HaveOccurred())
- hostname := notifications.GetHostname(command)
- title := notifications.GetTitle(hostname)
+ 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("")
+ 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
@@ -60,9 +146,9 @@ var _ = Describe("notifications", func() {
channel := "123456789"
token := "abvsihdbau"
color := notifications.ColorInt
- hostname := notifications.GetHostname(command)
- title := url.QueryEscape(notifications.GetTitle(hostname))
- expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&title=%s&username=watchtower", token, channel, color, title)
+ 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",
@@ -74,11 +160,32 @@ 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(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(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() {
@@ -89,8 +196,6 @@ var _ = Describe("notifications", func() {
tokenB := "BBBBBBBBB"
tokenC := "123456789123456789123456"
color := url.QueryEscape(notifications.ColorHex)
- hostname := notifications.GetHostname(command)
- title := url.QueryEscape(notifications.GetTitle(hostname))
iconURL := "https://containrrr.dev/watchtower-sq180.png"
iconEmoji := "whale"
@@ -98,7 +203,8 @@ var _ = Describe("notifications", func() {
It("should return the expected URL", func() {
hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
- expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s&title=%s", tokenA, tokenB, tokenC, username, color, url.QueryEscape(iconURL), title)
+ expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s", tokenA, tokenB, tokenC, username, color, url.QueryEscape(iconURL))
+ expectedDelay := time.Duration(7) * time.Second
args := []string{
"--notifications",
@@ -109,16 +215,18 @@ var _ = Describe("notifications", func() {
username,
"--notification-slack-icon-url",
iconURL,
+ "--notifications-delay",
+ fmt.Sprint(expectedDelay.Seconds()),
}
- testURL(args, expectedOutput)
+ testURL(args, expectedOutput, expectedDelay)
})
})
When("icon emoji is specified", func() {
It("should return the expected URL", func() {
hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
- expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s&title=%s", tokenA, tokenB, tokenC, username, color, iconEmoji, title)
+ expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s", tokenA, tokenB, tokenC, username, color, iconEmoji)
args := []string{
"--notifications",
@@ -131,7 +239,7 @@ var _ = Describe("notifications", func() {
iconEmoji,
}
- testURL(args, expectedOutput)
+ testURL(args, expectedOutput, time.Duration(0))
})
})
})
@@ -145,10 +253,8 @@ var _ = Describe("notifications", func() {
token := "aaa"
host := "shoutrrr.local"
- hostname := notifications.GetHostname(command)
- title := url.QueryEscape(notifications.GetTitle(hostname))
- expectedOutput := fmt.Sprintf("gotify://%s/%s?title=%s", host, token, title)
+ expectedOutput := fmt.Sprintf("gotify://%s/%s?title=", host, token)
args := []string{
"--notifications",
@@ -159,7 +265,7 @@ var _ = Describe("notifications", func() {
token,
}
- testURL(args, expectedOutput)
+ testURL(args, expectedOutput, time.Duration(0))
})
})
})
@@ -174,11 +280,9 @@ var _ = Describe("notifications", func() {
tokenB := "33333333012222222222333333333344"
tokenC := "44444444-4444-4444-8444-cccccccccccc"
color := url.QueryEscape(notifications.ColorHex)
- hostname := notifications.GetHostname(command)
- title := url.QueryEscape(notifications.GetTitle(hostname))
hookURL := fmt.Sprintf("https://outlook.office.com/webhook/%s/IncomingWebhook/%s/%s", tokenA, tokenB, tokenC)
- expectedOutput := fmt.Sprintf("teams://%s/%s/%s?color=%s&title=%s", tokenA, tokenB, tokenC, color, title)
+ expectedOutput := fmt.Sprintf("teams://%s/%s/%s?color=%s", tokenA, tokenB, tokenC, color)
args := []string{
"--notifications",
@@ -187,7 +291,7 @@ var _ = Describe("notifications", func() {
hookURL,
}
- testURL(args, expectedOutput)
+ testURL(args, expectedOutput, time.Duration(0))
})
})
})
@@ -197,6 +301,8 @@ var _ = Describe("notifications", func() {
It("should set the from address in the URL", func() {
fromAddress := "lala@example.com"
expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, "mail@example.com", "Plain")
+ expectedDelay := time.Duration(7) * time.Second
+
args := []string{
"--notifications",
"email",
@@ -210,8 +316,10 @@ var _ = Describe("notifications", func() {
"secret-password",
"--notification-email-server",
"mail.containrrr.dev",
+ "--notifications-delay",
+ fmt.Sprint(expectedDelay.Seconds()),
}
- testURL(args, expectedOutput)
+ testURL(args, expectedOutput, expectedDelay)
})
It("should return the expected URL", func() {
@@ -219,6 +327,7 @@ var _ = Describe("notifications", func() {
fromAddress := "sender@example.com"
toAddress := "receiver@example.com"
expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, toAddress, "Plain")
+ expectedDelay := time.Duration(7) * time.Second
args := []string{
"--notifications",
@@ -233,44 +342,36 @@ var _ = Describe("notifications", func() {
"secret-password",
"--notification-email-server",
"mail.containrrr.dev",
+ "--notification-email-delay",
+ fmt.Sprint(expectedDelay.Seconds()),
}
- testURL(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&fromaddress=%s&fromname=Watchtower&subject=%s&toaddresses=%s"
+ var template = "smtp://%s:%s@%s:%d/?auth=%s&fromaddress=%s&fromname=Watchtower&subject=&toaddresses=%s"
return fmt.Sprintf(template,
url.QueryEscape(username),
url.QueryEscape(password),
host, port, auth,
url.QueryEscape(from),
- url.QueryEscape(subject),
url.QueryEscape(to))
}
-func testURL(args []string, expectedURL string) {
+func testURL(args []string, expectedURL string, expectedDelay time.Duration) {
defer GinkgoRecover()
command := cmd.NewRootCommand()
flags.RegisterNotificationFlags(command)
- err := command.ParseFlags(args)
- Expect(err).NotTo(HaveOccurred())
+ Expect(command.ParseFlags(args)).To(Succeed())
- hostname := notifications.GetHostname(command)
- title := notifications.GetTitle(hostname)
- urls, _ := notifications.AppendLegacyUrls([]string{}, command, title)
-
- Expect(err).NotTo(HaveOccurred())
+ 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 bc9499e..cc3a931 100644
--- a/pkg/notifications/shoutrrr.go
+++ b/pkg/notifications/shoutrrr.go
@@ -3,12 +3,14 @@ package notifications
import (
"bytes"
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"
)
@@ -17,29 +19,6 @@ import (
var LocalLog = log.WithField("notify", "no")
const (
- shoutrrrDefaultLegacyTemplate = "{{range .}}{{.Message}}{{println}}{{end}}"
- shoutrrrDefaultTemplate = `
-{{- 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 -}}`
shoutrrrType = "shoutrrr"
)
@@ -52,13 +31,15 @@ type shoutrrrTypeNotifier struct {
Urls []string
Router router
entries []*log.Entry
- logLevels []log.Level
+ logLevel log.Level
template *template.Template
messages chan string
done chan bool
legacyTemplate bool
params *types.Params
- hostname string
+ data StaticData
+ receiving bool
+ delay time.Duration
}
// GetScheme returns the scheme part of a Shoutrrr URL
@@ -79,45 +60,62 @@ func (n *shoutrrrTypeNotifier) GetNames() []string {
return names
}
-func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy bool, hostname string, delay time.Duration, urls ...string) t.Notifier {
-
- notifier := createNotifier(urls, acceptedLogLevels, tplString, legacy)
- notifier.hostname = hostname
- notifier.params = &types.Params{"title": GetTitle(hostname)}
- log.AddHook(notifier)
-
- // Do the sending in a separate goroutine so we don't block the main process.
- go sendNotifications(notifier, delay)
-
- return notifier
+// GetURLs returns a list of URLs for notification services that has been added
+func (n *shoutrrrTypeNotifier) GetURLs() []string {
+ return n.Urls
}
-func createNotifier(urls []string, levels []log.Level, tplString string, legacy bool) *shoutrrrTypeNotifier {
+// AddLogHook adds the notifier as a receiver of log messages and starts a go func for processing them
+func (n *shoutrrrTypeNotifier) AddLogHook() {
+ if n.receiving {
+ return
+ }
+ n.receiving = true
+ log.AddHook(n)
+
+ // Do the sending in a separate goroutine, so we don't block the main process.
+ go sendNotifications(n)
+}
+
+func createNotifier(urls []string, level log.Level, tplString string, legacy bool, data StaticData, stdout bool, delay time.Duration) *shoutrrrTypeNotifier {
tpl, err := getShoutrrrTemplate(tplString, legacy)
if err != nil {
log.Errorf("Could not use configured notification template: %s. Using default template", err)
}
- traceWriter := log.StandardLogger().WriterLevel(log.TraceLevel)
- r, err := shoutrrr.NewSender(stdlog.New(traceWriter, "Shoutrrr: ", 0), urls...)
+ 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())
}
+ params := &types.Params{}
+ if data.Title != "" {
+ params.SetTitle(data.Title)
+ }
+
return &shoutrrrTypeNotifier{
Urls: urls,
Router: r,
messages: make(chan string, 1),
done: make(chan bool),
- logLevels: levels,
+ logLevel: level,
template: tpl,
legacyTemplate: legacy,
+ data: data,
+ params: params,
+ delay: delay,
}
}
-func sendNotifications(n *shoutrrrTypeNotifier, delay time.Duration) {
+func sendNotifications(n *shoutrrrTypeNotifier) {
for msg := range n.messages {
- time.Sleep(delay)
+ time.Sleep(n.delay)
errs := n.Router.Send(msg, n.params)
for i, err := range errs {
@@ -149,9 +147,7 @@ func (n *shoutrrrTypeNotifier) buildMessage(data Data) (string, error) {
}
func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry, report t.Report) {
- title, _ := n.params.Title()
- host := n.hostname
- msg, err := n.buildMessage(Data{entries, report, title, host})
+ msg, err := n.buildMessage(Data{n.data, entries, report})
if msg == "" {
// Log in go func in case we entered from Fire to avoid stalling
@@ -187,12 +183,12 @@ func (n *shoutrrrTypeNotifier) Close() {
// Use fmt so it doesn't trigger another notification.
LocalLog.Info("Waiting for the notification goroutine to finish")
- _ = <-n.done
+ <-n.done
}
// Levels return what log levels trigger notifications
func (n *shoutrrrTypeNotifier) Levels() []log.Level {
- return n.logLevels
+ return log.AllLevels[:n.logLevel+1]
}
// Fire is the hook that logrus calls on a new log message
@@ -211,12 +207,13 @@ func (n *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
}
func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template, err error) {
- funcs := template.FuncMap{
- "ToUpper": strings.ToUpper,
- "ToLower": strings.ToLower,
- "Title": strings.Title,
+
+ tplBase := template.New("").Funcs(templates.Funcs)
+
+ if builtin, found := commonTemplates[tplString]; found {
+ log.WithField(`template`, tplString).Debug(`Using common template`)
+ tplString = builtin
}
- tplBase := template.New("").Funcs(funcs)
// If we succeed in getting a non-empty template configuration
// try to parse the template string.
@@ -225,25 +222,17 @@ func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template,
}
// 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 == "" {
- defaultTemplate := shoutrrrDefaultTemplate
+ defaultKey := `default`
if legacy {
- defaultTemplate = shoutrrrDefaultLegacyTemplate
+ defaultKey = `default-legacy`
}
- tpl = template.Must(tplBase.Parse(defaultTemplate))
+ tpl = template.Must(tplBase.Parse(commonTemplates[defaultKey]))
}
return
}
-
-// Data is the notification template data model
-type Data struct {
- Entries []*log.Entry
- Report t.Report
- Title string
- Host string
-}
diff --git a/pkg/notifications/shoutrrr_test.go b/pkg/notifications/shoutrrr_test.go
index dbdd9eb..703958b 100644
--- a/pkg/notifications/shoutrrr_test.go
+++ b/pkg/notifications/shoutrrr_test.go
@@ -14,7 +14,7 @@ import (
"github.com/spf13/cobra"
)
-var allButTrace = logrus.AllLevels[0:logrus.TraceLevel]
+var allButTrace = logrus.DebugLevel
var legacyMockData = Data{
Entries: []*logrus.Entry{
@@ -49,11 +49,14 @@ var mockDataAllFresh = Data{
func mockDataFromStates(states ...s.State) Data {
hostname := "Mock"
+ prefix := ""
return Data{
Entries: legacyMockData.Entries,
Report: mocks.CreateMockProgressReport(states...),
- Title: GetTitle(hostname),
- Host: hostname,
+ StaticData: StaticData{
+ Title: GetTitle(hostname, prefix),
+ Host: hostname,
+ },
}
}
@@ -70,6 +73,40 @@ var _ = Describe("Shoutrrr", func() {
})
})
+ 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() {
@@ -77,7 +114,7 @@ var _ = Describe("Shoutrrr", func() {
cmd := new(cobra.Command)
flags.RegisterNotificationFlags(cmd)
- shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true)
+ shoutrrr := createNotifier([]string{}, logrus.TraceLevel, "", true, StaticData{}, false, time.Second)
entries := []*logrus.Entry{
{
@@ -165,7 +202,6 @@ var _ = Describe("Shoutrrr", func() {
})
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
@@ -233,7 +269,7 @@ Turns out everything is on fire
When("batching notifications", func() {
When("no messages are queued", func() {
It("should not send any notification", func() {
- shoutrrr := newShoutrrrNotifier("", allButTrace, true, "", time.Duration(0), "logger://")
+ shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{}, false, time.Duration(0))
shoutrrr.StartNotification()
shoutrrr.SendNotification(nil)
Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`))
@@ -241,7 +277,8 @@ Turns out everything is on fire
})
When("at least one message is queued", func() {
It("should send a notification", func() {
- shoutrrr := newShoutrrrNotifier("", allButTrace, true, "", time.Duration(0), "logger://")
+ shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{}, false, time.Duration(0))
+ shoutrrr.AddLogHook()
shoutrrr.StartNotification()
logrus.Info("This log message is sponsored by ContainrrrVPN")
shoutrrr.SendNotification(nil)
@@ -250,6 +287,17 @@ Turns out everything is on fire
})
})
+ 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() {
@@ -276,7 +324,7 @@ type blockingRouter struct {
}
func (b blockingRouter) Send(_ string, _ *types.Params) []error {
- _ = <-b.unlock
+ <-b.unlock
b.sent <- true
return nil
}
@@ -298,13 +346,14 @@ func sendNotificationsWithBlockingRouter(legacy bool) (*shoutrrrTypeNotifier, *b
Router: router,
legacyTemplate: legacy,
params: &types.Params{},
+ delay: time.Duration(0),
}
entry := &logrus.Entry{
Message: "foo bar",
}
- go sendNotifications(shoutrrr, time.Duration(0))
+ go sendNotifications(shoutrrr)
shoutrrr.StartNotification()
_ = shoutrrr.Fire(entry)
diff --git a/pkg/notifications/slack.go b/pkg/notifications/slack.go
index faff944..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,11 +15,15 @@ const (
)
type slackTypeNotifier struct {
- slackrus.SlackrusHook
+ HookURL string
+ Username string
+ Channel string
+ IconEmoji string
+ IconURL string
}
-func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
- flags := c.PersistentFlags()
+func newSlackNotifier(c *cobra.Command) t.ConvertibleNotifier {
+ flags := c.Flags()
hookURL, _ := flags.GetString("notification-slack-hook-url")
userName, _ := flags.GetString("notification-slack-identifier")
@@ -29,21 +32,18 @@ func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert
iconURL, _ := flags.GetString("notification-slack-icon-url")
n := &slackTypeNotifier{
- SlackrusHook: slackrus.SlackrusHook{
- HookURL: hookURL,
- Username: userName,
- Channel: channel,
- IconEmoji: emoji,
- IconURL: iconURL,
- AcceptedLevels: acceptedLogLevels,
- },
+ HookURL: hookURL,
+ Username: userName,
+ Channel: channel,
+ IconEmoji: emoji,
+ IconURL: iconURL,
}
return n
}
-func (s *slackTypeNotifier) GetURL(c *cobra.Command, title string) (string, error) {
+func (s *slackTypeNotifier) GetURL(c *cobra.Command) (string, error) {
trimmedURL := strings.TrimRight(s.HookURL, "/")
- trimmedURL = strings.TrimLeft(trimmedURL, "https://")
+ trimmedURL = strings.TrimPrefix(trimmedURL, "https://")
parts := strings.Split(trimmedURL, "/")
if parts[0] == "discord.com" || parts[0] == "discordapp.com" {
@@ -52,10 +52,14 @@ func (s *slackTypeNotifier) GetURL(c *cobra.Command, title string) (string, erro
WebhookID: parts[len(parts)-3],
Token: parts[len(parts)-2],
Color: ColorInt,
- Title: title,
SplitLines: true,
Username: s.Username,
}
+
+ if s.IconURL != "" {
+ conf.Avatar = s.IconURL
+ }
+
return conf.GetURL().String(), nil
}
@@ -65,7 +69,6 @@ func (s *slackTypeNotifier) GetURL(c *cobra.Command, title string) (string, erro
BotName: s.Username,
Color: ColorHex,
Channel: "webhook",
- Title: title,
}
if s.IconURL != "" {
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/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 26fbd8e..e569599 100644
--- a/pkg/registry/digest/digest.go
+++ b/pkg/registry/digest/digest.go
@@ -6,15 +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"
- "net/http"
- "strings"
- "time"
)
// ContentDigestHeader is the key for the key-value pair containing the digest header
@@ -25,7 +26,7 @@ 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)
@@ -93,16 +94,18 @@ func GetDigest(url string, token string) (string, error) {
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")
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_test.go b/pkg/registry/registry_test.go
index 5f3f57f..481c91d 100644
--- a/pkg/registry/registry_test.go
+++ b/pkg/registry/registry_test.go
@@ -23,11 +23,9 @@ var _ = Describe("Registry", func() {
})
When("Given a container with an image explicitly from dockerhub", func() {
It("should want to warn", func() {
- Expect(testContainerWithImage("registry-1.docker.io/docker:latest")).To(BeTrue())
Expect(testContainerWithImage("index.docker.io/docker:latest")).To(BeTrue())
Expect(testContainerWithImage("docker.io/docker:latest")).To(BeTrue())
})
-
})
When("Given a container with an image from some other registry", func() {
It("should not want to warn", func() {
diff --git a/pkg/registry/trust.go b/pkg/registry/trust.go
index ab9e353..0b20248 100644
--- a/pkg/registry/trust.go
+++ b/pkg/registry/trust.go
@@ -5,13 +5,12 @@ import (
"encoding/json"
"errors"
"os"
- "strings"
+ "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/cli/cli/config/types"
- "github.com/docker/distribution/reference"
log "github.com/sirupsen/logrus"
)
@@ -19,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)
}
@@ -29,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 != "" {
@@ -37,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)
@@ -70,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 {
diff --git a/pkg/registry/trust_test.go b/pkg/registry/trust_test.go
index 3dab6ad..00fc8a7 100644
--- a/pkg/registry/trust_test.go
+++ b/pkg/registry/trust_test.go
@@ -1,65 +1,49 @@
package registry
import (
+ "os"
+
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
- "os"
)
-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")
+var _ = Describe("Registry credential helpers", func() {
+ Describe("EncodedAuth", func() {
+ It("should return repo credentials from env when set", func() {
+ var err error
+ expected := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
- _, err := EncodedEnvAuth("")
- Expect(err).To(HaveOccurred())
+ err = os.Setenv("REPO_USER", "containrrr-user")
+ Expect(err).NotTo(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/report.go b/pkg/session/report.go
index 646a0c0..707eb91 100644
--- a/pkg/session/report.go
+++ b/pkg/session/report.go
@@ -1,8 +1,9 @@
package session
import (
- "github.com/containrrr/watchtower/pkg/types"
"sort"
+
+ "github.com/containrrr/watchtower/pkg/types"
)
type report struct {
@@ -32,6 +33,33 @@ func (r *report) Stale() []types.ContainerReport {
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 {
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 22742e9..8a22f44 100644
--- a/pkg/types/container.go
+++ b/pkg/types/container.go
@@ -1,8 +1,10 @@
package types
import (
- "github.com/docker/docker/api/types"
"strings"
+
+ "github.com/docker/docker/api/types"
+ dc "github.com/docker/docker/api/types/container"
)
// ImageID is a hash string representing a container image
@@ -50,7 +52,7 @@ type Container interface {
SafeImageID() ImageID
ImageName() string
Enabled() (bool, bool)
- IsMonitorOnly() bool
+ IsMonitorOnly(UpdateParams) bool
Scope() (string, bool)
Links() []string
ToRestart() bool
@@ -62,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/convertible_notifier.go b/pkg/types/convertible_notifier.go
index 37d8872..82d7b7b 100644
--- a/pkg/types/convertible_notifier.go
+++ b/pkg/types/convertible_notifier.go
@@ -1,16 +1,17 @@
package types
import (
- "github.com/spf13/cobra"
"time"
+
+ "github.com/spf13/cobra"
)
// ConvertibleNotifier is a notifier capable of creating a shoutrrr URL
type ConvertibleNotifier interface {
- GetURL(c *cobra.Command, title string) (string, error)
+ GetURL(c *cobra.Command) (string, error)
}
// DelayNotifier is a notifier that might need to be delayed before sending notifications
type DelayNotifier interface {
GetDelay() time.Duration
-}
\ No newline at end of file
+}
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 ccb2cb6..478a4c4 100644
--- a/pkg/types/notifier.go
+++ b/pkg/types/notifier.go
@@ -4,6 +4,8 @@ package types
type Notifier interface {
StartNotification()
SendNotification(Report)
+ AddLogHook()
GetNames() []string
+ GetURLs() []string
Close()
}
diff --git a/pkg/types/report.go b/pkg/types/report.go
index 8013b58..f454fc6 100644
--- a/pkg/types/report.go
+++ b/pkg/types/report.go
@@ -8,6 +8,7 @@ type Report interface {
Skipped() []ContainerReport
Stale() []ContainerReport
Fresh() []ContainerReport
+ All() []ContainerReport
}
// ContainerReport represents a container that was included in watchtower session
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/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/docker-util.sh b/scripts/docker-util.sh
index 13a84ca..bd0dbda 100644
--- a/scripts/docker-util.sh
+++ b/scripts/docker-util.sh
@@ -122,4 +122,65 @@ function container-started() {
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
old mode 100644
new mode 100755
index 973dec5..611f720
--- a/scripts/du-cli.sh
+++ b/scripts/du-cli.sh
@@ -16,7 +16,7 @@ case $1 in
registry-host
;;
*)
- echo "Unknown keyword \"$2\""
+ echo "Unknown registry action \"$2\""
;;
esac
;;
@@ -28,8 +28,11 @@ case $1 in
latest)
latest-image-rev "$3"
;;
+ rm)
+ remove-repo-images "$3"
+ ;;
*)
- echo "Unknown keyword \"$2\""
+ echo "Unknown image action \"$2\""
;;
esac
;;
@@ -47,8 +50,26 @@ case $1 in
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 keyword \"$2\""
+ echo "Unknown container action \"$2\""
;;
esac
;;
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
+}