├── .codacy.yml ├── .devbots └── lock-issue.yml ├── .editorconfig ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── config.yml │ └── feature_request.yml ├── pull_request_template.md ├── stale.yml └── workflows │ ├── publish-docs.yaml │ ├── pull-request.yaml │ ├── release-dev.yaml │ └── release.yaml ├── .gitignore ├── CNAME ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── build.sh ├── cmd ├── notify-upgrade.go └── root.go ├── code_of_conduct.md ├── docker-compose.yml ├── dockerfiles ├── Dockerfile ├── Dockerfile.dev-self-contained ├── Dockerfile.self-contained └── container-networking │ └── docker-compose.yml ├── docs-requirements.txt ├── docs ├── CNAME ├── arguments.md ├── assets │ └── grafana-dashboard.png ├── container-selection.md ├── http-api-mode.md ├── images │ ├── favicon.ico │ └── logo-450px.png ├── index.md ├── introduction.md ├── lifecycle-hooks.md ├── linked-containers.md ├── metrics.md ├── notifications.md ├── private-registries.md ├── remote-hosts.md ├── running-multiple-instances.md ├── secure-connections.md ├── stop-signals.md ├── stylesheets │ └── theme.css ├── template-preview.md ├── updating.md └── usage-overview.md ├── go.mod ├── go.sum ├── gopher-watchtower.png ├── goreleaser.yml ├── grafana ├── dashboards │ ├── dashboard.json │ └── dashboard.yml └── datasources │ └── datasource.yml ├── internal ├── actions │ ├── actions_suite_test.go │ ├── check.go │ ├── mocks │ │ ├── client.go │ │ ├── container.go │ │ └── progress.go │ ├── update.go │ └── update_test.go ├── flags │ ├── flags.go │ └── flags_test.go ├── meta │ └── meta.go └── util │ ├── rand_name.go │ ├── rand_sha256.go │ ├── util.go │ └── util_test.go ├── logo.png ├── main.go ├── mkdocs.yml ├── oryxBuildBinary ├── pkg ├── api │ ├── api.go │ ├── api_test.go │ ├── metrics │ │ ├── metrics.go │ │ └── metrics_test.go │ └── update │ │ └── update.go ├── container │ ├── cgroup_id.go │ ├── cgroup_id_test.go │ ├── client.go │ ├── client_test.go │ ├── container.go │ ├── container_mock_test.go │ ├── container_suite_test.go │ ├── container_test.go │ ├── errors.go │ ├── metadata.go │ ├── mocks │ │ ├── ApiServer.go │ │ ├── FilterableContainer.go │ │ ├── container_ref.go │ │ └── data │ │ │ ├── container_net_consumer-missing_supplier.json │ │ │ ├── container_net_consumer.json │ │ │ ├── container_net_supplier.json │ │ │ ├── container_restarting.json │ │ │ ├── container_running.json │ │ │ ├── container_stopped.json │ │ │ ├── container_watchtower.json │ │ │ ├── containers.json │ │ │ ├── image_default.json │ │ │ ├── image_net_consumer.json │ │ │ ├── image_net_producer.json │ │ │ └── image_running.json │ └── util_test.go ├── filters │ ├── filters.go │ └── filters_test.go ├── lifecycle │ └── lifecycle.go ├── metrics │ └── metrics.go ├── notifications │ ├── common_templates.go │ ├── email.go │ ├── gotify.go │ ├── json.go │ ├── json_test.go │ ├── model.go │ ├── msteams.go │ ├── notifications_suite_test.go │ ├── notifier.go │ ├── notifier_test.go │ ├── preview │ │ ├── data │ │ │ ├── data.go │ │ │ ├── logs.go │ │ │ ├── preview_strings.go │ │ │ ├── report.go │ │ │ └── status.go │ │ └── tplprev.go │ ├── shoutrrr.go │ ├── shoutrrr_test.go │ ├── slack.go │ └── templates │ │ └── funcs.go ├── registry │ ├── auth │ │ ├── auth.go │ │ └── auth_test.go │ ├── digest │ │ ├── digest.go │ │ └── digest_test.go │ ├── helpers │ │ ├── helpers.go │ │ └── helpers_test.go │ ├── manifest │ │ ├── manifest.go │ │ └── manifest_test.go │ ├── registry.go │ ├── registry_suite_test.go │ ├── registry_test.go │ ├── trust.go │ └── trust_test.go ├── session │ ├── container_status.go │ ├── progress.go │ └── report.go ├── sorter │ └── sort.go └── types │ ├── container.go │ ├── convertible_notifier.go │ ├── filter.go │ ├── filterable_container.go │ ├── notifier.go │ ├── registry_credentials.go │ ├── report.go │ ├── token_response.go │ └── update_params.go ├── prometheus └── prometheus.yml ├── renovate.json ├── scripts ├── build-tplprev.sh ├── codecov.sh ├── contnet-tests.sh ├── dependency-test.sh ├── docker-util.sh ├── du-cli.sh └── lifecycle-tests.sh └── tplprev ├── main.go └── main_wasm.go /.codacy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | coverage: 4 | exclude_paths: 5 | - "*.md" 6 | - "**/*.md" -------------------------------------------------------------------------------- /.devbots/lock-issue.yml: -------------------------------------------------------------------------------- 1 | enabled: true 2 | comment: > 3 | To avoid important communication to get lost in a closed issues no one monitors, I'll go ahead and lock this issue. 4 | If you want to continue the discussion, please open a new issue. Thank you! 🙏🏼 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | 8 | [*.css] 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [{go.mod,go.sum,*.go}] 13 | indent_style = tab 14 | indent_size = 4 -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @beatkind 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Create a report to help us improve 3 | labels: ["Priority: Medium, Status: Available, Type: Bug"] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | 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. 9 | 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: Describe the bug 14 | description: A clear and concise description of what the bug is 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | id: reproduce 20 | attributes: 21 | label: Steps to reproduce 22 | description: Steps to reproduce the behavior 23 | value: | 24 | 1. Go to '...' 25 | 2. Click on '....' 26 | 3. Scroll down to '....' 27 | 4. See error 28 | validations: 29 | required: true 30 | 31 | - type: textarea 32 | id: expected 33 | attributes: 34 | label: Expected behavior 35 | description: A clear and concise description of what you expected to happen. 36 | validations: 37 | required: true 38 | 39 | - type: textarea 40 | id: screenshots 41 | attributes: 42 | label: Screenshots 43 | description: Please add screenshots if applicable 44 | validations: 45 | required: false 46 | 47 | - type: textarea 48 | attributes: 49 | label: Environment 50 | description: We would want to know the following things 51 | value: | 52 | - Platform 53 | - Architecture 54 | - Docker Version 55 | validations: 56 | required: true 57 | 58 | - type: textarea 59 | attributes: 60 | label: Your logs 61 | description: Paste the logs from running watchtower with the `--debug` option. 62 | render: text 63 | validations: 64 | required: true 65 | 66 | - type: textarea 67 | attributes: 68 | label: Additional context 69 | description: Add any other context about the problem here. 70 | validations: 71 | required: false 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Ask a question 4 | url: https://github.com/beatkind/watchtower/discussions 5 | about: Ask questions and discuss with other community members 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Feature request 2 | description: Have a new idea/feature ? Please suggest! 3 | labels: ["Priority: Low, Status: Available, Type: Enhancement"] 4 | body: 5 | - type: textarea 6 | id: description 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 10 | validations: 11 | required: true 12 | 13 | - type: textarea 14 | id: solution 15 | attributes: 16 | label: Describe the solution you'd like 17 | description: A clear and concise description of what you want to happen. 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | id: alternatives 23 | attributes: 24 | label: Describe alternatives you've considered 25 | description: A clear and concise description of any alternative solutions or features you've considered. 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | id: extrainfo 31 | attributes: 32 | label: Additional context 33 | description: Add any other context or screenshots about the feature request here. 34 | validations: 35 | required: false 36 | 37 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | daysUntilStale: 60 2 | daysUntilClose: 7 3 | exemptMilestones: true 4 | exemptLabels: 5 | - "Public Service Announcement" 6 | - "Do not close" 7 | - "Type: Bug" 8 | - "Type: Security" 9 | staleLabel: "Status: Stale" 10 | markComment: > 11 | This issue has been automatically marked as stale because it has not had 12 | recent activity. It will be closed if no further activity occurs. Thank you 13 | for your contributions. 14 | closeComment: false 15 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Docs 2 | 3 | on: 4 | workflow_dispatch: { } 5 | workflow_run: 6 | workflows: [ "Release (Production)" ] 7 | branches: [ main ] 8 | types: 9 | - completed 10 | 11 | jobs: 12 | publish-docs: 13 | name: Publish Docs 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: 1.24.x 24 | - name: Build tplprev 25 | run: scripts/build-tplprev.sh 26 | - name: Setup python 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: '3.13' 30 | cache: 'pip' 31 | cache-dependency-path: | 32 | docs-requirements.txt 33 | - name: Install mkdocs 34 | run: | 35 | pip install -r docs-requirements.txt 36 | - name: Generate docs 37 | run: mkdocs gh-deploy --strict 38 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | workflow_dispatch: {} 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | # lint: 14 | # name: Lint 15 | # runs-on: ubuntu-latest 16 | # steps: 17 | # - uses: actions/checkout@v4 18 | # - uses: actions/setup-go@v5 19 | # with: 20 | # go-version: stable 21 | # - name: golangci-lint 22 | # uses: golangci/golangci-lint-action@v6 23 | # with: 24 | # version: v1.60 25 | test: 26 | name: Test 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | go-version: 31 | - 1.24.x 32 | platform: 33 | - macos-latest 34 | - windows-latest 35 | - ubuntu-latest 36 | runs-on: ${{ matrix.platform }} 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v4 40 | with: 41 | fetch-depth: 0 42 | - name: Set up Go 43 | uses: actions/setup-go@v5 44 | with: 45 | go-version: 1.24.x 46 | - name: Run tests 47 | run: | 48 | go test -v -coverprofile coverage.out -covermode atomic ./... 49 | - name: Publish coverage 50 | uses: codecov/codecov-action@v5 51 | with: 52 | token: ${{ secrets.CODECOV_TOKEN }} 53 | build: 54 | name: Build 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v4 59 | with: 60 | fetch-depth: 0 61 | - name: Set up Go 62 | uses: actions/setup-go@v5 63 | with: 64 | go-version: 1.24.x 65 | - name: Build 66 | uses: goreleaser/goreleaser-action@90a3faa9d0182683851fbfa97ca1a2cb983bfca3 # v6 67 | with: 68 | version: v0.155.0 69 | args: --snapshot --skip-publish --debug 70 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release (Production) 2 | 3 | on: 4 | workflow_dispatch: {} 5 | push: 6 | tags: 7 | - "v[0-9]+.[0-9]+.[0-9]+" 8 | - "**/v[0-9]+.[0-9]+.[0-9]+" 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | # lint: 15 | # name: Lint 16 | # runs-on: ubuntu-latest 17 | # steps: 18 | # - uses: actions/checkout@v4 19 | # - uses: actions/setup-go@v5 20 | # with: 21 | # go-version: stable 22 | # - name: golangci-lint 23 | # uses: golangci/golangci-lint-action@v6 24 | # with: 25 | # version: v1.60 26 | 27 | test: 28 | name: Test 29 | strategy: 30 | matrix: 31 | go-version: 32 | - 1.23.x 33 | platform: 34 | - ubuntu-latest 35 | - macos-latest 36 | - windows-latest 37 | runs-on: ${{ matrix.platform }} 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 43 | - name: Set up Go 44 | uses: actions/setup-go@v5 45 | with: 46 | go-version: 1.24.x 47 | - name: Run tests 48 | run: | 49 | go test ./... -coverprofile coverage.out 50 | 51 | build: 52 | name: Build 53 | runs-on: ubuntu-latest 54 | needs: 55 | - test 56 | env: 57 | CGO_ENABLED: 0 58 | TAG: ${{ github.ref_name }} 59 | steps: 60 | - name: Checkout 61 | uses: actions/checkout@v4 62 | with: 63 | fetch-depth: 0 64 | - name: Set up Go 65 | uses: actions/setup-go@v5 66 | with: 67 | go-version: 1.24.x 68 | - name: Login to Docker Hub 69 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 70 | with: 71 | username: ${{ secrets.DOCKERHUB_USERNAME }} 72 | password: ${{ secrets.DOCKERHUB_TOKEN }} 73 | - name: Build 74 | uses: goreleaser/goreleaser-action@90a3faa9d0182683851fbfa97ca1a2cb983bfca3 # v6 75 | with: 76 | version: v0.155.0 77 | args: --debug 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | - name: Enable experimental docker features 81 | run: | 82 | mkdir -p ~/.docker/ && \ 83 | echo '{"experimental": "enabled"}' > ~/.docker/config.json 84 | - name: Create manifest for version 85 | run: | 86 | export DH_TAG=$(git tag --points-at HEAD | sed 's/^v*//') 87 | docker manifest create \ 88 | beatkind/watchtower:$DH_TAG \ 89 | beatkind/watchtower:amd64-$DH_TAG \ 90 | beatkind/watchtower:i386-$DH_TAG \ 91 | beatkind/watchtower:armhf-$DH_TAG \ 92 | beatkind/watchtower:arm64v8-$DH_TAG 93 | - name: Create manifest for latest 94 | run: | 95 | docker manifest create \ 96 | beatkind/watchtower:latest \ 97 | beatkind/watchtower:amd64-latest \ 98 | beatkind/watchtower:i386-latest \ 99 | beatkind/watchtower:armhf-latest \ 100 | beatkind/watchtower:arm64v8-latest 101 | - name: Push manifests to Dockerhub 102 | env: 103 | DOCKER_USER: ${{ secrets.DOCKERHUB_USERNAME }} 104 | DOCKER_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} 105 | run: | 106 | docker login -u $DOCKER_USER -p $DOCKER_TOKEN && \ 107 | docker manifest push beatkind/watchtower:$(echo $TAG | sed 's/^v*//') && \ 108 | docker manifest push beatkind/watchtower:latest 109 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | watchtower 2 | watchtower.exe 3 | vendor 4 | .glide 5 | dist 6 | .idea 7 | .DS_Store 8 | /site 9 | coverage.out 10 | *.coverprofile 11 | 12 | docs/assets/wasm_exec.js 13 | docs/assets/*.wasm 14 | .vscode/settings.json 15 | 16 | .env 17 | .venv 18 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | watchtower.devcdn.net -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Prerequisites 4 | 5 | To contribute code changes to this project you will need the following development kits. 6 | 7 | * [Go](https://golang.org/doc/install) 8 | * [Docker](https://docs.docker.com/engine/installation/) 9 | 10 | As watchtower utilizes go modules for vendor locking, you'll need at least Go 1.11. 11 | You can check your current version of the go language as follows: 12 | 13 | ```bash 14 | ~ $ go version 15 | go version go1.24.x darwin/amd64 16 | ``` 17 | 18 | ## Checking out the code 19 | 20 | Do not place your code in the go source path. 21 | 22 | ```bash 23 | git clone git@github.com:/watchtower.git 24 | cd watchtower 25 | ``` 26 | 27 | ## Building and testing 28 | 29 | watchtower is a go application and is built with go commands. The following commands assume that you are at the root level of your repo. 30 | 31 | ```bash 32 | go build # compiles and packages an executable binary, watchtower 33 | go test ./... -v # runs tests with verbose output 34 | ./watchtower # runs the application (outside of a container) 35 | ``` 36 | 37 | If you dont have it enabled, you'll either have to prefix each command with `GO111MODULE=on` or run `export GO111MODULE=on` before running the commands. [You can read more about modules here.](https://github.com/golang/go/wiki/Modules) 38 | 39 | To build a Watchtower image of your own, use the self-contained Dockerfiles. As the main Dockerfile, they can be found in `dockerfiles/`: 40 | 41 | * `dockerfiles/Dockerfile.dev-self-contained` will build an image based on your current local Watchtower files. 42 | * `dockerfiles/Dockerfile.self-contained` will build an image based on current Watchtower's repository on GitHub. 43 | 44 | e.g.: 45 | 46 | ```bash 47 | sudo docker build . -f dockerfiles/Dockerfile.dev-self-contained -t beatkind/watchtower # to build an image from local files 48 | ``` 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Watchtower 2 | 3 | > [!NOTE] 4 | > This is a fork of the really nice project from [containrrr](https://github.com/containrrr) called [watchtower](https://github.com/containrrr/watchtower). 5 | > 6 | > I am not the original author of this project. I just forked it to make some changes to it and keep it up-to-date as properly as I can. 7 | > 8 | > Contributions, tips and hints are welcome. Just open an issue or a pull request. Please be aware that I am by no means a professional developer. I am just a Platform Engineer. 9 | 10 |
11 | 12 | 13 | A process for automating Docker container base image updates. 14 | 15 |

16 | 17 | [![codecov](https://codecov.io/gh/beatkind/watchtower/branch/main/graph/badge.svg)](https://codecov.io/gh/beatkind/watchtower) 18 | [![GoDoc](https://godoc.org/github.com/beatkind/watchtower?status.svg)](https://godoc.org/github.com/beatkind/watchtower) 19 | [![Go Report Card](https://goreportcard.com/badge/github.com/beatkind/watchtower)](https://goreportcard.com/report/github.com/beatkind/watchtower) 20 | [![latest version](https://img.shields.io/github/tag/beatkind/watchtower.svg)](https://github.com/beatkind/watchtower/releases) 21 | [![Apache-2.0 License](https://img.shields.io/github/license/beatkind/watchtower.svg)](https://www.apache.org/licenses/LICENSE-2.0) 22 | [![Pulls from DockerHub](https://img.shields.io/docker/pulls/beatkind/watchtower.svg)](https://hub.docker.com/r/beatkind/watchtower) 23 | 24 |
25 | 26 | ## Quick Start 27 | 28 | 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. 29 | 30 | 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: 31 | 32 | ```bash 33 | $ docker run --detach \ 34 | --name watchtower \ 35 | --volume /var/run/docker.sock:/var/run/docker.sock \ 36 | beatkind/watchtower 37 | ``` 38 | 39 | 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. 40 | 41 | ## Documentation 42 | 43 | The full documentation is available at . 44 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Nothing here yet. We'll figure it out. Message me if you need something under: . 4 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BINFILE=watchtower 4 | if [ -n "$MSYSTEM" ]; then 5 | BINFILE=watchtower.exe 6 | fi 7 | VERSION=$(git describe --tags) 8 | echo "Building $VERSION..." 9 | go build -o $BINFILE -ldflags "-X github.com/beatkind/watchtower/internal/meta.Version=$VERSION" 10 | -------------------------------------------------------------------------------- /cmd/notify-upgrade.go: -------------------------------------------------------------------------------- 1 | // Package cmd contains the watchtower (sub-)commands 2 | package cmd 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "strings" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/beatkind/watchtower/internal/flags" 13 | "github.com/beatkind/watchtower/pkg/container" 14 | "github.com/beatkind/watchtower/pkg/notifications" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | var notifyUpgradeCommand = NewNotifyUpgradeCommand() 19 | 20 | // NewNotifyUpgradeCommand creates the notify upgrade command for watchtower 21 | func NewNotifyUpgradeCommand() *cobra.Command { 22 | return &cobra.Command{ 23 | Use: "notify-upgrade", 24 | Short: "Upgrade legacy notification configuration to shoutrrr URLs", 25 | Run: runNotifyUpgrade, 26 | } 27 | } 28 | 29 | func runNotifyUpgrade(cmd *cobra.Command, args []string) { 30 | if err := runNotifyUpgradeE(cmd, args); err != nil { 31 | logf("Notification upgrade failed: %v", err) 32 | } 33 | } 34 | 35 | func runNotifyUpgradeE(cmd *cobra.Command, _ []string) error { 36 | f := cmd.Flags() 37 | flags.ProcessFlagAliases(f) 38 | 39 | notifier = notifications.NewNotifier(cmd) 40 | urls := notifier.GetURLs() 41 | 42 | logf("Found notification configurations for: %v", strings.Join(notifier.GetNames(), ", ")) 43 | 44 | outFile, err := os.CreateTemp("/", "watchtower-notif-urls-*") 45 | if err != nil { 46 | return fmt.Errorf("failed to create output file: %v", err) 47 | } 48 | logf("Writing notification URLs to %v", outFile.Name()) 49 | logf("") 50 | 51 | sb := strings.Builder{} 52 | sb.WriteString("WATCHTOWER_NOTIFICATION_URL=") 53 | 54 | for i, u := range urls { 55 | if i != 0 { 56 | sb.WriteRune(' ') 57 | } 58 | sb.WriteString(u) 59 | } 60 | 61 | _, err = fmt.Fprint(outFile, sb.String()) 62 | tryOrLog(err, "Failed to write to output file") 63 | 64 | tryOrLog(outFile.Sync(), "Failed to sync output file") 65 | tryOrLog(outFile.Close(), "Failed to close output file") 66 | 67 | containerID := "" 68 | cid, err := container.GetRunningContainerID() 69 | tryOrLog(err, "Failed to get running container ID") 70 | if cid != "" { 71 | containerID = cid.ShortID() 72 | } 73 | logf("To get the environment file, use:") 74 | logf("cp %v:%v ./watchtower-notifications.env", containerID, outFile.Name()) 75 | logf("") 76 | logf("Note: This file will be removed in 5 minutes or when this container is stopped!") 77 | 78 | signalChannel := make(chan os.Signal, 1) 79 | time.AfterFunc(5*time.Minute, func() { 80 | signalChannel <- syscall.SIGALRM 81 | }) 82 | 83 | signal.Notify(signalChannel, os.Interrupt) 84 | signal.Notify(signalChannel, syscall.SIGTERM) 85 | 86 | switch <-signalChannel { 87 | case syscall.SIGALRM: 88 | logf("Timed out!") 89 | case os.Interrupt, syscall.SIGTERM: 90 | logf("Stopping...") 91 | default: 92 | } 93 | 94 | if err := os.Remove(outFile.Name()); err != nil { 95 | logf("Failed to remove file, it may still be present in the container image! Error: %v", err) 96 | } else { 97 | logf("Environment file has been removed.") 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func tryOrLog(err error, message string) { 104 | if err != nil { 105 | logf("%v: %v\n", message, err) 106 | } 107 | } 108 | 109 | func logf(format string, v ...interface{}) { 110 | fmt.Fprintln(os.Stderr, fmt.Sprintf(format, v...)) 111 | } 112 | -------------------------------------------------------------------------------- /code_of_conduct.md: -------------------------------------------------------------------------------- 1 | # Containrrr Community Code of Conduct 2 | 3 | Nothing here yet. We'll figure it our, once it is at that point. 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | watchtower: 5 | container_name: watchtower 6 | build: 7 | context: ./ 8 | dockerfile: dockerfiles/Dockerfile.dev-self-contained 9 | volumes: 10 | - /var/run/docker.sock:/var/run/docker.sock:ro 11 | ports: 12 | - 8080:8080 13 | command: --interval 10 --http-api-metrics --http-api-token demotoken --debug prometheus grafana parent child 14 | prometheus: 15 | container_name: prometheus 16 | image: prom/prometheus 17 | volumes: 18 | - ./prometheus/:/etc/prometheus/ 19 | - prometheus:/prometheus/ 20 | ports: 21 | - 9090:9090 22 | grafana: 23 | container_name: grafana 24 | image: grafana/grafana 25 | ports: 26 | - 3000:3000 27 | environment: 28 | GF_INSTALL_PLUGINS: grafana-clock-panel,grafana-simple-json-datasource 29 | volumes: 30 | - grafana:/var/lib/grafana 31 | - ./grafana:/etc/grafana/provisioning 32 | parent: 33 | image: nginx 34 | container_name: parent 35 | child: 36 | image: nginx:alpine 37 | labels: 38 | com.centurylinklabs.watchtower.depends-on: parent 39 | container_name: child 40 | 41 | volumes: 42 | prometheus: {} 43 | grafana: {} 44 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM alpine:3.21.3 as alpine 2 | 3 | RUN apk add --no-cache \ 4 | ca-certificates \ 5 | tzdata 6 | 7 | FROM scratch 8 | LABEL "com.centurylinklabs.watchtower"="true" 9 | 10 | COPY --from=alpine \ 11 | /etc/ssl/certs/ca-certificates.crt \ 12 | /etc/ssl/certs/ca-certificates.crt 13 | COPY --from=alpine \ 14 | /usr/share/zoneinfo \ 15 | /usr/share/zoneinfo 16 | 17 | EXPOSE 8080 18 | 19 | COPY watchtower / 20 | 21 | HEALTHCHECK CMD [ "/watchtower", "--health-check"] 22 | 23 | ENTRYPOINT ["/watchtower"] 24 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.dev-self-contained: -------------------------------------------------------------------------------- 1 | # 2 | # Builder 3 | # 4 | 5 | FROM golang:alpine as builder 6 | 7 | # use version (for example "v0.3.3") or "main" 8 | ARG WATCHTOWER_VERSION=main 9 | 10 | # Pre download required modules to avoid redownloading at each build thanks to docker layer caching. 11 | # Copying go.mod and go.sum ensure to invalid the layer/build cache if there is a change in module requirement 12 | WORKDIR /watchtower 13 | COPY go.mod . 14 | COPY go.sum . 15 | RUN go mod download 16 | 17 | RUN apk add --no-cache \ 18 | alpine-sdk \ 19 | ca-certificates \ 20 | git \ 21 | tzdata 22 | 23 | COPY . /watchtower 24 | 25 | RUN \ 26 | cd /watchtower && \ 27 | \ 28 | GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -X github.com/beatkind/watchtower/internal/meta.Version=$(git describe --tags)" . && \ 29 | GO111MODULE=on go test ./... -v 30 | 31 | 32 | # 33 | # watchtower 34 | # 35 | 36 | FROM scratch 37 | 38 | LABEL "com.centurylinklabs.watchtower"="true" 39 | 40 | # copy files from other container 41 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 42 | COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo 43 | COPY --from=builder /watchtower/watchtower /watchtower 44 | 45 | HEALTHCHECK CMD [ "/watchtower", "--health-check"] 46 | 47 | ENTRYPOINT ["/watchtower"] 48 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.self-contained: -------------------------------------------------------------------------------- 1 | # 2 | # Builder 3 | # 4 | 5 | FROM golang:alpine as builder 6 | 7 | # use version (for example "v0.3.3") or "main" 8 | ARG WATCHTOWER_VERSION=main 9 | 10 | RUN apk add --no-cache \ 11 | alpine-sdk \ 12 | ca-certificates \ 13 | git \ 14 | tzdata 15 | 16 | RUN git clone --branch "${WATCHTOWER_VERSION}" https://github.com/beatkind/watchtower.git 17 | 18 | RUN \ 19 | cd watchtower && \ 20 | \ 21 | GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -X github.com/beatkind/watchtower/internal/meta.Version=$(git describe --tags)" . && \ 22 | GO111MODULE=on go test ./... -v 23 | 24 | 25 | # 26 | # watchtower 27 | # 28 | 29 | FROM scratch 30 | 31 | LABEL "com.centurylinklabs.watchtower"="true" 32 | 33 | # copy files from other container 34 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 35 | COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo 36 | COPY --from=builder /go/watchtower/watchtower /watchtower 37 | 38 | HEALTHCHECK CMD [ "/watchtower", "--health-check"] 39 | 40 | ENTRYPOINT ["/watchtower"] 41 | -------------------------------------------------------------------------------- /dockerfiles/container-networking/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | producer: 3 | image: qmcgaw/gluetun:v3.40.0 4 | cap_add: 5 | - NET_ADMIN 6 | environment: 7 | - VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER} 8 | - OPENVPN_USER=${OPENVPN_USER} 9 | - OPENVPN_PASSWORD=${OPENVPN_PASSWORD} 10 | - SERVER_COUNTRIES=${SERVER_COUNTRIES} 11 | consumer: 12 | depends_on: 13 | - producer 14 | image: nginx:1.28.0 15 | network_mode: "service:producer" 16 | labels: 17 | - "com.centurylinklabs.watchtower.depends-on=/wt-contnet-producer-1" 18 | -------------------------------------------------------------------------------- /docs-requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs 2 | mkdocs-material 3 | md-toc 4 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | watchtower.devcdn.net -------------------------------------------------------------------------------- /docs/assets/grafana-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beatkind/watchtower/6d22ff25bb20ff1bc82ea0c2c6356b35de253310/docs/assets/grafana-dashboard.png -------------------------------------------------------------------------------- /docs/container-selection.md: -------------------------------------------------------------------------------- 1 | # Container Selection 2 | 3 | By default, watchtower will watch all containers. However, sometimes only some containers should be updated. 4 | 5 | There are two options: 6 | 7 | - **Fully exclude**: You can choose to exclude containers entirely from being watched by watchtower. 8 | - **Monitor only**: In this mode, watchtower checks for container updates, sends notifications and invokes the [pre-check/post-check hooks](https://watchtower.devcdn.net/lifecycle-hooks/) on the containers but does **not** perform the update. 9 | 10 | ## Full Exclude 11 | 12 | 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. 13 | 14 | === "dockerfile" 15 | 16 | ```docker 17 | LABEL com.centurylinklabs.watchtower.enable="false" 18 | ``` 19 | === "docker run" 20 | 21 | ```bash 22 | docker run -d --label=com.centurylinklabs.watchtower.enable=false someimage 23 | ``` 24 | 25 | === "docker-compose" 26 | 27 | ``` yaml 28 | services: 29 | someimage: 30 | container_name: someimage 31 | labels: 32 | - "com.centurylinklabs.watchtower.enable=false" 33 | ``` 34 | 35 | If instead you want to [only include containers with the enable label](https://watchtower.devcdn.net/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. 36 | 37 | === "dockerfile" 38 | 39 | ```docker 40 | LABEL com.centurylinklabs.watchtower.enable="true" 41 | ``` 42 | === "docker run" 43 | 44 | ```bash 45 | docker run -d --label=com.centurylinklabs.watchtower.enable=true someimage 46 | ``` 47 | 48 | === "docker-compose" 49 | 50 | ``` yaml 51 | services: 52 | someimage: 53 | container_name: someimage 54 | labels: 55 | - "com.centurylinklabs.watchtower.enable=true" 56 | ``` 57 | 58 | If you wish to create a monitoring scope, you will need to [run multiple instances and set a scope for each of them](https://watchtower.devcdn.net/running-multiple-instances). 59 | 60 | Watchtower filters running containers by testing them against each configured criteria. A container is monitored if all criteria are met. For example: 61 | 62 | - 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; 63 | - 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; 64 | 65 | ## Monitor Only 66 | 67 | Individual containers can be marked to only be monitored (without being updated). 68 | 69 | To do so, set the *com.centurylinklabs.watchtower.monitor-only* label to `true` on that container. 70 | 71 | ```docker 72 | LABEL com.centurylinklabs.watchtower.monitor-only="true" 73 | ``` 74 | 75 | Or, it can be specified as part of the `docker run` command line: 76 | 77 | ```bash 78 | docker run -d --label=com.centurylinklabs.watchtower.monitor-only=true someimage 79 | ``` 80 | 81 | When the label is specified on a container, watchtower treats that container exactly as if [`WATCHTOWER_MONITOR_ONLY`](https://watchtower.devcdn.net/arguments/#without_updating_containers) was set, but the effect is limited to the individual container. 82 | -------------------------------------------------------------------------------- /docs/http-api-mode.md: -------------------------------------------------------------------------------- 1 | # HTTP API Mode 2 | 3 | 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: 4 | 5 | - `/v1/update` - triggers an update for all of the containers monitored by this Watchtower instance. 6 | 7 | --- 8 | 9 | To enable this mode, use the flag `--http-api-update`. For example, in a Docker Compose config file: 10 | 11 | ```yaml 12 | 13 | 14 | services: 15 | app-monitored-by-watchtower: 16 | image: myapps/monitored-by-watchtower 17 | labels: 18 | - "com.centurylinklabs.watchtower.enable=true" 19 | 20 | watchtower: 21 | image: beatkind/watchtower 22 | volumes: 23 | - /var/run/docker.sock:/var/run/docker.sock 24 | command: --debug --http-api-update 25 | environment: 26 | - WATCHTOWER_HTTP_API_TOKEN=mytoken 27 | labels: 28 | - "com.centurylinklabs.watchtower.enable=false" 29 | ports: 30 | - 8080:8080 31 | ``` 32 | 33 | 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`. 34 | 35 | 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: 36 | 37 | ```bash 38 | curl -H "Authorization: Bearer mytoken" localhost:8080/v1/update 39 | ``` 40 | 41 | --- 42 | 43 | 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`: 44 | 45 | ```bash 46 | curl -H "Authorization: Bearer mytoken" localhost:8080/v1/update?image=foo/bar,foo/baz 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beatkind/watchtower/6d22ff25bb20ff1bc82ea0c2c6356b35de253310/docs/images/favicon.ico -------------------------------------------------------------------------------- /docs/images/logo-450px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beatkind/watchtower/6d22ff25bb20ff1bc82ea0c2c6356b35de253310/docs/images/logo-450px.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | Logotype depicting a lighthouse 3 |

4 |

5 | Watchtower 6 |

7 | 8 |

9 | A container-based solution for automating Docker container base image updates. 10 |

11 | 12 | Codecov 13 | 14 | 15 | GoDoc 16 | 17 | 18 | Go Report Card 19 | 20 | 21 | latest version 22 | 23 | 24 | Apache-2.0 License 25 | 26 | 27 | Pulls from DockerHub 28 | 29 |

30 | 31 | # Overview 32 | 33 | !!! note "Watchtower fork" 34 | This is a fork of the really nice project from [containrrr](https://github.com/containrrr) called [watchtower](https://github.com/containrrr/watchtower). 35 | I am not the original author of this project. I just forked it to make some changes to it and keep it up-to-date as properly as I can. 36 | Contributions, tips and hints are welcome. Just open an issue or a pull request. Please be aware that I am by no means a professional developer. I am just a Platform Engineer. 37 | 38 | ## Quick Start 39 | 40 | With watchtower you can update the running version of your containerized app simply by pushing a new image to the Docker 41 | Hub or your own image registry. Watchtower will pull down your new image, gracefully shut down your existing container 42 | and restart it with the same options that were used when it was deployed initially. Run the watchtower container with 43 | the following command: 44 | 45 | === "docker run" 46 | 47 | ```bash 48 | $ docker run -d \ 49 | --name watchtower \ 50 | -v /var/run/docker.sock:/var/run/docker.sock \ 51 | beatkind/watchtower 52 | ``` 53 | 54 | === "docker-compose.yml" 55 | 56 | ```yaml 57 | services: 58 | watchtower: 59 | image: beatkind/watchtower 60 | volumes: 61 | - /var/run/docker.sock:/var/run/docker.sock 62 | ``` 63 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Watchtower is an application that will monitor your running Docker containers and watch for changes to the images that those containers were originally started from. If watchtower detects that an image has changed, it will automatically restart the container using the new image. 4 | 5 | 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. 6 | 7 | For example, let's say you were running watchtower along with an instance of _centurylink/wetty-cli_ image: 8 | 9 | ```text 10 | $ docker ps 11 | CONTAINER ID IMAGE STATUS PORTS NAMES 12 | 967848166a45 centurylink/wetty-cli Up 10 minutes 0.0.0.0:8080->3000/tcp wetty 13 | 6cc4d2a9d1a5 beatkind/watchtower Up 15 minutes watchtower 14 | ``` 15 | 16 | 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). 17 | -------------------------------------------------------------------------------- /docs/lifecycle-hooks.md: -------------------------------------------------------------------------------- 1 | # Executing commands before and after updating 2 | 3 | !!! note 4 | These are shell commands executed with `sh`, and therefore require the container to provide the `sh` 5 | executable. 6 | 7 | > **DO NOTE**: If the container is not running then lifecycle hooks can not run and therefore 8 | > the update is executed without running any lifecycle hooks. 9 | 10 | It is possible to execute _pre/post\-check_ and _pre/post\-update_ commands 11 | **inside** every container updated by watchtower. 12 | 13 | - The _pre-check_ command is executed for each container prior to every update cycle. 14 | - The _pre-update_ command is executed before stopping the container when an update is about to start. 15 | - The _post-update_ command is executed after restarting the updated container 16 | - The _post-check_ command is executed for each container post every update cycle. 17 | 18 | This feature is disabled by default. To enable it, you need to set the option 19 | `--enable-lifecycle-hooks` on the command line, or set the environment variable 20 | `WATCHTOWER_LIFECYCLE_HOOKS` to `true`. 21 | 22 | ## Specifying update commands 23 | 24 | The commands are specified using docker container labels, the following are currently available: 25 | 26 | | Type | Docker Container Label | 27 | | ----------- | ------------------------------------------------------ | 28 | | Pre Check | `com.centurylinklabs.watchtower.lifecycle.pre-check` | 29 | | Pre Update | `com.centurylinklabs.watchtower.lifecycle.pre-update` | 30 | | Post Update | `com.centurylinklabs.watchtower.lifecycle.post-update` | 31 | | Post Check | `com.centurylinklabs.watchtower.lifecycle.post-check` | 32 | 33 | These labels can be declared as instructions in a Dockerfile (with some example .sh files) or be specified as part of 34 | the `docker run` command line: 35 | 36 | === "Dockerfile" 37 | ```docker 38 | LABEL com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh" 39 | LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh" 40 | LABEL com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh" 41 | LABEL com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh" 42 | ``` 43 | 44 | === "docker run" 45 | ```bash 46 | docker run -d \ 47 | --label=com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh" \ 48 | --label=com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh" \ 49 | --label=com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh" \ 50 | someimage --label=com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh" \ 51 | ``` 52 | 53 | ## Timeouts 54 | 55 | The timeout for all lifecycle commands is 60 seconds. After that, a timeout will 56 | occur, forcing Watchtower to continue the update loop. 57 | 58 | ### Pre- or Post-update timeouts 59 | 60 | For the `pre-update` or `post-update` lifecycle command, it is possible to override this timeout to 61 | allow the script to finish before forcefully killing it. This is done by adding the 62 | label `com.centurylinklabs.watchtower.lifecycle.pre-update-timeout` or post-update-timeout respectively followed by 63 | the timeout expressed in minutes. 64 | 65 | If the label value is explicitly set to `0`, the timeout will be disabled. 66 | 67 | ## Execution failure 68 | 69 | The failure of a command to execute, identified by an exit code different than 70 | 0 or 75 (EX_TEMPFAIL), will not prevent watchtower from updating the container. Only an error 71 | log statement containing the exit code will be reported. 72 | -------------------------------------------------------------------------------- /docs/linked-containers.md: -------------------------------------------------------------------------------- 1 | # Linked Containers 2 | 3 | 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. 4 | 5 | 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. 6 | 7 | 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. 8 | 9 | When you have a depending container that is using `network_mode: service:container` then watchtower will treat that container as an implicit link. 10 | -------------------------------------------------------------------------------- /docs/metrics.md: -------------------------------------------------------------------------------- 1 | # Watchtower Metrics 2 | 3 | !!! warning "Experimental feature" 4 | This feature was added in v1.0.4 and is still considered experimental. If you notice any strange behavior, please raise 5 | a ticket in the repository issues. 6 | 7 | Metrics can be used to track how Watchtower behaves over time. 8 | 9 | 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), 10 | as well as creating a port mapping for your container for port `8080`. 11 | 12 | The metrics API endpoint is `/v1/metrics`. 13 | 14 | ## Available Metrics 15 | 16 | | Name | Type | Description | 17 | | ------------------------------- | ------- | --------------------------------------------------------------------------- | 18 | | `watchtower_containers_scanned` | Gauge | Number of containers scanned for changes by watchtower during the last scan | 19 | | `watchtower_containers_updated` | Gauge | Number of containers updated by watchtower during the last scan | 20 | | `watchtower_containers_failed` | Gauge | Number of containers where update failed during the last scan | 21 | | `watchtower_scans_total` | Counter | Number of scans since the watchtower started | 22 | | `watchtower_scans_skipped` | Counter | Number of skipped scans since watchtower started | 23 | 24 | ## Example Prometheus `scrape_config` 25 | 26 | ```yaml 27 | scrape_configs: 28 | - job_name: watchtower 29 | scrape_interval: 5s 30 | metrics_path: /v1/metrics 31 | bearer_token: demotoken 32 | static_configs: 33 | - targets: 34 | - 'watchtower:8080' 35 | ``` 36 | 37 | Replace `demotoken` with the Bearer token you have set accordingly. 38 | 39 | ## Demo 40 | 41 | The repository contains a demo with prometheus and grafana, available through `docker-compose.yml`. This demo 42 | is preconfigured with a dashboard, which will look something like this: 43 | 44 | ![grafana metrics](assets/grafana-dashboard.png) 45 | -------------------------------------------------------------------------------- /docs/remote-hosts.md: -------------------------------------------------------------------------------- 1 | # Remote Docker Hosts 2 | 3 | By default, watchtower is set-up to monitor the local Docker daemon (the same daemon running the watchtower container itself). However, it is possible to configure watchtower to monitor a remote Docker endpoint. When starting the watchtower container you can specify a remote Docker endpoint with either the `--host` flag or the `DOCKER_HOST` environment variable: 4 | 5 | ```bash 6 | docker run -d \ 7 | --name watchtower \ 8 | beatkind/watchtower --host "tcp://10.0.1.2:2375" 9 | ``` 10 | 11 | or 12 | 13 | ```bash 14 | docker run -d \ 15 | --name watchtower \ 16 | -e DOCKER_HOST="tcp://10.0.1.2:2375" \ 17 | beatkind/watchtower 18 | ``` 19 | 20 | Note in both of the examples above that it is unnecessary to mount the _/var/run/docker.sock_ into the watchtower container. 21 | -------------------------------------------------------------------------------- /docs/running-multiple-instances.md: -------------------------------------------------------------------------------- 1 | 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://watchtower.devcdn.net/arguments/#filter_by_scope) to each running instance. 2 | 3 | !!! note 4 | - Multiple instances can't run with the same scope; 5 | - An instance without a scope will clean up other running instances, even if they have a defined scope; 6 | - 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. 7 | 8 | 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). 9 | 10 | For example, in a Docker Compose config file: 11 | 12 | ```yaml 13 | 14 | 15 | services: 16 | app-with-scope: 17 | image: myapps/monitored-by-watchtower 18 | labels: [ "com.centurylinklabs.watchtower.scope=myscope" ] 19 | 20 | scoped-watchtower: 21 | image: beatkind/watchtower 22 | volumes: [ "/var/run/docker.sock:/var/run/docker.sock" ] 23 | command: --interval 30 --scope myscope 24 | labels: [ "com.centurylinklabs.watchtower.scope=myscope" ] 25 | 26 | unscoped-app-a: 27 | image: myapps/app-a 28 | 29 | unscoped-app-b: 30 | image: myapps/app-b 31 | labels: [ "com.centurylinklabs.watchtower.scope=none" ] 32 | 33 | unscoped-app-c: 34 | image: myapps/app-b 35 | labels: [ "com.centurylinklabs.watchtower.scope=" ] 36 | 37 | unscoped-watchtower: 38 | image: beatkind/watchtower 39 | volumes: [ "/var/run/docker.sock:/var/run/docker.sock" ] 40 | command: --interval 30 --scope none 41 | ``` 42 | -------------------------------------------------------------------------------- /docs/secure-connections.md: -------------------------------------------------------------------------------- 1 | # Secure Connections 2 | 3 | Watchtower is also capable of connecting to Docker endpoints which are protected by SSL/TLS. If you've used _docker-machine_ to provision your remote Docker host, you simply need to volume mount the certificates generated by _docker-machine_ into the watchtower container and optionally specify `--tlsverify` flag. 4 | 5 | The _docker-machine_ certificates for a particular host can be located by executing the `docker-machine env` command for the desired host (note the values for the `DOCKER_HOST` and `DOCKER_CERT_PATH` environment variables that are returned from this command). The directory containing the certificates for the remote host needs to be mounted into the watchtower container at _/etc/ssl/docker_. 6 | 7 | With the certificates mounted into the watchtower container you need to specify the `--tlsverify` flag to enable verification of the certificate: 8 | 9 | ```bash 10 | docker run -d \ 11 | --name watchtower \ 12 | -e DOCKER_HOST=$DOCKER_HOST \ 13 | -e DOCKER_CERT_PATH=/etc/ssl/docker \ 14 | -v $DOCKER_CERT_PATH:/etc/ssl/docker \ 15 | beatkind/watchtower --tlsverify 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/stop-signals.md: -------------------------------------------------------------------------------- 1 | # Stop signals 2 | 3 | When watchtower detects that a running container needs to be updated it will stop the container by sending it a SIGTERM signal. 4 | If your container should be shutdown with a different signal you can communicate this to watchtower by setting a label named _com.centurylinklabs.watchtower.stop-signal_ with the value of the desired signal. 5 | 6 | This label can be coded directly into your image by using the `LABEL` instruction in your Dockerfile: 7 | 8 | ```docker 9 | LABEL com.centurylinklabs.watchtower.stop-signal="SIGHUP" 10 | ``` 11 | 12 | Or, it can be specified as part of the `docker run` command line: 13 | 14 | ```bash 15 | docker run -d --label=com.centurylinklabs.watchtower.stop-signal=SIGHUP someimage 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/stylesheets/theme.css: -------------------------------------------------------------------------------- 1 | [data-md-color-scheme="containrrr"] { 2 | /* Primary and accent */ 3 | --md-primary-fg-color: #406170; 4 | --md-primary-fg-color--light:#acbfc7; 5 | --md-primary-fg-color--dark: #003343; 6 | --md-accent-fg-color: #003343; 7 | --md-accent-fg-color--transparent: #00334310; 8 | 9 | /* Typeset overrides */ 10 | --md-typeset-a-color: var(--md-primary-fg-color); 11 | } 12 | 13 | [data-md-color-scheme="containrrr-dark"] { 14 | --md-hue: 199; 15 | 16 | /* Primary and accent */ 17 | --md-primary-fg-color: hsl(199deg 27% 35% / 100%); 18 | --md-primary-fg-color--link: hsl(199deg 45% 65% / 100%); 19 | --md-primary-fg-color--light: hsl(198deg 19% 73% / 100%); 20 | --md-primary-fg-color--dark: hsl(194deg 100% 13% / 100%); 21 | --md-accent-fg-color: hsl(194deg 45% 50% / 100%); 22 | --md-accent-fg-color--transparent: hsl(194deg 45% 50% / 6.3%); 23 | 24 | /* Default */ 25 | --md-default-fg-color: hsl(var(--md-hue) 75% 95% / 100%); 26 | --md-default-fg-color--light: hsl(var(--md-hue) 75% 90% / 62%); 27 | --md-default-fg-color--lighter: hsl(var(--md-hue) 75% 90% / 32%); 28 | --md-default-fg-color--lightest: hsl(var(--md-hue) 75% 90% / 12%); 29 | --md-default-bg-color: hsl(var(--md-hue) 15% 21% / 100%); 30 | --md-default-bg-color--light: hsl(var(--md-hue) 15% 21% / 54%); 31 | --md-default-bg-color--lighter: hsl(var(--md-hue) 15% 21% / 26%); 32 | --md-default-bg-color--lightest: hsl(var(--md-hue) 15% 21% / 7%); 33 | 34 | /* Code */ 35 | --md-code-fg-color: hsl(var(--md-hue) 18% 86% / 100%); 36 | --md-code-bg-color: hsl(var(--md-hue) 15% 15% / 100%); 37 | --md-code-hl-color: hsl(218deg 100% 63% / 15%); 38 | --md-code-hl-number-color: hsl(346deg 74% 63% / 100%); 39 | --md-code-hl-special-color: hsl(320deg 83% 66% / 100%); 40 | --md-code-hl-function-color: hsl(271deg 57% 65% / 100%); 41 | --md-code-hl-constant-color: hsl(230deg 62% 70% / 100%); 42 | --md-code-hl-keyword-color: hsl(199deg 33% 64% / 100%); 43 | --md-code-hl-string-color: hsl( 50deg 34% 74% / 100%); 44 | --md-code-hl-name-color: var(--md-code-fg-color); 45 | --md-code-hl-operator-color: var(--md-default-fg-color--light); 46 | --md-code-hl-punctuation-color: var(--md-default-fg-color--light); 47 | --md-code-hl-comment-color: var(--md-default-fg-color--light); 48 | --md-code-hl-generic-color: var(--md-default-fg-color--light); 49 | --md-code-hl-variable-color: hsl(241deg 22% 60% / 100%); 50 | 51 | /* Typeset */ 52 | --md-typeset-color: var(--md-default-fg-color); 53 | --md-typeset-a-color: var(--md-primary-fg-color--link); 54 | --md-typeset-mark-color: hsl(218deg 100% 63% / 30%); 55 | --md-typeset-kbd-color: hsl(var(--md-hue) 15% 94% / 12%); 56 | --md-typeset-kbd-accent-color: hsl(var(--md-hue) 15% 94% / 20%); 57 | --md-typeset-kbd-border-color: hsl(var(--md-hue) 15% 14% / 100%); 58 | --md-typeset-table-color: hsl(var(--md-hue) 75% 95% / 12%); 59 | 60 | /* Admonition */ 61 | --md-admonition-fg-color: var(--md-default-fg-color); 62 | --md-admonition-bg-color: var(--md-default-bg-color); 63 | 64 | /* Footer */ 65 | --md-footer-bg-color: hsl(var(--md-hue) 15% 12% / 87%); 66 | --md-footer-bg-color--dark: hsl(var(--md-hue) 15% 10% / 100%); 67 | 68 | /* Shadows */ 69 | --md-shadow-z1: 70 | 0 0.2rem 0.50rem rgba(0 0 0 20%), 71 | 0 0 0.05rem rgba(0 0 0 10%); 72 | --md-shadow-z2: 73 | 0 0.2rem 0.50rem rgba(0 0 0 30%), 74 | 0 0 0.05rem rgba(0 0 0 25%); 75 | --md-shadow-z3: 76 | 0 0.2rem 0.50rem rgba(0 0 0 40%), 77 | 0 0 0.05rem rgba(0 0 0 35%); 78 | } 79 | 80 | .md-header-nav__button.md-logo { 81 | padding: 0; 82 | } 83 | 84 | .md-header-nav__button.md-logo img { 85 | width: 1.6rem; 86 | height: 1.6rem; 87 | } 88 | -------------------------------------------------------------------------------- /docs/updating.md: -------------------------------------------------------------------------------- 1 | # Updating Watchtower 2 | 3 | If watchtower is monitoring the same Docker daemon under which the watchtower container itself is running (i.e. if you 4 | volume-mounted `/var/run/docker.sock` into the watchtower container) then it has the ability to update itself. 5 | If a new version of the `beatkind/watchtower` image is pushed to the Docker Hub, your watchtower will pull down the 6 | new image and restart itself automatically. 7 | -------------------------------------------------------------------------------- /docs/usage-overview.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | Watchtower is itself packaged as a Docker container so installation is as simple as pulling the `beatkind/watchtower` image. If you are using ARM based architecture, pull the appropriate `beatkind/watchtower:armhf-` image from the [beatkind Docker Hub](https://hub.docker.com/r/beatkind/watchtower/tags/). 4 | 5 | Since the watchtower code needs to interact with the Docker API in order to monitor the running containers, you need to mount _/var/run/docker.sock_ into the container with the `-v` flag when you run it. 6 | 7 | !!! warning "Minimum Docker API version" 8 | Watchtower is by default supporting the last supported version of Docker, which can be found [here](https://endoflife.date/docker-engine). The maximum version number for the oldest supported version of Docker can be found inside the [Docker docs](https://docs.docker.com/reference/api/engine/#api-version-matrix). 9 | 10 | If you are using a version of Docker that is older than the minimum supported version, you will need to set the environment variable `DOCKER_API_VERSION` to the minimum supported version. For example, if you are using Docker 24.0, you would set `DOCKER_API_VERSION=1.43`. 11 | 12 | Run the `watchtower` container with the following command: 13 | 14 | ```bash 15 | docker run -d \ 16 | --name watchtower \ 17 | -v /var/run/docker.sock:/var/run/docker.sock \ 18 | beatkind/watchtower 19 | ``` 20 | 21 | If pulling images from private Docker registries, supply registry authentication credentials with the environment variables `REPO_USER` and `REPO_PASS` 22 | or by mounting the host's docker config file into the container (at the root of the container filesystem `/`). 23 | 24 | Passing environment variables: 25 | 26 | ```bash 27 | docker run -d \ 28 | --name watchtower \ 29 | -e REPO_USER=username \ 30 | -e REPO_PASS=password \ 31 | -v /var/run/docker.sock:/var/run/docker.sock \ 32 | beatkind/watchtower container_to_watch --debug 33 | ``` 34 | 35 | Also check out [this Stack Overflow answer](https://stackoverflow.com/a/30494145/7872793) for more options on how to pass environment variables. 36 | 37 | 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: 38 | 39 | ```bash 40 | docker run -d \ 41 | --name watchtower \ 42 | -v $HOME/.docker/config.json:/config.json \ 43 | -v /var/run/docker.sock:/var/run/docker.sock \ 44 | beatkind/watchtower container_to_watch --debug 45 | ``` 46 | 47 | !!! note "Changes to config.json while running" 48 | If you mount `config.json` in the manner above, changes from the host system will (generally) not be propagated to the 49 | running container. Mounting files into the Docker daemon uses bind mounts, which are based on inodes. Most 50 | applications (including `docker login` and `vim`) will not directly edit the file, but instead make a copy and replace 51 | the original file, which results in a new inode which in turn _breaks_ the bind mount. 52 | **As a workaround**, you can create a symlink to your `config.json` file and then mount the symlink in the container. 53 | The symlinked file will always have the same inode, which keeps the bind mount intact and will ensure changes 54 | to the original file are propagated to the running container (regardless of the inode of the source file!). 55 | 56 | If you mount the config file as described above, be sure to also prepend the URL for the registry when starting up your 57 | watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container 58 | from a private repo on the GitHub Registry and monitors it with watchtower. Note the command argument changing the interval 59 | to 30s rather than the default 24 hours. 60 | 61 | ```yaml 62 | 63 | services: 64 | cavo: 65 | image: ghcr.io//: 66 | ports: 67 | - "443:3443" 68 | - "80:3080" 69 | watchtower: 70 | image: beatkind/watchtower 71 | volumes: 72 | - /var/run/docker.sock:/var/run/docker.sock 73 | - /root/.docker/config.json:/config.json 74 | command: --interval 30 75 | ``` 76 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/beatkind/watchtower 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/containrrr/shoutrrr v0.8.0 7 | github.com/distribution/reference v0.6.0 8 | github.com/docker/cli v28.1.1+incompatible 9 | github.com/docker/docker v28.1.1+incompatible 10 | github.com/docker/go-connections v0.5.0 11 | github.com/onsi/ginkgo/v2 v2.23.3 12 | github.com/onsi/gomega v1.36.3 13 | github.com/prometheus/client_golang v1.22.0 14 | github.com/robfig/cron v1.2.0 15 | github.com/sirupsen/logrus v1.9.3 16 | github.com/spf13/cobra v1.9.1 17 | github.com/spf13/pflag v1.0.6 18 | github.com/spf13/viper v1.20.1 19 | github.com/stretchr/testify v1.10.0 20 | golang.org/x/net v0.40.0 21 | ) 22 | 23 | require ( 24 | github.com/containerd/log v0.1.0 // indirect 25 | github.com/felixge/httpsnoop v1.0.4 // indirect 26 | github.com/go-logr/logr v1.4.2 // indirect 27 | github.com/go-logr/stdr v1.2.2 // indirect 28 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 29 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 30 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect 31 | github.com/klauspost/compress v1.18.0 // indirect 32 | github.com/moby/docker-image-spec v1.3.1 // indirect 33 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 34 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 35 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 36 | go.opentelemetry.io/otel v1.35.0 // indirect 37 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 // indirect 38 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 39 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 40 | golang.org/x/tools v0.31.0 // indirect 41 | ) 42 | 43 | require ( 44 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect 45 | github.com/Microsoft/go-winio v0.6.2 // indirect 46 | github.com/beorn7/perks v1.0.1 // indirect 47 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 48 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 49 | github.com/docker/docker-credential-helpers v0.9.3 // indirect 50 | github.com/docker/go-units v0.5.0 // indirect 51 | github.com/fatih/color v1.18.0 // indirect 52 | github.com/fsnotify/fsnotify v1.8.0 // indirect 53 | github.com/gogo/protobuf v1.3.2 // indirect 54 | github.com/google/go-cmp v0.7.0 // indirect 55 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 56 | github.com/mattn/go-colorable v0.1.14 // indirect 57 | github.com/mattn/go-isatty v0.0.20 // indirect 58 | github.com/moby/term v0.5.2 // indirect 59 | github.com/morikuni/aec v1.0.0 // indirect 60 | github.com/opencontainers/go-digest v1.0.0 // indirect 61 | github.com/opencontainers/image-spec v1.1.1 // indirect 62 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 63 | github.com/pkg/errors v0.9.1 // indirect 64 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 65 | github.com/prometheus/client_model v0.6.1 // indirect 66 | github.com/prometheus/common v0.63.0 // indirect 67 | github.com/prometheus/procfs v0.16.0 // indirect 68 | github.com/sagikazarmark/locafero v0.9.0 // indirect 69 | github.com/sourcegraph/conc v0.3.0 // indirect 70 | github.com/spf13/afero v1.14.0 // indirect 71 | github.com/spf13/cast v1.7.1 // indirect 72 | github.com/stretchr/objx v0.5.2 // indirect 73 | github.com/subosito/gotenv v1.6.0 // indirect 74 | go.uber.org/multierr v1.11.0 // indirect 75 | golang.org/x/sys v0.33.0 // indirect 76 | golang.org/x/text v0.25.0 77 | golang.org/x/time v0.11.0 // indirect 78 | google.golang.org/protobuf v1.36.6 // indirect 79 | gopkg.in/yaml.v3 v3.0.1 // indirect 80 | gotest.tools/v3 v3.0.3 // indirect 81 | ) 82 | -------------------------------------------------------------------------------- /gopher-watchtower.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beatkind/watchtower/6d22ff25bb20ff1bc82ea0c2c6356b35de253310/gopher-watchtower.png -------------------------------------------------------------------------------- /goreleaser.yml: -------------------------------------------------------------------------------- 1 | build: 2 | main: ./main.go 3 | binary: watchtower 4 | goos: 5 | - linux 6 | - windows 7 | goarch: 8 | - amd64 9 | - 386 10 | - arm 11 | - arm64 12 | ldflags: 13 | - -s -w -X github.com/beatkind/watchtower/internal/meta.Version={{.Version}} 14 | archives: 15 | - 16 | name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}" 17 | format: tar.gz 18 | replacements: 19 | arm: armhf 20 | arm64: arm64v8 21 | amd64: amd64 22 | 386: 386 23 | darwin: macOS 24 | linux: linux 25 | format_overrides: 26 | - goos: windows 27 | format: zip 28 | files: 29 | - LICENSE.md 30 | dockers: 31 | - 32 | use_buildx: true 33 | build_flag_templates: [ "--platform=linux/amd64" ] 34 | goos: linux 35 | goarch: amd64 36 | goarm: '' 37 | dockerfile: dockerfiles/Dockerfile 38 | image_templates: 39 | - beatkind/watchtower:amd64-{{ .Version }} 40 | - beatkind/watchtower:amd64-latest 41 | - 42 | use_buildx: true 43 | build_flag_templates: [ "--platform=linux/386" ] 44 | goos: linux 45 | goarch: 386 46 | goarm: '' 47 | dockerfile: dockerfiles/Dockerfile 48 | image_templates: 49 | - beatkind/watchtower:i386-{{ .Version }} 50 | - beatkind/watchtower:i386-latest 51 | - 52 | use_buildx: true 53 | build_flag_templates: [ "--platform=linux/arm/v6" ] 54 | goos: linux 55 | goarch: arm 56 | goarm: 6 57 | dockerfile: dockerfiles/Dockerfile 58 | image_templates: 59 | - beatkind/watchtower:armhf-{{ .Version }} 60 | - beatkind/watchtower:armhf-latest 61 | - 62 | use_buildx: true 63 | build_flag_templates: [ "--platform=linux/arm64/v8" ] 64 | goos: linux 65 | goarch: arm64 66 | goarm: '' 67 | dockerfile: dockerfiles/Dockerfile 68 | image_templates: 69 | - beatkind/watchtower:arm64v8-{{ .Version }} 70 | - beatkind/watchtower:arm64v8-latest -------------------------------------------------------------------------------- /grafana/dashboards/dashboard.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: 'Prometheus' 5 | orgId: 1 6 | folder: '' 7 | type: file 8 | disableDeletion: false 9 | editable: true 10 | options: 11 | path: /etc/grafana/provisioning/dashboards -------------------------------------------------------------------------------- /grafana/datasources/datasource.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Prometheus 5 | type: prometheus 6 | access: proxy 7 | url: http://prometheus:9090 8 | isDefault: true -------------------------------------------------------------------------------- /internal/actions/actions_suite_test.go: -------------------------------------------------------------------------------- 1 | package actions_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/sirupsen/logrus" 8 | 9 | "github.com/beatkind/watchtower/internal/actions" 10 | "github.com/beatkind/watchtower/pkg/types" 11 | 12 | . "github.com/beatkind/watchtower/internal/actions/mocks" 13 | . "github.com/onsi/ginkgo/v2" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | func TestActions(t *testing.T) { 18 | RegisterFailHandler(Fail) 19 | logrus.SetOutput(GinkgoWriter) 20 | RunSpecs(t, "Actions Suite") 21 | } 22 | 23 | var _ = Describe("the actions package", func() { 24 | Describe("the check prerequisites method", func() { 25 | When("given an empty array", func() { 26 | It("should not do anything", func() { 27 | client := CreateMockClient( 28 | &TestData{}, 29 | // pullImages: 30 | false, 31 | // removeVolumes: 32 | false, 33 | ) 34 | Expect(actions.CheckForMultipleWatchtowerInstances(client, false, "")).To(Succeed()) 35 | }) 36 | }) 37 | When("given an array of one", func() { 38 | It("should not do anything", func() { 39 | client := CreateMockClient( 40 | &TestData{ 41 | Containers: []types.Container{ 42 | CreateMockContainer( 43 | "test-container", 44 | "test-container", 45 | "watchtower", 46 | time.Now()), 47 | }, 48 | }, 49 | // pullImages: 50 | false, 51 | // removeVolumes: 52 | false, 53 | ) 54 | Expect(actions.CheckForMultipleWatchtowerInstances(client, false, "")).To(Succeed()) 55 | }) 56 | }) 57 | When("given multiple containers", func() { 58 | var client MockClient 59 | BeforeEach(func() { 60 | client = CreateMockClient( 61 | &TestData{ 62 | NameOfContainerToKeep: "test-container-02", 63 | Containers: []types.Container{ 64 | CreateMockContainer( 65 | "test-container-01", 66 | "test-container-01", 67 | "watchtower", 68 | time.Now().AddDate(0, 0, -1)), 69 | CreateMockContainer( 70 | "test-container-02", 71 | "test-container-02", 72 | "watchtower", 73 | time.Now()), 74 | }, 75 | }, 76 | // pullImages: 77 | false, 78 | // removeVolumes: 79 | false, 80 | ) 81 | }) 82 | 83 | It("should stop all but the latest one", func() { 84 | err := actions.CheckForMultipleWatchtowerInstances(client, false, "") 85 | Expect(err).NotTo(HaveOccurred()) 86 | }) 87 | }) 88 | When("deciding whether to cleanup images", func() { 89 | var client MockClient 90 | BeforeEach(func() { 91 | client = CreateMockClient( 92 | &TestData{ 93 | Containers: []types.Container{ 94 | CreateMockContainer( 95 | "test-container-01", 96 | "test-container-01", 97 | "watchtower", 98 | time.Now().AddDate(0, 0, -1)), 99 | CreateMockContainer( 100 | "test-container-02", 101 | "test-container-02", 102 | "watchtower", 103 | time.Now()), 104 | }, 105 | }, 106 | // pullImages: 107 | false, 108 | // removeVolumes: 109 | false, 110 | ) 111 | }) 112 | It("should try to delete the image if the cleanup flag is true", func() { 113 | err := actions.CheckForMultipleWatchtowerInstances(client, true, "") 114 | Expect(err).NotTo(HaveOccurred()) 115 | Expect(client.TestData.TriedToRemoveImage()).To(BeTrue()) 116 | }) 117 | It("should not try to delete the image if the cleanup flag is false", func() { 118 | err := actions.CheckForMultipleWatchtowerInstances(client, false, "") 119 | Expect(err).NotTo(HaveOccurred()) 120 | Expect(client.TestData.TriedToRemoveImage()).To(BeFalse()) 121 | }) 122 | }) 123 | }) 124 | }) 125 | -------------------------------------------------------------------------------- /internal/actions/check.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "time" 7 | 8 | "github.com/beatkind/watchtower/pkg/container" 9 | "github.com/beatkind/watchtower/pkg/filters" 10 | "github.com/beatkind/watchtower/pkg/sorter" 11 | "github.com/beatkind/watchtower/pkg/types" 12 | 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | // CheckForSanity makes sure everything is sane before starting 17 | func CheckForSanity(client container.Client, filter types.Filter, rollingRestarts bool) error { 18 | log.Debug("Making sure everything is sane before starting") 19 | 20 | if rollingRestarts { 21 | containers, err := client.ListContainers(filter) 22 | if err != nil { 23 | return err 24 | } 25 | for _, c := range containers { 26 | if len(c.Links()) > 0 { 27 | return fmt.Errorf( 28 | "%q is depending on at least one other container. This is not compatible with rolling restarts", 29 | c.Name(), 30 | ) 31 | } 32 | } 33 | } 34 | return nil 35 | } 36 | 37 | // CheckForMultipleWatchtowerInstances will ensure that there are not multiple instances of the 38 | // watchtower running simultaneously. If multiple watchtower containers are detected, this function 39 | // will stop and remove all but the most recently started container. This behaviour can be bypassed 40 | // if a scope UID is defined. 41 | func CheckForMultipleWatchtowerInstances(client container.Client, cleanup bool, scope string) error { 42 | filter := filters.WatchtowerContainersFilter 43 | if scope != "" { 44 | filter = filters.FilterByScope(scope, filter) 45 | } 46 | containers, err := client.ListContainers(filter) 47 | 48 | if err != nil { 49 | return err 50 | } 51 | 52 | if len(containers) <= 1 { 53 | log.Debug("There are no additional watchtower containers") 54 | return nil 55 | } 56 | 57 | log.Info("Found multiple running watchtower instances. Cleaning up.") 58 | return cleanupExcessWatchtowers(containers, client, cleanup) 59 | } 60 | 61 | func cleanupExcessWatchtowers(containers []types.Container, client container.Client, cleanup bool) error { 62 | var stopErrors int 63 | 64 | sort.Sort(sorter.ByCreated(containers)) 65 | allContainersExceptLast := containers[0 : len(containers)-1] 66 | 67 | for _, c := range allContainersExceptLast { 68 | if err := client.StopContainer(c, 10*time.Minute); err != nil { 69 | // logging the original here as we're just returning a count 70 | log.WithError(err).Error("Could not stop a previous watchtower instance.") 71 | stopErrors++ 72 | continue 73 | } 74 | 75 | if cleanup { 76 | if err := client.RemoveImageByID(c.ImageID()); err != nil { 77 | log.WithError(err).Warning("Could not cleanup watchtower images, possibly because of other watchtowers instances in other scopes.") 78 | } 79 | } 80 | } 81 | 82 | if stopErrors > 0 { 83 | return fmt.Errorf("%d errors while stopping watchtower containers", stopErrors) 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /internal/actions/mocks/client.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | t "github.com/beatkind/watchtower/pkg/types" 9 | ) 10 | 11 | // MockClient is a mock that passes as a watchtower Client 12 | type MockClient struct { 13 | TestData *TestData 14 | pullImages bool 15 | removeVolumes bool 16 | } 17 | 18 | // TestData is the data used to perform the test 19 | type TestData struct { 20 | TriedToRemoveImageCount int 21 | NameOfContainerToKeep string 22 | Containers []t.Container 23 | Staleness map[string]bool 24 | } 25 | 26 | // TriedToRemoveImage is a test helper function to check whether RemoveImageByID has been called 27 | func (testdata *TestData) TriedToRemoveImage() bool { 28 | return testdata.TriedToRemoveImageCount > 0 29 | } 30 | 31 | // CreateMockClient creates a mock watchtower Client for usage in tests 32 | func CreateMockClient(data *TestData, pullImages bool, removeVolumes bool) MockClient { 33 | return MockClient{ 34 | data, 35 | pullImages, 36 | removeVolumes, 37 | } 38 | } 39 | 40 | // ListContainers is a mock method returning the provided container testdata 41 | func (client MockClient) ListContainers(_ t.Filter) ([]t.Container, error) { 42 | return client.TestData.Containers, nil 43 | } 44 | 45 | // StopContainer is a mock method 46 | func (client MockClient) StopContainer(c t.Container, _ time.Duration) error { 47 | if c.Name() == client.TestData.NameOfContainerToKeep { 48 | return errors.New("tried to stop the instance we want to keep") 49 | } 50 | return nil 51 | } 52 | 53 | // StartContainer is a mock method 54 | func (client MockClient) StartContainer(_ t.Container) (t.ContainerID, error) { 55 | return "", nil 56 | } 57 | 58 | // RenameContainer is a mock method 59 | func (client MockClient) RenameContainer(_ t.Container, _ string) error { 60 | return nil 61 | } 62 | 63 | // RemoveImageByID increments the TriedToRemoveImageCount on being called 64 | func (client MockClient) RemoveImageByID(_ t.ImageID) error { 65 | client.TestData.TriedToRemoveImageCount++ 66 | return nil 67 | } 68 | 69 | // GetContainer is a mock method 70 | func (client MockClient) GetContainer(_ t.ContainerID) (t.Container, error) { 71 | return client.TestData.Containers[0], nil 72 | } 73 | 74 | // ExecuteCommand is a mock method 75 | func (client MockClient) ExecuteCommand(_ t.ContainerID, command string, _ int) (SkipUpdate bool, err error) { 76 | switch command { 77 | case "/PreUpdateReturn0.sh": 78 | return false, nil 79 | case "/PreUpdateReturn1.sh": 80 | return false, fmt.Errorf("command exited with code 1") 81 | case "/PreUpdateReturn75.sh": 82 | return true, nil 83 | default: 84 | return false, nil 85 | } 86 | } 87 | 88 | // IsContainerStale is true if not explicitly stated in TestData for the mock client 89 | func (client MockClient) IsContainerStale(cont t.Container, params t.UpdateParams) (bool, t.ImageID, error) { 90 | stale, found := client.TestData.Staleness[cont.Name()] 91 | if !found { 92 | stale = true 93 | } 94 | return stale, "", nil 95 | } 96 | 97 | // WarnOnHeadPullFailed is always true for the mock client 98 | func (client MockClient) WarnOnHeadPullFailed(_ t.Container) bool { 99 | return true 100 | } 101 | -------------------------------------------------------------------------------- /internal/actions/mocks/progress.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/beatkind/watchtower/pkg/session" 7 | wt "github.com/beatkind/watchtower/pkg/types" 8 | ) 9 | 10 | // CreateMockProgressReport creates a mock report from a given set of container states 11 | // All containers will be given a unique ID and name based on its state and index 12 | func CreateMockProgressReport(states ...session.State) wt.Report { 13 | 14 | stateNums := make(map[session.State]int) 15 | progress := session.Progress{} 16 | failed := make(map[wt.ContainerID]error) 17 | 18 | for _, state := range states { 19 | index := stateNums[state] 20 | 21 | switch state { 22 | case session.SkippedState: 23 | c, _ := CreateContainerForProgress(index, 41, "skip%d") 24 | progress.AddSkipped(c, errors.New("unpossible")) 25 | case session.FreshState: 26 | c, _ := CreateContainerForProgress(index, 31, "frsh%d") 27 | progress.AddScanned(c, c.ImageID()) 28 | case session.UpdatedState: 29 | c, newImage := CreateContainerForProgress(index, 11, "updt%d") 30 | progress.AddScanned(c, newImage) 31 | progress.MarkForUpdate(c.ID()) 32 | case session.FailedState: 33 | c, newImage := CreateContainerForProgress(index, 21, "fail%d") 34 | progress.AddScanned(c, newImage) 35 | failed[c.ID()] = errors.New("accidentally the whole container") 36 | } 37 | 38 | stateNums[state] = index + 1 39 | } 40 | progress.UpdateFailed(failed) 41 | 42 | return progress.Report() 43 | 44 | } 45 | -------------------------------------------------------------------------------- /internal/meta/meta.go: -------------------------------------------------------------------------------- 1 | package meta 2 | 3 | var ( 4 | // Version is the compile-time set version of Watchtower 5 | Version = "v0.0.0-unknown" 6 | 7 | // UserAgent is the http client identifier derived from Version 8 | UserAgent string 9 | ) 10 | 11 | func init() { 12 | UserAgent = "Watchtower/" + Version 13 | } 14 | -------------------------------------------------------------------------------- /internal/util/rand_name.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "math/rand" 4 | 5 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 6 | 7 | // RandName Generates a random, 32-character, Docker-compatible container name. 8 | func RandName() string { 9 | b := make([]rune, 32) 10 | for i := range b { 11 | b[i] = letters[rand.Intn(len(letters))] 12 | } 13 | 14 | return string(b) 15 | } 16 | -------------------------------------------------------------------------------- /internal/util/rand_sha256.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "fmt" 7 | ) 8 | 9 | // GenerateRandomSHA256 generates a random 64 character SHA 256 hash string 10 | func GenerateRandomSHA256() string { 11 | return GenerateRandomPrefixedSHA256()[7:] 12 | } 13 | 14 | // GenerateRandomPrefixedSHA256 generates a random 64 character SHA 256 hash string, prefixed with `sha256:` 15 | func GenerateRandomPrefixedSHA256() string { 16 | hash := make([]byte, 32) 17 | _, _ = rand.Read(hash) 18 | sb := bytes.NewBufferString("sha256:") 19 | sb.Grow(64) 20 | for _, h := range hash { 21 | _, _ = fmt.Fprintf(sb, "%02x", h) 22 | } 23 | return sb.String() 24 | } 25 | -------------------------------------------------------------------------------- /internal/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // SliceEqual compares two slices and checks whether they have equal content 4 | func SliceEqual(s1, s2 []string) bool { 5 | if len(s1) != len(s2) { 6 | return false 7 | } 8 | 9 | for i := range s1 { 10 | if s1[i] != s2[i] { 11 | return false 12 | } 13 | } 14 | 15 | return true 16 | } 17 | 18 | // SliceSubtract subtracts the content of slice a2 from slice a1 19 | func SliceSubtract(a1, a2 []string) []string { 20 | a := []string{} 21 | 22 | for _, e1 := range a1 { 23 | found := false 24 | 25 | for _, e2 := range a2 { 26 | if e1 == e2 { 27 | found = true 28 | break 29 | } 30 | } 31 | 32 | if !found { 33 | a = append(a, e1) 34 | } 35 | } 36 | 37 | return a 38 | } 39 | 40 | // StringMapSubtract subtracts the content of structmap m2 from structmap m1 41 | func StringMapSubtract(m1, m2 map[string]string) map[string]string { 42 | m := map[string]string{} 43 | 44 | for k1, v1 := range m1 { 45 | if v2, ok := m2[k1]; ok { 46 | if v2 != v1 { 47 | m[k1] = v1 48 | } 49 | } else { 50 | m[k1] = v1 51 | } 52 | } 53 | 54 | return m 55 | } 56 | 57 | // StructMapSubtract subtracts the content of structmap m2 from structmap m1 58 | func StructMapSubtract(m1, m2 map[string]struct{}) map[string]struct{} { 59 | m := map[string]struct{}{} 60 | 61 | for k1, v1 := range m1 { 62 | if _, ok := m2[k1]; !ok { 63 | m[k1] = v1 64 | } 65 | } 66 | 67 | return m 68 | } 69 | -------------------------------------------------------------------------------- /internal/util/util_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSliceEqual_True(t *testing.T) { 11 | s1 := []string{"a", "b", "c"} 12 | s2 := []string{"a", "b", "c"} 13 | 14 | result := SliceEqual(s1, s2) 15 | 16 | assert.True(t, result) 17 | } 18 | 19 | func TestSliceEqual_DifferentLengths(t *testing.T) { 20 | s1 := []string{"a", "b", "c"} 21 | s2 := []string{"a", "b", "c", "d"} 22 | 23 | result := SliceEqual(s1, s2) 24 | 25 | assert.False(t, result) 26 | } 27 | 28 | func TestSliceEqual_DifferentContents(t *testing.T) { 29 | s1 := []string{"a", "b", "c"} 30 | s2 := []string{"a", "b", "d"} 31 | 32 | result := SliceEqual(s1, s2) 33 | 34 | assert.False(t, result) 35 | } 36 | 37 | func TestSliceSubtract(t *testing.T) { 38 | a1 := []string{"a", "b", "c"} 39 | a2 := []string{"a", "c"} 40 | 41 | result := SliceSubtract(a1, a2) 42 | assert.Equal(t, []string{"b"}, result) 43 | assert.Equal(t, []string{"a", "b", "c"}, a1) 44 | assert.Equal(t, []string{"a", "c"}, a2) 45 | } 46 | 47 | func TestStringMapSubtract(t *testing.T) { 48 | m1 := map[string]string{"a": "a", "b": "b", "c": "sea"} 49 | m2 := map[string]string{"a": "a", "c": "c"} 50 | 51 | result := StringMapSubtract(m1, m2) 52 | assert.Equal(t, map[string]string{"b": "b", "c": "sea"}, result) 53 | assert.Equal(t, map[string]string{"a": "a", "b": "b", "c": "sea"}, m1) 54 | assert.Equal(t, map[string]string{"a": "a", "c": "c"}, m2) 55 | } 56 | 57 | func TestStructMapSubtract(t *testing.T) { 58 | x := struct{}{} 59 | m1 := map[string]struct{}{"a": x, "b": x, "c": x} 60 | m2 := map[string]struct{}{"a": x, "c": x} 61 | 62 | result := StructMapSubtract(m1, m2) 63 | assert.Equal(t, map[string]struct{}{"b": x}, result) 64 | assert.Equal(t, map[string]struct{}{"a": x, "b": x, "c": x}, m1) 65 | assert.Equal(t, map[string]struct{}{"a": x, "c": x}, m2) 66 | } 67 | 68 | // GenerateRandomSHA256 generates a random 64 character SHA 256 hash string 69 | func TestGenerateRandomSHA256(t *testing.T) { 70 | res := GenerateRandomSHA256() 71 | assert.Len(t, res, 64) 72 | assert.NotContains(t, res, "sha256:") 73 | } 74 | 75 | func TestGenerateRandomPrefixedSHA256(t *testing.T) { 76 | res := GenerateRandomPrefixedSHA256() 77 | assert.Regexp(t, regexp.MustCompile("sha256:[0-9|a-f]{64}"), res) 78 | } 79 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beatkind/watchtower/6d22ff25bb20ff1bc82ea0c2c6356b35de253310/logo.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/beatkind/watchtower/cmd" 5 | log "github.com/sirupsen/logrus" 6 | ) 7 | 8 | func init() { 9 | log.SetLevel(log.InfoLevel) 10 | } 11 | 12 | func main() { 13 | cmd.Execute() 14 | } 15 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Watchtower 2 | site_url: https://watchtower.devcdn.net/ 3 | repo_url: https://github.com/beatkind/watchtower/ 4 | edit_uri: edit/main/docs/ 5 | theme: 6 | name: 'material' 7 | palette: 8 | - media: "(prefers-color-scheme: light)" 9 | scheme: containrrr 10 | toggle: 11 | icon: material/weather-night 12 | name: Switch to dark mode 13 | - media: "(prefers-color-scheme: dark)" 14 | scheme: containrrr-dark 15 | toggle: 16 | icon: material/weather-sunny 17 | name: Switch to light mode 18 | logo: images/logo-450px.png 19 | favicon: images/favicon.ico 20 | extra_css: 21 | - stylesheets/theme.css 22 | markdown_extensions: 23 | - toc: 24 | permalink: True 25 | separator: "_" 26 | - admonition 27 | - pymdownx.highlight 28 | - pymdownx.superfences 29 | - pymdownx.magiclink: 30 | repo_url_shortener: True 31 | provider: github 32 | user: containrrr 33 | repo: watchtower 34 | - pymdownx.saneheaders 35 | - pymdownx.tabbed: 36 | alternate_style: true 37 | nav: 38 | - 'Home': 'index.md' 39 | - 'Introduction': 'introduction.md' 40 | - 'Usage overview': 'usage-overview.md' 41 | - 'Arguments': 'arguments.md' 42 | - 'Notifications': 'notifications.md' 43 | - 'Container selection': 'container-selection.md' 44 | - 'Private registries': 'private-registries.md' 45 | - 'Linked containers': 'linked-containers.md' 46 | - 'Remote hosts': 'remote-hosts.md' 47 | - 'Secure connections': 'secure-connections.md' 48 | - 'Stop signals': 'stop-signals.md' 49 | - 'Lifecycle hooks': 'lifecycle-hooks.md' 50 | - 'Running multiple instances': 'running-multiple-instances.md' 51 | - 'HTTP API Mode': 'http-api-mode.md' 52 | - 'Metrics': 'metrics.md' 53 | plugins: 54 | - search 55 | -------------------------------------------------------------------------------- /oryxBuildBinary: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beatkind/watchtower/6d22ff25bb20ff1bc82ea0c2c6356b35de253310/oryxBuildBinary -------------------------------------------------------------------------------- /pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | const tokenMissingMsg = "api token is empty or has not been set. exiting" 11 | 12 | // API is the http server responsible for serving the HTTP API endpoints 13 | type API struct { 14 | Token string 15 | hasHandlers bool 16 | } 17 | 18 | // New is a factory function creating a new API instance 19 | func New(token string) *API { 20 | return &API{ 21 | Token: token, 22 | hasHandlers: false, 23 | } 24 | } 25 | 26 | // RequireToken is wrapper around http.HandleFunc that checks token validity 27 | func (api *API) RequireToken(fn http.HandlerFunc) http.HandlerFunc { 28 | return func(w http.ResponseWriter, r *http.Request) { 29 | auth := r.Header.Get("Authorization") 30 | want := fmt.Sprintf("Bearer %s", api.Token) 31 | if auth != want { 32 | w.WriteHeader(http.StatusUnauthorized) 33 | return 34 | } 35 | log.Debug("Valid token found.") 36 | fn(w, r) 37 | } 38 | } 39 | 40 | // RegisterFunc is a wrapper around http.HandleFunc that also sets the flag used to determine whether to launch the API 41 | func (api *API) RegisterFunc(path string, fn http.HandlerFunc) { 42 | api.hasHandlers = true 43 | http.HandleFunc(path, api.RequireToken(fn)) 44 | } 45 | 46 | // RegisterHandler is a wrapper around http.Handler that also sets the flag used to determine whether to launch the API 47 | func (api *API) RegisterHandler(path string, handler http.Handler) { 48 | api.hasHandlers = true 49 | http.Handle(path, api.RequireToken(handler.ServeHTTP)) 50 | } 51 | 52 | // Start the API and serve over HTTP. Requires an API Token to be set. 53 | func (api *API) Start(block bool) error { 54 | 55 | if !api.hasHandlers { 56 | log.Debug("Watchtower HTTP API skipped.") 57 | return nil 58 | } 59 | 60 | if api.Token == "" { 61 | log.Fatal(tokenMissingMsg) 62 | } 63 | 64 | if block { 65 | runHTTPServer() 66 | } else { 67 | go func() { 68 | runHTTPServer() 69 | }() 70 | } 71 | return nil 72 | } 73 | 74 | func runHTTPServer() { 75 | log.Fatal(http.ListenAndServe(":8080", nil)) 76 | } 77 | -------------------------------------------------------------------------------- /pkg/api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | const ( 14 | token = "123123123" 15 | ) 16 | 17 | func TestAPI(t *testing.T) { 18 | RegisterFailHandler(Fail) 19 | RunSpecs(t, "API Suite") 20 | } 21 | 22 | var _ = Describe("API", func() { 23 | api := New(token) 24 | 25 | Describe("RequireToken middleware", func() { 26 | It("should return 401 Unauthorized when token is not provided", func() { 27 | handlerFunc := api.RequireToken(testHandler) 28 | 29 | rec := httptest.NewRecorder() 30 | req := httptest.NewRequest("GET", "/hello", nil) 31 | 32 | handlerFunc(rec, req) 33 | 34 | Expect(rec.Code).To(Equal(http.StatusUnauthorized)) 35 | }) 36 | 37 | It("should return 401 Unauthorized when token is invalid", func() { 38 | handlerFunc := api.RequireToken(testHandler) 39 | 40 | rec := httptest.NewRecorder() 41 | req := httptest.NewRequest("GET", "/hello", nil) 42 | req.Header.Set("Authorization", "Bearer 123") 43 | 44 | handlerFunc(rec, req) 45 | 46 | Expect(rec.Code).To(Equal(http.StatusUnauthorized)) 47 | }) 48 | 49 | It("should return 200 OK when token is valid", func() { 50 | handlerFunc := api.RequireToken(testHandler) 51 | 52 | rec := httptest.NewRecorder() 53 | req := httptest.NewRequest("GET", "/hello", nil) 54 | req.Header.Set("Authorization", "Bearer "+token) 55 | 56 | handlerFunc(rec, req) 57 | 58 | Expect(rec.Code).To(Equal(http.StatusOK)) 59 | }) 60 | }) 61 | }) 62 | 63 | func testHandler(w http.ResponseWriter, req *http.Request) { 64 | _, _ = io.WriteString(w, "Hello!") 65 | } 66 | -------------------------------------------------------------------------------- /pkg/api/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "github.com/beatkind/watchtower/pkg/metrics" 5 | "net/http" 6 | 7 | "github.com/prometheus/client_golang/prometheus/promhttp" 8 | ) 9 | 10 | // Handler is an HTTP handle for serving metric data 11 | type Handler struct { 12 | Path string 13 | Handle http.HandlerFunc 14 | Metrics *metrics.Metrics 15 | } 16 | 17 | // New is a factory function creating a new Metrics instance 18 | func New() *Handler { 19 | m := metrics.Default() 20 | handler := promhttp.Handler() 21 | 22 | return &Handler{ 23 | Path: "/v1/metrics", 24 | Handle: handler.ServeHTTP, 25 | Metrics: m, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/api/metrics/metrics_test.go: -------------------------------------------------------------------------------- 1 | package metrics_test 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | 11 | . "github.com/onsi/ginkgo/v2" 12 | . "github.com/onsi/gomega" 13 | 14 | "github.com/beatkind/watchtower/pkg/api" 15 | metricsAPI "github.com/beatkind/watchtower/pkg/api/metrics" 16 | "github.com/beatkind/watchtower/pkg/metrics" 17 | ) 18 | 19 | const ( 20 | token = "123123123" 21 | getURL = "http://localhost:8080/v1/metrics" 22 | ) 23 | 24 | func TestMetrics(t *testing.T) { 25 | RegisterFailHandler(Fail) 26 | RunSpecs(t, "Metrics Suite") 27 | } 28 | 29 | func getWithToken(handler http.Handler) map[string]string { 30 | metricMap := map[string]string{} 31 | respWriter := httptest.NewRecorder() 32 | 33 | req := httptest.NewRequest("GET", getURL, nil) 34 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) 35 | 36 | handler.ServeHTTP(respWriter, req) 37 | 38 | res := respWriter.Result() 39 | body, _ := io.ReadAll(res.Body) 40 | 41 | for _, line := range strings.Split(string(body), "\n") { 42 | if len(line) < 1 || line[0] == '#' { 43 | continue 44 | } 45 | parts := strings.Split(line, " ") 46 | metricMap[parts[0]] = parts[1] 47 | } 48 | 49 | return metricMap 50 | } 51 | 52 | var _ = Describe("the metrics API", func() { 53 | httpAPI := api.New(token) 54 | m := metricsAPI.New() 55 | 56 | handleReq := httpAPI.RequireToken(m.Handle) 57 | tryGetMetrics := func() map[string]string { return getWithToken(handleReq) } 58 | 59 | It("should serve metrics", func() { 60 | 61 | Expect(tryGetMetrics()).To(HaveKeyWithValue("watchtower_containers_updated", "0")) 62 | 63 | metric := &metrics.Metric{ 64 | Scanned: 4, 65 | Updated: 3, 66 | Failed: 1, 67 | } 68 | 69 | metrics.RegisterScan(metric) 70 | Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue()) 71 | 72 | Eventually(tryGetMetrics).Should(SatisfyAll( 73 | HaveKeyWithValue("watchtower_containers_updated", "3"), 74 | HaveKeyWithValue("watchtower_containers_failed", "1"), 75 | HaveKeyWithValue("watchtower_containers_scanned", "4"), 76 | HaveKeyWithValue("watchtower_scans_total", "1"), 77 | HaveKeyWithValue("watchtower_scans_skipped", "0"), 78 | )) 79 | 80 | for i := 0; i < 3; i++ { 81 | metrics.RegisterScan(nil) 82 | } 83 | Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue()) 84 | 85 | Eventually(tryGetMetrics).Should(SatisfyAll( 86 | HaveKeyWithValue("watchtower_scans_total", "4"), 87 | HaveKeyWithValue("watchtower_scans_skipped", "3"), 88 | )) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /pkg/api/update/update.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | "strings" 8 | 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | var ( 13 | lock chan bool 14 | ) 15 | 16 | // New is a factory function creating a new Handler instance 17 | func New(updateFn func(images []string), updateLock chan bool) *Handler { 18 | if updateLock != nil { 19 | lock = updateLock 20 | } else { 21 | lock = make(chan bool, 1) 22 | lock <- true 23 | } 24 | 25 | return &Handler{ 26 | fn: updateFn, 27 | Path: "/v1/update", 28 | } 29 | } 30 | 31 | // Handler is an API handler used for triggering container update scans 32 | type Handler struct { 33 | fn func(images []string) 34 | Path string 35 | } 36 | 37 | // Handle is the actual http.Handle function doing all the heavy lifting 38 | func (handle *Handler) Handle(w http.ResponseWriter, r *http.Request) { 39 | log.Info("Updates triggered by HTTP API request.") 40 | 41 | _, err := io.Copy(os.Stdout, r.Body) 42 | if err != nil { 43 | log.Println(err) 44 | return 45 | } 46 | 47 | var images []string 48 | imageQueries, found := r.URL.Query()["image"] 49 | if found { 50 | for _, image := range imageQueries { 51 | images = append(images, strings.Split(image, ",")...) 52 | } 53 | 54 | } else { 55 | images = nil 56 | } 57 | 58 | if len(images) > 0 { 59 | chanValue := <-lock 60 | defer func() { lock <- chanValue }() 61 | handle.fn(images) 62 | } else { 63 | select { 64 | case chanValue := <-lock: 65 | defer func() { lock <- chanValue }() 66 | handle.fn(images) 67 | default: 68 | log.Debug("Skipped. Another update already running.") 69 | } 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /pkg/container/cgroup_id.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | 8 | "github.com/beatkind/watchtower/pkg/types" 9 | ) 10 | 11 | var dockerContainerPattern = regexp.MustCompile(`[0-9]+:.*:/docker/([a-f|0-9]{64})`) 12 | 13 | // GetRunningContainerID tries to resolve the current container ID from the current process cgroup information 14 | func GetRunningContainerID() (cid types.ContainerID, err error) { 15 | file, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", os.Getpid())) 16 | if err != nil { 17 | return 18 | } 19 | 20 | return getRunningContainerIDFromString(string(file)), nil 21 | } 22 | 23 | func getRunningContainerIDFromString(s string) types.ContainerID { 24 | matches := dockerContainerPattern.FindStringSubmatch(s) 25 | if len(matches) < 2 { 26 | return "" 27 | } 28 | return types.ContainerID(matches[1]) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/container/cgroup_id_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | ) 7 | 8 | var _ = Describe("GetRunningContainerID", func() { 9 | When("a matching container ID is found", func() { 10 | It("should return that container ID", func() { 11 | cid := getRunningContainerIDFromString(` 12 | 15:name=systemd:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 13 | 14:misc:/ 14 | 13:rdma:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 15 | 12:pids:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 16 | 11:hugetlb:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 17 | 10:net_prio:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 18 | 9:perf_event:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 19 | 8:net_cls:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 20 | 7:freezer:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 21 | 6:devices:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 22 | 5:blkio:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 23 | 4:cpuacct:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 24 | 3:cpu:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 25 | 2:cpuset:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 26 | 1:memory:/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 27 | 0::/docker/991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377 28 | `) 29 | Expect(cid).To(BeEquivalentTo(`991b6b42691449d3ce90192ff9f006863dcdafc6195e227aeefa298235004377`)) 30 | }) 31 | }) 32 | When("no matching container ID could be found", func() { 33 | It("should return that container ID", func() { 34 | cid := getRunningContainerIDFromString(`14:misc:/`) 35 | Expect(cid).To(BeEmpty()) 36 | }) 37 | }) 38 | }) 39 | 40 | // 41 | -------------------------------------------------------------------------------- /pkg/container/container_mock_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | dockerContainer "github.com/docker/docker/api/types/container" 5 | "github.com/docker/docker/api/types/image" 6 | "github.com/docker/go-connections/nat" 7 | ) 8 | 9 | type MockContainerUpdate func(*dockerContainer.InspectResponse, *image.InspectResponse) 10 | 11 | func MockContainer(updates ...MockContainerUpdate) *Container { 12 | containerInfo := dockerContainer.InspectResponse{ 13 | ContainerJSONBase: &dockerContainer.ContainerJSONBase{ 14 | ID: "container_id", 15 | Image: "image", 16 | Name: "test-containrrr", 17 | HostConfig: &dockerContainer.HostConfig{}, 18 | }, 19 | Config: &dockerContainer.Config{ 20 | Labels: map[string]string{}, 21 | }, 22 | } 23 | image := image.InspectResponse{ 24 | ID: "image_id", 25 | Config: &dockerContainer.Config{}, 26 | } 27 | 28 | for _, update := range updates { 29 | update(&containerInfo, &image) 30 | } 31 | return NewContainer(&containerInfo, &image) 32 | } 33 | 34 | func WithPortBindings(portBindingSources ...string) MockContainerUpdate { 35 | return func(c *dockerContainer.InspectResponse, i *image.InspectResponse) { 36 | portBindings := nat.PortMap{} 37 | for _, pbs := range portBindingSources { 38 | portBindings[nat.Port(pbs)] = []nat.PortBinding{} 39 | } 40 | c.HostConfig.PortBindings = portBindings 41 | } 42 | } 43 | 44 | func WithImageName(name string) MockContainerUpdate { 45 | return func(c *dockerContainer.InspectResponse, i *image.InspectResponse) { 46 | c.Config.Image = name 47 | i.RepoTags = append(i.RepoTags, name) 48 | } 49 | } 50 | 51 | func WithLinks(links []string) MockContainerUpdate { 52 | return func(c *dockerContainer.InspectResponse, i *image.InspectResponse) { 53 | c.HostConfig.Links = links 54 | } 55 | } 56 | 57 | func WithLabels(labels map[string]string) MockContainerUpdate { 58 | return func(c *dockerContainer.InspectResponse, i *image.InspectResponse) { 59 | c.Config.Labels = labels 60 | } 61 | } 62 | 63 | func WithContainerState(state dockerContainer.State) MockContainerUpdate { 64 | return func(cnt *dockerContainer.InspectResponse, img *image.InspectResponse) { 65 | cnt.State = &state 66 | } 67 | } 68 | 69 | func WithHealthcheck(healthConfig dockerContainer.HealthConfig) MockContainerUpdate { 70 | return func(cnt *dockerContainer.InspectResponse, img *image.InspectResponse) { 71 | cnt.Config.Healthcheck = &healthConfig 72 | } 73 | } 74 | 75 | func WithImageHealthcheck(healthConfig dockerContainer.HealthConfig) MockContainerUpdate { 76 | return func(cnt *dockerContainer.InspectResponse, img *image.InspectResponse) { 77 | img.Config.Healthcheck = &healthConfig 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pkg/container/container_suite_test.go: -------------------------------------------------------------------------------- 1 | package container_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestContainer(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Container Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/container/errors.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import "errors" 4 | 5 | var errorNoImageInfo = errors.New("no available image info") 6 | var errorNoContainerInfo = errors.New("no available container info") 7 | var errorInvalidConfig = errors.New("container configuration missing or invalid") 8 | var errorLabelNotFound = errors.New("label was not found in container") 9 | -------------------------------------------------------------------------------- /pkg/container/metadata.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import "strconv" 4 | 5 | const ( 6 | watchtowerLabel = "com.centurylinklabs.watchtower" 7 | signalLabel = "com.centurylinklabs.watchtower.stop-signal" 8 | enableLabel = "com.centurylinklabs.watchtower.enable" 9 | monitorOnlyLabel = "com.centurylinklabs.watchtower.monitor-only" 10 | noPullLabel = "com.centurylinklabs.watchtower.no-pull" 11 | dependsOnLabel = "com.centurylinklabs.watchtower.depends-on" 12 | zodiacLabel = "com.centurylinklabs.zodiac.original-image" 13 | scope = "com.centurylinklabs.watchtower.scope" 14 | preCheckLabel = "com.centurylinklabs.watchtower.lifecycle.pre-check" 15 | postCheckLabel = "com.centurylinklabs.watchtower.lifecycle.post-check" 16 | preUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.pre-update" 17 | postUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.post-update" 18 | preUpdateTimeoutLabel = "com.centurylinklabs.watchtower.lifecycle.pre-update-timeout" 19 | postUpdateTimeoutLabel = "com.centurylinklabs.watchtower.lifecycle.post-update-timeout" 20 | ) 21 | 22 | // GetLifecyclePreCheckCommand returns the pre-check command set in the container metadata or an empty string 23 | func (c Container) GetLifecyclePreCheckCommand() string { 24 | return c.getLabelValueOrEmpty(preCheckLabel) 25 | } 26 | 27 | // GetLifecyclePostCheckCommand returns the post-check command set in the container metadata or an empty string 28 | func (c Container) GetLifecyclePostCheckCommand() string { 29 | return c.getLabelValueOrEmpty(postCheckLabel) 30 | } 31 | 32 | // GetLifecyclePreUpdateCommand returns the pre-update command set in the container metadata or an empty string 33 | func (c Container) GetLifecyclePreUpdateCommand() string { 34 | return c.getLabelValueOrEmpty(preUpdateLabel) 35 | } 36 | 37 | // GetLifecyclePostUpdateCommand returns the post-update command set in the container metadata or an empty string 38 | func (c Container) GetLifecyclePostUpdateCommand() string { 39 | return c.getLabelValueOrEmpty(postUpdateLabel) 40 | } 41 | 42 | // ContainsWatchtowerLabel takes a map of labels and values and tells 43 | // the consumer whether it contains a valid watchtower instance label 44 | func ContainsWatchtowerLabel(labels map[string]string) bool { 45 | val, ok := labels[watchtowerLabel] 46 | return ok && val == "true" 47 | } 48 | 49 | func (c Container) getLabelValueOrEmpty(label string) string { 50 | if val, ok := c.containerInfo.Config.Labels[label]; ok { 51 | return val 52 | } 53 | return "" 54 | } 55 | 56 | func (c Container) getLabelValue(label string) (string, bool) { 57 | val, ok := c.containerInfo.Config.Labels[label] 58 | return val, ok 59 | } 60 | 61 | func (c Container) getBoolLabelValue(label string) (bool, error) { 62 | if strVal, ok := c.containerInfo.Config.Labels[label]; ok { 63 | value, err := strconv.ParseBool(strVal) 64 | return value, err 65 | } 66 | return false, errorLabelNotFound 67 | } 68 | -------------------------------------------------------------------------------- /pkg/container/mocks/FilterableContainer.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import mock "github.com/stretchr/testify/mock" 4 | 5 | // FilterableContainer is an autogenerated mock type for the FilterableContainer type 6 | type FilterableContainer struct { 7 | mock.Mock 8 | } 9 | 10 | // Enabled provides a mock function with given fields: 11 | func (_m *FilterableContainer) Enabled() (bool, bool) { 12 | ret := _m.Called() 13 | 14 | var r0 bool 15 | if rf, ok := ret.Get(0).(func() bool); ok { 16 | r0 = rf() 17 | } else { 18 | r0 = ret.Get(0).(bool) 19 | } 20 | 21 | var r1 bool 22 | if rf, ok := ret.Get(1).(func() bool); ok { 23 | r1 = rf() 24 | } else { 25 | r1 = ret.Get(1).(bool) 26 | } 27 | 28 | return r0, r1 29 | } 30 | 31 | // IsWatchtower provides a mock function with given fields: 32 | func (_m *FilterableContainer) IsWatchtower() bool { 33 | ret := _m.Called() 34 | 35 | var r0 bool 36 | if rf, ok := ret.Get(0).(func() bool); ok { 37 | r0 = rf() 38 | } else { 39 | r0 = ret.Get(0).(bool) 40 | } 41 | 42 | return r0 43 | } 44 | 45 | // Name provides a mock function with given fields: 46 | func (_m *FilterableContainer) Name() string { 47 | ret := _m.Called() 48 | 49 | var r0 string 50 | if rf, ok := ret.Get(0).(func() string); ok { 51 | r0 = rf() 52 | } else { 53 | r0 = ret.Get(0).(string) 54 | } 55 | 56 | return r0 57 | } 58 | 59 | // Scope provides a mock function with given fields: 60 | func (_m *FilterableContainer) Scope() (string, bool) { 61 | ret := _m.Called() 62 | 63 | var r0 string 64 | 65 | if rf, ok := ret.Get(0).(func() string); ok { 66 | r0 = rf() 67 | } else { 68 | r0 = ret.Get(0).(string) 69 | } 70 | 71 | var r1 bool 72 | 73 | if rf, ok := ret.Get(1).(func() bool); ok { 74 | r1 = rf() 75 | } else { 76 | r1 = ret.Get(1).(bool) 77 | } 78 | 79 | return r0, r1 80 | } 81 | 82 | // ImageName provides a mock function with given fields: 83 | func (_m *FilterableContainer) ImageName() string { 84 | ret := _m.Called() 85 | 86 | var r0 string 87 | if rf, ok := ret.Get(0).(func() string); ok { 88 | r0 = rf() 89 | } else { 90 | r0 = ret.Get(0).(string) 91 | } 92 | 93 | return r0 94 | } 95 | -------------------------------------------------------------------------------- /pkg/container/mocks/container_ref.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | t "github.com/beatkind/watchtower/pkg/types" 8 | ) 9 | 10 | type imageRef struct { 11 | id t.ImageID 12 | file string 13 | } 14 | 15 | func (ir *imageRef) getFileName() string { 16 | return fmt.Sprintf("./mocks/data/image_%v.json", ir.file) 17 | } 18 | 19 | type ContainerRef struct { 20 | name string 21 | id t.ContainerID 22 | image *imageRef 23 | file string 24 | references []*ContainerRef 25 | isMissing bool 26 | } 27 | 28 | func (cr *ContainerRef) getContainerFile() (containerFile string, err error) { 29 | file := cr.file 30 | if file == "" { 31 | file = cr.name 32 | } 33 | 34 | containerFile = fmt.Sprintf("./mocks/data/container_%v.json", file) 35 | _, err = os.Stat(containerFile) 36 | 37 | return containerFile, err 38 | } 39 | 40 | func (cr *ContainerRef) ContainerID() t.ContainerID { 41 | return cr.id 42 | } 43 | -------------------------------------------------------------------------------- /pkg/container/mocks/data/image_default.json: -------------------------------------------------------------------------------- 1 | { 2 | "Id": "sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd", 3 | "RepoTags": [ 4 | "portainer/portainer:latest" 5 | ], 6 | "RepoDigests": [ 7 | "portainer/portainer@sha256:d6cc2c20c0af38d8d557ab994c419c799a10fe825e4aa57fea2e2e507a13747d" 8 | ], 9 | "Parent": "", 10 | "Comment": "", 11 | "Created": "2019-03-05T04:41:17.612066939Z", 12 | "Container": "022100cf79dfee27867d5ff7aa3ff7ecc5cbd486747e808a59b6accd393d65f5", 13 | "ContainerConfig": { 14 | "Hostname": "022100cf79df", 15 | "Domainname": "", 16 | "User": "", 17 | "AttachStdin": false, 18 | "AttachStdout": false, 19 | "AttachStderr": false, 20 | "ExposedPorts": { 21 | "9000/tcp": {} 22 | }, 23 | "Tty": false, 24 | "OpenStdin": false, 25 | "StdinOnce": false, 26 | "Env": [ 27 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 28 | ], 29 | "Cmd": [ 30 | "/bin/sh", 31 | "-c", 32 | "#(nop) ", 33 | "ENTRYPOINT [\"/portainer\"]" 34 | ], 35 | "Image": "sha256:9cf3ead5068a16f1bc1e18d6e730940f05fd59f60dfe1f6b3a5956196191dc77", 36 | "Volumes": { 37 | "/data": {} 38 | }, 39 | "WorkingDir": "/", 40 | "Entrypoint": [ 41 | "/portainer" 42 | ], 43 | "OnBuild": null, 44 | "Labels": {} 45 | }, 46 | "DockerVersion": "18.09.2", 47 | "Author": "", 48 | "Config": { 49 | "Hostname": "", 50 | "Domainname": "", 51 | "User": "", 52 | "AttachStdin": false, 53 | "AttachStdout": false, 54 | "AttachStderr": false, 55 | "ExposedPorts": { 56 | "9000/tcp": {} 57 | }, 58 | "Tty": false, 59 | "OpenStdin": false, 60 | "StdinOnce": false, 61 | "Env": [ 62 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 63 | ], 64 | "Cmd": null, 65 | "Image": "sha256:9cf3ead5068a16f1bc1e18d6e730940f05fd59f60dfe1f6b3a5956196191dc77", 66 | "Volumes": { 67 | "/data": {} 68 | }, 69 | "WorkingDir": "/", 70 | "Entrypoint": [ 71 | "/portainer" 72 | ], 73 | "OnBuild": null, 74 | "Labels": null 75 | }, 76 | "Architecture": "amd64", 77 | "Os": "linux", 78 | "Size": 74089106, 79 | "VirtualSize": 74089106, 80 | "GraphDriver": { 81 | "Data": { 82 | "LowerDir": "/var/lib/docker/overlay2/6c3f44131f6f13c9ea1a99a1b24bf348f70ba3eef244f29202faef3a2216ac11/diff", 83 | "MergedDir": "/var/lib/docker/overlay2/2e0c03c2476f5b4df855cb8b02a88f76d336d7e0becc3e5193906aaa760687fd/merged", 84 | "UpperDir": "/var/lib/docker/overlay2/2e0c03c2476f5b4df855cb8b02a88f76d336d7e0becc3e5193906aaa760687fd/diff", 85 | "WorkDir": "/var/lib/docker/overlay2/2e0c03c2476f5b4df855cb8b02a88f76d336d7e0becc3e5193906aaa760687fd/work" 86 | }, 87 | "Name": "overlay2" 88 | }, 89 | "RootFS": { 90 | "Type": "layers", 91 | "Layers": [ 92 | "sha256:dd4969f97241b9aefe2a70f560ce399ee9fa0354301c9aef841082ad52161ec5", 93 | "sha256:e7260fd2a5f240122129b2d421726d7a4a2bda0cc292e962b694196af8856f20" 94 | ] 95 | }, 96 | "Metadata": { 97 | "LastTagTime": "0001-01-01T00:00:00Z" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /pkg/container/mocks/data/image_net_consumer.json: -------------------------------------------------------------------------------- 1 | { 2 | "Id": "sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8", 3 | "RepoTags": [ 4 | "nginx:latest" 5 | ], 6 | "RepoDigests": [ 7 | "nginx@sha256:aa0afebbb3cfa473099a62c4b32e9b3fb73ed23f2a75a65ce1d4b4f55a5c2ef2" 8 | ], 9 | "Parent": "", 10 | "Comment": "", 11 | "Created": "2023-03-01T18:43:12.914398123Z", 12 | "Container": "71a4c9a59d252d7c54812429bfe5df477e54e91ebfff1939ae39ecdf055d445c", 13 | "ContainerConfig": { 14 | "Hostname": "71a4c9a59d25", 15 | "Domainname": "", 16 | "User": "", 17 | "AttachStdin": false, 18 | "AttachStdout": false, 19 | "AttachStderr": false, 20 | "ExposedPorts": { 21 | "80/tcp": {} 22 | }, 23 | "Tty": false, 24 | "OpenStdin": false, 25 | "StdinOnce": false, 26 | "Env": [ 27 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", 28 | "NGINX_VERSION=1.23.3", 29 | "NJS_VERSION=0.7.9", 30 | "PKG_RELEASE=1~bullseye" 31 | ], 32 | "Cmd": [ 33 | "/bin/sh", 34 | "-c", 35 | "#(nop) ", 36 | "CMD [\"nginx\" \"-g\" \"daemon off;\"]" 37 | ], 38 | "Image": "sha256:6716b8a33f73b21e193bb63424ea1105eaaa6a8237fefe75570bea18c87a1711", 39 | "Volumes": null, 40 | "WorkingDir": "", 41 | "Entrypoint": [ 42 | "/docker-entrypoint.sh" 43 | ], 44 | "OnBuild": null, 45 | "Labels": { 46 | "maintainer": "NGINX Docker Maintainers " 47 | }, 48 | "StopSignal": "SIGQUIT" 49 | }, 50 | "DockerVersion": "20.10.23", 51 | "Author": "", 52 | "Config": { 53 | "Hostname": "", 54 | "Domainname": "", 55 | "User": "", 56 | "AttachStdin": false, 57 | "AttachStdout": false, 58 | "AttachStderr": false, 59 | "ExposedPorts": { 60 | "80/tcp": {} 61 | }, 62 | "Tty": false, 63 | "OpenStdin": false, 64 | "StdinOnce": false, 65 | "Env": [ 66 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", 67 | "NGINX_VERSION=1.23.3", 68 | "NJS_VERSION=0.7.9", 69 | "PKG_RELEASE=1~bullseye" 70 | ], 71 | "Cmd": [ 72 | "nginx", 73 | "-g", 74 | "daemon off;" 75 | ], 76 | "Image": "sha256:6716b8a33f73b21e193bb63424ea1105eaaa6a8237fefe75570bea18c87a1711", 77 | "Volumes": null, 78 | "WorkingDir": "", 79 | "Entrypoint": [ 80 | "/docker-entrypoint.sh" 81 | ], 82 | "OnBuild": null, 83 | "Labels": { 84 | "maintainer": "NGINX Docker Maintainers " 85 | }, 86 | "StopSignal": "SIGQUIT" 87 | }, 88 | "Architecture": "amd64", 89 | "Os": "linux", 90 | "Size": 141838643, 91 | "VirtualSize": 141838643, 92 | "GraphDriver": { 93 | "Data": { 94 | "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", 95 | "MergedDir": "/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/merged", 96 | "UpperDir": "/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/diff", 97 | "WorkDir": "/var/lib/docker/overlay2/105427179e5628eb7e893d53e21f42f9e76278f8b5665387ecdeed54a7231137/work" 98 | }, 99 | "Name": "overlay2" 100 | }, 101 | "RootFS": { 102 | "Type": "layers", 103 | "Layers": [ 104 | "sha256:650abce4b096b06ac8bec2046d821d66d801af34f1f1d4c5e272ad030c7873db", 105 | "sha256:4dc5cd799a08ff49a603870c8378ea93083bfc2a4176f56e5531997e94c195d0", 106 | "sha256:e161c82b34d21179db1f546c1cd84153d28a17d865ccaf2dedeb06a903fec12c", 107 | "sha256:83ba6d8ffb8c2974174c02d3ba549e7e0656ebb1bc075a6b6ee89b6c609c6a71", 108 | "sha256:d8466e142d8710abf5b495ebb536478f7e19d9d03b151b5d5bd09df4cfb49248", 109 | "sha256:101af4ba983b04be266217ecee414e88b23e394f62e9801c7c1bdb37cb37bcaa" 110 | ] 111 | }, 112 | "Metadata": { 113 | "LastTagTime": "0001-01-01T00:00:00Z" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /pkg/container/mocks/data/image_running.json: -------------------------------------------------------------------------------- 1 | { 2 | "Id": "sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa", 3 | "RepoTags": [ 4 | "containrrr/watchtower:latest" 5 | ], 6 | "RepoDigests": [], 7 | "Parent": "sha256:2753b9621e0d76153e1725d0cea015baf0ae4d829782a463b4ea9532ec976447", 8 | "Comment": "", 9 | "Created": "2019-04-10T19:49:07.970840451Z", 10 | "Container": "b8387976426946f5c5191255204a66514c5e64be157f792c5bac329bb055041c", 11 | "ContainerConfig": { 12 | "Hostname": "b83879764269", 13 | "Domainname": "", 14 | "User": "", 15 | "AttachStdin": false, 16 | "AttachStdout": false, 17 | "AttachStderr": false, 18 | "Tty": false, 19 | "OpenStdin": false, 20 | "StdinOnce": false, 21 | "Env": [ 22 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 23 | ], 24 | "Cmd": [ 25 | "/bin/sh", 26 | "-c", 27 | "#(nop) ", 28 | "ENTRYPOINT [\"/watchtower\"]" 29 | ], 30 | "Image": "sha256:2753b9621e0d76153e1725d0cea015baf0ae4d829782a463b4ea9532ec976447", 31 | "Volumes": null, 32 | "WorkingDir": "", 33 | "Entrypoint": [ 34 | "/watchtower" 35 | ], 36 | "OnBuild": null, 37 | "Labels": { 38 | "com.centurylinklabs.watchtower": "true" 39 | } 40 | }, 41 | "DockerVersion": "18.09.1", 42 | "Author": "", 43 | "Config": { 44 | "Hostname": "", 45 | "Domainname": "", 46 | "User": "", 47 | "AttachStdin": false, 48 | "AttachStdout": false, 49 | "AttachStderr": false, 50 | "Tty": false, 51 | "OpenStdin": false, 52 | "StdinOnce": false, 53 | "Env": [ 54 | "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 55 | ], 56 | "Cmd": null, 57 | "Image": "sha256:2753b9621e0d76153e1725d0cea015baf0ae4d829782a463b4ea9532ec976447", 58 | "Volumes": null, 59 | "WorkingDir": "", 60 | "Entrypoint": [ 61 | "/watchtower" 62 | ], 63 | "OnBuild": null, 64 | "Labels": { 65 | "com.centurylinklabs.watchtower": "true" 66 | } 67 | }, 68 | "Architecture": "amd64", 69 | "Os": "linux", 70 | "Size": 13005733, 71 | "VirtualSize": 13005733, 72 | "GraphDriver": { 73 | "Data": { 74 | "LowerDir": "/var/lib/docker/overlay2/8108325ee844603c9b08d2772cf6e65dccf31dd5171f265078e5ed79a0ba3c0f/diff:/var/lib/docker/overlay2/e5e0cce6bf91b829a308424d99d7e56a33be3a11414ff5cdc48e762a1342b20f/diff", 75 | "MergedDir": "/var/lib/docker/overlay2/cdf82f50bc49177d0c17c24f3eaa29eba607b70cc6a081f77781b21c59a13eb8/merged", 76 | "UpperDir": "/var/lib/docker/overlay2/cdf82f50bc49177d0c17c24f3eaa29eba607b70cc6a081f77781b21c59a13eb8/diff", 77 | "WorkDir": "/var/lib/docker/overlay2/cdf82f50bc49177d0c17c24f3eaa29eba607b70cc6a081f77781b21c59a13eb8/work" 78 | }, 79 | "Name": "overlay2" 80 | }, 81 | "RootFS": { 82 | "Type": "layers", 83 | "Layers": [ 84 | "sha256:1d3ad125af2c636cdd793fcf94c9d4fd2b5c4c7d63a770a01056719db13c2271", 85 | "sha256:06cfe8fe0892ba4a91cb93e3a25344d4a1c4771cf7297a93e3bd86a1e0fba6eb", 86 | "sha256:f58d451769dc30a938d8dcae22fda2acd816899f65fc6b6fa519ddf230dab447" 87 | ] 88 | }, 89 | "Metadata": { 90 | "LastTagTime": "2019-04-10T19:49:08.03921105Z" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /pkg/container/util_test.go: -------------------------------------------------------------------------------- 1 | package container_test 2 | 3 | import ( 4 | wt "github.com/beatkind/watchtower/pkg/types" 5 | . "github.com/onsi/ginkgo/v2" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | var _ = Describe("container utils", func() { 10 | Describe("ShortID", func() { 11 | When("given a normal image ID", func() { 12 | When("it contains a sha256 prefix", func() { 13 | It("should return that ID in short version", func() { 14 | actual := shortID("sha256:0123456789abcd00000000001111111111222222222233333333334444444444") 15 | Expect(actual).To(Equal("0123456789ab")) 16 | }) 17 | }) 18 | When("it doesn't contain a prefix", func() { 19 | It("should return that ID in short version", func() { 20 | actual := shortID("0123456789abcd00000000001111111111222222222233333333334444444444") 21 | Expect(actual).To(Equal("0123456789ab")) 22 | }) 23 | }) 24 | }) 25 | When("given a short image ID", func() { 26 | When("it contains no prefix", func() { 27 | It("should return the same string", func() { 28 | Expect(shortID("0123456789ab")).To(Equal("0123456789ab")) 29 | }) 30 | }) 31 | When("it contains a the sha256 prefix", func() { 32 | It("should return the ID without the prefix", func() { 33 | Expect(shortID("sha256:0123456789ab")).To(Equal("0123456789ab")) 34 | }) 35 | }) 36 | }) 37 | When("given an ID with an unknown prefix", func() { 38 | It("should return a short version of that ID including the prefix", func() { 39 | Expect(shortID("md5:0123456789ab")).To(Equal("md5:0123456789ab")) 40 | Expect(shortID("md5:0123456789abcdefg")).To(Equal("md5:0123456789ab")) 41 | Expect(shortID("md5:01")).To(Equal("md5:01")) 42 | }) 43 | }) 44 | }) 45 | }) 46 | 47 | func shortID(id string) string { 48 | // Proxy to the types implementation, relocated due to package dependency resolution 49 | return wt.ImageID(id).ShortID() 50 | } 51 | -------------------------------------------------------------------------------- /pkg/lifecycle/lifecycle.go: -------------------------------------------------------------------------------- 1 | package lifecycle 2 | 3 | import ( 4 | "github.com/beatkind/watchtower/pkg/container" 5 | "github.com/beatkind/watchtower/pkg/types" 6 | log "github.com/sirupsen/logrus" 7 | ) 8 | 9 | // ExecutePreChecks tries to run the pre-check lifecycle hook for all containers included by the current filter. 10 | func ExecutePreChecks(client container.Client, params types.UpdateParams) { 11 | containers, err := client.ListContainers(params.Filter) 12 | if err != nil { 13 | return 14 | } 15 | for _, currentContainer := range containers { 16 | ExecutePreCheckCommand(client, currentContainer) 17 | } 18 | } 19 | 20 | // ExecutePostChecks tries to run the post-check lifecycle hook for all containers included by the current filter. 21 | func ExecutePostChecks(client container.Client, params types.UpdateParams) { 22 | containers, err := client.ListContainers(params.Filter) 23 | if err != nil { 24 | return 25 | } 26 | for _, currentContainer := range containers { 27 | ExecutePostCheckCommand(client, currentContainer) 28 | } 29 | } 30 | 31 | // ExecutePreCheckCommand tries to run the pre-check lifecycle hook for a single container. 32 | func ExecutePreCheckCommand(client container.Client, container types.Container) { 33 | clog := log.WithField("container", container.Name()) 34 | command := container.GetLifecyclePreCheckCommand() 35 | if len(command) == 0 { 36 | clog.Debug("No pre-check command supplied. Skipping") 37 | return 38 | } 39 | 40 | clog.Debug("Executing pre-check command.") 41 | _, err := client.ExecuteCommand(container.ID(), command, 1) 42 | if err != nil { 43 | clog.Error(err) 44 | } 45 | } 46 | 47 | // ExecutePostCheckCommand tries to run the post-check lifecycle hook for a single container. 48 | func ExecutePostCheckCommand(client container.Client, container types.Container) { 49 | clog := log.WithField("container", container.Name()) 50 | command := container.GetLifecyclePostCheckCommand() 51 | if len(command) == 0 { 52 | clog.Debug("No post-check command supplied. Skipping") 53 | return 54 | } 55 | 56 | clog.Debug("Executing post-check command.") 57 | _, err := client.ExecuteCommand(container.ID(), command, 1) 58 | if err != nil { 59 | clog.Error(err) 60 | } 61 | } 62 | 63 | // ExecutePreUpdateCommand tries to run the pre-update lifecycle hook for a single container. 64 | func ExecutePreUpdateCommand(client container.Client, container types.Container) (SkipUpdate bool, err error) { 65 | timeout := container.PreUpdateTimeout() 66 | command := container.GetLifecyclePreUpdateCommand() 67 | clog := log.WithField("container", container.Name()) 68 | 69 | if len(command) == 0 { 70 | clog.Debug("No pre-update command supplied. Skipping") 71 | return false, nil 72 | } 73 | 74 | if !container.IsRunning() || container.IsRestarting() { 75 | clog.Debug("Container is not running. Skipping pre-update command.") 76 | return false, nil 77 | } 78 | 79 | clog.Debug("Executing pre-update command.") 80 | return client.ExecuteCommand(container.ID(), command, timeout) 81 | } 82 | 83 | // ExecutePostUpdateCommand tries to run the post-update lifecycle hook for a single container. 84 | func ExecutePostUpdateCommand(client container.Client, newContainerID types.ContainerID) { 85 | newContainer, err := client.GetContainer(newContainerID) 86 | timeout := newContainer.PostUpdateTimeout() 87 | 88 | if err != nil { 89 | log.WithField("containerID", newContainerID.ShortID()).Error(err) 90 | return 91 | } 92 | clog := log.WithField("container", newContainer.Name()) 93 | 94 | command := newContainer.GetLifecyclePostUpdateCommand() 95 | if len(command) == 0 { 96 | clog.Debug("No post-update command supplied. Skipping") 97 | return 98 | } 99 | 100 | clog.Debug("Executing post-update command.") 101 | _, err = client.ExecuteCommand(newContainerID, command, timeout) 102 | 103 | if err != nil { 104 | clog.Error(err) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /pkg/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "github.com/beatkind/watchtower/pkg/types" 5 | "github.com/prometheus/client_golang/prometheus" 6 | "github.com/prometheus/client_golang/prometheus/promauto" 7 | ) 8 | 9 | var metrics *Metrics 10 | 11 | // Metric is the data points of a single scan 12 | type Metric struct { 13 | Scanned int 14 | Updated int 15 | Failed int 16 | } 17 | 18 | // Metrics is the handler processing all individual scan metrics 19 | type Metrics struct { 20 | channel chan *Metric 21 | scanned prometheus.Gauge 22 | updated prometheus.Gauge 23 | failed prometheus.Gauge 24 | total prometheus.Counter 25 | skipped prometheus.Counter 26 | } 27 | 28 | // NewMetric returns a Metric with the counts taken from the appropriate types.Report fields 29 | func NewMetric(report types.Report) *Metric { 30 | return &Metric{ 31 | Scanned: len(report.Scanned()), 32 | // Note: This is for backwards compatibility. ideally, stale containers should be counted separately 33 | Updated: len(report.Updated()) + len(report.Stale()), 34 | Failed: len(report.Failed()), 35 | } 36 | } 37 | 38 | // QueueIsEmpty checks whether any messages are enqueued in the channel 39 | func (metrics *Metrics) QueueIsEmpty() bool { 40 | return len(metrics.channel) == 0 41 | } 42 | 43 | // Register registers metrics for an executed scan 44 | func (metrics *Metrics) Register(metric *Metric) { 45 | metrics.channel <- metric 46 | } 47 | 48 | // Default creates a new metrics handler if none exists, otherwise returns the existing one 49 | func Default() *Metrics { 50 | if metrics != nil { 51 | return metrics 52 | } 53 | 54 | metrics = &Metrics{ 55 | scanned: promauto.NewGauge(prometheus.GaugeOpts{ 56 | Name: "watchtower_containers_scanned", 57 | Help: "Number of containers scanned for changes by watchtower during the last scan", 58 | }), 59 | updated: promauto.NewGauge(prometheus.GaugeOpts{ 60 | Name: "watchtower_containers_updated", 61 | Help: "Number of containers updated by watchtower during the last scan", 62 | }), 63 | failed: promauto.NewGauge(prometheus.GaugeOpts{ 64 | Name: "watchtower_containers_failed", 65 | Help: "Number of containers where update failed during the last scan", 66 | }), 67 | total: promauto.NewCounter(prometheus.CounterOpts{ 68 | Name: "watchtower_scans_total", 69 | Help: "Number of scans since the watchtower started", 70 | }), 71 | skipped: promauto.NewCounter(prometheus.CounterOpts{ 72 | Name: "watchtower_scans_skipped", 73 | Help: "Number of skipped scans since watchtower started", 74 | }), 75 | channel: make(chan *Metric, 10), 76 | } 77 | 78 | go metrics.HandleUpdate(metrics.channel) 79 | 80 | return metrics 81 | } 82 | 83 | // RegisterScan fetches a metric handler and enqueues a metric 84 | func RegisterScan(metric *Metric) { 85 | metrics := Default() 86 | metrics.Register(metric) 87 | } 88 | 89 | // HandleUpdate dequeue the metric channel and processes it 90 | func (metrics *Metrics) HandleUpdate(channel <-chan *Metric) { 91 | for change := range channel { 92 | if change == nil { 93 | // Update was skipped and rescheduled 94 | metrics.total.Inc() 95 | metrics.skipped.Inc() 96 | metrics.scanned.Set(0) 97 | metrics.updated.Set(0) 98 | metrics.failed.Set(0) 99 | continue 100 | } 101 | // Update metrics with the new values 102 | metrics.total.Inc() 103 | metrics.scanned.Set(float64(change.Scanned)) 104 | metrics.updated.Set(float64(change.Updated)) 105 | metrics.failed.Set(float64(change.Failed)) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /pkg/notifications/common_templates.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | var commonTemplates = map[string]string{ 4 | `default-legacy`: "{{range .}}{{.Message}}{{println}}{{end}}", 5 | 6 | `default`: ` 7 | {{- if .Report -}} 8 | {{- with .Report -}} 9 | {{- if ( or .Updated .Failed ) -}} 10 | {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed 11 | {{- range .Updated}} 12 | - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}} 13 | {{- end -}} 14 | {{- range .Fresh}} 15 | - {{.Name}} ({{.ImageName}}): {{.State}} 16 | {{- end -}} 17 | {{- range .Skipped}} 18 | - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} 19 | {{- end -}} 20 | {{- range .Failed}} 21 | - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} 22 | {{- end -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- else -}} 26 | {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}} 27 | {{- end -}}`, 28 | 29 | `porcelain.v1.summary-no-log`: ` 30 | {{- if .Report -}} 31 | {{- range .Report.All }} 32 | {{- .Name}} ({{.ImageName}}): {{.State -}} 33 | {{- with .Error}} Error: {{.}}{{end}}{{ println }} 34 | {{- else -}} 35 | no containers matched filter 36 | {{- end -}} 37 | {{- end -}}`, 38 | 39 | `json.v1`: `{{ . | ToJSON }}`, 40 | } 41 | -------------------------------------------------------------------------------- /pkg/notifications/email.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | t "github.com/beatkind/watchtower/pkg/types" 9 | shoutrrrSmtp "github.com/containrrr/shoutrrr/pkg/services/smtp" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | const ( 14 | emailType = "email" 15 | ) 16 | 17 | type emailTypeNotifier struct { 18 | From, FromName, To string 19 | Server, User, Password string 20 | Port int 21 | tlsSkipVerify bool 22 | entries []*log.Entry 23 | delay time.Duration 24 | } 25 | 26 | func newEmailNotifier(c *cobra.Command) t.ConvertibleNotifier { 27 | flags := c.Flags() 28 | 29 | from, _ := flags.GetString("notification-email-from") 30 | fromName, _ := flags.GetString("notification-email-from-name") 31 | to, _ := flags.GetString("notification-email-to") 32 | server, _ := flags.GetString("notification-email-server") 33 | user, _ := flags.GetString("notification-email-server-user") 34 | password, _ := flags.GetString("notification-email-server-password") 35 | port, _ := flags.GetInt("notification-email-server-port") 36 | tlsSkipVerify, _ := flags.GetBool("notification-email-server-tls-skip-verify") 37 | delay, _ := flags.GetInt("notification-email-delay") 38 | 39 | n := &emailTypeNotifier{ 40 | entries: []*log.Entry{}, 41 | From: from, 42 | FromName: fromName, 43 | To: to, 44 | Server: server, 45 | User: user, 46 | Password: password, 47 | Port: port, 48 | tlsSkipVerify: tlsSkipVerify, 49 | delay: time.Duration(delay) * time.Second, 50 | } 51 | 52 | return n 53 | } 54 | 55 | func (e *emailTypeNotifier) GetURL(c *cobra.Command) (string, error) { 56 | conf := &shoutrrrSmtp.Config{ 57 | FromAddress: e.From, 58 | FromName: e.FromName, 59 | ToAddresses: []string{e.To}, 60 | Port: uint16(e.Port), 61 | Host: e.Server, 62 | Username: e.User, 63 | Password: e.Password, 64 | UseStartTLS: !e.tlsSkipVerify, 65 | UseHTML: false, 66 | Encryption: shoutrrrSmtp.EncMethods.Auto, 67 | Auth: shoutrrrSmtp.AuthTypes.None, 68 | ClientHost: "localhost", 69 | } 70 | 71 | if len(e.User) > 0 { 72 | conf.Auth = shoutrrrSmtp.AuthTypes.Plain 73 | } 74 | 75 | if e.tlsSkipVerify { 76 | conf.Encryption = shoutrrrSmtp.EncMethods.None 77 | } 78 | 79 | return conf.GetURL().String(), nil 80 | } 81 | 82 | func (e *emailTypeNotifier) GetDelay() time.Duration { 83 | return e.delay 84 | } 85 | -------------------------------------------------------------------------------- /pkg/notifications/gotify.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | 7 | t "github.com/beatkind/watchtower/pkg/types" 8 | shoutrrrGotify "github.com/containrrr/shoutrrr/pkg/services/gotify" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/pflag" 12 | ) 13 | 14 | const ( 15 | gotifyType = "gotify" 16 | ) 17 | 18 | type gotifyTypeNotifier struct { 19 | gotifyURL string 20 | gotifyAppToken string 21 | gotifyInsecureSkipVerify bool 22 | } 23 | 24 | func newGotifyNotifier(c *cobra.Command) t.ConvertibleNotifier { 25 | flags := c.Flags() 26 | 27 | apiURL := getGotifyURL(flags) 28 | token := getGotifyToken(flags) 29 | 30 | skipVerify, _ := flags.GetBool("notification-gotify-tls-skip-verify") 31 | 32 | n := &gotifyTypeNotifier{ 33 | gotifyURL: apiURL, 34 | gotifyAppToken: token, 35 | gotifyInsecureSkipVerify: skipVerify, 36 | } 37 | 38 | return n 39 | } 40 | 41 | func getGotifyToken(flags *pflag.FlagSet) string { 42 | gotifyToken, _ := flags.GetString("notification-gotify-token") 43 | if len(gotifyToken) < 1 { 44 | log.Fatal("Required argument --notification-gotify-token(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN(env) is empty.") 45 | } 46 | return gotifyToken 47 | } 48 | 49 | func getGotifyURL(flags *pflag.FlagSet) string { 50 | gotifyURL, _ := flags.GetString("notification-gotify-url") 51 | 52 | if len(gotifyURL) < 1 { 53 | log.Fatal("Required argument --notification-gotify-url(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_URL(env) is empty.") 54 | } else if !(strings.HasPrefix(gotifyURL, "http://") || strings.HasPrefix(gotifyURL, "https://")) { 55 | log.Fatal("Gotify URL must start with \"http://\" or \"https://\"") 56 | } else if strings.HasPrefix(gotifyURL, "http://") { 57 | log.Warn("Using an HTTP url for Gotify is insecure") 58 | } 59 | 60 | return gotifyURL 61 | } 62 | 63 | func (n *gotifyTypeNotifier) GetURL(c *cobra.Command) (string, error) { 64 | apiURL, err := url.Parse(n.gotifyURL) 65 | if err != nil { 66 | return "", err 67 | } 68 | 69 | config := &shoutrrrGotify.Config{ 70 | Host: apiURL.Host, 71 | Path: apiURL.Path, 72 | DisableTLS: apiURL.Scheme == "http", 73 | Token: n.gotifyAppToken, 74 | } 75 | 76 | return config.GetURL().String(), nil 77 | } 78 | -------------------------------------------------------------------------------- /pkg/notifications/json.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | t "github.com/beatkind/watchtower/pkg/types" 7 | ) 8 | 9 | type jsonMap = map[string]interface{} 10 | 11 | // MarshalJSON implements json.Marshaler 12 | func (d Data) MarshalJSON() ([]byte, error) { 13 | var entries = make([]jsonMap, len(d.Entries)) 14 | for i, entry := range d.Entries { 15 | entries[i] = jsonMap{ 16 | `level`: entry.Level, 17 | `message`: entry.Message, 18 | `data`: entry.Data, 19 | `time`: entry.Time, 20 | } 21 | } 22 | 23 | var report jsonMap 24 | if d.Report != nil { 25 | report = jsonMap{ 26 | `scanned`: marshalReports(d.Report.Scanned()), 27 | `updated`: marshalReports(d.Report.Updated()), 28 | `failed`: marshalReports(d.Report.Failed()), 29 | `skipped`: marshalReports(d.Report.Skipped()), 30 | `stale`: marshalReports(d.Report.Stale()), 31 | `fresh`: marshalReports(d.Report.Fresh()), 32 | } 33 | } 34 | 35 | return json.Marshal(jsonMap{ 36 | `report`: report, 37 | `title`: d.Title, 38 | `host`: d.Host, 39 | `entries`: entries, 40 | }) 41 | } 42 | 43 | func marshalReports(reports []t.ContainerReport) []jsonMap { 44 | jsonReports := make([]jsonMap, len(reports)) 45 | for i, report := range reports { 46 | jsonReports[i] = jsonMap{ 47 | `id`: report.ID().ShortID(), 48 | `name`: report.Name(), 49 | `currentImageId`: report.CurrentImageID().ShortID(), 50 | `latestImageId`: report.LatestImageID().ShortID(), 51 | `imageName`: report.ImageName(), 52 | `state`: report.State(), 53 | } 54 | if errorMessage := report.Error(); errorMessage != "" { 55 | jsonReports[i][`error`] = errorMessage 56 | } 57 | } 58 | return jsonReports 59 | } 60 | 61 | var _ json.Marshaler = &Data{} 62 | -------------------------------------------------------------------------------- /pkg/notifications/json_test.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | s "github.com/beatkind/watchtower/pkg/session" 5 | . "github.com/onsi/ginkgo/v2" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | var _ = Describe("JSON template", func() { 10 | When("using report templates", func() { 11 | When("JSON template is used", func() { 12 | It("should format the messages to the expected format", func() { 13 | expected := `{ 14 | "entries": [ 15 | { 16 | "data": null, 17 | "level": "info", 18 | "message": "foo Bar", 19 | "time": "0001-01-01T00:00:00Z" 20 | } 21 | ], 22 | "host": "Mock", 23 | "report": { 24 | "failed": [ 25 | { 26 | "currentImageId": "01d210000000", 27 | "error": "accidentally the whole container", 28 | "id": "c79210000000", 29 | "imageName": "mock/fail1:latest", 30 | "latestImageId": "d0a210000000", 31 | "name": "fail1", 32 | "state": "Failed" 33 | } 34 | ], 35 | "fresh": [ 36 | { 37 | "currentImageId": "01d310000000", 38 | "id": "c79310000000", 39 | "imageName": "mock/frsh1:latest", 40 | "latestImageId": "01d310000000", 41 | "name": "frsh1", 42 | "state": "Fresh" 43 | } 44 | ], 45 | "scanned": [ 46 | { 47 | "currentImageId": "01d110000000", 48 | "id": "c79110000000", 49 | "imageName": "mock/updt1:latest", 50 | "latestImageId": "d0a110000000", 51 | "name": "updt1", 52 | "state": "Updated" 53 | }, 54 | { 55 | "currentImageId": "01d120000000", 56 | "id": "c79120000000", 57 | "imageName": "mock/updt2:latest", 58 | "latestImageId": "d0a120000000", 59 | "name": "updt2", 60 | "state": "Updated" 61 | }, 62 | { 63 | "currentImageId": "01d210000000", 64 | "error": "accidentally the whole container", 65 | "id": "c79210000000", 66 | "imageName": "mock/fail1:latest", 67 | "latestImageId": "d0a210000000", 68 | "name": "fail1", 69 | "state": "Failed" 70 | }, 71 | { 72 | "currentImageId": "01d310000000", 73 | "id": "c79310000000", 74 | "imageName": "mock/frsh1:latest", 75 | "latestImageId": "01d310000000", 76 | "name": "frsh1", 77 | "state": "Fresh" 78 | } 79 | ], 80 | "skipped": [ 81 | { 82 | "currentImageId": "01d410000000", 83 | "error": "unpossible", 84 | "id": "c79410000000", 85 | "imageName": "mock/skip1:latest", 86 | "latestImageId": "01d410000000", 87 | "name": "skip1", 88 | "state": "Skipped" 89 | } 90 | ], 91 | "stale": [], 92 | "updated": [ 93 | { 94 | "currentImageId": "01d110000000", 95 | "id": "c79110000000", 96 | "imageName": "mock/updt1:latest", 97 | "latestImageId": "d0a110000000", 98 | "name": "updt1", 99 | "state": "Updated" 100 | }, 101 | { 102 | "currentImageId": "01d120000000", 103 | "id": "c79120000000", 104 | "imageName": "mock/updt2:latest", 105 | "latestImageId": "d0a120000000", 106 | "name": "updt2", 107 | "state": "Updated" 108 | } 109 | ] 110 | }, 111 | "title": "Watchtower updates on Mock" 112 | }` 113 | data := mockDataFromStates(s.UpdatedState, s.FreshState, s.FailedState, s.SkippedState, s.UpdatedState) 114 | Expect(getTemplatedResult(`json.v1`, false, data)).To(MatchJSON(expected)) 115 | }) 116 | }) 117 | }) 118 | }) 119 | -------------------------------------------------------------------------------- /pkg/notifications/model.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | t "github.com/beatkind/watchtower/pkg/types" 5 | log "github.com/sirupsen/logrus" 6 | ) 7 | 8 | // StaticData is the part of the notification template data model set upon initialization 9 | type StaticData struct { 10 | Title string 11 | Host string 12 | } 13 | 14 | // Data is the notification template data model 15 | type Data struct { 16 | StaticData 17 | Entries []*log.Entry 18 | Report t.Report 19 | } 20 | -------------------------------------------------------------------------------- /pkg/notifications/msteams.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "net/url" 5 | 6 | t "github.com/beatkind/watchtower/pkg/types" 7 | shoutrrrTeams "github.com/containrrr/shoutrrr/pkg/services/teams" 8 | log "github.com/sirupsen/logrus" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | const ( 13 | msTeamsType = "msteams" 14 | ) 15 | 16 | type msTeamsTypeNotifier struct { 17 | webHookURL string 18 | data bool 19 | } 20 | 21 | func newMsTeamsNotifier(cmd *cobra.Command) t.ConvertibleNotifier { 22 | 23 | flags := cmd.Flags() 24 | 25 | webHookURL, _ := flags.GetString("notification-msteams-hook") 26 | if len(webHookURL) <= 0 { 27 | log.Fatal("Required argument --notification-msteams-hook(cli) or WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL(env) is empty.") 28 | } 29 | 30 | withData, _ := flags.GetBool("notification-msteams-data") 31 | n := &msTeamsTypeNotifier{ 32 | webHookURL: webHookURL, 33 | data: withData, 34 | } 35 | 36 | return n 37 | } 38 | 39 | func (n *msTeamsTypeNotifier) GetURL(c *cobra.Command) (string, error) { 40 | webhookURL, err := url.Parse(n.webHookURL) 41 | if err != nil { 42 | return "", err 43 | } 44 | 45 | config, err := shoutrrrTeams.ConfigFromWebhookURL(*webhookURL) 46 | if err != nil { 47 | return "", err 48 | } 49 | 50 | config.Color = ColorHex 51 | 52 | return config.GetURL().String(), nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/notifications/notifications_suite_test.go: -------------------------------------------------------------------------------- 1 | package notifications_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/onsi/gomega/format" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func TestNotifications(t *testing.T) { 13 | RegisterFailHandler(Fail) 14 | format.CharactersAroundMismatchToInclude = 20 15 | RunSpecs(t, "Notifications Suite") 16 | } 17 | -------------------------------------------------------------------------------- /pkg/notifications/notifier.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "time" 7 | 8 | ty "github.com/beatkind/watchtower/pkg/types" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // NewNotifier creates and returns a new Notifier, using global configuration. 14 | func NewNotifier(c *cobra.Command) ty.Notifier { 15 | f := c.Flags() 16 | 17 | level, _ := f.GetString("notifications-level") 18 | logLevel, err := log.ParseLevel(level) 19 | if err != nil { 20 | log.Fatalf("Notifications invalid log level: %s", err.Error()) 21 | } 22 | 23 | reportTemplate, _ := f.GetBool("notification-report") 24 | stdout, _ := f.GetBool("notification-log-stdout") 25 | tplString, _ := f.GetString("notification-template") 26 | urls, _ := f.GetStringArray("notification-url") 27 | 28 | data := GetTemplateData(c) 29 | urls, delay := AppendLegacyUrls(urls, c) 30 | 31 | return createNotifier(urls, logLevel, tplString, !reportTemplate, data, stdout, delay) 32 | } 33 | 34 | // AppendLegacyUrls creates shoutrrr equivalent URLs from legacy notification flags 35 | func AppendLegacyUrls(urls []string, cmd *cobra.Command) ([]string, time.Duration) { 36 | 37 | // Parse types and create notifiers. 38 | types, err := cmd.Flags().GetStringSlice("notifications") 39 | if err != nil { 40 | log.WithError(err).Fatal("could not read notifications argument") 41 | } 42 | 43 | legacyDelay := time.Duration(0) 44 | 45 | for _, t := range types { 46 | 47 | var legacyNotifier ty.ConvertibleNotifier 48 | var err error 49 | 50 | switch t { 51 | case emailType: 52 | legacyNotifier = newEmailNotifier(cmd) 53 | case slackType: 54 | legacyNotifier = newSlackNotifier(cmd) 55 | case msTeamsType: 56 | legacyNotifier = newMsTeamsNotifier(cmd) 57 | case gotifyType: 58 | legacyNotifier = newGotifyNotifier(cmd) 59 | case shoutrrrType: 60 | continue 61 | default: 62 | log.Fatalf("Unknown notification type %q", t) 63 | // Not really needed, used for nil checking static analysis 64 | continue 65 | } 66 | 67 | shoutrrrURL, err := legacyNotifier.GetURL(cmd) 68 | if err != nil { 69 | log.Fatal("failed to create notification config: ", err) 70 | } 71 | urls = append(urls, shoutrrrURL) 72 | 73 | if delayNotifier, ok := legacyNotifier.(ty.DelayNotifier); ok { 74 | legacyDelay = delayNotifier.GetDelay() 75 | } 76 | 77 | log.WithField("URL", shoutrrrURL).Trace("created Shoutrrr URL from legacy notifier") 78 | } 79 | 80 | delay := GetDelay(cmd, legacyDelay) 81 | return urls, delay 82 | } 83 | 84 | // GetDelay returns the legacy delay if defined, otherwise the delay as set by args is returned 85 | func GetDelay(c *cobra.Command, legacyDelay time.Duration) time.Duration { 86 | if legacyDelay > 0 { 87 | return legacyDelay 88 | } 89 | 90 | delay, _ := c.PersistentFlags().GetInt("notifications-delay") 91 | if delay > 0 { 92 | return time.Duration(delay) * time.Second 93 | } 94 | return time.Duration(0) 95 | } 96 | 97 | // GetTitle formats the title based on the passed hostname and tag 98 | func GetTitle(hostname string, tag string) string { 99 | tb := strings.Builder{} 100 | 101 | if tag != "" { 102 | tb.WriteRune('[') 103 | tb.WriteString(tag) 104 | tb.WriteRune(']') 105 | tb.WriteRune(' ') 106 | } 107 | 108 | tb.WriteString("Watchtower updates") 109 | 110 | if hostname != "" { 111 | tb.WriteString(" on ") 112 | tb.WriteString(hostname) 113 | } 114 | 115 | return tb.String() 116 | } 117 | 118 | // GetTemplateData populates the static notification data from flags and environment 119 | func GetTemplateData(c *cobra.Command) StaticData { 120 | f := c.PersistentFlags() 121 | 122 | hostname, _ := f.GetString("notifications-hostname") 123 | if hostname == "" { 124 | hostname, _ = os.Hostname() 125 | } 126 | 127 | title := "" 128 | if skip, _ := f.GetBool("notification-skip-title"); !skip { 129 | tag, _ := f.GetString("notification-title-tag") 130 | if tag == "" { 131 | // For legacy email support 132 | tag, _ = f.GetString("notification-email-subjecttag") 133 | } 134 | title = GetTitle(hostname, tag) 135 | } 136 | 137 | return StaticData{ 138 | Host: hostname, 139 | Title: title, 140 | } 141 | } 142 | 143 | // ColorHex is the default notification color used for services that support it (formatted as a CSS hex string) 144 | const ColorHex = "#406170" 145 | 146 | // ColorInt is the default notification color used for services that support it (as an int value) 147 | const ColorInt = 0x406170 148 | -------------------------------------------------------------------------------- /pkg/notifications/preview/data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "encoding/hex" 5 | "errors" 6 | "math/rand" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/beatkind/watchtower/pkg/types" 11 | ) 12 | 13 | type previewData struct { 14 | rand *rand.Rand 15 | lastTime time.Time 16 | report *report 17 | containerCount int 18 | Entries []*logEntry 19 | StaticData staticData 20 | } 21 | 22 | type staticData struct { 23 | Title string 24 | Host string 25 | } 26 | 27 | // New initializes a new preview data struct 28 | func New() *previewData { 29 | return &previewData{ 30 | rand: rand.New(rand.NewSource(1)), 31 | lastTime: time.Now().Add(-30 * time.Minute), 32 | report: nil, 33 | containerCount: 0, 34 | Entries: []*logEntry{}, 35 | StaticData: staticData{ 36 | Title: "Title", 37 | Host: "Host", 38 | }, 39 | } 40 | } 41 | 42 | // AddFromState adds a container status entry to the report with the given state 43 | func (pb *previewData) AddFromState(state State) { 44 | cid := types.ContainerID(pb.generateID()) 45 | old := types.ImageID(pb.generateID()) 46 | new := types.ImageID(pb.generateID()) 47 | name := pb.generateName() 48 | image := pb.generateImageName(name) 49 | var err error 50 | if state == FailedState { 51 | err = errors.New(pb.randomEntry(errorMessages)) 52 | } else if state == SkippedState { 53 | err = errors.New(pb.randomEntry(skippedMessages)) 54 | } 55 | pb.addContainer(containerStatus{ 56 | containerID: cid, 57 | oldImage: old, 58 | newImage: new, 59 | containerName: name, 60 | imageName: image, 61 | error: err, 62 | state: state, 63 | }) 64 | } 65 | 66 | func (pb *previewData) addContainer(c containerStatus) { 67 | if pb.report == nil { 68 | pb.report = &report{} 69 | } 70 | switch c.state { 71 | case ScannedState: 72 | pb.report.scanned = append(pb.report.scanned, &c) 73 | case UpdatedState: 74 | pb.report.updated = append(pb.report.updated, &c) 75 | case FailedState: 76 | pb.report.failed = append(pb.report.failed, &c) 77 | case SkippedState: 78 | pb.report.skipped = append(pb.report.skipped, &c) 79 | case StaleState: 80 | pb.report.stale = append(pb.report.stale, &c) 81 | case FreshState: 82 | pb.report.fresh = append(pb.report.fresh, &c) 83 | default: 84 | return 85 | } 86 | pb.containerCount += 1 87 | } 88 | 89 | // AddLogEntry adds a preview log entry of the given level 90 | func (pd *previewData) AddLogEntry(level LogLevel) { 91 | var msg string 92 | switch level { 93 | case FatalLevel: 94 | fallthrough 95 | case ErrorLevel: 96 | fallthrough 97 | case WarnLevel: 98 | msg = pd.randomEntry(logErrors) 99 | default: 100 | msg = pd.randomEntry(logMessages) 101 | } 102 | pd.Entries = append(pd.Entries, &logEntry{ 103 | Message: msg, 104 | Data: map[string]any{}, 105 | Time: pd.generateTime(), 106 | Level: level, 107 | }) 108 | } 109 | 110 | // Report returns a preview report 111 | func (pb *previewData) Report() types.Report { 112 | return pb.report 113 | } 114 | 115 | func (pb *previewData) generateID() string { 116 | buf := make([]byte, 32) 117 | _, _ = pb.rand.Read(buf) 118 | return hex.EncodeToString(buf) 119 | } 120 | 121 | func (pb *previewData) generateTime() time.Time { 122 | pb.lastTime = pb.lastTime.Add(time.Duration(pb.rand.Intn(30)) * time.Second) 123 | return pb.lastTime 124 | } 125 | 126 | func (pb *previewData) randomEntry(arr []string) string { 127 | return arr[pb.rand.Intn(len(arr))] 128 | } 129 | 130 | func (pb *previewData) generateName() string { 131 | index := pb.containerCount 132 | if index <= len(containerNames) { 133 | return "/" + containerNames[index] 134 | } 135 | suffix := index / len(containerNames) 136 | index %= len(containerNames) 137 | return "/" + containerNames[index] + strconv.FormatInt(int64(suffix), 10) 138 | } 139 | 140 | func (pb *previewData) generateImageName(name string) string { 141 | index := pb.containerCount % len(organizationNames) 142 | return organizationNames[index] + name + ":latest" 143 | } 144 | -------------------------------------------------------------------------------- /pkg/notifications/preview/data/logs.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type logEntry struct { 8 | Message string 9 | Data map[string]any 10 | Time time.Time 11 | Level LogLevel 12 | } 13 | 14 | // LogLevel is the analog of logrus.Level 15 | type LogLevel string 16 | 17 | const ( 18 | TraceLevel LogLevel = "trace" 19 | DebugLevel LogLevel = "debug" 20 | InfoLevel LogLevel = "info" 21 | WarnLevel LogLevel = "warning" 22 | ErrorLevel LogLevel = "error" 23 | FatalLevel LogLevel = "fatal" 24 | PanicLevel LogLevel = "panic" 25 | ) 26 | 27 | // LevelsFromString parses a string of level characters and returns a slice of the corresponding log levels 28 | func LevelsFromString(str string) []LogLevel { 29 | levels := make([]LogLevel, 0, len(str)) 30 | for _, c := range str { 31 | switch c { 32 | case 'p': 33 | levels = append(levels, PanicLevel) 34 | case 'f': 35 | levels = append(levels, FatalLevel) 36 | case 'e': 37 | levels = append(levels, ErrorLevel) 38 | case 'w': 39 | levels = append(levels, WarnLevel) 40 | case 'i': 41 | levels = append(levels, InfoLevel) 42 | case 'd': 43 | levels = append(levels, DebugLevel) 44 | case 't': 45 | levels = append(levels, TraceLevel) 46 | default: 47 | continue 48 | } 49 | } 50 | return levels 51 | } 52 | 53 | // String returns the log level as a string 54 | func (level LogLevel) String() string { 55 | return string(level) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/notifications/preview/data/report.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/beatkind/watchtower/pkg/types" 7 | ) 8 | 9 | // State is the outcome of a container in a session report 10 | type State string 11 | 12 | const ( 13 | ScannedState State = "scanned" 14 | UpdatedState State = "updated" 15 | FailedState State = "failed" 16 | SkippedState State = "skipped" 17 | StaleState State = "stale" 18 | FreshState State = "fresh" 19 | ) 20 | 21 | // StatesFromString parses a string of state characters and returns a slice of the corresponding report states 22 | func StatesFromString(str string) []State { 23 | states := make([]State, 0, len(str)) 24 | for _, c := range str { 25 | switch c { 26 | case 'c': 27 | states = append(states, ScannedState) 28 | case 'u': 29 | states = append(states, UpdatedState) 30 | case 'e': 31 | states = append(states, FailedState) 32 | case 'k': 33 | states = append(states, SkippedState) 34 | case 't': 35 | states = append(states, StaleState) 36 | case 'f': 37 | states = append(states, FreshState) 38 | default: 39 | continue 40 | } 41 | } 42 | return states 43 | } 44 | 45 | type report struct { 46 | scanned []types.ContainerReport 47 | updated []types.ContainerReport 48 | failed []types.ContainerReport 49 | skipped []types.ContainerReport 50 | stale []types.ContainerReport 51 | fresh []types.ContainerReport 52 | } 53 | 54 | func (r *report) Scanned() []types.ContainerReport { 55 | return r.scanned 56 | } 57 | func (r *report) Updated() []types.ContainerReport { 58 | return r.updated 59 | } 60 | func (r *report) Failed() []types.ContainerReport { 61 | return r.failed 62 | } 63 | func (r *report) Skipped() []types.ContainerReport { 64 | return r.skipped 65 | } 66 | func (r *report) Stale() []types.ContainerReport { 67 | return r.stale 68 | } 69 | func (r *report) Fresh() []types.ContainerReport { 70 | return r.fresh 71 | } 72 | 73 | func (r *report) All() []types.ContainerReport { 74 | allLen := len(r.scanned) + len(r.updated) + len(r.failed) + len(r.skipped) + len(r.stale) + len(r.fresh) 75 | all := make([]types.ContainerReport, 0, allLen) 76 | 77 | presentIds := map[types.ContainerID][]string{} 78 | 79 | appendUnique := func(reports []types.ContainerReport) { 80 | for _, cr := range reports { 81 | if _, found := presentIds[cr.ID()]; found { 82 | continue 83 | } 84 | all = append(all, cr) 85 | presentIds[cr.ID()] = nil 86 | } 87 | } 88 | 89 | appendUnique(r.updated) 90 | appendUnique(r.failed) 91 | appendUnique(r.skipped) 92 | appendUnique(r.stale) 93 | appendUnique(r.fresh) 94 | appendUnique(r.scanned) 95 | 96 | sort.Sort(sortableContainers(all)) 97 | 98 | return all 99 | } 100 | 101 | type sortableContainers []types.ContainerReport 102 | 103 | // Len implements sort.Interface.Len 104 | func (s sortableContainers) Len() int { return len(s) } 105 | 106 | // Less implements sort.Interface.Less 107 | func (s sortableContainers) Less(i, j int) bool { return s[i].ID() < s[j].ID() } 108 | 109 | // Swap implements sort.Interface.Swap 110 | func (s sortableContainers) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 111 | -------------------------------------------------------------------------------- /pkg/notifications/preview/data/status.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import wt "github.com/beatkind/watchtower/pkg/types" 4 | 5 | type containerStatus struct { 6 | containerID wt.ContainerID 7 | oldImage wt.ImageID 8 | newImage wt.ImageID 9 | containerName string 10 | imageName string 11 | error 12 | state State 13 | } 14 | 15 | func (u *containerStatus) ID() wt.ContainerID { 16 | return u.containerID 17 | } 18 | 19 | func (u *containerStatus) Name() string { 20 | return u.containerName 21 | } 22 | 23 | func (u *containerStatus) CurrentImageID() wt.ImageID { 24 | return u.oldImage 25 | } 26 | 27 | func (u *containerStatus) LatestImageID() wt.ImageID { 28 | return u.newImage 29 | } 30 | 31 | func (u *containerStatus) ImageName() string { 32 | return u.imageName 33 | } 34 | 35 | func (u *containerStatus) Error() string { 36 | if u.error == nil { 37 | return "" 38 | } 39 | return u.error.Error() 40 | } 41 | 42 | func (u *containerStatus) State() string { 43 | return string(u.state) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/notifications/preview/tplprev.go: -------------------------------------------------------------------------------- 1 | package preview 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "text/template" 7 | 8 | "github.com/beatkind/watchtower/pkg/notifications/preview/data" 9 | "github.com/beatkind/watchtower/pkg/notifications/templates" 10 | ) 11 | 12 | func Render(input string, states []data.State, loglevels []data.LogLevel) (string, error) { 13 | 14 | data := data.New() 15 | 16 | tpl, err := template.New("").Funcs(templates.Funcs).Parse(input) 17 | if err != nil { 18 | return "", fmt.Errorf("failed to parse %v", err) 19 | } 20 | 21 | for _, state := range states { 22 | data.AddFromState(state) 23 | } 24 | 25 | for _, level := range loglevels { 26 | data.AddLogEntry(level) 27 | } 28 | 29 | var buf strings.Builder 30 | err = tpl.Execute(&buf, data) 31 | if err != nil { 32 | return "", fmt.Errorf("failed to execute template: %v", err) 33 | } 34 | 35 | return buf.String(), nil 36 | } 37 | -------------------------------------------------------------------------------- /pkg/notifications/slack.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "strings" 5 | 6 | t "github.com/beatkind/watchtower/pkg/types" 7 | shoutrrrDisco "github.com/containrrr/shoutrrr/pkg/services/discord" 8 | shoutrrrSlack "github.com/containrrr/shoutrrr/pkg/services/slack" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | const ( 14 | slackType = "slack" 15 | ) 16 | 17 | type slackTypeNotifier struct { 18 | HookURL string 19 | Username string 20 | Channel string 21 | IconEmoji string 22 | IconURL string 23 | } 24 | 25 | func newSlackNotifier(c *cobra.Command) t.ConvertibleNotifier { 26 | flags := c.Flags() 27 | 28 | hookURL, _ := flags.GetString("notification-slack-hook-url") 29 | userName, _ := flags.GetString("notification-slack-identifier") 30 | channel, _ := flags.GetString("notification-slack-channel") 31 | emoji, _ := flags.GetString("notification-slack-icon-emoji") 32 | iconURL, _ := flags.GetString("notification-slack-icon-url") 33 | 34 | n := &slackTypeNotifier{ 35 | HookURL: hookURL, 36 | Username: userName, 37 | Channel: channel, 38 | IconEmoji: emoji, 39 | IconURL: iconURL, 40 | } 41 | return n 42 | } 43 | 44 | func (s *slackTypeNotifier) GetURL(c *cobra.Command) (string, error) { 45 | trimmedURL := strings.TrimRight(s.HookURL, "/") 46 | trimmedURL = strings.TrimPrefix(trimmedURL, "https://") 47 | parts := strings.Split(trimmedURL, "/") 48 | 49 | if parts[0] == "discord.com" || parts[0] == "discordapp.com" { 50 | log.Debug("Detected a discord slack wrapper URL, using shoutrrr discord service") 51 | conf := &shoutrrrDisco.Config{ 52 | WebhookID: parts[len(parts)-3], 53 | Token: parts[len(parts)-2], 54 | Color: ColorInt, 55 | SplitLines: true, 56 | Username: s.Username, 57 | } 58 | 59 | if s.IconURL != "" { 60 | conf.Avatar = s.IconURL 61 | } 62 | 63 | return conf.GetURL().String(), nil 64 | } 65 | 66 | webhookToken := strings.Replace(s.HookURL, "https://hooks.slack.com/services/", "", 1) 67 | 68 | conf := &shoutrrrSlack.Config{ 69 | BotName: s.Username, 70 | Color: ColorHex, 71 | Channel: "webhook", 72 | } 73 | 74 | if s.IconURL != "" { 75 | conf.Icon = s.IconURL 76 | } else if s.IconEmoji != "" { 77 | conf.Icon = s.IconEmoji 78 | } 79 | 80 | if err := conf.Token.SetFromProp(webhookToken); err != nil { 81 | return "", err 82 | } 83 | 84 | return conf.GetURL().String(), nil 85 | } 86 | -------------------------------------------------------------------------------- /pkg/notifications/templates/funcs.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | "text/template" 8 | 9 | "golang.org/x/text/cases" 10 | "golang.org/x/text/language" 11 | ) 12 | 13 | var Funcs = template.FuncMap{ 14 | "ToUpper": strings.ToUpper, 15 | "ToLower": strings.ToLower, 16 | "ToJSON": toJSON, 17 | "Title": cases.Title(language.AmericanEnglish).String, 18 | } 19 | 20 | func toJSON(v interface{}) string { 21 | var bytes []byte 22 | var err error 23 | if bytes, err = json.MarshalIndent(v, "", " "); err != nil { 24 | return fmt.Sprintf("failed to marshal JSON in notification template: %v", err) 25 | } 26 | return string(bytes) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/registry/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | 12 | "github.com/beatkind/watchtower/pkg/registry/helpers" 13 | "github.com/beatkind/watchtower/pkg/types" 14 | ref "github.com/distribution/reference" 15 | "github.com/sirupsen/logrus" 16 | ) 17 | 18 | // ChallengeHeader is the HTTP Header containing challenge instructions 19 | const ChallengeHeader = "WWW-Authenticate" 20 | 21 | // GetToken fetches a token for the registry hosting the provided image 22 | func GetToken(container types.Container, registryAuth string) (string, error) { 23 | normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName()) 24 | if err != nil { 25 | return "", err 26 | } 27 | 28 | URL := GetChallengeURL(normalizedRef) 29 | logrus.WithField("URL", URL.String()).Debug("Built challenge URL") 30 | 31 | var req *http.Request 32 | if req, err = GetChallengeRequest(URL); err != nil { 33 | return "", err 34 | } 35 | 36 | client := &http.Client{} 37 | var res *http.Response 38 | if res, err = client.Do(req); err != nil { 39 | return "", err 40 | } 41 | defer res.Body.Close() 42 | v := res.Header.Get(ChallengeHeader) 43 | 44 | logrus.WithFields(logrus.Fields{ 45 | "status": res.Status, 46 | "header": v, 47 | }).Debug("Got response to challenge request") 48 | 49 | challenge := strings.ToLower(v) 50 | if strings.HasPrefix(challenge, "basic") { 51 | if registryAuth == "" { 52 | return "", fmt.Errorf("no credentials available") 53 | } 54 | 55 | return fmt.Sprintf("Basic %s", registryAuth), nil 56 | } 57 | if strings.HasPrefix(challenge, "bearer") { 58 | return GetBearerHeader(challenge, normalizedRef, registryAuth) 59 | } 60 | 61 | return "", errors.New("unsupported challenge type from registry") 62 | } 63 | 64 | // GetChallengeRequest creates a request for getting challenge instructions 65 | func GetChallengeRequest(URL url.URL) (*http.Request, error) { 66 | req, err := http.NewRequest("GET", URL.String(), nil) 67 | if err != nil { 68 | return nil, err 69 | } 70 | req.Header.Set("Accept", "*/*") 71 | req.Header.Set("User-Agent", "Watchtower (Docker)") 72 | return req, nil 73 | } 74 | 75 | // GetBearerHeader tries to fetch a bearer token from the registry based on the challenge instructions 76 | func GetBearerHeader(challenge string, imageRef ref.Named, registryAuth string) (string, error) { 77 | client := http.Client{} 78 | authURL, err := GetAuthURL(challenge, imageRef) 79 | 80 | if err != nil { 81 | return "", err 82 | } 83 | 84 | var r *http.Request 85 | if r, err = http.NewRequest("GET", authURL.String(), nil); err != nil { 86 | return "", err 87 | } 88 | 89 | if registryAuth != "" { 90 | logrus.Debug("Credentials found.") 91 | // CREDENTIAL: Uncomment to log registry credentials 92 | // logrus.Tracef("Credentials: %v", registryAuth) 93 | r.Header.Add("Authorization", fmt.Sprintf("Basic %s", registryAuth)) 94 | } else { 95 | logrus.Debug("No credentials found.") 96 | } 97 | 98 | var authResponse *http.Response 99 | if authResponse, err = client.Do(r); err != nil { 100 | return "", err 101 | } 102 | 103 | body, _ := io.ReadAll(authResponse.Body) 104 | tokenResponse := &types.TokenResponse{} 105 | 106 | err = json.Unmarshal(body, tokenResponse) 107 | if err != nil { 108 | return "", err 109 | } 110 | 111 | return fmt.Sprintf("Bearer %s", tokenResponse.Token), nil 112 | } 113 | 114 | // GetAuthURL from the instructions in the challenge 115 | func GetAuthURL(challenge string, imageRef ref.Named) (*url.URL, error) { 116 | loweredChallenge := strings.ToLower(challenge) 117 | raw := strings.TrimPrefix(loweredChallenge, "bearer") 118 | 119 | pairs := strings.Split(raw, ",") 120 | values := make(map[string]string, len(pairs)) 121 | 122 | for _, pair := range pairs { 123 | trimmed := strings.Trim(pair, " ") 124 | if key, val, ok := strings.Cut(trimmed, "="); ok { 125 | values[key] = strings.Trim(val, `"`) 126 | } 127 | } 128 | logrus.WithFields(logrus.Fields{ 129 | "realm": values["realm"], 130 | "service": values["service"], 131 | }).Debug("Checking challenge header content") 132 | if values["realm"] == "" || values["service"] == "" { 133 | 134 | return nil, fmt.Errorf("challenge header did not include all values needed to construct an auth url") 135 | } 136 | 137 | authURL, _ := url.Parse(values["realm"]) 138 | q := authURL.Query() 139 | q.Add("service", values["service"]) 140 | 141 | scopeImage := ref.Path(imageRef) 142 | 143 | scope := fmt.Sprintf("repository:%s:pull", scopeImage) 144 | logrus.WithFields(logrus.Fields{"scope": scope, "image": imageRef.Name()}).Debug("Setting scope for auth token") 145 | q.Add("scope", scope) 146 | 147 | authURL.RawQuery = q.Encode() 148 | return authURL, nil 149 | } 150 | 151 | // GetChallengeURL returns the URL to check auth requirements 152 | // for access to a given image 153 | func GetChallengeURL(imageRef ref.Named) url.URL { 154 | host, _ := helpers.GetRegistryAddress(imageRef.Name()) 155 | 156 | URL := url.URL{ 157 | Scheme: "https", 158 | Host: host, 159 | Path: "/v2/", 160 | } 161 | return URL 162 | } 163 | -------------------------------------------------------------------------------- /pkg/registry/digest/digest.go: -------------------------------------------------------------------------------- 1 | package digest 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "net" 10 | "net/http" 11 | "strings" 12 | "time" 13 | 14 | "github.com/beatkind/watchtower/internal/meta" 15 | "github.com/beatkind/watchtower/pkg/registry/auth" 16 | "github.com/beatkind/watchtower/pkg/registry/manifest" 17 | "github.com/beatkind/watchtower/pkg/types" 18 | "github.com/sirupsen/logrus" 19 | ) 20 | 21 | // ContentDigestHeader is the key for the key-value pair containing the digest header 22 | const ContentDigestHeader = "Docker-Content-Digest" 23 | 24 | // CompareDigest ... 25 | func CompareDigest(container types.Container, registryAuth string) (bool, error) { 26 | if !container.HasImageInfo() { 27 | return false, errors.New("container image info missing") 28 | } 29 | 30 | var digest string 31 | 32 | registryAuth = TransformAuth(registryAuth) 33 | token, err := auth.GetToken(container, registryAuth) 34 | if err != nil { 35 | return false, err 36 | } 37 | 38 | digestURL, err := manifest.BuildManifestURL(container) 39 | if err != nil { 40 | return false, err 41 | } 42 | 43 | if digest, err = GetDigest(digestURL, token); err != nil { 44 | return false, err 45 | } 46 | 47 | logrus.WithField("remote", digest).Debug("Found a remote digest to compare with") 48 | 49 | for _, dig := range container.ImageInfo().RepoDigests { 50 | localDigest := strings.Split(dig, "@")[1] 51 | fields := logrus.Fields{"local": localDigest, "remote": digest} 52 | logrus.WithFields(fields).Debug("Comparing") 53 | 54 | if localDigest == digest { 55 | logrus.Debug("Found a match") 56 | return true, nil 57 | } 58 | } 59 | 60 | return false, nil 61 | } 62 | 63 | // TransformAuth from a base64 encoded json object to base64 encoded string 64 | func TransformAuth(registryAuth string) string { 65 | b, _ := base64.StdEncoding.DecodeString(registryAuth) 66 | credentials := &types.RegistryCredentials{} 67 | _ = json.Unmarshal(b, credentials) 68 | 69 | if credentials.Username != "" && credentials.Password != "" { 70 | ba := []byte(fmt.Sprintf("%s:%s", credentials.Username, credentials.Password)) 71 | registryAuth = base64.StdEncoding.EncodeToString(ba) 72 | } 73 | 74 | return registryAuth 75 | } 76 | 77 | // GetDigest from registry using a HEAD request to prevent rate limiting 78 | func GetDigest(url string, token string) (string, error) { 79 | tr := &http.Transport{ 80 | Proxy: http.ProxyFromEnvironment, 81 | DialContext: (&net.Dialer{ 82 | Timeout: 30 * time.Second, 83 | KeepAlive: 30 * time.Second, 84 | }).DialContext, 85 | ForceAttemptHTTP2: true, 86 | MaxIdleConns: 100, 87 | IdleConnTimeout: 90 * time.Second, 88 | TLSHandshakeTimeout: 10 * time.Second, 89 | ExpectContinueTimeout: 1 * time.Second, 90 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 91 | } 92 | client := &http.Client{Transport: tr} 93 | 94 | req, _ := http.NewRequest("HEAD", url, nil) 95 | req.Header.Set("User-Agent", meta.UserAgent) 96 | 97 | if token == "" { 98 | return "", errors.New("could not fetch token") 99 | } 100 | 101 | // CREDENTIAL: Uncomment to log the request token 102 | // logrus.WithField("token", token).Trace("Setting request token") 103 | 104 | req.Header.Add("Authorization", token) 105 | req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json") 106 | req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.list.v2+json") 107 | req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v1+json") 108 | req.Header.Add("Accept", "application/vnd.oci.image.index.v1+json") 109 | 110 | logrus.WithField("url", url).Debug("Doing a HEAD request to fetch a digest") 111 | 112 | res, err := client.Do(req) 113 | if err != nil { 114 | return "", err 115 | } 116 | defer res.Body.Close() 117 | 118 | if res.StatusCode != 200 { 119 | wwwAuthHeader := res.Header.Get("www-authenticate") 120 | if wwwAuthHeader == "" { 121 | wwwAuthHeader = "not present" 122 | } 123 | return "", fmt.Errorf("registry responded to head request with %q, auth: %q", res.Status, wwwAuthHeader) 124 | } 125 | return res.Header.Get(ContentDigestHeader), nil 126 | } 127 | -------------------------------------------------------------------------------- /pkg/registry/digest/digest_test.go: -------------------------------------------------------------------------------- 1 | package digest_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/beatkind/watchtower/internal/actions/mocks" 11 | "github.com/beatkind/watchtower/pkg/registry/digest" 12 | wtTypes "github.com/beatkind/watchtower/pkg/types" 13 | . "github.com/onsi/ginkgo/v2" 14 | . "github.com/onsi/gomega" 15 | "github.com/onsi/gomega/ghttp" 16 | ) 17 | 18 | func TestDigest(t *testing.T) { 19 | 20 | RegisterFailHandler(Fail) 21 | RunSpecs(GinkgoT(), "Digest Suite") 22 | } 23 | 24 | var ( 25 | DockerHubCredentials = &wtTypes.RegistryCredentials{ 26 | Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_USERNAME"), 27 | Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_PASSWORD"), 28 | } 29 | GHCRCredentials = &wtTypes.RegistryCredentials{ 30 | Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"), 31 | Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"), 32 | } 33 | ) 34 | 35 | func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() { 36 | if credentials.Username == "" { 37 | return func() { 38 | Skip("Username missing. Skipping integration test") 39 | } 40 | } else if credentials.Password == "" { 41 | return func() { 42 | Skip("Password missing. Skipping integration test") 43 | } 44 | } else { 45 | return fn 46 | } 47 | } 48 | 49 | var _ = Describe("Digests", func() { 50 | mockId := "mock-id" 51 | mockName := "mock-container" 52 | mockImage := "ghcr.io/k6io/operator:latest" 53 | mockCreated := time.Now() 54 | mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547" 55 | 56 | mockContainer := mocks.CreateMockContainerWithDigest( 57 | mockId, 58 | mockName, 59 | mockImage, 60 | mockCreated, 61 | mockDigest) 62 | 63 | mockContainerNoImage := mocks.CreateMockContainerWithImageInfoP(mockId, mockName, mockImage, mockCreated, nil) 64 | 65 | When("a digest comparison is done", func() { 66 | It("should return true if digests match", 67 | SkipIfCredentialsEmpty(GHCRCredentials, func() { 68 | creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password) 69 | matches, err := digest.CompareDigest(mockContainer, creds) 70 | Expect(err).NotTo(HaveOccurred()) 71 | Expect(matches).To(Equal(true)) 72 | }), 73 | ) 74 | 75 | It("should return false if digests differ", func() { 76 | 77 | }) 78 | It("should return an error if the registry isn't available", func() { 79 | 80 | }) 81 | It("should return an error when container contains no image info", func() { 82 | matches, err := digest.CompareDigest(mockContainerNoImage, `user:pass`) 83 | Expect(err).To(HaveOccurred()) 84 | Expect(matches).To(Equal(false)) 85 | }) 86 | }) 87 | When("using different registries", func() { 88 | It("should work with DockerHub", 89 | SkipIfCredentialsEmpty(DockerHubCredentials, func() { 90 | fmt.Println(DockerHubCredentials != nil) // to avoid crying linters 91 | }), 92 | ) 93 | It("should work with GitHub Container Registry", 94 | SkipIfCredentialsEmpty(GHCRCredentials, func() { 95 | fmt.Println(GHCRCredentials != nil) // to avoid crying linters 96 | }), 97 | ) 98 | }) 99 | When("sending a HEAD request", func() { 100 | var server *ghttp.Server 101 | BeforeEach(func() { 102 | server = ghttp.NewServer() 103 | }) 104 | AfterEach(func() { 105 | server.Close() 106 | }) 107 | It("should use a custom user-agent", func() { 108 | server.AppendHandlers( 109 | ghttp.CombineHandlers( 110 | ghttp.VerifyHeader(http.Header{ 111 | "User-Agent": []string{"Watchtower/v0.0.0-unknown"}, 112 | }), 113 | ghttp.RespondWith(http.StatusOK, "", http.Header{ 114 | digest.ContentDigestHeader: []string{ 115 | mockDigest, 116 | }, 117 | }), 118 | ), 119 | ) 120 | dig, err := digest.GetDigest(server.URL(), "token") 121 | Expect(server.ReceivedRequests()).Should(HaveLen(1)) 122 | Expect(err).NotTo(HaveOccurred()) 123 | Expect(dig).To(Equal(mockDigest)) 124 | }) 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /pkg/registry/helpers/helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "github.com/distribution/reference" 5 | ) 6 | 7 | // domains for Docker Hub, the default registry 8 | const ( 9 | DefaultRegistryDomain = "docker.io" 10 | DefaultRegistryHost = "index.docker.io" 11 | LegacyDefaultRegistryDomain = "index.docker.io" 12 | ) 13 | 14 | // GetRegistryAddress parses an image name 15 | // and returns the address of the specified registry 16 | func GetRegistryAddress(imageRef string) (string, error) { 17 | normalizedRef, err := reference.ParseNormalizedNamed(imageRef) 18 | if err != nil { 19 | return "", err 20 | } 21 | 22 | address := reference.Domain(normalizedRef) 23 | 24 | if address == DefaultRegistryDomain { 25 | address = DefaultRegistryHost 26 | } 27 | return address, nil 28 | } 29 | -------------------------------------------------------------------------------- /pkg/registry/helpers/helpers_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestHelpers(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Helper Suite") 13 | } 14 | 15 | var _ = Describe("the helpers", func() { 16 | Describe("GetRegistryAddress", func() { 17 | It("should return error if passed empty string", func() { 18 | _, err := GetRegistryAddress("") 19 | Expect(err).To(HaveOccurred()) 20 | }) 21 | It("should return index.docker.io for image refs with no explicit registry", func() { 22 | Expect(GetRegistryAddress("watchtower")).To(Equal("index.docker.io")) 23 | Expect(GetRegistryAddress("containrrr/watchtower")).To(Equal("index.docker.io")) 24 | }) 25 | It("should return index.docker.io for image refs with docker.io domain", func() { 26 | Expect(GetRegistryAddress("docker.io/watchtower")).To(Equal("index.docker.io")) 27 | Expect(GetRegistryAddress("docker.io/containrrr/watchtower")).To(Equal("index.docker.io")) 28 | }) 29 | It("should return the host if passed an image name containing a local host", func() { 30 | Expect(GetRegistryAddress("henk:80/watchtower")).To(Equal("henk:80")) 31 | Expect(GetRegistryAddress("localhost/watchtower")).To(Equal("localhost")) 32 | }) 33 | It("should return the server address if passed a fully qualified image name", func() { 34 | Expect(GetRegistryAddress("github.com/beatkind/config")).To(Equal("github.com")) 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /pkg/registry/manifest/manifest.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | url2 "net/url" 7 | 8 | "github.com/beatkind/watchtower/pkg/registry/helpers" 9 | "github.com/beatkind/watchtower/pkg/types" 10 | ref "github.com/distribution/reference" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | // BuildManifestURL from raw image data 15 | func BuildManifestURL(container types.Container) (string, error) { 16 | normalizedRef, err := ref.ParseDockerRef(container.ImageName()) 17 | if err != nil { 18 | return "", err 19 | } 20 | normalizedTaggedRef, isTagged := normalizedRef.(ref.NamedTagged) 21 | if !isTagged { 22 | return "", errors.New("Parsed container image ref has no tag: " + normalizedRef.String()) 23 | } 24 | 25 | host, _ := helpers.GetRegistryAddress(normalizedTaggedRef.Name()) 26 | img, tag := ref.Path(normalizedTaggedRef), normalizedTaggedRef.Tag() 27 | 28 | logrus.WithFields(logrus.Fields{ 29 | "image": img, 30 | "tag": tag, 31 | "normalized": normalizedTaggedRef.Name(), 32 | "host": host, 33 | }).Debug("Parsing image ref") 34 | 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | url := url2.URL{ 40 | Scheme: "https", 41 | Host: host, 42 | Path: fmt.Sprintf("/v2/%s/manifests/%s", img, tag), 43 | } 44 | return url.String(), nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/registry/manifest/manifest_test.go: -------------------------------------------------------------------------------- 1 | package manifest_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/beatkind/watchtower/internal/actions/mocks" 8 | "github.com/beatkind/watchtower/pkg/registry/manifest" 9 | "github.com/docker/docker/api/types/image" 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | func TestManifest(t *testing.T) { 15 | RegisterFailHandler(Fail) 16 | RunSpecs(t, "Manifest Suite") 17 | } 18 | 19 | var _ = Describe("the manifest module", func() { 20 | Describe("BuildManifestURL", func() { 21 | It("should return a valid url given a fully qualified image", func() { 22 | imageRef := "ghcr.io/containrrr/watchtower:mytag" 23 | expected := "https://ghcr.io/v2/containrrr/watchtower/manifests/mytag" 24 | 25 | URL, err := buildMockContainerManifestURL(imageRef) 26 | Expect(err).NotTo(HaveOccurred()) 27 | Expect(URL).To(Equal(expected)) 28 | }) 29 | It("should assume Docker Hub for image refs with no explicit registry", func() { 30 | imageRef := "containrrr/watchtower:latest" 31 | expected := "https://index.docker.io/v2/containrrr/watchtower/manifests/latest" 32 | 33 | URL, err := buildMockContainerManifestURL(imageRef) 34 | Expect(err).NotTo(HaveOccurred()) 35 | Expect(URL).To(Equal(expected)) 36 | }) 37 | It("should assume latest for image refs with no explicit tag", func() { 38 | imageRef := "containrrr/watchtower" 39 | expected := "https://index.docker.io/v2/containrrr/watchtower/manifests/latest" 40 | 41 | URL, err := buildMockContainerManifestURL(imageRef) 42 | Expect(err).NotTo(HaveOccurred()) 43 | Expect(URL).To(Equal(expected)) 44 | }) 45 | It("should not prepend library/ for single-part container names in registries other than Docker Hub", func() { 46 | imageRef := "docker-registry.domain/imagename:latest" 47 | expected := "https://docker-registry.domain/v2/imagename/manifests/latest" 48 | 49 | URL, err := buildMockContainerManifestURL(imageRef) 50 | Expect(err).NotTo(HaveOccurred()) 51 | Expect(URL).To(Equal(expected)) 52 | }) 53 | It("should throw an error on pinned images", func() { 54 | imageRef := "docker-registry.domain/imagename@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7" 55 | URL, err := buildMockContainerManifestURL(imageRef) 56 | Expect(err).To(HaveOccurred()) 57 | Expect(URL).To(BeEmpty()) 58 | }) 59 | }) 60 | }) 61 | 62 | func buildMockContainerManifestURL(imageRef string) (string, error) { 63 | imageInfo := image.InspectResponse{ 64 | RepoTags: []string{ 65 | imageRef, 66 | }, 67 | } 68 | mockID := "mock-id" 69 | mockName := "mock-container" 70 | mockCreated := time.Now() 71 | mock := mocks.CreateMockContainerWithImageInfo(mockID, mockName, imageRef, mockCreated, imageInfo) 72 | 73 | return manifest.BuildManifestURL(mock) 74 | } 75 | -------------------------------------------------------------------------------- /pkg/registry/registry.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "github.com/beatkind/watchtower/pkg/registry/helpers" 5 | watchtowerTypes "github.com/beatkind/watchtower/pkg/types" 6 | ref "github.com/distribution/reference" 7 | "github.com/docker/docker/api/types/image" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // GetPullOptions creates a struct with all options needed for pulling images from a registry 12 | func GetPullOptions(imageName string) (image.PullOptions, error) { 13 | auth, err := EncodedAuth(imageName) 14 | log.Debugf("Got image name: %s", imageName) 15 | if err != nil { 16 | return image.PullOptions{}, err 17 | } 18 | 19 | if auth == "" { 20 | return image.PullOptions{}, nil 21 | } 22 | 23 | // CREDENTIAL: Uncomment to log docker config auth 24 | // log.Tracef("Got auth value: %s", auth) 25 | 26 | return image.PullOptions{ 27 | RegistryAuth: auth, 28 | // PrivilegeFunc: DefaultAuthHandler, 29 | }, nil 30 | } 31 | 32 | // WarnOnAPIConsumption will return true if the registry is known-expected 33 | // to respond well to HTTP HEAD in checking the container digest -- or if there 34 | // are problems parsing the container hostname. 35 | // Will return false if behavior for container is unknown. 36 | func WarnOnAPIConsumption(container watchtowerTypes.Container) bool { 37 | 38 | normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName()) 39 | if err != nil { 40 | return true 41 | } 42 | 43 | containerHost, err := helpers.GetRegistryAddress(normalizedRef.Name()) 44 | if err != nil { 45 | return true 46 | } 47 | 48 | if containerHost == helpers.DefaultRegistryHost || containerHost == "ghcr.io" { 49 | return true 50 | } 51 | 52 | return false 53 | } 54 | -------------------------------------------------------------------------------- /pkg/registry/registry_suite_test.go: -------------------------------------------------------------------------------- 1 | package registry_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func TestRegistry(t *testing.T) { 13 | RegisterFailHandler(Fail) 14 | logrus.SetOutput(GinkgoWriter) 15 | RunSpecs(t, "Registry Suite") 16 | } 17 | -------------------------------------------------------------------------------- /pkg/registry/registry_test.go: -------------------------------------------------------------------------------- 1 | package registry_test 2 | 3 | import ( 4 | "github.com/beatkind/watchtower/internal/actions/mocks" 5 | unit "github.com/beatkind/watchtower/pkg/registry" 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | 9 | "time" 10 | ) 11 | 12 | var _ = Describe("Registry", func() { 13 | Describe("WarnOnAPIConsumption", func() { 14 | When("Given a container with an image from ghcr.io", func() { 15 | It("should want to warn", func() { 16 | Expect(testContainerWithImage("ghcr.io/containrrr/watchtower")).To(BeTrue()) 17 | }) 18 | }) 19 | When("Given a container with an image implicitly from dockerhub", func() { 20 | It("should want to warn", func() { 21 | Expect(testContainerWithImage("docker:latest")).To(BeTrue()) 22 | }) 23 | }) 24 | When("Given a container with an image explicitly from dockerhub", func() { 25 | It("should want to warn", func() { 26 | Expect(testContainerWithImage("index.docker.io/docker:latest")).To(BeTrue()) 27 | Expect(testContainerWithImage("docker.io/docker:latest")).To(BeTrue()) 28 | }) 29 | }) 30 | When("Given a container with an image from some other registry", func() { 31 | It("should not want to warn", func() { 32 | Expect(testContainerWithImage("docker.fsf.org/docker:latest")).To(BeFalse()) 33 | Expect(testContainerWithImage("altavista.com/docker:latest")).To(BeFalse()) 34 | Expect(testContainerWithImage("gitlab.com/docker:latest")).To(BeFalse()) 35 | }) 36 | }) 37 | }) 38 | }) 39 | 40 | func testContainerWithImage(imageName string) bool { 41 | container := mocks.CreateMockContainer("", "", imageName, time.Now()) 42 | return unit.WarnOnAPIConsumption(container) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/registry/trust.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "os" 8 | 9 | "github.com/beatkind/watchtower/pkg/registry/helpers" 10 | cliconfig "github.com/docker/cli/cli/config" 11 | "github.com/docker/cli/cli/config/configfile" 12 | "github.com/docker/cli/cli/config/credentials" 13 | "github.com/docker/cli/cli/config/types" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | // EncodedAuth returns an encoded auth config for the given registry 18 | // loaded from environment variables or docker config 19 | // as available in that order 20 | func EncodedAuth(ref string) (string, error) { 21 | auth, err := EncodedEnvAuth() 22 | if err != nil { 23 | auth, err = EncodedConfigAuth(ref) 24 | } 25 | return auth, err 26 | } 27 | 28 | // EncodedEnvAuth returns an encoded auth config for the given registry 29 | // loaded from environment variables 30 | // Returns an error if authentication environment variables have not been set 31 | func EncodedEnvAuth() (string, error) { 32 | username := os.Getenv("REPO_USER") 33 | password := os.Getenv("REPO_PASS") 34 | if username != "" && password != "" { 35 | auth := types.AuthConfig{ 36 | Username: username, 37 | Password: password, 38 | } 39 | 40 | log.Debugf("Loaded auth credentials for registry user %s from environment", auth.Username) 41 | // CREDENTIAL: Uncomment to log REPO_PASS environment variable 42 | // log.Tracef("Using auth password %s", auth.Password) 43 | 44 | return EncodeAuth(auth) 45 | } 46 | return "", errors.New("registry auth environment variables (REPO_USER, REPO_PASS) not set") 47 | } 48 | 49 | // EncodedConfigAuth returns an encoded auth config for the given registry 50 | // loaded from the docker config 51 | // Returns an empty string if credentials cannot be found for the referenced server 52 | // The docker config must be mounted on the container 53 | func EncodedConfigAuth(imageRef string) (string, error) { 54 | server, err := helpers.GetRegistryAddress(imageRef) 55 | if err != nil { 56 | log.Errorf("Could not get registry from image ref %s", imageRef) 57 | return "", err 58 | } 59 | 60 | configDir := os.Getenv("DOCKER_CONFIG") 61 | if configDir == "" { 62 | configDir = "/" 63 | } 64 | configFile, err := cliconfig.Load(configDir) 65 | if err != nil { 66 | log.Errorf("Unable to find default config file: %s", err) 67 | return "", err 68 | } 69 | credStore := CredentialsStore(*configFile) 70 | auth, _ := credStore.Get(server) // returns (types.AuthConfig{}) if server not in credStore 71 | 72 | if auth == (types.AuthConfig{}) { 73 | log.WithField("config_file", configFile.Filename).Debugf("No credentials for %s found", server) 74 | return "", nil 75 | } 76 | log.Debugf("Loaded auth credentials for user %s, on registry %s, from file %s", auth.Username, server, configFile.Filename) 77 | // CREDENTIAL: Uncomment to log docker config password 78 | // log.Tracef("Using auth password %s", auth.Password) 79 | return EncodeAuth(auth) 80 | } 81 | 82 | // CredentialsStore returns a new credentials store based 83 | // on the settings provided in the configuration file. 84 | func CredentialsStore(configFile configfile.ConfigFile) credentials.Store { 85 | if configFile.CredentialsStore != "" { 86 | return credentials.NewNativeStore(&configFile, configFile.CredentialsStore) 87 | } 88 | return credentials.NewFileStore(&configFile) 89 | } 90 | 91 | // EncodeAuth Base64 encode an AuthConfig struct for transmission over HTTP 92 | func EncodeAuth(authConfig types.AuthConfig) (string, error) { 93 | buf, err := json.Marshal(authConfig) 94 | if err != nil { 95 | return "", err 96 | } 97 | return base64.URLEncoding.EncodeToString(buf), nil 98 | } 99 | -------------------------------------------------------------------------------- /pkg/registry/trust_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "os" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | var _ = Describe("Registry credential helpers", func() { 11 | Describe("EncodedAuth", func() { 12 | It("should return repo credentials from env when set", func() { 13 | var err error 14 | expected := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0=" 15 | 16 | err = os.Setenv("REPO_USER", "containrrr-user") 17 | Expect(err).NotTo(HaveOccurred()) 18 | 19 | err = os.Setenv("REPO_PASS", "containrrr-pass") 20 | Expect(err).NotTo(HaveOccurred()) 21 | 22 | config, err := EncodedEnvAuth() 23 | Expect(config).To(Equal(expected)) 24 | Expect(err).NotTo(HaveOccurred()) 25 | }) 26 | }) 27 | 28 | Describe("EncodedEnvAuth", func() { 29 | It("should return an error if repo envs are unset", func() { 30 | _ = os.Unsetenv("REPO_USER") 31 | _ = os.Unsetenv("REPO_PASS") 32 | 33 | _, err := EncodedEnvAuth() 34 | Expect(err).To(HaveOccurred()) 35 | }) 36 | }) 37 | 38 | Describe("EncodedConfigAuth", func() { 39 | It("should return an error if file is not present", func() { 40 | var err error 41 | 42 | err = os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail") 43 | Expect(err).NotTo(HaveOccurred()) 44 | 45 | _, err = EncodedConfigAuth("") 46 | Expect(err).To(HaveOccurred()) 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /pkg/session/container_status.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import wt "github.com/beatkind/watchtower/pkg/types" 4 | 5 | // State indicates what the current state is of the container 6 | type State int 7 | 8 | // State enum values 9 | const ( 10 | // UnknownState is only used to represent an uninitialized State value 11 | UnknownState State = iota 12 | SkippedState 13 | ScannedState 14 | UpdatedState 15 | FailedState 16 | FreshState 17 | StaleState 18 | ) 19 | 20 | // ContainerStatus contains the container state during a session 21 | type ContainerStatus struct { 22 | containerID wt.ContainerID 23 | oldImage wt.ImageID 24 | newImage wt.ImageID 25 | containerName string 26 | imageName string 27 | error 28 | state State 29 | } 30 | 31 | // ID returns the container ID 32 | func (u *ContainerStatus) ID() wt.ContainerID { 33 | return u.containerID 34 | } 35 | 36 | // Name returns the container name 37 | func (u *ContainerStatus) Name() string { 38 | return u.containerName 39 | } 40 | 41 | // CurrentImageID returns the image ID that the container used when the session started 42 | func (u *ContainerStatus) CurrentImageID() wt.ImageID { 43 | return u.oldImage 44 | } 45 | 46 | // LatestImageID returns the newest image ID found during the session 47 | func (u *ContainerStatus) LatestImageID() wt.ImageID { 48 | return u.newImage 49 | } 50 | 51 | // ImageName returns the name:tag that the container uses 52 | func (u *ContainerStatus) ImageName() string { 53 | return u.imageName 54 | } 55 | 56 | // Error returns the error (if any) that was encountered for the container during a session 57 | func (u *ContainerStatus) Error() string { 58 | if u.error == nil { 59 | return "" 60 | } 61 | return u.error.Error() 62 | } 63 | 64 | // State returns the current State that the container is in 65 | func (u *ContainerStatus) State() string { 66 | switch u.state { 67 | case SkippedState: 68 | return "Skipped" 69 | case ScannedState: 70 | return "Scanned" 71 | case UpdatedState: 72 | return "Updated" 73 | case FailedState: 74 | return "Failed" 75 | case FreshState: 76 | return "Fresh" 77 | case StaleState: 78 | return "Stale" 79 | default: 80 | return "Unknown" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /pkg/session/progress.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "github.com/beatkind/watchtower/pkg/types" 5 | ) 6 | 7 | // Progress contains the current session container status 8 | type Progress map[types.ContainerID]*ContainerStatus 9 | 10 | // UpdateFromContainer sets various status fields from their corresponding container equivalents 11 | func UpdateFromContainer(cont types.Container, newImage types.ImageID, state State) *ContainerStatus { 12 | return &ContainerStatus{ 13 | containerID: cont.ID(), 14 | containerName: cont.Name(), 15 | imageName: cont.ImageName(), 16 | oldImage: cont.SafeImageID(), 17 | newImage: newImage, 18 | state: state, 19 | } 20 | } 21 | 22 | // AddSkipped adds a container to the Progress with the state set as skipped 23 | func (m Progress) AddSkipped(cont types.Container, err error) { 24 | update := UpdateFromContainer(cont, cont.SafeImageID(), SkippedState) 25 | update.error = err 26 | m.Add(update) 27 | } 28 | 29 | // AddScanned adds a container to the Progress with the state set as scanned 30 | func (m Progress) AddScanned(cont types.Container, newImage types.ImageID) { 31 | m.Add(UpdateFromContainer(cont, newImage, ScannedState)) 32 | } 33 | 34 | // UpdateFailed updates the containers passed, setting their state as failed with the supplied error 35 | func (m Progress) UpdateFailed(failures map[types.ContainerID]error) { 36 | for id, err := range failures { 37 | update := m[id] 38 | update.error = err 39 | update.state = FailedState 40 | } 41 | } 42 | 43 | // Add a container to the map using container ID as the key 44 | func (m Progress) Add(update *ContainerStatus) { 45 | m[update.containerID] = update 46 | } 47 | 48 | // MarkForUpdate marks the container identified by containerID for update 49 | func (m Progress) MarkForUpdate(containerID types.ContainerID) { 50 | m[containerID].state = UpdatedState 51 | } 52 | 53 | // Report creates a new Report from a Progress instance 54 | func (m Progress) Report() types.Report { 55 | return NewReport(m) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/session/report.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/beatkind/watchtower/pkg/types" 7 | ) 8 | 9 | type report struct { 10 | scanned []types.ContainerReport 11 | updated []types.ContainerReport 12 | failed []types.ContainerReport 13 | skipped []types.ContainerReport 14 | stale []types.ContainerReport 15 | fresh []types.ContainerReport 16 | } 17 | 18 | func (r *report) Scanned() []types.ContainerReport { 19 | return r.scanned 20 | } 21 | func (r *report) Updated() []types.ContainerReport { 22 | return r.updated 23 | } 24 | func (r *report) Failed() []types.ContainerReport { 25 | return r.failed 26 | } 27 | func (r *report) Skipped() []types.ContainerReport { 28 | return r.skipped 29 | } 30 | func (r *report) Stale() []types.ContainerReport { 31 | return r.stale 32 | } 33 | func (r *report) Fresh() []types.ContainerReport { 34 | return r.fresh 35 | } 36 | func (r *report) All() []types.ContainerReport { 37 | allLen := len(r.scanned) + len(r.updated) + len(r.failed) + len(r.skipped) + len(r.stale) + len(r.fresh) 38 | all := make([]types.ContainerReport, 0, allLen) 39 | 40 | presentIds := map[types.ContainerID][]string{} 41 | 42 | appendUnique := func(reports []types.ContainerReport) { 43 | for _, cr := range reports { 44 | if _, found := presentIds[cr.ID()]; found { 45 | continue 46 | } 47 | all = append(all, cr) 48 | presentIds[cr.ID()] = nil 49 | } 50 | } 51 | 52 | appendUnique(r.updated) 53 | appendUnique(r.failed) 54 | appendUnique(r.skipped) 55 | appendUnique(r.stale) 56 | appendUnique(r.fresh) 57 | appendUnique(r.scanned) 58 | 59 | sort.Sort(sortableContainers(all)) 60 | 61 | return all 62 | } 63 | 64 | // NewReport creates a types.Report from the supplied Progress 65 | func NewReport(progress Progress) types.Report { 66 | report := &report{ 67 | scanned: []types.ContainerReport{}, 68 | updated: []types.ContainerReport{}, 69 | failed: []types.ContainerReport{}, 70 | skipped: []types.ContainerReport{}, 71 | stale: []types.ContainerReport{}, 72 | fresh: []types.ContainerReport{}, 73 | } 74 | 75 | for _, update := range progress { 76 | if update.state == SkippedState { 77 | report.skipped = append(report.skipped, update) 78 | continue 79 | } 80 | 81 | report.scanned = append(report.scanned, update) 82 | if update.newImage == update.oldImage { 83 | update.state = FreshState 84 | report.fresh = append(report.fresh, update) 85 | continue 86 | } 87 | 88 | switch update.state { 89 | case UpdatedState: 90 | report.updated = append(report.updated, update) 91 | case FailedState: 92 | report.failed = append(report.failed, update) 93 | default: 94 | update.state = StaleState 95 | report.stale = append(report.stale, update) 96 | } 97 | } 98 | 99 | sort.Sort(sortableContainers(report.scanned)) 100 | sort.Sort(sortableContainers(report.updated)) 101 | sort.Sort(sortableContainers(report.failed)) 102 | sort.Sort(sortableContainers(report.skipped)) 103 | sort.Sort(sortableContainers(report.stale)) 104 | sort.Sort(sortableContainers(report.fresh)) 105 | 106 | return report 107 | } 108 | 109 | type sortableContainers []types.ContainerReport 110 | 111 | // Len implements sort.Interface.Len 112 | func (s sortableContainers) Len() int { return len(s) } 113 | 114 | // Less implements sort.Interface.Less 115 | func (s sortableContainers) Less(i, j int) bool { return s[i].ID() < s[j].ID() } 116 | 117 | // Swap implements sort.Interface.Swap 118 | func (s sortableContainers) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 119 | -------------------------------------------------------------------------------- /pkg/sorter/sort.go: -------------------------------------------------------------------------------- 1 | package sorter 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/beatkind/watchtower/pkg/types" 8 | ) 9 | 10 | // ByCreated allows a list of Container structs to be sorted by the container's 11 | // created date. 12 | type ByCreated []types.Container 13 | 14 | func (c ByCreated) Len() int { return len(c) } 15 | func (c ByCreated) Swap(i, j int) { c[i], c[j] = c[j], c[i] } 16 | 17 | // Less will compare two elements (identified by index) in the Container 18 | // list by created-date. 19 | func (c ByCreated) Less(i, j int) bool { 20 | t1, err := time.Parse(time.RFC3339Nano, c[i].ContainerInfo().Created) 21 | if err != nil { 22 | t1 = time.Now() 23 | } 24 | 25 | t2, _ := time.Parse(time.RFC3339Nano, c[j].ContainerInfo().Created) 26 | if err != nil { 27 | t1 = time.Now() 28 | } 29 | 30 | return t1.Before(t2) 31 | } 32 | 33 | // SortByDependencies will sort the list of containers taking into account any 34 | // links between containers. Container with no outgoing links will be sorted to 35 | // the front of the list while containers with links will be sorted after all 36 | // of their dependencies. This sort order ensures that linked containers can 37 | // be started in the correct order. 38 | func SortByDependencies(containers []types.Container) ([]types.Container, error) { 39 | sorter := dependencySorter{} 40 | return sorter.Sort(containers) 41 | } 42 | 43 | type dependencySorter struct { 44 | unvisited []types.Container 45 | marked map[string]bool 46 | sorted []types.Container 47 | } 48 | 49 | func (ds *dependencySorter) Sort(containers []types.Container) ([]types.Container, error) { 50 | ds.unvisited = containers 51 | ds.marked = map[string]bool{} 52 | 53 | for len(ds.unvisited) > 0 { 54 | if err := ds.visit(ds.unvisited[0]); err != nil { 55 | return nil, err 56 | } 57 | } 58 | 59 | return ds.sorted, nil 60 | } 61 | 62 | func (ds *dependencySorter) visit(c types.Container) error { 63 | 64 | if _, ok := ds.marked[c.Name()]; ok { 65 | return fmt.Errorf("circular reference to %s", c.Name()) 66 | } 67 | 68 | // Mark any visited node so that circular references can be detected 69 | ds.marked[c.Name()] = true 70 | defer delete(ds.marked, c.Name()) 71 | 72 | // Recursively visit links 73 | for _, linkName := range c.Links() { 74 | if linkedContainer := ds.findUnvisited(linkName); linkedContainer != nil { 75 | if err := ds.visit(*linkedContainer); err != nil { 76 | return err 77 | } 78 | } 79 | } 80 | 81 | // Move container from unvisited to sorted 82 | ds.removeUnvisited(c) 83 | ds.sorted = append(ds.sorted, c) 84 | 85 | return nil 86 | } 87 | 88 | func (ds *dependencySorter) findUnvisited(name string) *types.Container { 89 | for _, c := range ds.unvisited { 90 | if c.Name() == name { 91 | return &c 92 | } 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func (ds *dependencySorter) removeUnvisited(c types.Container) { 99 | var idx int 100 | for i := range ds.unvisited { 101 | if ds.unvisited[i].Name() == c.Name() { 102 | idx = i 103 | break 104 | } 105 | } 106 | 107 | ds.unvisited = append(ds.unvisited[0:idx], ds.unvisited[idx+1:]...) 108 | } 109 | -------------------------------------------------------------------------------- /pkg/types/container.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "strings" 5 | 6 | dockerContainer "github.com/docker/docker/api/types/container" 7 | "github.com/docker/docker/api/types/image" 8 | ) 9 | 10 | // ImageID is a hash string representing a container image 11 | type ImageID string 12 | 13 | // ContainerID is a hash string representing a container instance 14 | type ContainerID string 15 | 16 | // ShortID returns the 12-character (hex) short version of an image ID hash, removing any "sha256:" prefix if present 17 | func (id ImageID) ShortID() (short string) { 18 | return shortID(string(id)) 19 | } 20 | 21 | // ShortID returns the 12-character (hex) short version of a container ID hash, removing any "sha256:" prefix if present 22 | func (id ContainerID) ShortID() (short string) { 23 | return shortID(string(id)) 24 | } 25 | 26 | func shortID(longID string) string { 27 | prefixSep := strings.IndexRune(longID, ':') 28 | offset := 0 29 | length := 12 30 | if prefixSep >= 0 { 31 | if longID[0:prefixSep] == "sha256" { 32 | offset = prefixSep + 1 33 | } else { 34 | length += prefixSep + 1 35 | } 36 | } 37 | 38 | if len(longID) >= offset+length { 39 | return longID[offset : offset+length] 40 | } 41 | 42 | return longID 43 | } 44 | 45 | // Container is a docker container running an image 46 | type Container interface { 47 | ContainerInfo() *dockerContainer.InspectResponse 48 | ID() ContainerID 49 | IsRunning() bool 50 | Name() string 51 | ImageID() ImageID 52 | SafeImageID() ImageID 53 | ImageName() string 54 | Enabled() (bool, bool) 55 | IsMonitorOnly(UpdateParams) bool 56 | Scope() (string, bool) 57 | Links() []string 58 | ToRestart() bool 59 | IsWatchtower() bool 60 | StopSignal() string 61 | HasImageInfo() bool 62 | ImageInfo() *image.InspectResponse 63 | GetLifecyclePreCheckCommand() string 64 | GetLifecyclePostCheckCommand() string 65 | GetLifecyclePreUpdateCommand() string 66 | GetLifecyclePostUpdateCommand() string 67 | VerifyConfiguration() error 68 | SetStale(bool) 69 | IsStale() bool 70 | IsNoPull(UpdateParams) bool 71 | SetLinkedToRestarting(bool) 72 | IsLinkedToRestarting() bool 73 | PreUpdateTimeout() int 74 | PostUpdateTimeout() int 75 | IsRestarting() bool 76 | GetCreateConfig() *dockerContainer.Config 77 | GetCreateHostConfig() *dockerContainer.HostConfig 78 | } 79 | -------------------------------------------------------------------------------- /pkg/types/convertible_notifier.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // ConvertibleNotifier is a notifier capable of creating a shoutrrr URL 10 | type ConvertibleNotifier interface { 11 | GetURL(c *cobra.Command) (string, error) 12 | } 13 | 14 | // DelayNotifier is a notifier that might need to be delayed before sending notifications 15 | type DelayNotifier interface { 16 | GetDelay() time.Duration 17 | } 18 | -------------------------------------------------------------------------------- /pkg/types/filter.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // A Filter is a prototype for a function that can be used to filter the 4 | // results from a call to the ListContainers() method on the Client. 5 | type Filter func(FilterableContainer) bool 6 | -------------------------------------------------------------------------------- /pkg/types/filterable_container.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // A FilterableContainer is the interface which is used to filter 4 | // containers. 5 | type FilterableContainer interface { 6 | Name() string 7 | IsWatchtower() bool 8 | Enabled() (bool, bool) 9 | Scope() (string, bool) 10 | ImageName() string 11 | } 12 | -------------------------------------------------------------------------------- /pkg/types/notifier.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // Notifier is the interface that all notification services have in common 4 | type Notifier interface { 5 | StartNotification() 6 | SendNotification(Report) 7 | AddLogHook() 8 | GetNames() []string 9 | GetURLs() []string 10 | Close() 11 | } 12 | -------------------------------------------------------------------------------- /pkg/types/registry_credentials.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // RegistryCredentials is a credential pair used for basic auth 4 | type RegistryCredentials struct { 5 | Username string 6 | Password string // usually a token rather than an actual password 7 | } 8 | -------------------------------------------------------------------------------- /pkg/types/report.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // Report contains reports for all the containers processed during a session 4 | type Report interface { 5 | Scanned() []ContainerReport 6 | Updated() []ContainerReport 7 | Failed() []ContainerReport 8 | Skipped() []ContainerReport 9 | Stale() []ContainerReport 10 | Fresh() []ContainerReport 11 | All() []ContainerReport 12 | } 13 | 14 | // ContainerReport represents a container that was included in watchtower session 15 | type ContainerReport interface { 16 | ID() ContainerID 17 | Name() string 18 | CurrentImageID() ImageID 19 | LatestImageID() ImageID 20 | ImageName() string 21 | Error() string 22 | State() string 23 | } 24 | -------------------------------------------------------------------------------- /pkg/types/token_response.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // TokenResponse is returned by the registry on successful authentication 4 | type TokenResponse struct { 5 | Token string `json:"token"` 6 | } 7 | -------------------------------------------------------------------------------- /pkg/types/update_params.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // UpdateParams contains all different options available to alter the behavior of the Update func 8 | type UpdateParams struct { 9 | Filter Filter 10 | Cleanup bool 11 | NoRestart bool 12 | Timeout time.Duration 13 | MonitorOnly bool 14 | NoPull bool 15 | LifecycleHooks bool 16 | RollingRestart bool 17 | LabelPrecedence bool 18 | } 19 | -------------------------------------------------------------------------------- /prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | scrape_configs: 2 | - job_name: watchtower 3 | scrape_interval: 5s 4 | metrics_path: /v1/metrics 5 | bearer_token: demotoken 6 | static_configs: 7 | - targets: 8 | - 'watchtower:8080' 9 | 10 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /scripts/build-tplprev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd $(git rev-parse --show-toplevel) 4 | 5 | cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./docs/assets/ 6 | 7 | GOARCH=wasm GOOS=js go build -o ./docs/assets/tplprev.wasm ./tplprev -------------------------------------------------------------------------------- /scripts/codecov.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | go test -v -coverprofile coverage.out -covermode atomic ./... 4 | 5 | # Requires CODECOV_TOKEN to be set 6 | bash <(curl -s https://codecov.io/bash) -------------------------------------------------------------------------------- /scripts/contnet-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | function exit_env_err() { 6 | >&2 echo "Required environment variable not set: $1" 7 | exit 1 8 | } 9 | 10 | if [ -z "$VPN_SERVICE_PROVIDER" ]; then exit_env_err "VPN_SERVICE_PROVIDER"; fi 11 | if [ -z "$OPENVPN_USER" ]; then exit_env_err "OPENVPN_USER"; fi 12 | if [ -z "$OPENVPN_PASSWORD" ]; then exit_env_err "OPENVPN_PASSWORD"; fi 13 | # if [ -z "$SERVER_COUNTRIES" ]; then exit_env_err "SERVER_COUNTRIES"; fi 14 | 15 | 16 | export SERVER_COUNTRIES=${SERVER_COUNTRIES:"Sweden"} 17 | REPO_ROOT="$(git rev-parse --show-toplevel)" 18 | COMPOSE_FILE="$REPO_ROOT/dockerfiles/container-networking/docker-compose.yml" 19 | DEFAULT_WATCHTOWER="$REPO_ROOT/watchtower" 20 | WATCHTOWER="$*" 21 | WATCHTOWER=${WATCHTOWER:-$DEFAULT_WATCHTOWER} 22 | echo "repo root path is $REPO_ROOT" 23 | echo "watchtower path is $WATCHTOWER" 24 | echo "compose file path is $COMPOSE_FILE" 25 | 26 | echo; echo "=== Forcing network container producer update..." 27 | 28 | echo "Pull previous version of gluetun..." 29 | docker pull qmcgaw/gluetun:v3.34.3 30 | echo "Fake new version of gluetun by retagging v3.34.4 as v3.35.0..." 31 | docker tag qmcgaw/gluetun:v3.34.3 qmcgaw/gluetun:v3.35.0 32 | 33 | echo; echo "=== Creating containers..." 34 | 35 | docker compose -p "wt-contnet" -f "$COMPOSE_FILE" up -d 36 | 37 | echo; echo "=== Running watchtower" 38 | $WATCHTOWER --run-once 39 | 40 | echo; echo "=== Removing containers..." 41 | 42 | docker compose -p "wt-contnet" -f "$COMPOSE_FILE" down 43 | -------------------------------------------------------------------------------- /scripts/dependency-test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Simulates a container that will always be updated, checking whether it shuts down it's dependencies correctly. 4 | # Note that this test does not verify the results in any way 5 | 6 | set -e 7 | SCRIPT_ROOT=$(dirname "$(readlink -m "$(type -p "$0")")") 8 | source "$SCRIPT_ROOT/docker-util.sh" 9 | 10 | DepArgs="" 11 | if [ -z "$1" ] || [ "$1" == "depends-on" ]; then 12 | DepArgs="--label com.centurylinklabs.watchtower.depends-on=parent" 13 | elif [ "$1" == "linked" ]; then 14 | DepArgs="--link parent" 15 | else 16 | DepArgs=$1 17 | fi 18 | 19 | WatchArgs="${*:2}" 20 | if [ -z "$WatchArgs" ]; then 21 | WatchArgs="--debug" 22 | fi 23 | 24 | try-remove-container parent 25 | try-remove-container depending 26 | 27 | REPO=$(registry-host) 28 | 29 | create-dummy-image deptest/parent 30 | create-dummy-image deptest/depending 31 | 32 | echo "" 33 | 34 | echo -en "Starting \e[94mparent\e[0m container... " 35 | CmdParent="docker run -d -p 9090 --name parent $REPO/deptest/parent" 36 | $CmdParent 37 | PARENT_REV_BEFORE=$(query-rev parent) 38 | PARENT_START_BEFORE=$(container-started parent) 39 | echo -e "Rev: \e[92m$PARENT_REV_BEFORE\e[0m" 40 | echo -e "Started: \e[96m$PARENT_START_BEFORE\e[0m" 41 | echo -e "Command: \e[37m$CmdParent\e[0m" 42 | 43 | echo "" 44 | 45 | echo -en "Starting \e[94mdepending\e[0m container... " 46 | CmdDepend="docker run -d -p 9090 --name depending $DepArgs $REPO/deptest/depending" 47 | $CmdDepend 48 | DEPEND_REV_BEFORE=$(query-rev depending) 49 | DEPEND_START_BEFORE=$(container-started depending) 50 | echo -e "Rev: \e[92m$DEPEND_REV_BEFORE\e[0m" 51 | echo -e "Started: \e[96m$DEPEND_START_BEFORE\e[0m" 52 | echo -e "Command: \e[37m$CmdDepend\e[0m" 53 | 54 | echo -e "" 55 | 56 | create-dummy-image deptest/parent 57 | 58 | echo -e "\nRunning watchtower..." 59 | 60 | if [ -z "$WATCHTOWER_TAG" ]; then 61 | ## Windows support: 62 | #export DOCKER_HOST=tcp://localhost:2375 63 | #export CLICOLOR=1 64 | go run . --run-once $WatchArgs 65 | else 66 | docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower:"$WATCHTOWER_TAG" --run-once $WatchArgs 67 | fi 68 | 69 | echo -e "\nSession results:" 70 | 71 | PARENT_REV_AFTER=$(query-rev parent) 72 | PARENT_START_AFTER=$(container-started parent) 73 | echo -en " Parent image: \e[95m$PARENT_REV_BEFORE\e[0m => \e[94m$PARENT_REV_AFTER\e[0m " 74 | if [ "$PARENT_REV_AFTER" == "$PARENT_REV_BEFORE" ]; then 75 | echo -e "(\e[91mSame\e[0m)" 76 | else 77 | echo -e "(\e[92mUpdated\e[0m)" 78 | fi 79 | echo -en " Parent container: \e[95m$PARENT_START_BEFORE\e[0m => \e[94m$PARENT_START_AFTER\e[0m " 80 | if [ "$PARENT_START_AFTER" == "$PARENT_START_BEFORE" ]; then 81 | echo -e "(\e[91mSame\e[0m)" 82 | else 83 | echo -e "(\e[92mRestarted\e[0m)" 84 | fi 85 | 86 | echo "" 87 | 88 | DEPEND_REV_AFTER=$(query-rev depending) 89 | DEPEND_START_AFTER=$(container-started depending) 90 | echo -en " Depend image: \e[95m$DEPEND_REV_BEFORE\e[0m => \e[94m$DEPEND_REV_AFTER\e[0m " 91 | if [ "$DEPEND_REV_BEFORE" == "$DEPEND_REV_AFTER" ]; then 92 | echo -e "(\e[92mSame\e[0m)" 93 | else 94 | echo -e "(\e[91mUpdated\e[0m)" 95 | fi 96 | echo -en " Depend container: \e[95m$DEPEND_START_BEFORE\e[0m => \e[94m$DEPEND_START_AFTER\e[0m " 97 | if [ "$DEPEND_START_BEFORE" == "$DEPEND_START_AFTER" ]; then 98 | echo -e "(\e[91mSame\e[0m)" 99 | else 100 | echo -e "(\e[92mRestarted\e[0m)" 101 | fi 102 | 103 | echo "" -------------------------------------------------------------------------------- /scripts/docker-util.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # This file is meant to be sourced into other scripts and contain some utility functions for docker e2e testing 3 | 4 | 5 | CONTAINER_PREFIX=${CONTAINER_PREFIX:-du} 6 | 7 | function get-port() { 8 | Container=$1 9 | Port=$2 10 | 11 | if [ -z "$Container" ]; then 12 | echo "CONTAINER missing" 1>&2 13 | return 1 14 | fi 15 | 16 | if [ -z "$Port" ]; then 17 | echo "PORT missing" 1>&2 18 | return 1 19 | fi 20 | 21 | Query=".[].NetworkSettings.Ports[\"$Port/tcp\"] | .[0].HostPort" 22 | docker container inspect "$Container" | jq -r "$Query" 23 | } 24 | 25 | function start-registry() { 26 | local Name="$CONTAINER_PREFIX-registry" 27 | echo -en "Starting \e[94m$Name\e[0m container... " 28 | local Port="${1:-5000}" 29 | docker run -d -p 5000:"$Port" --restart=unless-stopped --name "$Name" registry:2 30 | } 31 | 32 | function stop-registry() { 33 | try-remove-container "$CONTAINER_PREFIX-registry" 34 | } 35 | 36 | function registry-host() { 37 | echo "localhost:$(get-port "$CONTAINER_PREFIX"-registry 5000)" 38 | } 39 | 40 | function try-remove-container() { 41 | echo -en "Looking for container \e[95m$1\e[0m... " 42 | local Found 43 | Found=$(container-id "$1") 44 | if [ -n "$Found" ]; then 45 | echo "$Found" 46 | echo -n " Stopping... " 47 | docker stop "$1" 48 | echo -n " Removing... " 49 | docker rm "$1" 50 | else 51 | echo "Not found" 52 | fi 53 | } 54 | 55 | function create-dummy-image() { 56 | if [ -z "$1" ]; then 57 | echo "TAG missing" 58 | return 1 59 | fi 60 | local Tag="$1" 61 | local Repo 62 | Repo="$(registry-host)" 63 | local Revision=${2:-$(("$(date +%s)" - "$(date --date='2021-10-21' +%s)"))} 64 | 65 | echo -e "Creating new image \e[95m$Tag\e[0m revision: \e[94m$Revision\e[0m" 66 | 67 | local BuildDir="/tmp/docker-dummy-$Tag-$Revision" 68 | 69 | mkdir -p "$BuildDir" 70 | 71 | cat > "$BuildDir/Dockerfile" << END 72 | FROM alpine 73 | 74 | RUN echo "Tag: $Tag" 75 | RUN echo "Revision: $Revision" 76 | ENTRYPOINT ["nc", "-lk", "-v", "-l", "-p", "9090", "-e", "echo", "-e", "HTTP/1.1 200 OK\n\n$Tag $Revision"] 77 | END 78 | 79 | docker build -t "$Repo/$Tag:latest" -t "$Repo/$Tag:r$Revision" "$BuildDir" 80 | 81 | echo -e "Pushing images...\e[93m" 82 | docker push -q "$Repo/$Tag:latest" 83 | docker push -q "$Repo/$Tag:r$Revision" 84 | echo -en "\e[0m" 85 | 86 | rm -r "$BuildDir" 87 | } 88 | 89 | function query-rev() { 90 | local Name=$1 91 | if [ -z "$Name" ]; then 92 | echo "NAME missing" 93 | return 1 94 | fi 95 | curl -s "localhost:$(get-port "$Name" 9090)" 96 | } 97 | 98 | function latest-image-rev() { 99 | local Tag=$1 100 | if [ -z "$Tag" ]; then 101 | echo "TAG missing" 102 | return 1 103 | fi 104 | local ID 105 | ID=$(docker image ls "$(registry-host)"/"$Tag":latest -q) 106 | docker image inspect "$ID" | jq -r '.[].RepoTags | .[]' | grep -v latest 107 | } 108 | 109 | function container-id() { 110 | local Name=$1 111 | if [ -z "$Name" ]; then 112 | echo "NAME missing" 113 | return 1 114 | fi 115 | docker container ls -f name="$Name" -q 116 | } 117 | 118 | function container-started() { 119 | local Name=$1 120 | if [ -z "$Name" ]; then 121 | echo "NAME missing" 122 | return 1 123 | fi 124 | docker container inspect "$Name" | jq -r .[].State.StartedAt 125 | } 126 | 127 | 128 | function container-exists() { 129 | local Name=$1 130 | if [ -z "$Name" ]; then 131 | echo "NAME missing" 132 | return 1 133 | fi 134 | 135 | docker container inspect "$Name" 1> /dev/null 2> /dev/null 136 | } 137 | 138 | function registry-exists() { 139 | container-exists "$CONTAINER_PREFIX-registry" 140 | } 141 | 142 | function create-container() { 143 | local container_name=$1 144 | if [ -z "$container_name" ]; then 145 | echo "NAME missing" 146 | return 1 147 | fi 148 | local image_name="${2:-$container_name}" 149 | 150 | echo -en "Creating \e[94m$container_name\e[0m container... " 151 | local result 152 | result=$(docker run -d --name "$container_name" "$(registry-host)/$image_name" 2>&1) 153 | if [ "${#result}" -eq 64 ]; then 154 | echo -e "\e[92m${result:0:12}\e[0m" 155 | return 0 156 | else 157 | echo -e "\e[91mFailed!\n\e[97m$result\e[0m" 158 | return 1 159 | fi 160 | } 161 | 162 | function remove-images() { 163 | local image_name=$1 164 | if [ -z "$image_name" ]; then 165 | echo "NAME missing" 166 | return 1 167 | fi 168 | 169 | local images 170 | mapfile -t images < <(docker images -q "$image_name" | uniq) 171 | if [ -n "${images[*]}" ]; then 172 | docker image rm "${images[@]}" 173 | else 174 | echo "No images matched \"$image_name\"" 175 | fi 176 | } 177 | 178 | function remove-repo-images() { 179 | local image_name=$1 180 | if [ -z "$image_name" ]; then 181 | echo "NAME missing" 182 | return 1 183 | fi 184 | 185 | remove-images "$(registry-host)/images/$image_name" 186 | } -------------------------------------------------------------------------------- /scripts/du-cli.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | SCRIPT_ROOT=$(dirname "$(readlink -m "$(type -p "$0")")") 4 | source "$SCRIPT_ROOT/docker-util.sh" 5 | 6 | case $1 in 7 | registry | reg | r) 8 | case $2 in 9 | start) 10 | start-registry 11 | ;; 12 | stop) 13 | stop-registry 14 | ;; 15 | host) 16 | registry-host 17 | ;; 18 | *) 19 | echo "Unknown registry action \"$2\"" 20 | ;; 21 | esac 22 | ;; 23 | image | img | i) 24 | case $2 in 25 | rev) 26 | create-dummy-image "${@:3:2}" 27 | ;; 28 | latest) 29 | latest-image-rev "$3" 30 | ;; 31 | rm) 32 | remove-repo-images "$3" 33 | ;; 34 | *) 35 | echo "Unknown image action \"$2\"" 36 | ;; 37 | esac 38 | ;; 39 | container | cnt | c) 40 | case $2 in 41 | query) 42 | query-rev "$3" 43 | ;; 44 | rm) 45 | try-remove-container "$3" 46 | ;; 47 | id) 48 | container-id "$3" 49 | ;; 50 | started) 51 | container-started "$3" 52 | ;; 53 | create) 54 | create-container "${@:3:2}" 55 | ;; 56 | create-stale) 57 | if [ -z "$3" ]; then 58 | echo "NAME missing" 59 | exit 1 60 | fi 61 | if ! registry-exists; then 62 | echo "Registry container missing! Creating..." 63 | start-registry || exit 1 64 | fi 65 | image_name="images/$3" 66 | container_name=$3 67 | $0 image rev "$image_name" || exit 1 68 | $0 container create "$container_name" "$image_name" || exit 1 69 | $0 image rev "$image_name" || exit 1 70 | ;; 71 | *) 72 | echo "Unknown container action \"$2\"" 73 | ;; 74 | esac 75 | ;; 76 | *) 77 | echo "Unknown keyword \"$1\"" 78 | ;; 79 | esac -------------------------------------------------------------------------------- /tplprev/main.go: -------------------------------------------------------------------------------- 1 | //go:build !wasm 2 | 3 | package main 4 | 5 | import ( 6 | "flag" 7 | "fmt" 8 | "os" 9 | 10 | "github.com/beatkind/watchtower/internal/meta" 11 | "github.com/beatkind/watchtower/pkg/notifications/preview" 12 | "github.com/beatkind/watchtower/pkg/notifications/preview/data" 13 | ) 14 | 15 | func main() { 16 | fmt.Fprintf(os.Stderr, "watchtower/tplprev %v\n\n", meta.Version) 17 | 18 | var states string 19 | var entries string 20 | 21 | flag.StringVar(&states, "states", "cccuuueeekkktttfff", "sCanned, Updated, failEd, sKipped, sTale, Fresh") 22 | flag.StringVar(&entries, "entries", "ewwiiidddd", "Fatal,Error,Warn,Info,Debug,Trace") 23 | 24 | flag.Parse() 25 | 26 | if len(flag.Args()) < 1 { 27 | fmt.Fprintln(os.Stderr, "Missing required argument TEMPLATE") 28 | flag.Usage() 29 | os.Exit(1) 30 | return 31 | } 32 | 33 | input, err := os.ReadFile(flag.Arg(0)) 34 | if err != nil { 35 | 36 | fmt.Fprintf(os.Stderr, "Failed to read template file %q: %v\n", flag.Arg(0), err) 37 | os.Exit(1) 38 | return 39 | } 40 | 41 | result, err := preview.Render(string(input), data.StatesFromString(states), data.LevelsFromString(entries)) 42 | if err != nil { 43 | fmt.Fprintf(os.Stderr, "Failed to read template file %q: %v\n", flag.Arg(0), err) 44 | os.Exit(1) 45 | return 46 | } 47 | 48 | fmt.Println(result) 49 | } 50 | -------------------------------------------------------------------------------- /tplprev/main_wasm.go: -------------------------------------------------------------------------------- 1 | //go:build wasm 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/beatkind/watchtower/internal/meta" 9 | "github.com/beatkind/watchtower/pkg/notifications/preview" 10 | "github.com/beatkind/watchtower/pkg/notifications/preview/data" 11 | 12 | "syscall/js" 13 | ) 14 | 15 | func main() { 16 | fmt.Println("watchtower/tplprev v" + meta.Version) 17 | 18 | js.Global().Set("WATCHTOWER", js.ValueOf(map[string]any{ 19 | "tplprev": js.FuncOf(jsTplPrev), 20 | })) 21 | <-make(chan bool) 22 | 23 | } 24 | 25 | func jsTplPrev(this js.Value, args []js.Value) any { 26 | 27 | if len(args) < 3 { 28 | return "Requires 3 arguments passed" 29 | } 30 | 31 | input := args[0].String() 32 | 33 | statesArg := args[1] 34 | var states []data.State 35 | 36 | if statesArg.Type() == js.TypeString { 37 | states = data.StatesFromString(statesArg.String()) 38 | } else { 39 | for i := 0; i < statesArg.Length(); i++ { 40 | state := data.State(statesArg.Index(i).String()) 41 | states = append(states, state) 42 | } 43 | } 44 | 45 | levelsArg := args[2] 46 | var levels []data.LogLevel 47 | 48 | if levelsArg.Type() == js.TypeString { 49 | levels = data.LevelsFromString(statesArg.String()) 50 | } else { 51 | for i := 0; i < levelsArg.Length(); i++ { 52 | level := data.LogLevel(levelsArg.Index(i).String()) 53 | levels = append(levels, level) 54 | } 55 | } 56 | 57 | result, err := preview.Render(input, states, levels) 58 | if err != nil { 59 | return "Error: " + err.Error() 60 | } 61 | return result 62 | } 63 | --------------------------------------------------------------------------------