diff --git a/.all-contributorsrc b/.all-contributorsrc
index a088c85..270f462 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -5,6 +5,30 @@
"imageSize": 100,
"commit": false,
"contributors": [
+ {
+ "login": "piksel",
+ "name": "nils mΓ₯sΓ©n",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/807383?v=4",
+ "profile": "https://piksel.se",
+ "contributions": [
+ "code",
+ "doc",
+ "maintenance",
+ "review"
+ ]
+ },
+ {
+ "login": "simskij",
+ "name": "Simon Aronsson",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/1596025?v=4",
+ "profile": "http://simme.dev",
+ "contributions": [
+ "code",
+ "doc",
+ "maintenance",
+ "review"
+ ]
+ },
{
"login": "Codelica",
"name": "James",
@@ -273,18 +297,6 @@
"code"
]
},
- {
- "login": "simskij",
- "name": "Simon Aronsson",
- "avatar_url": "https://avatars0.githubusercontent.com/u/1596025?v=4",
- "profile": "http://simme.dev",
- "contributions": [
- "code",
- "maintenance",
- "review",
- "doc"
- ]
- },
{
"login": "Ansem93",
"name": "Ansem93",
@@ -311,7 +323,8 @@
"profile": "https://github.com/zoispag",
"contributions": [
"code",
- "review"
+ "review",
+ "maintenance"
]
},
{
@@ -458,6 +471,411 @@
"contributions": [
"code"
]
+ },
+ {
+ "login": "auanasgheps",
+ "name": "Oliver Cervera",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/20586878?v=4",
+ "profile": "https://github.com/auanasgheps",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "victorcmoura",
+ "name": "Victor Moura",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/26290053?v=4",
+ "profile": "https://github.com/victorcmoura",
+ "contributions": [
+ "test",
+ "code",
+ "doc"
+ ]
+ },
+ {
+ "login": "mbrandau",
+ "name": "Maximilian Brandau",
+ "avatar_url": "https://avatars3.githubusercontent.com/u/12972798?v=4",
+ "profile": "https://github.com/mbrandau",
+ "contributions": [
+ "code",
+ "test"
+ ]
+ },
+ {
+ "login": "aneisch",
+ "name": "Andrew",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/6991461?v=4",
+ "profile": "https://github.com/aneisch",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "sixcorners",
+ "name": "sixcorners",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/585501?v=4",
+ "profile": "https://github.com/sixcorners",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "arnested",
+ "name": "Arne JΓΈrgensen",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/190005?v=4",
+ "profile": "https://arnested.dk",
+ "contributions": [
+ "test",
+ "review"
+ ]
+ },
+ {
+ "login": "patski123",
+ "name": "PatSki123",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/19295295?v=4",
+ "profile": "https://github.com/patski123",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "Saicheg",
+ "name": "Valentine Zavadsky",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/624999?v=4",
+ "profile": "https://rubyroidlabs.com/",
+ "contributions": [
+ "code",
+ "doc",
+ "test"
+ ]
+ },
+ {
+ "login": "bopoh24",
+ "name": "Alexander Voronin",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/4086631?v=4",
+ "profile": "https://github.com/bopoh24",
+ "contributions": [
+ "code",
+ "bug"
+ ]
+ },
+ {
+ "login": "ogmueller",
+ "name": "Oliver Mueller",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/788989?v=4",
+ "profile": "http://www.teqneers.de",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "tammert",
+ "name": "Sebastiaan Tammer",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/8885250?v=4",
+ "profile": "https://github.com/tammert",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "miosame",
+ "name": "miosame",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/8201077?v=4",
+ "profile": "https://github.com/Miosame",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "andrewjmetzger",
+ "name": "Andrew Metzger",
+ "avatar_url": "https://avatars3.githubusercontent.com/u/590246?v=4",
+ "profile": "https://mtz.gr",
+ "contributions": [
+ "bug",
+ "example"
+ ]
+ },
+ {
+ "login": "pgrimaud",
+ "name": "Pierre Grimaud",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/1866496?v=4",
+ "profile": "https://github.com/pgrimaud",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "mattdoran",
+ "name": "Matt Doran",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/577779?v=4",
+ "profile": "https://github.com/mattdoran",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "MihailITPlace",
+ "name": "MihailITPlace",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/28401551?v=4",
+ "profile": "https://github.com/MihailITPlace",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "bugficks",
+ "name": "bugficks",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/2992895?v=4",
+ "profile": "https://github.com/bugficks",
+ "contributions": [
+ "code",
+ "doc"
+ ]
+ },
+ {
+ "login": "MichaelSp",
+ "name": "Michael",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/448282?v=4",
+ "profile": "https://github.com/MichaelSp",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "jokay",
+ "name": "D. Domig",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/18613935?v=4",
+ "profile": "https://github.com/jokay",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "osheroff",
+ "name": "Ben Osheroff",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/260084?v=4",
+ "profile": "https://maxwells-daemon.io",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "dhet",
+ "name": "David H.",
+ "avatar_url": "https://avatars3.githubusercontent.com/u/2668621?v=4",
+ "profile": "https://github.com/dhet",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "chander",
+ "name": "Chander Ganesan",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/671887?v=4",
+ "profile": "http://www.gridgeo.com",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "yrien30",
+ "name": "yrien30",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/26816162?v=4",
+ "profile": "https://github.com/yrien30",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "ksurl",
+ "name": "ksurl",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/1371562?v=4",
+ "profile": "https://github.com/ksurl",
+ "contributions": [
+ "doc",
+ "code",
+ "infra"
+ ]
+ },
+ {
+ "login": "rg9400",
+ "name": "rg9400",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/39887349?v=4",
+ "profile": "https://github.com/rg9400",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "tkalus",
+ "name": "Turtle Kalus",
+ "avatar_url": "https://avatars2.githubusercontent.com/u/287181?v=4",
+ "profile": "https://github.com/tkalus",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "SrihariThalla",
+ "name": "Srihari Thalla",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/7479937?v=4",
+ "profile": "https://github.com/SrihariThalla",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "nymous",
+ "name": "Thomas Gaudin",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/4216559?v=4",
+ "profile": "https://nymous.io",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "hydrargyrum",
+ "name": "hydrargyrum",
+ "avatar_url": "https://avatars.githubusercontent.com/u/2804645?v=4",
+ "profile": "https://indigo.re/",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "reinout",
+ "name": "Reinout van Rees",
+ "avatar_url": "https://avatars.githubusercontent.com/u/121433?v=4",
+ "profile": "https://reinout.vanrees.org",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "DasSkelett",
+ "name": "DasSkelett",
+ "avatar_url": "https://avatars.githubusercontent.com/u/28812678?v=4",
+ "profile": "https://github.com/DasSkelett",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "zenjabba",
+ "name": "zenjabba",
+ "avatar_url": "https://avatars.githubusercontent.com/u/679864?v=4",
+ "profile": "https://github.com/zenjabba",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "djquan",
+ "name": "Dan Quan",
+ "avatar_url": "https://avatars.githubusercontent.com/u/3526705?v=4",
+ "profile": "https://quan.io",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "modem7",
+ "name": "modem7",
+ "avatar_url": "https://avatars.githubusercontent.com/u/4349962?v=4",
+ "profile": "https://github.com/modem7",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "hypnoglow",
+ "name": "Igor Zibarev",
+ "avatar_url": "https://avatars.githubusercontent.com/u/4853075?v=4",
+ "profile": "https://github.com/hypnoglow",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "patricegautier",
+ "name": "Patrice",
+ "avatar_url": "https://avatars.githubusercontent.com/u/38435239?v=4",
+ "profile": "https://github.com/patricegautier",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "jamesmacwhite",
+ "name": "James White",
+ "avatar_url": "https://avatars.githubusercontent.com/u/8067792?v=4",
+ "profile": "http://jamesw.link/me",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "Foxite",
+ "name": "Dirk Kok",
+ "avatar_url": "https://avatars.githubusercontent.com/u/20421657?v=4",
+ "profile": "https://ko-fi.com/foxite",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "EDIflyer",
+ "name": "EDIflyer",
+ "avatar_url": "https://avatars.githubusercontent.com/u/13610277?v=4",
+ "profile": "https://github.com/EDIflyer",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "jauderho",
+ "name": "Jauder Ho",
+ "avatar_url": "https://avatars.githubusercontent.com/u/13562?v=4",
+ "profile": "https://github.com/jauderho",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "andriibratanin",
+ "name": "Andrii Bratanin",
+ "avatar_url": "https://avatars.githubusercontent.com/u/20169213?v=4",
+ "profile": "https://github.com/andriibratanin"
+ },
+ {
+ "login": "IAmTamal",
+ "name": "Tamal Das ",
+ "avatar_url": "https://avatars.githubusercontent.com/u/72851613?v=4",
+ "profile": "https://tamal.vercel.app/",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "testwill",
+ "name": "guangwu",
+ "avatar_url": "https://avatars.githubusercontent.com/u/8717479?v=4",
+ "profile": "https://github.com/testwill",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "nothub",
+ "name": "Florian HΓΌbner",
+ "avatar_url": "https://avatars.githubusercontent.com/u/48992448?v=4",
+ "profile": "http://hub.lol",
+ "contributions": [
+ "doc",
+ "code"
+ ]
}
],
"contributorsPerLine": 7,
@@ -466,5 +884,6 @@
"repoType": "github",
"repoHost": "https://github.com",
"commitConvention": "none",
- "skipCi": true
+ "skipCi": true,
+ "commitType": "docs"
}
diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 41183b5..0000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,238 +0,0 @@
-version: 2.1
-
-executors:
- py:
- docker:
- - image: circleci/python:latest
- working_directory: ~/repo
- go:
- docker:
- - image: circleci/golang:latest
- working_directory: ~/repo
-
-workflows:
- version: 2
- ci:
- jobs:
- - checkout:
- filters:
- branches:
- only: /.*/
- tags:
- only: /.*/
- - linting:
- requires:
- - checkout
- filters:
- branches:
- only: /.*/
- tags:
- only: /.*/
- - testing:
- requires:
- - checkout
- filters:
- branches:
- only: /.*/
- tags:
- only: /.*/
- - build:
- requires:
- - testing
- - linting
- filters:
- branches:
- only: /.*/
- tags:
- ignore: /^v[0-9]+(\.[0-9]+)*$/
- - publishing:
- requires:
- - testing
- - linting
- filters:
- branches:
- ignore: /.*/
- tags:
- only: /^v[0-9]+(\.[0-9]+)*$/
- - publish-docs:
- requires:
- - testing
- - linting
- filters:
- branches:
- ignore: /.*/
- tags:
- only: /^v[0-9]+(\.[0-9]+)*$/
-jobs:
- checkout:
- executor: go
- steps:
- - checkout
- - persist_to_workspace:
- paths:
- - .
- root: ~/repo
- linting:
- executor: go
- steps:
- - attach_workspace:
- at: .
- - run: go build .
- - run: go get -u golang.org/x/lint/golint
- - run: golint -set_exit_status ./...
- testing:
- executor: go
- steps:
- - attach_workspace:
- at: .
- - run: go build ./...
- - run: go get github.com/schrej/godacov
- - run: go test ./... -coverprofile coverage.out
- - run: godacov -t $CODACY_TOKEN -r ./coverage.out -c $CIRCLE_SHA1
- build:
- executor: go
- steps:
- - attach_workspace:
- at: .
- - setup_remote_docker
- - run:
- name: Install Goreleaser
- command: |
- cd .. && \
- wget https://github.com/goreleaser/goreleaser/releases/download/v0.104.1/goreleaser_Linux_x86_64.tar.gz && \
- tar -xvf goreleaser_Linux_x86_64.tar.gz && \
- ./goreleaser -v
- - run:
- name: Execute goreleaser
- command: CGO_ENABLED=${CGO_ENABLED:-0} ../goreleaser --snapshot --skip-publish --debug
- publishing:
- executor: go
- steps:
- - attach_workspace:
- at: .
- - setup_remote_docker
- - run:
- name: Install Goreleaser
- command: |
- cd .. && \
- wget https://github.com/goreleaser/goreleaser/releases/download/v0.104.1/goreleaser_Linux_x86_64.tar.gz && \
- tar -xvf goreleaser_Linux_x86_64.tar.gz && \
- ./goreleaser -v
- - run:
- name: Login to docker hub
- command: |
- echo "$DOCKER_PASS" | docker login -u $DOCKER_USER --password-stdin
- - run:
- name: Execute goreleaser
- command: CGO_ENABLED=${CGO_ENABLED:-0} ../goreleaser --debug
- - run:
- name: Enable experimental docker features
- command: |
- mkdir -p ~/.docker/ && \
- echo '{"experimental": "enabled"}' > ~/.docker/config.json
- - run:
- name: Create manifest for version
- command: |
- docker manifest create \
- containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:amd64-$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:i386-$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:armhf-$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:arm64v8-$(echo $CIRCLE_TAG | sed 's/^v*//')
- - run:
- name: Annotate i386 version
- command: |
- docker manifest annotate \
- containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:i386-$(echo $CIRCLE_TAG | sed 's/^v*//') \
- --os linux \
- --arch 386
- - run:
- name: Annotate ARM version
- command: |
- docker manifest annotate \
- containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:armhf-$(echo $CIRCLE_TAG | sed 's/^v*//') \
- --os linux \
- --arch arm
- - run:
- name: Annotate ARM64 version
- command: |
- docker manifest annotate \
- containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') \
- containrrr/watchtower:arm64v8-$(echo $CIRCLE_TAG | sed 's/^v*//') \
- --os linux \
- --arch arm64 \
- --variant v8
- - run:
- name: Create manifest for latest
- command: |
- docker manifest create \
- containrrr/watchtower:latest \
- containrrr/watchtower:amd64-latest \
- containrrr/watchtower:i386-latest \
- containrrr/watchtower:armhf-latest \
- containrrr/watchtower:arm64v8-latest
- - run:
- name: Annotate i386 latest
- command: |
- docker manifest annotate \
- containrrr/watchtower:latest \
- containrrr/watchtower:i386-latest \
- --os linux \
- --arch 386
- - run:
- name: Annotate ARM latest
- command: |
- docker manifest annotate \
- containrrr/watchtower:latest \
- containrrr/watchtower:armhf-latest \
- --os linux \
- --arch arm
- - run:
- name: Annotate ARM64 latest
- command: |
- docker manifest annotate \
- containrrr/watchtower:latest \
- containrrr/watchtower:arm64v8-latest \
- --os linux \
- --arch arm64 \
- --variant v8
- - run:
- name: Push manifests to Dockerhub
- command: |
- echo "$DOCKER_PASS" | docker login -u $DOCKER_USER --password-stdin &&
- docker manifest push containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') && \
- docker manifest push containrrr/watchtower:latest
- - run:
- name: Sync Readme to Dockerhub
- command: |
- docker pull lsiodev/readme-sync && \
- docker run --rm=true \
- -e DOCKERHUB_USERNAME=$DOCKER_USER \
- -e DOCKERHUB_PASSWORD="$DOCKER_PASS" \
- -e GIT_REPOSITORY=containrrr/watchtower \
- -e DOCKER_REPOSITORY=containrrr/watchtower \
- -e GIT_BRANCH=master \
- lsiodev/readme-sync bash -c 'node sync'
- publish-docs:
- executor: py
- steps:
- - attach_workspace:
- at: .
- - run:
- name: Install prerequisites
- command: |
- sudo pip install \
- mkdocs \
- mkdocs-material \
- md-toc
- - add_ssh_keys:
- fingerprints:
- - "91:75:47:15:b2:8e:85:e5:67:0e:63:7f:22:d2:b4:6e"
- - run:
- name: Generate and publish
- command: |
- mkdir ~/.ssh && touch ~/.ssh/known_hosts;
- ssh-keyscan -H github.com >> ~/.ssh/known_hosts && \
- mkdocs gh-deploy
diff --git a/.devbots/lock-issue.yml b/.devbots/lock-issue.yml
new file mode 100644
index 0000000..1afd922
--- /dev/null
+++ b/.devbots/lock-issue.yml
@@ -0,0 +1,4 @@
+enabled: true
+comment: >
+ To avoid important communication to get lost in a closed issues no one monitors, I'll go ahead and lock this issue.
+ If you want to continue the discussion, please open a new issue. Thank you! ππΌ
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/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..42fd645
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,8 @@
+pkg/notifications/smtp.go @piksel
+pkg/notifications/email.go @piksel
+pkg/notifications/shoutrrr.go @piksel @simskij @arnested
+pkg/container/* @simskij
+pkg/api/* @victorcmoura
+.devbots/* @simskij
+.github/* @simskij
+docs/* @containrrr/watchtower-contributors
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 76f46f4..0000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-name: Bug report
-about: Create a report to help us improve
-title: ''
-labels: 'Priority: Medium, Status: Available, Type: Bug'
-assignees: ''
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behavior:
-1. Go to '...'
-2. Click on '....'
-3. Scroll down to '....'
-4. See error
-
-**Expected behavior**
-A clear and concise description of what you expected to happen.
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
-
-**Environment**
- - Platform
- - Architecture
- - Docker version
-
-**Logs from running watchtower with the `--debug` option**
-
-**Additional context**
-Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..01cc777
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Ask a question
+ url: https://github.com/containrrr/watchtower/discussions
+ about: Ask questions and discuss with other community members
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/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md
deleted file mode 100644
index bce7228..0000000
--- a/.github/ISSUE_TEMPLATE/question.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-name: Question
-about: Questions regarding usage or specific use cases.
-title: ''
-labels: 'Priority: Medium, Status: Available, Type: Question'
-assignees: ''
-
----
-
-
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..fe53bc9
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,21 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "github-actions" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "gomod" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "docker" # See documentation for possible values
+ directory: "/dockerfiles" # Location of package manifests
+ schedule:
+ interval: "weekly"
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..1c07727
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,17 @@
+
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..c479d05
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,72 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+name: "CodeQL"
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [main]
+ schedule:
+ - cron: '0 1 * * 4'
+ workflow_dispatch:
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ # Override automatic language detection by changing the below list
+ # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
+ language: ['go']
+ # Learn more...
+ # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ # We must fetch at least the immediate parents so that if this is
+ # a pull request then we can checkout the head.
+ fetch-depth: 2
+
+ # If this run was triggered by a pull request event, then checkout
+ # the head of the pull request instead of the merge commit.
+ - run: git checkout HEAD^2
+ if: ${{ github.event_name == 'pull_request' }}
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+ # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v3
+
+ # βΉοΈ Command-line programs to run using the OS shell.
+ # π https://git.io/JvXDl
+
+ # βοΈ If the Autobuild fails above, remove it and uncomment the following three lines
+ # and modify them (or add more) to build your code if your project
+ # uses a compiled language
+
+ #- run: |
+ # make bootstrap
+ # make release
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/dependabot-approve.yml b/.github/workflows/dependabot-approve.yml
new file mode 100644
index 0000000..46f9d18
--- /dev/null
+++ b/.github/workflows/dependabot-approve.yml
@@ -0,0 +1,12 @@
+name: Auto approve dependabot PRs
+
+on: pull_request_target
+
+jobs:
+ auto-approve:
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ if: github.actor == 'dependabot[bot]'
+ steps:
+ - uses: hmarr/auto-approve-action@v3
diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml
index 7034c9e..83ff8a0 100644
--- a/.github/workflows/greetings.yml
+++ b/.github/workflows/greetings.yml
@@ -1,6 +1,11 @@
name: Greetings
-on: [pull_request, issues]
+on:
+ # Runs in the context of the target (containrrr/watchtower) repository, and as such has access to GITHUB_TOKEN
+ pull_request_target:
+ types: [opened]
+ issues:
+ types: [opened]
jobs:
greeting:
@@ -11,7 +16,7 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: >
Hi there! ππΌ
- As you're new to this repo, we'd like to suggest that you read our [code of conduct](https://github.com/containrrr/watchtower/blob/master/CODE_OF_CONDUCT.md)
+ As you're new to this repo, we'd like to suggest that you read our [code of conduct](https://github.com/containrrr/.github/blob/master/CODE_OF_CONDUCT.md)
as well as our [contribution guidelines](https://github.com/containrrr/watchtower/blob/master/CONTRIBUTING.md).
Thanks a bunch for opening your first issue! π
pr-message: >
diff --git a/.github/workflows/publish-dev-dockerimage.yaml b/.github/workflows/publish-dev-dockerimage.yaml
deleted file mode 100644
index c02186a..0000000
--- a/.github/workflows/publish-dev-dockerimage.yaml
+++ /dev/null
@@ -1,23 +0,0 @@
-name: Docker image (latest-dev)
-
-on:
- push:
- branches:
- - master
-
-jobs:
-
- build:
-
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v2
-
- - uses: jerray/publish-docker-action@master
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_PASSWORD }}
- file: dockerfiles/Dockerfile.self-contained
- repository: containrrr/watchtower
- tags: latest-dev
diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml
new file mode 100644
index 0000000..4818e54
--- /dev/null
+++ b/.github/workflows/publish-docs.yml
@@ -0,0 +1,37 @@
+name: Publish Docs
+
+on:
+ workflow_dispatch: { }
+ workflow_run:
+ workflows: [ "Release (Production)" ]
+ branches: [ main ]
+ types:
+ - completed
+
+jobs:
+ publish-docs:
+ name: Publish Docs
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Build tplprev
+ run: scripts/build-tplprev.sh
+ - name: Setup python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.10'
+ cache: 'pip'
+ cache-dependency-path: |
+ docs-requirements.txt
+ - name: Install mkdocs
+ run: |
+ pip install -r docs-requirements.txt
+ - name: Generate docs
+ run: mkdocs gh-deploy --strict
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
new file mode 100644
index 0000000..f6866af
--- /dev/null
+++ b/.github/workflows/pull-request.yml
@@ -0,0 +1,70 @@
+name: Pull Request
+
+on:
+ workflow_dispatch: {}
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - uses: dominikh/staticcheck-action@ba605356b4b29a60e87ab9404b712f3461e566dc #v1.3.0
+ with:
+ version: "2023.1.6"
+ install-go: "false" # StaticCheck uses go v1.17 which does not support `any`
+ test:
+ name: Test
+ strategy:
+ fail-fast: false
+ matrix:
+ go-version:
+ - 1.20.x
+ platform:
+ - macos-latest
+ - windows-latest
+ - ubuntu-latest
+ runs-on: ${{ matrix.platform }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Run tests
+ run: |
+ go test -v -coverprofile coverage.out -covermode atomic ./...
+ - name: Publish coverage
+ uses: codecov/codecov-action@v3
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Build
+ uses: goreleaser/goreleaser-action@7ec5c2b0c6cdda6e8bbb49444bc797dd33d74dd8 #v3
+ with:
+ version: v0.155.0
+ args: --snapshot --skip-publish --debug
diff --git a/.github/workflows/release-dev.yaml b/.github/workflows/release-dev.yaml
new file mode 100644
index 0000000..95ee68d
--- /dev/null
+++ b/.github/workflows/release-dev.yaml
@@ -0,0 +1,59 @@
+name: Push to main
+
+on:
+ workflow_dispatch: {}
+ push:
+ branches:
+ - main
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Build
+ run: ./build.sh
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Test
+ run: go test -v -coverprofile coverage.out -covermode atomic ./...
+ - name: Publish coverage
+ uses: codecov/codecov-action@v3
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ publish:
+ needs:
+ - build
+ - test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Publish to Docker Hub
+ uses: jerray/publish-docker-action@87d84711629b0dc9f6bb127b568413cc92a2088e #master@2022-10-14
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_PASSWORD }}
+ file: dockerfiles/Dockerfile.self-contained
+ repository: containrrr/watchtower
+ tags: latest-dev
+ - name: Publish to GHCR
+ uses: jerray/publish-docker-action@87d84711629b0dc9f6bb127b568413cc92a2088e #master@2022-10-14
+ with:
+ username: ${{ secrets.BOT_USERNAME }}
+ password: ${{ secrets.BOT_GHCR_PAT }}
+ file: dockerfiles/Dockerfile.self-contained
+ registry: ghcr.io
+ repository: containrrr/watchtower
+ tags: latest-dev
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..370d395
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,197 @@
+name: Release (Production)
+
+on:
+ workflow_dispatch: {}
+ push:
+ tags:
+ - 'v[0-9]+.[0-9]+.[0-9]+'
+ - '**/v[0-9]+.[0-9]+.[0-9]+'
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - uses: dominikh/staticcheck-action@ba605356b4b29a60e87ab9404b712f3461e566dc #v1.3.0
+ with:
+ version: "2022.1.1"
+ install-go: "false" # StaticCheck uses go v1.17 which does not support `any`
+
+ test:
+ name: Test
+ strategy:
+ matrix:
+ go-version:
+ - 1.20.x
+ platform:
+ - ubuntu-latest
+ - macos-latest
+ - windows-latest
+ runs-on: ${{ matrix.platform }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Run tests
+ run: |
+ go test ./... -coverprofile coverage.out
+
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ needs:
+ - test
+ - lint
+ env:
+ CGO_ENABLED: 0
+ TAG: ${{ github.ref_name }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.20.x
+ - name: Login to Docker Hub
+ uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc #v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Login to GHCR
+ uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc #v2
+ with:
+ username: ${{ secrets.BOT_USERNAME }}
+ password: ${{ secrets.BOT_GHCR_PAT }}
+ registry: ghcr.io
+ - name: Build
+ uses: goreleaser/goreleaser-action@7ec5c2b0c6cdda6e8bbb49444bc797dd33d74dd8 #v3
+ with:
+ version: v0.155.0
+ args: --debug
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Enable experimental docker features
+ run: |
+ mkdir -p ~/.docker/ && \
+ echo '{"experimental": "enabled"}' > ~/.docker/config.json
+ - name: Create manifest for version
+ run: |
+ export DH_TAG=$(git tag --points-at HEAD | sed 's/^v*//')
+ docker manifest create \
+ containrrr/watchtower:$DH_TAG \
+ containrrr/watchtower:amd64-$DH_TAG \
+ containrrr/watchtower:i386-$DH_TAG \
+ containrrr/watchtower:armhf-$DH_TAG \
+ containrrr/watchtower:arm64v8-$DH_TAG
+ docker manifest create \
+ ghcr.io/containrrr/watchtower:$DH_TAG \
+ ghcr.io/containrrr/watchtower:amd64-$DH_TAG \
+ ghcr.io/containrrr/watchtower:i386-$DH_TAG \
+ ghcr.io/containrrr/watchtower:armhf-$DH_TAG \
+ ghcr.io/containrrr/watchtower:arm64v8-$DH_TAG
+ - name: Annotate manifest for version
+ run: |
+ for REPO in '' ghcr.io/ ; do
+
+ docker manifest annotate \
+ ${REPO}containrrr/watchtower:$(echo $TAG | sed 's/^v*//') \
+ ${REPO}containrrr/watchtower:i386-$(echo $TAG | sed 's/^v*//') \
+ --os linux \
+ --arch 386
+
+ docker manifest annotate \
+ ${REPO}containrrr/watchtower:$(echo $TAG | sed 's/^v*//') \
+ ${REPO}containrrr/watchtower:armhf-$(echo $TAG | sed 's/^v*//') \
+ --os linux \
+ --arch arm
+
+ docker manifest annotate \
+ ${REPO}containrrr/watchtower:$(echo $TAG | sed 's/^v*//') \
+ ${REPO}containrrr/watchtower:arm64v8-$(echo $TAG | sed 's/^v*//') \
+ --os linux \
+ --arch arm64 \
+ --variant v8
+
+ done
+ - name: Create manifest for latest
+ run: |
+ docker manifest create \
+ containrrr/watchtower:latest \
+ containrrr/watchtower:amd64-latest \
+ containrrr/watchtower:i386-latest \
+ containrrr/watchtower:armhf-latest \
+ containrrr/watchtower:arm64v8-latest
+ docker manifest create \
+ ghcr.io/containrrr/watchtower:latest \
+ ghcr.io/containrrr/watchtower:amd64-latest \
+ ghcr.io/containrrr/watchtower:i386-latest \
+ ghcr.io/containrrr/watchtower:armhf-latest \
+ ghcr.io/containrrr/watchtower:arm64v8-latest
+ - name: Annotate manifest for latest
+ run: |
+ for REPO in '' ghcr.io/ ; do
+
+ docker manifest annotate \
+ ${REPO}containrrr/watchtower:latest \
+ ${REPO}containrrr/watchtower:i386-latest \
+ --os linux \
+ --arch 386
+
+ docker manifest annotate \
+ ${REPO}containrrr/watchtower:latest \
+ ${REPO}containrrr/watchtower:armhf-latest \
+ --os linux \
+ --arch arm
+
+ docker manifest annotate \
+ ${REPO}containrrr/watchtower:latest \
+ ${REPO}containrrr/watchtower:arm64v8-latest \
+ --os linux \
+ --arch arm64 \
+ --variant v8
+
+ done
+ - name: Push manifests to Dockerhub
+ env:
+ DOCKER_USER: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKER_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+ run: |
+ docker login -u $DOCKER_USER -p $DOCKER_TOKEN && \
+ docker manifest push containrrr/watchtower:$(echo $TAG | sed 's/^v*//') && \
+ docker manifest push containrrr/watchtower:latest
+ - name: Push manifests to GitHub Container Registry
+ env:
+ DOCKER_USER: ${{ secrets.BOT_USERNAME }}
+ DOCKER_TOKEN: ${{ secrets.BOT_GHCR_PAT }}
+ run: |
+ docker login -u $DOCKER_USER -p $DOCKER_TOKEN ghcr.io && \
+ docker manifest push ghcr.io/containrrr/watchtower:$(echo $TAG | sed 's/^v*//') && \
+ docker manifest push ghcr.io/containrrr/watchtower:latest
+
+ renew-docs:
+ name: Refresh pkg.go.dev
+ needs: build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Pull new module version
+ uses: andrewslotin/go-proxy-pull-action@50fea06a976087614babb9508e5c528b464f4645 #master@2022-10-14
+
+
+
+
+
diff --git a/.gitignore b/.gitignore
index fda8d42..9519257 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,13 @@
watchtower
+watchtower.exe
vendor
.glide
dist
.idea
.DS_Store
/site
+coverage.out
+*.coverprofile
+
+docs/assets/wasm_exec.js
+docs/assets/*.wasm
\ No newline at end of file
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
deleted file mode 100644
index 6a76fea..0000000
--- a/CODE_OF_CONDUCT.md
+++ /dev/null
@@ -1,76 +0,0 @@
-# Contributor Covenant Code of Conduct
-
-## Our Pledge
-
-In the interest of fostering an open and welcoming environment, we as
-contributors and maintainers pledge to making participation in our project and
-our community a harassment-free experience for everyone, regardless of age, body
-size, disability, ethnicity, sex characteristics, gender identity and expression,
-level of experience, education, socio-economic status, nationality, personal
-appearance, race, religion, or sexual identity and orientation.
-
-## Our Standards
-
-Examples of behavior that contributes to creating a positive environment
-include:
-
-* Using welcoming and inclusive language
-* Being respectful of differing viewpoints and experiences
-* Gracefully accepting constructive criticism
-* Focusing on what is best for the community
-* Showing empathy towards other community members
-
-Examples of unacceptable behavior by participants include:
-
-* The use of sexualized language or imagery and unwelcome sexual attention or
- advances
-* Trolling, insulting/derogatory comments, and personal or political attacks
-* Public or private harassment
-* Publishing others' private information, such as a physical or electronic
- address, without explicit permission
-* Other conduct which could reasonably be considered inappropriate in a
- professional setting
-
-## Our Responsibilities
-
-Project maintainers are responsible for clarifying the standards of acceptable
-behavior and are expected to take appropriate and fair corrective action in
-response to any instances of unacceptable behavior.
-
-Project maintainers have the right and responsibility to remove, edit, or
-reject comments, commits, code, wiki edits, issues, and other contributions
-that are not aligned to this Code of Conduct, or to ban temporarily or
-permanently any contributor for other behaviors that they deem inappropriate,
-threatening, offensive, or harmful.
-
-## Scope
-
-This Code of Conduct applies both within project spaces and in public spaces
-when an individual is representing the project or its community. Examples of
-representing a project or community include using an official project e-mail
-address, posting via an official social media account, or acting as an appointed
-representative at an online or offline event. Representation of a project may be
-further defined and clarified by project maintainers.
-
-## Enforcement
-
-Instances of abusive, harassing, or otherwise unacceptable behavior may be
-reported by contacting the project team. All complaints will be reviewed and
-investigated and will result in a response that is deemed necessary and
-appropriate to the circumstances. The project team is obligated to maintain
-confidentiality with regard to the reporter of an incident. Further details
-of specific enforcement policies may be posted separately.
-
-Project maintainers who do not follow or enforce the Code of Conduct in good
-faith may face temporary or permanent repercussions as determined by other
-members of the project's leadership.
-
-## Attribution
-
-This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
-available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
-
-[homepage]: https://www.contributor-covenant.org
-
-For answers to common questions about this code of conduct, see
-https://www.contributor-covenant.org/faq
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 82b1b1b..0202384 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -3,7 +3,7 @@ To contribute code changes to this project you will need the following developme
* [Go](https://golang.org/doc/install)
* [Docker](https://docs.docker.com/engine/installation/)
-As watchtower utilizes go modules for vendor locking, you'll need atleast Go 1.11.
+As watchtower utilizes go modules for vendor locking, you'll need at least Go 1.11.
You can check your current version of the go language as follows:
```bash
~ $ go version
diff --git a/README.md b/README.md
index 241d484..f550302 100644
--- a/README.md
+++ b/README.md
@@ -1,66 +1,40 @@
-
-
-
-
- Watchtower
-
-
-
+
+

+
+ # Watchtower
+
A process for automating Docker container base image updates.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-> ### β οΈ Help needed
->
-> As I [@simskij](https://github.com/simskij) currently am the sole maintainer of watchtower, i'm finding it a bit hard to keep up with all issues and pull requests. Interested in helping out with triage, troubleshooting and issue handling? Let me know on gitter!
+
+ [](https://circleci.com/gh/containrrr/watchtower)
+ [](https://codecov.io/gh/containrrr/watchtower)
+ [](https://godoc.org/github.com/containrrr/watchtower)
+ [](https://goreportcard.com/report/github.com/containrrr/watchtower)
+ [](https://github.com/containrrr/watchtower/releases)
+ [](https://www.apache.org/licenses/LICENSE-2.0)
+ [](https://www.codacy.com/gh/containrrr/watchtower/dashboard?utm_source=github.com&utm_medium=referral&utm_content=containrrr/watchtower&utm_campaign=Badge_Grade)
+ [](#contributors)
+ [](https://hub.docker.com/r/containrrr/watchtower)
+
## Quick Start
-With watchtower you can update the running version of your containerized app simply by pushing a new image to the Docker Hub or your own image registry. Watchtower will pull down your new image, gracefully shut down your existing container and restart it with the same options that were used when it was deployed initially. Run the watchtower container with the following command:
+With watchtower you can update the running version of your containerized app simply by pushing a new image to the Docker Hub or your own image registry.
+
+Watchtower will pull down your new image, gracefully shut down your existing container and restart it with the same options that were used when it was deployed initially. Run the watchtower container with the following command:
```
-$ docker run -d \
+$ docker run --detach \
--name watchtower \
- -v /var/run/docker.sock:/var/run/docker.sock \
+ --volume /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower
```
+Watchtower is intended to be used in homelabs, media centers, local dev environments, and similar. We do **not** recommend using Watchtower in a commercial or production environment. If that is you, you should be looking into using Kubernetes. If that feels like too big a step for you, please look into solutions like [MicroK8s](https://microk8s.io/) and [k3s](https://k3s.io/) that take away a lot of the toil of running a Kubernetes cluster.
+
## Documentation
-The full documentation is available at https://containrrr.github.io/watchtower.
+The full documentation is available at https://containrrr.dev/watchtower.
## Contributors
@@ -70,73 +44,135 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
-
+
+
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..550f904
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,13 @@
+# Security Policy
+
+## Supported Versions
+
+Security updates will always only be applied to the latest version of Watchtower.
+As the software by default is set to auto-update if you use the `latest` tag, you will get these security updates automatically as soon as they are released.
+
+## Reporting a Vulnerability
+
+Critical vulnerabilities that might open up for external attacks are best reported directly either to simme@arcticbit.se or nils@piksel.se.
+We'll always try to get back to you as swiftly as possible, but keep in mind that since this is a community project, we can't really leave any guarantees about the speed.
+
+Non-critical vulnerabilities may be reported as regular GitHub issues.
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..78b1bfc
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+BINFILE=watchtower
+if [ -n "$MSYSTEM" ]; then
+ BINFILE=watchtower.exe
+fi
+VERSION=$(git describe --tags)
+echo "Building $VERSION..."
+go build -o $BINFILE -ldflags "-X github.com/containrrr/watchtower/internal/meta.Version=$VERSION"
diff --git a/cmd/notify-upgrade.go b/cmd/notify-upgrade.go
new file mode 100644
index 0000000..9991ee6
--- /dev/null
+++ b/cmd/notify-upgrade.go
@@ -0,0 +1,111 @@
+// Package cmd contains the watchtower (sub-)commands
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/containrrr/watchtower/internal/flags"
+ "github.com/containrrr/watchtower/pkg/container"
+ "github.com/containrrr/watchtower/pkg/notifications"
+ "github.com/spf13/cobra"
+)
+
+var notifyUpgradeCommand = NewNotifyUpgradeCommand()
+
+// NewNotifyUpgradeCommand creates the notify upgrade command for watchtower
+func NewNotifyUpgradeCommand() *cobra.Command {
+ return &cobra.Command{
+ Use: "notify-upgrade",
+ Short: "Upgrade legacy notification configuration to shoutrrr URLs",
+ Run: runNotifyUpgrade,
+ }
+}
+
+func runNotifyUpgrade(cmd *cobra.Command, args []string) {
+ if err := runNotifyUpgradeE(cmd, args); err != nil {
+ logf("Notification upgrade failed: %v", err)
+ }
+}
+
+func runNotifyUpgradeE(cmd *cobra.Command, _ []string) error {
+ f := cmd.Flags()
+ flags.ProcessFlagAliases(f)
+
+ notifier = notifications.NewNotifier(cmd)
+ urls := notifier.GetURLs()
+
+ logf("Found notification configurations for: %v", strings.Join(notifier.GetNames(), ", "))
+
+ outFile, err := os.CreateTemp("/", "watchtower-notif-urls-*")
+ if err != nil {
+ return fmt.Errorf("failed to create output file: %v", err)
+ }
+ logf("Writing notification URLs to %v", outFile.Name())
+ logf("")
+
+ sb := strings.Builder{}
+ sb.WriteString("WATCHTOWER_NOTIFICATION_URL=")
+
+ for i, u := range urls {
+ if i != 0 {
+ sb.WriteRune(' ')
+ }
+ sb.WriteString(u)
+ }
+
+ _, err = fmt.Fprint(outFile, sb.String())
+ tryOrLog(err, "Failed to write to output file")
+
+ tryOrLog(outFile.Sync(), "Failed to sync output file")
+ tryOrLog(outFile.Close(), "Failed to close output file")
+
+ containerID := ""
+ cid, err := container.GetRunningContainerID()
+ tryOrLog(err, "Failed to get running container ID")
+ if cid != "" {
+ containerID = cid.ShortID()
+ }
+ logf("To get the environment file, use:")
+ logf("cp %v:%v ./watchtower-notifications.env", containerID, outFile.Name())
+ logf("")
+ logf("Note: This file will be removed in 5 minutes or when this container is stopped!")
+
+ signalChannel := make(chan os.Signal, 1)
+ time.AfterFunc(5*time.Minute, func() {
+ signalChannel <- syscall.SIGALRM
+ })
+
+ signal.Notify(signalChannel, os.Interrupt)
+ signal.Notify(signalChannel, syscall.SIGTERM)
+
+ switch <-signalChannel {
+ case syscall.SIGALRM:
+ logf("Timed out!")
+ case os.Interrupt, syscall.SIGTERM:
+ logf("Stopping...")
+ default:
+ }
+
+ if err := os.Remove(outFile.Name()); err != nil {
+ logf("Failed to remove file, it may still be present in the container image! Error: %v", err)
+ } else {
+ logf("Environment file has been removed.")
+ }
+
+ return nil
+}
+
+func tryOrLog(err error, message string) {
+ if err != nil {
+ logf("%v: %v\n", message, err)
+ }
+}
+
+func logf(format string, v ...interface{}) {
+ fmt.Fprintln(os.Stderr, fmt.Sprintf(format, v...))
+}
diff --git a/cmd/root.go b/cmd/root.go
index ee64e56..eef13ce 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -1,16 +1,25 @@
package cmd
import (
+ "errors"
+ "math"
+ "net/http"
"os"
"os/signal"
"strconv"
+ "strings"
"syscall"
"time"
"github.com/containrrr/watchtower/internal/actions"
"github.com/containrrr/watchtower/internal/flags"
+ "github.com/containrrr/watchtower/internal/meta"
+ "github.com/containrrr/watchtower/pkg/api"
+ apiMetrics "github.com/containrrr/watchtower/pkg/api/metrics"
+ "github.com/containrrr/watchtower/pkg/api/update"
"github.com/containrrr/watchtower/pkg/container"
"github.com/containrrr/watchtower/pkg/filters"
+ "github.com/containrrr/watchtower/pkg/metrics"
"github.com/containrrr/watchtower/pkg/notifications"
t "github.com/containrrr/watchtower/pkg/types"
"github.com/robfig/cron"
@@ -20,26 +29,37 @@ import (
)
var (
- client container.Client
- scheduleSpec string
- cleanup bool
- noRestart bool
- monitorOnly bool
- enableLabel bool
- notifier *notifications.Notifier
- timeout time.Duration
- lifecycleHooks bool
+ 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 = &cobra.Command{
- Use: "watchtower",
- Short: "Automatically updates running Docker containers",
- Long: `
-Watchtower automatically updates running Docker containers whenever a new image is released.
-More information available at https://github.com/containrrr/watchtower/.
-`,
- Run: Run,
- PreRun: PreRun,
+var rootCmd = NewRootCommand()
+
+// NewRootCommand creates the root command for watchtower
+func NewRootCommand() *cobra.Command {
+ return &cobra.Command{
+ Use: "watchtower",
+ Short: "Automatically updates running Docker containers",
+ Long: `
+ Watchtower automatically updates running Docker containers whenever a new image is released.
+ More information available at https://github.com/containrrr/watchtower/.
+ `,
+ Run: Run,
+ PreRun: PreRun,
+ Args: cobra.ArbitraryArgs,
+ }
}
func init() {
@@ -51,32 +71,23 @@ func init() {
// Execute the root func and exit in case of errors
func Execute() {
+ rootCmd.AddCommand(notifyUpgradeCommand)
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
// PreRun is a lifecycle hook that runs before the command is executed.
-func PreRun(cmd *cobra.Command, args []string) {
+func PreRun(cmd *cobra.Command, _ []string) {
f := cmd.PersistentFlags()
-
- if enabled, _ := f.GetBool("debug"); enabled {
- log.SetLevel(log.DebugLevel)
+ flags.ProcessFlagAliases(f)
+ if err := flags.SetupLogging(f); err != nil {
+ log.Fatalf("Failed to initialize logging: %s", err.Error())
}
- 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)
if timeout < 0 {
@@ -84,7 +95,15 @@ func PreRun(cmd *cobra.Command, args []string) {
}
enableLabel, _ = f.GetBool("label-enable")
+ disableContainers, _ = f.GetStringSlice("disable-containers")
lifecycleHooks, _ = f.GetBool("enable-lifecycle-hooks")
+ rollingRestart, _ = f.GetBool("rolling-restart")
+ scope, _ = f.GetString("scope")
+ labelPrecedence, _ = f.GetBool("label-take-precedence")
+
+ if scope != "" {
+ log.Debugf(`Using scope %q`, scope)
+ }
// configure environment vars for client
err := flags.EnvConfig(cmd)
@@ -92,61 +111,226 @@ func PreRun(cmd *cobra.Command, args []string) {
log.Fatal(err)
}
- noPull, _ := f.GetBool("no-pull")
+ noPull, _ = f.GetBool("no-pull")
includeStopped, _ := f.GetBool("include-stopped")
+ includeRestarting, _ := f.GetBool("include-restarting")
reviveStopped, _ := f.GetBool("revive-stopped")
removeVolumes, _ := f.GetBool("remove-volumes")
+ warnOnHeadPullFailed, _ := f.GetString("warn-on-head-failure")
- client = container.NewClient(
- !noPull,
- includeStopped,
- reviveStopped,
- removeVolumes,
- )
+ if monitorOnly && noPull {
+ log.Warn("Using `WATCHTOWER_NO_PULL` and `WATCHTOWER_MONITOR_ONLY` simultaneously might lead to no action being taken at all. If this is intentional, you may safely ignore this message.")
+ }
+
+ client = container.NewClient(container.ClientOptions{
+ IncludeStopped: includeStopped,
+ ReviveStopped: reviveStopped,
+ RemoveVolumes: removeVolumes,
+ IncludeRestarting: includeRestarting,
+ WarnOnHeadFailed: container.WarningStrategy(warnOnHeadPullFailed),
+ })
notifier = notifications.NewNotifier(cmd)
+ notifier.AddLogHook()
}
// Run is the main execution flow of the command
func Run(c *cobra.Command, names []string) {
- filter := filters.BuildFilter(names, enableLabel)
+ filter, filterDesc := filters.BuildFilter(names, disableContainers, enableLabel, scope)
runOnce, _ := c.PersistentFlags().GetBool("run-once")
+ enableUpdateAPI, _ := c.PersistentFlags().GetBool("http-api-update")
+ enableMetricsAPI, _ := c.PersistentFlags().GetBool("http-api-metrics")
+ unblockHTTPAPI, _ := c.PersistentFlags().GetBool("http-api-periodic-polls")
+ apiToken, _ := c.PersistentFlags().GetString("http-api-token")
+ healthCheck, _ := c.PersistentFlags().GetBool("health-check")
+
+ if healthCheck {
+ // health check should not have pid 1
+ if os.Getpid() == 1 {
+ time.Sleep(1 * time.Second)
+ log.Fatal("The health check flag should never be passed to the main watchtower container process")
+ }
+ os.Exit(0)
+ }
+
+ if rollingRestart && monitorOnly {
+ log.Fatal("Rolling restarts is not compatible with the global monitor only flag")
+ }
+
+ awaitDockerClient()
+
+ if err := actions.CheckForSanity(client, filter, rollingRestart); err != nil {
+ logNotifyExit(err)
+ }
if runOnce {
- log.Info("Running a one time update.")
+ writeStartupMessage(c, time.Time{}, filterDesc)
runUpdatesWithNotifications(filter)
+ notifier.Close()
os.Exit(0)
return
}
- if err := actions.CheckForMultipleWatchtowerInstances(client, cleanup); err != nil {
- log.Fatal(err)
+ if err := actions.CheckForMultipleWatchtowerInstances(client, cleanup, scope); err != nil {
+ logNotifyExit(err)
}
- if err := runUpgradesOnSchedule(c, filter); err != nil {
+ // The lock is shared between the scheduler and the HTTP API. It only allows one update to run at a time.
+ updateLock := make(chan bool, 1)
+ updateLock <- true
+
+ httpAPI := api.New(apiToken)
+
+ if enableUpdateAPI {
+ updateHandler := update.New(func(images []string) {
+ metric := runUpdatesWithNotifications(filters.FilterByImage(images, filter))
+ metrics.RegisterScan(metric)
+ }, updateLock)
+ httpAPI.RegisterFunc(updateHandler.Path, updateHandler.Handle)
+ // If polling isn't enabled the scheduler is never started, and
+ // we need to trigger the startup messages manually.
+ if !unblockHTTPAPI {
+ writeStartupMessage(c, time.Time{}, filterDesc)
+ }
+ }
+
+ if enableMetricsAPI {
+ metricsHandler := apiMetrics.New()
+ httpAPI.RegisterHandler(metricsHandler.Path, metricsHandler.Handle)
+ }
+
+ if err := httpAPI.Start(enableUpdateAPI && !unblockHTTPAPI); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Error("failed to start API", err)
+ }
+
+ if err := runUpgradesOnSchedule(c, filter, filterDesc, updateLock); err != nil {
log.Error(err)
}
os.Exit(1)
}
-func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter) error {
- tryLockSem := make(chan bool, 1)
- tryLockSem <- true
+func logNotifyExit(err error) {
+ log.Error(err)
+ notifier.Close()
+ os.Exit(1)
+}
- cron := cron.New()
- err := cron.AddFunc(
+func awaitDockerClient() {
+ log.Debug("Sleeping for a second to ensure the docker api client has been properly initialized.")
+ time.Sleep(1 * time.Second)
+}
+
+func formatDuration(d time.Duration) string {
+ sb := strings.Builder{}
+
+ hours := int64(d.Hours())
+ minutes := int64(math.Mod(d.Minutes(), 60))
+ seconds := int64(math.Mod(d.Seconds(), 60))
+
+ if hours == 1 {
+ sb.WriteString("1 hour")
+ } else if hours != 0 {
+ sb.WriteString(strconv.FormatInt(hours, 10))
+ sb.WriteString(" hours")
+ }
+
+ if hours != 0 && (seconds != 0 || minutes != 0) {
+ sb.WriteString(", ")
+ }
+
+ if minutes == 1 {
+ sb.WriteString("1 minute")
+ } else if minutes != 0 {
+ sb.WriteString(strconv.FormatInt(minutes, 10))
+ sb.WriteString(" minutes")
+ }
+
+ if minutes != 0 && (seconds != 0) {
+ sb.WriteString(", ")
+ }
+
+ if seconds == 1 {
+ sb.WriteString("1 second")
+ } else if seconds != 0 || (hours == 0 && minutes == 0) {
+ sb.WriteString(strconv.FormatInt(seconds, 10))
+ sb.WriteString(" seconds")
+ }
+
+ return sb.String()
+}
+
+func writeStartupMessage(c *cobra.Command, sched time.Time, filtering string) {
+ noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message")
+ enableUpdateAPI, _ := c.PersistentFlags().GetBool("http-api-update")
+
+ var startupLog *log.Entry
+ if noStartupMessage {
+ startupLog = notifications.LocalLog
+ } else {
+ startupLog = log.NewEntry(log.StandardLogger())
+ // Batch up startup messages to send them as a single notification
+ notifier.StartNotification()
+ }
+
+ startupLog.Info("Watchtower ", meta.Version)
+
+ notifierNames := notifier.GetNames()
+ if len(notifierNames) > 0 {
+ startupLog.Info("Using notifications: " + strings.Join(notifierNames, ", "))
+ } else {
+ startupLog.Info("Using no notifications")
+ }
+
+ startupLog.Info(filtering)
+
+ if !sched.IsZero() {
+ until := formatDuration(time.Until(sched))
+ startupLog.Info("Scheduling first run: " + sched.Format("2006-01-02 15:04:05 -0700 MST"))
+ startupLog.Info("Note that the first check will be performed in " + until)
+ } else if runOnce, _ := c.PersistentFlags().GetBool("run-once"); runOnce {
+ startupLog.Info("Running a one time update.")
+ } else {
+ startupLog.Info("Periodic runs are not enabled.")
+ }
+
+ if enableUpdateAPI {
+ // TODO: make listen port configurable
+ startupLog.Info("The HTTP API is enabled at :8080.")
+ }
+
+ if !noStartupMessage {
+ // Send the queued up startup messages, not including the trace warning below (to make sure it's noticed)
+ notifier.SendNotification(nil)
+ }
+
+ if log.IsLevelEnabled(log.TraceLevel) {
+ startupLog.Warn("Trace level enabled: log will include sensitive information as credentials and tokens")
+ }
+}
+
+func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter, filtering string, lock chan bool) error {
+ if lock == nil {
+ lock = make(chan bool, 1)
+ lock <- true
+ }
+
+ scheduler := cron.New()
+ err := scheduler.AddFunc(
scheduleSpec,
func() {
select {
- case v := <-tryLockSem:
- defer func() { tryLockSem <- v }()
- runUpdatesWithNotifications(filter)
+ case v := <-lock:
+ defer func() { lock <- v }()
+ metric := runUpdatesWithNotifications(filter)
+ metrics.RegisterScan(metric)
default:
+ // Update was skipped
+ metrics.RegisterScan(nil)
log.Debug("Skipped another update already running.")
}
- nextRuns := cron.Entries()
+ nextRuns := scheduler.Entries()
if len(nextRuns) > 0 {
log.Debug("Scheduled next run: " + nextRuns[0].Next.String())
}
@@ -156,11 +340,9 @@ func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter) error {
return err
}
- if noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message"); !noStartupMessage {
- log.Info("Starting Watchtower and scheduling first run: " + cron.Entries()[0].Schedule.Next(time.Now()).String())
- }
+ writeStartupMessage(c, scheduler.Entries()[0].Schedule.Next(time.Now()), filtering)
- cron.Start()
+ scheduler.Start()
// Graceful shut-down on SIGINT/SIGTERM
interrupt := make(chan os.Signal, 1)
@@ -168,25 +350,35 @@ func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter) error {
signal.Notify(interrupt, syscall.SIGTERM)
<-interrupt
- cron.Stop()
+ scheduler.Stop()
log.Info("Waiting for running update to be finished...")
- <-tryLockSem
+ <-lock
return nil
}
-func runUpdatesWithNotifications(filter t.Filter) {
+func runUpdatesWithNotifications(filter t.Filter) *metrics.Metric {
notifier.StartNotification()
updateParams := t.UpdateParams{
- Filter: filter,
- Cleanup: cleanup,
- NoRestart: noRestart,
- Timeout: timeout,
- MonitorOnly: monitorOnly,
- LifecycleHooks: lifecycleHooks,
+ Filter: filter,
+ Cleanup: cleanup,
+ NoRestart: noRestart,
+ Timeout: timeout,
+ MonitorOnly: monitorOnly,
+ LifecycleHooks: lifecycleHooks,
+ RollingRestart: rollingRestart,
+ LabelPrecedence: labelPrecedence,
+ NoPull: noPull,
}
- err := actions.Update(client, updateParams)
+ result, err := actions.Update(client, updateParams)
if err != nil {
- log.Println(err)
+ log.Error(err)
}
- notifier.SendNotification()
+ notifier.SendNotification(result)
+ metricResults := metrics.NewMetric(result)
+ notifications.LocalLog.WithFields(log.Fields{
+ "Scanned": metricResults.Scanned,
+ "Updated": metricResults.Updated,
+ "Failed": metricResults.Failed,
+ }).Info("Session done")
+ return metricResults
}
diff --git a/code_of_conduct.md b/code_of_conduct.md
new file mode 100644
index 0000000..65d4355
--- /dev/null
+++ b/code_of_conduct.md
@@ -0,0 +1,3 @@
+### Containrrr Community Code of Conduct
+
+Please refer to out [Containrrr Community Code of Conduct](https://github.com/containrrr/.github/blob/master/CODE_OF_CONDUCT.md)
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..b0a3373
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,43 @@
+version: '3.7'
+
+services:
+ watchtower:
+ container_name: watchtower
+ build:
+ context: ./
+ dockerfile: dockerfiles/Dockerfile.dev-self-contained
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ ports:
+ - 8080:8080
+ command: --interval 10 --http-api-metrics --http-api-token demotoken --debug prometheus grafana parent child
+ prometheus:
+ container_name: prometheus
+ image: prom/prometheus
+ volumes:
+ - ./prometheus/:/etc/prometheus/
+ - prometheus:/prometheus/
+ ports:
+ - 9090:9090
+ grafana:
+ container_name: grafana
+ image: grafana/grafana
+ ports:
+ - 3000:3000
+ environment:
+ GF_INSTALL_PLUGINS: grafana-clock-panel,grafana-simple-json-datasource
+ volumes:
+ - grafana:/var/lib/grafana
+ - ./grafana:/etc/grafana/provisioning
+ parent:
+ image: nginx
+ container_name: parent
+ child:
+ image: nginx:alpine
+ labels:
+ com.centurylinklabs.watchtower.depends-on: parent
+ container_name: child
+
+volumes:
+ prometheus: {}
+ grafana: {}
diff --git a/dockerfiles/Dockerfile b/dockerfiles/Dockerfile
index f792d32..2fc571d 100644
--- a/dockerfiles/Dockerfile
+++ b/dockerfiles/Dockerfile
@@ -1,4 +1,4 @@
-FROM alpine:3.11 as alpine
+FROM --platform=$BUILDPLATFORM alpine:3.19.0 as alpine
RUN apk add --no-cache \
ca-certificates \
@@ -14,5 +14,10 @@ COPY --from=alpine \
/usr/share/zoneinfo \
/usr/share/zoneinfo
+EXPOSE 8080
+
COPY watchtower /
+
+HEALTHCHECK CMD [ "/watchtower", "--health-check"]
+
ENTRYPOINT ["/watchtower"]
diff --git a/dockerfiles/Dockerfile.dev-self-contained b/dockerfiles/Dockerfile.dev-self-contained
index 307ffbe..1a39c26 100644
--- a/dockerfiles/Dockerfile.dev-self-contained
+++ b/dockerfiles/Dockerfile.dev-self-contained
@@ -4,8 +4,15 @@
FROM golang:alpine as builder
-# use version (for example "v0.3.3") or "master"
-ARG WATCHTOWER_VERSION=master
+# use version (for example "v0.3.3") or "main"
+ARG WATCHTOWER_VERSION=main
+
+# Pre download required modules to avoid redownloading at each build thanks to docker layer caching.
+# Copying go.mod and go.sum ensure to invalid the layer/build cache if there is a change in module requirement
+WORKDIR /watchtower
+COPY go.mod .
+COPY go.sum .
+RUN go mod download
RUN apk add --no-cache \
alpine-sdk \
@@ -18,7 +25,7 @@ COPY . /watchtower
RUN \
cd /watchtower && \
\
- GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' . && \
+ GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -X github.com/containrrr/watchtower/internal/meta.Version=$(git describe --tags)" . && \
GO111MODULE=on go test ./... -v
@@ -35,4 +42,6 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certifi
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /watchtower/watchtower /watchtower
+HEALTHCHECK CMD [ "/watchtower", "--health-check"]
+
ENTRYPOINT ["/watchtower"]
diff --git a/dockerfiles/Dockerfile.self-contained b/dockerfiles/Dockerfile.self-contained
index 64d5dc0..04a6047 100644
--- a/dockerfiles/Dockerfile.self-contained
+++ b/dockerfiles/Dockerfile.self-contained
@@ -4,8 +4,8 @@
FROM golang:alpine as builder
-# use version (for example "v0.3.3") or "master"
-ARG WATCHTOWER_VERSION=master
+# use version (for example "v0.3.3") or "main"
+ARG WATCHTOWER_VERSION=main
RUN apk add --no-cache \
alpine-sdk \
@@ -18,7 +18,7 @@ RUN git clone --branch "${WATCHTOWER_VERSION}" https://github.com/containrrr/wat
RUN \
cd watchtower && \
\
- GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' . && \
+ GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -X github.com/containrrr/watchtower/internal/meta.Version=$(git describe --tags)" . && \
GO111MODULE=on go test ./... -v
@@ -35,4 +35,6 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certifi
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /go/watchtower/watchtower /watchtower
+HEALTHCHECK CMD [ "/watchtower", "--health-check"]
+
ENTRYPOINT ["/watchtower"]
diff --git a/dockerfiles/container-networking/docker-compose.yml b/dockerfiles/container-networking/docker-compose.yml
new file mode 100644
index 0000000..24cd00d
--- /dev/null
+++ b/dockerfiles/container-networking/docker-compose.yml
@@ -0,0 +1,17 @@
+services:
+ producer:
+ image: qmcgaw/gluetun:v3.35.0
+ cap_add:
+ - NET_ADMIN
+ environment:
+ - VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
+ - OPENVPN_USER=${OPENVPN_USER}
+ - OPENVPN_PASSWORD=${OPENVPN_PASSWORD}
+ - SERVER_COUNTRIES=${SERVER_COUNTRIES}
+ consumer:
+ depends_on:
+ - producer
+ image: nginx:1.25.1
+ network_mode: "service:producer"
+ labels:
+ - "com.centurylinklabs.watchtower.depends-on=/wt-contnet-producer-1"
diff --git a/docs-requirements.txt b/docs-requirements.txt
new file mode 100644
index 0000000..3a0104e
--- /dev/null
+++ b/docs-requirements.txt
@@ -0,0 +1,3 @@
+mkdocs
+mkdocs-material
+md-toc
diff --git a/docs/arguments.md b/docs/arguments.md
index c172eb8..d7ed0b0 100644
--- a/docs/arguments.md
+++ b/docs/arguments.md
@@ -27,10 +27,37 @@ In the example above, watchtower will execute an upgrade attempt on the containe
When no arguments are specified, watchtower will monitor all running containers.
+## Secrets/Files
+
+Some arguments can also reference a file, in which case the contents of the file are used as the value.
+This can be used to avoid putting secrets in the configuration file or command line.
+
+The following arguments are currently supported (including their corresponding `WATCHTOWER_` environment variables):
+ - `notification-url`
+ - `notification-email-server-password`
+ - `notification-slack-hook-url`
+ - `notification-msteams-hook`
+ - `notification-gotify-token`
+ - `http-api-token`
+
+### Example docker-compose usage
+```yaml
+secrets:
+ access_token:
+ file: access_token
+
+services:
+ watchtower:
+ secrets:
+ - access_token
+ environment:
+ - WATCHTOWER_HTTP_API_TOKEN=/run/secrets/access_token
+```
+
## Help
Shows documentation about the supported flags.
-```
+```text
Argument: --help
Environment Variable: N/A
Type: N/A
@@ -39,8 +66,9 @@ Environment Variable: N/A
## Time Zone
Sets the time zone to be used by WatchTower's logs and the optional Cron scheduling argument (--schedule). If this environment variable is not set, Watchtower will use the default time zone: UTC.
+To find out the right value, see [this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), find your location and use the value in _TZ Database Name_, e.g _Europe/Rome_. The timezone can alternatively be set by volume mounting your hosts /etc/localtime file. `-v /etc/localtime:/etc/localtime:ro`
-```
+```text
Argument: N/A
Environment Variable: TZ
Type: String
@@ -50,17 +78,17 @@ Environment Variable: TZ
## Cleanup
Removes old images after updating. When this flag is specified, watchtower will remove the old image after restarting a container with a new image. Use this option to prevent the accumulation of orphaned images on your system as containers are updated.
-```
+```text
Argument: --cleanup
Environment Variable: WATCHTOWER_CLEANUP
Type: Boolean
Default: false
```
-## Remove attached volumes
-Removes attached volumes after updating. When this flag is specified, watchtower will remove all attached volumes from the container before restarting with a new image. Use this option to force new volumes to be populated as containers are updated.
+## Remove anonymous volumes
+Removes anonymous volumes after updating. When this flag is specified, watchtower will remove all anonymous volumes from the container before restarting with a new image. Named volumes will not be removed!
-```
+```text
Argument: --remove-volumes
Environment Variable: WATCHTOWER_REMOVE_VOLUMES
Type: Boolean
@@ -70,18 +98,68 @@ Environment Variable: WATCHTOWER_REMOVE_VOLUMES
## Debug
Enable debug mode with verbose logging.
-```
- Argument: --debug
+!!! note "Notes"
+ Alias for `--log-level debug`. See [Maximum log level](#maximum-log-level).
+ Does _not_ take an argument when used as an argument. Using `--debug true` will **not** work.
+
+```text
+ Argument: --debug, -d
Environment Variable: WATCHTOWER_DEBUG
Type: Boolean
Default: false
```
+## Trace
+Enable trace mode with very verbose logging. Caution: exposes credentials!
+
+!!! note "Notes"
+ Alias for `--log-level trace`. See [Maximum log level](#maximum-log-level).
+ Does _not_ take an argument when used as an argument. Using `--trace true` will **not** work.
+
+```text
+ Argument: --trace
+Environment Variable: WATCHTOWER_TRACE
+ Type: Boolean
+ Default: false
+```
+
+## Maximum log level
+
+The maximum log level that will be written to STDERR (shown in `docker log` when used in a container).
+
+```text
+ Argument: --log-level
+Environment Variable: WATCHTOWER_LOG_LEVEL
+ Possible values: panic, fatal, error, warn, info, debug or trace
+ Default: info
+```
+
+## Logging format
+
+Sets what logging format to use for console output.
+
+```text
+ Argument: --log-format, -l
+Environment Variable: WATCHTOWER_LOG_FORMAT
+ Possible values: Auto, LogFmt, Pretty or JSON
+ Default: Auto
+```
+
+## ANSI colors
+Disable ANSI color escape codes in log output.
+
+```text
+ Argument: --no-color
+Environment Variable: NO_COLOR
+ Type: Boolean
+ Default: false
+```
+
## Docker host
Docker daemon socket to connect to. Can be pointed at a remote Docker host by specifying a TCP endpoint as "tcp://hostname:port".
-```
- Argument: --host, -h
+```text
+ Argument: --host, -H
Environment Variable: DOCKER_HOST
Type: String
Default: "unix:///var/run/docker.sock"
@@ -90,67 +168,117 @@ Environment Variable: DOCKER_HOST
## Docker API version
The API version to use by the Docker client for connecting to the Docker daemon. The minimum supported version is 1.24.
-```
+```text
Argument: --api-version, -a
Environment Variable: DOCKER_API_VERSION
Type: String
Default: "1.24"
```
+## Include restarting
+Will also include restarting containers.
+
+```text
+ Argument: --include-restarting
+Environment Variable: WATCHTOWER_INCLUDE_RESTARTING
+ Type: Boolean
+ Default: false
+```
+
## Include stopped
Will also include created and exited containers.
-```
- Argument: --include-stopped
+```text
+ Argument: --include-stopped, -S
Environment Variable: WATCHTOWER_INCLUDE_STOPPED
Type: Boolean
Default: false
-```
+```
## Revive stopped
Start any stopped containers that have had their image updated. This argument is only usable with the `--include-stopped` argument.
-```
+```text
Argument: --revive-stopped
Environment Variable: WATCHTOWER_REVIVE_STOPPED
Type: Boolean
Default: false
-```
+```
## Poll interval
-Poll interval (in seconds). This value controls how frequently watchtower will poll for new images.
+Poll interval (in seconds). This value controls how frequently watchtower will poll for new images. Either `--schedule` or a poll interval can be defined, but not both.
-```
+```text
Argument: --interval, -i
Environment Variable: WATCHTOWER_POLL_INTERVAL
Type: Integer
- Default: 300
-```
+ Default: 86400 (24 hours)
+```
## Filter by enable label
-Update containers that have a `com.centurylinklabs.watchtower.enable` label set to true.
+Monitor and update containers that have a `com.centurylinklabs.watchtower.enable` label set to true.
-```
+```text
Argument: --label-enable
Environment Variable: WATCHTOWER_LABEL_ENABLE
Type: Boolean
Default: false
-```
+```
+
+## Filter by disable label
+__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, not update the containers.
+Will only monitor for new images, send notifications and invoke
+the [pre-check/post-check hooks](https://containrrr.dev/watchtower/lifecycle-hooks/), but will __not__ update the
+containers.
-```
+!!! note
+ Due to Docker API limitations the latest image will still be pulled from the registry.
+ The HEAD digest checks allows watchtower to skip pulling when there are no changes, but to know _what_ has changed it
+ will still do a pull whenever the repository digest doesn't match the local image digest.
+
+```text
Argument: --monitor-only
Environment Variable: WATCHTOWER_MONITOR_ONLY
Type: Boolean
Default: false
-```
+```
+
+Note that monitor-only can also be specified on a per-container basis with the `com.centurylinklabs.watchtower.monitor-only` label set on those containers.
+
+See [With label taking precedence over arguments](#With-label-taking-precedence-over-arguments) for behavior when both argument and label are set
+
+## With label taking precedence over arguments
+
+By default, arguments will take precedence over labels. This means that if you set `WATCHTOWER_MONITOR_ONLY` to true or use `--monitor-only`, a container with `com.centurylinklabs.watchtower.monitor-only` set to false will not be updated. If you set `WATCHTOWER_LABEL_TAKE_PRECEDENCE` to true or use `--label-take-precedence`, then the container will also be updated. This also apply to the no pull option. if you set `WATCHTOWER_NO_PULL` to true or use `--no-pull`, a container with `com.centurylinklabs.watchtower.no-pull` set to false will not pull the new image. If you set `WATCHTOWER_LABEL_TAKE_PRECEDENCE` to true or use `--label-take-precedence`, then the container will pull image
+
+```text
+ Argument: --label-take-precedence
+Environment Variable: WATCHTOWER_LABEL_TAKE_PRECEDENCE
+ Type: Boolean
+ Default: false
+```
## Without restarting containers
Do not restart containers after updating. This option can be useful when the start of the containers
is managed by an external system such as systemd.
-```
+```text
Argument: --no-restart
Environment Variable: WATCHTOWER_NO_RESTART
Type: Boolean
@@ -163,17 +291,22 @@ new images from the registry. Instead it will only monitor the local image cache
Use this option if you are building new images directly on the Docker host without pushing
them to a registry.
-```
+```text
Argument: --no-pull
Environment Variable: WATCHTOWER_NO_PULL
Type: Boolean
Default: false
-```
+```
+
+Note that no-pull can also be specified on a per-container basis with the
+`com.centurylinklabs.watchtower.no-pull` label set on those containers.
+
+See [With label taking precedence over arguments](#With-label-taking-precedence-over-arguments) for behavior when both argument and label are set
## Without sending a startup message
-Do not send a send a message after watchtower started. Otherwise there will be an info-level notification.
+Do not send a message after watchtower started. Otherwise there will be an info-level notification.
-```
+```text
Argument: --no-startup-message
Environment Variable: WATCHTOWER_NO_STARTUP_MESSAGE
Type: Boolean
@@ -183,39 +316,152 @@ Environment Variable: WATCHTOWER_NO_STARTUP_MESSAGE
## Run once
Run an update attempt against a container name list one time immediately and exit.
-```
- Argument: --run-once
+```text
+ Argument: --run-once, -R
Environment Variable: WATCHTOWER_RUN_ONCE
Type: Boolean
Default: false
-```
-
-## Scheduling
-[Cron expression](https://godoc.org/github.com/robfig/cron#hdr-CRON_Expression_Format) in 6 fields (rather than the traditional 5) which defines when and how often to check for new images. Either `--interval` or the schedule expression could be defined, but not both. An example: `--schedule "0 0 4 * * *"`
-
```
- Argument: --schedule, -s
-Environment Variable: WATCHTOWER_SCHEDULE
+
+## HTTP API Mode
+Runs Watchtower in HTTP API mode, only allowing image updates to be triggered by an HTTP request.
+For details see [HTTP API](https://containrrr.dev/watchtower/http-api-mode).
+
+```text
+ Argument: --http-api-update
+Environment Variable: WATCHTOWER_HTTP_API_UPDATE
+ Type: Boolean
+ Default: false
+```
+
+## HTTP API Token
+Sets an authentication token to HTTP API requests.
+Can also reference a file, in which case the contents of the file are used.
+
+```text
+ Argument: --http-api-token
+Environment Variable: WATCHTOWER_HTTP_API_TOKEN
+ Type: String
+ Default: -
+```
+
+## HTTP API periodic polls
+Keep running periodic updates if the HTTP API mode is enabled, otherwise the HTTP API would prevent periodic polls.
+
+```text
+ Argument: --http-api-periodic-polls
+Environment Variable: WATCHTOWER_HTTP_API_PERIODIC_POLLS
+ Type: Boolean
+ Default: false
+```
+
+## Filter by scope
+Update containers that have a `com.centurylinklabs.watchtower.scope` label set with the same value as the given argument.
+This enables [running multiple instances](https://containrrr.dev/watchtower/running-multiple-instances).
+
+!!! note "Filter by lack of scope"
+ If you want other instances of watchtower to ignore the scoped containers, set this argument to `none`.
+ When omitted, watchtower will update all containers regardless of scope.
+
+
+```text
+ Argument: --scope
+Environment Variable: WATCHTOWER_SCOPE
Type: String
Default: -
```
+## HTTP API Metrics
+Enables a metrics endpoint, exposing prometheus metrics via HTTP. See [Metrics](metrics.md) for details.
+
+```text
+ Argument: --http-api-metrics
+Environment Variable: WATCHTOWER_HTTP_API_METRICS
+ Type: Boolean
+ Default: false
+```
+
+## Scheduling
+[Cron expression](https://pkg.go.dev/github.com/robfig/cron@v1.2.0?tab=doc#hdr-CRON_Expression_Format) in 6 fields (rather than the traditional 5) which defines when and how often to check for new images. Either `--interval` or the schedule expression
+can be defined, but not both. An example: `--schedule "0 0 4 * * *"`
+
+```text
+ Argument: --schedule, -s
+Environment Variable: WATCHTOWER_SCHEDULE
+ Type: String
+ Default: -
+```
+
+## Rolling restart
+Restart one image at time instead of stopping and starting all at once. Useful in conjunction with lifecycle hooks
+to implement zero-downtime deploy.
+
+```text
+ Argument: --rolling-restart
+Environment Variable: WATCHTOWER_ROLLING_RESTART
+ Type: Boolean
+ Default: false
+```
+
## Wait until timeout
Timeout before the container is forcefully stopped. When set, this option will change the default (`10s`) wait time to the given value. An example: `--stop-timeout 30s` will set the timeout to 30 seconds.
-```
+```text
Argument: --stop-timeout
Environment Variable: WATCHTOWER_TIMEOUT
Type: Duration
Default: 10s
-```
+```
## TLS Verification
-Use TLS when connecting to the Docker socket and verify the server's certificate. See below for options used to configure notifications.
-```
+Use TLS when connecting to the Docker socket and verify the server's certificate. See below for options used to
+configure notifications.
+
+```text
Argument: --tlsverify
Environment Variable: DOCKER_TLS_VERIFY
Type: Boolean
Default: false
```
+
+## HEAD failure warnings
+
+When to warn about HEAD pull requests failing. Auto means that it will warn when the registry is known to handle the
+requests and may rate limit pull requests (mainly docker.io).
+
+```text
+ Argument: --warn-on-head-failure
+Environment Variable: WATCHTOWER_WARN_ON_HEAD_FAILURE
+ Possible values: always, auto, never
+ Default: auto
+```
+
+## Health check
+
+Returns a success exit code to enable usage with docker `HEALTHCHECK`. This check is naive and only returns checks whether there is another process running inside the container, as it is the only known form of failure state for watchtowers container.
+
+!!! note "Only for HEALTHCHECK use"
+ Never put this on the main container executable command line as it is only meant to be run from docker HEALTHCHECK.
+
+```text
+ Argument: --health-check
+```
+
+## Programatic Output (porcelain)
+
+Writes the session results to STDOUT using a stable, machine-readable format (indicated by the argument VERSION).
+
+Alias for:
+
+```text
+ --notification-url logger://
+ --notification-log-stdout
+ --notification-report
+ --notification-template porcelain.VERSION.summary-no-log
+
+ Argument: --porcelain, -P
+Environment Variable: WATCHTOWER_PORCELAIN
+ Possible values: v1
+ Default: -
+```
diff --git a/docs/assets/grafana-dashboard.png b/docs/assets/grafana-dashboard.png
new file mode 100644
index 0000000..faab422
Binary files /dev/null and b/docs/assets/grafana-dashboard.png differ
diff --git a/docs/container-selection.md b/docs/container-selection.md
index 4c3312c..8327c66 100644
--- a/docs/container-selection.md
+++ b/docs/container-selection.md
@@ -1,25 +1,81 @@
By default, watchtower will watch all containers. However, sometimes only some containers should be updated.
-If you need to exclude some containers, set the _com.centurylinklabs.watchtower.enable_ label to `false`.
+There are two options:
+
+- **Fully exclude**: You can choose to exclude containers entirely from being watched by watchtower.
+- **Monitor only**: In this mode, watchtower checks for container updates, sends notifications and invokes the [pre-check/post-check hooks](https://containrrr.dev/watchtower/lifecycle-hooks/) on the containers but does **not** perform the update.
+
+## Full Exclude
+
+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.
+
+=== "dockerfile"
+
+ ```docker
+ LABEL com.centurylinklabs.watchtower.enable="false"
+ ```
+=== "docker run"
+
+ ```bash
+ docker run -d --label=com.centurylinklabs.watchtower.enable=false someimage
+ ```
+
+=== "docker-compose"
+
+ ``` yaml
+ version: "3"
+ services:
+ someimage:
+ container_name: someimage
+ labels:
+ - "com.centurylinklabs.watchtower.enable=false"
+ ```
+
+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.
+
+=== "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;
+
+## Monitor Only
+
+Individual containers can be marked to only be monitored (without being updated).
+
+To do so, set the *com.centurylinklabs.watchtower.monitor-only* label to `true` on that container.
```docker
-LABEL com.centurylinklabs.watchtower.enable="false"
+LABEL com.centurylinklabs.watchtower.monitor-only="true"
```
Or, it can be specified as part of the `docker run` command line:
```bash
-docker run -d --label=com.centurylinklabs.watchtower.enable=false someimage
+docker run -d --label=com.centurylinklabs.watchtower.monitor-only=true someimage
```
-If you need to include only some containers, pass the `--label-enable` flag on startup and set the _com.centurylinklabs.watchtower.enable_ label with a value of `true` for the containers you want to watch.
-
-```docker
-LABEL com.centurylinklabs.watchtower.enable="true"
-```
-
-Or, it can be specified as part of the `docker run` command line:
-
-```bash
-docker run -d --label=com.centurylinklabs.watchtower.enable=true someimage
-```
\ No newline at end of file
+When the label is specified on a container, watchtower treats that container exactly as if [`WATCHTOWER_MONITOR_ONLY`](https://containrrr.dev/watchtower/arguments/#without_updating_containers) was set, but the effect is limited to the individual container.
diff --git a/docs/http-api-mode.md b/docs/http-api-mode.md
new file mode 100644
index 0000000..69812bb
--- /dev/null
+++ b/docs/http-api-mode.md
@@ -0,0 +1,45 @@
+Watchtower provides an HTTP API mode that enables an HTTP endpoint that can be requested to trigger container updating. The current available endpoint list is:
+
+- `/v1/update` - triggers an update for all of the containers monitored by this Watchtower instance.
+
+---
+
+To enable this mode, use the flag `--http-api-update`. For example, in a Docker Compose config file:
+
+```yaml
+version: '3'
+
+services:
+ app-monitored-by-watchtower:
+ image: myapps/monitored-by-watchtower
+ labels:
+ - "com.centurylinklabs.watchtower.enable=true"
+
+ watchtower:
+ image: containrrr/watchtower
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ command: --debug --http-api-update
+ environment:
+ - WATCHTOWER_HTTP_API_TOKEN=mytoken
+ labels:
+ - "com.centurylinklabs.watchtower.enable=false"
+ ports:
+ - 8080:8080
+```
+
+By default, enabling this mode prevents periodic polls (i.e. what is specified using `--interval` or `--schedule`). To run periodic updates regardless, pass `--http-api-periodic-polls`.
+
+Notice that there is an environment variable named WATCHTOWER_HTTP_API_TOKEN. To prevent external services from accidentally triggering image updates, all of the requests have to contain a "Token" field, valued as the token defined in WATCHTOWER_HTTP_API_TOKEN, in their headers. In this case, there is a port bind to the host machine, allowing to request localhost:8080 to reach Watchtower. The following `curl` command would trigger an image update:
+
+```bash
+curl -H "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/images/favicon.ico b/docs/images/favicon.ico
new file mode 100644
index 0000000..b9099b4
Binary files /dev/null and b/docs/images/favicon.ico differ
diff --git a/docs/images/logo-450px.png b/docs/images/logo-450px.png
new file mode 100644
index 0000000..3049526
Binary files /dev/null and b/docs/images/logo-450px.png differ
diff --git a/docs/index.md b/docs/index.md
index c0bab4c..1d0b2cc 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,3 +1,6 @@
+
+
+
Watchtower
@@ -8,12 +11,12 @@
+
+
+
-
-
-
@@ -23,27 +26,40 @@
-
-
+
+
-
-
+
+
-
-
-
-
-
+
+
## Quick Start
-With watchtower you can update the running version of your containerized app simply by pushing a new image to the Docker Hub or your own image registry. Watchtower will pull down your new image, gracefully shut down your existing container and restart it with the same options that were used when it was deployed initially. Run the watchtower container with the following command:
+With watchtower you can update the running version of your containerized app simply by pushing a new image to the Docker
+Hub or your own image registry. Watchtower will pull down your new image, gracefully shut down your existing container
+and restart it with the same options that were used when it was deployed initially. Run the watchtower container with
+the following command:
-```
-$ docker run -d \
+=== "docker run"
+
+ ```bash
+ $ docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower
-```
\ No newline at end of file
+ ```
+
+=== "docker-compose.yml"
+
+ ```yaml
+ version: "3"
+ services:
+ watchtower:
+ image: containrrr/watchtower
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ ```
diff --git a/docs/introduction.md b/docs/introduction.md
index 9e0f5fe..cbbc3a3 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -4,12 +4,12 @@ With watchtower you can update the running version of your containerized app sim
For example, let's say you were running watchtower along with an instance of _centurylink/wetty-cli_ image:
-```bash
+```text
$ docker ps
CONTAINER ID IMAGE STATUS PORTS NAMES
967848166a45 centurylink/wetty-cli Up 10 minutes 0.0.0.0:8080->3000/tcp wetty
6cc4d2a9d1a5 containrrr/watchtower Up 15 minutes watchtower
```
-Every few minutes watchtower will pull the latest _centurylink/wetty-cli_ image and compare it to the one that was used to run the "wetty" container. If it sees that the image has changed it will stop/remove the "wetty" container and then restart it using the new image and the same `docker run` options that were used to start the container initially (in this case, that would include the `-p 8080:3000` port mapping).
+Every day watchtower will pull the latest _centurylink/wetty-cli_ image and compare it to the one that was used to run the "wetty" container. If it sees that the image has changed it will stop/remove the "wetty" container and then restart it using the new image and the same `docker run` options that were used to start the container initially (in this case, that would include the `-p 8080:3000` port mapping).
diff --git a/docs/lifecycle-hooks.md b/docs/lifecycle-hooks.md
index 071726c..0c1e4e0 100644
--- a/docs/lifecycle-hooks.md
+++ b/docs/lifecycle-hooks.md
@@ -1,15 +1,19 @@
## Executing commands before and after updating
-> **DO NOTE**: These are shell commands executed with `sh`, and therefore require the
-> container to provide the `sh` executable.
+!!! note
+ These are shell commands executed with `sh`, and therefore require the container to provide the `sh`
+ executable.
+
+> **DO NOTE**: If the container is not running then lifecycle hooks can not run and therefore
+> the update is executed without running any lifecycle hooks.
It is possible to execute _pre/post\-check_ and _pre/post\-update_ commands
**inside** every container updated by watchtower.
-- The _pre-check_ command is executed for each container prior to every update cycle.
-- The _pre-update_ command is executed before stopping the container when an update is about to start.
-- The _post-update_ command is executed after restarting the updated container
-- The _post-check_ command is executed for each container post every update cycle.
+- The _pre-check_ command is executed for each container prior to every update cycle.
+- The _pre-update_ command is executed before stopping the container when an update is about to start.
+- The _post-update_ command is executed after restarting the updated container
+- The _post-check_ command is executed for each container post every update cycle.
This feature is disabled by default. To enable it, you need to set the option
`--enable-lifecycle-hooks` on the command line, or set the environment variable
@@ -26,28 +30,41 @@ The commands are specified using docker container labels, the following are curr
| Post Update | `com.centurylinklabs.watchtower.lifecycle.post-update` |
| Post Check | `com.centurylinklabs.watchtower.lifecycle.post-check` |
-These labels can be declared as instructions in a Dockerfile (with some example .sh files):
+These labels can be declared as instructions in a Dockerfile (with some example .sh files) or be specified as part of
+the `docker run` command line:
-```docker
-LABEL com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh"
-LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh"
-LABEL com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh"
-LABEL com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh"
-```
+=== "Dockerfile"
+ ```docker
+ LABEL com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh"
+ LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh"
+ LABEL com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh"
+ LABEL com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh"
+ ```
-Or be specified as part of the `docker run` command line:
+=== "docker run"
+ ```bash
+ docker run -d \
+ --label=com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh" \
+ --label=com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh" \
+ --label=com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh" \
+ someimage --label=com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh" \
+ ```
-```bash
-docker run -d \
- --label=com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh" \
- --label=com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh" \
- --label=com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh" \
- someimage
- --label=com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh" \
-```
+### Timeouts
+The timeout for all lifecycle commands is 60 seconds. After that, a timeout will
+occur, forcing Watchtower to continue the update loop.
+
+#### Pre- or Post-update timeouts
+
+For the `pre-update` or `post-update` lifecycle command, it is possible to override this timeout to
+allow the script to finish before forcefully killing it. This is done by adding the
+label `com.centurylinklabs.watchtower.lifecycle.pre-update-timeout` or post-update-timeout respectively followed by
+the timeout expressed in minutes.
+
+If the label value is explicitly set to `0`, the timeout will be disabled.
### Execution failure
The failure of a command to execute, identified by an exit code different than
-0, will not prevent watchtower from updating the container. Only an error
+0 or 75 (EX_TEMPFAIL), will not prevent watchtower from updating the container. Only an error
log statement containing the exit code will be reported.
diff --git a/docs/linked-containers.md b/docs/linked-containers.md
index 6960b5b..c7e9be8 100644
--- a/docs/linked-containers.md
+++ b/docs/linked-containers.md
@@ -1,3 +1,7 @@
Watchtower will detect if there are links between any of the running containers and ensures that things are stopped/started in a way that won't break any of the links. If an update is detected for one of the dependencies in a group of linked containers, watchtower will stop and start all of the containers in the correct order so that the application comes back up correctly.
-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.
\ No newline at end of file
+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, 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
new file mode 100644
index 0000000..480d7c6
--- /dev/null
+++ b/docs/metrics.md
@@ -0,0 +1,42 @@
+!!! warning "Experimental feature"
+ This feature was added in v1.0.4 and is still considered experimental. If you notice any strange behavior, please raise
+ a ticket in the repository issues.
+
+Metrics can be used to track how Watchtower behaves over time.
+
+To use this feature, you have to set an [API token](arguments.md#http_api_token) and [enable the metrics API](arguments.md#http_api_metrics),
+as well as creating a port mapping for your container for port `8080`.
+
+The metrics API endpoint is `/v1/metrics`.
+
+## Available Metrics
+
+| Name | Type | Description |
+| ------------------------------- | ------- | --------------------------------------------------------------------------- |
+| `watchtower_containers_scanned` | Gauge | Number of containers scanned for changes by watchtower during the last scan |
+| `watchtower_containers_updated` | Gauge | Number of containers updated by watchtower during the last scan |
+| `watchtower_containers_failed` | Gauge | Number of containers where update failed during the last scan |
+| `watchtower_scans_total` | Counter | Number of scans since the watchtower started |
+| `watchtower_scans_skipped` | Counter | Number of skipped scans since watchtower started |
+
+## Example Prometheus `scrape_config`
+
+```yaml
+scrape_configs:
+ - job_name: watchtower
+ scrape_interval: 5s
+ metrics_path: /v1/metrics
+ bearer_token: demotoken
+ static_configs:
+ - targets:
+ - 'watchtower:8080'
+```
+
+Replace `demotoken` with the Bearer token you have set accordingly.
+
+## Demo
+
+The repository contains a demo with prometheus and grafana, available through `docker-compose.yml`. This demo
+is preconfigured with a dashboard, which will look something like this:
+
+
diff --git a/docs/notifications.md b/docs/notifications.md
index b95e95e..d5da4fe 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -1,39 +1,262 @@
-
# Notifications
-Watchtower can send notifications when containers are updated. Notifications are sent via hooks in the logging system, [logrus](http://github.com/sirupsen/logrus).
-The types of notifications to send are set by passing a comma-separated list of values to the `--notifications` option (or corresponding environment variable `WATCHTOWER_NOTIFICATIONS`), which has the following valid values:
+Watchtower can send notifications when containers are updated. Notifications are sent via hooks in the logging
+system, [logrus](http://github.com/sirupsen/logrus).
-- `email` to send notifications via e-mail
-- `slack` to send notifications through a Slack webhook
-- `msteams` to send notifications via MSTeams webhook
-- `gotify` to send notifications via Gotify
-
-> There is currently a [bug](https://github.com/spf13/viper/issues/380) in Viper, which prevents comma-separated slices to be used when using the environment variable. A workaround is available where we instead put quotes around the environment variable value and replace the commas with spaces, as `WATCHTOWER_NOTIFICATIONS="slack msteams"`
-
-> If you're a `docker-compose` user, make sure to specify environment variables' values in your `.yml` file without double quotes (`"`).
->
-> This prevents unexpected errors when watchtower starts.
+!!! note "Using multiple notifications with environment variables"
+ There is currently a bug in Viper (https://github.com/spf13/viper/issues/380), which prevents comma-separated slices to
+ be used when using the environment variable.
+ A workaround is available where we instead put quotes around the environment variable value and replace the commas with
+ spaces:
+ ```
+ WATCHTOWER_NOTIFICATIONS="slack msteams"
+ ```
+ If you're a `docker-compose` user, make sure to specify environment variables' values in your `.yml` file without double
+ quotes (`"`). This prevents unexpected errors when watchtower starts.
## Settings
-- `--notifications-level` (env. `WATCHTOWER_NOTIFICATIONS_LEVEL`): Controls the log level which is used for the notifications. If omitted, the default log level is `info`. Possible values are: `panic`, `fatal`, `error`, `warn`, `info` or `debug`.
+- `--notifications-level` (env. `WATCHTOWER_NOTIFICATIONS_LEVEL`): Controls the log level which is used for the notifications. If omitted, the default log level is `info`. Possible values are: `panic`, `fatal`, `error`, `warn`, `info`, `debug` or `trace`.
+- `--notifications-hostname` (env. `WATCHTOWER_NOTIFICATIONS_HOSTNAME`): Custom hostname specified in subject/title. Useful to override the operating system hostname.
+- `--notifications-delay` (env. `WATCHTOWER_NOTIFICATIONS_DELAY`): Delay before sending notifications expressed in seconds.
+- Watchtower will post a notification every time it is started. This behavior [can be changed](https://containrrr.github.io/watchtower/arguments/#without_sending_a_startup_message) with an argument.
+- `--notification-title-tag` (env. `WATCHTOWER_NOTIFICATION_TITLE_TAG`): Prefix to include in the title. Useful when running multiple watchtowers.
+- `--notification-skip-title` (env. `WATCHTOWER_NOTIFICATION_SKIP_TITLE`): Do not pass the title param to notifications. This will not pass a dynamic title override to notification services. If no title is configured for the service, it will remove the title all together.
+- `--notification-log-stdout` (env. `WATCHTOWER_NOTIFICATION_LOG_STDOUT`): Enable output from `logger://` shoutrrr service to stdout.
-## Available services
+## [Shoutrrr](https://github.com/containrrr/shoutrrr) notifications
+
+To send notifications via shoutrrr, the following command-line options, or their corresponding environment variables, can be set:
+
+- `--notification-url` (env. `WATCHTOWER_NOTIFICATION_URL`): The shoutrrr service URL to be used. This option can also reference a file, in which case the contents of the file are used.
+
+
+Go to [containrrr.dev/shoutrrr/v0.8/services/overview](https://containrrr.dev/shoutrrr/v0.8/services/overview) to
+learn more about the different service URLs you can use. You can define multiple services by space separating the
+URLs. (See example below)
+
+You can customize the message posted by setting a template.
+
+- `--notification-template` (env. `WATCHTOWER_NOTIFICATION_TEMPLATE`): The template used for the message.
+
+The template is a Go [template](https://golang.org/pkg/text/template/) that either format a list
+of [log entries](https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry) or a `notification.Data` struct.
+
+Simple templates are used unless the `notification-report` flag is specified:
+
+- `--notification-report` (env. `WATCHTOWER_NOTIFICATION_REPORT`): Use the session report as the notification template data.
+
+## Simple templates
+
+The default value if not set is `{{range .}}{{.Message}}{{println}}{{end}}`. The example below uses a template that also
+outputs timestamp and log level.
+
+!!! tip "Custom date format"
+ If you want to adjust the date/time format it must show how the
+ [reference time](https://golang.org/pkg/time/#pkg-constants) (_Mon Jan 2 15:04:05 MST 2006_) would be displayed in your
+ custom format.
+ i.e., The day of the year has to be 1, the month has to be 2 (february), the hour 3 (or 15 for 24h time) etc.
+
+!!! note "Skipping notifications"
+ To skip sending notifications that do not contain any information, you can wrap your template with `{{if .}}` and `{{end}}`.
+
+
+Example:
+
+```bash
+docker run -d \
+ --name watchtower \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -e WATCHTOWER_NOTIFICATION_URL="discord://token@channel slack://watchtower@token-a/token-b/token-c" \
+ -e WATCHTOWER_NOTIFICATION_TEMPLATE="{{range .}}{{.Time.Format \"2006-01-02 15:04:05\"}} ({{.Level}}): {{.Message}}{{println}}{{end}}" \
+ containrrr/watchtower
+```
+
+## Report templates
+
+The default template for report notifications are the following:
+```go
+{{- if .Report -}}
+ {{- with .Report -}}
+ {{- if ( or .Updated .Failed ) -}}
+{{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
+ {{- range .Updated}}
+- {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
+ {{- end -}}
+ {{- range .Fresh}}
+- {{.Name}} ({{.ImageName}}): {{.State}}
+ {{- end -}}
+ {{- range .Skipped}}
+- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- range .Failed}}
+- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+{{- else -}}
+ {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
+{{- end -}}
+```
+
+It will be used to send a summary of every session if there are any containers that were updated or which failed to update.
+
+!!! note "Skipping notifications"
+ Whenever the result of applying the template results in an empty string, no notifications will
+ be sent. This is by default used to limit the notifications to only be sent when there something noteworthy occurred.
+
+ You can replace `{{- if ( or .Updated .Failed ) -}}` with any logic you want to decide when to send the notifications.
+
+Example using a custom report template that always sends a session report after each run:
+
+=== "docker run"
+
+ ```bash
+ docker run -d \
+ --name watchtower \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -e WATCHTOWER_NOTIFICATION_REPORT="true" \
+ -e WATCHTOWER_NOTIFICATION_URL="discord://token@channel slack://watchtower@token-a/token-b/token-c" \
+ -e WATCHTOWER_NOTIFICATION_TEMPLATE="
+ {{- if .Report -}}
+ {{- with .Report -}}
+ {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
+ {{- range .Updated}}
+ - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
+ {{- end -}}
+ {{- range .Fresh}}
+ - {{.Name}} ({{.ImageName}}): {{.State}}
+ {{- end -}}
+ {{- range .Skipped}}
+ - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- range .Failed}}
+ - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- end -}}
+ {{- else -}}
+ {{range .Entries -}}{{.Message}}{{\"\n\"}}{{- end -}}
+ {{- end -}}
+ " \
+ containrrr/watchtower
+ ```
+
+=== "docker-compose"
+
+ ``` yaml
+ version: "3"
+ services:
+ watchtower:
+ image: containrrr/watchtower
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ env:
+ WATCHTOWER_NOTIFICATION_REPORT: "true"
+ WATCHTOWER_NOTIFICATION_URL: >
+ discord://token@channel
+ slack://watchtower@token-a/token-b/token-c
+ WATCHTOWER_NOTIFICATION_TEMPLATE: |
+ {{- if .Report -}}
+ {{- with .Report -}}
+ {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
+ {{- range .Updated}}
+ - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
+ {{- end -}}
+ {{- range .Fresh}}
+ - {{.Name}} ({{.ImageName}}): {{.State}}
+ {{- end -}}
+ {{- range .Skipped}}
+ - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- range .Failed}}
+ - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+ {{- end -}}
+ {{- end -}}
+ {{- else -}}
+ {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
+ {{- end -}}
+ ```
+
+## Legacy notifications
+
+For backwards compatibility, the notifications can also be configured using legacy notification options. These will automatically be converted to shoutrrr URLs when used.
+The types of notifications to send are set by passing a comma-separated list of values to the `--notifications` option
+(or corresponding environment variable `WATCHTOWER_NOTIFICATIONS`), which has the following valid values:
+
+- `email` to send notifications via e-mail
+- `slack` to send notifications through a Slack webhook
+- `msteams` to send notifications via MSTeams webhook
+- `gotify` to send notifications via Gotify
+
+### `notify-upgrade`
+If watchtower is started with `notify-upgrade` as it's first argument, it will generate a .env file with your current legacy notification options converted to shoutrrr URLs.
+
+=== "docker run"
+
+ ```bash
+ $ docker run -d \
+ --name watchtower \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -e WATCHTOWER_NOTIFICATIONS=slack \
+ -e WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL="https://hooks.slack.com/services/xxx/yyyyyyyyyyyyyyy" \
+ containrrr/watchtower \
+ notify-upgrade
+ ```
+
+=== "docker-compose.yml"
+
+ ```yaml
+ version: "3"
+ services:
+ watchtower:
+ image: containrrr/watchtower
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ env:
+ WATCHTOWER_NOTIFICATIONS: slack
+ WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL: https://hooks.slack.com/services/xxx/yyyyyyyyyyyyyyy
+ command: notify-upgrade
+ ```
+
+
+You can then copy this file from the container (a message with the full command to do so will be logged) and use it with your current setup:
+
+=== "docker run"
+
+ ```bash
+ $ docker run -d \
+ --name watchtower \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ --env-file watchtower-notifications.env \
+ containrrr/watchtower
+ ```
+
+=== "docker-compose.yml"
+
+ ```yaml
+ version: "3"
+ services:
+ watchtower:
+ image: containrrr/watchtower
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ env_file:
+ - watchtower-notifications.env
+ ```
### Email
To receive notifications by email, the following command-line options, or their corresponding environment variables, can be set:
-- `--notification-email-from` (env. `WATCHTOWER_NOTIFICATION_EMAIL_FROM`): The e-mail address from which notifications will be sent.
-- `--notification-email-to` (env. `WATCHTOWER_NOTIFICATION_EMAIL_TO`): The e-mail address to which notifications will be sent.
-- `--notification-email-server` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER`): The SMTP server to send e-mails through.
-- `--notification-email-server-tls-skip-verify` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY`): Do not verify the TLS certificate of the mail server. This should be used only for testing.
-- `--notification-email-server-port` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT`): The port used to connect to the SMTP server to send e-mails through. Defaults to `25`.
-- `--notification-email-server-user` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER`): The username to authenticate with the SMTP server with.
-- `--notification-email-server-password` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD`): The password to authenticate with the SMTP server with.
-- `--notification-email-delay` (env. `WATCHTOWER_NOTIFICATION_EMAIL_DELAY`): Delay before sending notifications expressed in seconds.
-- `--notification-email-subjecttag` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG`): Prefix to include in the subject tag. Useful when running multiple watchtowers.
+- `--notification-email-from` (env. `WATCHTOWER_NOTIFICATION_EMAIL_FROM`): The e-mail address from which notifications will be sent.
+- `--notification-email-to` (env. `WATCHTOWER_NOTIFICATION_EMAIL_TO`): The e-mail address to which notifications will be sent.
+- `--notification-email-server` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER`): The SMTP server to send e-mails through.
+- `--notification-email-server-tls-skip-verify` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY`): Do not verify the TLS certificate of the mail server. This should be used only for testing.
+- `--notification-email-server-port` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT`): The port used to connect to the SMTP server to send e-mails through. Defaults to `25`.
+- `--notification-email-server-user` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER`): The username to authenticate with the SMTP server with.
+- `--notification-email-server-password` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD`): The password to authenticate with the SMTP server with. Can also reference a file, in which case the contents of the file are used.
+- `--notification-email-delay` (env. `WATCHTOWER_NOTIFICATION_EMAIL_DELAY`): Delay before sending notifications expressed in seconds.
+- `--notification-email-subjecttag` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG`): Prefix to include in the subject tag. Useful when running multiple watchtowers. **NOTE:** This will affect all notification types.
Example:
@@ -45,26 +268,74 @@ docker run -d \
-e WATCHTOWER_NOTIFICATION_EMAIL_FROM=fromaddress@gmail.com \
-e WATCHTOWER_NOTIFICATION_EMAIL_TO=toaddress@gmail.com \
-e WATCHTOWER_NOTIFICATION_EMAIL_SERVER=smtp.gmail.com \
+ -e WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT=587 \
-e WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER=fromaddress@gmail.com \
-e WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD=app_password \
-e WATCHTOWER_NOTIFICATION_EMAIL_DELAY=2 \
containrrr/watchtower
```
+The previous example assumes, that you already have an SMTP server up and running you can connect to. If you don't or you want to bring up watchtower with your own simple SMTP relay the following `docker-compose.yml` might be a good start for you.
+
+The following example assumes, that your domain is called `your-domain.com` and that you are going to use a certificate valid for `smtp.your-domain.com`. This hostname has to be used as `WATCHTOWER_NOTIFICATION_EMAIL_SERVER` otherwise the TLS connection is going to fail with `Failed to send notification email` or `connect: connection refused`. We also have to add a network for this setup in order to add an alias to it. If you also want to enable DKIM or other features on the SMTP server, you will find more information at [freinet/postfix-relay](https://hub.docker.com/r/freinet/postfix-relay).
+
+Example including an SMTP relay:
+
+```yaml
+version: '3.8'
+services:
+ watchtower:
+ image: containrrr/watchtower:latest
+ container_name: watchtower
+ environment:
+ WATCHTOWER_MONITOR_ONLY: 'true'
+ WATCHTOWER_NOTIFICATIONS: email
+ WATCHTOWER_NOTIFICATION_EMAIL_FROM: from-address@your-domain.com
+ WATCHTOWER_NOTIFICATION_EMAIL_TO: to-address@your-domain.com
+ # you have to use a network alias here, if you use your own certificate
+ WATCHTOWER_NOTIFICATION_EMAIL_SERVER: smtp.your-domain.com
+ WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT: 25
+ WATCHTOWER_NOTIFICATION_EMAIL_DELAY: 2
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ networks:
+ - watchtower
+ depends_on:
+ - postfix
+
+ # SMTP needed to send out status emails
+ postfix:
+ image: freinet/postfix-relay:latest
+ expose:
+ - 25
+ environment:
+ MAILNAME: somename.your-domain.com
+ TLS_KEY: '/etc/ssl/domains/your-domain.com/your-domain.com.key'
+ TLS_CRT: '/etc/ssl/domains/your-domain.com/your-domain.com.crt'
+ TLS_CA: '/etc/ssl/domains/your-domain.com/intermediate.crt'
+ volumes:
+ - /etc/ssl/domains/your-domain.com/:/etc/ssl/domains/your-domain.com/:ro
+ networks:
+ watchtower:
+ # this alias is really important to make your certificate work
+ aliases:
+ - smtp.your-domain.com
+networks:
+ watchtower:
+ external: false
+```
+
### Slack
-If watchtower is monitoring the same Docker daemon under which the watchtower container itself is running (i.e. if you volume-mounted _/var/run/docker.sock_ into the watchtower container) then it has the ability to update itself. If a new version of the _containrrr/watchtower_ image is pushed to the Docker Hub, your watchtower will pull down the new image and restart itself automatically.
To receive notifications in Slack, add `slack` to the `--notifications` option or the `WATCHTOWER_NOTIFICATIONS` environment variable.
-Additionally, you should set the Slack webhook URL using the `--notification-slack-hook-url` option or the `WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL` environment variable.
+Additionally, you should set the Slack webhook URL using the `--notification-slack-hook-url` option or the `WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL` environment variable. This option can also reference a file, in which case the contents of the file are used.
By default, watchtower will send messages under the name `watchtower`, you can customize this string through the `--notification-slack-identifier` option or the `WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER` environment variable.
Other, optional, variables include:
-- `--notification-slack-channel` (env. `WATCHTOWER_NOTIFICATION_SLACK_CHANNEL`): A string which overrides the webhook's default channel. Example: #my-custom-channel.
-- `--notification-slack-icon-emoji` (env. `WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI`): An [emoji code](https://www.webpagefx.com/tools/emoji-cheat-sheet/) string to use in place of the default icon.
-- `--notification-slack-icon-url` (env. `WATCHTOWER_NOTIFICATION_SLACK_ICON_URL`): An icon image URL string to use in place of the default icon.
+- `--notification-slack-channel` (env. `WATCHTOWER_NOTIFICATION_SLACK_CHANNEL`): A string which overrides the webhook's default channel. Example: #my-custom-channel.
Example:
@@ -76,8 +347,6 @@ docker run -d \
-e WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL="https://hooks.slack.com/services/xxx/yyyyyyyyyyyyyyy" \
-e WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER=watchtower-server-1 \
-e WATCHTOWER_NOTIFICATION_SLACK_CHANNEL=#my-custom-channel \
- -e WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI=:whale: \
- -e WATCHTOWER_NOTIFICATION_SLACK_ICON_URL= \
containrrr/watchtower
```
@@ -85,7 +354,7 @@ docker run -d \
To receive notifications in MSTeams channel, add `msteams` to the `--notifications` option or the `WATCHTOWER_NOTIFICATIONS` environment variable.
-Additionally, you should set the MSTeams webhook URL using the `--notification-msteams-hook` option or the `WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL` environment variable.
+Additionally, you should set the MSTeams webhook URL using the `--notification-msteams-hook` option or the `WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL` environment variable. This option can also reference a file, in which case the contents of the file are used.
MSTeams notifier could send keys/values filled by `log.WithField` or `log.WithFields` as MSTeams message facts. To enable this feature add `--notification-msteams-data` flag or set `WATCHTOWER_NOTIFICATION_MSTEAMS_USE_LOG_DATA=true` environment variable.
@@ -105,7 +374,6 @@ docker run -d \
To push a notification to your Gotify instance, register a Gotify app and specify the Gotify URL and app token:
-
```bash
docker run -d \
--name watchtower \
@@ -115,3 +383,8 @@ docker run -d \
-e WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN="SuperSecretToken" \
containrrr/watchtower
```
+
+`-e WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN` or `--notification-gotify-token` can also reference a file, in which case the contents of the file are used.
+
+If you want to disable TLS verification for the Gotify instance, you can use either `-e WATCHTOWER_NOTIFICATION_GOTIFY_TLS_SKIP_VERIFY=true` or `--notification-gotify-tls-skip-verify`.
+
diff --git a/docs/private-registries.md b/docs/private-registries.md
index 13e7618..5367a8c 100644
--- a/docs/private-registries.md
+++ b/docs/private-registries.md
@@ -1,12 +1,17 @@
-Watchtower supports private Docker image registries. In many cases, accessing a private registry requires a valid username and password (i.e., _credentials_). In order to operate in such an environment, watchtower needs to know the credentials to access the registry.
+Watchtower supports private Docker image registries. In many cases, accessing a private registry
+requires a valid username and password (i.e., _credentials_). In order to operate in such an
+environment, watchtower needs to know the credentials to access the registry.
-The credentials can be provided to watchtower in a configuration file called `config.json`. There are two ways to generate this configuration file:
+The credentials can be provided to watchtower in a configuration file called `config.json`.
+There are two ways to generate this configuration file:
-* The configuration file can be created manually.
-* Call `docker login ` and share the resulting configuration file.
+* The configuration file can be created manually.
+* Call `docker login ` and share the resulting configuration file.
### Create the configuration file manually
-Create a new configuration file with the following syntax and a base64 encoded username and password `auth` string:
+Create a new configuration file with the following syntax and a base64 encoded username and
+password `auth` string:
+
```json
{
"auths": {
@@ -17,36 +22,91 @@ Create a new configuration file with the following syntax and a base64 encoded u
}
```
-`` needs to be replaced by the name of your private registry (e.g., `my-private-registry.example.org`)
+`` needs to be replaced by the name of your private registry
+(e.g., `my-private-registry.example.org`).
+
+!!! info "Using private images on Docker Hub"
+ To access private repositories on Docker Hub,
+ `` should be `https://index.docker.io/v1/`.
+ In this special case, the registry domain does not have to be specified
+ in `docker run` or `docker-compose`. Like Docker, Watchtower will use the
+ Docker Hub registry and its credentials when no registry domain is specified.
+
+ Watchtower will recognize credentials with `` `index.docker.io`,
+ but the Docker CLI will not.
+
+!!! important "Using a private registry on a local host"
+ To use a private registry hosted locally, make sure to correctly specify the registry host
+ in both `config.json` and the `docker run` command or `docker-compose` file.
+ Valid hosts are `localhost[:PORT]`, `HOST:PORT`,
+ or any multi-part `domain.name` or IP-address with or without a port.
+
+ Examples:
+ * `localhost` -> `localhost/myimage`
+ * `127.0.0.1` -> `127.0.0.1/myimage:mytag`
+ * `host.domain` -> `host.domain/myorganization/myimage`
+ * `other-lan-host:80` -> `other-lan-host:80/imagename:latest`
The required `auth` string can be generated as follows:
+
```bash
echo -n 'username:password' | base64
```
-When the watchtower Docker container is started, the created configuration file (`/config.json` in this example) needs to be passed to the container:
+!!! info "Username and Password for GCloud"
+ For gcloud, we'll use `_json_key` as our username and the content of `gcloudauth.json` as the password.
+ ```
+ bash echo -n "_json_key:$(cat gcloudauth.json)" | base64 -w0
+ ```
+
+When the watchtower Docker container is started, the created configuration file
+(`/config.json` in this example) needs to be passed to the container:
+
```bash
docker run [...] -v /config.json:/config.json containrrr/watchtower
```
### Share the Docker configuration file
-To pull an image from a private registry, `docker login` needs to be called first, to get access to the registry. The provided credentials are stored in a configuration file called `/.docker/config.json`. This configuration file can be directly used by watchtower. In this case, the creation of an additional configuration file is not necessary.
+
+To pull an image from a private registry, `docker login` needs to be called first, to get access
+to the registry. The provided credentials are stored in a configuration file called `/.docker/config.json`.
+This configuration file can be directly used by watchtower. In this case, the creation of an
+additional configuration file is not necessary.
When the Docker container is started, pass the configuration file to watchtower:
+
```bash
docker run [...] -v /.docker/config.json:/config.json containrrr/watchtower
```
When creating the watchtower container via docker-compose, use the following lines:
+
```yaml
-version: "3"
-[...]
-watchtower:
- image: index.docker.io/containrrr/watchtower:latest
- volumes:
+version: "3.4"
+services:
+ watchtower:
+ image: containrrr/watchtower:latest
+ volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /.docker/config.json:/config.json
-[...]
+ ...
+```
+
+#### Docker Config path
+By default, watchtower will look for the `config.json` file in `/`, but this can be changed by setting the `DOCKER_CONFIG` environment variable to the directory path where your config is located. This is useful for setups where the config.json file is changed while the watchtower instance is running, as the changes will not be picked up for a mounted file if the inode changes.
+Example usage:
+
+```yaml
+version: "3.4"
+
+services:
+ watchtower:
+ image: containrrr/watchtower
+ environment:
+ DOCKER_CONFIG: /config
+ volumes:
+ - /etc/watchtower/config/:/config/
+ - /var/run/docker.sock:/var/run/docker.sock
```
## Credential helpers
@@ -59,60 +119,89 @@ helper in a separate container and mount it using volumes.
### Example
Example implementation for use with [amazon-ecr-credential-helper](https://github.com/awslabs/amazon-ecr-credential-helper):
-```Dockerfile
-FROM golang:latest
+Use the dockerfile below to build the [amazon-ecr-credential-helper](https://github.com/awslabs/amazon-ecr-credential-helper),
+in a volume that may be mounted onto your watchtower container.
-ENV CGO_ENABLED 0
-ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
+1. Create the Dockerfile (contents below):
+ ```Dockerfile
+ FROM golang:1.20
+
+ ENV GO111MODULE off
+ ENV CGO_ENABLED 0
+ ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
+
+ RUN go get -u $REPO
+
+ RUN rm /go/bin/docker-credential-ecr-login
+
+ RUN go build \
+ -o /go/bin/docker-credential-ecr-login \
+ /go/src/$REPO
+
+ WORKDIR /go/bin/
+ ```
-RUN go get -u $REPO
+2. Use the following commands to build the aws-ecr-dock-cred-helper and store it's output in a volume:
+ ```bash
+ # Create a volume to store the command (once built)
+ docker volume create helper
+
+ # Build the container
+ docker build -t aws-ecr-dock-cred-helper .
+
+ # Build the command and store it in the new volume in the /go/bin directory.
+ docker run -d --rm --name aws-cred-helper \
+ --volume helper:/go/bin aws-ecr-dock-cred-helper
+ ```
-RUN rm /go/bin/docker-credential-ecr-login
-
-RUN go build \
- -o /go/bin/docker-credential-ecr-login \
- /go/src/$REPO
-
-WORKDIR /go/bin/
-```
-
-and the docker-compose definition:
-```yaml
-version: "3"
-
-services:
- watchtower:
- image: index.docker.io/containrrr/watchtower:latest
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - /.docker/config.json:/config.json
- - helper:/go/bin
- environment:
- - HOME=/
- - PATH=$PATH:/go/bin
- - AWS_REGION=
- - AWS_ACCESS_KEY_ID=
- - AWS_SECRET_ACCESS_KEY=
-volumes:
- helper: {}
-```
-
-and for `/.docker/config.json`:
-```json
- {
- "HttpHeaders" : {
- "User-Agent" : "Docker-Client/19.03.1 (XXXXXX)"
- },
- "credsStore" : "osxkeychain",
- "auths" : {
- "xyzxyzxyz.dkr.ecr.eu-north-1.amazonaws.com" : {},
- "https://index.docker.io/v1/": {}
- },
- "credHelpers": {
- "xyzxyzxyz.dkr.ecr.eu-north-1.amazonaws.com" : "ecr-login",
- "index.docker.io": "osxkeychain"
+3. Create a configuration file for docker, and store it in $HOME/.docker/config.json (replace the
+ placeholders with your AWS Account ID and with your AWS ECR Region):
+ ```json
+ {
+ "credsStore" : "ecr-login",
+ "HttpHeaders" : {
+ "User-Agent" : "Docker-Client/19.03.1 (XXXXXX)"
+ },
+ "auths" : {
+ ".dkr.ecr..amazonaws.com" : {}
+ },
+ "credHelpers": {
+ ".dkr.ecr..amazonaws.com" : "ecr-login"
+ }
}
- }
-```
+ ```
-*Note:* `osxkeychain` can be changed to your prefered credentials helper.
+4. Create a docker-compose file (as an example) to help launch the container:
+ ```yaml
+ version: "3.4"
+ services:
+ # Check for new images and restart things if a new image exists
+ # for any of our containers.
+ watchtower:
+ image: containrrr/watchtower:latest
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - .docker/config.json:/config.json
+ - helper:/go/bin
+ environment:
+ - HOME=/
+ - PATH=$PATH:/go/bin
+ - AWS_REGION=us-west-1
+ volumes:
+ helper:
+ external: true
+ ```
+
+A few additional notes:
+
+1. With docker-compose the volume (helper, in this case) MUST be set to `external: true`, otherwise docker-compose
+ will preface it with the directory name.
+
+2. Note that "credsStore" : "ecr-login" is needed - and in theory if you have that you can remove the
+ credHelpers section
+
+3. I have this running on an EC2 instance that has credentials assigned to it - so no keys are needed; however,
+ you may need to include the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables as well.
+
+4. An alternative to adding the various variables is to create a ~/.aws/config and ~/.aws/credentials files and
+ place the settings there, then mount the ~/.aws directory to / in the container.
diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md
index e08fbd3..22c3f94 100644
--- a/docs/remote-hosts.md
+++ b/docs/remote-hosts.md
@@ -15,4 +15,4 @@ docker run -d \
containrrr/watchtower
```
-Note in both of the examples above that it is unnecessary to mount the _/var/run/docker.sock_ into the watchtower container.
\ No newline at end of file
+Note in both of the examples above that it is unnecessary to mount the _/var/run/docker.sock_ into the watchtower container.
diff --git a/docs/running-multiple-instances.md b/docs/running-multiple-instances.md
new file mode 100644
index 0000000..5a82c80
--- /dev/null
+++ b/docs/running-multiple-instances.md
@@ -0,0 +1,41 @@
+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.
+
+!!! 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).
+
+For example, in a Docker Compose config file:
+
+```yaml
+version: '3'
+
+services:
+ app-with-scope:
+ image: myapps/monitored-by-watchtower
+ labels: [ "com.centurylinklabs.watchtower.scope=myscope" ]
+
+ scoped-watchtower:
+ image: containrrr/watchtower
+ volumes: [ "/var/run/docker.sock:/var/run/docker.sock" ]
+ command: --interval 30 --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
new file mode 100644
index 0000000..e552129
--- /dev/null
+++ b/docs/stylesheets/theme.css
@@ -0,0 +1,87 @@
+[data-md-color-scheme="containrrr"] {
+ /* 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;
+}
+
+.md-header-nav__button.md-logo img {
+ width: 1.6rem;
+ height: 1.6rem;
+}
diff --git a/docs/template-preview.md b/docs/template-preview.md
new file mode 100644
index 0000000..3d99ce9
--- /dev/null
+++ b/docs/template-preview.md
@@ -0,0 +1,251 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/updating.md b/docs/updating.md
new file mode 100644
index 0000000..952a0f9
--- /dev/null
+++ b/docs/updating.md
@@ -0,0 +1,6 @@
+## Updating Watchtower
+
+If watchtower is monitoring the same Docker daemon under which the watchtower container itself is running (i.e. if you
+volume-mounted `/var/run/docker.sock` into the watchtower container) then it has the ability to update itself.
+If a new version of the `containrrr/watchtower` image is pushed to the Docker Hub, your watchtower will pull down the
+new image and restart itself automatically.
diff --git a/docs/usage-overview.md b/docs/usage-overview.md
index f74a9a7..1cac352 100644
--- a/docs/usage-overview.md
+++ b/docs/usage-overview.md
@@ -27,23 +27,35 @@ docker run -d \
Also check out [this Stack Overflow answer](https://stackoverflow.com/a/30494145/7872793) for more options on how to pass environment variables.
-Mounting the host's docker config file:
+Alternatively if you 2FA authentication setup on Docker Hub then passing username and password will be insufficient. Instead you can run `docker login` to store your credentials in `$HOME/.docker/config.json` and then mount this config file to make it available to the Watchtower container:
```bash
docker run -d \
--name watchtower \
- -v /home//.docker/config.json:/config.json \
+ -v $HOME/.docker/config.json:/config.json \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower container_to_watch --debug
```
-If you mount the config file as described above, be sure to also prepend the URL for the registry when starting up your watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container from a private repo at Docker Hub and monitors it with watchtower. Note the command argument changing the interval to 30s rather than the default 5 minutes.
+!!! note "Changes to config.json while running"
+ If you mount `config.json` in the manner above, changes from the host system will (generally) not be propagated to the
+ running container. Mounting files into the Docker daemon uses bind mounts, which are based on inodes. Most
+ applications (including `docker login` and `vim`) will not directly edit the file, but instead make a copy and replace
+ the original file, which results in a new inode which in turn _breaks_ the bind mount.
+ **As a workaround**, you can create a symlink to your `config.json` file and then mount the symlink in the container.
+ The symlinked file will always have the same inode, which keeps the bind mount intact and will ensure changes
+ to the original file are propagated to the running container (regardless of the inode of the source file!).
-```json
+If you mount the config file as described above, be sure to also prepend the URL for the registry when starting up your
+watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container
+from a private repo on the GitHub Registry and monitors it with watchtower. Note the command argument changing the interval
+to 30s rather than the default 24 hours.
+
+```yaml
version: "3"
services:
cavo:
- image: index.docker.io//:
+ image: ghcr.io//:
ports:
- "443:3443"
- "80:3080"
@@ -53,4 +65,4 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
- /root/.docker/config.json:/config.json
command: --interval 30
-```
\ No newline at end of file
+```
diff --git a/go.mod b/go.mod
index 2d2ced6..6c20d11 100644
--- a/go.mod
+++ b/go.mod
@@ -1,59 +1,74 @@
module github.com/containrrr/watchtower
-go 1.12
+go 1.20
require (
- github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect
- github.com/Microsoft/go-winio v0.4.12 // indirect
- github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d // indirect
- github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 // indirect
- github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 // indirect
- github.com/bitly/go-simplejson v0.5.0 // indirect
- github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
- github.com/bugsnag/bugsnag-go v1.5.3 // indirect
- github.com/bugsnag/panicwrap v1.2.0 // indirect
- github.com/cenkalti/backoff v2.2.1+incompatible // indirect
- github.com/cloudflare/cfssl v0.0.0-20190911221928-1a911ca1b1d6 // indirect
- github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 // indirect
- github.com/docker/cli v0.0.0-20190327152802-57b27434ea29
- github.com/docker/distribution v2.7.1+incompatible
- github.com/docker/docker v0.0.0-20190404075923-dbe4a30928d4
- github.com/docker/docker-credential-helpers v0.6.1 // indirect
- github.com/docker/go v1.5.1-1 // indirect
- github.com/docker/go-connections v0.4.0 // indirect
- github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82 // indirect
- github.com/docker/go-units v0.3.3 // indirect
- github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
- github.com/gofrs/uuid v3.2.0+incompatible // indirect
- github.com/google/certificate-transparency-go v1.0.21 // indirect
- github.com/gorilla/mux v1.7.0 // indirect
- github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
- github.com/hashicorp/go-version v1.1.0 // indirect
- github.com/inconshreveable/mousetrap v1.0.0 // indirect
- github.com/jinzhu/gorm v1.9.11 // indirect
- github.com/johntdyer/slack-go v0.0.0-20180213144715-95fac1160b22 // indirect
- github.com/johntdyer/slackrus v0.0.0-20180518184837-f7aae3243a07
- github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
- github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
- github.com/lib/pq v1.2.0 // indirect
- github.com/miekg/pkcs11 v0.0.0-20190401114359-553cfdd26aaa // indirect
- github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c // indirect
- github.com/onsi/ginkgo v1.8.0
- github.com/onsi/gomega v1.5.0
- github.com/opencontainers/go-digest v1.0.0-rc1 // indirect
- github.com/opencontainers/image-spec v1.0.1 // indirect
- github.com/opencontainers/runc v0.1.1
- github.com/pkg/errors v0.8.1 // indirect
- github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967
- github.com/sirupsen/logrus v1.4.1
- github.com/spf13/cobra v0.0.3
- github.com/spf13/viper v1.4.0
- github.com/stretchr/testify v1.3.0
- github.com/theupdateframework/notary v0.6.1 // indirect
- github.com/zmap/zlint v1.0.2 // indirect
- golang.org/x/net v0.0.0-20190522155817-f3200d17e092
- gopkg.in/dancannon/gorethink.v3 v3.0.5 // indirect
- gopkg.in/fatih/pool.v2 v2.0.0 // indirect
- gopkg.in/gorethink/gorethink.v3 v3.0.5 // indirect
- gotest.tools v2.2.0+incompatible // 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/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/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 230df62..cab338f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,352 +1,240 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.37.4 h1:glPeL3BQJsbF6aIIYfZizMwc5LTYz250bDMjttbBGAU=
-cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
-github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
-github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
-github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/Microsoft/go-winio v0.4.12 h1:xAfWHN1IrQ0NJ9TBC0KBZoqLjzDTr1ML+4MywiUOryc=
-github.com/Microsoft/go-winio v0.4.12/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
-github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs=
-github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
-github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
-github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
-github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI=
-github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0=
-github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU=
-github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
-github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
-github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
-github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
-github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
-github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
-github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
-github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
-github.com/bugsnag/bugsnag-go v1.5.3 h1:yeRUT3mUE13jL1tGwvoQsKdVbAsQx9AJ+fqahKveP04=
-github.com/bugsnag/bugsnag-go v1.5.3/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
-github.com/bugsnag/panicwrap v1.2.0 h1:OzrKrRvXis8qEvOkfcxNcYbOd2O7xXS2nnKMEMABFQA=
-github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
-github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
-github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
-github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cloudflare/cfssl v0.0.0-20190911221928-1a911ca1b1d6 h1:A7RURps5t4yDU0zktlgrE3Bdmjfv35nVs+xJdoWgIgY=
-github.com/cloudflare/cfssl v0.0.0-20190911221928-1a911ca1b1d6/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
-github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 h1:4BX8f882bXEDKfWIf0wa8HRvpnBoPszJJXL+TVbBw4M=
-github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
-github.com/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/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
+github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Microsoft/go-winio v0.4.17 h1:iT12IBVClFevaf8PuVyi3UmZOVh4OqnaLxDTW2O6j3w=
+github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
+github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
+github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
+github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3 h1:tkum0XDgfR0jcVVXuTsYv/erY2NnEDqwRojbxR1rBYA=
-github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM=
-github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
-github.com/docker/cli v0.0.0-20190327152802-57b27434ea29 h1:ciaXDHaWQda0nvevWqcjtXX/buQY3e0lga1vq8Batq0=
-github.com/docker/cli v0.0.0-20190327152802-57b27434ea29/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
-github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
-github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v0.0.0-20190404075923-dbe4a30928d4 h1:34LfsqlE2kEvmGP9qbRoPvOWkmluYGzmlvWVTzwvT0A=
-github.com/docker/docker v0.0.0-20190404075923-dbe4a30928d4/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
+github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/cli v24.0.7+incompatible h1:wa/nIwYFW7BVTGa7SWPVyyXU9lgORqUb1xfI36MSkFg=
+github.com/docker/cli v24.0.7+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
+github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
+github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
+github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.6.1 h1:Dq4iIfcM7cNtddhLVWe9h4QDjsi4OER3Z8voPu/I52g=
github.com/docker/docker-credential-helpers v0.6.1/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=
-github.com/docker/go v1.5.1-1 h1:hr4w35acWBPhGBXlzPoHpmZ/ygPjnmFVxGxxGnMyP7k=
-github.com/docker/go v1.5.1-1/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
-github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82 h1:X0fj836zx99zFu83v/M79DuBn84IL/Syx1SY6Y5ZEMA=
-github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI=
-github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk=
-github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
-github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4=
-github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
-github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
-github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
-github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
-github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
-github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
-github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
+github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
+github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
+github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
-github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
-github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
-github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
-github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
-github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
-github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
-github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE=
-github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
+github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/certificate-transparency-go v1.0.21 h1:Yf1aXowfZ2nuboBsg7iYGLmwsOARdV86pfH3g95wXmE=
-github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg=
-github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
-github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
-github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
-github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U=
-github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
-github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
-github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
-github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
-github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
-github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
-github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
-github.com/hashicorp/go-version v1.1.0 h1:bPIoEKD27tNdebFGGxxYwcL4nepeY4j1QP23PFRGzg0=
-github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/jinzhu/gorm v1.9.11 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE=
-github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw=
-github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
-github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
-github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
-github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
-github.com/johntdyer/slack-go v0.0.0-20180213144715-95fac1160b22 h1:jKUP9TQ0c7X3w6+IPyMit07RE42MtTWNd77sN2cHngQ=
-github.com/johntdyer/slack-go v0.0.0-20180213144715-95fac1160b22/go.mod h1:u0Jo4f2dNlTJeeOywkM6bLwxq6gC3pZ9rEFHn3AhTdk=
-github.com/johntdyer/slackrus v0.0.0-20180518184837-f7aae3243a07 h1:+kBG/8rjCa6vxJZbUjAiE4MQmBEBYc8nLEb51frnvBY=
-github.com/johntdyer/slackrus v0.0.0-20180518184837-f7aae3243a07/go.mod h1:j1kV/8f3jowErEq4XyeypkCdvg5EeHkf0YCKCcq5Ybo=
-github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
-github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
-github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
-github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
-github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
-github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
-github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
-github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
-github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
-github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q=
-github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
-github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
-github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/miekg/pkcs11 v0.0.0-20190401114359-553cfdd26aaa h1:gOXc1BXmFuxWYmTfoK51YJR7srco3CwbsVHgr+8Y4r0=
-github.com/miekg/pkcs11 v0.0.0-20190401114359-553cfdd26aaa/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
-github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
+github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
+github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw=
github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE=
github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
-github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8=
-github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
+github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w=
-github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
-github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo=
-github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
-github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
-github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ=
-github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
-github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI=
-github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
-github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y=
-github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
-github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
-github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
-github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
-github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
+github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
+github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8=
+github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
+github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
+github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
-github.com/prometheus/client_golang v0.9.3 h1:9iH4JKXLzFbOAdtqv/a+j8aewx2Y8lAjAydhbaScPF8=
-github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
-github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
-github.com/prometheus/common v0.2.0 h1:kUZDBDTdBVBYBj5Tmh2NZLlF60mfjA27rM34b+cVwNU=
-github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.4.0 h1:7etb9YClo3a6HjLzfl6rIQaU+FDfi0VSX39io3aQ+DM=
-github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 h1:sofwID9zm4tzrgykg80hfFph1mryUeLRsUfoocVVmRY=
-github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
-github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
-github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 h1:x7xEyJDP7Hv3LVgvWhzioQqbC/KtuUhTigKlH/8ehhE=
-github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
-github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
-github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k=
-github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
-github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
-github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
-github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
-github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
-github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8=
-github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
-github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
-github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
-github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
+github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
+github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
+github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
+github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
+github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
+github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
+github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
+github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
+github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
+github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
+github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
+github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
+github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
+github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
+github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
+github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
+github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
-github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
+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.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
+github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/theupdateframework/notary v0.6.1 h1:7wshjstgS9x9F5LuB1L5mBI2xNMObWqjz+cjWoom6l0=
-github.com/theupdateframework/notary v0.6.1/go.mod h1:MOfgIfmox8s7/7fduvB2xyPPMJCrjRLRizA8OFwpnKY=
-github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
-github.com/weppos/publicsuffix-go v0.4.0 h1:YSnfg3V65LcCFKtIGKGoBhkyKolEd0hlipcXaOjdnQw=
-github.com/weppos/publicsuffix-go v0.4.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k=
-github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
-github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
-github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE=
-github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is=
-github.com/zmap/zcrypto v0.0.0-20190729165852-9051775e6a2e h1:mvOa4+/DXStR4ZXOks/UsjeFdn5O5JpLUtzqk9U8xXw=
-github.com/zmap/zcrypto v0.0.0-20190729165852-9051775e6a2e/go.mod h1:w7kd3qXHh8FNaczNjslXqvFQiv5mMWRXlL9klTUAHc8=
-github.com/zmap/zlint v1.0.2 h1:07+WuC/prlXVlWa1CJx2lCpuCd8biIeBAVnwTN2CPaA=
-github.com/zmap/zlint v1.0.2/go.mod h1:29UiAJNsiVdvTBFCJW8e3q6dcDbOoPkhMgttOSCIMMY=
-go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
-go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
-go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
-go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
-golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
+go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc=
-golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
+golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190522155817-f3200d17e092 h1:4QSRKanuywn15aTZvI/mIDEgPQpswuFndXpOj3rKEco=
-golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/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/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
+golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sync v0.0.0-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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
+golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2 h1:z99zHgr7hKfrUcX/KsoJk5FJfjTceCKIp96+biqP4To=
-golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
-golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107 h1:xtNn7qFlagY2mQNFHMSRPjT2RkOV4OXM7P5TVy9xATo=
-google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.21.0 h1:G+97AoqBnmZIT91cLG/EkCoK9NSelj64P8bOHHNmGn0=
-google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/dancannon/gorethink.v3 v3.0.5 h1:/g7PWP7zUS6vSNmHSDbjCHQh1Rqn8Jy6zSMQxAsBSMQ=
-gopkg.in/dancannon/gorethink.v3 v3.0.5/go.mod h1:GXsi1e3N2OcKhcP6nsYABTiUejbWMFO4GY5a4pEaeEc=
-gopkg.in/fatih/pool.v2 v2.0.0 h1:xIFeWtxifuQJGk/IEPKsTduEKcKvPmhoiVDGpC40nKg=
-gopkg.in/fatih/pool.v2 v2.0.0/go.mod h1:8xVGeu1/2jr2wm5V9SPuMht2H5AEmf5aFMGSQixtjTY=
-gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-gopkg.in/gorethink/gorethink.v3 v3.0.5 h1:e2Uc/Xe+hpcVQFsj6MuHlYog3r0JYpnTzwDj/y2O4MU=
-gopkg.in/gorethink/gorethink.v3 v3.0.5/go.mod h1:+3yIIHJUGMBK+wyPH+iN5TP+88ikFDfZdqTlK3Y9q8I=
-gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
-gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
-gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
-honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
+gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
+gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
diff --git a/gopher-watchtower.png b/gopher-watchtower.png
new file mode 100644
index 0000000..7cf5233
Binary files /dev/null and b/gopher-watchtower.png differ
diff --git a/goreleaser.yml b/goreleaser.yml
index 927cdcd..1904d5e 100644
--- a/goreleaser.yml
+++ b/goreleaser.yml
@@ -9,23 +9,28 @@ build:
- 386
- arm
- arm64
-archive:
- name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}"
- format: tar.gz
- replacements:
- arm: armhf
- arm64: arm64v8
- amd64: amd64
- 386: 386
- darwin: macOS
- linux: linux
- format_overrides:
- - goos: windows
- format: zip
- files:
- - LICENSE.md
+ ldflags:
+ - -s -w -X github.com/containrrr/watchtower/internal/meta.Version={{.Version}}
+archives:
+ -
+ name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}"
+ format: tar.gz
+ replacements:
+ arm: armhf
+ arm64: arm64v8
+ amd64: amd64
+ 386: 386
+ darwin: macOS
+ linux: linux
+ format_overrides:
+ - goos: windows
+ format: zip
+ files:
+ - LICENSE.md
dockers:
-
+ use_buildx: true
+ build_flag_templates: [ "--platform=linux/amd64" ]
goos: linux
goarch: amd64
goarm: ''
@@ -33,9 +38,13 @@ dockers:
image_templates:
- containrrr/watchtower:amd64-{{ .Version }}
- containrrr/watchtower:amd64-latest
+ - ghcr.io/containrrr/watchtower:amd64-{{ .Version }}
+ - ghcr.io/containrrr/watchtower:amd64-latest
binaries:
- watchtower
- -
+ -
+ use_buildx: true
+ build_flag_templates: [ "--platform=linux/386" ]
goos: linux
goarch: 386
goarm: ''
@@ -43,9 +52,13 @@ dockers:
image_templates:
- containrrr/watchtower:i386-{{ .Version }}
- containrrr/watchtower:i386-latest
+ - ghcr.io/containrrr/watchtower:i386-{{ .Version }}
+ - ghcr.io/containrrr/watchtower:i386-latest
binaries:
- watchtower
- -
+ -
+ use_buildx: true
+ build_flag_templates: [ "--platform=linux/arm/v6" ]
goos: linux
goarch: arm
goarm: 6
@@ -53,9 +66,13 @@ dockers:
image_templates:
- containrrr/watchtower:armhf-{{ .Version }}
- containrrr/watchtower:armhf-latest
+ - ghcr.io/containrrr/watchtower:armhf-{{ .Version }}
+ - ghcr.io/containrrr/watchtower:armhf-latest
binaries:
- watchtower
- -
+ -
+ use_buildx: true
+ build_flag_templates: [ "--platform=linux/arm64/v8" ]
goos: linux
goarch: arm64
goarm: ''
@@ -63,5 +80,7 @@ dockers:
image_templates:
- containrrr/watchtower:arm64v8-{{ .Version }}
- containrrr/watchtower:arm64v8-latest
+ - ghcr.io/containrrr/watchtower:arm64v8-{{ .Version }}
+ - ghcr.io/containrrr/watchtower:arm64v8-latest
binaries:
- watchtower
diff --git a/grafana/dashboards/dashboard.json b/grafana/dashboards/dashboard.json
new file mode 100644
index 0000000..3b7aa8f
--- /dev/null
+++ b/grafana/dashboards/dashboard.json
@@ -0,0 +1,293 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": "-- Grafana --",
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "gnetId": null,
+ "graphTooltip": 0,
+ "id": 1,
+ "links": [],
+ "panels": [
+ {
+ "datasource": "Prometheus",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {},
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 1,
+ "x": 0,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "7.3.6",
+ "targets": [
+ {
+ "expr": "watchtower_scans_total",
+ "interval": "",
+ "legendFormat": "",
+ "refId": "A"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Total Scans",
+ "type": "stat"
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": null,
+ "fieldConfig": {
+ "defaults": {
+ "custom": {}
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "watchtower_containers_scanned{instance=\"watchtower:8080\", job=\"watchtower\"}"
+ },
+ "properties": [
+ {
+ "id": "displayName",
+ "value": "Scanned"
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "watchtower_containers_failed{instance=\"watchtower:8080\", job=\"watchtower\"}"
+ },
+ "properties": [
+ {
+ "id": "displayName",
+ "value": "Failed"
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "watchtower_containers_updated{instance=\"watchtower:8080\", job=\"watchtower\"}"
+ },
+ "properties": [
+ {
+ "id": "displayName",
+ "value": "Updated"
+ }
+ ]
+ }
+ ]
+ },
+ "fill": 1,
+ "fillGradient": 0,
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 1,
+ "y": 0
+ },
+ "hiddenSeries": false,
+ "id": 5,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "nullPointMode": "null as zero",
+ "options": {
+ "alertThreshold": true
+ },
+ "percentage": false,
+ "pluginVersion": "7.3.6",
+ "pointradius": 2,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "expr": "watchtower_containers_scanned",
+ "interval": "",
+ "legendFormat": "",
+ "refId": "A"
+ },
+ {
+ "expr": "watchtower_containers_failed",
+ "interval": "",
+ "legendFormat": "",
+ "refId": "B"
+ },
+ {
+ "expr": "watchtower_containers_updated",
+ "interval": "",
+ "legendFormat": "",
+ "refId": "C"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [],
+ "timeShift": null,
+ "title": "Container Updates",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "decimals": 0,
+ "format": "short",
+ "label": "",
+ "logBase": 1,
+ "max": null,
+ "min": "0",
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "datasource": "Prometheus",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {},
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 1,
+ "x": 0,
+ "y": 4
+ },
+ "id": 3,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "7.3.6",
+ "targets": [
+ {
+ "expr": "watchtower_scans_skipped",
+ "interval": "",
+ "legendFormat": "",
+ "refId": "A"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Skipped Scans",
+ "type": "stat"
+ }
+ ],
+ "refresh": false,
+ "schemaVersion": 26,
+ "style": "dark",
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-1h",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "",
+ "title": "Watchtower",
+ "uid": "d7bdoT-Gz",
+ "version": 1
+}
diff --git a/grafana/dashboards/dashboard.yml b/grafana/dashboards/dashboard.yml
new file mode 100644
index 0000000..9f7232c
--- /dev/null
+++ b/grafana/dashboards/dashboard.yml
@@ -0,0 +1,11 @@
+apiVersion: 1
+
+providers:
+ - name: 'Prometheus'
+ orgId: 1
+ folder: ''
+ type: file
+ disableDeletion: false
+ editable: true
+ options:
+ path: /etc/grafana/provisioning/dashboards
\ No newline at end of file
diff --git a/grafana/datasources/datasource.yml b/grafana/datasources/datasource.yml
new file mode 100644
index 0000000..8049912
--- /dev/null
+++ b/grafana/datasources/datasource.yml
@@ -0,0 +1,8 @@
+apiVersion: 1
+
+datasources:
+ - name: Prometheus
+ type: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
\ No newline at end of file
diff --git a/internal/actions/actions_suite_test.go b/internal/actions/actions_suite_test.go
index 2c9b0c8..c320564 100644
--- a/internal/actions/actions_suite_test.go
+++ b/internal/actions/actions_suite_test.go
@@ -1,14 +1,13 @@
package actions_test
import (
- "github.com/containrrr/watchtower/internal/actions"
"testing"
"time"
- "github.com/containrrr/watchtower/pkg/container"
- "github.com/containrrr/watchtower/pkg/container/mocks"
+ "github.com/sirupsen/logrus"
- cli "github.com/docker/docker/client"
+ "github.com/containrrr/watchtower/internal/actions"
+ "github.com/containrrr/watchtower/pkg/types"
. "github.com/containrrr/watchtower/internal/actions/mocks"
. "github.com/onsi/ginkgo"
@@ -17,59 +16,51 @@ import (
func TestActions(t *testing.T) {
RegisterFailHandler(Fail)
+ logrus.SetOutput(GinkgoWriter)
RunSpecs(t, "Actions Suite")
}
var _ = Describe("the actions package", func() {
- var dockerClient cli.CommonAPIClient
- var client MockClient
- BeforeSuite(func() {
- server := mocks.NewMockAPIServer()
- dockerClient, _ = cli.NewClientWithOpts(
- cli.WithHost(server.URL),
- cli.WithHTTPClient(server.Client()))
- })
- BeforeEach(func() {
- pullImages := false
- removeVolumes := false
-
- client = CreateMockClient(
- &TestData {},
- dockerClient,
- pullImages,
- removeVolumes,
- )
- })
-
Describe("the check prerequisites method", func() {
When("given an empty array", func() {
It("should not do anything", func() {
- client.TestData.Containers = []container.Container{}
- err := actions.CheckForMultipleWatchtowerInstances(client, false)
- Expect(err).NotTo(HaveOccurred())
+ client := CreateMockClient(
+ &TestData{},
+ // pullImages:
+ false,
+ // removeVolumes:
+ false,
+ )
+ Expect(actions.CheckForMultipleWatchtowerInstances(client, false, "")).To(Succeed())
})
})
When("given an array of one", func() {
It("should not do anything", func() {
- client.TestData.Containers = []container.Container{
- CreateMockContainer(
- "test-container",
- "test-container",
- "watchtower",
- time.Now()),
- }
- err := actions.CheckForMultipleWatchtowerInstances(client, false)
- Expect(err).NotTo(HaveOccurred())
+ client := CreateMockClient(
+ &TestData{
+ Containers: []types.Container{
+ CreateMockContainer(
+ "test-container",
+ "test-container",
+ "watchtower",
+ time.Now()),
+ },
+ },
+ // pullImages:
+ false,
+ // removeVolumes:
+ false,
+ )
+ Expect(actions.CheckForMultipleWatchtowerInstances(client, false, "")).To(Succeed())
})
})
When("given multiple containers", func() {
+ var client MockClient
BeforeEach(func() {
- pullImages := false
- removeVolumes := false
client = CreateMockClient(
&TestData{
NameOfContainerToKeep: "test-container-02",
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainer(
"test-container-01",
"test-container-01",
@@ -82,25 +73,24 @@ var _ = Describe("the actions package", func() {
time.Now()),
},
},
- dockerClient,
- pullImages,
- removeVolumes,
+ // pullImages:
+ false,
+ // removeVolumes:
+ false,
)
})
It("should stop all but the latest one", func() {
- err := actions.CheckForMultipleWatchtowerInstances(client, false)
+ err := actions.CheckForMultipleWatchtowerInstances(client, false, "")
Expect(err).NotTo(HaveOccurred())
})
})
When("deciding whether to cleanup images", func() {
+ var client MockClient
BeforeEach(func() {
- pullImages := false
- removeVolumes := false
-
client = CreateMockClient(
&TestData{
- Containers: []container.Container{
+ Containers: []types.Container{
CreateMockContainer(
"test-container-01",
"test-container-01",
@@ -113,22 +103,22 @@ var _ = Describe("the actions package", func() {
time.Now()),
},
},
- dockerClient,
- pullImages,
- removeVolumes,
+ // pullImages:
+ false,
+ // removeVolumes:
+ false,
)
})
It("should try to delete the image if the cleanup flag is true", func() {
- err := actions.CheckForMultipleWatchtowerInstances(client, true)
+ err := actions.CheckForMultipleWatchtowerInstances(client, true, "")
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImage()).To(BeTrue())
})
It("should not try to delete the image if the cleanup flag is false", func() {
- err := actions.CheckForMultipleWatchtowerInstances(client, false)
+ err := actions.CheckForMultipleWatchtowerInstances(client, false, "")
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImage()).To(BeFalse())
})
})
})
})
-
diff --git a/internal/actions/check.go b/internal/actions/check.go
index 785701f..77a2266 100644
--- a/internal/actions/check.go
+++ b/internal/actions/check.go
@@ -1,30 +1,51 @@
package actions
import (
- "errors"
"fmt"
- "github.com/containrrr/watchtower/pkg/filters"
- "github.com/containrrr/watchtower/pkg/sorter"
"sort"
- "strings"
"time"
- "github.com/opencontainers/runc/Godeps/_workspace/src/github.com/Sirupsen/logrus"
+ "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
+func CheckForSanity(client container.Client, filter types.Filter, rollingRestarts bool) error {
+ log.Debug("Making sure everything is sane before starting")
+
+ if rollingRestarts {
+ containers, err := client.ListContainers(filter)
+ if err != nil {
+ return err
+ }
+ for _, c := range containers {
+ if len(c.Links()) > 0 {
+ return fmt.Errorf(
+ "%q is depending on at least one other container. This is not compatible with rolling restarts",
+ c.Name(),
+ )
+ }
+ }
+ }
+ return nil
+}
+
// CheckForMultipleWatchtowerInstances will ensure that there are not multiple instances of the
// watchtower running simultaneously. If multiple watchtower containers are detected, this function
-// will stop and remove all but the most recently started container.
-func CheckForMultipleWatchtowerInstances(client container.Client, cleanup bool) error {
- awaitDockerClient()
- containers, err := client.ListContainers(filters.WatchtowerContainersFilter)
+// 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 {
+ filter := filters.WatchtowerContainersFilter
+ if scope != "" {
+ filter = filters.FilterByScope(scope, filter)
+ }
+ containers, err := client.ListContainers(filter)
if err != nil {
- log.Fatal(err)
return err
}
@@ -37,50 +58,30 @@ func CheckForMultipleWatchtowerInstances(client container.Client, cleanup bool)
return cleanupExcessWatchtowers(containers, client, cleanup)
}
-func cleanupExcessWatchtowers(containers []container.Container, client container.Client, cleanup bool) error {
- var cleanupErrors int
+func cleanupExcessWatchtowers(containers []types.Container, client container.Client, cleanup bool) error {
var stopErrors int
sort.Sort(sorter.ByCreated(containers))
allContainersExceptLast := containers[0 : len(containers)-1]
for _, c := range allContainersExceptLast {
- if err := client.StopContainer(c, 60); err != nil {
+ if err := client.StopContainer(c, 10*time.Minute); err != nil {
// logging the original here as we're just returning a count
- logrus.Error(err)
+ log.WithError(err).Error("Could not stop a previous watchtower instance.")
stopErrors++
continue
}
if cleanup {
if err := client.RemoveImageByID(c.ImageID()); err != nil {
- // logging the original here as we're just returning a count
- logrus.Error(err)
- cleanupErrors++
+ log.WithError(err).Warning("Could not cleanup watchtower images, possibly because of other watchtowers instances in other scopes.")
}
}
}
- return createErrorIfAnyHaveOccurred(stopErrors, cleanupErrors)
-}
-
-func createErrorIfAnyHaveOccurred(c int, i int) error {
- if c == 0 && i == 0 {
- return nil
+ if stopErrors > 0 {
+ return fmt.Errorf("%d errors while stopping watchtower containers", stopErrors)
}
- var output strings.Builder
-
- if c > 0 {
- output.WriteString(fmt.Sprintf("%d errors while stopping containers", c))
- }
- if i > 0 {
- output.WriteString(fmt.Sprintf("%d errors while cleaning up images", c))
- }
- return errors.New(output.String())
-}
-
-func awaitDockerClient() {
- log.Debug("Sleeping for a second to ensure the docker api client has been properly initialized.")
- time.Sleep(1 * time.Second)
+ return nil
}
diff --git a/internal/actions/mocks/client.go b/internal/actions/mocks/client.go
index dad2506..737404a 100644
--- a/internal/actions/mocks/client.go
+++ b/internal/actions/mocks/client.go
@@ -2,17 +2,15 @@ package mocks
import (
"errors"
- "github.com/containrrr/watchtower/pkg/container"
+ "fmt"
"time"
t "github.com/containrrr/watchtower/pkg/types"
- cli "github.com/docker/docker/client"
)
// MockClient is a mock that passes as a watchtower Client
type MockClient struct {
TestData *TestData
- api cli.CommonAPIClient
pullImages bool
removeVolumes bool
}
@@ -20,8 +18,9 @@ type MockClient struct {
// TestData is the data used to perform the test
type TestData struct {
TriedToRemoveImageCount int
- NameOfContainerToKeep string
- Containers []container.Container
+ NameOfContainerToKeep string
+ Containers []t.Container
+ Staleness map[string]bool
}
// TriedToRemoveImage is a test helper function to check whether RemoveImageByID has been called
@@ -30,22 +29,21 @@ func (testdata *TestData) TriedToRemoveImage() bool {
}
// CreateMockClient creates a mock watchtower Client for usage in tests
-func CreateMockClient(data *TestData, api cli.CommonAPIClient, pullImages bool, removeVolumes bool) MockClient {
- return MockClient {
+func CreateMockClient(data *TestData, pullImages bool, removeVolumes bool) MockClient {
+ return MockClient{
data,
- api,
pullImages,
removeVolumes,
}
}
// ListContainers is a mock method returning the provided container testdata
-func (client MockClient) ListContainers(f t.Filter) ([]container.Container, error) {
+func (client MockClient) ListContainers(_ t.Filter) ([]t.Container, error) {
return client.TestData.Containers, nil
}
// StopContainer is a mock method
-func (client MockClient) StopContainer(c container.Container, d time.Duration) error {
+func (client MockClient) StopContainer(c t.Container, _ time.Duration) error {
if c.Name() == client.TestData.NameOfContainerToKeep {
return errors.New("tried to stop the instance we want to keep")
}
@@ -53,32 +51,50 @@ func (client MockClient) StopContainer(c container.Container, d time.Duration) e
}
// StartContainer is a mock method
-func (client MockClient) StartContainer(c container.Container) (string, error) {
+func (client MockClient) StartContainer(_ t.Container) (t.ContainerID, error) {
return "", nil
}
+
// RenameContainer is a mock method
-func (client MockClient) RenameContainer(c container.Container, s string) error {
+func (client MockClient) RenameContainer(_ t.Container, _ string) error {
return nil
}
// RemoveImageByID increments the TriedToRemoveImageCount on being called
-func (client MockClient) RemoveImageByID(id string) error {
+func (client MockClient) RemoveImageByID(_ t.ImageID) error {
client.TestData.TriedToRemoveImageCount++
return nil
}
// GetContainer is a mock method
-func (client MockClient) GetContainer(containerID string) (container.Container, error) {
- return container.Container{}, nil
+func (client MockClient) GetContainer(_ t.ContainerID) (t.Container, error) {
+ return client.TestData.Containers[0], nil
}
// ExecuteCommand is a mock method
-func (client MockClient) ExecuteCommand(containerID string, command string) error {
- return nil
+func (client MockClient) ExecuteCommand(_ t.ContainerID, command string, _ int) (SkipUpdate bool, err error) {
+ switch command {
+ case "/PreUpdateReturn0.sh":
+ return false, nil
+ case "/PreUpdateReturn1.sh":
+ return false, fmt.Errorf("command exited with code 1")
+ case "/PreUpdateReturn75.sh":
+ return true, nil
+ default:
+ return false, nil
+ }
}
-// IsContainerStale is always true for the mock client
-func (client MockClient) IsContainerStale(c container.Container) (bool, error) {
- return true, nil
+// IsContainerStale is true if not explicitly stated in TestData for the mock client
+func (client MockClient) IsContainerStale(cont t.Container, params t.UpdateParams) (bool, t.ImageID, error) {
+ stale, found := client.TestData.Staleness[cont.Name()]
+ if !found {
+ stale = true
+ }
+ return stale, "", nil
}
+// WarnOnHeadPullFailed is always true for the mock client
+func (client MockClient) WarnOnHeadPullFailed(_ t.Container) bool {
+ return true
+}
diff --git a/internal/actions/mocks/container.go b/internal/actions/mocks/container.go
index 060340e..e830587 100644
--- a/internal/actions/mocks/container.go
+++ b/internal/actions/mocks/container.go
@@ -1,14 +1,59 @@
package mocks
import (
- "github.com/containrrr/watchtower/pkg/container"
- "github.com/docker/docker/api/types"
- container2 "github.com/docker/docker/api/types/container"
+ "fmt"
+ "strconv"
+ "strings"
"time"
+
+ "github.com/containrrr/watchtower/pkg/container"
+ wt "github.com/containrrr/watchtower/pkg/types"
+ "github.com/docker/docker/api/types"
+ dockerContainer "github.com/docker/docker/api/types/container"
+ "github.com/docker/go-connections/nat"
)
// CreateMockContainer creates a container substitute valid for testing
-func CreateMockContainer(id string, name string, image string, created time.Time) container.Container {
+func CreateMockContainer(id string, name string, image string, created time.Time) wt.Container {
+ content := types.ContainerJSON{
+ ContainerJSONBase: &types.ContainerJSONBase{
+ ID: id,
+ Image: image,
+ Name: name,
+ Created: created.String(),
+ HostConfig: &dockerContainer.HostConfig{
+ PortBindings: map[nat.Port][]nat.PortBinding{},
+ },
+ },
+ Config: &dockerContainer.Config{
+ Image: image,
+ Labels: make(map[string]string),
+ ExposedPorts: map[nat.Port]struct{}{},
+ },
+ }
+ return container.NewContainer(
+ &content,
+ CreateMockImageInfo(image),
+ )
+}
+
+// CreateMockImageInfo returns a mock image info struct based on the passed image
+func CreateMockImageInfo(image string) *types.ImageInspect {
+ return &types.ImageInspect{
+ ID: image,
+ RepoDigests: []string{
+ image,
+ },
+ }
+}
+
+// CreateMockContainerWithImageInfo should only be used for testing
+func CreateMockContainerWithImageInfo(id string, name string, image string, created time.Time, imageInfo types.ImageInspect) wt.Container {
+ return CreateMockContainerWithImageInfoP(id, name, image, created, &imageInfo)
+}
+
+// CreateMockContainerWithImageInfoP should only be used for testing
+func CreateMockContainerWithImageInfoP(id string, name string, image string, created time.Time, imageInfo *types.ImageInspect) wt.Container {
content := types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
ID: id,
@@ -16,14 +61,83 @@ func CreateMockContainer(id string, name string, image string, created time.Time
Name: name,
Created: created.String(),
},
- Config: &container2.Config{
+ Config: &dockerContainer.Config{
+ Image: image,
Labels: make(map[string]string),
},
}
- return *container.NewContainer(
+ return container.NewContainer(
&content,
- &types.ImageInspect{
- ID: image,
- },
+ imageInfo,
+ )
+}
+
+// CreateMockContainerWithDigest should only be used for testing
+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) wt.Container {
+ content := types.ContainerJSON{
+ ContainerJSONBase: &types.ContainerJSONBase{
+ ID: id,
+ Image: image,
+ Name: name,
+ State: &types.ContainerState{
+ Running: running,
+ Restarting: restarting,
+ },
+ Created: created.String(),
+ HostConfig: &dockerContainer.HostConfig{
+ PortBindings: map[nat.Port][]nat.PortBinding{},
+ },
+ },
+ Config: config,
+ }
+ return container.NewContainer(
+ &content,
+ CreateMockImageInfo(image),
+ )
+}
+
+// CreateContainerForProgress creates a container substitute for tracking session/update progress
+func CreateContainerForProgress(index int, idPrefix int, nameFormat string) (wt.Container, wt.ImageID) {
+ indexStr := strconv.Itoa(idPrefix + index)
+ mockID := indexStr + strings.Repeat("0", 61-len(indexStr))
+ contID := "c79" + mockID
+ contName := fmt.Sprintf(nameFormat, index+1)
+ oldImgID := "01d" + mockID
+ newImgID := "d0a" + mockID
+ imageName := fmt.Sprintf("mock/%s:latest", contName)
+ config := &dockerContainer.Config{
+ Image: imageName,
+ }
+ c := CreateMockContainerWithConfig(contID, contName, oldImgID, true, false, time.Now(), config)
+ return c, wt.ImageID(newImgID)
+}
+
+// CreateMockContainerWithLinks should only be used for testing
+func CreateMockContainerWithLinks(id string, name string, image string, created time.Time, links []string, imageInfo *types.ImageInspect) wt.Container {
+ content := types.ContainerJSON{
+ ContainerJSONBase: &types.ContainerJSONBase{
+ ID: id,
+ Image: image,
+ Name: name,
+ Created: created.String(),
+ HostConfig: &dockerContainer.HostConfig{
+ Links: links,
+ },
+ },
+ Config: &dockerContainer.Config{
+ Image: image,
+ Labels: make(map[string]string),
+ },
+ }
+ return container.NewContainer(
+ &content,
+ imageInfo,
)
}
diff --git a/internal/actions/mocks/progress.go b/internal/actions/mocks/progress.go
new file mode 100644
index 0000000..23fc441
--- /dev/null
+++ b/internal/actions/mocks/progress.go
@@ -0,0 +1,44 @@
+package mocks
+
+import (
+ "errors"
+
+ "github.com/containrrr/watchtower/pkg/session"
+ wt "github.com/containrrr/watchtower/pkg/types"
+)
+
+// CreateMockProgressReport creates a mock report from a given set of container states
+// All containers will be given a unique ID and name based on its state and index
+func CreateMockProgressReport(states ...session.State) wt.Report {
+
+ stateNums := make(map[session.State]int)
+ progress := session.Progress{}
+ failed := make(map[wt.ContainerID]error)
+
+ for _, state := range states {
+ index := stateNums[state]
+
+ switch state {
+ case session.SkippedState:
+ c, _ := CreateContainerForProgress(index, 41, "skip%d")
+ progress.AddSkipped(c, errors.New("unpossible"))
+ case session.FreshState:
+ c, _ := CreateContainerForProgress(index, 31, "frsh%d")
+ progress.AddScanned(c, c.ImageID())
+ case session.UpdatedState:
+ c, newImage := CreateContainerForProgress(index, 11, "updt%d")
+ progress.AddScanned(c, newImage)
+ progress.MarkForUpdate(c.ID())
+ case session.FailedState:
+ c, newImage := CreateContainerForProgress(index, 21, "fail%d")
+ progress.AddScanned(c, newImage)
+ failed[c.ID()] = errors.New("accidentally the whole container")
+ }
+
+ stateNums[state] = index + 1
+ }
+ progress.UpdateFailed(failed)
+
+ return progress.Report()
+
+}
diff --git a/internal/actions/update.go b/internal/actions/update.go
index 874e705..8853c6e 100644
--- a/internal/actions/update.go
+++ b/internal/actions/update.go
@@ -1,9 +1,12 @@
package actions
import (
+ "errors"
+
"github.com/containrrr/watchtower/internal/util"
"github.com/containrrr/watchtower/pkg/container"
"github.com/containrrr/watchtower/pkg/lifecycle"
+ "github.com/containrrr/watchtower/pkg/session"
"github.com/containrrr/watchtower/pkg/sorter"
"github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
@@ -13,8 +16,10 @@ import (
// used to start those containers have been updated. If a change is detected in
// any of the images, the associated containers are stopped and restarted with
// the new image.
-func Update(client container.Client, params types.UpdateParams) error {
+func Update(client container.Client, params types.UpdateParams) (types.Report, error) {
log.Debug("Checking containers for updated images")
+ progress := &session.Progress{}
+ staleCount := 0
if params.LifecycleHooks {
lifecycle.ExecutePreChecks(client, params)
@@ -22,87 +27,188 @@ func Update(client container.Client, params types.UpdateParams) error {
containers, err := client.ListContainers(params.Filter)
if err != nil {
- return err
+ return nil, err
}
- for i, container := range containers {
- stale, err := client.IsContainerStale(container)
- if err != nil {
- log.Infof("Unable to update container %s. Proceeding to next.", containers[i].Name())
- log.Debug(err)
- stale = false
+ staleCheckFailed := 0
+
+ for i, targetContainer := range containers {
+ stale, newestImage, err := client.IsContainerStale(targetContainer, params)
+ shouldUpdate := stale && !params.NoRestart && !targetContainer.IsMonitorOnly(params)
+ if err == nil && shouldUpdate {
+ // Check to make sure we have all the necessary information for recreating the container
+ err = targetContainer.VerifyConfiguration()
+ // If the image information is incomplete and trace logging is enabled, log it for further diagnosis
+ if err != nil && log.IsLevelEnabled(log.TraceLevel) {
+ imageInfo := targetContainer.ImageInfo()
+ log.Tracef("Image info: %#v", imageInfo)
+ log.Tracef("Container info: %#v", targetContainer.ContainerInfo())
+ if imageInfo != nil {
+ log.Tracef("Image config: %#v", imageInfo.Config)
+ }
+ }
+ }
+
+ if err != nil {
+ log.Infof("Unable to update container %q: %v. Proceeding to next.", targetContainer.Name(), err)
+ stale = false
+ staleCheckFailed++
+ progress.AddSkipped(targetContainer, err)
+ } else {
+ progress.AddScanned(targetContainer, newestImage)
+ }
+ containers[i].SetStale(stale)
+
+ if stale {
+ staleCount++
}
- containers[i].Stale = stale
}
containers, err = sorter.SortByDependencies(containers)
if err != nil {
- return err
+ return nil, err
}
- checkDependencies(containers)
+ UpdateImplicitRestart(containers)
- if params.MonitorOnly {
- if params.LifecycleHooks {
- lifecycle.ExecutePostChecks(client, params)
+ var containersToUpdate []types.Container
+ for _, c := range containers {
+ if !c.IsMonitorOnly(params) {
+ containersToUpdate = append(containersToUpdate, c)
+ progress.MarkForUpdate(c.ID())
}
- return nil
}
- stopContainersInReversedOrder(containers, client, params)
- restartContainersInSortedOrder(containers, client, params)
+ if params.RollingRestart {
+ progress.UpdateFailed(performRollingRestart(containersToUpdate, client, params))
+ } else {
+ failedStop, stoppedImages := stopContainersInReversedOrder(containersToUpdate, client, params)
+ progress.UpdateFailed(failedStop)
+ failedStart := restartContainersInSortedOrder(containersToUpdate, client, params, stoppedImages)
+ progress.UpdateFailed(failedStart)
+ }
if params.LifecycleHooks {
lifecycle.ExecutePostChecks(client, params)
}
- return nil
+ return progress.Report(), nil
}
-func stopContainersInReversedOrder(containers []container.Container, client container.Client, params types.UpdateParams) {
+func performRollingRestart(containers []types.Container, client container.Client, params types.UpdateParams) map[types.ContainerID]error {
+ cleanupImageIDs := make(map[types.ImageID]bool, len(containers))
+ failed := make(map[types.ContainerID]error, len(containers))
+
for i := len(containers) - 1; i >= 0; i-- {
- stopStaleContainer(containers[i], client, params)
+ if containers[i].ToRestart() {
+ err := stopStaleContainer(containers[i], client, params)
+ if err != nil {
+ failed[containers[i].ID()] = err
+ } else {
+ if err := restartStaleContainer(containers[i], client, params); err != nil {
+ failed[containers[i].ID()] = err
+ } else if containers[i].IsStale() {
+ // Only add (previously) stale containers' images to cleanup
+ cleanupImageIDs[containers[i].ImageID()] = true
+ }
+ }
+ }
}
+
+ if params.Cleanup {
+ cleanupImages(client, cleanupImageIDs)
+ }
+ return failed
}
-func stopStaleContainer(container container.Container, client container.Client, params types.UpdateParams) {
+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 {
+ // NOTE: If a container is restarted due to a dependency this might be empty
+ stopped[containers[i].SafeImageID()] = true
+ }
+
+ }
+ return
+}
+
+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
+ return nil
}
- if !container.Stale {
- return
+ 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 {
- lifecycle.ExecutePreUpdateCommand(client, container)
-
+ skipUpdate, err := lifecycle.ExecutePreUpdateCommand(client, container)
+ if err != nil {
+ log.Error(err)
+ log.Info("Skipping container as the pre-update command failed")
+ return err
+ }
+ if skipUpdate {
+ log.Debug("Skipping container as the pre-update command returned exit code 75 (EX_TEMPFAIL)")
+ return errors.New("skipping container as the pre-update command returned exit code 75 (EX_TEMPFAIL)")
+ }
}
if err := client.StopContainer(container, params.Timeout); err != nil {
log.Error(err)
+ return err
}
+ return nil
}
-func restartContainersInSortedOrder(containers []container.Container, client container.Client, params types.UpdateParams) {
- imageIDs := make(map[string]bool)
+func restartContainersInSortedOrder(containers []types.Container, client container.Client, params types.UpdateParams, stoppedImages map[types.ImageID]bool) map[types.ContainerID]error {
+ cleanupImageIDs := make(map[types.ImageID]bool, len(containers))
+ failed := make(map[types.ContainerID]error, len(containers))
- for _, container := range containers {
- if !container.Stale {
+ for _, c := range containers {
+ if !c.ToRestart() {
continue
}
- restartStaleContainer(container, client, params)
- imageIDs[container.ImageID()] = true
- }
- if params.Cleanup {
- for imageID := range imageIDs {
- if err := client.RemoveImageByID(imageID); err != nil {
- log.Error(err)
+ 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
}
}
}
+
+ if params.Cleanup {
+ cleanupImages(client, cleanupImageIDs)
+ }
+
+ return failed
}
-func restartStaleContainer(container container.Container, client container.Client, params types.UpdateParams) {
+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 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
@@ -110,34 +216,52 @@ func restartStaleContainer(container container.Container, client container.Clien
if container.IsWatchtower() {
if err := client.RenameContainer(container, util.RandName()); err != nil {
log.Error(err)
- return
+ return nil
}
}
if !params.NoRestart {
if newContainerID, err := client.StartContainer(container); err != nil {
log.Error(err)
- } else if container.Stale && params.LifecycleHooks {
+ return err
+ } else if container.ToRestart() && params.LifecycleHooks {
lifecycle.ExecutePostUpdateCommand(client, newContainerID)
}
}
+ return nil
}
-func checkDependencies(containers []container.Container) {
+// UpdateImplicitRestart iterates through the passed containers, setting the
+// `LinkedToRestarting` flag if any of it's linked containers are marked for restart
+func UpdateImplicitRestart(containers []types.Container) {
- for i, parent := range containers {
- if parent.ToRestart() {
+ for ci, c := range containers {
+ if c.ToRestart() {
+ // The container is already marked for restart, no need to check
continue
}
- LinkLoop:
- for _, linkName := range parent.Links() {
- for _, child := range containers {
- if child.Name() == linkName && child.ToRestart() {
- containers[i].Linked = true
- break LinkLoop
- }
+ if link := linkedContainerMarkedForRestart(c.Links(), containers); link != "" {
+ log.WithFields(log.Fields{
+ "restarting": link,
+ "linked": c.Name(),
+ }).Debug("container is linked to restarting")
+ // NOTE: To mutate the array, the `c` variable cannot be used as it's a copy
+ containers[ci].SetLinkedToRestarting(true)
+ }
+
+ }
+}
+
+// linkedContainerMarkedForRestart returns the name of the first link that matches a
+// container marked for restart
+func linkedContainerMarkedForRestart(links []string, containers []types.Container) string {
+ for _, linkName := range links {
+ for _, candidate := range containers {
+ if candidate.Name() == linkName && candidate.ToRestart() {
+ return linkName
}
}
}
+ return ""
}
diff --git a/internal/actions/update_test.go b/internal/actions/update_test.go
index 3c9befe..9209dcd 100644
--- a/internal/actions/update_test.go
+++ b/internal/actions/update_test.go
@@ -1,74 +1,85 @@
package actions_test
import (
- "github.com/containrrr/watchtower/internal/actions"
- "github.com/containrrr/watchtower/pkg/container"
- "github.com/containrrr/watchtower/pkg/container/mocks"
- "github.com/containrrr/watchtower/pkg/types"
- cli "github.com/docker/docker/client"
"time"
+ "github.com/containrrr/watchtower/internal/actions"
+ "github.com/containrrr/watchtower/pkg/types"
+ dockerTypes "github.com/docker/docker/api/types"
+ dockerContainer "github.com/docker/docker/api/types/container"
+ "github.com/docker/go-connections/nat"
+
. "github.com/containrrr/watchtower/internal/actions/mocks"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
+func getCommonTestData(keepContainer string) *TestData {
+ return &TestData{
+ NameOfContainerToKeep: keepContainer,
+ Containers: []types.Container{
+ CreateMockContainer(
+ "test-container-01",
+ "test-container-01",
+ "fake-image:latest",
+ time.Now().AddDate(0, 0, -1)),
+ CreateMockContainer(
+ "test-container-02",
+ "test-container-02",
+ "fake-image:latest",
+ time.Now()),
+ CreateMockContainer(
+ "test-container-02",
+ "test-container-02",
+ "fake-image:latest",
+ time.Now()),
+ },
+ }
+}
+
+func getLinkedTestData(withImageInfo bool) *TestData {
+ staleContainer := CreateMockContainer(
+ "test-container-01",
+ "/test-container-01",
+ "fake-image1:latest",
+ time.Now().AddDate(0, 0, -1))
+
+ var imageInfo *dockerTypes.ImageInspect
+ if withImageInfo {
+ imageInfo = CreateMockImageInfo("test-container-02")
+ }
+ linkingContainer := CreateMockContainerWithLinks(
+ "test-container-02",
+ "/test-container-02",
+ "fake-image2:latest",
+ time.Now(),
+ []string{staleContainer.Name()},
+ imageInfo)
+
+ return &TestData{
+ Staleness: map[string]bool{linkingContainer.Name(): false},
+ Containers: []types.Container{
+ staleContainer,
+ linkingContainer,
+ },
+ }
+}
var _ = Describe("the update action", func() {
- var dockerClient cli.CommonAPIClient
- var client MockClient
-
- BeforeEach(func() {
- server := mocks.NewMockAPIServer()
- dockerClient, _ = cli.NewClientWithOpts(
- cli.WithHost(server.URL),
- cli.WithHTTPClient(server.Client()))
- })
-
-
When("watchtower has been instructed to clean up", func() {
- BeforeEach(func() {
- pullImages := false
- removeVolumes := false
- client = CreateMockClient(
- &TestData{
- NameOfContainerToKeep: "test-container-02",
- Containers: []container.Container{
- CreateMockContainer(
- "test-container-01",
- "test-container-01",
- "fake-image:latest",
- time.Now().AddDate(0, 0, -1)),
- CreateMockContainer(
- "test-container-02",
- "test-container-02",
- "fake-image:latest",
- time.Now()),
- CreateMockContainer(
- "test-container-02",
- "test-container-02",
- "fake-image:latest",
- time.Now()),
- },
- },
- dockerClient,
- pullImages,
- removeVolumes,
- )
- })
-
When("there are multiple containers using the same image", func() {
It("should only try to remove the image once", func() {
-
- err := actions.Update(client, types.UpdateParams{ Cleanup: true })
+ client := CreateMockClient(getCommonTestData(""), false, false)
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true})
Expect(err).NotTo(HaveOccurred())
Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
})
})
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",
@@ -76,10 +87,386 @@ var _ = Describe("the update action", func() {
time.Now(),
),
)
- err := actions.Update(client, types.UpdateParams{ Cleanup: true })
+ 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() {
+ It("should not update those containers", func() {
+ client := CreateMockClient(
+ &TestData{
+ NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainer(
+ "test-container-01",
+ "test-container-01",
+ "fake-image1:latest",
+ time.Now()),
+ 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})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+ })
+ })
+
+ When("monitor only is set globally", func() {
+ It("should not update any containers", func() {
+ client := CreateMockClient(
+ &TestData{
+ Containers: []types.Container{
+ CreateMockContainer(
+ "test-container-01",
+ "test-container-01",
+ "fake-image:latest",
+ time.Now()),
+ CreateMockContainer(
+ "test-container-02",
+ "test-container-02",
+ "fake-image:latest",
+ time.Now()),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, MonitorOnly: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
+ })
+ When("watchtower has been instructed to have label take precedence", func() {
+ It("it should update containers when monitor only is set to false", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ false,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.monitor-only": "false",
+ },
+ }),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, MonitorOnly: true, LabelPrecedence: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+ })
+ It("it should update not containers when monitor only is set to true", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ false,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.monitor-only": "true",
+ },
+ }),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, MonitorOnly: true, LabelPrecedence: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
+ })
+ It("it should update not containers when monitor only is not set", func() {
+ client := CreateMockClient(
+ &TestData{
+ Containers: []types.Container{
+ CreateMockContainer(
+ "test-container-01",
+ "test-container-01",
+ "fake-image:latest",
+ time.Now()),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, MonitorOnly: true, LabelPrecedence: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
+ })
+
+ })
+ })
+ })
+
+ When("watchtower has been instructed to run lifecycle hooks", func() {
+
+ When("pre-update script returns 1", func() {
+ It("should not update those containers", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ true,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+ "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn1.sh",
+ },
+ ExposedPorts: map[nat.Port]struct{}{},
+ }),
+ },
+ },
+ false,
+ false,
+ )
+
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
+ })
+
+ })
+
+ When("prupddate script returns 75", func() {
+ It("should not update those containers", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ true,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+ "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn75.sh",
+ },
+ ExposedPorts: map[nat.Port]struct{}{},
+ }),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
+ })
+
+ })
+
+ When("prupddate script returns 0", func() {
+ It("should update those containers", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ true,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+ "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn0.sh",
+ },
+ ExposedPorts: map[nat.Port]struct{}{},
+ }),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+ })
+ })
+
+ When("container is linked to restarting containers", func() {
+ It("should be marked for restart", func() {
+
+ provider := CreateMockContainerWithConfig(
+ "test-container-provider",
+ "/test-container-provider",
+ "fake-image2:latest",
+ true,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{},
+ ExposedPorts: map[nat.Port]struct{}{},
+ })
+
+ provider.SetStale(true)
+
+ consumer := CreateMockContainerWithConfig(
+ "test-container-consumer",
+ "/test-container-consumer",
+ "fake-image3:latest",
+ true,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.depends-on": "test-container-provider",
+ },
+ ExposedPorts: map[nat.Port]struct{}{},
+ })
+
+ containers := []types.Container{
+ provider,
+ consumer,
+ }
+
+ Expect(provider.ToRestart()).To(BeTrue())
+ Expect(consumer.ToRestart()).To(BeFalse())
+
+ actions.UpdateImplicitRestart(containers)
+
+ Expect(containers[0].ToRestart()).To(BeTrue())
+ Expect(containers[1].ToRestart()).To(BeTrue())
+
+ })
+
+ })
+
+ When("container is not running", func() {
+ It("skip running preupdate", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ false,
+ false,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+ "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn1.sh",
+ },
+ ExposedPorts: map[nat.Port]struct{}{},
+ }),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+ })
+
+ })
+
+ When("container is restarting", func() {
+ It("skip running preupdate", func() {
+ client := CreateMockClient(
+ &TestData{
+ //NameOfContainerToKeep: "test-container-02",
+ Containers: []types.Container{
+ CreateMockContainerWithConfig(
+ "test-container-02",
+ "test-container-02",
+ "fake-image2:latest",
+ false,
+ true,
+ time.Now(),
+ &dockerContainer.Config{
+ Labels: map[string]string{
+ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+ "com.centurylinklabs.watchtower.lifecycle.pre-update": "/PreUpdateReturn1.sh",
+ },
+ ExposedPorts: map[nat.Port]struct{}{},
+ }),
+ },
+ },
+ false,
+ false,
+ )
+ _, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+ })
+
+ })
+
})
})
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index a60d18f..c11cdae 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -1,24 +1,32 @@
package flags
import (
+ "bufio"
+ "errors"
+ "fmt"
"os"
+ "regexp"
+ "strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
+ "github.com/spf13/pflag"
"github.com/spf13/viper"
)
// DockerAPIMinVersion is the minimum version of the docker api required to
// use watchtower
-const DockerAPIMinVersion string = "1.24"
+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
@@ -27,90 +35,182 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
flags.IntP(
"interval",
"i",
- viper.GetInt("WATCHTOWER_POLL_INTERVAL"),
- "poll interval (in seconds)")
+ envInt("WATCHTOWER_POLL_INTERVAL"),
+ "Poll interval (in seconds)")
- flags.StringP("schedule",
+ flags.StringP(
+ "schedule",
"s",
- viper.GetString("WATCHTOWER_SCHEDULE"),
- "the cron expression which defines when to update")
+ envString("WATCHTOWER_SCHEDULE"),
+ "The cron expression which defines when to update")
- flags.DurationP("stop-timeout",
+ flags.DurationP(
+ "stop-timeout",
"t",
- viper.GetDuration("WATCHTOWER_TIMEOUT"),
- "timeout before a container is forcefully stopped")
+ envDuration("WATCHTOWER_TIMEOUT"),
+ "Timeout before a container is forcefully stopped")
flags.BoolP(
"no-pull",
"",
- viper.GetBool("WATCHTOWER_NO_PULL"),
- "do not pull any new images")
+ envBool("WATCHTOWER_NO_PULL"),
+ "Do not pull any new images")
flags.BoolP(
"no-restart",
"",
- viper.GetBool("WATCHTOWER_NO_RESTART"),
- "do not restart any containers")
+ envBool("WATCHTOWER_NO_RESTART"),
+ "Do not restart any containers")
flags.BoolP(
"no-startup-message",
"",
- viper.GetBool("WATCHTOWER_NO_STARTUP_MESSAGE"),
+ envBool("WATCHTOWER_NO_STARTUP_MESSAGE"),
"Prevents watchtower from sending a startup message")
flags.BoolP(
"cleanup",
"c",
- viper.GetBool("WATCHTOWER_CLEANUP"),
- "remove previously used images after updating")
+ envBool("WATCHTOWER_CLEANUP"),
+ "Remove previously used images after updating")
flags.BoolP(
"remove-volumes",
"",
- viper.GetBool("WATCHTOWER_REMOVE_VOLUMES"),
- "remove attached volumes before updating")
+ envBool("WATCHTOWER_REMOVE_VOLUMES"),
+ "Remove attached volumes before updating")
flags.BoolP(
"label-enable",
"e",
- viper.GetBool("WATCHTOWER_LABEL_ENABLE"),
- "watch containers where the com.centurylinklabs.watchtower.enable label is true")
+ envBool("WATCHTOWER_LABEL_ENABLE"),
+ "Watch containers where the com.centurylinklabs.watchtower.enable label is true")
+
+ flags.StringSliceP(
+ "disable-containers",
+ "x",
+ // Due to issue spf13/viper#380, can't use viper.GetStringSlice:
+ regexp.MustCompile("[, ]+").Split(envString("WATCHTOWER_DISABLE_CONTAINERS"), -1),
+ "Comma-separated list of containers to explicitly exclude from watching.")
+
+ flags.StringP(
+ "log-format",
+ "l",
+ viper.GetString("WATCHTOWER_LOG_FORMAT"),
+ "Sets what logging format to use for console output. Possible values: Auto, LogFmt, Pretty, JSON")
flags.BoolP(
"debug",
"d",
- viper.GetBool("WATCHTOWER_DEBUG"),
- "enable debug mode with verbose logging")
+ envBool("WATCHTOWER_DEBUG"),
+ "Enable debug mode with verbose logging")
+
+ flags.BoolP(
+ "trace",
+ "",
+ 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",
+ "",
+ 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",
+ "",
+ envBool("WATCHTOWER_ROLLING_RESTART"),
+ "Restart containers one at a time")
+
+ flags.BoolP(
+ "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",
+ "",
+ envBool("WATCHTOWER_HTTP_API_METRICS"),
+ "Runs Watchtower with the Prometheus metrics API enabled")
+
+ flags.StringP(
+ "http-api-token",
+ "",
+ envString("WATCHTOWER_HTTP_API_TOKEN"),
+ "Sets an authentication token to HTTP API requests.")
+
+ flags.BoolP(
+ "http-api-periodic-polls",
+ "",
+ envBool("WATCHTOWER_HTTP_API_PERIODIC_POLLS"),
+ "Also run periodic updates (specified with --interval and --schedule) if HTTP API is enabled")
+
+ // https://no-color.org/
+ flags.BoolP(
+ "no-color",
+ "",
+ viper.IsSet("NO_COLOR"),
+ "Disable ANSI color escape codes in log output")
+
+ flags.StringP(
+ "scope",
+ "",
+ 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
@@ -120,124 +220,200 @@ func RegisterNotificationFlags(rootCmd *cobra.Command) {
flags.StringSliceP(
"notifications",
"n",
- viper.GetStringSlice("WATCHTOWER_NOTIFICATIONS"),
- " notification types to send (valid: email, slack, msteams, gotify)")
+ envStringSlice("WATCHTOWER_NOTIFICATIONS"),
+ " Notification types to send (valid: email, slack, msteams, gotify, shoutrrr)")
+
+ flags.String(
+ "notifications-level",
+ envString("WATCHTOWER_NOTIFICATIONS_LEVEL"),
+ "The log level used for sending notifications. Possible values: panic, fatal, error, warn, info or debug")
+
+ flags.IntP(
+ "notifications-delay",
+ "",
+ envInt("WATCHTOWER_NOTIFICATIONS_DELAY"),
+ "Delay before sending notifications, expressed in seconds")
flags.StringP(
- "notifications-level",
+ "notifications-hostname",
"",
- viper.GetString("WATCHTOWER_NOTIFICATIONS_LEVEL"),
- "The log level used for sending notifications. Possible values: panic, fatal, error, warn, info or debug")
+ envString("WATCHTOWER_NOTIFICATIONS_HOSTNAME"),
+ "Custom hostname for notification titles")
flags.StringP(
"notification-email-from",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_FROM"),
+ envString("WATCHTOWER_NOTIFICATION_EMAIL_FROM"),
"Address to send notification emails from")
flags.StringP(
"notification-email-to",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_TO"),
+ envString("WATCHTOWER_NOTIFICATION_EMAIL_TO"),
"Address to send notification emails to")
flags.IntP(
"notification-email-delay",
"",
- viper.GetInt("WATCHTOWER_NOTIFICATION_EMAIL_DELAY"),
+ envInt("WATCHTOWER_NOTIFICATION_EMAIL_DELAY"),
"Delay before sending notifications, expressed in seconds")
flags.StringP(
"notification-email-server",
"",
- viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER"),
+ envString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER"),
"SMTP server to send notification emails through")
flags.IntP(
"notification-email-server-port",
"",
- viper.GetInt("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT"),
+ envInt("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT"),
"SMTP server port to send notification emails through")
flags.BoolP(
"notification-email-server-tls-skip-verify",
"",
- viper.GetBool("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY"),
- `
-Controls whether watchtower verifies the SMTP server's certificate chain and host name.
-Should only be used for testing.
-`)
+ 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",
+ "",
+ 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",
+ envString("WATCHTOWER_NOTIFICATION_TEMPLATE"),
+ "The shoutrrr text/template for the messages")
+
+ flags.StringArray(
+ "notification-url",
+ envStringSlice("WATCHTOWER_NOTIFICATION_URL"),
+ "The shoutrrr URL to send notifications to")
+
+ flags.Bool("notification-report",
+ envBool("WATCHTOWER_NOTIFICATION_REPORT"),
+ "Use the session report as the notification template data")
+
+ flags.StringP(
+ "notification-title-tag",
+ "",
+ envString("WATCHTOWER_NOTIFICATION_TITLE_TAG"),
+ "Title prefix tag for notifications")
+
+ flags.Bool("notification-skip-title",
+ envBool("WATCHTOWER_NOTIFICATION_SKIP_TITLE"),
+ "Do not pass the title param to notifications")
+
+ flags.String(
+ "warn-on-head-failure",
+ envString("WATCHTOWER_WARN_ON_HEAD_FAILURE"),
+ "When to warn about HEAD pull requests failing. Possible values: always, auto or never")
+
+ flags.Bool(
+ "notification-log-stdout",
+ envBool("WATCHTOWER_NOTIFICATION_LOG_STDOUT"),
+ "Write notification logs to stdout instead of logging (to stderr)")
+}
+
+func envString(key string) string {
+ viper.MustBindEnv(key)
+ return viper.GetString(key)
+}
+
+func envStringSlice(key string) []string {
+ viper.MustBindEnv(key)
+ return viper.GetStringSlice(key)
+}
+
+func envInt(key string) int {
+ viper.MustBindEnv(key)
+ return viper.GetInt(key)
+}
+
+func envBool(key string) bool {
+ viper.MustBindEnv(key)
+ return viper.GetBool(key)
+}
+
+func envDuration(key string) time.Duration {
+ viper.MustBindEnv(key)
+ return viper.GetDuration(key)
}
// SetDefaults provides default values for environment variables
@@ -245,13 +421,15 @@ func SetDefaults() {
viper.AutomaticEnv()
viper.SetDefault("DOCKER_HOST", "unix:///var/run/docker.sock")
viper.SetDefault("DOCKER_API_VERSION", DockerAPIMinVersion)
- viper.SetDefault("WATCHTOWER_POLL_INTERVAL", 300)
+ viper.SetDefault("WATCHTOWER_POLL_INTERVAL", 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
@@ -328,3 +506,202 @@ func setEnvOptBool(env string, opt bool) error {
}
return nil
}
+
+// GetSecretsFromFiles checks if passwords/tokens/webhooks have been passed as a file instead of plaintext.
+// If so, the value of the flag will be replaced with the contents of the file.
+func GetSecretsFromFiles(rootCmd *cobra.Command) {
+ flags := rootCmd.PersistentFlags()
+
+ secrets := []string{
+ "notification-email-server-password",
+ "notification-slack-hook-url",
+ "notification-msteams-hook",
+ "notification-gotify-token",
+ "notification-url",
+ "http-api-token",
+ }
+ for _, secret := range secrets {
+ 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) 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) {
+ content, err := os.ReadFile(value)
+ if err != nil {
+ return err
+ }
+ return flags.Set(secret, strings.TrimSpace(string(content)))
+ }
+
+ return nil
+}
+
+func isFile(s string) bool {
+ 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
+ }
+ _, 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 ac57b30..2856456 100644
--- a/internal/flags/flags_test.go
+++ b/internal/flags/flags_test.go
@@ -2,14 +2,22 @@ package flags
import (
"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)
@@ -19,7 +27,8 @@ func TestEnvConfig_Defaults(t *testing.T) {
assert.Equal(t, "unix:///var/run/docker.sock", os.Getenv("DOCKER_HOST"))
assert.Equal(t, "", os.Getenv("DOCKER_TLS_VERIFY"))
- assert.Equal(t, DockerAPIMinVersion, os.Getenv("DOCKER_API_VERSION"))
+ // Re-enable this test when we've moved to github actions.
+ // assert.Equal(t, DockerAPIMinVersion, os.Getenv("DOCKER_API_VERSION"))
}
func TestEnvConfig_Custom(t *testing.T) {
@@ -35,5 +44,296 @@ func TestEnvConfig_Custom(t *testing.T) {
assert.Equal(t, "some-custom-docker-host", os.Getenv("DOCKER_HOST"))
assert.Equal(t, "1", os.Getenv("DOCKER_TLS_VERIFY"))
- assert.Equal(t, "1.99", os.Getenv("DOCKER_API_VERSION"))
+ // Re-enable this test when we've moved to github actions.
+ // assert.Equal(t, "1.99", os.Getenv("DOCKER_API_VERSION"))
+}
+
+func TestGetSecretsFromFilesWithString(t *testing.T) {
+ value := "supersecretstring"
+ t.Setenv("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD", value)
+
+ testGetSecretsFromFiles(t, "notification-email-server-password", value)
+}
+
+func TestGetSecretsFromFilesWithFile(t *testing.T) {
+ value := "megasecretstring"
+
+ // 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.
+ _, err = file.Write([]byte(value))
+ require.NoError(t, err)
+ require.NoError(t, file.Close())
+
+ t.Setenv("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD", file.Name())
+
+ testGetSecretsFromFiles(t, "notification-email-server-password", value)
+}
+
+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)
+ flag := cmd.PersistentFlags().Lookup(flagName)
+ require.NotNil(t, flag)
+ value := flag.Value.String()
+
+ assert.Equal(t, expected, value)
+}
+
+func TestHTTPAPIPeriodicPollsFlag(t *testing.T) {
+ cmd := new(cobra.Command)
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+
+ err := cmd.ParseFlags([]string{"--http-api-periodic-polls"})
+ require.NoError(t, err)
+
+ periodicPolls, err := cmd.PersistentFlags().GetBool("http-api-periodic-polls")
+ require.NoError(t, err)
+
+ assert.Equal(t, true, periodicPolls)
+}
+
+func TestIsFile(t *testing.T) {
+ assert.False(t, isFile("https://google.com"), "an URL should never be considered a file")
+ assert.True(t, isFile(os.Args[0]), "the currently running binary path should always be considered a file")
+}
+
+func TestProcessFlagAliases(t *testing.T) {
+ logrus.StandardLogger().ExitFunc = func(_ int) { t.FailNow() }
+ cmd := new(cobra.Command)
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+ RegisterNotificationFlags(cmd)
+
+ require.NoError(t, cmd.ParseFlags([]string{
+ `--porcelain`, `v1`,
+ `--interval`, `10`,
+ `--trace`,
+ }))
+ flags := cmd.Flags()
+ ProcessFlagAliases(flags)
+
+ urls, _ := flags.GetStringArray(`notification-url`)
+ assert.Contains(t, urls, `logger://`)
+
+ logStdout, _ := flags.GetBool(`notification-log-stdout`)
+ assert.True(t, logStdout)
+
+ report, _ := flags.GetBool(`notification-report`)
+ assert.True(t, report)
+
+ template, _ := flags.GetString(`notification-template`)
+ assert.Equal(t, `porcelain.v1.summary-no-log`, template)
+
+ sched, _ := flags.GetString(`schedule`)
+ assert.Equal(t, `@every 10s`, sched)
+
+ logLevel, _ := flags.GetString(`log-level`)
+ assert.Equal(t, `trace`, logLevel)
+}
+
+func TestProcessFlagAliasesLogLevelFromEnvironment(t *testing.T) {
+ cmd := new(cobra.Command)
+ t.Setenv("WATCHTOWER_DEBUG", `true`)
+
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+ RegisterNotificationFlags(cmd)
+
+ require.NoError(t, cmd.ParseFlags([]string{}))
+ flags := cmd.Flags()
+ ProcessFlagAliases(flags)
+
+ logLevel, _ := flags.GetString(`log-level`)
+ assert.Equal(t, `debug`, logLevel)
+}
+
+func TestLogFormatFlag(t *testing.T) {
+ cmd := new(cobra.Command)
+
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+
+ // Ensure the default value is Auto
+ require.NoError(t, cmd.ParseFlags([]string{}))
+ require.NoError(t, SetupLogging(cmd.Flags()))
+ assert.IsType(t, &logrus.TextFormatter{}, logrus.StandardLogger().Formatter)
+
+ // Test JSON format
+ require.NoError(t, cmd.ParseFlags([]string{`--log-format`, `JSON`}))
+ require.NoError(t, SetupLogging(cmd.Flags()))
+ assert.IsType(t, &logrus.JSONFormatter{}, logrus.StandardLogger().Formatter)
+
+ // Test Pretty format
+ require.NoError(t, cmd.ParseFlags([]string{`--log-format`, `pretty`}))
+ require.NoError(t, SetupLogging(cmd.Flags()))
+ assert.IsType(t, &logrus.TextFormatter{}, logrus.StandardLogger().Formatter)
+ textFormatter, ok := (logrus.StandardLogger().Formatter).(*logrus.TextFormatter)
+ assert.True(t, ok)
+ assert.True(t, textFormatter.ForceColors)
+ assert.False(t, textFormatter.FullTimestamp)
+
+ // Test LogFmt format
+ require.NoError(t, cmd.ParseFlags([]string{`--log-format`, `logfmt`}))
+ require.NoError(t, SetupLogging(cmd.Flags()))
+ textFormatter, ok = (logrus.StandardLogger().Formatter).(*logrus.TextFormatter)
+ assert.True(t, ok)
+ assert.True(t, textFormatter.DisableColors)
+ assert.True(t, textFormatter.FullTimestamp)
+
+ // Test invalid format
+ require.NoError(t, cmd.ParseFlags([]string{`--log-format`, `cowsay`}))
+ require.Error(t, SetupLogging(cmd.Flags()))
+}
+
+func TestLogLevelFlag(t *testing.T) {
+ cmd := new(cobra.Command)
+
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+
+ // Test invalid format
+ require.NoError(t, cmd.ParseFlags([]string{`--log-level`, `gossip`}))
+ require.Error(t, SetupLogging(cmd.Flags()))
+}
+
+func TestProcessFlagAliasesSchedAndInterval(t *testing.T) {
+ logrus.StandardLogger().ExitFunc = func(_ int) { panic(`FATAL`) }
+ cmd := new(cobra.Command)
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+ RegisterNotificationFlags(cmd)
+
+ require.NoError(t, cmd.ParseFlags([]string{`--schedule`, `@hourly`, `--interval`, `10`}))
+ flags := cmd.Flags()
+
+ assert.PanicsWithValue(t, `FATAL`, func() {
+ ProcessFlagAliases(flags)
+ })
+}
+
+func TestProcessFlagAliasesScheduleFromEnvironment(t *testing.T) {
+ cmd := new(cobra.Command)
+
+ t.Setenv("WATCHTOWER_SCHEDULE", `@hourly`)
+
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+ RegisterNotificationFlags(cmd)
+
+ require.NoError(t, cmd.ParseFlags([]string{}))
+ flags := cmd.Flags()
+ ProcessFlagAliases(flags)
+
+ sched, _ := flags.GetString(`schedule`)
+ assert.Equal(t, `@hourly`, sched)
+}
+
+func TestProcessFlagAliasesInvalidPorcelaineVersion(t *testing.T) {
+ logrus.StandardLogger().ExitFunc = func(_ int) { panic(`FATAL`) }
+ cmd := new(cobra.Command)
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+ RegisterNotificationFlags(cmd)
+
+ require.NoError(t, cmd.ParseFlags([]string{`--porcelain`, `cowboy`}))
+ flags := cmd.Flags()
+
+ assert.PanicsWithValue(t, `FATAL`, func() {
+ ProcessFlagAliases(flags)
+ })
+}
+
+func TestFlagsArePrecentInDocumentation(t *testing.T) {
+
+ // Legacy notifcations are ignored, since they are (soft) deprecated
+ ignoredEnvs := map[string]string{
+ "WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI": "legacy",
+ "WATCHTOWER_NOTIFICATION_SLACK_ICON_URL": "legacy",
+ }
+
+ ignoredFlags := map[string]string{
+ "notification-gotify-url": "legacy",
+ "notification-slack-icon-emoji": "legacy",
+ "notification-slack-icon-url": "legacy",
+ }
+
+ cmd := new(cobra.Command)
+ SetDefaults()
+ RegisterDockerFlags(cmd)
+ RegisterSystemFlags(cmd)
+ RegisterNotificationFlags(cmd)
+
+ flags := cmd.PersistentFlags()
+
+ docFiles := []string{
+ "../../docs/arguments.md",
+ "../../docs/lifecycle-hooks.md",
+ "../../docs/notifications.md",
+ }
+ allDocs := ""
+ for _, f := range docFiles {
+ bytes, err := os.ReadFile(f)
+ if err != nil {
+ t.Fatalf("Could not load docs file %q: %v", f, err)
+ }
+ allDocs += string(bytes)
+ }
+
+ flags.VisitAll(func(f *pflag.Flag) {
+ if !strings.Contains(allDocs, "--"+f.Name) {
+ if _, found := ignoredFlags[f.Name]; !found {
+ t.Logf("Docs does not mention flag long name %q", f.Name)
+ t.Fail()
+ }
+ }
+ if !strings.Contains(allDocs, "-"+f.Shorthand) {
+ t.Logf("Docs does not mention flag shorthand %q (%q)", f.Shorthand, f.Name)
+ t.Fail()
+ }
+ })
+
+ for _, key := range viper.AllKeys() {
+ envKey := strings.ToUpper(key)
+ if !strings.Contains(allDocs, envKey) {
+ if _, found := ignoredEnvs[envKey]; !found {
+ t.Logf("Docs does not mention environment variable %q", envKey)
+ t.Fail()
+ }
+ }
+ }
}
diff --git a/internal/meta/meta.go b/internal/meta/meta.go
new file mode 100644
index 0000000..1571291
--- /dev/null
+++ b/internal/meta/meta.go
@@ -0,0 +1,13 @@
+package meta
+
+var (
+ // Version is the compile-time set version of Watchtower
+ Version = "v0.0.0-unknown"
+
+ // UserAgent is the http client identifier derived from Version
+ UserAgent string
+)
+
+func init() {
+ UserAgent = "Watchtower/" + Version
+}
diff --git a/internal/util/rand_sha256.go b/internal/util/rand_sha256.go
new file mode 100644
index 0000000..38a3736
--- /dev/null
+++ b/internal/util/rand_sha256.go
@@ -0,0 +1,24 @@
+package util
+
+import (
+ "bytes"
+ "crypto/rand"
+ "fmt"
+)
+
+// GenerateRandomSHA256 generates a random 64 character SHA 256 hash string
+func GenerateRandomSHA256() string {
+ return GenerateRandomPrefixedSHA256()[7:]
+}
+
+// GenerateRandomPrefixedSHA256 generates a random 64 character SHA 256 hash string, prefixed with `sha256:`
+func GenerateRandomPrefixedSHA256() string {
+ hash := make([]byte, 32)
+ _, _ = rand.Read(hash)
+ sb := bytes.NewBufferString("sha256:")
+ sb.Grow(64)
+ for _, h := range hash {
+ _, _ = fmt.Fprintf(sb, "%02x", h)
+ }
+ return sb.String()
+}
diff --git a/internal/util/util_test.go b/internal/util/util_test.go
index a6dd657..0b2c36c 100644
--- a/internal/util/util_test.go
+++ b/internal/util/util_test.go
@@ -1,8 +1,10 @@
package util
import (
- "github.com/stretchr/testify/assert"
+ "regexp"
"testing"
+
+ "github.com/stretchr/testify/assert"
)
func TestSliceEqual_True(t *testing.T) {
@@ -62,3 +64,15 @@ func TestStructMapSubtract(t *testing.T) {
assert.Equal(t, map[string]struct{}{"a": x, "b": x, "c": x}, m1)
assert.Equal(t, map[string]struct{}{"a": x, "c": x}, m2)
}
+
+// GenerateRandomSHA256 generates a random 64 character SHA 256 hash string
+func TestGenerateRandomSHA256(t *testing.T) {
+ res := GenerateRandomSHA256()
+ assert.Len(t, res, 64)
+ assert.NotContains(t, res, "sha256:")
+}
+
+func TestGenerateRandomPrefixedSHA256(t *testing.T) {
+ res := GenerateRandomPrefixedSHA256()
+ assert.Regexp(t, regexp.MustCompile("sha256:[0-9|a-f]{64}"), res)
+}
diff --git a/logo.png b/logo.png
new file mode 100644
index 0000000..cc65ff4
Binary files /dev/null and b/logo.png differ
diff --git a/mkdocs.yml b/mkdocs.yml
index 645c1cc..5227004 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,12 +1,39 @@
site_name: Watchtower
-site_url: http://containrrr.github.io/watchtower/
+site_url: https://containrrr.dev/watchtower/
repo_url: https://github.com/containrrr/watchtower/
+edit_uri: edit/main/docs/
theme:
name: 'material'
+ palette:
+ - 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:
+ - stylesheets/theme.css
markdown_extensions:
- toc:
permalink: True
separator: "_"
+ - admonition
+ - pymdownx.highlight
+ - pymdownx.superfences
+ - pymdownx.magiclink:
+ repo_url_shortener: True
+ provider: github
+ user: containrrr
+ repo: watchtower
+ - pymdownx.saneheaders
+ - pymdownx.tabbed:
+ alternate_style: true
nav:
- 'Home': 'index.md'
- 'Introduction': 'introduction.md'
@@ -20,5 +47,8 @@ nav:
- 'Secure connections': 'secure-connections.md'
- 'Stop signals': 'stop-signals.md'
- 'Lifecycle hooks': 'lifecycle-hooks.md'
+ - 'Running multiple instances': 'running-multiple-instances.md'
+ - 'HTTP API Mode': 'http-api-mode.md'
+ - 'Metrics': 'metrics.md'
plugins:
- search
diff --git a/oryxBuildBinary b/oryxBuildBinary
new file mode 100755
index 0000000..86cbe57
Binary files /dev/null and b/oryxBuildBinary differ
diff --git a/pkg/api/api.go b/pkg/api/api.go
new file mode 100644
index 0000000..2ceaea8
--- /dev/null
+++ b/pkg/api/api.go
@@ -0,0 +1,76 @@
+package api
+
+import (
+ "fmt"
+ "net/http"
+
+ log "github.com/sirupsen/logrus"
+)
+
+const tokenMissingMsg = "api token is empty or has not been set. exiting"
+
+// API is the http server responsible for serving the HTTP API endpoints
+type API struct {
+ Token string
+ hasHandlers bool
+}
+
+// New is a factory function creating a new API instance
+func New(token string) *API {
+ return &API{
+ Token: token,
+ hasHandlers: false,
+ }
+}
+
+// RequireToken is wrapper around http.HandleFunc that checks token validity
+func (api *API) RequireToken(fn http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ auth := r.Header.Get("Authorization")
+ want := fmt.Sprintf("Bearer %s", api.Token)
+ if auth != want {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ log.Debug("Valid token found.")
+ fn(w, r)
+ }
+}
+
+// RegisterFunc is a wrapper around http.HandleFunc that also sets the flag used to determine whether to launch the API
+func (api *API) RegisterFunc(path string, fn http.HandlerFunc) {
+ api.hasHandlers = true
+ http.HandleFunc(path, api.RequireToken(fn))
+}
+
+// RegisterHandler is a wrapper around http.Handler that also sets the flag used to determine whether to launch the API
+func (api *API) RegisterHandler(path string, handler http.Handler) {
+ api.hasHandlers = true
+ http.Handle(path, api.RequireToken(handler.ServeHTTP))
+}
+
+// Start the API and serve over HTTP. Requires an API Token to be set.
+func (api *API) Start(block bool) error {
+
+ if !api.hasHandlers {
+ log.Debug("Watchtower HTTP API skipped.")
+ return nil
+ }
+
+ if api.Token == "" {
+ log.Fatal(tokenMissingMsg)
+ }
+
+ if block {
+ runHTTPServer()
+ } else {
+ go func() {
+ runHTTPServer()
+ }()
+ }
+ return nil
+}
+
+func runHTTPServer() {
+ log.Fatal(http.ListenAndServe(":8080", nil))
+}
diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go
new file mode 100644
index 0000000..4e9110b
--- /dev/null
+++ b/pkg/api/api_test.go
@@ -0,0 +1,65 @@
+package api
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+const (
+ token = "123123123"
+)
+
+func TestAPI(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "API Suite")
+}
+
+var _ = Describe("API", func() {
+ api := New(token)
+
+ Describe("RequireToken middleware", func() {
+ It("should return 401 Unauthorized when token is not provided", func() {
+ handlerFunc := api.RequireToken(testHandler)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/hello", nil)
+
+ handlerFunc(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("should return 401 Unauthorized when token is invalid", func() {
+ handlerFunc := api.RequireToken(testHandler)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/hello", nil)
+ req.Header.Set("Authorization", "Bearer 123")
+
+ handlerFunc(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("should return 200 OK when token is valid", func() {
+ handlerFunc := api.RequireToken(testHandler)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/hello", nil)
+ req.Header.Set("Authorization", "Bearer " + token)
+
+ handlerFunc(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ })
+ })
+})
+
+func testHandler(w http.ResponseWriter, req *http.Request) {
+ _, _ = io.WriteString(w, "Hello!")
+}
diff --git a/pkg/api/metrics/metrics.go b/pkg/api/metrics/metrics.go
new file mode 100644
index 0000000..4faad4a
--- /dev/null
+++ b/pkg/api/metrics/metrics.go
@@ -0,0 +1,27 @@
+package metrics
+
+import (
+ "github.com/containrrr/watchtower/pkg/metrics"
+ "net/http"
+
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+// Handler is an HTTP handle for serving metric data
+type Handler struct {
+ Path string
+ Handle http.HandlerFunc
+ Metrics *metrics.Metrics
+}
+
+// New is a factory function creating a new Metrics instance
+func New() *Handler {
+ m := metrics.Default()
+ handler := promhttp.Handler()
+
+ return &Handler{
+ Path: "/v1/metrics",
+ Handle: handler.ServeHTTP,
+ Metrics: m,
+ }
+}
diff --git a/pkg/api/metrics/metrics_test.go b/pkg/api/metrics/metrics_test.go
new file mode 100644
index 0000000..48b6dd7
--- /dev/null
+++ b/pkg/api/metrics/metrics_test.go
@@ -0,0 +1,90 @@
+package metrics_test
+
+import (
+ "fmt"
+ "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"
+)
+
+const (
+ token = "123123123"
+ getURL = "http://localhost:8080/v1/metrics"
+)
+
+func TestMetrics(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Metrics Suite")
+}
+
+func getWithToken(handler http.Handler) map[string]string {
+ metricMap := map[string]string{}
+ respWriter := httptest.NewRecorder()
+
+ req := httptest.NewRequest("GET", getURL, nil)
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
+
+ handler.ServeHTTP(respWriter, req)
+
+ res := respWriter.Result()
+ body, _ := io.ReadAll(res.Body)
+
+ for _, line := range strings.Split(string(body), "\n") {
+ if len(line) < 1 || line[0] == '#' {
+ continue
+ }
+ parts := strings.Split(line, " ")
+ metricMap[parts[0]] = parts[1]
+ }
+
+ return metricMap
+}
+
+var _ = Describe("the metrics API", func() {
+ httpAPI := api.New(token)
+ m := metricsAPI.New()
+
+ handleReq := httpAPI.RequireToken(m.Handle)
+ tryGetMetrics := func() map[string]string { return getWithToken(handleReq) }
+
+ It("should serve metrics", func() {
+
+ Expect(tryGetMetrics()).To(HaveKeyWithValue("watchtower_containers_updated", "0"))
+
+ metric := &metrics.Metric{
+ Scanned: 4,
+ Updated: 3,
+ Failed: 1,
+ }
+
+ metrics.RegisterScan(metric)
+ Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue())
+
+ Eventually(tryGetMetrics).Should(SatisfyAll(
+ HaveKeyWithValue("watchtower_containers_updated", "3"),
+ HaveKeyWithValue("watchtower_containers_failed", "1"),
+ HaveKeyWithValue("watchtower_containers_scanned", "4"),
+ HaveKeyWithValue("watchtower_scans_total", "1"),
+ HaveKeyWithValue("watchtower_scans_skipped", "0"),
+ ))
+
+ for i := 0; i < 3; i++ {
+ metrics.RegisterScan(nil)
+ }
+ Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue())
+
+ Eventually(tryGetMetrics).Should(SatisfyAll(
+ HaveKeyWithValue("watchtower_scans_total", "4"),
+ HaveKeyWithValue("watchtower_scans_skipped", "3"),
+ ))
+ })
+})
diff --git a/pkg/api/update/update.go b/pkg/api/update/update.go
new file mode 100644
index 0000000..ba044ab
--- /dev/null
+++ b/pkg/api/update/update.go
@@ -0,0 +1,72 @@
+package update
+
+import (
+ "io"
+ "net/http"
+ "os"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+)
+
+var (
+ lock chan bool
+)
+
+// New is a factory function creating a new Handler instance
+func New(updateFn func(images []string), updateLock chan bool) *Handler {
+ if updateLock != nil {
+ lock = updateLock
+ } else {
+ lock = make(chan bool, 1)
+ lock <- true
+ }
+
+ return &Handler{
+ fn: updateFn,
+ Path: "/v1/update",
+ }
+}
+
+// Handler is an API handler used for triggering container update scans
+type Handler struct {
+ fn func(images []string)
+ Path string
+}
+
+// Handle is the actual http.Handle function doing all the heavy lifting
+func (handle *Handler) Handle(w http.ResponseWriter, r *http.Request) {
+ log.Info("Updates triggered by HTTP API request.")
+
+ _, err := io.Copy(os.Stdout, r.Body)
+ if err != nil {
+ log.Println(err)
+ return
+ }
+
+ 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(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 607b84c..c6c37de 100644
--- a/pkg/container/client.go
+++ b/pkg/container/client.go
@@ -3,12 +3,10 @@ package container
import (
"bytes"
"fmt"
- "github.com/containrrr/watchtower/pkg/registry"
- "io/ioutil"
+ "io"
"strings"
"time"
- 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"
@@ -16,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"
@@ -23,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 string) (Container, error)
- StopContainer(Container, time.Duration) error
- StartContainer(Container) (string, error)
- RenameContainer(Container, string) error
- IsContainerStale(Container) (bool, error)
- ExecuteCommand(containerID string, command string) error
- RemoveImageByID(string) error
-
+ ListContainers(t.Filter) ([]t.Container, error)
+ GetContainer(containerID t.ContainerID) (t.Container, error)
+ StopContainer(t.Container, time.Duration) error
+ StartContainer(t.Container) (t.ContainerID, error)
+ RenameContainer(t.Container, string) error
+ IsContainerStale(t.Container, t.UpdateParams) (stale bool, latestImage t.ImageID, err error)
+ ExecuteCommand(containerID t.ContainerID, command string, timeout int) (SkipUpdate bool, err error)
+ RemoveImageByID(t.ImageID) error
+ WarnOnHeadPullFailed(container t.Container) bool
}
// NewClient returns a new Client instance which can be used to interact with
// the Docker API.
// The client reads its configuration from the following environment variables:
-// * DOCKER_HOST the docker-engine host to send api requests to
-// * DOCKER_TLS_VERIFY whether to verify tls certificates
-// * DOCKER_API_VERSION the minimum docker api version to work with
-func NewClient(pullImages bool, includeStopped bool, reviveStopped bool, removeVolumes bool) 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 {
@@ -48,28 +50,58 @@ func NewClient(pullImages bool, includeStopped bool, reviveStopped bool, removeV
}
return dockerClient{
- api: cli,
- pullImages: pullImages,
- removeVolumes: removeVolumes,
- includeStopped: includeStopped,
- reviveStopped: reviveStopped,
+ api: cli,
+ ClientOptions: opts,
}
}
-type dockerClient struct {
- api sdkClient.CommonAPIClient
- pullImages bool
- removeVolumes bool
- includeStopped bool
- reviveStopped bool
+// ClientOptions contains the options for how the docker client wrapper should behave
+type ClientOptions struct {
+ RemoveVolumes bool
+ IncludeStopped bool
+ ReviveStopped bool
+ IncludeRestarting bool
+ WarnOnHeadFailed WarningStrategy
}
-func (client dockerClient) ListContainers(fn t.Filter) ([]Container, error) {
- cs := []Container{}
+// WarningStrategy is a value determining when to show warnings
+type WarningStrategy string
+
+const (
+ // WarnAlways warns whenever the problem occurs
+ WarnAlways WarningStrategy = "always"
+ // WarnNever never warns when the problem occurs
+ WarnNever WarningStrategy = "never"
+ // WarnAuto skips warning when the problem was expected
+ WarnAuto WarningStrategy = "auto"
+)
+
+type dockerClient struct {
+ api sdkClient.CommonAPIClient
+ ClientOptions
+}
+
+func (client dockerClient) WarnOnHeadPullFailed(container t.Container) bool {
+ if client.WarnOnHeadFailed == WarnAlways {
+ return true
+ }
+ if client.WarnOnHeadFailed == WarnNever {
+ return false
+ }
+
+ return registry.WarnOnAPIConsumption(container)
+}
+
+func (client dockerClient) ListContainers(fn t.Filter) ([]t.Container, error) {
+ cs := []t.Container{}
bg := context.Background()
- if client.includeStopped {
- log.Debug("Retrieving containers including stopped and exited")
+ if client.IncludeStopped && client.IncludeRestarting {
+ log.Debug("Retrieving running, stopped, restarting and exited containers")
+ } else if client.IncludeStopped {
+ log.Debug("Retrieving running, stopped and exited containers")
+ } else if client.IncludeRestarting {
+ log.Debug("Retrieving running and restarting containers")
} else {
log.Debug("Retrieving running containers")
}
@@ -87,7 +119,7 @@ func (client dockerClient) ListContainers(fn t.Filter) ([]Container, error) {
for _, runningContainer := range containers {
- c, err := client.GetContainer(runningContainer.ID)
+ c, err := client.GetContainer(t.ContainerID(runningContainer.ID))
if err != nil {
return nil, err
}
@@ -104,41 +136,64 @@ func (client dockerClient) createListFilter() filters.Args {
filterArgs := filters.NewArgs()
filterArgs.Add("status", "running")
- if client.includeStopped {
+ if client.IncludeStopped {
filterArgs.Add("status", "created")
filterArgs.Add("status", "exited")
}
+ if client.IncludeRestarting {
+ filterArgs.Add("status", "restarting")
+ }
+
return filterArgs
}
-func (client dockerClient) GetContainer(containerID string) (Container, error) {
+func (client dockerClient) GetContainer(containerID t.ContainerID) (t.Container, error) {
bg := context.Background()
- containerInfo, err := client.api.ContainerInspect(bg, containerID)
+ containerInfo, err := client.api.ContainerInspect(bg, string(containerID))
if err != nil {
- return Container{}, err
+ return &Container{}, err
+ }
+
+ netType, netContainerId, found := strings.Cut(string(containerInfo.HostConfig.NetworkMode), ":")
+ if found && netType == "container" {
+ parentContainer, err := client.api.ContainerInspect(bg, netContainerId)
+ if err != nil {
+ log.WithFields(map[string]interface{}{
+ "container": containerInfo.Name,
+ "error": err,
+ "network-container": netContainerId,
+ }).Warnf("Unable to resolve network container: %v", err)
+
+ } else {
+ // Replace the container ID with a container name to allow it to reference the re-created network container
+ containerInfo.HostConfig.NetworkMode = container.NetworkMode(fmt.Sprintf("container:%s", parentContainer.Name))
+ }
}
imageInfo, _, err := client.api.ImageInspectWithRaw(bg, containerInfo.Image)
if err != nil {
- return Container{}, err
+ log.Warnf("Failed to retrieve container image info: %v", err)
+ return &Container{containerInfo: &containerInfo, imageInfo: nil}, nil
}
- container := Container{containerInfo: &containerInfo, imageInfo: &imageInfo}
- return container, nil
+ return &Container{containerInfo: &containerInfo, imageInfo: &imageInfo}, nil
}
-func (client dockerClient) StopContainer(c Container, timeout time.Duration) error {
+func (client dockerClient) StopContainer(c t.Container, timeout time.Duration) error {
bg := context.Background()
signal := c.StopSignal()
if signal == "" {
signal = defaultStopSignal
}
+ idStr := string(c.ID())
+ shortID := c.ID().ShortID()
+
if c.IsRunning() {
- log.Infof("Stopping %s (%s) with %s", c.Name(), c.ID(), signal)
- if err := client.api.ContainerKill(bg, c.ID(), signal); err != nil {
+ log.Infof("Stopping %s (%s) with %s", c.Name(), shortID, signal)
+ if err := client.api.ContainerKill(bg, idStr, signal); err != nil {
return err
}
}
@@ -146,29 +201,56 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
// TODO: This should probably be checked.
_ = client.waitForStopOrTimeout(c, timeout)
- if c.containerInfo.HostConfig.AutoRemove {
- log.Debugf("AutoRemove container %s, skipping ContainerRemove call.", c.ID())
+ if c.ContainerInfo().HostConfig.AutoRemove {
+ log.Debugf("AutoRemove container %s, skipping ContainerRemove call.", shortID)
} else {
- log.Debugf("Removing container %s", c.ID())
+ log.Debugf("Removing container %s", shortID)
- if err := client.api.ContainerRemove(bg, c.ID(), types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.removeVolumes}); err != nil {
+ if err := client.api.ContainerRemove(bg, idStr, types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.RemoveVolumes}); err != nil {
+ if sdkClient.IsErrNotFound(err) {
+ log.Debugf("Container %s not found, skipping removal.", shortID)
+ return nil
+ }
return err
}
}
// Wait for container to be removed. In this case an error is a good thing
if err := client.waitForStopOrTimeout(c, timeout); err == nil {
- return fmt.Errorf("container %s (%s) could not be removed", c.Name(), c.ID())
+ return fmt.Errorf("container %s (%s) could not be removed", c.Name(), shortID)
}
return nil
}
-func (client dockerClient) StartContainer(c Container) (string, error) {
+func (client dockerClient) GetNetworkConfig(c t.Container) *network.NetworkingConfig {
+ config := &network.NetworkingConfig{
+ EndpointsConfig: c.ContainerInfo().NetworkSettings.Networks,
+ }
+
+ for _, ep := range config.EndpointsConfig {
+ aliases := make([]string, 0, len(ep.Aliases))
+ cidAlias := c.ID().ShortID()
+
+ // Remove the old container ID alias from the network aliases, as it would accumulate across updates otherwise
+ for _, alias := range ep.Aliases {
+ if alias == cidAlias {
+ continue
+ }
+ aliases = append(aliases, alias)
+ }
+
+ ep.Aliases = aliases
+ }
+ return config
+}
+
+func (client dockerClient) StartContainer(c t.Container) (t.ContainerID, error) {
bg := context.Background()
- config := c.runtimeConfig()
- hostConfig := c.hostConfig()
- networkConfig := &network.NetworkingConfig{EndpointsConfig: c.containerInfo.NetworkSettings.Networks}
+ config := c.GetCreateConfig()
+ hostConfig := c.GetCreateHostConfig()
+ networkConfig := client.GetNetworkConfig(c)
+
// simpleNetworkConfig is a networkConfig with only 1 network.
// see: https://github.com/docker/docker/issues/29265
simpleNetworkConfig := func() *network.NetworkingConfig {
@@ -184,7 +266,8 @@ func (client dockerClient) StartContainer(c Container) (string, error) {
name := c.Name()
log.Infof("Creating %s", name)
- createdContainer, err := client.api.ContainerCreate(bg, config, hostConfig, simpleNetworkConfig, name)
+
+ createdContainer, err := client.api.ContainerCreate(bg, config, hostConfig, simpleNetworkConfig, nil, name)
if err != nil {
return "", err
}
@@ -207,18 +290,19 @@ func (client dockerClient) StartContainer(c Container) (string, error) {
}
- if !c.IsRunning() && !client.reviveStopped {
- return createdContainer.ID, nil
+ createdContainerID := t.ContainerID(createdContainer.ID)
+ if !c.IsRunning() && !client.ReviveStopped {
+ return createdContainerID, nil
}
- return createdContainer.ID, client.doStartContainer(bg, c, createdContainer)
+ return createdContainerID, client.doStartContainer(bg, c, createdContainer)
}
-func (client dockerClient) doStartContainer(bg context.Context, c Container, creation container.ContainerCreateCreatedBody) error {
+func (client dockerClient) doStartContainer(bg context.Context, c t.Container, creation container.CreateResponse) error {
name := c.Name()
- log.Debugf("Starting container %s (%s)", name, creation.ID)
+ log.Debugf("Starting container %s (%s)", name, t.ContainerID(creation.ID).ShortID())
err := client.api.ContainerStart(bg, creation.ID, types.ContainerStartOptions{})
if err != nil {
return err
@@ -226,52 +310,85 @@ func (client dockerClient) doStartContainer(bg context.Context, c Container, cre
return nil
}
-func (client dockerClient) RenameContainer(c Container, newName string) error {
+func (client dockerClient) RenameContainer(c t.Container, newName string) error {
bg := context.Background()
- log.Debugf("Renaming container %s (%s) to %s", c.Name(), c.ID(), newName)
- return client.api.ContainerRename(bg, c.ID(), newName)
+ log.Debugf("Renaming container %s (%s) to %s", c.Name(), c.ID().ShortID(), newName)
+ return client.api.ContainerRename(bg, string(c.ID()), newName)
}
-func (client dockerClient) IsContainerStale(container Container) (bool, error) {
+func (client dockerClient) IsContainerStale(container t.Container, params t.UpdateParams) (stale bool, latestImage t.ImageID, err error) {
ctx := context.Background()
- if !client.pullImages {
+ if container.IsNoPull(params) {
log.Debugf("Skipping image pull.")
} else if err := client.PullImage(ctx, container); err != nil {
- return false, err
+ return false, container.SafeImageID(), err
}
return client.HasNewImage(ctx, container)
}
-func (client dockerClient) HasNewImage(ctx context.Context, container Container) (bool, error) {
- oldImageID := container.imageInfo.ID
+func (client dockerClient) HasNewImage(ctx context.Context, container t.Container) (hasNew bool, latestImage t.ImageID, err error) {
+ currentImageID := t.ImageID(container.ContainerInfo().ContainerJSONBase.Image)
imageName := container.ImageName()
newImageInfo, _, err := client.api.ImageInspectWithRaw(ctx, imageName)
if err != nil {
- return false, err
+ return false, currentImageID, err
}
- if newImageInfo.ID == oldImageID {
+ newImageID := t.ImageID(newImageInfo.ID)
+ if newImageID == currentImageID {
log.Debugf("No new images found for %s", container.Name())
- return false, nil
+ return false, currentImageID, nil
}
- log.Infof("Found new %s image (%s)", imageName, newImageInfo.ID)
- return true, nil
+ log.Infof("Found new %s image (%s)", imageName, newImageID.ShortID())
+ return true, newImageID, nil
}
-func (client dockerClient) PullImage(ctx context.Context, container Container) error {
+// PullImage pulls the latest image for the supplied container, optionally skipping if it's digest can be confirmed
+// to match the one that the registry reports via a HEAD request
+func (client dockerClient) PullImage(ctx context.Context, container t.Container) error {
containerName := container.Name()
imageName := container.ImageName()
- log.Debugf("Pulling %s for %s", imageName, containerName)
+ fields := log.Fields{
+ "image": imageName,
+ "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 {
log.Debugf("Error loading authentication credentials %s", err)
return err
}
+ if opts.RegistryAuth != "" {
+ log.Debug("Credentials loaded")
+ }
+
+ log.WithFields(fields).Debugf("Checking if pull is needed")
+
+ if match, err := digest.CompareDigest(container, opts.RegistryAuth); err != nil {
+ headLevel := log.DebugLevel
+ if client.WarnOnHeadPullFailed(container) {
+ headLevel = log.WarnLevel
+ }
+ log.WithFields(fields).Logf(headLevel, "Could not do a head request for %q, falling back to regular pull.", imageName)
+ log.WithFields(fields).Log(headLevel, "Reason: ", err)
+ } else if match {
+ log.Debug("No pull needed. Skipping image.")
+ return nil
+ } else {
+ log.Debug("Digests did not match, doing a pull.")
+ }
+
+ log.WithFields(fields).Debugf("Pulling image")
response, err := client.api.ImagePull(ctx, imageName, opts)
if err != nil {
@@ -281,28 +398,50 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
defer response.Close()
// the pull request will be aborted prematurely unless the response is read
- if _, err = ioutil.ReadAll(response); err != nil {
+ if _, err = io.ReadAll(response); err != nil {
log.Error(err)
return err
}
return nil
}
-func (client dockerClient) RemoveImageByID(id string) error {
- log.Infof("Removing image %s", id)
+func (client dockerClient) RemoveImageByID(id t.ImageID) error {
+ log.Infof("Removing image %s", id.ShortID())
- _, err := client.api.ImageRemove(
+ items, err := client.api.ImageRemove(
context.Background(),
- id,
+ string(id),
types.ImageRemoveOptions{
Force: true,
})
+ if log.IsLevelEnabled(log.DebugLevel) {
+ deleted := strings.Builder{}
+ untagged := strings.Builder{}
+ for _, item := range items {
+ if item.Deleted != "" {
+ if deleted.Len() > 0 {
+ deleted.WriteString(`, `)
+ }
+ deleted.WriteString(t.ImageID(item.Deleted).ShortID())
+ }
+ if item.Untagged != "" {
+ if untagged.Len() > 0 {
+ untagged.WriteString(`, `)
+ }
+ untagged.WriteString(t.ImageID(item.Untagged).ShortID())
+ }
+ }
+ fields := log.Fields{`deleted`: deleted.String(), `untagged`: untagged.String()}
+ log.WithFields(fields).Debug("Image removal completed")
+ }
+
return err
}
-func (client dockerClient) ExecuteCommand(containerID string, command string) error {
+func (client dockerClient) ExecuteCommand(containerID t.ContainerID, command string, timeout int) (SkipUpdate bool, err error) {
bg := context.Background()
+ clog := log.WithField("containerID", containerID)
// Create the exec
execConfig := types.ExecConfig{
@@ -311,9 +450,9 @@ func (client dockerClient) ExecuteCommand(containerID string, command string) er
Cmd: []string{"sh", "-c", command},
}
- exec, err := client.api.ContainerExecCreate(bg, containerID, execConfig)
+ exec, err := client.api.ContainerExecCreate(bg, string(containerID), execConfig)
if err != nil {
- return err
+ return false, err
}
response, attachErr := client.api.ContainerExecAttach(bg, exec.ID, types.ExecStartCheck{
@@ -321,48 +460,85 @@ func (client dockerClient) ExecuteCommand(containerID string, command string) er
Detach: false,
})
if attachErr != nil {
- log.Errorf("Failed to extract command exec logs: %v", attachErr)
+ clog.Errorf("Failed to extract command exec logs: %v", attachErr)
}
// Run the exec
execStartCheck := types.ExecStartCheck{Detach: false, Tty: true}
err = client.api.ContainerExecStart(bg, exec.ID, execStartCheck)
if err != nil {
- return err
+ return false, err
}
- var execOutput string
+ var output string
if attachErr == nil {
defer response.Close()
var writer bytes.Buffer
written, err := writer.ReadFrom(response.Reader)
if err != nil {
- log.Error(err)
+ clog.Error(err)
} else if written > 0 {
- execOutput = strings.TrimSpace(writer.String())
+ output = strings.TrimSpace(writer.String())
}
}
// Inspect the exec to get the exit code and print a message if the
// exit code is not success.
- execInspect, err := client.api.ContainerExecInspect(bg, exec.ID)
+ skipUpdate, err := client.waitForExecOrTimeout(bg, exec.ID, output, timeout)
if err != nil {
- return err
+ return true, err
}
- if execInspect.ExitCode > 0 {
- log.Errorf("Command exited with code %v.", execInspect.ExitCode)
- log.Error(execOutput)
+ return skipUpdate, nil
+}
+
+func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, execOutput string, timeout int) (SkipUpdate bool, err error) {
+ const ExTempFail = 75
+ var ctx context.Context
+ var cancel context.CancelFunc
+
+ if timeout > 0 {
+ ctx, cancel = context.WithTimeout(bg, time.Duration(timeout)*time.Minute)
+ defer cancel()
} else {
+ ctx = bg
+ }
+
+ for {
+ execInspect, err := client.api.ContainerExecInspect(ctx, ID)
+
+ //goland:noinspection GoNilness
+ log.WithFields(log.Fields{
+ "exit-code": execInspect.ExitCode,
+ "exec-id": execInspect.ExecID,
+ "running": execInspect.Running,
+ "container-id": execInspect.ContainerID,
+ }).Debug("Awaiting timeout or completion")
+
+ if err != nil {
+ return false, err
+ }
+ if execInspect.Running {
+ time.Sleep(1 * time.Second)
+ continue
+ }
if len(execOutput) > 0 {
log.Infof("Command output:\n%v", execOutput)
}
- }
- return nil
+ if execInspect.ExitCode == ExTempFail {
+ return true, nil
+ }
+
+ if execInspect.ExitCode > 0 {
+ 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)
@@ -371,13 +547,12 @@ func (client dockerClient) waitForStopOrTimeout(c Container, waitTime time.Durat
case <-timeout:
return nil
default:
- if ci, err := client.api.ContainerInspect(bg, c.ID()); err != nil {
+ if ci, err := client.api.ContainerInspect(bg, string(c.ID())); err != nil {
return err
} else if !ci.State.Running {
return nil
}
}
-
time.Sleep(1 * time.Second)
}
}
diff --git a/pkg/container/client_test.go b/pkg/container/client_test.go
new file mode 100644
index 0000000..4e75409
--- /dev/null
+++ b/pkg/container/client_test.go
@@ -0,0 +1,377 @@
+package container
+
+import (
+ "github.com/docker/docker/api/types/network"
+ "time"
+
+ "github.com/containrrr/watchtower/internal/util"
+ "github.com/containrrr/watchtower/pkg/container/mocks"
+ "github.com/containrrr/watchtower/pkg/filters"
+ t "github.com/containrrr/watchtower/pkg/types"
+
+ "github.com/docker/docker/api/types"
+ "github.com/docker/docker/api/types/backend"
+ cli "github.com/docker/docker/client"
+ "github.com/docker/docker/errdefs"
+ "github.com/onsi/gomega/gbytes"
+ "github.com/onsi/gomega/ghttp"
+ "github.com/sirupsen/logrus"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+ gt "github.com/onsi/gomega/types"
+
+ "context"
+ "net/http"
+)
+
+var _ = Describe("the client", func() {
+ var docker *cli.Client
+ var mockServer *ghttp.Server
+ BeforeEach(func() {
+ mockServer = ghttp.NewServer()
+ docker, _ = cli.NewClientWithOpts(
+ cli.WithHost(mockServer.URL()),
+ cli.WithHTTPClient(mockServer.HTTPTestServer.Client()))
+ })
+ AfterEach(func() {
+ mockServer.Close()
+ })
+ Describe("WarnOnHeadPullFailed", func() {
+ containerUnknown := MockContainer(WithImageName("unknown.repo/prefix/imagename:latest"))
+ containerKnown := MockContainer(WithImageName("docker.io/prefix/imagename:latest"))
+
+ When(`warn on head failure is set to "always"`, func() {
+ c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnAlways}}
+ It("should always return true", func() {
+ Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeTrue())
+ Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
+ })
+ })
+ When(`warn on head failure is set to "auto"`, func() {
+ c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnAuto}}
+ It("should return false for unknown repos", func() {
+ Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
+ })
+ It("should return true for known repos", func() {
+ Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
+ })
+ })
+ When(`warn on head failure is set to "never"`, func() {
+ c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnNever}}
+ It("should never return true", func() {
+ Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
+ Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeFalse())
+ })
+ })
+ })
+ When("pulling the latest image", func() {
+ When("the image consist of a pinned hash", func() {
+ It("should gracefully fail with a useful message", func() {
+ c := dockerClient{}
+ pinnedContainer := MockContainer(WithImageName("sha256:fa5269854a5e615e51a72b17ad3fd1e01268f278a6684c8ed3c5f0cdce3f230b"))
+ err := c.PullImage(context.Background(), pinnedContainer)
+ Expect(err).To(MatchError(`container uses a pinned image, and cannot be updated by watchtower`))
+ })
+ })
+ })
+ When("removing a running container", func() {
+ When("the container still exist after stopping", func() {
+ It("should attempt to remove the container", func() {
+ container := MockContainer(WithContainerState(types.ContainerState{Running: true}))
+ containerStopped := MockContainer(WithContainerState(types.ContainerState{Running: false}))
+
+ cid := container.ContainerInfo().ID
+ mockServer.AppendHandlers(
+ mocks.KillContainerHandler(cid, mocks.Found),
+ mocks.GetContainerHandler(cid, containerStopped.ContainerInfo()),
+ mocks.RemoveContainerHandler(cid, mocks.Found),
+ mocks.GetContainerHandler(cid, nil),
+ )
+
+ Expect(dockerClient{api: docker}.StopContainer(container, time.Minute)).To(Succeed())
+ })
+ })
+ When("the container does not exist after stopping", func() {
+ It("should not cause an error", func() {
+ container := MockContainer(WithContainerState(types.ContainerState{Running: true}))
+
+ cid := container.ContainerInfo().ID
+ mockServer.AppendHandlers(
+ mocks.KillContainerHandler(cid, mocks.Found),
+ mocks.GetContainerHandler(cid, nil),
+ mocks.RemoveContainerHandler(cid, mocks.Missing),
+ )
+
+ Expect(dockerClient{api: docker}.StopContainer(container, time.Minute)).To(Succeed())
+ })
+ })
+ })
+ When("removing a image", func() {
+ When("debug logging is enabled", func() {
+ It("should log removed and untagged images", func() {
+ imageA := util.GenerateRandomSHA256()
+ imageAParent := util.GenerateRandomSHA256()
+ images := map[string][]string{imageA: {imageAParent}}
+ mockServer.AppendHandlers(mocks.RemoveImageHandler(images))
+ c := dockerClient{api: docker}
+
+ resetLogrus, logbuf := captureLogrus(logrus.DebugLevel)
+ defer resetLogrus()
+
+ Expect(c.RemoveImageByID(t.ImageID(imageA))).To(Succeed())
+
+ shortA := t.ImageID(imageA).ShortID()
+ shortAParent := t.ImageID(imageAParent).ShortID()
+
+ Eventually(logbuf).Should(gbytes.Say(`deleted="%v, %v" untagged="?%v"?`, shortA, shortAParent, shortA))
+ })
+ })
+ When("image is not found", func() {
+ It("should return an error", func() {
+ image := util.GenerateRandomSHA256()
+ mockServer.AppendHandlers(mocks.RemoveImageHandler(nil))
+ c := dockerClient{api: docker}
+
+ err := c.RemoveImageByID(t.ImageID(image))
+ Expect(errdefs.IsNotFound(err)).To(BeTrue())
+ })
+ })
+ })
+ When("listing containers", func() {
+ When("no filter is provided", func() {
+ It("should return all available containers", func() {
+ mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{},
+ }
+ containers, err := client.ListContainers(filters.NoFilter)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(containers).To(HaveLen(2))
+ })
+ })
+ When("a filter matching nothing", func() {
+ It("should return an empty array", func() {
+ mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
+ filter := filters.FilterByNames([]string{"lollercoaster"}, filters.NoFilter)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{},
+ }
+ containers, err := client.ListContainers(filter)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(containers).To(BeEmpty())
+ })
+ })
+ When("a watchtower filter is provided", func() {
+ It("should return only the watchtower container", func() {
+ mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{},
+ }
+ containers, err := client.ListContainers(filters.WatchtowerContainersFilter)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(containers).To(ConsistOf(withContainerImageName(Equal("containrrr/watchtower:latest"))))
+ })
+ })
+ When(`include stopped is enabled`, func() {
+ It("should return both stopped and running containers", func() {
+ mockServer.AppendHandlers(mocks.ListContainersHandler("running", "exited", "created"))
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Stopped, &mocks.Watchtower, &mocks.Running)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{IncludeStopped: true},
+ }
+ containers, err := client.ListContainers(filters.NoFilter)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(containers).To(ContainElement(havingRunningState(false)))
+ })
+ })
+ When(`include restarting is enabled`, func() {
+ It("should return both restarting and running containers", func() {
+ mockServer.AppendHandlers(mocks.ListContainersHandler("running", "restarting"))
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running, &mocks.Restarting)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{IncludeRestarting: true},
+ }
+ containers, err := client.ListContainers(filters.NoFilter)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(containers).To(ContainElement(havingRestartingState(true)))
+ })
+ })
+ When(`include restarting is disabled`, func() {
+ It("should not return restarting containers", func() {
+ mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{IncludeRestarting: false},
+ }
+ containers, err := client.ListContainers(filters.NoFilter)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(containers).NotTo(ContainElement(havingRestartingState(true)))
+ })
+ })
+ When(`a container uses container network mode`, func() {
+ When(`the network container can be resolved`, func() {
+ It("should return the container name instead of the ID", func() {
+ consumerContainerRef := mocks.NetConsumerOK
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&consumerContainerRef)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{},
+ }
+ container, err := client.GetContainer(consumerContainerRef.ContainerID())
+ Expect(err).NotTo(HaveOccurred())
+ networkMode := container.ContainerInfo().HostConfig.NetworkMode
+ Expect(networkMode.ConnectedContainer()).To(Equal(mocks.NetSupplierContainerName))
+ })
+ })
+ When(`the network container cannot be resolved`, func() {
+ It("should still return the container ID", func() {
+ consumerContainerRef := mocks.NetConsumerInvalidSupplier
+ mockServer.AppendHandlers(mocks.GetContainerHandlers(&consumerContainerRef)...)
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{},
+ }
+ container, err := client.GetContainer(consumerContainerRef.ContainerID())
+ Expect(err).NotTo(HaveOccurred())
+ networkMode := container.ContainerInfo().HostConfig.NetworkMode
+ Expect(networkMode.ConnectedContainer()).To(Equal(mocks.NetSupplierNotFoundID))
+ })
+ })
+ })
+ })
+ Describe(`ExecuteCommand`, func() {
+ When(`logging`, func() {
+ It("should include container id field", func() {
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{},
+ }
+
+ // Capture logrus output in buffer
+ resetLogrus, logbuf := captureLogrus(logrus.DebugLevel)
+ defer resetLogrus()
+
+ user := ""
+ containerID := t.ContainerID("ex-cont-id")
+ execID := "ex-exec-id"
+ cmd := "exec-cmd"
+
+ mockServer.AppendHandlers(
+ // API.ContainerExecCreate
+ ghttp.CombineHandlers(
+ ghttp.VerifyRequest("POST", HaveSuffix("containers/%v/exec", containerID)),
+ ghttp.VerifyJSONRepresenting(types.ExecConfig{
+ User: user,
+ Detach: false,
+ Tty: true,
+ Cmd: []string{
+ "sh",
+ "-c",
+ cmd,
+ },
+ }),
+ ghttp.RespondWithJSONEncoded(http.StatusOK, types.IDResponse{ID: execID}),
+ ),
+ // API.ContainerExecStart
+ ghttp.CombineHandlers(
+ ghttp.VerifyRequest("POST", HaveSuffix("exec/%v/start", execID)),
+ ghttp.VerifyJSONRepresenting(types.ExecStartCheck{
+ Detach: false,
+ Tty: true,
+ }),
+ ghttp.RespondWith(http.StatusOK, nil),
+ ),
+ // API.ContainerExecInspect
+ ghttp.CombineHandlers(
+ ghttp.VerifyRequest("GET", HaveSuffix("exec/ex-exec-id/json")),
+ ghttp.RespondWithJSONEncoded(http.StatusOK, backend.ExecInspect{
+ ID: execID,
+ Running: false,
+ ExitCode: nil,
+ ProcessConfig: &backend.ExecProcessConfig{
+ Entrypoint: "sh",
+ Arguments: []string{"-c", cmd},
+ User: user,
+ },
+ ContainerID: string(containerID),
+ }),
+ ),
+ )
+
+ _, err := client.ExecuteCommand(containerID, cmd, 1)
+ Expect(err).NotTo(HaveOccurred())
+ // Note: Since Execute requires opening up a raw TCP stream to the daemon for the output, this will fail
+ // when using the mock API server. Regardless of the outcome, the log should include the container ID
+ Eventually(logbuf).Should(gbytes.Say(`containerID="?ex-cont-id"?`))
+ })
+ })
+ })
+ Describe(`GetNetworkConfig`, func() {
+ When(`providing a container with network aliases`, func() {
+ It(`should omit the container ID alias`, func() {
+ client := dockerClient{
+ api: docker,
+ ClientOptions: ClientOptions{IncludeRestarting: false},
+ }
+ container := MockContainer(WithImageName("docker.io/prefix/imagename:latest"))
+
+ aliases := []string{"One", "Two", container.ID().ShortID(), "Four"}
+ endpoints := map[string]*network.EndpointSettings{
+ `test`: {Aliases: aliases},
+ }
+ container.containerInfo.NetworkSettings = &types.NetworkSettings{Networks: endpoints}
+ Expect(container.ContainerInfo().NetworkSettings.Networks[`test`].Aliases).To(Equal(aliases))
+ Expect(client.GetNetworkConfig(container).EndpointsConfig[`test`].Aliases).To(Equal([]string{"One", "Two", "Four"}))
+ })
+ })
+ })
+})
+
+// Capture logrus output in buffer
+func captureLogrus(level logrus.Level) (func(), *gbytes.Buffer) {
+
+ logbuf := gbytes.NewBuffer()
+
+ origOut := logrus.StandardLogger().Out
+ logrus.SetOutput(logbuf)
+
+ origLev := logrus.StandardLogger().Level
+ logrus.SetLevel(level)
+
+ return func() {
+ logrus.SetOutput(origOut)
+ logrus.SetLevel(origLev)
+ }, logbuf
+}
+
+// Gomega matcher helpers
+
+func withContainerImageName(matcher gt.GomegaMatcher) gt.GomegaMatcher {
+ return WithTransform(containerImageName, matcher)
+}
+
+func containerImageName(container t.Container) string {
+ return container.ImageName()
+}
+
+func havingRestartingState(expected bool) gt.GomegaMatcher {
+ return WithTransform(func(container t.Container) bool {
+ return container.ContainerInfo().State.Restarting
+ }, Equal(expected))
+}
+
+func havingRunningState(expected bool) gt.GomegaMatcher {
+ return WithTransform(func(container t.Container) bool {
+ return container.ContainerInfo().State.Running
+ }, Equal(expected))
+}
diff --git a/pkg/container/container.go b/pkg/container/container.go
index f88ff91..10ed677 100644
--- a/pkg/container/container.go
+++ b/pkg/container/container.go
@@ -1,13 +1,19 @@
+// Package container contains code related to dealing with docker containers
package container
import (
+ "errors"
"fmt"
- "github.com/containrrr/watchtower/internal/util"
"strconv"
"strings"
+ "github.com/containrrr/watchtower/internal/util"
+ wt "github.com/containrrr/watchtower/pkg/types"
+ "github.com/sirupsen/logrus"
+
"github.com/docker/docker/api/types"
dockercontainer "github.com/docker/docker/api/types/container"
+ "github.com/docker/go-connections/nat"
)
// NewContainer returns a new Container instance instantiated with the
@@ -21,21 +27,41 @@ func NewContainer(containerInfo *types.ContainerJSON, imageInfo *types.ImageInsp
// Container represents a running Docker container.
type Container struct {
- Linked bool
- Stale bool
+ LinkedToRestarting bool
+ Stale bool
containerInfo *types.ContainerJSON
imageInfo *types.ImageInspect
}
+// IsLinkedToRestarting returns the current value of the LinkedToRestarting field for the container
+func (c *Container) IsLinkedToRestarting() bool {
+ return c.LinkedToRestarting
+}
+
+// IsStale returns the current value of the Stale field for the container
+func (c *Container) IsStale() bool {
+ return c.Stale
+}
+
+// SetLinkedToRestarting sets the LinkedToRestarting field for the container
+func (c *Container) SetLinkedToRestarting(value bool) {
+ c.LinkedToRestarting = value
+}
+
+// SetStale implements sets the Stale field for the container
+func (c *Container) SetStale(value bool) {
+ c.Stale = value
+}
+
// ContainerInfo fetches JSON info for the container
func (c Container) ContainerInfo() *types.ContainerJSON {
return c.containerInfo
}
// ID returns the Docker container ID.
-func (c Container) ID() string {
- return c.containerInfo.ID
+func (c Container) ID() wt.ContainerID {
+ return wt.ContainerID(c.containerInfo.ID)
}
// IsRunning returns a boolean flag indicating whether or not the current
@@ -45,15 +71,31 @@ func (c Container) IsRunning() bool {
return c.containerInfo.State.Running
}
+// IsRestarting returns a boolean flag indicating whether or not the current
+// container is restarting. The status is determined by the value of the
+// container's "State.Restarting" property.
+func (c Container) IsRestarting() bool {
+ return c.containerInfo.State.Restarting
+}
+
// Name returns the Docker container name.
func (c Container) Name() string {
return c.containerInfo.Name
}
// ImageID returns the ID of the Docker image that was used to start the
-// container.
-func (c Container) ImageID() string {
- return c.imageInfo.ID
+// container. May cause nil dereference if imageInfo is not set!
+func (c Container) ImageID() wt.ImageID {
+ return wt.ImageID(c.imageInfo.ID)
+}
+
+// SafeImageID returns the ID of the Docker image that was used to start the container if available,
+// otherwise returns an empty string
+func (c Container) SafeImageID() wt.ImageID {
+ if c.imageInfo == nil {
+ return ""
+ }
+ return wt.ImageID(c.imageInfo.ID)
}
// ImageName returns the name of the Docker image that was used to start the
@@ -89,16 +131,75 @@ func (c Container) Enabled() (bool, bool) {
return parsedBool, true
}
+// 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)
+}
+
+// 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)
+}
+
+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
+// was set.
+func (c Container) Scope() (string, bool) {
+ rawString, ok := c.getLabelValue(scope)
+ if !ok {
+ return "", false
+ }
+
+ return rawString, true
+}
+
// Links returns a list containing the names of all the containers to which
// this container is linked.
func (c Container) Links() []string {
var links []string
+ dependsOnLabelValue := c.getLabelValueOrEmpty(dependsOnLabel)
+
+ if 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
+ }
+
if (c.containerInfo != nil) && (c.containerInfo.HostConfig != nil) {
for _, link := range c.containerInfo.HostConfig.Links {
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
@@ -107,7 +208,7 @@ func (c Container) Links() []string {
// ToRestart return whether the container should be restarted, either because
// is stale or linked to another stale container.
func (c Container) ToRestart() bool {
- return c.Stale || c.Linked
+ return c.Stale || c.LinkedToRestarting
}
// IsWatchtower returns a boolean flag indicating whether or not the current
@@ -118,6 +219,44 @@ func (c Container) IsWatchtower() bool {
return ContainsWatchtowerLabel(c.containerInfo.Config.Labels)
}
+// PreUpdateTimeout checks whether a container has a specific timeout set
+// for how long the pre-update command is allowed to run. This value is expressed
+// either as an integer, in minutes, or as 0 which will allow the command/script
+// to run indefinitely. Users should be cautious with the 0 option, as that
+// could result in watchtower waiting forever.
+func (c Container) PreUpdateTimeout() int {
+ var minutes int
+ var err error
+
+ val := c.getLabelValueOrEmpty(preUpdateTimeoutLabel)
+
+ minutes, err = strconv.Atoi(val)
+ if err != nil || val == "" {
+ return 1
+ }
+
+ return minutes
+}
+
+// PostUpdateTimeout checks whether a container has a specific timeout set
+// for how long the post-update command is allowed to run. This value is expressed
+// either as an integer, in minutes, or as 0 which will allow the command/script
+// to run indefinitely. Users should be cautious with the 0 option, as that
+// could result in watchtower waiting forever.
+func (c Container) PostUpdateTimeout() int {
+ var minutes int
+ var err error
+
+ val := c.getLabelValueOrEmpty(postUpdateTimeoutLabel)
+
+ minutes, err = strconv.Atoi(val)
+ if err != nil || val == "" {
+ return 1
+ }
+
+ return minutes
+}
+
// StopSignal returns the custom stop signal (if any) that is encoded in the
// container's metadata. If the container has not specified a custom stop
// signal, the empty string "" is returned.
@@ -125,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
@@ -160,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)
@@ -180,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 {
@@ -194,3 +361,44 @@ func (c Container) hostConfig() *dockercontainer.HostConfig {
return hostConfig
}
+
+// HasImageInfo returns whether image information could be retrieved for the container
+func (c Container) HasImageInfo() bool {
+ return c.imageInfo != nil
+}
+
+// ImageInfo fetches the ImageInspect data of the current container
+func (c Container) ImageInfo() *types.ImageInspect {
+ return c.imageInfo
+}
+
+// VerifyConfiguration checks the container and image configurations for nil references to make sure
+// that the container can be recreated once deleted
+func (c Container) VerifyConfiguration() error {
+ if c.imageInfo == nil {
+ return errorNoImageInfo
+ }
+
+ containerInfo := c.ContainerInfo()
+ if containerInfo == nil {
+ return errorNoContainerInfo
+ }
+
+ containerConfig := containerInfo.Config
+ if containerConfig == nil {
+ return errorInvalidConfig
+ }
+
+ hostConfig := containerInfo.HostConfig
+ if hostConfig == nil {
+ return errorInvalidConfig
+ }
+
+ // Instead of returning an error here, we just create an empty map
+ // This should allow for updating containers where the exposed ports are missing
+ if len(hostConfig.PortBindings) > 0 && containerConfig.ExposedPorts == nil {
+ containerConfig.ExposedPorts = make(map[nat.Port]struct{})
+ }
+
+ return nil
+}
diff --git a/pkg/container/container_mock_test.go b/pkg/container/container_mock_test.go
new file mode 100644
index 0000000..8aa1470
--- /dev/null
+++ b/pkg/container/container_mock_test.go
@@ -0,0 +1,79 @@
+package container
+
+import (
+ "github.com/docker/docker/api/types"
+ dockerContainer "github.com/docker/docker/api/types/container"
+ "github.com/docker/go-connections/nat"
+)
+
+type MockContainerUpdate func(*types.ContainerJSON, *types.ImageInspect)
+
+func MockContainer(updates ...MockContainerUpdate) *Container {
+ containerInfo := types.ContainerJSON{
+ ContainerJSONBase: &types.ContainerJSONBase{
+ ID: "container_id",
+ Image: "image",
+ Name: "test-containrrr",
+ HostConfig: &dockerContainer.HostConfig{},
+ },
+ Config: &dockerContainer.Config{
+ Labels: map[string]string{},
+ },
+ }
+ image := types.ImageInspect{
+ ID: "image_id",
+ Config: &dockerContainer.Config{},
+ }
+
+ for _, update := range updates {
+ update(&containerInfo, &image)
+ }
+ return NewContainer(&containerInfo, &image)
+}
+
+func WithPortBindings(portBindingSources ...string) MockContainerUpdate {
+ return func(c *types.ContainerJSON, i *types.ImageInspect) {
+ portBindings := nat.PortMap{}
+ for _, pbs := range portBindingSources {
+ portBindings[nat.Port(pbs)] = []nat.PortBinding{}
+ }
+ c.HostConfig.PortBindings = portBindings
+ }
+}
+
+func WithImageName(name string) MockContainerUpdate {
+ return func(c *types.ContainerJSON, i *types.ImageInspect) {
+ c.Config.Image = name
+ i.RepoTags = append(i.RepoTags, name)
+ }
+}
+
+func WithLinks(links []string) MockContainerUpdate {
+ return func(c *types.ContainerJSON, i *types.ImageInspect) {
+ c.HostConfig.Links = links
+ }
+}
+
+func WithLabels(labels map[string]string) MockContainerUpdate {
+ return func(c *types.ContainerJSON, i *types.ImageInspect) {
+ c.Config.Labels = labels
+ }
+}
+
+func WithContainerState(state types.ContainerState) MockContainerUpdate {
+ return func(cnt *types.ContainerJSON, img *types.ImageInspect) {
+ cnt.State = &state
+ }
+}
+
+func WithHealthcheck(healthConfig dockerContainer.HealthConfig) MockContainerUpdate {
+ return func(cnt *types.ContainerJSON, img *types.ImageInspect) {
+ cnt.Config.Healthcheck = &healthConfig
+ }
+}
+
+func WithImageHealthcheck(healthConfig dockerContainer.HealthConfig) MockContainerUpdate {
+ return func(cnt *types.ContainerJSON, img *types.ImageInspect) {
+ img.Config.Healthcheck = &healthConfig
+ }
+}
diff --git a/pkg/container/container_suite_test.go b/pkg/container/container_suite_test.go
new file mode 100644
index 0000000..292a008
--- /dev/null
+++ b/pkg/container/container_suite_test.go
@@ -0,0 +1,13 @@
+package container_test
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+func TestContainer(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Container Suite")
+}
diff --git a/pkg/container/container_test.go b/pkg/container/container_test.go
index fe838f7..a129afe 100644
--- a/pkg/container/container_test.go
+++ b/pkg/container/container_test.go
@@ -1,81 +1,167 @@
package container
import (
- "github.com/containrrr/watchtower/pkg/container/mocks"
- "github.com/containrrr/watchtower/pkg/filters"
- "github.com/docker/docker/api/types"
- "github.com/docker/docker/api/types/container"
- cli "github.com/docker/docker/client"
+ "github.com/containrrr/watchtower/pkg/types"
+ dc "github.com/docker/docker/api/types/container"
+ "github.com/docker/go-connections/nat"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
- "testing"
)
-func TestContainer(t *testing.T) {
- RegisterFailHandler(Fail)
- RunSpecs(t, "Container Suite")
-}
-
var _ = Describe("the container", func() {
- Describe("the client", func() {
- var docker *cli.Client
- var client Client
- BeforeSuite(func() {
- server := mocks.NewMockAPIServer()
- docker, _ = cli.NewClientWithOpts(
- cli.WithHost(server.URL),
- cli.WithHTTPClient(server.Client()))
- client = dockerClient{
- api: docker,
- pullImages: false,
- }
- })
- It("should return a client for the api", func() {
- Expect(client).NotTo(BeNil())
- })
- When("listing containers without any filter", func() {
- It("should return all available containers", func() {
- containers, err := client.ListContainers(filters.NoFilter)
- Expect(err).NotTo(HaveOccurred())
- Expect(len(containers) == 2).To(BeTrue())
+ Describe("VerifyConfiguration", func() {
+ When("verifying a container with no image info", func() {
+ It("should return an error", func() {
+ c := MockContainer(WithPortBindings())
+ c.imageInfo = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorNoImageInfo))
})
})
- When("listing containers with a filter matching nothing", func() {
- It("should return an empty array", func() {
- filter := filters.FilterByNames([]string{"lollercoaster"}, filters.NoFilter)
- containers, err := client.ListContainers(filter)
- Expect(err).NotTo(HaveOccurred())
- Expect(len(containers) == 0).To(BeTrue())
+ When("verifying a container with no container info", func() {
+ It("should return an error", func() {
+ c := MockContainer(WithPortBindings())
+ c.containerInfo = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorNoContainerInfo))
})
})
- When("listing containers with a watchtower filter", func() {
- It("should return only the watchtower container", func() {
- containers, err := client.ListContainers(filters.WatchtowerContainersFilter)
- Expect(err).NotTo(HaveOccurred())
- Expect(len(containers) == 1).To(BeTrue())
- Expect(containers[0].ImageName()).To(Equal("containrrr/watchtower:latest"))
+ When("verifying a container with no config", func() {
+ It("should return an error", func() {
+ c := MockContainer(WithPortBindings())
+ c.containerInfo.Config = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorInvalidConfig))
})
})
- When(`listing containers with the "include stopped" option`, func() {
- It("should return both stopped and running containers", func() {
- client = dockerClient{
- api: docker,
- pullImages: false,
- includeStopped: true,
- }
- containers, err := client.ListContainers(filters.NoFilter)
- Expect(err).NotTo(HaveOccurred())
- Expect(len(containers) > 0).To(BeTrue())
+ When("verifying a container with no host config", func() {
+ It("should return an error", func() {
+ c := MockContainer(WithPortBindings())
+ c.containerInfo.HostConfig = nil
+ err := c.VerifyConfiguration()
+ Expect(err).To(Equal(errorInvalidConfig))
+ })
+ })
+ When("verifying a container with no port bindings", func() {
+ It("should not return an error", func() {
+ c := MockContainer(WithPortBindings())
+ err := c.VerifyConfiguration()
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+ When("verifying a container with port bindings, but no exposed ports", func() {
+ It("should make the config compatible with updating", func() {
+ c := MockContainer(WithPortBindings("80/tcp"))
+ c.containerInfo.Config.ExposedPorts = nil
+ Expect(c.VerifyConfiguration()).To(Succeed())
+
+ Expect(c.containerInfo.Config.ExposedPorts).ToNot(BeNil())
+ Expect(c.containerInfo.Config.ExposedPorts).To(BeEmpty())
+ })
+ })
+ When("verifying a container with port bindings and exposed ports is non-nil", func() {
+ It("should return an error", func() {
+ c := MockContainer(WithPortBindings("80/tcp"))
+ c.containerInfo.Config.ExposedPorts = map[nat.Port]struct{}{"80/tcp": {}}
+ err := c.VerifyConfiguration()
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+ })
+ Describe("GetCreateConfig", func() {
+ When("container healthcheck config is equal to image config", func() {
+ It("should return empty healthcheck values", func() {
+ c := MockContainer(WithHealthcheck(dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "1s"},
+ }), WithImageHealthcheck(dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "1s"},
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{}))
+
+ c = MockContainer(WithHealthcheck(dc.HealthConfig{
+ Timeout: 30,
+ }), WithImageHealthcheck(dc.HealthConfig{
+ Timeout: 30,
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{}))
+
+ c = MockContainer(WithHealthcheck(dc.HealthConfig{
+ StartPeriod: 30,
+ }), WithImageHealthcheck(dc.HealthConfig{
+ StartPeriod: 30,
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{}))
+
+ c = MockContainer(WithHealthcheck(dc.HealthConfig{
+ Retries: 30,
+ }), WithImageHealthcheck(dc.HealthConfig{
+ Retries: 30,
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{}))
+ })
+ })
+ When("container healthcheck config is different to image config", func() {
+ It("should return the container healthcheck values", func() {
+ c := MockContainer(WithHealthcheck(dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "1s"},
+ Interval: 30,
+ Timeout: 30,
+ StartPeriod: 10,
+ Retries: 2,
+ }), WithImageHealthcheck(dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "10s"},
+ Interval: 10,
+ Timeout: 60,
+ StartPeriod: 30,
+ Retries: 10,
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "1s"},
+ Interval: 30,
+ Timeout: 30,
+ StartPeriod: 10,
+ Retries: 2,
+ }))
+ })
+ })
+ When("container healthcheck config is empty", func() {
+ It("should not panic", func() {
+ c := MockContainer(WithImageHealthcheck(dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "10s"},
+ Interval: 10,
+ Timeout: 60,
+ StartPeriod: 30,
+ Retries: 10,
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(BeNil())
+ })
+ })
+ When("container image healthcheck config is empty", func() {
+ It("should not panic", func() {
+ c := MockContainer(WithHealthcheck(dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "1s"},
+ Interval: 30,
+ Timeout: 30,
+ StartPeriod: 10,
+ Retries: 2,
+ }))
+ Expect(c.GetCreateConfig().Healthcheck).To(Equal(&dc.HealthConfig{
+ Test: []string{"/usr/bin/sleep", "1s"},
+ Interval: 30,
+ Timeout: 30,
+ StartPeriod: 10,
+ Retries: 2,
+ }))
})
})
})
When("asked for metadata", func() {
var c *Container
BeforeEach(func() {
- c = mockContainerWithLabels(map[string]string{
+ c = MockContainer(WithLabels(map[string]string{
"com.centurylinklabs.watchtower.enable": "true",
"com.centurylinklabs.watchtower": "true",
- })
+ }))
})
It("should return its name on calls to .Name()", func() {
name := c.Name()
@@ -85,43 +171,35 @@ var _ = Describe("the container", func() {
It("should return its ID on calls to .ID()", func() {
id := c.ID()
- Expect(id).To(Equal("container_id"))
- Expect(id).NotTo(Equal("wrong-id"))
+ Expect(id).To(BeEquivalentTo("container_id"))
+ Expect(id).NotTo(BeEquivalentTo("wrong-id"))
})
It("should return true, true if enabled on calls to .Enabled()", func() {
enabled, exists := c.Enabled()
Expect(enabled).To(BeTrue())
- Expect(enabled).NotTo(BeFalse())
Expect(exists).To(BeTrue())
- Expect(exists).NotTo(BeFalse())
})
It("should return false, true if present but not true on calls to .Enabled()", func() {
- c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "false"})
+ c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "false"}))
enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse())
- Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeTrue())
- Expect(exists).NotTo(BeFalse())
})
It("should return false, false if not present on calls to .Enabled()", func() {
- c = mockContainerWithLabels(map[string]string{"lol": "false"})
+ c = MockContainer(WithLabels(map[string]string{"lol": "false"}))
enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse())
- Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeFalse())
- Expect(exists).NotTo(BeTrue())
})
It("should return false, false if present but not parsable .Enabled()", func() {
- c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "falsy"})
+ c = MockContainer(WithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "falsy"}))
enabled, exists := c.Enabled()
Expect(enabled).To(BeFalse())
- Expect(enabled).NotTo(BeTrue())
Expect(exists).To(BeFalse())
- Expect(exists).NotTo(BeTrue())
})
When("checking if its a watchtower instance", func() {
It("should return true if the label is set to true", func() {
@@ -129,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(""))
})
@@ -161,45 +239,153 @@ 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"))
})
})
+
+ When("fetching container links", func() {
+ When("the depends on label is present", func() {
+ It("should fetch depending containers from it", func() {
+ c = MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.depends-on": "postgres",
+ }))
+ links := c.Links()
+ Expect(links).To(SatisfyAll(ContainElement("/postgres"), HaveLen(1)))
+ })
+ It("should fetch depending containers if there are many", func() {
+ c = MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.depends-on": "postgres,redis",
+ }))
+ links := c.Links()
+ Expect(links).To(SatisfyAll(ContainElement("/postgres"), ContainElement("/redis"), HaveLen(2)))
+ })
+ It("should 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 = 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 = MockContainer(WithLinks([]string{
+ "redis:test-containrrr",
+ "postgres:test-containrrr",
+ }))
+ links := c.Links()
+ Expect(links).To(SatisfyAll(ContainElement("redis"), ContainElement("postgres"), HaveLen(2)))
+ })
+ })
+ })
+
+ When("checking no-pull label", func() {
+ When("no-pull argument is not set", func() {
+ When("no-pull label is true", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "true",
+ }))
+ It("should return true", func() {
+ Expect(c.IsNoPull(types.UpdateParams{})).To(Equal(true))
+ })
+ })
+ When("no-pull label is false", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "false",
+ }))
+ It("should return false", func() {
+ Expect(c.IsNoPull(types.UpdateParams{})).To(Equal(false))
+ })
+ })
+ When("no-pull label is set to an invalid value", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "maybe",
+ }))
+ It("should return false", func() {
+ Expect(c.IsNoPull(types.UpdateParams{})).To(Equal(false))
+ })
+ })
+ When("no-pull label is unset", func() {
+ c = MockContainer(WithLabels(map[string]string{}))
+ It("should return false", func() {
+ Expect(c.IsNoPull(types.UpdateParams{})).To(Equal(false))
+ })
+ })
+ })
+ When("no-pull argument is set to true", func() {
+ When("no-pull label is true", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "true",
+ }))
+ It("should return true", func() {
+ Expect(c.IsNoPull(types.UpdateParams{NoPull: true})).To(Equal(true))
+ })
+ })
+ When("no-pull label is false", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "false",
+ }))
+ It("should return true", func() {
+ Expect(c.IsNoPull(types.UpdateParams{NoPull: true})).To(Equal(true))
+ })
+ })
+ When("label-take-precedence argument is set to true", func() {
+ When("no-pull label is true", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "true",
+ }))
+ It("should return true", func() {
+ Expect(c.IsNoPull(types.UpdateParams{LabelPrecedence: true, NoPull: true})).To(Equal(true))
+ })
+ })
+ When("no-pull label is false", func() {
+ c := MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.no-pull": "false",
+ }))
+ It("should return false", func() {
+ Expect(c.IsNoPull(types.UpdateParams{LabelPrecedence: true, NoPull: true})).To(Equal(false))
+ })
+ })
+ })
+ })
+ })
+
+ When("there is a pre or post update timeout", func() {
+ It("should return minute values", func() {
+ c = MockContainer(WithLabels(map[string]string{
+ "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "3",
+ "com.centurylinklabs.watchtower.lifecycle.post-update-timeout": "5",
+ }))
+ preTimeout := c.PreUpdateTimeout()
+ Expect(preTimeout).To(Equal(3))
+ postTimeout := c.PostUpdateTimeout()
+ Expect(postTimeout).To(Equal(5))
+ })
+ })
+
})
})
-
-func mockContainerWithImageName(name string) *Container {
- container := mockContainerWithLabels(nil)
- container.containerInfo.Config.Image = name
- return container
-}
-
-func mockContainerWithLabels(labels map[string]string) *Container {
- content := types.ContainerJSON{
- ContainerJSONBase: &types.ContainerJSONBase{
- ID: "container_id",
- Image: "image",
- Name: "test-containrrr",
- },
- Config: &container.Config{
- Labels: labels,
- },
- }
- return NewContainer(&content, nil)
-}
diff --git a/pkg/container/errors.go b/pkg/container/errors.go
new file mode 100644
index 0000000..05dc722
--- /dev/null
+++ b/pkg/container/errors.go
@@ -0,0 +1,8 @@
+package container
+
+import "errors"
+
+var errorNoImageInfo = errors.New("no available image info")
+var errorNoContainerInfo = errors.New("no available container info")
+var errorInvalidConfig = errors.New("container configuration missing or invalid")
+var errorLabelNotFound = errors.New("label was not found in container")
diff --git a/pkg/container/metadata.go b/pkg/container/metadata.go
index 0e04350..8ac5f34 100644
--- a/pkg/container/metadata.go
+++ b/pkg/container/metadata.go
@@ -1,14 +1,22 @@
package container
+import "strconv"
+
const (
- watchtowerLabel = "com.centurylinklabs.watchtower"
- signalLabel = "com.centurylinklabs.watchtower.stop-signal"
- enableLabel = "com.centurylinklabs.watchtower.enable"
- zodiacLabel = "com.centurylinklabs.zodiac.original-image"
- 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"
+ watchtowerLabel = "com.centurylinklabs.watchtower"
+ signalLabel = "com.centurylinklabs.watchtower.stop-signal"
+ enableLabel = "com.centurylinklabs.watchtower.enable"
+ monitorOnlyLabel = "com.centurylinklabs.watchtower.monitor-only"
+ noPullLabel = "com.centurylinklabs.watchtower.no-pull"
+ dependsOnLabel = "com.centurylinklabs.watchtower.depends-on"
+ zodiacLabel = "com.centurylinklabs.zodiac.original-image"
+ scope = "com.centurylinklabs.watchtower.scope"
+ preCheckLabel = "com.centurylinklabs.watchtower.lifecycle.pre-check"
+ postCheckLabel = "com.centurylinklabs.watchtower.lifecycle.post-check"
+ preUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.pre-update"
+ postUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.post-update"
+ preUpdateTimeoutLabel = "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout"
+ postUpdateTimeoutLabel = "com.centurylinklabs.watchtower.lifecycle.post-update-timeout"
)
// GetLifecyclePreCheckCommand returns the pre-check command set in the container metadata or an empty string
@@ -49,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 82e05de..84756f0 100644
--- a/pkg/container/mocks/ApiServer.go
+++ b/pkg/container/mocks/ApiServer.go
@@ -1,54 +1,280 @@
package mocks
import (
+ "encoding/json"
"fmt"
- "github.com/sirupsen/logrus"
- "io/ioutil"
+ "github.com/onsi/ginkgo"
"net/http"
- "net/http/httptest"
+ "net/url"
+ "os"
"path/filepath"
"strings"
+
+ t "github.com/containrrr/watchtower/pkg/types"
+
+ "github.com/docker/docker/api/types"
+ "github.com/docker/docker/api/types/filters"
+ O "github.com/onsi/gomega"
+ "github.com/onsi/gomega/ghttp"
)
-// NewMockAPIServer returns a mocked docker api server that responds to some fixed requests
-// used in the test suite.
-func NewMockAPIServer() *httptest.Server {
- return httptest.NewServer(http.HandlerFunc(
- func(w http.ResponseWriter, r *http.Request) {
- logrus.Debug("Mock server has received a HTTP call on ", r.URL)
- var response = ""
-
- if isRequestFor("filters=%7B%22status%22%3A%7B%22running%22%3Atrue%7D%7D&limit=0", r) {
- response = getMockJSONFromDisk("./mocks/data/containers.json")
- } else if isRequestFor("filters=%7B%22status%22%3A%7B%22created%22%3Atrue%2C%22exited%22%3Atrue%2C%22running%22%3Atrue%7D%7D&limit=0", r) {
- response = getMockJSONFromDisk("./mocks/data/containers.json")
- } else if isRequestFor("containers/json?limit=0", r) {
- response = getMockJSONFromDisk("./mocks/data/containers.json")
- } else if isRequestFor("ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65", r) {
- response = getMockJSONFromDisk("./mocks/data/container_stopped.json")
- } else if isRequestFor("b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008", r) {
- response = getMockJSONFromDisk("./mocks/data/container_running.json")
- } else if isRequestFor("sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd", r) {
- response = getMockJSONFromDisk("./mocks/data/image01.json")
- } else if isRequestFor("sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa", r) {
- response = getMockJSONFromDisk("./mocks/data/image02.json")
- }
- fmt.Fprintln(w, response)
- },
- ))
-}
-
-func isRequestFor(urlPart string, r *http.Request) bool {
- return strings.Contains(r.URL.String(), urlPart)
-}
-
-func getMockJSONFromDisk(relPath string) string {
+func getMockJSONFile(relPath string) ([]byte, error) {
absPath, _ := filepath.Abs(relPath)
- logrus.Error(absPath)
- buf, err := ioutil.ReadFile(absPath)
+ buf, err := os.ReadFile(absPath)
if err != nil {
- logrus.Error(err)
- return ""
+ return nil, fmt.Errorf("mock JSON file %q not found: %e", absPath, err)
}
- return string(buf)
+ return buf, nil
+}
+
+// RespondWithJSONFile handles a request by returning the contents of the supplied file
+func RespondWithJSONFile(relPath string, statusCode int, optionalHeader ...http.Header) http.HandlerFunc {
+ handler, err := respondWithJSONFile(relPath, statusCode, optionalHeader...)
+ O.ExpectWithOffset(1, err).ShouldNot(O.HaveOccurred())
+ return handler
+}
+
+func respondWithJSONFile(relPath string, statusCode int, optionalHeader ...http.Header) (http.HandlerFunc, error) {
+ buf, err := getMockJSONFile(relPath)
+ if err != nil {
+ return nil, err
+ }
+ return ghttp.RespondWith(statusCode, buf, optionalHeader...), nil
+}
+
+// GetContainerHandlers returns the handlers serving lookups for the supplied container mock files
+func GetContainerHandlers(containerRefs ...*ContainerRef) []http.HandlerFunc {
+ handlers := make([]http.HandlerFunc, 0, len(containerRefs)*3)
+ for _, containerRef := range containerRefs {
+ handlers = append(handlers, getContainerFileHandler(containerRef))
+
+ // Also append any containers that the container references, if any
+ for _, ref := range containerRef.references {
+ handlers = append(handlers, getContainerFileHandler(ref))
+ }
+
+ // Also append the image request since that will be called for every container
+ handlers = append(handlers, getImageHandler(containerRef.image.id,
+ RespondWithJSONFile(containerRef.image.getFileName(), http.StatusOK),
+ ))
+ }
+
+ return handlers
+}
+
+func createFilterArgs(statuses []string) filters.Args {
+ args := filters.NewArgs()
+ for _, status := range statuses {
+ args.Add("status", status)
+ }
+ return args
+}
+
+var defaultImage = imageRef{
+ // watchtower
+ id: t.ImageID("sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa"),
+ file: "default",
+}
+
+var Watchtower = ContainerRef{
+ name: "watchtower",
+ id: "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134",
+ image: &defaultImage,
+}
+var Stopped = ContainerRef{
+ name: "stopped",
+ id: "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65",
+ image: &defaultImage,
+}
+var Running = ContainerRef{
+ name: "running",
+ id: "b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008",
+ image: &imageRef{
+ // portainer
+ id: t.ImageID("sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd"),
+ file: "running",
+ },
+}
+var Restarting = ContainerRef{
+ name: "restarting",
+ id: "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67",
+ image: &defaultImage,
+}
+
+var netSupplierOK = ContainerRef{
+ id: "25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2",
+ name: "net_supplier",
+ image: &imageRef{
+ // gluetun
+ id: t.ImageID("sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51"),
+ file: "net_producer",
+ },
+}
+var netSupplierNotFound = ContainerRef{
+ id: NetSupplierNotFoundID,
+ name: netSupplierOK.name,
+ isMissing: true,
+}
+
+// NetConsumerOK is used for testing `container` networking mode
+// returns a container that consumes an existing supplier container
+var NetConsumerOK = ContainerRef{
+ id: "1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6",
+ name: "net_consumer",
+ image: &imageRef{
+ id: t.ImageID("sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8"), // nginx
+ file: "net_consumer",
+ },
+ references: []*ContainerRef{&netSupplierOK},
+}
+
+// NetConsumerInvalidSupplier is used for testing `container` networking mode
+// returns a container that references a supplying container that does not exist
+var NetConsumerInvalidSupplier = ContainerRef{
+ id: NetConsumerOK.id,
+ name: "net_consumer-missing_supplier",
+ image: NetConsumerOK.image,
+ references: []*ContainerRef{&netSupplierNotFound},
+}
+
+const NetSupplierNotFoundID = "badc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc"
+const NetSupplierContainerName = "/wt-contnet-producer-1"
+
+func getContainerFileHandler(cr *ContainerRef) http.HandlerFunc {
+
+ if cr.isMissing {
+ return containerNotFoundResponse(string(cr.id))
+ }
+
+ containerFile, err := cr.getContainerFile()
+ if err != nil {
+ ginkgo.Fail(fmt.Sprintf("Failed to get container mock file: %v", err))
+ }
+
+ return getContainerHandler(
+ string(cr.id),
+ RespondWithJSONFile(containerFile, http.StatusOK),
+ )
+}
+
+func getContainerHandler(containerId string, responseHandler http.HandlerFunc) http.HandlerFunc {
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("GET", O.HaveSuffix("/containers/%v/json", containerId)),
+ responseHandler,
+ )
+}
+
+// GetContainerHandler mocks the GET containers/{id}/json endpoint
+func GetContainerHandler(containerID string, containerInfo *types.ContainerJSON) http.HandlerFunc {
+ responseHandler := containerNotFoundResponse(containerID)
+ if containerInfo != nil {
+ responseHandler = ghttp.RespondWithJSONEncoded(http.StatusOK, containerInfo)
+ }
+ return getContainerHandler(containerID, responseHandler)
+}
+
+// GetImageHandler mocks the GET images/{id}/json endpoint
+func GetImageHandler(imageInfo *types.ImageInspect) http.HandlerFunc {
+ return getImageHandler(t.ImageID(imageInfo.ID), ghttp.RespondWithJSONEncoded(http.StatusOK, imageInfo))
+}
+
+// ListContainersHandler mocks the GET containers/json endpoint, filtering the returned containers based on statuses
+func ListContainersHandler(statuses ...string) http.HandlerFunc {
+ filterArgs := createFilterArgs(statuses)
+ bytes, err := filterArgs.MarshalJSON()
+ O.ExpectWithOffset(1, err).ShouldNot(O.HaveOccurred())
+ query := url.Values{
+ "filters": []string{string(bytes)},
+ }
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("GET", O.HaveSuffix("containers/json"), query.Encode()),
+ respondWithFilteredContainers(filterArgs),
+ )
+}
+
+func respondWithFilteredContainers(filters filters.Args) http.HandlerFunc {
+ containersJSON, err := getMockJSONFile("./mocks/data/containers.json")
+ O.ExpectWithOffset(2, err).ShouldNot(O.HaveOccurred())
+ var filteredContainers []types.Container
+ var containers []types.Container
+ O.ExpectWithOffset(2, json.Unmarshal(containersJSON, &containers)).To(O.Succeed())
+ for _, v := range containers {
+ for _, key := range filters.Get("status") {
+ if v.State == key {
+ filteredContainers = append(filteredContainers, v)
+ }
+ }
+ }
+
+ return ghttp.RespondWithJSONEncoded(http.StatusOK, filteredContainers)
+}
+
+func getImageHandler(imageId t.ImageID, responseHandler http.HandlerFunc) http.HandlerFunc {
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("GET", O.HaveSuffix("/images/%s/json", imageId)),
+ responseHandler,
+ )
+}
+
+// KillContainerHandler mocks the POST containers/{id}/kill endpoint
+func KillContainerHandler(containerID string, found FoundStatus) http.HandlerFunc {
+ responseHandler := noContentStatusResponse
+ if !found {
+ responseHandler = containerNotFoundResponse(containerID)
+ }
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("POST", O.HaveSuffix("containers/%s/kill", containerID)),
+ responseHandler,
+ )
+}
+
+// RemoveContainerHandler mocks the DELETE containers/{id} endpoint
+func RemoveContainerHandler(containerID string, found FoundStatus) http.HandlerFunc {
+ responseHandler := noContentStatusResponse
+ if !found {
+ responseHandler = containerNotFoundResponse(containerID)
+ }
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("DELETE", O.HaveSuffix("containers/%s", containerID)),
+ responseHandler,
+ )
+}
+
+func containerNotFoundResponse(containerID string) http.HandlerFunc {
+ return ghttp.RespondWithJSONEncoded(http.StatusNotFound, struct{ message string }{message: "No such container: " + string(containerID)})
+}
+
+var noContentStatusResponse = ghttp.RespondWith(http.StatusNoContent, nil)
+
+type FoundStatus bool
+
+const (
+ Found FoundStatus = true
+ Missing FoundStatus = false
+)
+
+// RemoveImageHandler mocks the DELETE images/ID endpoint, simulating removal of the given imagesWithParents
+func RemoveImageHandler(imagesWithParents map[string][]string) http.HandlerFunc {
+ return ghttp.CombineHandlers(
+ ghttp.VerifyRequest("DELETE", O.MatchRegexp("/images/.*")),
+ func(w http.ResponseWriter, r *http.Request) {
+ parts := strings.Split(r.URL.Path, `/`)
+ image := parts[len(parts)-1]
+
+ if parents, found := imagesWithParents[image]; found {
+ items := []types.ImageDeleteResponseItem{
+ {Untagged: image},
+ {Deleted: image},
+ }
+ for _, parent := range parents {
+ items = append(items, types.ImageDeleteResponseItem{Deleted: parent})
+ }
+ ghttp.RespondWithJSONEncoded(http.StatusOK, items)(w, r)
+ } else {
+ ghttp.RespondWithJSONEncoded(http.StatusNotFound, struct{ message string }{
+ message: "Something went wrong.",
+ })(w, r)
+ }
+ },
+ )
}
diff --git a/pkg/container/mocks/FilterableContainer.go b/pkg/container/mocks/FilterableContainer.go
index 508bd7c..fa863b5 100644
--- a/pkg/container/mocks/FilterableContainer.go
+++ b/pkg/container/mocks/FilterableContainer.go
@@ -55,3 +55,40 @@ func (_m *FilterableContainer) Name() string {
return r0
}
+
+// Scope provides a mock function with given fields:
+func (_m *FilterableContainer) Scope() (string, bool) {
+ ret := _m.Called()
+
+ var r0 string
+
+ if rf, ok := ret.Get(0).(func() string); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(string)
+ }
+
+ var r1 bool
+
+ if rf, ok := ret.Get(1).(func() bool); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Get(1).(bool)
+ }
+
+ return r0, r1
+}
+
+// ImageName provides a mock function with given fields:
+func (_m *FilterableContainer) ImageName() string {
+ ret := _m.Called()
+
+ var r0 string
+ if rf, ok := ret.Get(0).(func() string); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(string)
+ }
+
+ return r0
+}
diff --git a/pkg/container/mocks/container_ref.go b/pkg/container/mocks/container_ref.go
new file mode 100644
index 0000000..c46eb93
--- /dev/null
+++ b/pkg/container/mocks/container_ref.go
@@ -0,0 +1,42 @@
+package mocks
+
+import (
+ "fmt"
+ "os"
+
+ t "github.com/containrrr/watchtower/pkg/types"
+)
+
+type imageRef struct {
+ id t.ImageID
+ file string
+}
+
+func (ir *imageRef) getFileName() string {
+ return fmt.Sprintf("./mocks/data/image_%v.json", ir.file)
+}
+
+type ContainerRef struct {
+ name string
+ id t.ContainerID
+ image *imageRef
+ file string
+ references []*ContainerRef
+ isMissing bool
+}
+
+func (cr *ContainerRef) getContainerFile() (containerFile string, err error) {
+ file := cr.file
+ if file == "" {
+ file = cr.name
+ }
+
+ containerFile = fmt.Sprintf("./mocks/data/container_%v.json", file)
+ _, err = os.Stat(containerFile)
+
+ return containerFile, err
+}
+
+func (cr *ContainerRef) ContainerID() t.ContainerID {
+ return cr.id
+}
diff --git a/pkg/container/mocks/data/container_net_consumer-missing_supplier.json b/pkg/container/mocks/data/container_net_consumer-missing_supplier.json
new file mode 100644
index 0000000..c1a233b
--- /dev/null
+++ b/pkg/container/mocks/data/container_net_consumer-missing_supplier.json
@@ -0,0 +1,205 @@
+{
+ "Id": "1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6",
+ "Created": "2023-07-25T14:55:14.69155887Z",
+ "Path": "/docker-entrypoint.sh",
+ "Args": [
+ "nginx",
+ "-g",
+ "daemon off;"
+ ],
+ "State": {
+ "Status": "running",
+ "Running": true,
+ "Paused": false,
+ "Restarting": false,
+ "OOMKilled": false,
+ "Dead": false,
+ "Pid": 3743,
+ "ExitCode": 0,
+ "Error": "",
+ "StartedAt": "2023-07-25T14:55:15.299654437Z",
+ "FinishedAt": "0001-01-01T00:00:00Z"
+ },
+ "Image": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8",
+ "ResolvConfPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/resolv.conf",
+ "HostnamePath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hostname",
+ "HostsPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hosts",
+ "LogPath": "/var/lib/docker/containers/1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6/1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6-json.log",
+ "Name": "/wt-contnet-consumer-1",
+ "RestartCount": 0,
+ "Driver": "overlay2",
+ "Platform": "linux",
+ "MountLabel": "",
+ "ProcessLabel": "",
+ "AppArmorProfile": "",
+ "ExecIDs": null,
+ "HostConfig": {
+ "Binds": null,
+ "ContainerIDFile": "",
+ "LogConfig": {
+ "Type": "json-file",
+ "Config": {}
+ },
+ "NetworkMode": "container:badc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc",
+ "PortBindings": {},
+ "RestartPolicy": {
+ "Name": "",
+ "MaximumRetryCount": 0
+ },
+ "AutoRemove": false,
+ "VolumeDriver": "",
+ "VolumesFrom": null,
+ "ConsoleSize": [
+ 0,
+ 0
+ ],
+ "CapAdd": null,
+ "CapDrop": null,
+ "CgroupnsMode": "host",
+ "Dns": null,
+ "DnsOptions": null,
+ "DnsSearch": null,
+ "ExtraHosts": [],
+ "GroupAdd": null,
+ "IpcMode": "private",
+ "Cgroup": "",
+ "Links": null,
+ "OomScoreAdj": 0,
+ "PidMode": "",
+ "Privileged": false,
+ "PublishAllPorts": false,
+ "ReadonlyRootfs": false,
+ "SecurityOpt": null,
+ "UTSMode": "",
+ "UsernsMode": "",
+ "ShmSize": 67108864,
+ "Runtime": "runc",
+ "Isolation": "",
+ "CpuShares": 0,
+ "Memory": 0,
+ "NanoCpus": 0,
+ "CgroupParent": "",
+ "BlkioWeight": 0,
+ "BlkioWeightDevice": null,
+ "BlkioDeviceReadBps": null,
+ "BlkioDeviceWriteBps": null,
+ "BlkioDeviceReadIOps": null,
+ "BlkioDeviceWriteIOps": null,
+ "CpuPeriod": 0,
+ "CpuQuota": 0,
+ "CpuRealtimePeriod": 0,
+ "CpuRealtimeRuntime": 0,
+ "CpusetCpus": "",
+ "CpusetMems": "",
+ "Devices": null,
+ "DeviceCgroupRules": null,
+ "DeviceRequests": null,
+ "MemoryReservation": 0,
+ "MemorySwap": 0,
+ "MemorySwappiness": null,
+ "OomKillDisable": false,
+ "PidsLimit": null,
+ "Ulimits": null,
+ "CpuCount": 0,
+ "CpuPercent": 0,
+ "IOMaximumIOps": 0,
+ "IOMaximumBandwidth": 0,
+ "MaskedPaths": [
+ "/proc/asound",
+ "/proc/acpi",
+ "/proc/kcore",
+ "/proc/keys",
+ "/proc/latency_stats",
+ "/proc/timer_list",
+ "/proc/timer_stats",
+ "/proc/sched_debug",
+ "/proc/scsi",
+ "/sys/firmware"
+ ],
+ "ReadonlyPaths": [
+ "/proc/bus",
+ "/proc/fs",
+ "/proc/irq",
+ "/proc/sys",
+ "/proc/sysrq-trigger"
+ ]
+ },
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2-init/diff:/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/diff:/var/lib/docker/overlay2/09785ba17f27c783ef8b44f369f9aac0ca936000b57abf22b3c54d1e6eb8e27b/diff:/var/lib/docker/overlay2/6f8acd64ae44fd4d14bcb90c105eceba46854aa3985b5b6b317bcc5692cfc286/diff:/var/lib/docker/overlay2/73d41c15edb21c5f12cf53e313f48b5da55283aafc77d35b7bc662241879d7e7/diff:/var/lib/docker/overlay2/d97b55f3d966ae031492369a98e9e00d2bd31e520290fe2034e0a2b1ed77c91e/diff:/var/lib/docker/overlay2/053e9ca65c6b64cb9d98a812ff7488c7e77938b4fb8e0c4d2ad7f8ec235f0f20/diff",
+ "MergedDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/merged",
+ "UpperDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/diff",
+ "WorkDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/work"
+ },
+ "Name": "overlay2"
+ },
+ "Mounts": [],
+ "Config": {
+ "Hostname": "25e75393800b",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": true,
+ "AttachStderr": true,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "NGINX_VERSION=1.23.3",
+ "NJS_VERSION=0.7.9",
+ "PKG_RELEASE=1~bullseye"
+ ],
+ "Cmd": [
+ "nginx",
+ "-g",
+ "daemon off;"
+ ],
+ "Image": "nginx",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/docker-entrypoint.sh"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "com.docker.compose.config-hash": "8bb0e1c8c61f6d495840ba9133ebfb1e4ffda3e1adb701a011b03951848bb9fa",
+ "com.docker.compose.container-number": "1",
+ "com.docker.compose.depends_on": "producer:service_started:false",
+ "com.docker.compose.image": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8",
+ "com.docker.compose.oneoff": "False",
+ "com.docker.compose.project": "wt-contnet",
+ "com.docker.compose.project.config_files": "/tmp/wt-contnet/docker-compose.yaml",
+ "com.docker.compose.project.working_dir": "/tmp/wt-contnet",
+ "com.docker.compose.replace": "07bb70608f96f577aa02b9f317500e23e691c94eb099f6fb52301dfb031d0668",
+ "com.docker.compose.service": "consumer",
+ "com.docker.compose.version": "2.19.1",
+ "desktop.docker.io/wsl-distro": "Ubuntu",
+ "maintainer": "NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e"
+ },
+ "StopSignal": "SIGQUIT"
+ },
+ "NetworkSettings": {
+ "Bridge": "",
+ "SandboxID": "",
+ "HairpinMode": false,
+ "LinkLocalIPv6Address": "",
+ "LinkLocalIPv6PrefixLen": 0,
+ "Ports": {},
+ "SandboxKey": "",
+ "SecondaryIPAddresses": null,
+ "SecondaryIPv6Addresses": null,
+ "EndpointID": "",
+ "Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "MacAddress": "",
+ "Networks": {}
+ }
+}
diff --git a/pkg/container/mocks/data/container_net_consumer.json b/pkg/container/mocks/data/container_net_consumer.json
new file mode 100644
index 0000000..2e64f89
--- /dev/null
+++ b/pkg/container/mocks/data/container_net_consumer.json
@@ -0,0 +1,205 @@
+{
+ "Id": "1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6",
+ "Created": "2023-07-25T14:55:14.69155887Z",
+ "Path": "/docker-entrypoint.sh",
+ "Args": [
+ "nginx",
+ "-g",
+ "daemon off;"
+ ],
+ "State": {
+ "Status": "running",
+ "Running": true,
+ "Paused": false,
+ "Restarting": false,
+ "OOMKilled": false,
+ "Dead": false,
+ "Pid": 3743,
+ "ExitCode": 0,
+ "Error": "",
+ "StartedAt": "2023-07-25T14:55:15.299654437Z",
+ "FinishedAt": "0001-01-01T00:00:00Z"
+ },
+ "Image": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8",
+ "ResolvConfPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/resolv.conf",
+ "HostnamePath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hostname",
+ "HostsPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hosts",
+ "LogPath": "/var/lib/docker/containers/1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6/1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6-json.log",
+ "Name": "/wt-contnet-consumer-1",
+ "RestartCount": 0,
+ "Driver": "overlay2",
+ "Platform": "linux",
+ "MountLabel": "",
+ "ProcessLabel": "",
+ "AppArmorProfile": "",
+ "ExecIDs": null,
+ "HostConfig": {
+ "Binds": null,
+ "ContainerIDFile": "",
+ "LogConfig": {
+ "Type": "json-file",
+ "Config": {}
+ },
+ "NetworkMode": "container:25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2",
+ "PortBindings": {},
+ "RestartPolicy": {
+ "Name": "",
+ "MaximumRetryCount": 0
+ },
+ "AutoRemove": false,
+ "VolumeDriver": "",
+ "VolumesFrom": null,
+ "ConsoleSize": [
+ 0,
+ 0
+ ],
+ "CapAdd": null,
+ "CapDrop": null,
+ "CgroupnsMode": "host",
+ "Dns": null,
+ "DnsOptions": null,
+ "DnsSearch": null,
+ "ExtraHosts": [],
+ "GroupAdd": null,
+ "IpcMode": "private",
+ "Cgroup": "",
+ "Links": null,
+ "OomScoreAdj": 0,
+ "PidMode": "",
+ "Privileged": false,
+ "PublishAllPorts": false,
+ "ReadonlyRootfs": false,
+ "SecurityOpt": null,
+ "UTSMode": "",
+ "UsernsMode": "",
+ "ShmSize": 67108864,
+ "Runtime": "runc",
+ "Isolation": "",
+ "CpuShares": 0,
+ "Memory": 0,
+ "NanoCpus": 0,
+ "CgroupParent": "",
+ "BlkioWeight": 0,
+ "BlkioWeightDevice": null,
+ "BlkioDeviceReadBps": null,
+ "BlkioDeviceWriteBps": null,
+ "BlkioDeviceReadIOps": null,
+ "BlkioDeviceWriteIOps": null,
+ "CpuPeriod": 0,
+ "CpuQuota": 0,
+ "CpuRealtimePeriod": 0,
+ "CpuRealtimeRuntime": 0,
+ "CpusetCpus": "",
+ "CpusetMems": "",
+ "Devices": null,
+ "DeviceCgroupRules": null,
+ "DeviceRequests": null,
+ "MemoryReservation": 0,
+ "MemorySwap": 0,
+ "MemorySwappiness": null,
+ "OomKillDisable": false,
+ "PidsLimit": null,
+ "Ulimits": null,
+ "CpuCount": 0,
+ "CpuPercent": 0,
+ "IOMaximumIOps": 0,
+ "IOMaximumBandwidth": 0,
+ "MaskedPaths": [
+ "/proc/asound",
+ "/proc/acpi",
+ "/proc/kcore",
+ "/proc/keys",
+ "/proc/latency_stats",
+ "/proc/timer_list",
+ "/proc/timer_stats",
+ "/proc/sched_debug",
+ "/proc/scsi",
+ "/sys/firmware"
+ ],
+ "ReadonlyPaths": [
+ "/proc/bus",
+ "/proc/fs",
+ "/proc/irq",
+ "/proc/sys",
+ "/proc/sysrq-trigger"
+ ]
+ },
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2-init/diff:/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/diff:/var/lib/docker/overlay2/09785ba17f27c783ef8b44f369f9aac0ca936000b57abf22b3c54d1e6eb8e27b/diff:/var/lib/docker/overlay2/6f8acd64ae44fd4d14bcb90c105eceba46854aa3985b5b6b317bcc5692cfc286/diff:/var/lib/docker/overlay2/73d41c15edb21c5f12cf53e313f48b5da55283aafc77d35b7bc662241879d7e7/diff:/var/lib/docker/overlay2/d97b55f3d966ae031492369a98e9e00d2bd31e520290fe2034e0a2b1ed77c91e/diff:/var/lib/docker/overlay2/053e9ca65c6b64cb9d98a812ff7488c7e77938b4fb8e0c4d2ad7f8ec235f0f20/diff",
+ "MergedDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/merged",
+ "UpperDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/diff",
+ "WorkDir": "/var/lib/docker/overlay2/05501c86219af9f713c74c129426cf5a17dc5e42f96f7f881f443cab100280e2/work"
+ },
+ "Name": "overlay2"
+ },
+ "Mounts": [],
+ "Config": {
+ "Hostname": "25e75393800b",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": true,
+ "AttachStderr": true,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "NGINX_VERSION=1.23.3",
+ "NJS_VERSION=0.7.9",
+ "PKG_RELEASE=1~bullseye"
+ ],
+ "Cmd": [
+ "nginx",
+ "-g",
+ "daemon off;"
+ ],
+ "Image": "nginx",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/docker-entrypoint.sh"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "com.docker.compose.config-hash": "8bb0e1c8c61f6d495840ba9133ebfb1e4ffda3e1adb701a011b03951848bb9fa",
+ "com.docker.compose.container-number": "1",
+ "com.docker.compose.depends_on": "producer:service_started:false",
+ "com.docker.compose.image": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8",
+ "com.docker.compose.oneoff": "False",
+ "com.docker.compose.project": "wt-contnet",
+ "com.docker.compose.project.config_files": "/tmp/wt-contnet/docker-compose.yaml",
+ "com.docker.compose.project.working_dir": "/tmp/wt-contnet",
+ "com.docker.compose.replace": "07bb70608f96f577aa02b9f317500e23e691c94eb099f6fb52301dfb031d0668",
+ "com.docker.compose.service": "consumer",
+ "com.docker.compose.version": "2.19.1",
+ "desktop.docker.io/wsl-distro": "Ubuntu",
+ "maintainer": "NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e"
+ },
+ "StopSignal": "SIGQUIT"
+ },
+ "NetworkSettings": {
+ "Bridge": "",
+ "SandboxID": "",
+ "HairpinMode": false,
+ "LinkLocalIPv6Address": "",
+ "LinkLocalIPv6PrefixLen": 0,
+ "Ports": {},
+ "SandboxKey": "",
+ "SecondaryIPAddresses": null,
+ "SecondaryIPv6Addresses": null,
+ "EndpointID": "",
+ "Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "MacAddress": "",
+ "Networks": {}
+ }
+}
diff --git a/pkg/container/mocks/data/container_net_supplier.json b/pkg/container/mocks/data/container_net_supplier.json
new file mode 100644
index 0000000..24db841
--- /dev/null
+++ b/pkg/container/mocks/data/container_net_supplier.json
@@ -0,0 +1,380 @@
+{
+ "Id": "25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2",
+ "Created": "2023-07-25T14:55:14.595662628Z",
+ "Path": "/gluetun-entrypoint",
+ "Args": [],
+ "State": {
+ "Status": "running",
+ "Running": true,
+ "Paused": false,
+ "Restarting": false,
+ "OOMKilled": false,
+ "Dead": false,
+ "Pid": 3648,
+ "ExitCode": 0,
+ "Error": "",
+ "StartedAt": "2023-07-25T14:55:15.193430103Z",
+ "FinishedAt": "0001-01-01T00:00:00Z",
+ "Health": {
+ "Status": "healthy",
+ "FailingStreak": 0,
+ "Log": [
+ {
+ "Start": "2023-07-25T15:00:32.078491228Z",
+ "End": "2023-07-25T15:00:32.194554876Z",
+ "ExitCode": 0,
+ "Output": ""
+ },
+ {
+ "Start": "2023-07-25T15:00:37.199245496Z",
+ "End": "2023-07-25T15:00:37.294845687Z",
+ "ExitCode": 0,
+ "Output": ""
+ },
+ {
+ "Start": "2023-07-25T15:00:42.299676089Z",
+ "End": "2023-07-25T15:00:42.384213818Z",
+ "ExitCode": 0,
+ "Output": ""
+ },
+ {
+ "Start": "2023-07-25T15:00:47.389142447Z",
+ "End": "2023-07-25T15:00:47.514483294Z",
+ "ExitCode": 0,
+ "Output": ""
+ },
+ {
+ "Start": "2023-07-25T15:00:52.518770886Z",
+ "End": "2023-07-25T15:00:52.644288742Z",
+ "ExitCode": 0,
+ "Output": ""
+ }
+ ]
+ }
+ },
+ "Image": "sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51",
+ "ResolvConfPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/resolv.conf",
+ "HostnamePath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hostname",
+ "HostsPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/hosts",
+ "LogPath": "/var/lib/docker/containers/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2/25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2-json.log",
+ "Name": "/wt-contnet-producer-1",
+ "RestartCount": 0,
+ "Driver": "overlay2",
+ "Platform": "linux",
+ "MountLabel": "",
+ "ProcessLabel": "",
+ "AppArmorProfile": "",
+ "ExecIDs": null,
+ "HostConfig": {
+ "Binds": null,
+ "ContainerIDFile": "",
+ "LogConfig": {
+ "Type": "json-file",
+ "Config": {}
+ },
+ "NetworkMode": "wt-contnet_default",
+ "PortBindings": {},
+ "RestartPolicy": {
+ "Name": "",
+ "MaximumRetryCount": 0
+ },
+ "AutoRemove": false,
+ "VolumeDriver": "",
+ "VolumesFrom": null,
+ "ConsoleSize": [
+ 0,
+ 0
+ ],
+ "CapAdd": [
+ "NET_ADMIN"
+ ],
+ "CapDrop": null,
+ "CgroupnsMode": "host",
+ "Dns": null,
+ "DnsOptions": null,
+ "DnsSearch": null,
+ "ExtraHosts": [],
+ "GroupAdd": null,
+ "IpcMode": "private",
+ "Cgroup": "",
+ "Links": null,
+ "OomScoreAdj": 0,
+ "PidMode": "",
+ "Privileged": false,
+ "PublishAllPorts": false,
+ "ReadonlyRootfs": false,
+ "SecurityOpt": null,
+ "UTSMode": "",
+ "UsernsMode": "",
+ "ShmSize": 67108864,
+ "Runtime": "runc",
+ "Isolation": "",
+ "CpuShares": 0,
+ "Memory": 0,
+ "NanoCpus": 0,
+ "CgroupParent": "",
+ "BlkioWeight": 0,
+ "BlkioWeightDevice": null,
+ "BlkioDeviceReadBps": null,
+ "BlkioDeviceWriteBps": null,
+ "BlkioDeviceReadIOps": null,
+ "BlkioDeviceWriteIOps": null,
+ "CpuPeriod": 0,
+ "CpuQuota": 0,
+ "CpuRealtimePeriod": 0,
+ "CpuRealtimeRuntime": 0,
+ "CpusetCpus": "",
+ "CpusetMems": "",
+ "Devices": null,
+ "DeviceCgroupRules": null,
+ "DeviceRequests": null,
+ "MemoryReservation": 0,
+ "MemorySwap": 0,
+ "MemorySwappiness": null,
+ "OomKillDisable": false,
+ "PidsLimit": null,
+ "Ulimits": null,
+ "CpuCount": 0,
+ "CpuPercent": 0,
+ "IOMaximumIOps": 0,
+ "IOMaximumBandwidth": 0,
+ "MaskedPaths": [
+ "/proc/asound",
+ "/proc/acpi",
+ "/proc/kcore",
+ "/proc/keys",
+ "/proc/latency_stats",
+ "/proc/timer_list",
+ "/proc/timer_stats",
+ "/proc/sched_debug",
+ "/proc/scsi",
+ "/sys/firmware"
+ ],
+ "ReadonlyPaths": [
+ "/proc/bus",
+ "/proc/fs",
+ "/proc/irq",
+ "/proc/sys",
+ "/proc/sysrq-trigger"
+ ]
+ },
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/618bd1e7a13880c07ec7f5bfc45012a9f81d5de452f942b49d8f49b3c67a19a2-init/diff:/var/lib/docker/overlay2/0d222a3aa067159831c4111a408e40325be1085b935c98d39c2e9a01ff50b224/diff:/var/lib/docker/overlay2/a20c9490a23ee8af51898892d9bf32258d44e0e07f3799475be8e8f273a50f73/diff:/var/lib/docker/overlay2/d4c97f367c37c6ada9de57f438a3e19cc714be2a54a6f582a03de9e42d88b344/diff",
+ "MergedDir": "/var/lib/docker/overlay2/618bd1e7a13880c07ec7f5bfc45012a9f81d5de452f942b49d8f49b3c67a19a2/merged",
+ "UpperDir": "/var/lib/docker/overlay2/618bd1e7a13880c07ec7f5bfc45012a9f81d5de452f942b49d8f49b3c67a19a2/diff",
+ "WorkDir": "/var/lib/docker/overlay2/618bd1e7a13880c07ec7f5bfc45012a9f81d5de452f942b49d8f49b3c67a19a2/work"
+ },
+ "Name": "overlay2"
+ },
+ "Mounts": [],
+ "Config": {
+ "Hostname": "25e75393800b",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": true,
+ "AttachStderr": true,
+ "ExposedPorts": {
+ "8000/tcp": {},
+ "8388/tcp": {},
+ "8388/udp": {},
+ "8888/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "OPENVPN_PASSWORD=",
+ "SERVER_COUNTRIES=Sweden",
+ "VPN_SERVICE_PROVIDER=nordvpn",
+ "OPENVPN_USER=",
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "VPN_TYPE=openvpn",
+ "VPN_ENDPOINT_IP=",
+ "VPN_ENDPOINT_PORT=",
+ "VPN_INTERFACE=tun0",
+ "OPENVPN_PROTOCOL=udp",
+ "OPENVPN_USER_SECRETFILE=/run/secrets/openvpn_user",
+ "OPENVPN_PASSWORD_SECRETFILE=/run/secrets/openvpn_password",
+ "OPENVPN_VERSION=2.5",
+ "OPENVPN_VERBOSITY=1",
+ "OPENVPN_FLAGS=",
+ "OPENVPN_CIPHERS=",
+ "OPENVPN_AUTH=",
+ "OPENVPN_PROCESS_USER=root",
+ "OPENVPN_CUSTOM_CONFIG=",
+ "WIREGUARD_PRIVATE_KEY=",
+ "WIREGUARD_PRESHARED_KEY=",
+ "WIREGUARD_PUBLIC_KEY=",
+ "WIREGUARD_ALLOWED_IPS=",
+ "WIREGUARD_ADDRESSES=",
+ "WIREGUARD_MTU=1400",
+ "WIREGUARD_IMPLEMENTATION=auto",
+ "SERVER_REGIONS=",
+ "SERVER_CITIES=",
+ "SERVER_HOSTNAMES=",
+ "ISP=",
+ "OWNED_ONLY=no",
+ "PRIVATE_INTERNET_ACCESS_OPENVPN_ENCRYPTION_PRESET=",
+ "VPN_PORT_FORWARDING=off",
+ "VPN_PORT_FORWARDING_PROVIDER=",
+ "VPN_PORT_FORWARDING_STATUS_FILE=/tmp/gluetun/forwarded_port",
+ "OPENVPN_CERT=",
+ "OPENVPN_KEY=",
+ "OPENVPN_CLIENTCRT_SECRETFILE=/run/secrets/openvpn_clientcrt",
+ "OPENVPN_CLIENTKEY_SECRETFILE=/run/secrets/openvpn_clientkey",
+ "OPENVPN_ENCRYPTED_KEY=",
+ "OPENVPN_ENCRYPTED_KEY_SECRETFILE=/run/secrets/openvpn_encrypted_key",
+ "OPENVPN_KEY_PASSPHRASE=",
+ "OPENVPN_KEY_PASSPHRASE_SECRETFILE=/run/secrets/openvpn_key_passphrase",
+ "SERVER_NUMBER=",
+ "SERVER_NAMES=",
+ "FREE_ONLY=",
+ "MULTIHOP_ONLY=",
+ "PREMIUM_ONLY=",
+ "FIREWALL=on",
+ "FIREWALL_VPN_INPUT_PORTS=",
+ "FIREWALL_INPUT_PORTS=",
+ "FIREWALL_OUTBOUND_SUBNETS=",
+ "FIREWALL_DEBUG=off",
+ "LOG_LEVEL=info",
+ "HEALTH_SERVER_ADDRESS=127.0.0.1:9999",
+ "HEALTH_TARGET_ADDRESS=cloudflare.com:443",
+ "HEALTH_SUCCESS_WAIT_DURATION=5s",
+ "HEALTH_VPN_DURATION_INITIAL=6s",
+ "HEALTH_VPN_DURATION_ADDITION=5s",
+ "DOT=on",
+ "DOT_PROVIDERS=cloudflare",
+ "DOT_PRIVATE_ADDRESS=127.0.0.1/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,::1/128,fc00::/7,fe80::/10,::ffff:7f00:1/104,::ffff:a00:0/104,::ffff:a9fe:0/112,::ffff:ac10:0/108,::ffff:c0a8:0/112",
+ "DOT_VERBOSITY=1",
+ "DOT_VERBOSITY_DETAILS=0",
+ "DOT_VALIDATION_LOGLEVEL=0",
+ "DOT_CACHING=on",
+ "DOT_IPV6=off",
+ "BLOCK_MALICIOUS=on",
+ "BLOCK_SURVEILLANCE=off",
+ "BLOCK_ADS=off",
+ "UNBLOCK=",
+ "DNS_UPDATE_PERIOD=24h",
+ "DNS_ADDRESS=127.0.0.1",
+ "DNS_KEEP_NAMESERVER=off",
+ "HTTPPROXY=",
+ "HTTPPROXY_LOG=off",
+ "HTTPPROXY_LISTENING_ADDRESS=:8888",
+ "HTTPPROXY_STEALTH=off",
+ "HTTPPROXY_USER=",
+ "HTTPPROXY_PASSWORD=",
+ "HTTPPROXY_USER_SECRETFILE=/run/secrets/httpproxy_user",
+ "HTTPPROXY_PASSWORD_SECRETFILE=/run/secrets/httpproxy_password",
+ "SHADOWSOCKS=off",
+ "SHADOWSOCKS_LOG=off",
+ "SHADOWSOCKS_LISTENING_ADDRESS=:8388",
+ "SHADOWSOCKS_PASSWORD=",
+ "SHADOWSOCKS_PASSWORD_SECRETFILE=/run/secrets/shadowsocks_password",
+ "SHADOWSOCKS_CIPHER=chacha20-ietf-poly1305",
+ "HTTP_CONTROL_SERVER_LOG=on",
+ "HTTP_CONTROL_SERVER_ADDRESS=:8000",
+ "UPDATER_PERIOD=0",
+ "UPDATER_MIN_RATIO=0.8",
+ "UPDATER_VPN_SERVICE_PROVIDERS=",
+ "PUBLICIP_FILE=/tmp/gluetun/ip",
+ "PUBLICIP_PERIOD=12h",
+ "PPROF_ENABLED=no",
+ "PPROF_BLOCK_PROFILE_RATE=0",
+ "PPROF_MUTEX_PROFILE_RATE=0",
+ "PPROF_HTTP_SERVER_ADDRESS=:6060",
+ "VERSION_INFORMATION=on",
+ "TZ=",
+ "PUID=",
+ "PGID="
+ ],
+ "Cmd": null,
+ "Healthcheck": {
+ "Test": [
+ "CMD-SHELL",
+ "/gluetun-entrypoint healthcheck"
+ ],
+ "Interval": 5000000000,
+ "Timeout": 5000000000,
+ "StartPeriod": 10000000000,
+ "Retries": 1
+ },
+ "Image": "qmcgaw/gluetun",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/gluetun-entrypoint"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "com.docker.compose.config-hash": "6dc7dc42a86edb47039de3650a9cb9bdcf4866c113b8f9d797722c9dfd20428b",
+ "com.docker.compose.container-number": "1",
+ "com.docker.compose.depends_on": "",
+ "com.docker.compose.image": "sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51",
+ "com.docker.compose.oneoff": "False",
+ "com.docker.compose.project": "wt-contnet",
+ "com.docker.compose.project.config_files": "/tmp/wt-contnet/docker-compose.yaml",
+ "com.docker.compose.project.working_dir": "/tmp/wt-contnet",
+ "com.docker.compose.replace": "9bd1ce000be81819fc915aa60a1674c7573b59a26ac4643ecf427a5732b9785f",
+ "com.docker.compose.service": "producer",
+ "com.docker.compose.version": "2.19.1",
+ "desktop.docker.io/wsl-distro": "Ubuntu",
+ "org.opencontainers.image.authors": "quentin.mcgaw@gmail.com",
+ "org.opencontainers.image.created": "2023-07-22T16:07:05.641Z",
+ "org.opencontainers.image.description": "VPN client in a thin Docker container for multiple VPN providers, written in Go, and using OpenVPN or Wireguard, DNS over TLS, with a few proxy servers built-in.",
+ "org.opencontainers.image.documentation": "https://github.com/qdm12/gluetun",
+ "org.opencontainers.image.licenses": "MIT",
+ "org.opencontainers.image.revision": "eecfb3952f202c0de3867d88e96d80c6b0f48359",
+ "org.opencontainers.image.source": "https://github.com/qdm12/gluetun",
+ "org.opencontainers.image.title": "gluetun",
+ "org.opencontainers.image.url": "https://github.com/qdm12/gluetun",
+ "org.opencontainers.image.version": "latest"
+ }
+ },
+ "NetworkSettings": {
+ "Bridge": "",
+ "SandboxID": "34a321b64bb1b15f994dfccff0e235f881504f240c2028876ff6683962eaa10e",
+ "HairpinMode": false,
+ "LinkLocalIPv6Address": "",
+ "LinkLocalIPv6PrefixLen": 0,
+ "Ports": {
+ "8000/tcp": null,
+ "8388/tcp": null,
+ "8388/udp": null,
+ "8888/tcp": null
+ },
+ "SandboxKey": "/var/run/docker/netns/34a321b64bb1",
+ "SecondaryIPAddresses": null,
+ "SecondaryIPv6Addresses": null,
+ "EndpointID": "",
+ "Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "MacAddress": "",
+ "Networks": {
+ "wt-contnet_default": {
+ "IPAMConfig": null,
+ "Links": null,
+ "Aliases": [
+ "wt-contnet-producer-1",
+ "producer",
+ "25e75393800b"
+ ],
+ "NetworkID": "f0f652a79efc54bcad52aafb4cbcc3b5dce1acaf11b172d8678d25f665faf63d",
+ "EndpointID": "2429c2b5d08db6c986bbd419a52ca4dd352715d80c5aeae04742efb84b0356fc",
+ "Gateway": "172.19.0.1",
+ "IPAddress": "172.19.0.2",
+ "IPPrefixLen": 16,
+ "IPv6Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "MacAddress": "02:42:ac:13:00:02",
+ "DriverOpts": null
+ }
+ }
+ }
+}
diff --git a/pkg/container/mocks/data/container_restarting.json b/pkg/container/mocks/data/container_restarting.json
new file mode 100644
index 0000000..4eae912
--- /dev/null
+++ b/pkg/container/mocks/data/container_restarting.json
@@ -0,0 +1,205 @@
+{
+ "Id": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67",
+ "Created": "2019-04-10T19:51:22.245041005Z",
+ "Path": "/watchtower",
+ "Args": [],
+ "State": {
+ "Status": "exited",
+ "Running": false,
+ "Paused": false,
+ "Restarting": true,
+ "OOMKilled": false,
+ "Dead": false,
+ "Pid": 0,
+ "ExitCode": 1,
+ "Error": "",
+ "StartedAt": "2019-04-10T19:51:22.918972606Z",
+ "FinishedAt": "2019-04-10T19:52:14.265091583Z"
+ },
+ "Image": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa",
+ "ResolvConfPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/resolv.conf",
+ "HostnamePath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hostname",
+ "HostsPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hosts",
+ "LogPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65-json.log",
+ "Name": "/watchtower-test",
+ "RestartCount": 0,
+ "Driver": "overlay2",
+ "Platform": "linux",
+ "MountLabel": "",
+ "ProcessLabel": "",
+ "AppArmorProfile": "",
+ "ExecIDs": null,
+ "HostConfig": {
+ "Binds": [
+ "/var/run/docker.sock:/var/run/docker.sock"
+ ],
+ "ContainerIDFile": "",
+ "LogConfig": {
+ "Type": "json-file",
+ "Config": {}
+ },
+ "NetworkMode": "default",
+ "PortBindings": {},
+ "RestartPolicy": {
+ "Name": "no",
+ "MaximumRetryCount": 0
+ },
+ "AutoRemove": false,
+ "VolumeDriver": "",
+ "VolumesFrom": null,
+ "CapAdd": null,
+ "CapDrop": null,
+ "Dns": [],
+ "DnsOptions": [],
+ "DnsSearch": [],
+ "ExtraHosts": null,
+ "GroupAdd": null,
+ "IpcMode": "shareable",
+ "Cgroup": "",
+ "Links": null,
+ "OomScoreAdj": 0,
+ "PidMode": "",
+ "Privileged": false,
+ "PublishAllPorts": false,
+ "ReadonlyRootfs": false,
+ "SecurityOpt": null,
+ "UTSMode": "",
+ "UsernsMode": "",
+ "ShmSize": 67108864,
+ "Runtime": "runc",
+ "ConsoleSize": [
+ 0,
+ 0
+ ],
+ "Isolation": "",
+ "CpuShares": 0,
+ "Memory": 0,
+ "NanoCpus": 0,
+ "CgroupParent": "",
+ "BlkioWeight": 0,
+ "BlkioWeightDevice": [],
+ "BlkioDeviceReadBps": null,
+ "BlkioDeviceWriteBps": null,
+ "BlkioDeviceReadIOps": null,
+ "BlkioDeviceWriteIOps": null,
+ "CpuPeriod": 0,
+ "CpuQuota": 0,
+ "CpuRealtimePeriod": 0,
+ "CpuRealtimeRuntime": 0,
+ "CpusetCpus": "",
+ "CpusetMems": "",
+ "Devices": [],
+ "DeviceCgroupRules": null,
+ "DiskQuota": 0,
+ "KernelMemory": 0,
+ "MemoryReservation": 0,
+ "MemorySwap": 0,
+ "MemorySwappiness": null,
+ "OomKillDisable": false,
+ "PidsLimit": 0,
+ "Ulimits": null,
+ "CpuCount": 0,
+ "CpuPercent": 0,
+ "IOMaximumIOps": 0,
+ "IOMaximumBandwidth": 0,
+ "MaskedPaths": [
+ "/proc/asound",
+ "/proc/acpi",
+ "/proc/kcore",
+ "/proc/keys",
+ "/proc/latency_stats",
+ "/proc/timer_list",
+ "/proc/timer_stats",
+ "/proc/sched_debug",
+ "/proc/scsi",
+ "/sys/firmware"
+ ],
+ "ReadonlyPaths": [
+ "/proc/bus",
+ "/proc/fs",
+ "/proc/irq",
+ "/proc/sys",
+ "/proc/sysrq-trigger"
+ ]
+ },
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc-init/diff:/var/lib/docker/overlay2/cdf82f50bc49177d0c17c24f3eaa29eba607b70cc6a081f77781b21c59a13eb8/diff:/var/lib/docker/overlay2/8108325ee844603c9b08d2772cf6e65dccf31dd5171f265078e5ed79a0ba3c0f/diff:/var/lib/docker/overlay2/e5e0cce6bf91b829a308424d99d7e56a33be3a11414ff5cdc48e762a1342b20f/diff",
+ "MergedDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/merged",
+ "UpperDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/diff",
+ "WorkDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/work"
+ },
+ "Name": "overlay2"
+ },
+ "Mounts": [
+ {
+ "Type": "bind",
+ "Source": "/var/run/docker.sock",
+ "Destination": "/var/run/docker.sock",
+ "Mode": "",
+ "RW": true,
+ "Propagation": "rprivate"
+ }
+ ],
+ "Config": {
+ "Hostname": "ae8964ba86c7",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": true,
+ "AttachStderr": true,
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+ ],
+ "Cmd": null,
+ "Image": "containrrr/watchtower:latest",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/watchtower"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "com.centurylinklabs.watchtower": "true"
+ }
+ },
+ "NetworkSettings": {
+ "Bridge": "",
+ "SandboxID": "05627d36c08ed994eebc44a2a8c9365a511756b55c500fb03fd5a14477cd4bf3",
+ "HairpinMode": false,
+ "LinkLocalIPv6Address": "",
+ "LinkLocalIPv6PrefixLen": 0,
+ "Ports": {},
+ "SandboxKey": "/var/run/docker/netns/05627d36c08e",
+ "SecondaryIPAddresses": null,
+ "SecondaryIPv6Addresses": null,
+ "EndpointID": "",
+ "Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "MacAddress": "",
+ "Networks": {
+ "bridge": {
+ "IPAMConfig": null,
+ "Links": null,
+ "Aliases": null,
+ "NetworkID": "8fcfd56fa9203bafa98510abb08bff66ad05bef5b6e97d158cbae3397e1e065e",
+ "EndpointID": "",
+ "Gateway": "",
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "MacAddress": "",
+ "DriverOpts": null
+ }
+ }
+ }
+}
diff --git a/pkg/container/mocks/data/container_stopped.json b/pkg/container/mocks/data/container_stopped.json
index a4519d7..0043c62 100644
--- a/pkg/container/mocks/data/container_stopped.json
+++ b/pkg/container/mocks/data/container_stopped.json
@@ -21,7 +21,7 @@
"HostnamePath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hostname",
"HostsPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hosts",
"LogPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65-json.log",
- "Name": "/watchtower-test",
+ "Name": "/watchtower-stopped",
"RestartCount": 0,
"Driver": "overlay2",
"Platform": "linux",
diff --git a/pkg/container/mocks/data/container_watchtower.json b/pkg/container/mocks/data/container_watchtower.json
new file mode 100644
index 0000000..14737b5
--- /dev/null
+++ b/pkg/container/mocks/data/container_watchtower.json
@@ -0,0 +1,205 @@
+{
+ "Id": "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134",
+ "Created": "2020-04-10T19:51:22.245041005Z",
+ "Path": "/watchtower",
+ "Args": [],
+ "State": {
+ "Status": "running",
+ "Running": true,
+ "Paused": false,
+ "Restarting": false,
+ "OOMKilled": false,
+ "Dead": false,
+ "Pid": 3854,
+ "ExitCode": 0,
+ "Error": "",
+ "StartedAt": "2019-04-13T22:38:24.498745809Z",
+ "FinishedAt": "2019-04-13T22:38:18.486292076Z"
+ },
+ "Image": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa",
+ "ResolvConfPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/resolv.conf",
+ "HostnamePath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hostname",
+ "HostsPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/hosts",
+ "LogPath": "/var/lib/docker/containers/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65/ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65-json.log",
+ "Name": "/watchtower-running",
+ "RestartCount": 0,
+ "Driver": "overlay2",
+ "Platform": "linux",
+ "MountLabel": "",
+ "ProcessLabel": "",
+ "AppArmorProfile": "",
+ "ExecIDs": null,
+ "HostConfig": {
+ "Binds": [
+ "/var/run/docker.sock:/var/run/docker.sock"
+ ],
+ "ContainerIDFile": "",
+ "LogConfig": {
+ "Type": "json-file",
+ "Config": {}
+ },
+ "NetworkMode": "default",
+ "PortBindings": {},
+ "RestartPolicy": {
+ "Name": "no",
+ "MaximumRetryCount": 0
+ },
+ "AutoRemove": false,
+ "VolumeDriver": "",
+ "VolumesFrom": null,
+ "CapAdd": null,
+ "CapDrop": null,
+ "Dns": [],
+ "DnsOptions": [],
+ "DnsSearch": [],
+ "ExtraHosts": null,
+ "GroupAdd": null,
+ "IpcMode": "shareable",
+ "Cgroup": "",
+ "Links": null,
+ "OomScoreAdj": 0,
+ "PidMode": "",
+ "Privileged": false,
+ "PublishAllPorts": false,
+ "ReadonlyRootfs": false,
+ "SecurityOpt": null,
+ "UTSMode": "",
+ "UsernsMode": "",
+ "ShmSize": 67108864,
+ "Runtime": "runc",
+ "ConsoleSize": [
+ 0,
+ 0
+ ],
+ "Isolation": "",
+ "CpuShares": 0,
+ "Memory": 0,
+ "NanoCpus": 0,
+ "CgroupParent": "",
+ "BlkioWeight": 0,
+ "BlkioWeightDevice": [],
+ "BlkioDeviceReadBps": null,
+ "BlkioDeviceWriteBps": null,
+ "BlkioDeviceReadIOps": null,
+ "BlkioDeviceWriteIOps": null,
+ "CpuPeriod": 0,
+ "CpuQuota": 0,
+ "CpuRealtimePeriod": 0,
+ "CpuRealtimeRuntime": 0,
+ "CpusetCpus": "",
+ "CpusetMems": "",
+ "Devices": [],
+ "DeviceCgroupRules": null,
+ "DiskQuota": 0,
+ "KernelMemory": 0,
+ "MemoryReservation": 0,
+ "MemorySwap": 0,
+ "MemorySwappiness": null,
+ "OomKillDisable": false,
+ "PidsLimit": 0,
+ "Ulimits": null,
+ "CpuCount": 0,
+ "CpuPercent": 0,
+ "IOMaximumIOps": 0,
+ "IOMaximumBandwidth": 0,
+ "MaskedPaths": [
+ "/proc/asound",
+ "/proc/acpi",
+ "/proc/kcore",
+ "/proc/keys",
+ "/proc/latency_stats",
+ "/proc/timer_list",
+ "/proc/timer_stats",
+ "/proc/sched_debug",
+ "/proc/scsi",
+ "/sys/firmware"
+ ],
+ "ReadonlyPaths": [
+ "/proc/bus",
+ "/proc/fs",
+ "/proc/irq",
+ "/proc/sys",
+ "/proc/sysrq-trigger"
+ ]
+ },
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc-init/diff:/var/lib/docker/overlay2/cdf82f50bc49177d0c17c24f3eaa29eba607b70cc6a081f77781b21c59a13eb8/diff:/var/lib/docker/overlay2/8108325ee844603c9b08d2772cf6e65dccf31dd5171f265078e5ed79a0ba3c0f/diff:/var/lib/docker/overlay2/e5e0cce6bf91b829a308424d99d7e56a33be3a11414ff5cdc48e762a1342b20f/diff",
+ "MergedDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/merged",
+ "UpperDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/diff",
+ "WorkDir": "/var/lib/docker/overlay2/9f6b91ea6e142835035d91123bbc7a05224dfa2abd4d020eac42f2ab420ccddc/work"
+ },
+ "Name": "overlay2"
+ },
+ "Mounts": [
+ {
+ "Type": "bind",
+ "Source": "/var/run/docker.sock",
+ "Destination": "/var/run/docker.sock",
+ "Mode": "",
+ "RW": true,
+ "Propagation": "rprivate"
+ }
+ ],
+ "Config": {
+ "Hostname": "ae8964ba86c7",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": true,
+ "AttachStderr": true,
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+ ],
+ "Cmd": null,
+ "Image": "containrrr/watchtower:latest",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/watchtower"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "com.centurylinklabs.watchtower": "true"
+ }
+ },
+ "NetworkSettings": {
+ "Bridge": "",
+ "SandboxID": "05627d36c08ed994eebc44a2a8c9365a511756b55c500fb03fd5a14477cd4bf3",
+ "HairpinMode": false,
+ "LinkLocalIPv6Address": "",
+ "LinkLocalIPv6PrefixLen": 0,
+ "Ports": {},
+ "SandboxKey": "/var/run/docker/netns/05627d36c08e",
+ "SecondaryIPAddresses": null,
+ "SecondaryIPv6Addresses": null,
+ "EndpointID": "",
+ "Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "MacAddress": "",
+ "Networks": {
+ "bridge": {
+ "IPAMConfig": null,
+ "Links": null,
+ "Aliases": null,
+ "NetworkID": "8fcfd56fa9203bafa98510abb08bff66ad05bef5b6e97d158cbae3397e1e065e",
+ "EndpointID": "",
+ "Gateway": "",
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "MacAddress": "",
+ "DriverOpts": null
+ }
+ }
+ }
+}
diff --git a/pkg/container/mocks/data/containers.json b/pkg/container/mocks/data/containers.json
index e2507bf..439cc51 100644
--- a/pkg/container/mocks/data/containers.json
+++ b/pkg/container/mocks/data/containers.json
@@ -2,7 +2,55 @@
{
"Id": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65",
"Names": [
- "/watchtower-test"
+ "/watchtower-stopped"
+ ],
+ "Image": "containrrr/watchtower:latest",
+ "ImageID": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa",
+ "Command": "/watchtower",
+ "Created": 1554925882,
+ "Ports": [],
+ "Labels": {
+ "com.centurylinklabs.watchtower": "true"
+ },
+ "State": "exited",
+ "Status": "Exited (1) 6 days ago",
+ "HostConfig": {
+ "NetworkMode": "default"
+ },
+ "NetworkSettings": {
+ "Networks": {
+ "bridge": {
+ "IPAMConfig": null,
+ "Links": null,
+ "Aliases": null,
+ "NetworkID": "8fcfd56fa9203bafa98510abb08bff66ad05bef5b6e97d158cbae3397e1e065e",
+ "EndpointID": "",
+ "Gateway": "",
+ "IPAddress": "",
+ "IPPrefixLen": 0,
+ "IPv6Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "MacAddress": "",
+ "DriverOpts": null
+ }
+ }
+ },
+ "Mounts": [
+ {
+ "Type": "bind",
+ "Source": "/var/run/docker.sock",
+ "Destination": "/var/run/docker.sock",
+ "Mode": "",
+ "RW": true,
+ "Propagation": "rprivate"
+ }
+ ]
+ },
+ {
+ "Id": "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134",
+ "Names": [
+ "/watchtower-running"
],
"Image": "containrrr/watchtower:latest",
"ImageID": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa",
@@ -13,7 +61,7 @@
"com.centurylinklabs.watchtower": "true"
},
"State": "running",
- "Status": "Exited (1) 6 days ago",
+ "Status": "Up 3 days",
"HostConfig": {
"NetworkMode": "default"
},
@@ -109,5 +157,68 @@
"Propagation": "rprivate"
}
]
+ },
+ {
+ "Id": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67",
+ "Names": [
+ "/portainer"
+ ],
+ "Image": "portainer/portainer:latest",
+ "ImageID": "sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd",
+ "Command": "/portainer",
+ "Created": 1554409712,
+ "Ports": [
+ {
+ "IP": "0.0.0.0",
+ "PrivatePort": 9000,
+ "PublicPort": 9000,
+ "Type": "tcp"
+ }
+ ],
+ "Labels": {},
+ "State": "restarting",
+ "Status": "Restarting (0) 35 seconds ago",
+ "HostConfig": {
+ "NetworkMode": "default"
+ },
+ "NetworkSettings": {
+ "Networks": {
+ "bridge": {
+ "IPAMConfig": null,
+ "Links": null,
+ "Aliases": null,
+ "NetworkID": "9352796e0330dcf31ce3d44fae4b719304b8b3fd97b02ade3aefb8737251682b",
+ "EndpointID": "a8bcd737f27edb4d2955f7bce0c777bb2990b792a6b335b0727387624abe0702",
+ "Gateway": "172.17.0.1",
+ "IPAddress": "172.17.0.2",
+ "IPPrefixLen": 16,
+ "IPv6Gateway": "",
+ "GlobalIPv6Address": "",
+ "GlobalIPv6PrefixLen": 0,
+ "MacAddress": "02:42:ac:11:00:02",
+ "DriverOpts": null
+ }
+ }
+ },
+ "Mounts": [
+ {
+ "Type": "volume",
+ "Name": "portainer_data",
+ "Source": "/var/lib/docker/volumes/portainer_data/_data",
+ "Destination": "/data",
+ "Driver": "local",
+ "Mode": "z",
+ "RW": true,
+ "Propagation": ""
+ },
+ {
+ "Type": "bind",
+ "Source": "/var/run/docker.sock",
+ "Destination": "/var/run/docker.sock",
+ "Mode": "",
+ "RW": true,
+ "Propagation": "rprivate"
+ }
+ ]
}
]
diff --git a/pkg/container/mocks/data/image01.json b/pkg/container/mocks/data/image_default.json
similarity index 100%
rename from pkg/container/mocks/data/image01.json
rename to pkg/container/mocks/data/image_default.json
diff --git a/pkg/container/mocks/data/image_net_consumer.json b/pkg/container/mocks/data/image_net_consumer.json
new file mode 100644
index 0000000..add6edf
--- /dev/null
+++ b/pkg/container/mocks/data/image_net_consumer.json
@@ -0,0 +1,115 @@
+{
+ "Id": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8",
+ "RepoTags": [
+ "nginx:latest"
+ ],
+ "RepoDigests": [
+ "nginx@sha256:aa0afebbb3cfa473099a62c4b32e9b3fb73ed23f2a75a65ce1d4b4f55a5c2ef2"
+ ],
+ "Parent": "",
+ "Comment": "",
+ "Created": "2023-03-01T18:43:12.914398123Z",
+ "Container": "71a4c9a59d252d7c54812429bfe5df477e54e91ebfff1939ae39ecdf055d445c",
+ "ContainerConfig": {
+ "Hostname": "71a4c9a59d25",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "NGINX_VERSION=1.23.3",
+ "NJS_VERSION=0.7.9",
+ "PKG_RELEASE=1~bullseye"
+ ],
+ "Cmd": [
+ "/bin/sh",
+ "-c",
+ "#(nop) ",
+ "CMD [\"nginx\" \"-g\" \"daemon off;\"]"
+ ],
+ "Image": "sha256:6716b8a33f73b21e193bb63424ea1105eaaa6a8237fefe75570bea18c87a1711",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/docker-entrypoint.sh"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "maintainer": "NGINX Docker Maintainers "
+ },
+ "StopSignal": "SIGQUIT"
+ },
+ "DockerVersion": "20.10.23",
+ "Author": "",
+ "Config": {
+ "Hostname": "",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "ExposedPorts": {
+ "80/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "NGINX_VERSION=1.23.3",
+ "NJS_VERSION=0.7.9",
+ "PKG_RELEASE=1~bullseye"
+ ],
+ "Cmd": [
+ "nginx",
+ "-g",
+ "daemon off;"
+ ],
+ "Image": "sha256:6716b8a33f73b21e193bb63424ea1105eaaa6a8237fefe75570bea18c87a1711",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/docker-entrypoint.sh"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "maintainer": "NGINX Docker Maintainers "
+ },
+ "StopSignal": "SIGQUIT"
+ },
+ "Architecture": "amd64",
+ "Os": "linux",
+ "Size": 141838643,
+ "VirtualSize": 141838643,
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/09785ba17f27c783ef8b44f369f9aac0ca936000b57abf22b3c54d1e6eb8e27b/diff:/var/lib/docker/overlay2/6f8acd64ae44fd4d14bcb90c105eceba46854aa3985b5b6b317bcc5692cfc286/diff:/var/lib/docker/overlay2/73d41c15edb21c5f12cf53e313f48b5da55283aafc77d35b7bc662241879d7e7/diff:/var/lib/docker/overlay2/d97b55f3d966ae031492369a98e9e00d2bd31e520290fe2034e0a2b1ed77c91e/diff:/var/lib/docker/overlay2/053e9ca65c6b64cb9d98a812ff7488c7e77938b4fb8e0c4d2ad7f8ec235f0f20/diff",
+ "MergedDir": "/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/merged",
+ "UpperDir": "/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/diff",
+ "WorkDir": "/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/work"
+ },
+ "Name": "overlay2"
+ },
+ "RootFS": {
+ "Type": "layers",
+ "Layers": [
+ "sha256:650abce4b096b06ac8bec2046d821d66d801af34f1f1d4c5e272ad030c7873db",
+ "sha256:4dc5cd799a08ff49a603870c8378ea93083bfc2a4176f56e5531997e94c195d0",
+ "sha256:e161c82b34d21179db1f546c1cd84153d28a17d865ccaf2dedeb06a903fec12c",
+ "sha256:83ba6d8ffb8c2974174c02d3ba549e7e0656ebb1bc075a6b6ee89b6c609c6a71",
+ "sha256:d8466e142d8710abf5b495ebb536478f7e19d9d03b151b5d5bd09df4cfb49248",
+ "sha256:101af4ba983b04be266217ecee414e88b23e394f62e9801c7c1bdb37cb37bcaa"
+ ]
+ },
+ "Metadata": {
+ "LastTagTime": "0001-01-01T00:00:00Z"
+ }
+}
diff --git a/pkg/container/mocks/data/image_net_producer.json b/pkg/container/mocks/data/image_net_producer.json
new file mode 100644
index 0000000..563ad95
--- /dev/null
+++ b/pkg/container/mocks/data/image_net_producer.json
@@ -0,0 +1,210 @@
+{
+ "Id": "sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51",
+ "RepoTags": [
+ "qmcgaw/gluetun:latest"
+ ],
+ "RepoDigests": [
+ "qmcgaw/gluetun@sha256:cd532bf4ef88a348a915c6dc62a9867a2eca89aa70559b0b4a1ea15cc0e595d1"
+ ],
+ "Parent": "",
+ "Comment": "buildkit.dockerfile.v0",
+ "Created": "2023-07-22T16:10:29.457146856Z",
+ "Container": "",
+ "ContainerConfig": {
+ "Hostname": "",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": null,
+ "Cmd": null,
+ "Image": "",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": null,
+ "OnBuild": null,
+ "Labels": null
+ },
+ "DockerVersion": "",
+ "Author": "",
+ "Config": {
+ "Hostname": "",
+ "Domainname": "",
+ "User": "",
+ "AttachStdin": false,
+ "AttachStdout": false,
+ "AttachStderr": false,
+ "ExposedPorts": {
+ "8000/tcp": {},
+ "8388/tcp": {},
+ "8388/udp": {},
+ "8888/tcp": {}
+ },
+ "Tty": false,
+ "OpenStdin": false,
+ "StdinOnce": false,
+ "Env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "VPN_SERVICE_PROVIDER=pia",
+ "VPN_TYPE=openvpn",
+ "VPN_ENDPOINT_IP=",
+ "VPN_ENDPOINT_PORT=",
+ "VPN_INTERFACE=tun0",
+ "OPENVPN_PROTOCOL=udp",
+ "OPENVPN_USER=",
+ "OPENVPN_PASSWORD=",
+ "OPENVPN_USER_SECRETFILE=/run/secrets/openvpn_user",
+ "OPENVPN_PASSWORD_SECRETFILE=/run/secrets/openvpn_password",
+ "OPENVPN_VERSION=2.5",
+ "OPENVPN_VERBOSITY=1",
+ "OPENVPN_FLAGS=",
+ "OPENVPN_CIPHERS=",
+ "OPENVPN_AUTH=",
+ "OPENVPN_PROCESS_USER=root",
+ "OPENVPN_CUSTOM_CONFIG=",
+ "WIREGUARD_PRIVATE_KEY=",
+ "WIREGUARD_PRESHARED_KEY=",
+ "WIREGUARD_PUBLIC_KEY=",
+ "WIREGUARD_ALLOWED_IPS=",
+ "WIREGUARD_ADDRESSES=",
+ "WIREGUARD_MTU=1400",
+ "WIREGUARD_IMPLEMENTATION=auto",
+ "SERVER_REGIONS=",
+ "SERVER_COUNTRIES=",
+ "SERVER_CITIES=",
+ "SERVER_HOSTNAMES=",
+ "ISP=",
+ "OWNED_ONLY=no",
+ "PRIVATE_INTERNET_ACCESS_OPENVPN_ENCRYPTION_PRESET=",
+ "VPN_PORT_FORWARDING=off",
+ "VPN_PORT_FORWARDING_PROVIDER=",
+ "VPN_PORT_FORWARDING_STATUS_FILE=/tmp/gluetun/forwarded_port",
+ "OPENVPN_CERT=",
+ "OPENVPN_KEY=",
+ "OPENVPN_CLIENTCRT_SECRETFILE=/run/secrets/openvpn_clientcrt",
+ "OPENVPN_CLIENTKEY_SECRETFILE=/run/secrets/openvpn_clientkey",
+ "OPENVPN_ENCRYPTED_KEY=",
+ "OPENVPN_ENCRYPTED_KEY_SECRETFILE=/run/secrets/openvpn_encrypted_key",
+ "OPENVPN_KEY_PASSPHRASE=",
+ "OPENVPN_KEY_PASSPHRASE_SECRETFILE=/run/secrets/openvpn_key_passphrase",
+ "SERVER_NUMBER=",
+ "SERVER_NAMES=",
+ "FREE_ONLY=",
+ "MULTIHOP_ONLY=",
+ "PREMIUM_ONLY=",
+ "FIREWALL=on",
+ "FIREWALL_VPN_INPUT_PORTS=",
+ "FIREWALL_INPUT_PORTS=",
+ "FIREWALL_OUTBOUND_SUBNETS=",
+ "FIREWALL_DEBUG=off",
+ "LOG_LEVEL=info",
+ "HEALTH_SERVER_ADDRESS=127.0.0.1:9999",
+ "HEALTH_TARGET_ADDRESS=cloudflare.com:443",
+ "HEALTH_SUCCESS_WAIT_DURATION=5s",
+ "HEALTH_VPN_DURATION_INITIAL=6s",
+ "HEALTH_VPN_DURATION_ADDITION=5s",
+ "DOT=on",
+ "DOT_PROVIDERS=cloudflare",
+ "DOT_PRIVATE_ADDRESS=127.0.0.1/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,::1/128,fc00::/7,fe80::/10,::ffff:7f00:1/104,::ffff:a00:0/104,::ffff:a9fe:0/112,::ffff:ac10:0/108,::ffff:c0a8:0/112",
+ "DOT_VERBOSITY=1",
+ "DOT_VERBOSITY_DETAILS=0",
+ "DOT_VALIDATION_LOGLEVEL=0",
+ "DOT_CACHING=on",
+ "DOT_IPV6=off",
+ "BLOCK_MALICIOUS=on",
+ "BLOCK_SURVEILLANCE=off",
+ "BLOCK_ADS=off",
+ "UNBLOCK=",
+ "DNS_UPDATE_PERIOD=24h",
+ "DNS_ADDRESS=127.0.0.1",
+ "DNS_KEEP_NAMESERVER=off",
+ "HTTPPROXY=",
+ "HTTPPROXY_LOG=off",
+ "HTTPPROXY_LISTENING_ADDRESS=:8888",
+ "HTTPPROXY_STEALTH=off",
+ "HTTPPROXY_USER=",
+ "HTTPPROXY_PASSWORD=",
+ "HTTPPROXY_USER_SECRETFILE=/run/secrets/httpproxy_user",
+ "HTTPPROXY_PASSWORD_SECRETFILE=/run/secrets/httpproxy_password",
+ "SHADOWSOCKS=off",
+ "SHADOWSOCKS_LOG=off",
+ "SHADOWSOCKS_LISTENING_ADDRESS=:8388",
+ "SHADOWSOCKS_PASSWORD=",
+ "SHADOWSOCKS_PASSWORD_SECRETFILE=/run/secrets/shadowsocks_password",
+ "SHADOWSOCKS_CIPHER=chacha20-ietf-poly1305",
+ "HTTP_CONTROL_SERVER_LOG=on",
+ "HTTP_CONTROL_SERVER_ADDRESS=:8000",
+ "UPDATER_PERIOD=0",
+ "UPDATER_MIN_RATIO=0.8",
+ "UPDATER_VPN_SERVICE_PROVIDERS=",
+ "PUBLICIP_FILE=/tmp/gluetun/ip",
+ "PUBLICIP_PERIOD=12h",
+ "PPROF_ENABLED=no",
+ "PPROF_BLOCK_PROFILE_RATE=0",
+ "PPROF_MUTEX_PROFILE_RATE=0",
+ "PPROF_HTTP_SERVER_ADDRESS=:6060",
+ "VERSION_INFORMATION=on",
+ "TZ=",
+ "PUID=",
+ "PGID="
+ ],
+ "Cmd": null,
+ "Healthcheck": {
+ "Test": [
+ "CMD-SHELL",
+ "/gluetun-entrypoint healthcheck"
+ ],
+ "Interval": 5000000000,
+ "Timeout": 5000000000,
+ "StartPeriod": 10000000000,
+ "Retries": 1
+ },
+ "Image": "",
+ "Volumes": null,
+ "WorkingDir": "",
+ "Entrypoint": [
+ "/gluetun-entrypoint"
+ ],
+ "OnBuild": null,
+ "Labels": {
+ "org.opencontainers.image.authors": "quentin.mcgaw@gmail.com",
+ "org.opencontainers.image.created": "2023-07-22T16:07:05.641Z",
+ "org.opencontainers.image.description": "VPN client in a thin Docker container for multiple VPN providers, written in Go, and using OpenVPN or Wireguard, DNS over TLS, with a few proxy servers built-in.",
+ "org.opencontainers.image.documentation": "https://github.com/qdm12/gluetun",
+ "org.opencontainers.image.licenses": "MIT",
+ "org.opencontainers.image.revision": "eecfb3952f202c0de3867d88e96d80c6b0f48359",
+ "org.opencontainers.image.source": "https://github.com/qdm12/gluetun",
+ "org.opencontainers.image.title": "gluetun",
+ "org.opencontainers.image.url": "https://github.com/qdm12/gluetun",
+ "org.opencontainers.image.version": "latest"
+ }
+ },
+ "Architecture": "amd64",
+ "Os": "linux",
+ "Size": 42602255,
+ "VirtualSize": 42602255,
+ "GraphDriver": {
+ "Data": {
+ "LowerDir": "/var/lib/docker/overlay2/a20c9490a23ee8af51898892d9bf32258d44e0e07f3799475be8e8f273a50f73/diff:/var/lib/docker/overlay2/d4c97f367c37c6ada9de57f438a3e19cc714be2a54a6f582a03de9e42d88b344/diff",
+ "MergedDir": "/var/lib/docker/overlay2/0d222a3aa067159831c4111a408e40325be1085b935c98d39c2e9a01ff50b224/merged",
+ "UpperDir": "/var/lib/docker/overlay2/0d222a3aa067159831c4111a408e40325be1085b935c98d39c2e9a01ff50b224/diff",
+ "WorkDir": "/var/lib/docker/overlay2/0d222a3aa067159831c4111a408e40325be1085b935c98d39c2e9a01ff50b224/work"
+ },
+ "Name": "overlay2"
+ },
+ "RootFS": {
+ "Type": "layers",
+ "Layers": [
+ "sha256:78a822fe2a2d2c84f3de4a403188c45f623017d6a4521d23047c9fbb0801794c",
+ "sha256:122dbeefc08382d88b3fe57ad81c1e2428af5b81c172d112723a33e2a20fe880",
+ "sha256:3d215e55b88a99dcd7cf4349618326ab129771e12fdf6c6ef5cbb71a265dbb6c"
+ ]
+ },
+ "Metadata": {
+ "LastTagTime": "0001-01-01T00:00:00Z"
+ }
+}
diff --git a/pkg/container/mocks/data/image02.json b/pkg/container/mocks/data/image_running.json
similarity index 100%
rename from pkg/container/mocks/data/image02.json
rename to pkg/container/mocks/data/image_running.json
diff --git a/pkg/container/util_test.go b/pkg/container/util_test.go
new file mode 100644
index 0000000..00912ba
--- /dev/null
+++ b/pkg/container/util_test.go
@@ -0,0 +1,50 @@
+package container_test
+
+import (
+ wt "github.com/containrrr/watchtower/pkg/types"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("container utils", func() {
+ Describe("ShortID", func() {
+ When("given a normal image ID", func() {
+ When("it contains a sha256 prefix", func() {
+ It("should return that ID in short version", func() {
+ actual := shortID("sha256:0123456789abcd00000000001111111111222222222233333333334444444444")
+ Expect(actual).To(Equal("0123456789ab"))
+ })
+ })
+ When("it doesn't contain a prefix", func() {
+ It("should return that ID in short version", func() {
+ actual := shortID("0123456789abcd00000000001111111111222222222233333333334444444444")
+ Expect(actual).To(Equal("0123456789ab"))
+ })
+ })
+ })
+ When("given a short image ID", func() {
+ When("it contains no prefix", func() {
+ It("should return the same string", func() {
+ Expect(shortID("0123456789ab")).To(Equal("0123456789ab"))
+ })
+ })
+ When("it contains a the sha256 prefix", func() {
+ It("should return the ID without the prefix", func() {
+ Expect(shortID("sha256:0123456789ab")).To(Equal("0123456789ab"))
+ })
+ })
+ })
+ When("given an ID with an unknown prefix", func() {
+ It("should return a short version of that ID including the prefix", func() {
+ Expect(shortID("md5:0123456789ab")).To(Equal("md5:0123456789ab"))
+ Expect(shortID("md5:0123456789abcdefg")).To(Equal("md5:0123456789ab"))
+ Expect(shortID("md5:01")).To(Equal("md5:01"))
+ })
+ })
+ })
+})
+
+func shortID(id string) string {
+ // Proxy to the types implementation, relocated due to package dependency resolution
+ return wt.ImageID(id).ShortID()
+}
diff --git a/pkg/filters/filters.go b/pkg/filters/filters.go
index b923745..4fa0bcd 100644
--- a/pkg/filters/filters.go
+++ b/pkg/filters/filters.go
@@ -1,6 +1,11 @@
package filters
-import t "github.com/containrrr/watchtower/pkg/types"
+import (
+ "regexp"
+ "strings"
+
+ t "github.com/containrrr/watchtower/pkg/types"
+)
// WatchtowerContainersFilter filters only watchtower containers
func WatchtowerContainersFilter(c t.FilterableContainer) bool { return c.IsWatchtower() }
@@ -8,7 +13,7 @@ func WatchtowerContainersFilter(c t.FilterableContainer) bool { return c.IsWatch
// NoFilter will not filter out any containers
func NoFilter(t.FilterableContainer) bool { return true }
-// FilterByNames returns all containers that match the specified name
+// FilterByNames returns all containers that match one of the specified names
func FilterByNames(names []string, baseFilter t.Filter) t.Filter {
if len(names) == 0 {
return baseFilter
@@ -16,14 +21,42 @@ func FilterByNames(names []string, baseFilter t.Filter) t.Filter {
return func(c t.FilterableContainer) bool {
for _, name := range names {
- if (name == c.Name()) || (name == c.Name()[1:]) {
+ if name == c.Name() || name == c.Name()[1:] {
return baseFilter(c)
}
+
+ if re, err := regexp.Compile(name); err == nil {
+ indices := re.FindStringIndex(c.Name())
+ if indices == nil {
+ continue
+ }
+ start := indices[0]
+ end := indices[1]
+ if start <= 1 && end >= len(c.Name())-1 {
+ return baseFilter(c)
+ }
+ }
}
return false
}
}
+// FilterByDisableNames returns all containers that don't match any of the specified names
+func FilterByDisableNames(disableNames []string, baseFilter t.Filter) t.Filter {
+ if len(disableNames) == 0 {
+ return baseFilter
+ }
+
+ return func(c t.FilterableContainer) bool {
+ for _, name := range disableNames {
+ if name == c.Name() || name == c.Name()[1:] {
+ return false
+ }
+ }
+ return baseFilter(c)
+ }
+}
+
// FilterByEnableLabel returns all containers that have the enabled label set
func FilterByEnableLabel(baseFilter t.Filter) t.Filter {
return func(c t.FilterableContainer) bool {
@@ -51,15 +84,98 @@ 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 {
+ return func(c t.FilterableContainer) bool {
+ containerScope, containerHasScope := c.Scope()
+
+ if !containerHasScope || containerScope == "" {
+ containerScope = "none"
+ }
+
+ if containerScope == scope {
+ return baseFilter(c)
+ }
+
+ return false
+ }
+}
+
+// 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) t.Filter {
+func BuildFilter(names []string, disableNames []string, enableLabel bool, scope string) (t.Filter, string) {
+ sb := strings.Builder{}
filter := NoFilter
filter = FilterByNames(names, filter)
+ filter = FilterByDisableNames(disableNames, filter)
+
+ if len(names) > 0 {
+ sb.WriteString("which name matches \"")
+ for i, n := range names {
+ sb.WriteString(n)
+ if i < len(names)-1 {
+ sb.WriteString(`" or "`)
+ }
+ }
+ sb.WriteString(`", `)
+ }
+ if len(disableNames) > 0 {
+ sb.WriteString("not named one of \"")
+ for i, n := range disableNames {
+ sb.WriteString(n)
+ if i < len(disableNames)-1 {
+ sb.WriteString(`" or "`)
+ }
+ }
+ sb.WriteString(`", `)
+ }
+
if enableLabel {
// If label filtering is enabled, containers should only be considered
// if the label is specifically set.
filter = FilterByEnableLabel(filter)
+ sb.WriteString("using enable label, ")
+ }
+
+ if scope == "none" {
+ // If a scope has explicitly defined as "none", containers should only be considered
+ // if they do not have a scope defined, or if it's explicitly set to "none".
+ filter = FilterByScope(scope, filter)
+ sb.WriteString(`without a scope, "`)
+ } else if scope != "" {
+ // If a scope has been defined, containers should only be considered
+ // if the scope is specifically set.
+ filter = FilterByScope(scope, filter)
+ sb.WriteString(`in scope "`)
+ sb.WriteString(scope)
+ sb.WriteString(`", `)
}
filter = FilterByDisabledLabel(filter)
- return filter
+
+ filterDesc := "Checking all containers (except explicitly disabled with label)"
+ if sb.Len() > 0 {
+ filterDesc = "Only checking containers " + sb.String()
+
+ // Remove the last ", "
+ filterDesc = filterDesc[:len(filterDesc)-2]
+ }
+
+ return filter, filterDesc
}
diff --git a/pkg/filters/filters_test.go b/pkg/filters/filters_test.go
index d24b186..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)
@@ -67,6 +89,75 @@ func TestFilterByEnableLabel(t *testing.T) {
container.AssertExpectations(t)
}
+func TestFilterByScope(t *testing.T) {
+ scope := "testscope"
+
+ filter := FilterByScope(scope, NoFilter)
+ assert.NotNil(t, filter)
+
+ container := new(mocks.FilterableContainer)
+ container.On("Scope").Return("testscope", true)
+ assert.True(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Scope").Return("nottestscope", true)
+ assert.False(t, filter(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("Scope").Return("", false)
+ assert.False(t, filter(container))
+ 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)
@@ -87,11 +178,50 @@ func TestFilterByDisabledLabel(t *testing.T) {
container.AssertExpectations(t)
}
-func TestBuildFilter(t *testing.T) {
- var names []string
- names = append(names, "test")
+func TestFilterByImage(t *testing.T) {
+ filterEmpty := FilterByImage(nil, NoFilter)
+ filterSingle := FilterByImage([]string{"registry"}, NoFilter)
+ filterMultiple := FilterByImage([]string{"registry", "bla"}, NoFilter)
+ assert.NotNil(t, filterSingle)
+ assert.NotNil(t, filterMultiple)
- filter := BuildFilter(names, false)
+ container := new(mocks.FilterableContainer)
+ container.On("ImageName").Return("registry:2")
+ assert.True(t, filterEmpty(container))
+ assert.True(t, filterSingle(container))
+ assert.True(t, filterMultiple(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("ImageName").Return("registry:latest")
+ assert.True(t, filterEmpty(container))
+ assert.True(t, filterSingle(container))
+ assert.True(t, filterMultiple(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("ImageName").Return("abcdef1234")
+ assert.True(t, filterEmpty(container))
+ assert.False(t, filterSingle(container))
+ assert.False(t, filterMultiple(container))
+ container.AssertExpectations(t)
+
+ container = new(mocks.FilterableContainer)
+ container.On("ImageName").Return("bla:latest")
+ assert.True(t, filterEmpty(container))
+ assert.False(t, filterSingle(container))
+ assert.True(t, filterMultiple(container))
+ container.AssertExpectations(t)
+
+}
+
+func TestBuildFilter(t *testing.T) {
+ names := []string{"test", "valid"}
+
+ filter, desc := BuildFilter(names, []string{}, false, "")
+ assert.Contains(t, desc, "test")
+ assert.Contains(t, desc, "or")
+ assert.Contains(t, desc, "valid")
container := new(mocks.FilterableContainer)
container.On("Name").Return("Invalid")
@@ -127,7 +257,8 @@ func TestBuildFilterEnableLabel(t *testing.T) {
var names []string
names = append(names, "test")
- filter := BuildFilter(names, true)
+ filter, desc := BuildFilter(names, []string{}, true, "")
+ assert.Contains(t, desc, "using enable label")
container := new(mocks.FilterableContainer)
container.On("Enabled").Return(false, false)
@@ -151,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 9823f9d..c0f962e 100644
--- a/pkg/lifecycle/lifecycle.go
+++ b/pkg/lifecycle/lifecycle.go
@@ -12,8 +12,8 @@ func ExecutePreChecks(client container.Client, params types.UpdateParams) {
if err != nil {
return
}
- for _, container := range containers {
- ExecutePreCheckCommand(client, container)
+ for _, currentContainer := range containers {
+ ExecutePreCheckCommand(client, currentContainer)
}
}
@@ -23,71 +23,84 @@ func ExecutePostChecks(client container.Client, params types.UpdateParams) {
if err != nil {
return
}
- for _, container := range containers {
- ExecutePostCheckCommand(client, container)
+ for _, currentContainer := range containers {
+ ExecutePostCheckCommand(client, currentContainer)
}
}
// ExecutePreCheckCommand tries to run the pre-check lifecycle hook for a single container.
-func ExecutePreCheckCommand(client container.Client, container container.Container) {
+func ExecutePreCheckCommand(client container.Client, container types.Container) {
+ clog := log.WithField("container", container.Name())
command := container.GetLifecyclePreCheckCommand()
if len(command) == 0 {
- log.Debug("No pre-check command supplied. Skipping")
+ clog.Debug("No pre-check command supplied. Skipping")
return
}
- log.Info("Executing pre-check command.")
- if err := client.ExecuteCommand(container.ID(), command); err != nil {
- log.Error(err)
+ clog.Debug("Executing pre-check command.")
+ _, err := client.ExecuteCommand(container.ID(), command, 1)
+ if err != nil {
+ clog.Error(err)
}
}
// ExecutePostCheckCommand tries to run the post-check lifecycle hook for a single container.
-func ExecutePostCheckCommand(client container.Client, container container.Container) {
+func ExecutePostCheckCommand(client container.Client, container types.Container) {
+ clog := log.WithField("container", container.Name())
command := container.GetLifecyclePostCheckCommand()
if len(command) == 0 {
- log.Debug("No post-check command supplied. Skipping")
+ clog.Debug("No post-check command supplied. Skipping")
return
}
- log.Info("Executing post-check command.")
- if err := client.ExecuteCommand(container.ID(), command); err != nil {
- log.Error(err)
+ clog.Debug("Executing post-check command.")
+ _, err := client.ExecuteCommand(container.ID(), command, 1)
+ if err != nil {
+ clog.Error(err)
}
}
// ExecutePreUpdateCommand tries to run the pre-update lifecycle hook for a single container.
-func ExecutePreUpdateCommand(client container.Client, container container.Container) {
-
+func ExecutePreUpdateCommand(client container.Client, container types.Container) (SkipUpdate bool, err error) {
+ timeout := container.PreUpdateTimeout()
command := container.GetLifecyclePreUpdateCommand()
+ clog := log.WithField("container", container.Name())
+
if len(command) == 0 {
- log.Debug("No pre-update command supplied. Skipping")
- return
+ clog.Debug("No pre-update command supplied. Skipping")
+ return false, nil
}
- log.Info("Executing pre-update command.")
- if err := client.ExecuteCommand(container.ID(), command); err != nil {
- log.Error(err)
+ if !container.IsRunning() || container.IsRestarting() {
+ clog.Debug("Container is not running. Skipping pre-update command.")
+ return false, nil
}
+
+ clog.Debug("Executing pre-update command.")
+ return client.ExecuteCommand(container.ID(), command, timeout)
}
// ExecutePostUpdateCommand tries to run the post-update lifecycle hook for a single container.
-func ExecutePostUpdateCommand(client container.Client, newContainerID string) {
+func ExecutePostUpdateCommand(client container.Client, newContainerID types.ContainerID) {
newContainer, err := client.GetContainer(newContainerID)
+ timeout := newContainer.PostUpdateTimeout()
+
if err != nil {
- log.Error(err)
+ log.WithField("containerID", newContainerID.ShortID()).Error(err)
return
}
+ clog := log.WithField("container", newContainer.Name())
command := newContainer.GetLifecyclePostUpdateCommand()
if len(command) == 0 {
- log.Debug("No post-update command supplied. Skipping")
+ clog.Debug("No post-update command supplied. Skipping")
return
}
- log.Info("Executing post-update command.")
- if err := client.ExecuteCommand(newContainerID, command); err != nil {
- log.Error(err)
+ clog.Debug("Executing post-update command.")
+ _, err = client.ExecuteCommand(newContainerID, command, timeout)
+
+ if err != nil {
+ clog.Error(err)
}
}
-
diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go
new file mode 100644
index 0000000..b681733
--- /dev/null
+++ b/pkg/metrics/metrics.go
@@ -0,0 +1,107 @@
+package metrics
+
+import (
+ "github.com/containrrr/watchtower/pkg/types"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+var metrics *Metrics
+
+// Metric is the data points of a single scan
+type Metric struct {
+ Scanned int
+ Updated int
+ Failed int
+}
+
+// Metrics is the handler processing all individual scan metrics
+type Metrics struct {
+ channel chan *Metric
+ scanned prometheus.Gauge
+ updated prometheus.Gauge
+ failed prometheus.Gauge
+ total prometheus.Counter
+ skipped prometheus.Counter
+}
+
+// NewMetric returns a Metric with the counts taken from the appropriate types.Report fields
+func NewMetric(report types.Report) *Metric {
+ return &Metric{
+ Scanned: len(report.Scanned()),
+ // Note: This is for backwards compatibility. ideally, stale containers should be counted separately
+ Updated: len(report.Updated()) + len(report.Stale()),
+ Failed: len(report.Failed()),
+ }
+}
+
+// QueueIsEmpty checks whether any messages are enqueued in the channel
+func (metrics *Metrics) QueueIsEmpty() bool {
+ return len(metrics.channel) == 0
+}
+
+// Register registers metrics for an executed scan
+func (metrics *Metrics) Register(metric *Metric) {
+ metrics.channel <- metric
+}
+
+// Default creates a new metrics handler if none exists, otherwise returns the existing one
+func Default() *Metrics {
+ if metrics != nil {
+ return metrics
+ }
+
+ metrics = &Metrics{
+ scanned: promauto.NewGauge(prometheus.GaugeOpts{
+ Name: "watchtower_containers_scanned",
+ Help: "Number of containers scanned for changes by watchtower during the last scan",
+ }),
+ updated: promauto.NewGauge(prometheus.GaugeOpts{
+ Name: "watchtower_containers_updated",
+ Help: "Number of containers updated by watchtower during the last scan",
+ }),
+ failed: promauto.NewGauge(prometheus.GaugeOpts{
+ Name: "watchtower_containers_failed",
+ Help: "Number of containers where update failed during the last scan",
+ }),
+ total: promauto.NewCounter(prometheus.CounterOpts{
+ Name: "watchtower_scans_total",
+ Help: "Number of scans since the watchtower started",
+ }),
+ skipped: promauto.NewCounter(prometheus.CounterOpts{
+ Name: "watchtower_scans_skipped",
+ Help: "Number of skipped scans since watchtower started",
+ }),
+ channel: make(chan *Metric, 10),
+ }
+
+ go metrics.HandleUpdate(metrics.channel)
+
+ return metrics
+}
+
+// RegisterScan fetches a metric handler and enqueues a metric
+func RegisterScan(metric *Metric) {
+ metrics := Default()
+ metrics.Register(metric)
+}
+
+// HandleUpdate dequeue the metric channel and processes it
+func (metrics *Metrics) HandleUpdate(channel <-chan *Metric) {
+ for change := range channel {
+ if change == nil {
+ // Update was skipped and rescheduled
+ metrics.total.Inc()
+ metrics.skipped.Inc()
+ metrics.scanned.Set(0)
+ metrics.updated.Set(0)
+ metrics.failed.Set(0)
+ continue
+ }
+ // Update metrics with the new values
+ metrics.total.Inc()
+ metrics.scanned.Set(float64(change.Scanned))
+ metrics.updated.Set(float64(change.Updated))
+ metrics.failed.Set(float64(change.Failed))
+ }
+}
diff --git a/pkg/notifications/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 fe4ca03..9103d38 100644
--- a/pkg/notifications/email.go
+++ b/pkg/notifications/email.go
@@ -1,40 +1,30 @@
package notifications
import (
- "encoding/base64"
- "fmt"
- "github.com/spf13/cobra"
- "net/smtp"
- "os"
- "strings"
"time"
+ "github.com/spf13/cobra"
+
+ shoutrrrSmtp "github.com/containrrr/shoutrrr/pkg/services/smtp"
t "github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
- "strconv"
)
const (
emailType = "email"
)
-// Implements Notifier, logrus.Hook
-// The default logrus email integration would have several issues:
-// - It would send one email per log output
-// - It would only send errors
-// We work around that by holding on to log entries until the update cycle is done.
type emailTypeNotifier struct {
- 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.Notifier {
- flags := c.PersistentFlags()
+func newEmailNotifier(c *cobra.Command) t.ConvertibleNotifier {
+ flags := c.Flags()
from, _ := flags.GetString("notification-email-from")
to, _ := flags.GetString("notification-email-to")
@@ -44,9 +34,9 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie
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{},
From: from,
To: to,
Server: server,
@@ -54,103 +44,39 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie
Password: password,
Port: port,
tlsSkipVerify: tlsSkipVerify,
- logLevels: acceptedLogLevels,
delay: time.Duration(delay) * time.Second,
- SubjectTag: subjecttag,
}
- log.AddHook(n)
-
return n
}
-func (e *emailTypeNotifier) buildMessage(entries []*log.Entry) []byte {
- var emailSubject string
-
- if e.SubjectTag == "" {
- emailSubject = "Watchtower updates"
- } else {
- emailSubject = e.SubjectTag + " Watchtower updates"
- }
- if hostname, err := os.Hostname(); err == nil {
- emailSubject += " on " + hostname
- }
- body := ""
- for _, entry := range entries {
- body += entry.Time.Format("2006-01-02 15:04:05") + " (" + entry.Level.String() + "): " + entry.Message + "\r\n"
- // We don't use fields in watchtower, so don't bother sending them.
+func (e *emailTypeNotifier) GetURL(c *cobra.Command) (string, error) {
+ conf := &shoutrrrSmtp.Config{
+ FromAddress: e.From,
+ FromName: "Watchtower",
+ ToAddresses: []string{e.To},
+ Port: uint16(e.Port),
+ Host: e.Server,
+ Username: e.User,
+ Password: e.Password,
+ UseStartTLS: !e.tlsSkipVerify,
+ UseHTML: false,
+ Encryption: shoutrrrSmtp.EncMethods.Auto,
+ Auth: shoutrrrSmtp.AuthTypes.None,
+ ClientHost: "localhost",
}
- t := time.Now()
-
- header := make(map[string]string)
- header["From"] = e.From
- header["To"] = e.To
- header["Subject"] = emailSubject
- header["Date"] = t.Format(time.RFC1123Z)
- header["MIME-Version"] = "1.0"
- header["Content-Type"] = "text/plain; charset=\"utf-8\""
- header["Content-Transfer-Encoding"] = "base64"
-
- message := ""
- for k, v := range header {
- message += fmt.Sprintf("%s: %s\r\n", k, v)
+ if len(e.User) > 0 {
+ conf.Auth = shoutrrrSmtp.AuthTypes.Plain
}
- encodedBody := base64.StdEncoding.EncodeToString([]byte(body))
- //RFC 2045 base64 encoding demands line no longer than 76 characters.
- for _, line := range SplitSubN(encodedBody, 76) {
- message += "\r\n" + line
+ if e.tlsSkipVerify {
+ conf.Encryption = shoutrrrSmtp.EncMethods.None
}
- return []byte(message)
+ return conf.GetURL().String(), nil
}
-func (e *emailTypeNotifier) sendEntries(entries []*log.Entry) {
- // Do the sending in a separate goroutine so we don't block the main process.
- msg := e.buildMessage(entries)
- go func() {
- var auth smtp.Auth
- if e.User != "" {
- auth = smtp.PlainAuth("", e.User, e.Password, e.Server)
- }
- err := SendMail(e.Server+":"+strconv.Itoa(e.Port), e.tlsSkipVerify, auth, e.From, strings.Split(e.To, ","), msg)
- if err != nil {
- // Use fmt so it doesn't trigger another email.
- fmt.Println("Failed to send notification email: ", err)
- }
- }()
-}
-
-func (e *emailTypeNotifier) StartNotification() {
- if e.entries == nil {
- e.entries = make([]*log.Entry, 0, 10)
- }
-}
-
-func (e *emailTypeNotifier) SendNotification() {
- if e.entries == nil || len(e.entries) <= 0 {
- return
- }
-
- if e.delay > 0 {
- time.Sleep(e.delay)
- }
-
- e.sendEntries(e.entries)
- e.entries = nil
-}
-
-func (e *emailTypeNotifier) Levels() []log.Level {
- return e.logLevels
-}
-
-func (e *emailTypeNotifier) Fire(entry *log.Entry) error {
- if e.entries != nil {
- e.entries = append(e.entries, entry)
- } else {
- // Log output generated outside a cycle is sent immediately.
- e.sendEntries([]*log.Entry{entry})
- }
- return nil
+func (e *emailTypeNotifier) GetDelay() time.Duration {
+ return e.delay
}
diff --git a/pkg/notifications/gotify.go b/pkg/notifications/gotify.go
index 3a4a539..c36eb4b 100644
--- a/pkg/notifications/gotify.go
+++ b/pkg/notifications/gotify.go
@@ -1,15 +1,14 @@
package notifications
import (
- "bytes"
- "encoding/json"
- "fmt"
- "net/http"
+ "net/url"
"strings"
+ shoutrrrGotify "github.com/containrrr/shoutrrr/pkg/services/gotify"
t "github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
+ "github.com/spf13/pflag"
)
const (
@@ -17,15 +16,39 @@ const (
)
type gotifyTypeNotifier struct {
- gotifyURL string
- gotifyAppToken string
- logLevels []log.Level
+ gotifyURL string
+ gotifyAppToken string
+ gotifyInsecureSkipVerify bool
}
-func newGotifyNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
- flags := c.PersistentFlags()
+func newGotifyNotifier(c *cobra.Command) t.ConvertibleNotifier {
+ flags := c.Flags()
+ apiURL := getGotifyURL(flags)
+ token := getGotifyToken(flags)
+
+ skipVerify, _ := flags.GetBool("notification-gotify-tls-skip-verify")
+
+ n := &gotifyTypeNotifier{
+ gotifyURL: apiURL,
+ gotifyAppToken: token,
+ gotifyInsecureSkipVerify: skipVerify,
+ }
+
+ return n
+}
+
+func getGotifyToken(flags *pflag.FlagSet) string {
+ gotifyToken, _ := flags.GetString("notification-gotify-token")
+ if len(gotifyToken) < 1 {
+ log.Fatal("Required argument --notification-gotify-token(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN(env) is empty.")
+ }
+ return gotifyToken
+}
+
+func getGotifyURL(flags *pflag.FlagSet) string {
gotifyURL, _ := flags.GetString("notification-gotify-url")
+
if len(gotifyURL) < 1 {
log.Fatal("Required argument --notification-gotify-url(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_URL(env) is empty.")
} else if !(strings.HasPrefix(gotifyURL, "http://") || strings.HasPrefix(gotifyURL, "https://")) {
@@ -34,69 +57,21 @@ func newGotifyNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifi
log.Warn("Using an HTTP url for Gotify is insecure")
}
- gotifyToken, _ := flags.GetString("notification-gotify-token")
- if len(gotifyToken) < 1 {
- log.Fatal("Required argument --notification-gotify-token(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN(env) is empty.")
+ return gotifyURL
+}
+
+func (n *gotifyTypeNotifier) GetURL(c *cobra.Command) (string, error) {
+ apiURL, err := url.Parse(n.gotifyURL)
+ if err != nil {
+ return "", err
}
- n := &gotifyTypeNotifier{
- gotifyURL: gotifyURL,
- gotifyAppToken: gotifyToken,
- logLevels: acceptedLogLevels,
+ config := &shoutrrrGotify.Config{
+ Host: apiURL.Host,
+ Path: apiURL.Path,
+ DisableTLS: apiURL.Scheme == "http",
+ Token: n.gotifyAppToken,
}
- log.AddHook(n)
-
- return n
-}
-
-func (n *gotifyTypeNotifier) StartNotification() {}
-
-func (n *gotifyTypeNotifier) SendNotification() {}
-
-func (n *gotifyTypeNotifier) Levels() []log.Level {
- return n.logLevels
-}
-
-func (n *gotifyTypeNotifier) getURL() string {
- url := n.gotifyURL
- if !strings.HasSuffix(url, "/") {
- url += "/"
- }
- return url + "message?token=" + n.gotifyAppToken
-}
-
-func (n *gotifyTypeNotifier) Fire(entry *log.Entry) error {
-
- go func() {
- jsonBody, err := json.Marshal(gotifyMessage{
- Message: "(" + entry.Level.String() + "): " + entry.Message,
- Title: "Watchtower",
- Priority: 0,
- })
- if err != nil {
- fmt.Println("Failed to create JSON body for Gotify notification: ", err)
- return
- }
-
- jsonBodyBuffer := bytes.NewBuffer([]byte(jsonBody))
- resp, err := http.Post(n.getURL(), "application/json", jsonBodyBuffer)
- if err != nil {
- fmt.Println("Failed to send Gotify notification: ", err)
- return
- }
- defer resp.Body.Close()
-
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- fmt.Printf("Gotify notification returned %d HTTP status code", resp.StatusCode)
- }
-
- }()
- return nil
-}
-
-type gotifyMessage struct {
- Message string `json:"message"`
- Title string `json:"title"`
- Priority int `json:"priority"`
+ return config.GetURL().String(), nil
}
diff --git a/pkg/notifications/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 b356814..cfca30e 100644
--- a/pkg/notifications/msteams.go
+++ b/pkg/notifications/msteams.go
@@ -1,15 +1,12 @@
package notifications
import (
- "bytes"
- "encoding/json"
- "fmt"
- "github.com/spf13/cobra"
- "net/http"
+ "net/url"
+ shoutrrrTeams "github.com/containrrr/shoutrrr/pkg/services/teams"
t "github.com/containrrr/watchtower/pkg/types"
log "github.com/sirupsen/logrus"
- "io/ioutil"
+ "github.com/spf13/cobra"
)
const (
@@ -18,13 +15,12 @@ const (
type msTeamsTypeNotifier struct {
webHookURL string
- levels []log.Level
data bool
}
-func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
+func newMsTeamsNotifier(cmd *cobra.Command) t.ConvertibleNotifier {
- flags := cmd.PersistentFlags()
+ flags := cmd.Flags()
webHookURL, _ := flags.GetString("notification-msteams-hook")
if len(webHookURL) <= 0 {
@@ -33,106 +29,25 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Not
withData, _ := flags.GetBool("notification-msteams-data")
n := &msTeamsTypeNotifier{
- levels: acceptedLogLevels,
webHookURL: webHookURL,
data: withData,
}
- log.AddHook(n)
-
return n
}
-func (n *msTeamsTypeNotifier) StartNotification() {}
+func (n *msTeamsTypeNotifier) GetURL(c *cobra.Command) (string, error) {
+ webhookURL, err := url.Parse(n.webHookURL)
+ if err != nil {
+ return "", err
+ }
-func (n *msTeamsTypeNotifier) SendNotification() {}
+ config, err := shoutrrrTeams.ConfigFromWebhookURL(*webhookURL)
+ if err != nil {
+ return "", err
+ }
-func (n *msTeamsTypeNotifier) Levels() []log.Level {
- return n.levels
-}
-
-func (n *msTeamsTypeNotifier) Fire(entry *log.Entry) error {
-
- message := "(" + entry.Level.String() + "): " + entry.Message
-
- go func() {
- webHookBody := messageCard{
- CardType: "MessageCard",
- Context: "http://schema.org/extensions",
- Markdown: true,
- Text: message,
- }
-
- if n.data && entry.Data != nil && len(entry.Data) > 0 {
- section := messageCardSection{
- Facts: make([]messageCardSectionFact, len(entry.Data)),
- Text: "",
- }
-
- index := 0
- for k, v := range entry.Data {
- section.Facts[index] = messageCardSectionFact{
- Name: k,
- Value: fmt.Sprint(v),
- }
- index++
- }
-
- webHookBody.Sections = []messageCardSection{section}
- }
-
- jsonBody, err := json.Marshal(webHookBody)
- if err != nil {
- fmt.Println("Failed to build JSON body for MSTeams notificattion: ", err)
- return
- }
-
- resp, err := http.Post(n.webHookURL, "application/json", bytes.NewBuffer([]byte(jsonBody)))
- if err != nil {
- fmt.Println("Failed to send MSTeams notificattion: ", err)
- }
-
- defer resp.Body.Close()
-
- if resp.StatusCode < 200 || resp.StatusCode > 299 {
- fmt.Println("Failed to send MSTeams notificattion. HTTP RESPONSE STATUS: ", resp.StatusCode)
- if resp.Body != nil {
- bodyBytes, err := ioutil.ReadAll(resp.Body)
- if err == nil {
- bodyString := string(bodyBytes)
- fmt.Println(bodyString)
- }
- }
- }
- }()
-
- return nil
-}
-
-type messageCard struct {
- CardType string `json:"@type"`
- Context string `json:"@context"`
- CorrelationID string `json:"correlationId,omitempty"`
- ThemeColor string `json:"themeColor,omitempty"`
- Summary string `json:"summary,omitempty"`
- Title string `json:"title,omitempty"`
- Text string `json:"text,omitempty"`
- Markdown bool `json:"markdown,bool"`
- Sections []messageCardSection `json:"sections,omitempty"`
-}
-
-type messageCardSection struct {
- Title string `json:"title,omitempty"`
- Text string `json:"text,omitempty"`
- ActivityTitle string `json:"activityTitle,omitempty"`
- ActivitySubtitle string `json:"activitySubtitle,omitempty"`
- ActivityImage string `json:"activityImage,omitempty"`
- ActivityText string `json:"activityText,omitempty"`
- HeroImage string `json:"heroImage,omitempty"`
- Facts []messageCardSectionFact `json:"facts,omitempty"`
-}
-
-type messageCardSectionFact struct {
- Name string `json:"name,omitempty"`
- Value string `json:"value,omitempty"`
+ config.Color = ColorHex
+
+ return config.GetURL().String(), nil
}
diff --git a/pkg/notifications/notifications_suite_test.go b/pkg/notifications/notifications_suite_test.go
new file mode 100644
index 0000000..19d286e
--- /dev/null
+++ b/pkg/notifications/notifications_suite_test.go
@@ -0,0 +1,15 @@
+package notifications_test
+
+import (
+ "github.com/onsi/gomega/format"
+ "testing"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+func TestNotifications(t *testing.T) {
+ RegisterFailHandler(Fail)
+ format.CharactersAroundMismatchToInclude = 20
+ RunSpecs(t, "Notifications Suite")
+}
diff --git a/pkg/notifications/notifier.go b/pkg/notifications/notifier.go
index 2f25824..ff7b6b5 100644
--- a/pkg/notifications/notifier.go
+++ b/pkg/notifications/notifier.go
@@ -1,22 +1,18 @@
package notifications
import (
+ "os"
+ "strings"
+ "time"
+
ty "github.com/containrrr/watchtower/pkg/types"
- "github.com/johntdyer/slackrus"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
-// Notifier can send log output as notification to admins, with optional batching.
-type Notifier struct {
- types []ty.Notifier
-}
-
// NewNotifier creates and returns a new Notifier, using global configuration.
-func NewNotifier(c *cobra.Command) *Notifier {
- n := &Notifier{}
-
- f := c.PersistentFlags()
+func NewNotifier(c *cobra.Command) ty.Notifier {
+ f := c.Flags()
level, _ := f.GetString("notifications-level")
logLevel, err := log.ParseLevel(level)
@@ -24,41 +20,128 @@ func NewNotifier(c *cobra.Command) *Notifier {
log.Fatalf("Notifications invalid log level: %s", err.Error())
}
- acceptedLogLevels := slackrus.LevelThreshold(logLevel)
+ reportTemplate, _ := f.GetBool("notification-report")
+ stdout, _ := f.GetBool("notification-log-stdout")
+ tplString, _ := f.GetString("notification-template")
+ urls, _ := f.GetStringArray("notification-url")
+
+ data := GetTemplateData(c)
+ urls, delay := AppendLegacyUrls(urls, c)
+
+ 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) ([]string, time.Duration) {
// Parse types and create notifiers.
- types, _ := f.GetStringSlice("notifications")
+ types, err := cmd.Flags().GetStringSlice("notifications")
+ if err != nil {
+ log.WithError(err).Fatal("could not read notifications argument")
+ }
+
+ legacyDelay := time.Duration(0)
for _, t := range types {
- var tn ty.Notifier
+
+ var legacyNotifier ty.ConvertibleNotifier
+ var err error
+
switch t {
case emailType:
- tn = newEmailNotifier(c, acceptedLogLevels)
+ legacyNotifier = newEmailNotifier(cmd)
case slackType:
- tn = newSlackNotifier(c, acceptedLogLevels)
+ legacyNotifier = newSlackNotifier(cmd)
case msTeamsType:
- tn = newMsTeamsNotifier(c, acceptedLogLevels)
+ legacyNotifier = newMsTeamsNotifier(cmd)
case gotifyType:
- tn = newGotifyNotifier(c, acceptedLogLevels)
+ legacyNotifier = newGotifyNotifier(cmd)
+ case shoutrrrType:
+ continue
default:
log.Fatalf("Unknown notification type %q", t)
+ // Not really needed, used for nil checking static analysis
+ continue
}
- n.types = append(n.types, tn)
+
+ 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 {
+ legacyDelay = delayNotifier.GetDelay()
+ }
+
+ log.WithField("URL", shoutrrrURL).Trace("created Shoutrrr URL from legacy notifier")
}
- return n
+ delay := GetDelay(cmd, legacyDelay)
+ return urls, delay
}
-// StartNotification starts a log batch. Notifications will be accumulated after this point and only sent when SendNotification() is called.
-func (n *Notifier) StartNotification() {
- for _, t := range n.types {
- t.StartNotification()
+// GetDelay returns the legacy delay if defined, otherwise the delay as set by args is returned
+func GetDelay(c *cobra.Command, legacyDelay time.Duration) time.Duration {
+ if legacyDelay > 0 {
+ return legacyDelay
+ }
+
+ delay, _ := c.PersistentFlags().GetInt("notifications-delay")
+ if delay > 0 {
+ return time.Duration(delay) * time.Second
+ }
+ return time.Duration(0)
+}
+
+// GetTitle formats the title based on the passed hostname and tag
+func GetTitle(hostname string, tag string) string {
+ tb := strings.Builder{}
+
+ if tag != "" {
+ tb.WriteRune('[')
+ tb.WriteString(tag)
+ tb.WriteRune(']')
+ tb.WriteRune(' ')
+ }
+
+ tb.WriteString("Watchtower updates")
+
+ if hostname != "" {
+ tb.WriteString(" on ")
+ tb.WriteString(hostname)
+ }
+
+ return tb.String()
+}
+
+// GetTemplateData populates the static notification data from flags and environment
+func GetTemplateData(c *cobra.Command) StaticData {
+ f := c.PersistentFlags()
+
+ hostname, _ := f.GetString("notifications-hostname")
+ if hostname == "" {
+ hostname, _ = os.Hostname()
+ }
+
+ title := ""
+ if skip, _ := f.GetBool("notification-skip-title"); !skip {
+ tag, _ := f.GetString("notification-title-tag")
+ if tag == "" {
+ // For legacy email support
+ tag, _ = f.GetString("notification-email-subjecttag")
+ }
+ title = GetTitle(hostname, tag)
+ }
+
+ return StaticData{
+ Host: hostname,
+ Title: title,
}
}
-// SendNotification sends any notifications accumulated since StartNotification() was called.
-func (n *Notifier) SendNotification() {
- for _, t := range n.types {
- t.SendNotification()
- }
-}
+// ColorHex is the default notification color used for services that support it (formatted as a CSS hex string)
+const ColorHex = "#406170"
+
+// ColorInt is the default notification color used for services that support it (as an int value)
+const ColorInt = 0x406170
diff --git a/pkg/notifications/notifier_test.go b/pkg/notifications/notifier_test.go
new file mode 100644
index 0000000..96d513c
--- /dev/null
+++ b/pkg/notifications/notifier_test.go
@@ -0,0 +1,377 @@
+package notifications_test
+
+import (
+ "fmt"
+ "net/url"
+ "time"
+
+ "github.com/containrrr/watchtower/cmd"
+ "github.com/containrrr/watchtower/internal/flags"
+ "github.com/containrrr/watchtower/pkg/notifications"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("notifications", func() {
+ Describe("the notifier", func() {
+ When("only empty notifier types are provided", func() {
+
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ err := command.ParseFlags([]string{
+ "--notifications",
+ "shoutrrr",
+ })
+ Expect(err).NotTo(HaveOccurred())
+ notif := notifications.NewNotifier(command)
+
+ Expect(notif.GetNames()).To(BeEmpty())
+ })
+ When("title is overriden in flag", func() {
+ It("should use the specified hostname in the title", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ err := command.ParseFlags([]string{
+ "--notifications-hostname",
+ "test.host",
+ })
+ Expect(err).NotTo(HaveOccurred())
+ data := notifications.GetTemplateData(command)
+ title := data.Title
+ Expect(title).To(Equal("Watchtower updates on test.host"))
+ })
+ })
+ When("no hostname can be resolved", func() {
+ It("should use the default simple title", func() {
+ title := notifications.GetTitle("", "")
+ Expect(title).To(Equal("Watchtower updates"))
+ })
+ })
+ When("title tag is set", func() {
+ It("should use the prefix in the title", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ Expect(command.ParseFlags([]string{
+ "--notification-title-tag",
+ "PREFIX",
+ })).To(Succeed())
+
+ data := notifications.GetTemplateData(command)
+ Expect(data.Title).To(HavePrefix("[PREFIX]"))
+ })
+ })
+ When("legacy email tag is set", func() {
+ It("should use the prefix in the title", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ Expect(command.ParseFlags([]string{
+ "--notification-email-subjecttag",
+ "PREFIX",
+ })).To(Succeed())
+
+ data := notifications.GetTemplateData(command)
+ Expect(data.Title).To(HavePrefix("[PREFIX]"))
+ })
+ })
+ When("the skip title flag is set", func() {
+ It("should return an empty title", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ Expect(command.ParseFlags([]string{
+ "--notification-skip-title",
+ })).To(Succeed())
+
+ data := notifications.GetTemplateData(command)
+ Expect(data.Title).To(BeEmpty())
+ })
+ })
+ When("no delay is defined", func() {
+ It("should use the default delay", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ delay := notifications.GetDelay(command, time.Duration(0))
+ Expect(delay).To(Equal(time.Duration(0)))
+ })
+ })
+ When("delay is defined", func() {
+ It("should use the specified delay", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ err := command.ParseFlags([]string{
+ "--notifications-delay",
+ "5",
+ })
+ Expect(err).NotTo(HaveOccurred())
+ delay := notifications.GetDelay(command, time.Duration(0))
+ Expect(delay).To(Equal(time.Duration(5) * time.Second))
+ })
+ })
+ When("legacy delay is defined", func() {
+ It("should use the specified legacy delay", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+ delay := notifications.GetDelay(command, time.Duration(5)*time.Second)
+ Expect(delay).To(Equal(time.Duration(5) * time.Second))
+ })
+ })
+ When("legacy delay and delay is defined", func() {
+ It("should use the specified legacy delay and ignore the specified delay", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ err := command.ParseFlags([]string{
+ "--notifications-delay",
+ "0",
+ })
+ Expect(err).NotTo(HaveOccurred())
+ delay := notifications.GetDelay(command, time.Duration(7)*time.Second)
+ Expect(delay).To(Equal(time.Duration(7) * time.Second))
+ })
+ })
+ })
+ Describe("the slack notifier", func() {
+ // builderFn := notifications.NewSlackNotifier
+
+ When("passing a discord url to the slack notifier", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ channel := "123456789"
+ token := "abvsihdbau"
+ color := notifications.ColorInt
+ username := "containrrrbot"
+ iconURL := "https://containrrr.dev/watchtower-sq180.png"
+ expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&username=watchtower", token, channel, color)
+ buildArgs := func(url string) []string {
+ return []string{
+ "--notifications",
+ "slack",
+ "--notification-slack-hook-url",
+ url,
+ }
+ }
+
+ It("should return a discord url when using a hook url with the domain discord.com", func() {
+ hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discord.com", channel, token)
+ testURL(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, time.Duration(0))
+ })
+ When("icon URL and username are specified", func() {
+ It("should return the expected URL", func() {
+ hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discord.com", channel, token)
+ expectedOutput := fmt.Sprintf("discord://%s@%s?avatar=%s&color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&username=%s", token, channel, url.QueryEscape(iconURL), color, username)
+ expectedDelay := time.Duration(7) * time.Second
+ args := []string{
+ "--notifications",
+ "slack",
+ "--notification-slack-hook-url",
+ hookURL,
+ "--notification-slack-identifier",
+ username,
+ "--notification-slack-icon-url",
+ iconURL,
+ "--notifications-delay",
+ fmt.Sprint(expectedDelay.Seconds()),
+ }
+
+ testURL(args, expectedOutput, expectedDelay)
+ })
+ })
+ })
+ When("converting a slack service config into a shoutrrr url", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+ username := "containrrrbot"
+ tokenA := "AAAAAAAAA"
+ tokenB := "BBBBBBBBB"
+ tokenC := "123456789123456789123456"
+ color := url.QueryEscape(notifications.ColorHex)
+ iconURL := "https://containrrr.dev/watchtower-sq180.png"
+ iconEmoji := "whale"
+
+ When("icon URL is specified", func() {
+ It("should return the expected URL", func() {
+
+ hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
+ expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s", tokenA, tokenB, tokenC, username, color, url.QueryEscape(iconURL))
+ 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("icon emoji is specified", func() {
+ It("should return the expected URL", func() {
+ hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
+ expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s", tokenA, tokenB, tokenC, username, color, iconEmoji)
+
+ args := []string{
+ "--notifications",
+ "slack",
+ "--notification-slack-hook-url",
+ hookURL,
+ "--notification-slack-identifier",
+ username,
+ "--notification-slack-icon-emoji",
+ iconEmoji,
+ }
+
+ testURL(args, expectedOutput, time.Duration(0))
+ })
+ })
+ })
+ })
+
+ Describe("the gotify notifier", func() {
+ When("converting a gotify service config into a shoutrrr url", func() {
+ It("should return the expected URL", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ token := "aaa"
+ host := "shoutrrr.local"
+
+ expectedOutput := fmt.Sprintf("gotify://%s/%s?title=", host, token)
+
+ args := []string{
+ "--notifications",
+ "gotify",
+ "--notification-gotify-url",
+ fmt.Sprintf("https://%s", host),
+ "--notification-gotify-token",
+ token,
+ }
+
+ testURL(args, expectedOutput, time.Duration(0))
+ })
+ })
+ })
+
+ Describe("the teams notifier", func() {
+ When("converting a teams service config into a shoutrrr url", func() {
+ It("should return the expected URL", func() {
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ tokenA := "11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc"
+ tokenB := "33333333012222222222333333333344"
+ tokenC := "44444444-4444-4444-8444-cccccccccccc"
+ color := url.QueryEscape(notifications.ColorHex)
+
+ hookURL := fmt.Sprintf("https://outlook.office.com/webhook/%s/IncomingWebhook/%s/%s", tokenA, tokenB, tokenC)
+ expectedOutput := fmt.Sprintf("teams://%s/%s/%s?color=%s", tokenA, tokenB, tokenC, color)
+
+ args := []string{
+ "--notifications",
+ "msteams",
+ "--notification-msteams-hook",
+ hookURL,
+ }
+
+ testURL(args, expectedOutput, time.Duration(0))
+ })
+ })
+ })
+
+ Describe("the email notifier", func() {
+ When("converting an email service config into a shoutrrr url", func() {
+ It("should set the from address in the URL", func() {
+ fromAddress := "lala@example.com"
+ expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, "mail@example.com", "Plain")
+ expectedDelay := time.Duration(7) * time.Second
+
+ args := []string{
+ "--notifications",
+ "email",
+ "--notification-email-from",
+ fromAddress,
+ "--notification-email-to",
+ "mail@example.com",
+ "--notification-email-server-user",
+ "containrrrbot",
+ "--notification-email-server-password",
+ "secret-password",
+ "--notification-email-server",
+ "mail.containrrr.dev",
+ "--notifications-delay",
+ fmt.Sprint(expectedDelay.Seconds()),
+ }
+ testURL(args, expectedOutput, expectedDelay)
+ })
+
+ It("should return the expected URL", func() {
+
+ fromAddress := "sender@example.com"
+ toAddress := "receiver@example.com"
+ expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, toAddress, "Plain")
+ expectedDelay := time.Duration(7) * time.Second
+
+ args := []string{
+ "--notifications",
+ "email",
+ "--notification-email-from",
+ fromAddress,
+ "--notification-email-to",
+ toAddress,
+ "--notification-email-server-user",
+ "containrrrbot",
+ "--notification-email-server-password",
+ "secret-password",
+ "--notification-email-server",
+ "mail.containrrr.dev",
+ "--notification-email-delay",
+ fmt.Sprint(expectedDelay.Seconds()),
+ }
+
+ testURL(args, expectedOutput, expectedDelay)
+ })
+ })
+ })
+})
+
+func buildExpectedURL(username string, password string, host string, port int, from string, to string, auth string) string {
+ var template = "smtp://%s:%s@%s:%d/?auth=%s&fromaddress=%s&fromname=Watchtower&subject=&toaddresses=%s"
+ return fmt.Sprintf(template,
+ url.QueryEscape(username),
+ url.QueryEscape(password),
+ host, port, auth,
+ url.QueryEscape(from),
+ url.QueryEscape(to))
+}
+
+func testURL(args []string, expectedURL string, expectedDelay time.Duration) {
+ defer GinkgoRecover()
+
+ command := cmd.NewRootCommand()
+ flags.RegisterNotificationFlags(command)
+
+ Expect(command.ParseFlags(args)).To(Succeed())
+
+ 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
new file mode 100644
index 0000000..cc3a931
--- /dev/null
+++ b/pkg/notifications/shoutrrr.go
@@ -0,0 +1,238 @@
+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"
+)
+
+// LocalLog is a logrus logger that does not send entries as notifications
+var LocalLog = log.WithField("notify", "no")
+
+const (
+ shoutrrrType = "shoutrrr"
+)
+
+type router interface {
+ Send(message string, params *types.Params) []error
+}
+
+// Implements Notifier, logrus.Hook
+type shoutrrrTypeNotifier struct {
+ Urls []string
+ Router router
+ entries []*log.Entry
+ logLevel log.Level
+ template *template.Template
+ messages chan string
+ done chan bool
+ legacyTemplate bool
+ params *types.Params
+ data StaticData
+ receiving bool
+ delay time.Duration
+}
+
+// GetScheme returns the scheme part of a Shoutrrr URL
+func GetScheme(url string) string {
+ schemeEnd := strings.Index(url, ":")
+ if schemeEnd <= 0 {
+ return "invalid"
+ }
+ return url[:schemeEnd]
+}
+
+// GetNames returns a list of notification services that has been added
+func (n *shoutrrrTypeNotifier) GetNames() []string {
+ names := make([]string, len(n.Urls))
+ for i, u := range n.Urls {
+ names[i] = GetScheme(u)
+ }
+ return names
+}
+
+// GetURLs returns a list of URLs for notification services that has been added
+func (n *shoutrrrTypeNotifier) GetURLs() []string {
+ return n.Urls
+}
+
+// AddLogHook adds the notifier as a receiver of log messages and starts a go func for processing them
+func (n *shoutrrrTypeNotifier) AddLogHook() {
+ if n.receiving {
+ return
+ }
+ n.receiving = true
+ log.AddHook(n)
+
+ // Do the sending in a separate goroutine, so we don't block the main process.
+ go sendNotifications(n)
+}
+
+func createNotifier(urls []string, level log.Level, tplString string, legacy bool, data StaticData, stdout bool, delay time.Duration) *shoutrrrTypeNotifier {
+ tpl, err := getShoutrrrTemplate(tplString, legacy)
+ if err != nil {
+ log.Errorf("Could not use configured notification template: %s. Using default template", err)
+ }
+
+ var logger types.StdLogger
+ if stdout {
+ logger = stdlog.New(os.Stdout, ``, 0)
+ } else {
+ logger = stdlog.New(log.StandardLogger().WriterLevel(log.TraceLevel), "Shoutrrr: ", 0)
+ }
+ r, err := shoutrrr.NewSender(logger, urls...)
+ if err != nil {
+ log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error())
+ }
+
+ 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),
+ logLevel: level,
+ template: tpl,
+ legacyTemplate: legacy,
+ data: data,
+ params: params,
+ delay: delay,
+ }
+}
+
+func sendNotifications(n *shoutrrrTypeNotifier) {
+ for msg := range n.messages {
+ time.Sleep(n.delay)
+ errs := n.Router.Send(msg, n.params)
+
+ for i, err := range errs {
+ if err != nil {
+ scheme := GetScheme(n.Urls[i])
+ // Use fmt so it doesn't trigger another notification.
+ LocalLog.WithFields(log.Fields{
+ "service": scheme,
+ "index": i,
+ }).WithError(err).Error("Failed to send shoutrrr notification")
+ }
+ }
+ }
+
+ n.done <- true
+}
+
+func (n *shoutrrrTypeNotifier) buildMessage(data Data) (string, error) {
+ var body bytes.Buffer
+ var templateData interface{} = data
+ if n.legacyTemplate {
+ templateData = data.Entries
+ }
+ if err := n.template.Execute(&body, templateData); err != nil {
+ return "", err
+ }
+
+ return body.String(), nil
+}
+
+func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry, report t.Report) {
+ msg, err := n.buildMessage(Data{n.data, entries, report})
+
+ if msg == "" {
+ // Log in go func in case we entered from Fire to avoid stalling
+ go func() {
+ if err != nil {
+ LocalLog.WithError(err).Fatal("Notification template error")
+ } else if len(n.Urls) > 1 {
+ LocalLog.Info("Skipping notification due to empty message")
+ }
+ }()
+ return
+ }
+ n.messages <- msg
+}
+
+// StartNotification begins queueing up messages to send them as a batch
+func (n *shoutrrrTypeNotifier) StartNotification() {
+ if n.entries == nil {
+ n.entries = make([]*log.Entry, 0, 10)
+ }
+}
+
+// SendNotification sends the queued up messages as a notification
+func (n *shoutrrrTypeNotifier) SendNotification(report t.Report) {
+ n.sendEntries(n.entries, report)
+ n.entries = nil
+}
+
+// Close prevents further messages from being queued and waits until all the currently queued up messages have been sent
+func (n *shoutrrrTypeNotifier) Close() {
+ close(n.messages)
+
+ // Use fmt so it doesn't trigger another notification.
+ LocalLog.Info("Waiting for the notification goroutine to finish")
+
+ <-n.done
+}
+
+// Levels return what log levels trigger notifications
+func (n *shoutrrrTypeNotifier) Levels() []log.Level {
+ return log.AllLevels[:n.logLevel+1]
+}
+
+// Fire is the hook that logrus calls on a new log message
+func (n *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
+ if entry.Data["notify"] == "no" {
+ // Skip logging if explicitly tagged as non-notify
+ return nil
+ }
+ if n.entries != nil {
+ n.entries = append(n.entries, entry)
+ } else {
+ // Log output generated outside a cycle is sent immediately.
+ n.sendEntries([]*log.Entry{entry}, nil)
+ }
+ return nil
+}
+
+func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template, err error) {
+
+ tplBase := template.New("").Funcs(templates.Funcs)
+
+ if builtin, found := commonTemplates[tplString]; found {
+ log.WithField(`template`, tplString).Debug(`Using common template`)
+ tplString = builtin
+ }
+
+ // If we succeed in getting a non-empty template configuration
+ // try to parse the template string.
+ if tplString != "" {
+ tpl, err = tplBase.Parse(tplString)
+ }
+
+ // If we had an error (either from parsing the template string
+ // 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 == "" {
+ defaultKey := `default`
+ if legacy {
+ defaultKey = `default-legacy`
+ }
+
+ tpl = template.Must(tplBase.Parse(commonTemplates[defaultKey]))
+ }
+
+ return
+}
diff --git a/pkg/notifications/shoutrrr_test.go b/pkg/notifications/shoutrrr_test.go
new file mode 100644
index 0000000..703958b
--- /dev/null
+++ b/pkg/notifications/shoutrrr_test.go
@@ -0,0 +1,381 @@
+package notifications
+
+import (
+ "time"
+
+ "github.com/containrrr/shoutrrr/pkg/types"
+ "github.com/containrrr/watchtower/internal/actions/mocks"
+ "github.com/containrrr/watchtower/internal/flags"
+ s "github.com/containrrr/watchtower/pkg/session"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+ "github.com/onsi/gomega/gbytes"
+ "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var allButTrace = logrus.DebugLevel
+
+var legacyMockData = Data{
+ Entries: []*logrus.Entry{
+ {
+ Level: logrus.InfoLevel,
+ Message: "foo Bar",
+ },
+ },
+}
+
+var mockDataMultipleEntries = Data{
+ Entries: []*logrus.Entry{
+ {
+ Level: logrus.InfoLevel,
+ Message: "The situation is under control",
+ },
+ {
+ Level: logrus.WarnLevel,
+ Message: "All the smoke might be covering up some problems",
+ },
+ {
+ Level: logrus.ErrorLevel,
+ Message: "Turns out everything is on fire",
+ },
+ },
+}
+
+var mockDataAllFresh = Data{
+ Entries: []*logrus.Entry{},
+ Report: mocks.CreateMockProgressReport(s.FreshState),
+}
+
+func mockDataFromStates(states ...s.State) Data {
+ hostname := "Mock"
+ prefix := ""
+ return Data{
+ Entries: legacyMockData.Entries,
+ Report: mocks.CreateMockProgressReport(states...),
+ StaticData: StaticData{
+ Title: GetTitle(hostname, prefix),
+ Host: hostname,
+ },
+ }
+}
+
+var _ = Describe("Shoutrrr", func() {
+ var logBuffer *gbytes.Buffer
+
+ BeforeEach(func() {
+ logBuffer = gbytes.NewBuffer()
+ logrus.SetOutput(logBuffer)
+ logrus.SetLevel(logrus.TraceLevel)
+ logrus.SetFormatter(&logrus.TextFormatter{
+ DisableColors: true,
+ DisableTimestamp: true,
+ })
+ })
+
+ When("passing a common template name", func() {
+ It("should format using that template", func() {
+ expected := `
+updt1 (mock/updt1:latest): Updated
+`[1:]
+ data := mockDataFromStates(s.UpdatedState)
+ Expect(getTemplatedResult(`porcelain.v1.summary-no-log`, false, data)).To(Equal(expected))
+ })
+ })
+
+ When("adding a log hook", func() {
+ When("it has not been added before", func() {
+ It("should be added to the logrus hooks", func() {
+ level := logrus.TraceLevel
+ hooksBefore := len(logrus.StandardLogger().Hooks[level])
+ shoutrrr := createNotifier([]string{}, level, "", true, StaticData{}, false, time.Second)
+ shoutrrr.AddLogHook()
+ hooksAfter := len(logrus.StandardLogger().Hooks[level])
+ Expect(hooksAfter).To(BeNumerically(">", hooksBefore))
+ })
+ })
+ When("it is being added a second time", func() {
+ It("should not be added to the logrus hooks", func() {
+ level := logrus.TraceLevel
+ shoutrrr := createNotifier([]string{}, level, "", true, StaticData{}, false, time.Second)
+ shoutrrr.AddLogHook()
+ hooksBefore := len(logrus.StandardLogger().Hooks[level])
+ shoutrrr.AddLogHook()
+ hooksAfter := len(logrus.StandardLogger().Hooks[level])
+ Expect(hooksAfter).To(Equal(hooksBefore))
+ })
+ })
+ })
+
+ When("using legacy templates", func() {
+
+ When("no custom template is provided", func() {
+ It("should format the messages using the default template", func() {
+ cmd := new(cobra.Command)
+ flags.RegisterNotificationFlags(cmd)
+
+ shoutrrr := createNotifier([]string{}, logrus.TraceLevel, "", true, StaticData{}, false, time.Second)
+
+ entries := []*logrus.Entry{
+ {
+ Message: "foo bar",
+ },
+ }
+
+ s, err := shoutrrr.buildMessage(Data{Entries: entries})
+ Expect(err).NotTo(HaveOccurred())
+
+ Expect(s).To(Equal("foo bar\n"))
+ })
+ })
+ When("given a valid custom template", func() {
+ It("should format the messages using the custom template", func() {
+
+ tplString := `{{range .}}{{.Level}}: {{.Message}}{{println}}{{end}}`
+ tpl, err := getShoutrrrTemplate(tplString, true)
+ Expect(err).ToNot(HaveOccurred())
+
+ shoutrrr := &shoutrrrTypeNotifier{
+ template: tpl,
+ legacyTemplate: true,
+ }
+
+ entries := []*logrus.Entry{
+ {
+ Level: logrus.InfoLevel,
+ Message: "foo bar",
+ },
+ }
+
+ s, err := shoutrrr.buildMessage(Data{Entries: entries})
+ Expect(err).NotTo(HaveOccurred())
+
+ Expect(s).To(Equal("info: foo bar\n"))
+ })
+ })
+
+ Describe("the default template", func() {
+ When("all containers are fresh", func() {
+ It("should return an empty string", func() {
+ Expect(getTemplatedResult(``, true, mockDataAllFresh)).To(Equal(""))
+ })
+ })
+ })
+
+ When("given an invalid custom template", func() {
+ It("should format the messages using the default template", func() {
+ invNotif, err := createNotifierWithTemplate(`{{ intentionalSyntaxError`, true)
+ Expect(err).To(HaveOccurred())
+ invMsg, err := invNotif.buildMessage(legacyMockData)
+ Expect(err).NotTo(HaveOccurred())
+
+ defNotif, err := createNotifierWithTemplate(``, true)
+ Expect(err).ToNot(HaveOccurred())
+ defMsg, err := defNotif.buildMessage(legacyMockData)
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(invMsg).To(Equal(defMsg))
+ })
+ })
+
+ When("given a template that is using ToUpper function", func() {
+ It("should return the text in UPPER CASE", func() {
+ tplString := `{{range .}}{{ .Message | ToUpper }}{{end}}`
+ Expect(getTemplatedResult(tplString, true, legacyMockData)).To(Equal("FOO BAR"))
+ })
+ })
+
+ When("given a template that is using ToLower function", func() {
+ It("should return the text in lower case", func() {
+ tplString := `{{range .}}{{ .Message | ToLower }}{{end}}`
+ Expect(getTemplatedResult(tplString, true, legacyMockData)).To(Equal("foo bar"))
+ })
+ })
+
+ When("given a template that is using Title function", func() {
+ It("should return the text in Title Case", func() {
+ tplString := `{{range .}}{{ .Message | Title }}{{end}}`
+ Expect(getTemplatedResult(tplString, true, legacyMockData)).To(Equal("Foo Bar"))
+ })
+ })
+
+ })
+
+ When("using report templates", func() {
+ When("no custom template is provided", func() {
+ It("should format the messages using the default template", func() {
+ expected := `4 Scanned, 2 Updated, 1 Failed
+- updt1 (mock/updt1:latest): 01d110000000 updated to d0a110000000
+- updt2 (mock/updt2:latest): 01d120000000 updated to d0a120000000
+- frsh1 (mock/frsh1:latest): Fresh
+- skip1 (mock/skip1:latest): Skipped: unpossible
+- fail1 (mock/fail1:latest): Failed: accidentally the whole container`
+ data := mockDataFromStates(s.UpdatedState, s.FreshState, s.FailedState, s.SkippedState, s.UpdatedState)
+ Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+ })
+
+ })
+
+ When("using a template referencing Title", func() {
+ It("should contain the title in the output", func() {
+ expected := `Watchtower updates on Mock`
+ data := mockDataFromStates(s.UpdatedState)
+ Expect(getTemplatedResult(`{{ .Title }}`, false, data)).To(Equal(expected))
+ })
+ })
+
+ When("using a template referencing Host", func() {
+ It("should contain the hostname in the output", func() {
+ expected := `Mock`
+ data := mockDataFromStates(s.UpdatedState)
+ Expect(getTemplatedResult(`{{ .Host }}`, false, data)).To(Equal(expected))
+ })
+ })
+
+ Describe("the default template", func() {
+ When("all containers are fresh", func() {
+ It("should return an empty string", func() {
+ Expect(getTemplatedResult(``, false, mockDataAllFresh)).To(Equal(""))
+ })
+ })
+ When("at least one container was updated", func() {
+ It("should send a report", func() {
+ expected := `1 Scanned, 1 Updated, 0 Failed
+- updt1 (mock/updt1:latest): 01d110000000 updated to d0a110000000`
+ data := mockDataFromStates(s.UpdatedState)
+ Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+ })
+ })
+ When("at least one container failed to update", func() {
+ It("should send a report", func() {
+ expected := `1 Scanned, 0 Updated, 1 Failed
+- fail1 (mock/fail1:latest): Failed: accidentally the whole container`
+ data := mockDataFromStates(s.FailedState)
+ Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+ })
+ })
+ When("the report is nil", func() {
+ It("should return the logged entries", func() {
+ expected := `The situation is under control
+All the smoke might be covering up some problems
+Turns out everything is on fire
+`
+ Expect(getTemplatedResult(``, false, mockDataMultipleEntries)).To(Equal(expected))
+ })
+ })
+ })
+ })
+
+ When("batching notifications", func() {
+ When("no messages are queued", func() {
+ It("should not send any notification", func() {
+ shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{}, false, time.Duration(0))
+ shoutrrr.StartNotification()
+ shoutrrr.SendNotification(nil)
+ Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`))
+ })
+ })
+ When("at least one message is queued", func() {
+ It("should send a notification", func() {
+ shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{}, false, time.Duration(0))
+ shoutrrr.AddLogHook()
+ shoutrrr.StartNotification()
+ logrus.Info("This log message is sponsored by ContainrrrVPN")
+ shoutrrr.SendNotification(nil)
+ Eventually(logBuffer).Should(gbytes.Say(`Shoutrrr: This log message is sponsored by ContainrrrVPN`))
+ })
+ })
+ })
+
+ When("the title data field is empty", func() {
+ It("should not have set the title param", func() {
+ shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{
+ Host: "test.host",
+ Title: "",
+ }, false, time.Second)
+ _, found := shoutrrr.params.Title()
+ Expect(found).ToNot(BeTrue())
+ })
+ })
+
+ When("sending notifications", func() {
+
+ It("SlowNotificationNotSent", func() {
+ _, blockingRouter := sendNotificationsWithBlockingRouter(true)
+
+ Eventually(blockingRouter.sent).Should(Not(Receive()))
+
+ })
+
+ It("SlowNotificationSent", func() {
+ shoutrrr, blockingRouter := sendNotificationsWithBlockingRouter(true)
+
+ blockingRouter.unlock <- true
+ shoutrrr.Close()
+
+ Eventually(blockingRouter.sent).Should(Receive(BeTrue()))
+ })
+ })
+})
+
+type blockingRouter struct {
+ unlock chan bool
+ sent chan bool
+}
+
+func (b blockingRouter) Send(_ string, _ *types.Params) []error {
+ <-b.unlock
+ b.sent <- true
+ return nil
+}
+
+func sendNotificationsWithBlockingRouter(legacy bool) (*shoutrrrTypeNotifier, *blockingRouter) {
+
+ router := &blockingRouter{
+ unlock: make(chan bool, 1),
+ sent: make(chan bool, 1),
+ }
+
+ tpl, err := getShoutrrrTemplate("", legacy)
+ Expect(err).NotTo(HaveOccurred())
+
+ shoutrrr := &shoutrrrTypeNotifier{
+ template: tpl,
+ messages: make(chan string, 1),
+ done: make(chan bool),
+ Router: router,
+ legacyTemplate: legacy,
+ params: &types.Params{},
+ delay: time.Duration(0),
+ }
+
+ entry := &logrus.Entry{
+ Message: "foo bar",
+ }
+
+ go sendNotifications(shoutrrr)
+
+ shoutrrr.StartNotification()
+ _ = shoutrrr.Fire(entry)
+
+ shoutrrr.SendNotification(nil)
+
+ return shoutrrr, router
+}
+
+func createNotifierWithTemplate(tplString string, legacy bool) (*shoutrrrTypeNotifier, error) {
+ tpl, err := getShoutrrrTemplate(tplString, legacy)
+
+ return &shoutrrrTypeNotifier{
+ template: tpl,
+ legacyTemplate: legacy,
+ }, err
+}
+
+func getTemplatedResult(tplString string, legacy bool, data Data) (msg string) {
+ notifier, err := createNotifierWithTemplate(tplString, legacy)
+ ExpectWithOffset(1, err).NotTo(HaveOccurred())
+ msg, err = notifier.buildMessage(data)
+ ExpectWithOffset(1, err).NotTo(HaveOccurred())
+ return msg
+}
diff --git a/pkg/notifications/slack.go b/pkg/notifications/slack.go
index 42b7915..9118527 100644
--- a/pkg/notifications/slack.go
+++ b/pkg/notifications/slack.go
@@ -1,8 +1,11 @@
package notifications
import (
+ "strings"
+
+ shoutrrrDisco "github.com/containrrr/shoutrrr/pkg/services/discord"
+ shoutrrrSlack "github.com/containrrr/shoutrrr/pkg/services/slack"
t "github.com/containrrr/watchtower/pkg/types"
- "github.com/johntdyer/slackrus"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
@@ -12,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.Notifier {
- 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")
@@ -25,20 +32,54 @@ func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie
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,
}
-
- log.AddHook(n)
return n
}
-func (s *slackTypeNotifier) StartNotification() {}
+func (s *slackTypeNotifier) GetURL(c *cobra.Command) (string, error) {
+ trimmedURL := strings.TrimRight(s.HookURL, "/")
+ trimmedURL = strings.TrimPrefix(trimmedURL, "https://")
+ parts := strings.Split(trimmedURL, "/")
-func (s *slackTypeNotifier) SendNotification() {}
+ if parts[0] == "discord.com" || parts[0] == "discordapp.com" {
+ log.Debug("Detected a discord slack wrapper URL, using shoutrrr discord service")
+ conf := &shoutrrrDisco.Config{
+ WebhookID: parts[len(parts)-3],
+ Token: parts[len(parts)-2],
+ Color: ColorInt,
+ SplitLines: true,
+ Username: s.Username,
+ }
+
+ if s.IconURL != "" {
+ conf.Avatar = s.IconURL
+ }
+
+ return conf.GetURL().String(), nil
+ }
+
+ webhookToken := strings.Replace(s.HookURL, "https://hooks.slack.com/services/", "", 1)
+
+ conf := &shoutrrrSlack.Config{
+ BotName: s.Username,
+ Color: ColorHex,
+ Channel: "webhook",
+ }
+
+ if s.IconURL != "" {
+ conf.Icon = s.IconURL
+ } else if s.IconEmoji != "" {
+ conf.Icon = s.IconEmoji
+ }
+
+ if err := conf.Token.SetFromProp(webhookToken); err != nil {
+ return "", err
+ }
+
+ return conf.GetURL().String(), nil
+}
diff --git a/pkg/notifications/smtp.go b/pkg/notifications/smtp.go
deleted file mode 100644
index 82954bc..0000000
--- a/pkg/notifications/smtp.go
+++ /dev/null
@@ -1,77 +0,0 @@
-// Package notifications ...
-// Copyright 2010 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license.
-package notifications
-
-import (
- "crypto/tls"
- "net"
- "net/smtp"
-)
-
-// SendMail connects to the server at addr, switches to TLS if
-// possible, authenticates with the optional mechanism a if possible,
-// and then sends an email from address from, to addresses to, with
-// message msg.
-// The addr must include a port, as in "mail.example.com:smtp".
-//
-// The addresses in the to parameter are the SMTP RCPT addresses.
-//
-// The msg parameter should be an RFC 822-style email with headers
-// first, a blank line, and then the message body. The lines of msg
-// should be CRLF terminated. The msg headers should usually include
-// fields such as "From", "To", "Subject", and "Cc". Sending "Bcc"
-// messages is accomplished by including an email address in the to
-// parameter but not including it in the msg headers.
-//
-// The SendMail function and the net/smtp package are low-level
-// mechanisms and provide no support for DKIM signing, MIME
-// attachments (see the mime/multipart package), or other mail
-// functionality. Higher-level packages exist outside of the standard
-// library.
-func SendMail(addr string, insecureSkipVerify bool, a smtp.Auth, from string, to []string, msg []byte) error {
- c, err := smtp.Dial(addr)
- if err != nil {
- return err
- }
- defer c.Close()
- if err = c.Hello("localHost"); err != nil {
- return err
- }
- if ok, _ := c.Extension("STARTTLS"); ok {
- serverName, _, _ := net.SplitHostPort(addr)
- config := &tls.Config{ServerName: serverName, InsecureSkipVerify: insecureSkipVerify}
- if err = c.StartTLS(config); err != nil {
- return err
- }
- }
- if a != nil {
- if ok, _ := c.Extension("AUTH"); ok {
- if err = c.Auth(a); err != nil {
- return err
- }
- }
- }
- if err = c.Mail(from); err != nil {
- return err
- }
- for _, addr := range to {
- if err = c.Rcpt(addr); err != nil {
- return err
- }
- }
- w, err := c.Data()
- if err != nil {
- return err
- }
- _, err = w.Write(msg)
- if err != nil {
- return err
- }
- err = w.Close()
- if err != nil {
- return err
- }
- return c.Quit()
-}
diff --git a/pkg/notifications/templates/funcs.go b/pkg/notifications/templates/funcs.go
new file mode 100644
index 0000000..6958c1a
--- /dev/null
+++ b/pkg/notifications/templates/funcs.go
@@ -0,0 +1,27 @@
+package templates
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "text/template"
+
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
+)
+
+var Funcs = template.FuncMap{
+ "ToUpper": strings.ToUpper,
+ "ToLower": strings.ToLower,
+ "ToJSON": toJSON,
+ "Title": cases.Title(language.AmericanEnglish).String,
+}
+
+func toJSON(v interface{}) string {
+ var bytes []byte
+ var err error
+ if bytes, err = json.MarshalIndent(v, "", " "); err != nil {
+ return fmt.Sprintf("failed to marshal JSON in notification template: %v", err)
+ }
+ return string(bytes)
+}
diff --git a/pkg/notifications/util.go b/pkg/notifications/util.go
deleted file mode 100644
index 5764341..0000000
--- a/pkg/notifications/util.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package notifications
-
-import "bytes"
-
-// SplitSubN splits a string into a list of string with each having
-// a maximum number of characters n
-func SplitSubN(s string, n int) []string {
- sub := ""
- subs := []string{}
-
- runes := bytes.Runes([]byte(s))
- l := len(runes)
- for i, r := range runes {
- sub = sub + string(r)
- if (i+1)%n == 0 {
- subs = append(subs, sub)
- sub = ""
- } else if (i + 1) == l {
- subs = append(subs, sub)
- }
- }
-
- return subs
-}
diff --git a/pkg/registry/auth/auth.go b/pkg/registry/auth/auth.go
new file mode 100644
index 0000000..99b05c9
--- /dev/null
+++ b/pkg/registry/auth/auth.go
@@ -0,0 +1,162 @@
+package auth
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "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
+const ChallengeHeader = "WWW-Authenticate"
+
+// GetToken fetches a token for the registry hosting the provided image
+func GetToken(container types.Container, registryAuth string) (string, error) {
+ normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName())
+ if err != nil {
+ return "", err
+ }
+
+ URL := GetChallengeURL(normalizedRef)
+ logrus.WithField("URL", URL.String()).Debug("Built challenge URL")
+
+ var req *http.Request
+ if req, err = GetChallengeRequest(URL); err != nil {
+ return "", err
+ }
+
+ client := &http.Client{}
+ var res *http.Response
+ if res, err = client.Do(req); err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+ v := res.Header.Get(ChallengeHeader)
+
+ logrus.WithFields(logrus.Fields{
+ "status": res.Status,
+ "header": v,
+ }).Debug("Got response to challenge request")
+
+ challenge := strings.ToLower(v)
+ if strings.HasPrefix(challenge, "basic") {
+ if registryAuth == "" {
+ return "", fmt.Errorf("no credentials available")
+ }
+
+ return fmt.Sprintf("Basic %s", registryAuth), nil
+ }
+ if strings.HasPrefix(challenge, "bearer") {
+ return GetBearerHeader(challenge, normalizedRef, registryAuth)
+ }
+
+ return "", errors.New("unsupported challenge type from registry")
+}
+
+// GetChallengeRequest creates a request for getting challenge instructions
+func GetChallengeRequest(URL url.URL) (*http.Request, error) {
+ req, err := http.NewRequest("GET", URL.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "*/*")
+ req.Header.Set("User-Agent", "Watchtower (Docker)")
+ return req, nil
+}
+
+// GetBearerHeader tries to fetch a bearer token from the registry based on the challenge instructions
+func GetBearerHeader(challenge string, imageRef ref.Named, registryAuth string) (string, error) {
+ client := http.Client{}
+ authURL, err := GetAuthURL(challenge, imageRef)
+
+ if err != nil {
+ return "", err
+ }
+
+ var r *http.Request
+ if r, err = http.NewRequest("GET", authURL.String(), nil); err != nil {
+ return "", err
+ }
+
+ if registryAuth != "" {
+ logrus.Debug("Credentials found.")
+ // 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.")
+ }
+
+ var authResponse *http.Response
+ if authResponse, err = client.Do(r); err != nil {
+ return "", err
+ }
+
+ body, _ := io.ReadAll(authResponse.Body)
+ tokenResponse := &types.TokenResponse{}
+
+ err = json.Unmarshal(body, tokenResponse)
+ if err != nil {
+ return "", err
+ }
+
+ return fmt.Sprintf("Bearer %s", tokenResponse.Token), nil
+}
+
+// GetAuthURL from the instructions in the challenge
+func GetAuthURL(challenge string, imageRef ref.Named) (*url.URL, error) {
+ loweredChallenge := strings.ToLower(challenge)
+ raw := strings.TrimPrefix(loweredChallenge, "bearer")
+
+ pairs := strings.Split(raw, ",")
+ values := make(map[string]string, len(pairs))
+
+ for _, pair := range pairs {
+ trimmed := strings.Trim(pair, " ")
+ if key, val, ok := strings.Cut(trimmed, "="); ok {
+ values[key] = strings.Trim(val, `"`)
+ }
+ }
+ logrus.WithFields(logrus.Fields{
+ "realm": values["realm"],
+ "service": values["service"],
+ }).Debug("Checking challenge header content")
+ if values["realm"] == "" || values["service"] == "" {
+
+ return nil, fmt.Errorf("challenge header did not include all values needed to construct an auth url")
+ }
+
+ authURL, _ := url.Parse(values["realm"])
+ q := authURL.Query()
+ q.Add("service", values["service"])
+
+ scopeImage := ref.Path(imageRef)
+
+ scope := fmt.Sprintf("repository:%s:pull", scopeImage)
+ 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
+}
+
+// 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
+}
diff --git a/pkg/registry/auth/auth_test.go b/pkg/registry/auth/auth_test.go
new file mode 100644
index 0000000..d295310
--- /dev/null
+++ b/pkg/registry/auth/auth_test.go
@@ -0,0 +1,162 @@
+package auth_test
+
+import (
+ "fmt"
+ "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"
+)
+
+func TestAuth(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Registry Auth Suite")
+}
+func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() {
+ if credentials.Username == "" {
+ return func() {
+ Skip("Username missing. Skipping integration test")
+ }
+ } else if credentials.Password == "" {
+ return func() {
+ Skip("Password missing. Skipping integration test")
+ }
+ } else {
+ return fn
+ }
+}
+
+var GHCRCredentials = &wtTypes.RegistryCredentials{
+ Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"),
+ Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"),
+}
+
+var _ = Describe("the auth module", func() {
+ mockId := "mock-id"
+ mockName := "mock-container"
+ mockImage := "ghcr.io/k6io/operator:latest"
+ mockCreated := time.Now()
+ mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547"
+
+ mockContainer := mocks.CreateMockContainerWithDigest(
+ mockId,
+ mockName,
+ mockImage,
+ mockCreated,
+ mockDigest)
+
+ Describe("GetToken", func() {
+ It("should parse the token from the response",
+ SkipIfCredentialsEmpty(GHCRCredentials, func() {
+ creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
+ token, err := auth.GetToken(mockContainer, creds)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(token).NotTo(Equal(""))
+ }),
+ )
+ })
+
+ Describe("GetAuthURL", func() {
+ It("should create a valid auth url object based on the challenge header supplied", func() {
+ 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",
+ }
+
+ URL, err := auth.GetAuthURL(challenge, imageRef)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(URL).To(Equal(expected))
+ })
+
+ 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())
+ })
+ })
+
+ 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/"}
+ imageRef, _ := ref.ParseNormalizedNamed("ghcr.io/containrrr/watchtower:latest")
+ Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected))
+ })
+ It("should assume Docker Hub for image refs with no explicit registry", func() {
+ expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
+ imageRef, _ := ref.ParseNormalizedNamed("containrrr/watchtower:latest")
+ Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected))
+ })
+ 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/"}
+ 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
new file mode 100644
index 0000000..e569599
--- /dev/null
+++ b/pkg/registry/digest/digest.go
@@ -0,0 +1,126 @@
+package digest
+
+import (
+ "crypto/tls"
+ "encoding/base64"
+ "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"
+)
+
+// ContentDigestHeader is the key for the key-value pair containing the digest header
+const ContentDigestHeader = "Docker-Content-Digest"
+
+// CompareDigest ...
+func CompareDigest(container types.Container, registryAuth string) (bool, error) {
+ if !container.HasImageInfo() {
+ return false, errors.New("container image info missing")
+ }
+
+ var digest string
+
+ registryAuth = TransformAuth(registryAuth)
+ token, err := auth.GetToken(container, registryAuth)
+ if err != nil {
+ return false, err
+ }
+
+ digestURL, err := manifest.BuildManifestURL(container)
+ if err != nil {
+ return false, err
+ }
+
+ if digest, err = GetDigest(digestURL, token); err != nil {
+ return false, err
+ }
+
+ logrus.WithField("remote", digest).Debug("Found a remote digest to compare with")
+
+ for _, dig := range container.ImageInfo().RepoDigests {
+ localDigest := strings.Split(dig, "@")[1]
+ fields := logrus.Fields{"local": localDigest, "remote": digest}
+ logrus.WithFields(fields).Debug("Comparing")
+
+ if localDigest == digest {
+ logrus.Debug("Found a match")
+ return true, nil
+ }
+ }
+
+ return false, nil
+}
+
+// TransformAuth from a base64 encoded json object to base64 encoded string
+func TransformAuth(registryAuth string) string {
+ b, _ := base64.StdEncoding.DecodeString(registryAuth)
+ credentials := &types.RegistryCredentials{}
+ _ = json.Unmarshal(b, credentials)
+
+ if credentials.Username != "" && credentials.Password != "" {
+ ba := []byte(fmt.Sprintf("%s:%s", credentials.Username, credentials.Password))
+ registryAuth = base64.StdEncoding.EncodeToString(ba)
+ }
+
+ return registryAuth
+}
+
+// GetDigest from registry using a HEAD request to prevent rate limiting
+func GetDigest(url string, token string) (string, error) {
+ tr := &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ DialContext: (&net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }).DialContext,
+ ForceAttemptHTTP2: true,
+ MaxIdleConns: 100,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ }
+ client := &http.Client{Transport: tr}
+
+ req, _ := http.NewRequest("HEAD", url, nil)
+ req.Header.Set("User-Agent", meta.UserAgent)
+
+ 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")
+
+ res, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != 200 {
+ wwwAuthHeader := res.Header.Get("www-authenticate")
+ if wwwAuthHeader == "" {
+ wwwAuthHeader = "not present"
+ }
+ return "", fmt.Errorf("registry responded to head request with %q, auth: %q", res.Status, wwwAuthHeader)
+ }
+ return res.Header.Get(ContentDigestHeader), nil
+}
diff --git a/pkg/registry/digest/digest_test.go b/pkg/registry/digest/digest_test.go
new file mode 100644
index 0000000..a6e6650
--- /dev/null
+++ b/pkg/registry/digest/digest_test.go
@@ -0,0 +1,125 @@
+package digest_test
+
+import (
+ "fmt"
+ "github.com/containrrr/watchtower/internal/actions/mocks"
+ "github.com/containrrr/watchtower/pkg/registry/digest"
+ wtTypes "github.com/containrrr/watchtower/pkg/types"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+ "github.com/onsi/gomega/ghttp"
+ "net/http"
+ "os"
+ "testing"
+ "time"
+)
+
+func TestDigest(t *testing.T) {
+
+ RegisterFailHandler(Fail)
+ RunSpecs(GinkgoT(), "Digest Suite")
+}
+
+var (
+ DockerHubCredentials = &wtTypes.RegistryCredentials{
+ Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_USERNAME"),
+ Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_PASSWORD"),
+ }
+ GHCRCredentials = &wtTypes.RegistryCredentials{
+ Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"),
+ Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"),
+ }
+)
+
+func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() {
+ if credentials.Username == "" {
+ return func() {
+ Skip("Username missing. Skipping integration test")
+ }
+ } else if credentials.Password == "" {
+ return func() {
+ Skip("Password missing. Skipping integration test")
+ }
+ } else {
+ return fn
+ }
+}
+
+var _ = Describe("Digests", func() {
+ mockId := "mock-id"
+ mockName := "mock-container"
+ mockImage := "ghcr.io/k6io/operator:latest"
+ mockCreated := time.Now()
+ mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547"
+
+ mockContainer := mocks.CreateMockContainerWithDigest(
+ mockId,
+ mockName,
+ mockImage,
+ mockCreated,
+ mockDigest)
+
+ mockContainerNoImage := mocks.CreateMockContainerWithImageInfoP(mockId, mockName, mockImage, mockCreated, nil)
+
+ When("a digest comparison is done", func() {
+ It("should return true if digests match",
+ SkipIfCredentialsEmpty(GHCRCredentials, func() {
+ creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
+ matches, err := digest.CompareDigest(mockContainer, creds)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(matches).To(Equal(true))
+ }),
+ )
+
+ It("should return false if digests differ", func() {
+
+ })
+ It("should return an error if the registry isn't available", func() {
+
+ })
+ It("should return an error when container contains no image info", func() {
+ matches, err := digest.CompareDigest(mockContainerNoImage, `user:pass`)
+ Expect(err).To(HaveOccurred())
+ Expect(matches).To(Equal(false))
+ })
+ })
+ When("using different registries", func() {
+ It("should work with DockerHub",
+ SkipIfCredentialsEmpty(DockerHubCredentials, func() {
+ fmt.Println(DockerHubCredentials != nil) // to avoid crying linters
+ }),
+ )
+ It("should work with GitHub Container Registry",
+ SkipIfCredentialsEmpty(GHCRCredentials, func() {
+ fmt.Println(GHCRCredentials != nil) // to avoid crying linters
+ }),
+ )
+ })
+ When("sending a HEAD request", func() {
+ var server *ghttp.Server
+ BeforeEach(func() {
+ server = ghttp.NewServer()
+ })
+ AfterEach(func() {
+ server.Close()
+ })
+ It("should use a custom user-agent", func() {
+ server.AppendHandlers(
+ ghttp.CombineHandlers(
+ ghttp.VerifyHeader(http.Header{
+ "User-Agent": []string{"Watchtower/v0.0.0-unknown"},
+ }),
+ ghttp.RespondWith(http.StatusOK, "", http.Header{
+ digest.ContentDigestHeader: []string{
+ mockDigest,
+ },
+ }),
+ ),
+ )
+ dig, err := digest.GetDigest(server.URL(), "token")
+ Expect(server.ReceivedRequests()).Should(HaveLen(1))
+ Expect(err).NotTo(HaveOccurred())
+ Expect(dig).To(Equal(mockDigest))
+ })
+ })
+})
diff --git a/pkg/registry/helpers/helpers.go b/pkg/registry/helpers/helpers.go
new file mode 100644
index 0000000..35d6ca3
--- /dev/null
+++ b/pkg/registry/helpers/helpers.go
@@ -0,0 +1,28 @@
+package helpers
+
+import (
+ "github.com/distribution/reference"
+)
+
+// domains for Docker Hub, the default registry
+const (
+ DefaultRegistryDomain = "docker.io"
+ DefaultRegistryHost = "index.docker.io"
+ LegacyDefaultRegistryDomain = "index.docker.io"
+)
+
+// 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
+ }
+
+ address := reference.Domain(normalizedRef)
+
+ if address == DefaultRegistryDomain {
+ address = DefaultRegistryHost
+ }
+ return address, nil
+}
diff --git a/pkg/registry/helpers/helpers_test.go b/pkg/registry/helpers/helpers_test.go
new file mode 100644
index 0000000..a561c2c
--- /dev/null
+++ b/pkg/registry/helpers/helpers_test.go
@@ -0,0 +1,37 @@
+package helpers
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+func TestHelpers(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Helper Suite")
+}
+
+var _ = Describe("the helpers", func() {
+ Describe("GetRegistryAddress", func() {
+ It("should return error if passed empty string", func() {
+ _, err := GetRegistryAddress("")
+ Expect(err).To(HaveOccurred())
+ })
+ 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
new file mode 100644
index 0000000..c732bae
--- /dev/null
+++ b/pkg/registry/manifest/manifest.go
@@ -0,0 +1,45 @@
+package manifest
+
+import (
+ "errors"
+ "fmt"
+ url2 "net/url"
+
+ "github.com/containrrr/watchtower/pkg/registry/helpers"
+ "github.com/containrrr/watchtower/pkg/types"
+ ref "github.com/distribution/reference"
+ "github.com/sirupsen/logrus"
+)
+
+// BuildManifestURL from raw image data
+func BuildManifestURL(container types.Container) (string, error) {
+ 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, _ := helpers.GetRegistryAddress(normalizedTaggedRef.Name())
+ img, tag := ref.Path(normalizedTaggedRef), normalizedTaggedRef.Tag()
+
+ logrus.WithFields(logrus.Fields{
+ "image": img,
+ "tag": tag,
+ "normalized": normalizedTaggedRef.Name(),
+ "host": host,
+ }).Debug("Parsing image ref")
+
+ if err != nil {
+ return "", err
+ }
+
+ url := url2.URL{
+ Scheme: "https",
+ Host: host,
+ Path: fmt.Sprintf("/v2/%s/manifests/%s", img, tag),
+ }
+ return url.String(), nil
+}
diff --git a/pkg/registry/manifest/manifest_test.go b/pkg/registry/manifest/manifest_test.go
new file mode 100644
index 0000000..b24d9bc
--- /dev/null
+++ b/pkg/registry/manifest/manifest_test.go
@@ -0,0 +1,74 @@
+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"
+)
+
+func TestManifest(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Manifest Suite")
+}
+
+var _ = Describe("the manifest module", func() {
+ Describe("BuildManifestURL", func() {
+ It("should return a valid url given a fully qualified image", func() {
+ imageRef := "ghcr.io/containrrr/watchtower:mytag"
+ expected := "https://ghcr.io/v2/containrrr/watchtower/manifests/mytag"
+
+ URL, err := buildMockContainerManifestURL(imageRef)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(URL).To(Equal(expected))
+ })
+ 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"
+
+ URL, err := buildMockContainerManifestURL(imageRef)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(URL).To(Equal(expected))
+ })
+ 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"
+
+ URL, err := buildMockContainerManifestURL(imageRef)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(URL).To(Equal(expected))
+ })
+ 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"
+
+ 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 2d7b9a8..430b401 100644
--- a/pkg/registry/registry.go
+++ b/pkg/registry/registry.go
@@ -1,6 +1,9 @@
package registry
import (
+ "github.com/containrrr/watchtower/pkg/registry/helpers"
+ watchtowerTypes "github.com/containrrr/watchtower/pkg/types"
+ ref "github.com/distribution/reference"
"github.com/docker/docker/api/types"
log "github.com/sirupsen/logrus"
)
@@ -13,11 +16,13 @@ func GetPullOptions(imageName string) (types.ImagePullOptions, error) {
return types.ImagePullOptions{}, err
}
- log.Debugf("Got auth value: %s", auth)
if auth == "" {
return types.ImagePullOptions{}, nil
}
+ // CREDENTIAL: Uncomment to log docker config auth
+ // log.Tracef("Got auth value: %s", auth)
+
return types.ImagePullOptions{
RegistryAuth: auth,
PrivilegeFunc: DefaultAuthHandler,
@@ -31,3 +36,26 @@ func DefaultAuthHandler() (string, error) {
log.Debug("Authentication request was rejected. Trying again without authentication")
return "", nil
}
+
+// WarnOnAPIConsumption will return true if the registry is known-expected
+// to respond well to HTTP HEAD in checking the container digest -- or if there
+// are problems parsing the container hostname.
+// Will return false if behavior for container is unknown.
+func WarnOnAPIConsumption(container watchtowerTypes.Container) bool {
+
+ normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName())
+ if err != nil {
+ return true
+ }
+
+ containerHost, err := helpers.GetRegistryAddress(normalizedRef.Name())
+ if err != nil {
+ return true
+ }
+
+ if containerHost == helpers.DefaultRegistryHost || containerHost == "ghcr.io" {
+ return true
+ }
+
+ return false
+}
diff --git a/pkg/registry/registry_suite_test.go b/pkg/registry/registry_suite_test.go
new file mode 100644
index 0000000..4dcbea6
--- /dev/null
+++ b/pkg/registry/registry_suite_test.go
@@ -0,0 +1,15 @@
+package registry_test
+
+import (
+ "github.com/sirupsen/logrus"
+ "testing"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+)
+
+func TestRegistry(t *testing.T) {
+ RegisterFailHandler(Fail)
+ logrus.SetOutput(GinkgoWriter)
+ RunSpecs(t, "Registry Suite")
+}
diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go
new file mode 100644
index 0000000..481c91d
--- /dev/null
+++ b/pkg/registry/registry_test.go
@@ -0,0 +1,43 @@
+package registry_test
+
+import (
+ "github.com/containrrr/watchtower/internal/actions/mocks"
+ unit "github.com/containrrr/watchtower/pkg/registry"
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
+
+ "time"
+)
+
+var _ = Describe("Registry", func() {
+ Describe("WarnOnAPIConsumption", func() {
+ When("Given a container with an image from ghcr.io", func() {
+ It("should want to warn", func() {
+ Expect(testContainerWithImage("ghcr.io/containrrr/watchtower")).To(BeTrue())
+ })
+ })
+ When("Given a container with an image implicitly from dockerhub", func() {
+ It("should want to warn", func() {
+ Expect(testContainerWithImage("docker:latest")).To(BeTrue())
+ })
+ })
+ When("Given a container with an image explicitly from dockerhub", func() {
+ It("should want to warn", func() {
+ Expect(testContainerWithImage("index.docker.io/docker:latest")).To(BeTrue())
+ Expect(testContainerWithImage("docker.io/docker:latest")).To(BeTrue())
+ })
+ })
+ When("Given a container with an image from some other registry", func() {
+ It("should not want to warn", func() {
+ Expect(testContainerWithImage("docker.fsf.org/docker:latest")).To(BeFalse())
+ Expect(testContainerWithImage("altavista.com/docker:latest")).To(BeFalse())
+ Expect(testContainerWithImage("gitlab.com/docker:latest")).To(BeFalse())
+ })
+ })
+ })
+})
+
+func testContainerWithImage(imageName string) bool {
+ container := mocks.CreateMockContainer("", "", imageName, time.Now())
+ return unit.WarnOnAPIConsumption(container)
+}
diff --git a/pkg/registry/trust.go b/pkg/registry/trust.go
index 7403d46..0b20248 100644
--- a/pkg/registry/trust.go
+++ b/pkg/registry/trust.go
@@ -1,16 +1,16 @@
package registry
import (
+ "encoding/base64"
+ "encoding/json"
"errors"
"os"
- "strings"
- "github.com/docker/cli/cli/command"
+ "github.com/containrrr/watchtower/pkg/registry/helpers"
cliconfig "github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/cli/config/credentials"
- "github.com/docker/distribution/reference"
- "github.com/docker/docker/api/types"
+ "github.com/docker/cli/cli/config/types"
log "github.com/sirupsen/logrus"
)
@@ -18,7 +18,7 @@ import (
// loaded from environment variables or docker config
// as available in that order
func EncodedAuth(ref string) (string, error) {
- auth, err := EncodedEnvAuth(ref)
+ auth, err := EncodedEnvAuth()
if err != nil {
auth, err = EncodedConfigAuth(ref)
}
@@ -28,7 +28,7 @@ func EncodedAuth(ref string) (string, error) {
// EncodedEnvAuth returns an encoded auth config for the given registry
// loaded from environment variables
// Returns an error if authentication environment variables have not been set
-func EncodedEnvAuth(ref string) (string, error) {
+func EncodedEnvAuth() (string, error) {
username := os.Getenv("REPO_USER")
password := os.Getenv("REPO_PASS")
if username != "" && password != "" {
@@ -36,54 +36,49 @@ func EncodedEnvAuth(ref string) (string, error) {
Username: username,
Password: password,
}
- log.Debugf("Loaded auth credentials %s for %s", auth, ref)
+
+ 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)
auth, _ := credStore.Get(server) // returns (types.AuthConfig{}) if server not in credStore
if auth == (types.AuthConfig{}) {
- log.Debugf("No credentials for %s in %s", server, configFile.Filename)
+ log.WithField("config_file", configFile.Filename).Debugf("No credentials for %s found", server)
return "", nil
}
- log.Debugf("Loaded auth credentials %s from %s", auth, configFile.Filename)
+ 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 {
@@ -94,6 +89,10 @@ func CredentialsStore(configFile configfile.ConfigFile) credentials.Store {
}
// EncodeAuth Base64 encode an AuthConfig struct for transmission over HTTP
-func EncodeAuth(auth types.AuthConfig) (string, error) {
- return command.EncodeAuthToBase64(auth)
+func EncodeAuth(authConfig types.AuthConfig) (string, error) {
+ buf, err := json.Marshal(authConfig)
+ if err != nil {
+ return "", err
+ }
+ return base64.URLEncoding.EncodeToString(buf), nil
}
diff --git a/pkg/registry/trust_test.go b/pkg/registry/trust_test.go
index 8ffe1b9..00fc8a7 100644
--- a/pkg/registry/trust_test.go
+++ b/pkg/registry/trust_test.go
@@ -1,59 +1,49 @@
package registry
import (
- "github.com/stretchr/testify/assert"
"os"
- "testing"
+
+ . "github.com/onsi/ginkgo"
+ . "github.com/onsi/gomega"
)
-func TestEncodedEnvAuth_ShouldReturnAnErrorIfRepoEnvsAreUnset(t *testing.T) {
- os.Unsetenv("REPO_USER")
- os.Unsetenv("REPO_PASS")
- _, err := EncodedEnvAuth("")
- assert.Error(t, err)
-}
-func TestEncodedEnvAuth_ShouldReturnAuthHashIfRepoEnvsAreSet(t *testing.T) {
- expectedHash := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
+var _ = Describe("Registry credential helpers", func() {
+ Describe("EncodedAuth", func() {
+ It("should return repo credentials from env when set", func() {
+ var err error
+ expected := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
- os.Setenv("REPO_USER", "containrrr-user")
- os.Setenv("REPO_PASS", "containrrr-pass")
- config, _ := EncodedEnvAuth("")
+ err = os.Setenv("REPO_USER", "containrrr-user")
+ Expect(err).NotTo(HaveOccurred())
- assert.Equal(t, config, expectedHash)
-}
+ err = os.Setenv("REPO_PASS", "containrrr-pass")
+ Expect(err).NotTo(HaveOccurred())
-func TestEncodedConfigAuth_ShouldReturnAnErrorIfFileIsNotPresent(t *testing.T) {
- os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
- _, err := EncodedConfigAuth("")
- assert.Error(t, err)
-}
+ config, err := EncodedEnvAuth()
+ Expect(config).To(Equal(expected))
+ 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
- */
+ Describe("EncodedEnvAuth", func() {
+ It("should return an error if repo envs are unset", func() {
+ _ = os.Unsetenv("REPO_USER")
+ _ = os.Unsetenv("REPO_PASS")
-func TestParseServerAddress_ShouldReturnErrorIfPassedEmptyString(t *testing.T) {
- _, err := ParseServerAddress("")
- assert.Error(t, err)
-}
+ _, err := EncodedEnvAuth()
+ Expect(err).To(HaveOccurred())
+ })
+ })
-func TestParseServerAddress_ShouldReturnTheRepoNameIfPassedAFullyQualifiedImageName(t *testing.T) {
- val, _ := ParseServerAddress("github.com/containrrrr/config")
- assert.Equal(t, val, "github.com")
-}
+ Describe("EncodedConfigAuth", func() {
+ It("should return an error if file is not present", func() {
+ var err error
-func TestParseServerAddress_ShouldReturnTheOrganizationPartIfPassedAnImageNameMissingServerName(t *testing.T) {
- val, _ := ParseServerAddress("containrrr/config")
- assert.Equal(t, val, "containrrr")
-}
+ err = os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
+ Expect(err).NotTo(HaveOccurred())
-func TestParseServerAddress_ShouldReturnTheServerNameIfPassedAFullyQualifiedImageName(t *testing.T) {
- val, _ := ParseServerAddress("github.com/containrrrr/config")
- assert.Equal(t, val, "github.com")
-}
+ _, err = EncodedConfigAuth("")
+ Expect(err).To(HaveOccurred())
+ })
+ })
+})
diff --git a/pkg/session/container_status.go b/pkg/session/container_status.go
new file mode 100644
index 0000000..8313da1
--- /dev/null
+++ b/pkg/session/container_status.go
@@ -0,0 +1,82 @@
+package session
+
+import wt "github.com/containrrr/watchtower/pkg/types"
+
+// State indicates what the current state is of the container
+type State int
+
+// State enum values
+const (
+ // UnknownState is only used to represent an uninitialized State value
+ UnknownState State = iota
+ SkippedState
+ ScannedState
+ UpdatedState
+ FailedState
+ FreshState
+ StaleState
+)
+
+// ContainerStatus contains the container state during a session
+type ContainerStatus struct {
+ containerID wt.ContainerID
+ oldImage wt.ImageID
+ newImage wt.ImageID
+ containerName string
+ imageName string
+ error
+ state State
+}
+
+// ID returns the container ID
+func (u *ContainerStatus) ID() wt.ContainerID {
+ return u.containerID
+}
+
+// Name returns the container name
+func (u *ContainerStatus) Name() string {
+ return u.containerName
+}
+
+// CurrentImageID returns the image ID that the container used when the session started
+func (u *ContainerStatus) CurrentImageID() wt.ImageID {
+ return u.oldImage
+}
+
+// LatestImageID returns the newest image ID found during the session
+func (u *ContainerStatus) LatestImageID() wt.ImageID {
+ return u.newImage
+}
+
+// ImageName returns the name:tag that the container uses
+func (u *ContainerStatus) ImageName() string {
+ return u.imageName
+}
+
+// Error returns the error (if any) that was encountered for the container during a session
+func (u *ContainerStatus) Error() string {
+ if u.error == nil {
+ return ""
+ }
+ return u.error.Error()
+}
+
+// State returns the current State that the container is in
+func (u *ContainerStatus) State() string {
+ switch u.state {
+ case SkippedState:
+ return "Skipped"
+ case ScannedState:
+ return "Scanned"
+ case UpdatedState:
+ return "Updated"
+ case FailedState:
+ return "Failed"
+ case FreshState:
+ return "Fresh"
+ case StaleState:
+ return "Stale"
+ default:
+ return "Unknown"
+ }
+}
diff --git a/pkg/session/progress.go b/pkg/session/progress.go
new file mode 100644
index 0000000..57069be
--- /dev/null
+++ b/pkg/session/progress.go
@@ -0,0 +1,56 @@
+package session
+
+import (
+ "github.com/containrrr/watchtower/pkg/types"
+)
+
+// Progress contains the current session container status
+type Progress map[types.ContainerID]*ContainerStatus
+
+// UpdateFromContainer sets various status fields from their corresponding container equivalents
+func UpdateFromContainer(cont types.Container, newImage types.ImageID, state State) *ContainerStatus {
+ return &ContainerStatus{
+ containerID: cont.ID(),
+ containerName: cont.Name(),
+ imageName: cont.ImageName(),
+ oldImage: cont.SafeImageID(),
+ newImage: newImage,
+ state: state,
+ }
+}
+
+// AddSkipped adds a container to the Progress with the state set as skipped
+func (m Progress) AddSkipped(cont types.Container, err error) {
+ update := UpdateFromContainer(cont, cont.SafeImageID(), SkippedState)
+ update.error = err
+ m.Add(update)
+}
+
+// AddScanned adds a container to the Progress with the state set as scanned
+func (m Progress) AddScanned(cont types.Container, newImage types.ImageID) {
+ m.Add(UpdateFromContainer(cont, newImage, ScannedState))
+}
+
+// UpdateFailed updates the containers passed, setting their state as failed with the supplied error
+func (m Progress) UpdateFailed(failures map[types.ContainerID]error) {
+ for id, err := range failures {
+ update := m[id]
+ update.error = err
+ update.state = FailedState
+ }
+}
+
+// Add a container to the map using container ID as the key
+func (m Progress) Add(update *ContainerStatus) {
+ m[update.containerID] = update
+}
+
+// MarkForUpdate marks the container identified by containerID for update
+func (m Progress) MarkForUpdate(containerID types.ContainerID) {
+ m[containerID].state = UpdatedState
+}
+
+// Report creates a new Report from a Progress instance
+func (m Progress) Report() types.Report {
+ return NewReport(m)
+}
diff --git a/pkg/session/report.go b/pkg/session/report.go
new file mode 100644
index 0000000..707eb91
--- /dev/null
+++ b/pkg/session/report.go
@@ -0,0 +1,118 @@
+package session
+
+import (
+ "sort"
+
+ "github.com/containrrr/watchtower/pkg/types"
+)
+
+type report struct {
+ scanned []types.ContainerReport
+ updated []types.ContainerReport
+ failed []types.ContainerReport
+ skipped []types.ContainerReport
+ stale []types.ContainerReport
+ fresh []types.ContainerReport
+}
+
+func (r *report) Scanned() []types.ContainerReport {
+ return r.scanned
+}
+func (r *report) Updated() []types.ContainerReport {
+ return r.updated
+}
+func (r *report) Failed() []types.ContainerReport {
+ return r.failed
+}
+func (r *report) Skipped() []types.ContainerReport {
+ return r.skipped
+}
+func (r *report) Stale() []types.ContainerReport {
+ return r.stale
+}
+func (r *report) Fresh() []types.ContainerReport {
+ return r.fresh
+}
+func (r *report) All() []types.ContainerReport {
+ allLen := len(r.scanned) + len(r.updated) + len(r.failed) + len(r.skipped) + len(r.stale) + len(r.fresh)
+ all := make([]types.ContainerReport, 0, allLen)
+
+ presentIds := map[types.ContainerID][]string{}
+
+ appendUnique := func(reports []types.ContainerReport) {
+ for _, cr := range reports {
+ if _, found := presentIds[cr.ID()]; found {
+ continue
+ }
+ all = append(all, cr)
+ presentIds[cr.ID()] = nil
+ }
+ }
+
+ appendUnique(r.updated)
+ appendUnique(r.failed)
+ appendUnique(r.skipped)
+ appendUnique(r.stale)
+ appendUnique(r.fresh)
+ appendUnique(r.scanned)
+
+ sort.Sort(sortableContainers(all))
+
+ return all
+}
+
+// NewReport creates a types.Report from the supplied Progress
+func NewReport(progress Progress) types.Report {
+ report := &report{
+ scanned: []types.ContainerReport{},
+ updated: []types.ContainerReport{},
+ failed: []types.ContainerReport{},
+ skipped: []types.ContainerReport{},
+ stale: []types.ContainerReport{},
+ fresh: []types.ContainerReport{},
+ }
+
+ for _, update := range progress {
+ if update.state == SkippedState {
+ report.skipped = append(report.skipped, update)
+ continue
+ }
+
+ report.scanned = append(report.scanned, update)
+ if update.newImage == update.oldImage {
+ update.state = FreshState
+ report.fresh = append(report.fresh, update)
+ continue
+ }
+
+ switch update.state {
+ case UpdatedState:
+ report.updated = append(report.updated, update)
+ case FailedState:
+ report.failed = append(report.failed, update)
+ default:
+ update.state = StaleState
+ report.stale = append(report.stale, update)
+ }
+ }
+
+ sort.Sort(sortableContainers(report.scanned))
+ sort.Sort(sortableContainers(report.updated))
+ sort.Sort(sortableContainers(report.failed))
+ sort.Sort(sortableContainers(report.skipped))
+ sort.Sort(sortableContainers(report.stale))
+ sort.Sort(sortableContainers(report.fresh))
+
+ return report
+}
+
+type sortableContainers []types.ContainerReport
+
+// Len implements sort.Interface.Len
+func (s sortableContainers) Len() int { return len(s) }
+
+// Less implements sort.Interface.Less
+func (s sortableContainers) Less(i, j int) bool { return s[i].ID() < s[j].ID() }
+
+// Swap implements sort.Interface.Swap
+func (s sortableContainers) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
diff --git a/pkg/sorter/sort.go b/pkg/sorter/sort.go
index 1e27f1b..b9d1e12 100644
--- a/pkg/sorter/sort.go
+++ b/pkg/sorter/sort.go
@@ -2,13 +2,14 @@ package sorter
import (
"fmt"
- "github.com/containrrr/watchtower/pkg/container"
"time"
+
+ "github.com/containrrr/watchtower/pkg/types"
)
// ByCreated allows a list of Container structs to be sorted by the container's
// created date.
-type ByCreated []container.Container
+type ByCreated []types.Container
func (c ByCreated) Len() int { return len(c) }
func (c ByCreated) Swap(i, j int) { c[i], c[j] = c[j], c[i] }
@@ -34,18 +35,18 @@ func (c ByCreated) Less(i, j int) bool {
// the front of the list while containers with links will be sorted after all
// of their dependencies. This sort order ensures that linked containers can
// be started in the correct order.
-func SortByDependencies(containers []container.Container) ([]container.Container, error) {
+func SortByDependencies(containers []types.Container) ([]types.Container, error) {
sorter := dependencySorter{}
return sorter.Sort(containers)
}
type dependencySorter struct {
- unvisited []container.Container
+ unvisited []types.Container
marked map[string]bool
- sorted []container.Container
+ sorted []types.Container
}
-func (ds *dependencySorter) Sort(containers []container.Container) ([]container.Container, error) {
+func (ds *dependencySorter) Sort(containers []types.Container) ([]types.Container, error) {
ds.unvisited = containers
ds.marked = map[string]bool{}
@@ -58,7 +59,7 @@ func (ds *dependencySorter) Sort(containers []container.Container) ([]container.
return ds.sorted, nil
}
-func (ds *dependencySorter) visit(c container.Container) error {
+func (ds *dependencySorter) visit(c types.Container) error {
if _, ok := ds.marked[c.Name()]; ok {
return fmt.Errorf("circular reference to %s", c.Name())
@@ -84,7 +85,7 @@ func (ds *dependencySorter) visit(c container.Container) error {
return nil
}
-func (ds *dependencySorter) findUnvisited(name string) *container.Container {
+func (ds *dependencySorter) findUnvisited(name string) *types.Container {
for _, c := range ds.unvisited {
if c.Name() == name {
return &c
@@ -94,7 +95,7 @@ func (ds *dependencySorter) findUnvisited(name string) *container.Container {
return nil
}
-func (ds *dependencySorter) removeUnvisited(c container.Container) {
+func (ds *dependencySorter) removeUnvisited(c types.Container) {
var idx int
for i := range ds.unvisited {
if ds.unvisited[i].Name() == c.Name() {
diff --git a/pkg/types/container.go b/pkg/types/container.go
new file mode 100644
index 0000000..8a22f44
--- /dev/null
+++ b/pkg/types/container.go
@@ -0,0 +1,78 @@
+package types
+
+import (
+ "strings"
+
+ "github.com/docker/docker/api/types"
+ dc "github.com/docker/docker/api/types/container"
+)
+
+// ImageID is a hash string representing a container image
+type ImageID string
+
+// ContainerID is a hash string representing a container instance
+type ContainerID string
+
+// ShortID returns the 12-character (hex) short version of an image ID hash, removing any "sha256:" prefix if present
+func (id ImageID) ShortID() (short string) {
+ return shortID(string(id))
+}
+
+// ShortID returns the 12-character (hex) short version of a container ID hash, removing any "sha256:" prefix if present
+func (id ContainerID) ShortID() (short string) {
+ return shortID(string(id))
+}
+
+func shortID(longID string) string {
+ prefixSep := strings.IndexRune(longID, ':')
+ offset := 0
+ length := 12
+ if prefixSep >= 0 {
+ if longID[0:prefixSep] == "sha256" {
+ offset = prefixSep + 1
+ } else {
+ length += prefixSep + 1
+ }
+ }
+
+ if len(longID) >= offset+length {
+ return longID[offset : offset+length]
+ }
+
+ return longID
+}
+
+// Container is a docker container running an image
+type Container interface {
+ ContainerInfo() *types.ContainerJSON
+ ID() ContainerID
+ IsRunning() bool
+ Name() string
+ ImageID() ImageID
+ SafeImageID() ImageID
+ ImageName() string
+ Enabled() (bool, bool)
+ IsMonitorOnly(UpdateParams) bool
+ Scope() (string, bool)
+ Links() []string
+ ToRestart() bool
+ IsWatchtower() bool
+ StopSignal() string
+ HasImageInfo() bool
+ ImageInfo() *types.ImageInspect
+ GetLifecyclePreCheckCommand() string
+ GetLifecyclePostCheckCommand() string
+ GetLifecyclePreUpdateCommand() string
+ GetLifecyclePostUpdateCommand() string
+ 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
new file mode 100644
index 0000000..82d7b7b
--- /dev/null
+++ b/pkg/types/convertible_notifier.go
@@ -0,0 +1,17 @@
+package types
+
+import (
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+// ConvertibleNotifier is a notifier capable of creating a shoutrrr URL
+type ConvertibleNotifier interface {
+ GetURL(c *cobra.Command) (string, error)
+}
+
+// DelayNotifier is a notifier that might need to be delayed before sending notifications
+type DelayNotifier interface {
+ GetDelay() time.Duration
+}
diff --git a/pkg/types/filterable_container.go b/pkg/types/filterable_container.go
index d89b910..b410b1c 100644
--- a/pkg/types/filterable_container.go
+++ b/pkg/types/filterable_container.go
@@ -6,4 +6,6 @@ type FilterableContainer interface {
Name() string
IsWatchtower() bool
Enabled() (bool, bool)
+ Scope() (string, bool)
+ ImageName() string
}
diff --git a/pkg/types/notifier.go b/pkg/types/notifier.go
index c8d07d0..478a4c4 100644
--- a/pkg/types/notifier.go
+++ b/pkg/types/notifier.go
@@ -3,5 +3,9 @@ package types
// Notifier is the interface that all notification services have in common
type Notifier interface {
StartNotification()
- SendNotification()
+ SendNotification(Report)
+ AddLogHook()
+ GetNames() []string
+ GetURLs() []string
+ Close()
}
diff --git a/pkg/types/registry_credentials.go b/pkg/types/registry_credentials.go
new file mode 100644
index 0000000..607fa05
--- /dev/null
+++ b/pkg/types/registry_credentials.go
@@ -0,0 +1,7 @@
+package types
+
+// RegistryCredentials is a credential pair used for basic auth
+type RegistryCredentials struct {
+ Username string
+ Password string // usually a token rather than an actual password
+}
diff --git a/pkg/types/report.go b/pkg/types/report.go
new file mode 100644
index 0000000..f454fc6
--- /dev/null
+++ b/pkg/types/report.go
@@ -0,0 +1,23 @@
+package types
+
+// Report contains reports for all the containers processed during a session
+type Report interface {
+ Scanned() []ContainerReport
+ Updated() []ContainerReport
+ Failed() []ContainerReport
+ Skipped() []ContainerReport
+ Stale() []ContainerReport
+ Fresh() []ContainerReport
+ All() []ContainerReport
+}
+
+// ContainerReport represents a container that was included in watchtower session
+type ContainerReport interface {
+ ID() ContainerID
+ Name() string
+ CurrentImageID() ImageID
+ LatestImageID() ImageID
+ ImageName() string
+ Error() string
+ State() string
+}
diff --git a/pkg/types/token_response.go b/pkg/types/token_response.go
new file mode 100644
index 0000000..722dde8
--- /dev/null
+++ b/pkg/types/token_response.go
@@ -0,0 +1,6 @@
+package types
+
+// TokenResponse is returned by the registry on successful authentication
+type TokenResponse struct {
+ Token string `json:"token"`
+}
diff --git a/pkg/types/update_params.go b/pkg/types/update_params.go
index 8c6fea7..2b6d3c4 100644
--- a/pkg/types/update_params.go
+++ b/pkg/types/update_params.go
@@ -6,10 +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
+ Filter Filter
+ Cleanup bool
+ NoRestart bool
+ Timeout time.Duration
+ MonitorOnly bool
+ NoPull bool
+ LifecycleHooks bool
+ RollingRestart bool
+ LabelPrecedence bool
}
diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml
new file mode 100644
index 0000000..1a30df0
--- /dev/null
+++ b/prometheus/prometheus.yml
@@ -0,0 +1,9 @@
+scrape_configs:
+ - job_name: watchtower
+ scrape_interval: 5s
+ metrics_path: /v1/metrics
+ bearer_token: demotoken
+ static_configs:
+ - targets:
+ - 'watchtower:8080'
+
diff --git a/scripts/build-tplprev.sh b/scripts/build-tplprev.sh
new file mode 100755
index 0000000..293710c
--- /dev/null
+++ b/scripts/build-tplprev.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+
+cd $(git rev-parse --show-toplevel)
+
+cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./docs/assets/
+
+GOARCH=wasm GOOS=js go build -o ./docs/assets/tplprev.wasm ./tplprev
\ No newline at end of file
diff --git a/scripts/codecov.sh b/scripts/codecov.sh
new file mode 100755
index 0000000..a3bc024
--- /dev/null
+++ b/scripts/codecov.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+
+go test -v -coverprofile coverage.out -covermode atomic ./...
+
+# Requires CODECOV_TOKEN to be set
+bash <(curl -s https://codecov.io/bash)
\ No newline at end of file
diff --git a/scripts/contnet-tests.sh b/scripts/contnet-tests.sh
new file mode 100755
index 0000000..25269dc
--- /dev/null
+++ b/scripts/contnet-tests.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+
+set -e
+
+function exit_env_err() {
+ >&2 echo "Required environment variable not set: $1"
+ exit 1
+}
+
+if [ -z "$VPN_SERVICE_PROVIDER" ]; then exit_env_err "VPN_SERVICE_PROVIDER"; fi
+if [ -z "$OPENVPN_USER" ]; then exit_env_err "OPENVPN_USER"; fi
+if [ -z "$OPENVPN_PASSWORD" ]; then exit_env_err "OPENVPN_PASSWORD"; fi
+# if [ -z "$SERVER_COUNTRIES" ]; then exit_env_err "SERVER_COUNTRIES"; fi
+
+
+export SERVER_COUNTRIES=${SERVER_COUNTRIES:"Sweden"}
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+COMPOSE_FILE="$REPO_ROOT/dockerfiles/container-networking/docker-compose.yml"
+DEFAULT_WATCHTOWER="$REPO_ROOT/watchtower"
+WATCHTOWER="$*"
+WATCHTOWER=${WATCHTOWER:-$DEFAULT_WATCHTOWER}
+echo "repo root path is $REPO_ROOT"
+echo "watchtower path is $WATCHTOWER"
+echo "compose file path is $COMPOSE_FILE"
+
+echo; echo "=== Forcing network container producer update..."
+
+echo "Pull previous version of gluetun..."
+docker pull qmcgaw/gluetun:v3.34.3
+echo "Fake new version of gluetun by retagging v3.34.4 as v3.35.0..."
+docker tag qmcgaw/gluetun:v3.34.3 qmcgaw/gluetun:v3.35.0
+
+echo; echo "=== Creating containers..."
+
+docker compose -p "wt-contnet" -f "$COMPOSE_FILE" up -d
+
+echo; echo "=== Running watchtower"
+$WATCHTOWER --run-once
+
+echo; echo "=== Removing containers..."
+
+docker compose -p "wt-contnet" -f "$COMPOSE_FILE" down
diff --git a/scripts/dependency-test.sh b/scripts/dependency-test.sh
new file mode 100755
index 0000000..49c672b
--- /dev/null
+++ b/scripts/dependency-test.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+
+# Simulates a container that will always be updated, checking whether it shuts down it's dependencies correctly.
+# Note that this test does not verify the results in any way
+
+set -e
+SCRIPT_ROOT=$(dirname "$(readlink -m "$(type -p "$0")")")
+source "$SCRIPT_ROOT/docker-util.sh"
+
+DepArgs=""
+if [ -z "$1" ] || [ "$1" == "depends-on" ]; then
+ DepArgs="--label com.centurylinklabs.watchtower.depends-on=parent"
+elif [ "$1" == "linked" ]; then
+ DepArgs="--link parent"
+else
+ DepArgs=$1
+fi
+
+WatchArgs="${*:2}"
+if [ -z "$WatchArgs" ]; then
+ WatchArgs="--debug"
+fi
+
+try-remove-container parent
+try-remove-container depending
+
+REPO=$(registry-host)
+
+create-dummy-image deptest/parent
+create-dummy-image deptest/depending
+
+echo ""
+
+echo -en "Starting \e[94mparent\e[0m container... "
+CmdParent="docker run -d -p 9090 --name parent $REPO/deptest/parent"
+$CmdParent
+PARENT_REV_BEFORE=$(query-rev parent)
+PARENT_START_BEFORE=$(container-started parent)
+echo -e "Rev: \e[92m$PARENT_REV_BEFORE\e[0m"
+echo -e "Started: \e[96m$PARENT_START_BEFORE\e[0m"
+echo -e "Command: \e[37m$CmdParent\e[0m"
+
+echo ""
+
+echo -en "Starting \e[94mdepending\e[0m container... "
+CmdDepend="docker run -d -p 9090 --name depending $DepArgs $REPO/deptest/depending"
+$CmdDepend
+DEPEND_REV_BEFORE=$(query-rev depending)
+DEPEND_START_BEFORE=$(container-started depending)
+echo -e "Rev: \e[92m$DEPEND_REV_BEFORE\e[0m"
+echo -e "Started: \e[96m$DEPEND_START_BEFORE\e[0m"
+echo -e "Command: \e[37m$CmdDepend\e[0m"
+
+echo -e ""
+
+create-dummy-image deptest/parent
+
+echo -e "\nRunning watchtower..."
+
+if [ -z "$WATCHTOWER_TAG" ]; then
+ ## Windows support:
+ #export DOCKER_HOST=tcp://localhost:2375
+ #export CLICOLOR=1
+ go run . --run-once $WatchArgs
+else
+ docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower:"$WATCHTOWER_TAG" --run-once $WatchArgs
+fi
+
+echo -e "\nSession results:"
+
+PARENT_REV_AFTER=$(query-rev parent)
+PARENT_START_AFTER=$(container-started parent)
+echo -en " Parent image: \e[95m$PARENT_REV_BEFORE\e[0m => \e[94m$PARENT_REV_AFTER\e[0m "
+if [ "$PARENT_REV_AFTER" == "$PARENT_REV_BEFORE" ]; then
+ echo -e "(\e[91mSame\e[0m)"
+else
+ echo -e "(\e[92mUpdated\e[0m)"
+fi
+echo -en " Parent container: \e[95m$PARENT_START_BEFORE\e[0m => \e[94m$PARENT_START_AFTER\e[0m "
+if [ "$PARENT_START_AFTER" == "$PARENT_START_BEFORE" ]; then
+ echo -e "(\e[91mSame\e[0m)"
+else
+ echo -e "(\e[92mRestarted\e[0m)"
+fi
+
+echo ""
+
+DEPEND_REV_AFTER=$(query-rev depending)
+DEPEND_START_AFTER=$(container-started depending)
+echo -en " Depend image: \e[95m$DEPEND_REV_BEFORE\e[0m => \e[94m$DEPEND_REV_AFTER\e[0m "
+if [ "$DEPEND_REV_BEFORE" == "$DEPEND_REV_AFTER" ]; then
+ echo -e "(\e[92mSame\e[0m)"
+else
+ echo -e "(\e[91mUpdated\e[0m)"
+fi
+echo -en " Depend container: \e[95m$DEPEND_START_BEFORE\e[0m => \e[94m$DEPEND_START_AFTER\e[0m "
+if [ "$DEPEND_START_BEFORE" == "$DEPEND_START_AFTER" ]; then
+ echo -e "(\e[91mSame\e[0m)"
+else
+ echo -e "(\e[92mRestarted\e[0m)"
+fi
+
+echo ""
\ No newline at end of file
diff --git a/scripts/docker-util.sh b/scripts/docker-util.sh
new file mode 100644
index 0000000..bd0dbda
--- /dev/null
+++ b/scripts/docker-util.sh
@@ -0,0 +1,186 @@
+#!/usr/bin/env bash
+# This file is meant to be sourced into other scripts and contain some utility functions for docker e2e testing
+
+
+CONTAINER_PREFIX=${CONTAINER_PREFIX:-du}
+
+function get-port() {
+ Container=$1
+ Port=$2
+
+ if [ -z "$Container" ]; then
+ echo "CONTAINER missing" 1>&2
+ return 1
+ fi
+
+ if [ -z "$Port" ]; then
+ echo "PORT missing" 1>&2
+ return 1
+ fi
+
+ Query=".[].NetworkSettings.Ports[\"$Port/tcp\"] | .[0].HostPort"
+ docker container inspect "$Container" | jq -r "$Query"
+}
+
+function start-registry() {
+ local Name="$CONTAINER_PREFIX-registry"
+ echo -en "Starting \e[94m$Name\e[0m container... "
+ local Port="${1:-5000}"
+ docker run -d -p 5000:"$Port" --restart=unless-stopped --name "$Name" registry:2
+}
+
+function stop-registry() {
+ try-remove-container "$CONTAINER_PREFIX-registry"
+}
+
+function registry-host() {
+ echo "localhost:$(get-port "$CONTAINER_PREFIX"-registry 5000)"
+}
+
+function try-remove-container() {
+ echo -en "Looking for container \e[95m$1\e[0m... "
+ local Found
+ Found=$(container-id "$1")
+ if [ -n "$Found" ]; then
+ echo "$Found"
+ echo -n " Stopping... "
+ docker stop "$1"
+ echo -n " Removing... "
+ docker rm "$1"
+ else
+ echo "Not found"
+ fi
+}
+
+function create-dummy-image() {
+ if [ -z "$1" ]; then
+ echo "TAG missing"
+ return 1
+ fi
+ local Tag="$1"
+ local Repo
+ Repo="$(registry-host)"
+ local Revision=${2:-$(("$(date +%s)" - "$(date --date='2021-10-21' +%s)"))}
+
+ echo -e "Creating new image \e[95m$Tag\e[0m revision: \e[94m$Revision\e[0m"
+
+ local BuildDir="/tmp/docker-dummy-$Tag-$Revision"
+
+ mkdir -p "$BuildDir"
+
+ cat > "$BuildDir/Dockerfile" << END
+FROM alpine
+
+RUN echo "Tag: $Tag"
+RUN echo "Revision: $Revision"
+ENTRYPOINT ["nc", "-lk", "-v", "-l", "-p", "9090", "-e", "echo", "-e", "HTTP/1.1 200 OK\n\n$Tag $Revision"]
+END
+
+ docker build -t "$Repo/$Tag:latest" -t "$Repo/$Tag:r$Revision" "$BuildDir"
+
+ echo -e "Pushing images...\e[93m"
+ docker push -q "$Repo/$Tag:latest"
+ docker push -q "$Repo/$Tag:r$Revision"
+ echo -en "\e[0m"
+
+ rm -r "$BuildDir"
+}
+
+function query-rev() {
+ local Name=$1
+ if [ -z "$Name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+ curl -s "localhost:$(get-port "$Name" 9090)"
+}
+
+function latest-image-rev() {
+ local Tag=$1
+ if [ -z "$Tag" ]; then
+ echo "TAG missing"
+ return 1
+ fi
+ local ID
+ ID=$(docker image ls "$(registry-host)"/"$Tag":latest -q)
+ docker image inspect "$ID" | jq -r '.[].RepoTags | .[]' | grep -v latest
+}
+
+function container-id() {
+ local Name=$1
+ if [ -z "$Name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+ docker container ls -f name="$Name" -q
+}
+
+function container-started() {
+ local Name=$1
+ if [ -z "$Name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+ docker container inspect "$Name" | jq -r .[].State.StartedAt
+}
+
+
+function container-exists() {
+ local Name=$1
+ if [ -z "$Name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+
+ docker container inspect "$Name" 1> /dev/null 2> /dev/null
+}
+
+function registry-exists() {
+ container-exists "$CONTAINER_PREFIX-registry"
+}
+
+function create-container() {
+ local container_name=$1
+ if [ -z "$container_name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+ local image_name="${2:-$container_name}"
+
+ echo -en "Creating \e[94m$container_name\e[0m container... "
+ local result
+ result=$(docker run -d --name "$container_name" "$(registry-host)/$image_name" 2>&1)
+ if [ "${#result}" -eq 64 ]; then
+ echo -e "\e[92m${result:0:12}\e[0m"
+ return 0
+ else
+ echo -e "\e[91mFailed!\n\e[97m$result\e[0m"
+ return 1
+ fi
+}
+
+function remove-images() {
+ local image_name=$1
+ if [ -z "$image_name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+
+ local images
+ mapfile -t images < <(docker images -q "$image_name" | uniq)
+ if [ -n "${images[*]}" ]; then
+ docker image rm "${images[@]}"
+ else
+ echo "No images matched \"$image_name\""
+ fi
+}
+
+function remove-repo-images() {
+ local image_name=$1
+ if [ -z "$image_name" ]; then
+ echo "NAME missing"
+ return 1
+ fi
+
+ remove-images "$(registry-host)/images/$image_name"
+}
\ No newline at end of file
diff --git a/scripts/du-cli.sh b/scripts/du-cli.sh
new file mode 100755
index 0000000..611f720
--- /dev/null
+++ b/scripts/du-cli.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+
+SCRIPT_ROOT=$(dirname "$(readlink -m "$(type -p "$0")")")
+source "$SCRIPT_ROOT/docker-util.sh"
+
+case $1 in
+ registry | reg | r)
+ case $2 in
+ start)
+ start-registry
+ ;;
+ stop)
+ stop-registry
+ ;;
+ host)
+ registry-host
+ ;;
+ *)
+ echo "Unknown registry action \"$2\""
+ ;;
+ esac
+ ;;
+ image | img | i)
+ case $2 in
+ rev)
+ create-dummy-image "${@:3:2}"
+ ;;
+ latest)
+ latest-image-rev "$3"
+ ;;
+ rm)
+ remove-repo-images "$3"
+ ;;
+ *)
+ echo "Unknown image action \"$2\""
+ ;;
+ esac
+ ;;
+ container | cnt | c)
+ case $2 in
+ query)
+ query-rev "$3"
+ ;;
+ rm)
+ try-remove-container "$3"
+ ;;
+ id)
+ container-id "$3"
+ ;;
+ started)
+ container-started "$3"
+ ;;
+ create)
+ create-container "${@:3:2}"
+ ;;
+ create-stale)
+ if [ -z "$3" ]; then
+ echo "NAME missing"
+ exit 1
+ fi
+ if ! registry-exists; then
+ echo "Registry container missing! Creating..."
+ start-registry || exit 1
+ fi
+ image_name="images/$3"
+ container_name=$3
+ $0 image rev "$image_name" || exit 1
+ $0 container create "$container_name" "$image_name" || exit 1
+ $0 image rev "$image_name" || exit 1
+ ;;
+ *)
+ echo "Unknown container action \"$2\""
+ ;;
+ esac
+ ;;
+ *)
+ echo "Unknown keyword \"$1\""
+ ;;
+esac
\ No newline at end of file
diff --git a/scripts/lifecycle-tests.sh b/scripts/lifecycle-tests.sh
index dd41823..9b2dbd7 100755
--- a/scripts/lifecycle-tests.sh
+++ b/scripts/lifecycle-tests.sh
@@ -89,7 +89,7 @@ builddocker
# Run container
docker run -d -p 0.0.0.0:8888:8888 --name $CONTAINER $IMAGE:latest >> /dev/null
sleep 1
-echo "Container $CONTAINER is runnning"
+echo "Container $CONTAINER is running"
# Test default value
RESP=$(curl -s http://localhost:8888)
@@ -126,7 +126,7 @@ docker run -d -p 0.0.0.0:8888:8888 \
--label=com.centurylinklabs.watchtower.lifecycle.post-update="echo container > /opt/test/value.txt" \
--name $CONTAINER $IMAGE:latest >> /dev/null
sleep 1
-echo "Container $CONTAINER is runnning"
+echo "Container $CONTAINER is running"
# Test default value
RESP=$(curl -s http://localhost:8888)
@@ -170,7 +170,7 @@ docker run -d -p 0.0.0.0:8989:8888 \
--link $CONTAINER \
--name $LINKED_CONTAINER $LINKED_IMAGE:latest >> /dev/null
sleep 1
-echo "Container $CONTAINER and $LINKED_CONTAINER are runnning"
+echo "Container $CONTAINER and $LINKED_CONTAINER are running"
# Test default value
RESP=$(curl -s http://localhost:8888)
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
+}