Merge branch 'main' into fix-container-downtime
commit
24276cfbc6
@ -1,227 +0,0 @@
|
||||
version: 2.1
|
||||
|
||||
executors:
|
||||
py:
|
||||
docker:
|
||||
- image: circleci/python:latest
|
||||
working_directory: ~/repo
|
||||
go:
|
||||
docker:
|
||||
- image: circleci/golang:latest
|
||||
working_directory: ~/repo
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
ci:
|
||||
jobs:
|
||||
- checkout:
|
||||
filters:
|
||||
branches:
|
||||
only: /.*/
|
||||
tags:
|
||||
only: /.*/
|
||||
- linting:
|
||||
requires:
|
||||
- checkout
|
||||
filters:
|
||||
branches:
|
||||
only: /.*/
|
||||
tags:
|
||||
only: /.*/
|
||||
- testing:
|
||||
requires:
|
||||
- checkout
|
||||
filters:
|
||||
branches:
|
||||
only: /.*/
|
||||
tags:
|
||||
only: /.*/
|
||||
- build:
|
||||
requires:
|
||||
- testing
|
||||
- linting
|
||||
filters:
|
||||
branches:
|
||||
only: /.*/
|
||||
tags:
|
||||
ignore: /^v[0-9]+(\.[0-9]+)*$/
|
||||
- publishing:
|
||||
requires:
|
||||
- testing
|
||||
- linting
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: /^v[0-9]+(\.[0-9]+)*$/
|
||||
- publish-docs:
|
||||
requires:
|
||||
- testing
|
||||
- linting
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: /^v[0-9]+(\.[0-9]+)*$/
|
||||
jobs:
|
||||
checkout:
|
||||
executor: go
|
||||
steps:
|
||||
- checkout
|
||||
- persist_to_workspace:
|
||||
paths:
|
||||
- .
|
||||
root: ~/repo
|
||||
linting:
|
||||
executor: go
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- run: go build .
|
||||
- run: go get -u golang.org/x/lint/golint
|
||||
- run: golint -set_exit_status ./...
|
||||
testing:
|
||||
executor: go
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- run: go build ./...
|
||||
- run: go get github.com/schrej/godacov
|
||||
- run: go test ./... -coverprofile coverage.out
|
||||
# - run: godacov -t $CODACY_TOKEN -r ./coverage.out -c $CIRCLE_SHA1
|
||||
build:
|
||||
executor: go
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- setup_remote_docker
|
||||
- run:
|
||||
name: Install Goreleaser
|
||||
command: |
|
||||
cd .. && \
|
||||
wget https://github.com/goreleaser/goreleaser/releases/download/v0.104.1/goreleaser_Linux_x86_64.tar.gz && \
|
||||
tar -xvf goreleaser_Linux_x86_64.tar.gz && \
|
||||
./goreleaser -v
|
||||
- run:
|
||||
name: Execute goreleaser
|
||||
command: CGO_ENABLED=${CGO_ENABLED:-0} ../goreleaser --snapshot --skip-publish --debug
|
||||
publishing:
|
||||
executor: go
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- setup_remote_docker
|
||||
- run:
|
||||
name: Install Goreleaser
|
||||
command: |
|
||||
cd .. && \
|
||||
wget https://github.com/goreleaser/goreleaser/releases/download/v0.104.1/goreleaser_Linux_x86_64.tar.gz && \
|
||||
tar -xvf goreleaser_Linux_x86_64.tar.gz && \
|
||||
./goreleaser -v
|
||||
- run:
|
||||
name: Login to docker hub
|
||||
command: |
|
||||
echo "$DOCKER_PASS" | docker login -u $DOCKER_USER --password-stdin
|
||||
- run:
|
||||
name: Execute goreleaser
|
||||
command: CGO_ENABLED=${CGO_ENABLED:-0} ../goreleaser --debug
|
||||
- run:
|
||||
name: Enable experimental docker features
|
||||
command: |
|
||||
mkdir -p ~/.docker/ && \
|
||||
echo '{"experimental": "enabled"}' > ~/.docker/config.json
|
||||
- run:
|
||||
name: Create manifest for version
|
||||
command: |
|
||||
docker manifest create \
|
||||
containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') \
|
||||
containrrr/watchtower:amd64-$(echo $CIRCLE_TAG | sed 's/^v*//') \
|
||||
containrrr/watchtower:i386-$(echo $CIRCLE_TAG | sed 's/^v*//') \
|
||||
containrrr/watchtower:armhf-$(echo $CIRCLE_TAG | sed 's/^v*//') \
|
||||
containrrr/watchtower:arm64v8-$(echo $CIRCLE_TAG | sed 's/^v*//')
|
||||
- run:
|
||||
name: Annotate i386 version
|
||||
command: |
|
||||
docker manifest annotate \
|
||||
containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') \
|
||||
containrrr/watchtower:i386-$(echo $CIRCLE_TAG | sed 's/^v*//') \
|
||||
--os linux \
|
||||
--arch 386
|
||||
- run:
|
||||
name: Annotate ARM version
|
||||
command: |
|
||||
docker manifest annotate \
|
||||
containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') \
|
||||
containrrr/watchtower:armhf-$(echo $CIRCLE_TAG | sed 's/^v*//') \
|
||||
--os linux \
|
||||
--arch arm
|
||||
- run:
|
||||
name: Annotate ARM64 version
|
||||
command: |
|
||||
docker manifest annotate \
|
||||
containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') \
|
||||
containrrr/watchtower:arm64v8-$(echo $CIRCLE_TAG | sed 's/^v*//') \
|
||||
--os linux \
|
||||
--arch arm64 \
|
||||
--variant v8
|
||||
- run:
|
||||
name: Create manifest for latest
|
||||
command: |
|
||||
docker manifest create \
|
||||
containrrr/watchtower:latest \
|
||||
containrrr/watchtower:amd64-latest \
|
||||
containrrr/watchtower:i386-latest \
|
||||
containrrr/watchtower:armhf-latest \
|
||||
containrrr/watchtower:arm64v8-latest
|
||||
- run:
|
||||
name: Annotate i386 latest
|
||||
command: |
|
||||
docker manifest annotate \
|
||||
containrrr/watchtower:latest \
|
||||
containrrr/watchtower:i386-latest \
|
||||
--os linux \
|
||||
--arch 386
|
||||
- run:
|
||||
name: Annotate ARM latest
|
||||
command: |
|
||||
docker manifest annotate \
|
||||
containrrr/watchtower:latest \
|
||||
containrrr/watchtower:armhf-latest \
|
||||
--os linux \
|
||||
--arch arm
|
||||
- run:
|
||||
name: Annotate ARM64 latest
|
||||
command: |
|
||||
docker manifest annotate \
|
||||
containrrr/watchtower:latest \
|
||||
containrrr/watchtower:arm64v8-latest \
|
||||
--os linux \
|
||||
--arch arm64 \
|
||||
--variant v8
|
||||
- run:
|
||||
name: Push manifests to Dockerhub
|
||||
command: |
|
||||
echo "$DOCKER_PASS" | docker login -u $DOCKER_USER --password-stdin &&
|
||||
docker manifest push containrrr/watchtower:$(echo $CIRCLE_TAG | sed 's/^v*//') && \
|
||||
docker manifest push containrrr/watchtower:latest
|
||||
publish-docs:
|
||||
executor: py
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- run:
|
||||
name: Install prerequisites
|
||||
command: |
|
||||
sudo pip install \
|
||||
mkdocs \
|
||||
mkdocs-material \
|
||||
md-toc
|
||||
- add_ssh_keys:
|
||||
fingerprints:
|
||||
- '91:75:47:15:b2:8e:85:e5:67:0e:63:7f:22:d2:b4:6e'
|
||||
- run:
|
||||
name: Generate and publish
|
||||
command: |
|
||||
mkdir ~/.ssh && touch ~/.ssh/known_hosts;
|
||||
ssh-keyscan -H github.com >> ~/.ssh/known_hosts && \
|
||||
mkdocs gh-deploy
|
@ -1,23 +0,0 @@
|
||||
name: Docker image (latest-dev)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: jerray/publish-docker-action@master
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
file: dockerfiles/Dockerfile.self-contained
|
||||
repository: containrrr/watchtower
|
||||
tags: latest-dev
|
@ -0,0 +1,91 @@
|
||||
name: Pull Request
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.15.x
|
||||
- name: Install linter
|
||||
run: |
|
||||
go get -u golang.org/x/lint/golint
|
||||
- name: Lint files
|
||||
run: |
|
||||
golint -set_exit_status ./...
|
||||
test:
|
||||
name: Test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
go-version:
|
||||
- 1.15.x
|
||||
platform:
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.15.x
|
||||
- name: Run tests
|
||||
run: |
|
||||
go test -v -coverprofile coverage.out -covermode atomic ./...
|
||||
- name: Publish coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
test-ubuntu:
|
||||
name: Test (Ubuntu)
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.15.x
|
||||
- name: Run tests
|
||||
run: |
|
||||
go test -v -coverprofile coverage.out -covermode atomic ./...
|
||||
- name: Publish coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.15.x
|
||||
- name: Build
|
||||
uses: goreleaser/goreleaser-action@v2
|
||||
with:
|
||||
version: v0.155.0
|
||||
args: --snapshot --skip-publish --debug
|
@ -0,0 +1,57 @@
|
||||
name: Push to main
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.15
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.15
|
||||
- name: Test
|
||||
run: go test -v -coverprofile coverage.out -covermode atomic ./...
|
||||
- name: Publish coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
publish:
|
||||
needs:
|
||||
- build
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Publish to Docker Hub
|
||||
uses: jerray/publish-docker-action@master
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
file: dockerfiles/Dockerfile.self-contained
|
||||
repository: containrrr/watchtower
|
||||
tags: latest-dev
|
||||
- name: Publish to GHCR
|
||||
uses: jerray/publish-docker-action@master
|
||||
with:
|
||||
username: ${{ secrets.BOT_USERNAME }}
|
||||
password: ${{ secrets.BOT_GHCR_PAT }}
|
||||
file: dockerfiles/Dockerfile.self-contained
|
||||
registry: ghcr.io
|
||||
repository: containrrr/watchtower
|
||||
tags: latest-dev
|
@ -0,0 +1,224 @@
|
||||
name: Release (Production)
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
release:
|
||||
types:
|
||||
- created
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
- '**/v[0-9]+.[0-9]+.[0-9]+'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.15.x
|
||||
- name: Install linter
|
||||
run: |
|
||||
go get -u golang.org/x/lint/golint
|
||||
- name: Lint files
|
||||
run: |
|
||||
golint -set_exit_status ./...
|
||||
|
||||
test:
|
||||
name: Test
|
||||
strategy:
|
||||
matrix:
|
||||
go-version:
|
||||
- 1.15.x
|
||||
platform:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.15.x
|
||||
- name: Run tests
|
||||
run: |
|
||||
go test ./... -coverprofile coverage.out
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
- lint
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.15.x
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.BOT_USERNAME }}
|
||||
password: ${{ secrets.BOT_GHCR_PAT }}
|
||||
registry: ghcr.io
|
||||
- name: Build
|
||||
uses: goreleaser/goreleaser-action@v2
|
||||
with:
|
||||
version: v0.155.0
|
||||
args: --debug
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Enable experimental docker features
|
||||
run: |
|
||||
mkdir -p ~/.docker/ && \
|
||||
echo '{"experimental": "enabled"}' > ~/.docker/config.json
|
||||
- name: Create manifest for version
|
||||
run: |
|
||||
export DH_TAG=$(echo $TAG | sed 's/^v*//')
|
||||
docker manifest create \
|
||||
containrrr/watchtower:$DH_TAG \
|
||||
containrrr/watchtower:amd64-$DH_TAG \
|
||||
containrrr/watchtower:i386-$DH_TAG \
|
||||
containrrr/watchtower:armhf-$DH_TAG \
|
||||
containrrr/watchtower:arm64v8-$DH_TAG
|
||||
docker manifest create \
|
||||
ghcr.io/containrrr/watchtower:$DH_TAG \
|
||||
ghcr.io/containrrr/watchtower:amd64-$DH_TAG \
|
||||
ghcr.io/containrrr/watchtower:i386-$DH_TAG \
|
||||
ghcr.io/containrrr/watchtower:armhf-$DH_TAG \
|
||||
ghcr.io/containrrr/watchtower:arm64v8-$DH_TAG
|
||||
- name: Annotate manifest for version
|
||||
run: |
|
||||
for REPO in '' ghcr.io/ ; do
|
||||
|
||||
docker manifest annotate \
|
||||
${REPO}containrrr/watchtower:$(echo $TAG | sed 's/^v*//') \
|
||||
${REPO}containrrr/watchtower:i386-$(echo $TAG | sed 's/^v*//') \
|
||||
--os linux \
|
||||
--arch 386
|
||||
|
||||
docker manifest annotate \
|
||||
${REPO}containrrr/watchtower:$(echo $TAG | sed 's/^v*//') \
|
||||
${REPO}containrrr/watchtower:armhf-$(echo $TAG | sed 's/^v*//') \
|
||||
--os linux \
|
||||
--arch arm
|
||||
|
||||
docker manifest annotate \
|
||||
${REPO}containrrr/watchtower:$(echo $TAG | sed 's/^v*//') \
|
||||
${REPO}containrrr/watchtower:arm64v8-$(echo $TAG | sed 's/^v*//') \
|
||||
--os linux \
|
||||
--arch arm64 \
|
||||
--variant v8
|
||||
|
||||
done
|
||||
- name: Create manifest for latest
|
||||
run: |
|
||||
docker manifest create \
|
||||
containrrr/watchtower:latest \
|
||||
containrrr/watchtower:amd64-latest \
|
||||
containrrr/watchtower:i386-latest \
|
||||
containrrr/watchtower:armhf-latest \
|
||||
containrrr/watchtower:arm64v8-latest
|
||||
docker manifest create \
|
||||
ghcr.io/containrrr/watchtower:latest \
|
||||
ghcr.io/containrrr/watchtower:amd64-latest \
|
||||
ghcr.io/containrrr/watchtower:i386-latest \
|
||||
ghcr.io/containrrr/watchtower:armhf-latest \
|
||||
ghcr.io/containrrr/watchtower:arm64v8-latest
|
||||
- name: Annotate manifest for latest
|
||||
run: |
|
||||
for REPO in '' ghcr.io/ ; do
|
||||
|
||||
docker manifest annotate \
|
||||
${REPO}containrrr/watchtower:latest \
|
||||
${REPO}containrrr/watchtower:i386-latest \
|
||||
--os linux \
|
||||
--arch 386
|
||||
|
||||
docker manifest annotate \
|
||||
${REPO}containrrr/watchtower:latest \
|
||||
${REPO}containrrr/watchtower:armhf-latest \
|
||||
--os linux \
|
||||
--arch arm
|
||||
|
||||
docker manifest annotate \
|
||||
${REPO}containrrr/watchtower:latest \
|
||||
${REPO}containrrr/watchtower:arm64v8-latest \
|
||||
--os linux \
|
||||
--arch arm64 \
|
||||
--variant v8
|
||||
|
||||
done
|
||||
- name: Push manifests to Dockerhub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: |
|
||||
docker login -u $DOCKER_USER -p $DOCKER_TOKEN && \
|
||||
docker manifest push containrrr/watchtower:$(echo $TAG | sed 's/^v*//') && \
|
||||
docker manifest push containrrr/watchtower:latest
|
||||
- name: Push manifests to GitHub Container Registry
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.BOT_USERNAME }}
|
||||
DOCKER_TOKEN: ${{ secrets.BOT_GHCR_PAT }}
|
||||
run: |
|
||||
docker login -u $DOCKER_USER -p $DOCKER_TOKEN ghcr.io && \
|
||||
docker manifest push ghcr.io/containrrr/watchtower:$(echo $TAG | sed 's/^v*//') && \
|
||||
docker manifest push ghcr.io/containrrr/watchtower:latest
|
||||
|
||||
publish-docs:
|
||||
name: Publish Docs
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install mkdocs
|
||||
run: |
|
||||
pip install \
|
||||
mkdocs \
|
||||
mkdocs-material \
|
||||
md-toc
|
||||
- name: Generate docs
|
||||
run: mkdocs build
|
||||
- name: Publish docs
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./site
|
||||
|
||||
renew-docs:
|
||||
name: Refresh pkg.go.dev
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Pull new module version
|
||||
uses: andrewslotin/go-proxy-pull-action@master
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,13 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates will always only be applied to the latest version of Watchtower.
|
||||
As the software by default is set to auto-update if you use the `latest` tag, you will get these security updates automatically as soon as they are released.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Critical vulnerabilities that might open up for external attacks are best reported directly either to simme@arcticbit.se or nils@piksel.se.
|
||||
We'll always try to get back to you as swiftly as possible, but keep in mind that since this is a community project, we can't really leave any guarantees about the speed.
|
||||
|
||||
Non-critical vulnerabilities may be reported as regular GitHub issues.
|
@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
VERSION=$(git describe --tags)
|
||||
echo "Building $VERSION..."
|
||||
go build -o watchtower -ldflags "-X github.com/containrrr/watchtower/cmd.version=$VERSION"
|
@ -0,0 +1,43 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
watchtower:
|
||||
container_name: watchtower
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: dockerfiles/Dockerfile.dev-self-contained
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
ports:
|
||||
- 8080:8080
|
||||
command: --interval 10 --http-api-metrics --http-api-token demotoken --debug prometheus grafana parent child
|
||||
prometheus:
|
||||
container_name: prometheus
|
||||
image: prom/prometheus
|
||||
volumes:
|
||||
- ./prometheus/:/etc/prometheus/
|
||||
- prometheus:/prometheus/
|
||||
ports:
|
||||
- 9090:9090
|
||||
grafana:
|
||||
container_name: grafana
|
||||
image: grafana/grafana
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
GF_INSTALL_PLUGINS: grafana-clock-panel,grafana-simple-json-datasource
|
||||
volumes:
|
||||
- grafana:/var/lib/grafana
|
||||
- ./grafana:/etc/grafana/provisioning
|
||||
parent:
|
||||
image: nginx
|
||||
container_name: parent
|
||||
child:
|
||||
image: nginx:alpine
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.depends-on: parent
|
||||
container_name: child
|
||||
|
||||
volumes:
|
||||
prometheus: {}
|
||||
grafana: {}
|
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
@ -0,0 +1,26 @@
|
||||
> **⚠️ Experimental feature**
|
||||
>
|
||||
> This feature was added in v1.0.4 and is still considered experimental.
|
||||
> If you notice any strange behavior, please raise a ticket in the repository issues.
|
||||
|
||||
Metrics can be used to track how Watchtower behaves over time.
|
||||
|
||||
To use this feature, you have to set an [API token](arguments.md#http-api-token) and [enable the metrics API](arguments.md#http-api-metrics),
|
||||
as well as creating a port mapping for your container for port `8080`.
|
||||
|
||||
## Available Metrics
|
||||
|
||||
| Name | Type | Description |
|
||||
| ------------------------------- | ------- | --------------------------------------------------------------------------- |
|
||||
| `watchtower_containers_scanned` | Gauge | Number of containers scanned for changes by watchtower during the last scan |
|
||||
| `watchtower_containers_updated` | Gauge | Number of containers updated by watchtower during the last scan |
|
||||
| `watchtower_containers_failed` | Gauge | Number of containers where update failed during the last scan |
|
||||
| `watchtower_scans_total` | Counter | Number of scans since the watchtower started |
|
||||
| `watchtower_scans_skipped` | Counter | Number of skipped scans since watchtower started |
|
||||
|
||||
## Demo
|
||||
|
||||
The repository contains a demo with prometheus and grafana, available through `docker-compose.yml`. This demo
|
||||
is preconfigured with a dashboard, which will look something like this:
|
||||
|
||||
![grafana metrics](assets/grafana-dashboard.png)
|
@ -0,0 +1,16 @@
|
||||
[data-md-color-scheme="containrrr"] {
|
||||
--md-primary-fg-color: #406170;
|
||||
--md-primary-fg-color--light:#acbfc7;
|
||||
--md-primary-fg-color--dark: #003343;
|
||||
--md-accent-fg-color: #003343;
|
||||
--md-accent-fg-color--transparent: #00334310;
|
||||
}
|
||||
|
||||
.md-header-nav__button.md-logo {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.md-header-nav__button.md-logo img {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
}
|
@ -0,0 +1,293 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": 1,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 1,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "7.3.6",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "watchtower_scans_total",
|
||||
"interval": "",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Total Scans",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": null,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "watchtower_containers_scanned{instance=\"watchtower:8080\", job=\"watchtower\"}"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "displayName",
|
||||
"value": "Scanned"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "watchtower_containers_failed{instance=\"watchtower:8080\", job=\"watchtower\"}"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "displayName",
|
||||
"value": "Faled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "watchtower_containers_updated{instance=\"watchtower:8080\", job=\"watchtower\"}"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "displayName",
|
||||
"value": "Updated"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 1,
|
||||
"y": 0
|
||||
},
|
||||
"hiddenSeries": false,
|
||||
"id": 5,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null as zero",
|
||||
"options": {
|
||||
"alertThreshold": true
|
||||
},
|
||||
"percentage": false,
|
||||
"pluginVersion": "7.3.6",
|
||||
"pointradius": 2,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "watchtower_containers_scanned",
|
||||
"interval": "",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "watchtower_containers_failed",
|
||||
"interval": "",
|
||||
"legendFormat": "",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"expr": "watchtower_containers_updated",
|
||||
"interval": "",
|
||||
"legendFormat": "",
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeFrom": null,
|
||||
"timeRegions": [],
|
||||
"timeShift": null,
|
||||
"title": "Container Updates",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"buckets": null,
|
||||
"mode": "time",
|
||||
"name": null,
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"decimals": 0,
|
||||
"format": "short",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"max": null,
|
||||
"min": "0",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"format": "short",
|
||||
"label": null,
|
||||
"logBase": 1,
|
||||
"max": null,
|
||||
"min": null,
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"yaxis": {
|
||||
"align": false,
|
||||
"alignLevel": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 1,
|
||||
"x": 0,
|
||||
"y": 4
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "7.3.6",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "watchtower_scans_skipped",
|
||||
"interval": "",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Skipped Scans",
|
||||
"type": "stat"
|
||||
}
|
||||
],
|
||||
"refresh": false,
|
||||
"schemaVersion": 26,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Watchtower",
|
||||
"uid": "d7bdoT-Gz",
|
||||
"version": 1
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: 'Prometheus'
|
||||
orgId: 1
|
||||
folder: ''
|
||||
type: file
|
||||
disableDeletion: false
|
||||
editable: true
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards
|
@ -0,0 +1,8 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
@ -1,63 +1,76 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"fmt"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
lock chan bool
|
||||
)
|
||||
const tokenMissingMsg = "api token is empty or has not been set. exiting"
|
||||
|
||||
func init() {
|
||||
lock = make(chan bool, 1)
|
||||
lock <- true
|
||||
// API is the http server responsible for serving the HTTP API endpoints
|
||||
type API struct {
|
||||
Token string
|
||||
hasHandlers bool
|
||||
}
|
||||
|
||||
// SetupHTTPUpdates configures the endpoint needed for triggering updates via http
|
||||
func SetupHTTPUpdates(apiToken string, updateFunction func()) error {
|
||||
if apiToken == "" {
|
||||
return errors.New("api token is empty or has not been set. not starting api")
|
||||
// New is a factory function creating a new API instance
|
||||
func New(token string) *API {
|
||||
return &API{
|
||||
Token: token,
|
||||
hasHandlers: false,
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Watchtower HTTP API started.")
|
||||
|
||||
http.HandleFunc("/v1/update", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Info("Updates triggered by HTTP API request.")
|
||||
|
||||
_, err := io.Copy(os.Stdout, r.Body)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
// RequireToken is wrapper around http.HandleFunc that checks token validity
|
||||
func (api *API) RequireToken(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", api.Token) {
|
||||
log.Tracef("Invalid token \"%s\"", r.Header.Get("Authorization"))
|
||||
log.Tracef("Expected token to be \"%s\"", api.Token)
|
||||
return
|
||||
}
|
||||
log.Debug("Valid token found.")
|
||||
fn(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
if r.Header.Get("Token") != apiToken {
|
||||
log.Println("Invalid token. Not updating.")
|
||||
return
|
||||
}
|
||||
// RegisterFunc is a wrapper around http.HandleFunc that also sets the flag used to determine whether to launch the API
|
||||
func (api *API) RegisterFunc(path string, fn http.HandlerFunc) {
|
||||
api.hasHandlers = true
|
||||
http.HandleFunc(path, api.RequireToken(fn))
|
||||
}
|
||||
|
||||
log.Println("Valid token found. Attempting to update.")
|
||||
// RegisterHandler is a wrapper around http.Handler that also sets the flag used to determine whether to launch the API
|
||||
func (api *API) RegisterHandler(path string, handler http.Handler) {
|
||||
api.hasHandlers = true
|
||||
http.Handle(path, api.RequireToken(handler.ServeHTTP))
|
||||
}
|
||||
|
||||
select {
|
||||
case chanValue := <-lock:
|
||||
defer func() { lock <- chanValue }()
|
||||
updateFunction()
|
||||
default:
|
||||
log.Debug("Skipped. Another update already running.")
|
||||
}
|
||||
// Start the API and serve over HTTP. Requires an API Token to be set.
|
||||
func (api *API) Start(block bool) error {
|
||||
|
||||
})
|
||||
if !api.hasHandlers {
|
||||
log.Debug("Watchtower HTTP API skipped.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if api.Token == "" {
|
||||
log.Fatal(tokenMissingMsg)
|
||||
}
|
||||
|
||||
log.Info("Watchtower HTTP API started.")
|
||||
if block {
|
||||
runHTTPServer()
|
||||
} else {
|
||||
go func() {
|
||||
runHTTPServer()
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitForHTTPUpdates starts the http server and listens for requests.
|
||||
func WaitForHTTPUpdates() error {
|
||||
func runHTTPServer() {
|
||||
log.Info("Serving HTTP")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
@ -0,0 +1,27 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/containrrr/watchtower/pkg/metrics"
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// Handler is an HTTP handle for serving metric data
|
||||
type Handler struct {
|
||||
Path string
|
||||
Handle http.HandlerFunc
|
||||
Metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// New is a factory function creating a new Metrics instance
|
||||
func New() *Handler {
|
||||
m := metrics.Default()
|
||||
handler := promhttp.Handler()
|
||||
|
||||
return &Handler{
|
||||
Path: "/v1/metrics",
|
||||
Handle: handler.ServeHTTP,
|
||||
Metrics: m,
|
||||
}
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
package metrics_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/pkg/metrics"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/containrrr/watchtower/pkg/api"
|
||||
metricsAPI "github.com/containrrr/watchtower/pkg/api/metrics"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const Token = "123123123"
|
||||
|
||||
func TestContainer(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Metrics Suite")
|
||||
}
|
||||
|
||||
func runTestServer(m *metricsAPI.Handler) {
|
||||
http.Handle(m.Path, m.Handle)
|
||||
go func() {
|
||||
http.ListenAndServe(":8080", nil)
|
||||
}()
|
||||
}
|
||||
|
||||
func getWithToken(c http.Client, url string) (*http.Response, error) {
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", Token))
|
||||
return c.Do(req)
|
||||
}
|
||||
|
||||
var _ = Describe("the metrics", func() {
|
||||
httpAPI := api.New(Token)
|
||||
m := metricsAPI.New()
|
||||
|
||||
httpAPI.RegisterHandler(m.Path, m.Handle)
|
||||
httpAPI.Start(false)
|
||||
|
||||
It("should serve metrics", func() {
|
||||
metric := &metrics.Metric{
|
||||
Scanned: 4,
|
||||
Updated: 3,
|
||||
Failed: 1,
|
||||
}
|
||||
metrics.RegisterScan(metric)
|
||||
Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue())
|
||||
|
||||
c := http.Client{}
|
||||
|
||||
res, err := getWithToken(c, "http://localhost:8080/v1/metrics")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
contents, err := ioutil.ReadAll(res.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_containers_updated 3"))
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_containers_failed 1"))
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_containers_scanned 4"))
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_scans_total 1"))
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_scans_skipped 0"))
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
metrics.RegisterScan(nil)
|
||||
}
|
||||
Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue())
|
||||
|
||||
res, err = getWithToken(c, "http://localhost:8080/v1/metrics")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
contents, err = ioutil.ReadAll(res.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_scans_total 4"))
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_scans_skipped 3"))
|
||||
})
|
||||
})
|
@ -0,0 +1,50 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
lock chan bool
|
||||
)
|
||||
|
||||
// New is a factory function creating a new Handler instance
|
||||
func New(updateFn func()) *Handler {
|
||||
lock = make(chan bool, 1)
|
||||
lock <- true
|
||||
|
||||
return &Handler{
|
||||
fn: updateFn,
|
||||
Path: "/v1/update",
|
||||
}
|
||||
}
|
||||
|
||||
// Handler is an API handler used for triggering container update scans
|
||||
type Handler struct {
|
||||
fn func()
|
||||
Path string
|
||||
}
|
||||
|
||||
// Handle is the actual http.Handle function doing all the heavy lifting
|
||||
func (handle *Handler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
log.Info("Updates triggered by HTTP API request.")
|
||||
|
||||
_, err := io.Copy(os.Stdout, r.Body)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case chanValue := <-lock:
|
||||
defer func() { lock <- chanValue }()
|
||||
handle.fn()
|
||||
default:
|
||||
log.Debug("Skipped. Another update already running.")
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
package container_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestContainer(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Container Suite")
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package container
|
||||
|
||||
import "errors"
|
||||
|
||||
var errorNoImageInfo = errors.New("no available image info")
|
||||
var errorNoExposedPorts = errors.New("exposed ports does not match port bindings")
|
||||
var errorInvalidConfig = errors.New("container configuration missing or invalid")
|
@ -0,0 +1,23 @@
|
||||
package container
|
||||
|
||||
import "strings"
|
||||
|
||||
// ShortID returns the 12-character (hex) short version of an image ID hash, removing any "sha256:" prefix if present
|
||||
func ShortID(imageID string) (short string) {
|
||||
prefixSep := strings.IndexRune(imageID, ':')
|
||||
offset := 0
|
||||
length := 12
|
||||
if prefixSep >= 0 {
|
||||
if imageID[0:prefixSep] == "sha256" {
|
||||
offset = prefixSep + 1
|
||||
} else {
|
||||
length += prefixSep + 1
|
||||
}
|
||||
}
|
||||
|
||||
if len(imageID) >= offset+length {
|
||||
return imageID[offset : offset+length]
|
||||
}
|
||||
|
||||
return imageID
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package container_test
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
. "github.com/containrrr/watchtower/pkg/container"
|
||||
)
|
||||
|
||||
var _ = Describe("container utils", func() {
|
||||
Describe("ShortID", func() {
|
||||
When("given a normal image ID", func() {
|
||||
When("it contains a sha256 prefix", func() {
|
||||
It("should return that ID in short version", func() {
|
||||
actual := ShortID("sha256:0123456789abcd00000000001111111111222222222233333333334444444444")
|
||||
Expect(actual).To(Equal("0123456789ab"))
|
||||
})
|
||||
})
|
||||
When("it doesn't contain a prefix", func() {
|
||||
It("should return that ID in short version", func() {
|
||||
actual := ShortID("0123456789abcd00000000001111111111222222222233333333334444444444")
|
||||
Expect(actual).To(Equal("0123456789ab"))
|
||||
})
|
||||
})
|
||||
})
|
||||
When("given a short image ID", func() {
|
||||
When("it contains no prefix", func() {
|
||||
It("should return the same string", func() {
|
||||
Expect(ShortID("0123456789ab")).To(Equal("0123456789ab"))
|
||||
})
|
||||
})
|
||||
When("it contains a the sha256 prefix", func() {
|
||||
It("should return the ID without the prefix", func() {
|
||||
Expect(ShortID("sha256:0123456789ab")).To(Equal("0123456789ab"))
|
||||
})
|
||||
})
|
||||
})
|
||||
When("given an ID with an unknown prefix", func() {
|
||||
It("should return a short version of that ID including the prefix", func() {
|
||||
Expect(ShortID("md5:0123456789ab")).To(Equal("md5:0123456789ab"))
|
||||
Expect(ShortID("md5:0123456789abcdefg")).To(Equal("md5:0123456789ab"))
|
||||
Expect(ShortID("md5:01")).To(Equal("md5:01"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@ -0,0 +1,96 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var metrics *Metrics
|
||||
|
||||
// Metric is the data points of a single scan
|
||||
type Metric struct {
|
||||
Scanned int
|
||||
Updated int
|
||||
Failed int
|
||||
}
|
||||
|
||||
// Metrics is the handler processing all individual scan metrics
|
||||
type Metrics struct {
|
||||
channel chan *Metric
|
||||
scanned prometheus.Gauge
|
||||
updated prometheus.Gauge
|
||||
failed prometheus.Gauge
|
||||
total prometheus.Counter
|
||||
skipped prometheus.Counter
|
||||
}
|
||||
|
||||
// QueueIsEmpty checks whether any messages are enqueued in the channel
|
||||
func (metrics *Metrics) QueueIsEmpty() bool {
|
||||
return len(metrics.channel) == 0
|
||||
}
|
||||
|
||||
// Register registers metrics for an executed scan
|
||||
func (metrics *Metrics) Register(metric *Metric) {
|
||||
metrics.channel <- metric
|
||||
}
|
||||
|
||||
// Default creates a new metrics handler if none exists, otherwise returns the existing one
|
||||
func Default() *Metrics {
|
||||
if metrics != nil {
|
||||
return metrics
|
||||
}
|
||||
|
||||
metrics = &Metrics{
|
||||
scanned: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "watchtower_containers_scanned",
|
||||
Help: "Number of containers scanned for changes by watchtower during the last scan",
|
||||
}),
|
||||
updated: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "watchtower_containers_updated",
|
||||
Help: "Number of containers updated by watchtower during the last scan",
|
||||
}),
|
||||
failed: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "watchtower_containers_failed",
|
||||
Help: "Number of containers where update failed during the last scan",
|
||||
}),
|
||||
total: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "watchtower_scans_total",
|
||||
Help: "Number of scans since the watchtower started",
|
||||
}),
|
||||
skipped: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "watchtower_scans_skipped",
|
||||
Help: "Number of skipped scans since watchtower started",
|
||||
}),
|
||||
channel: make(chan *Metric, 10),
|
||||
}
|
||||
|
||||
go metrics.HandleUpdate(metrics.channel)
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
// RegisterScan fetches a metric handler and enqueues a metric
|
||||
func RegisterScan(metric *Metric) {
|
||||
metrics := Default()
|
||||
metrics.Register(metric)
|
||||
}
|
||||
|
||||
// HandleUpdate dequeue the metric channel and processes it
|
||||
func (metrics *Metrics) HandleUpdate(channel <-chan *Metric) {
|
||||
for change := range channel {
|
||||
if change == nil {
|
||||
// Update was skipped and rescheduled
|
||||
metrics.total.Inc()
|
||||
metrics.skipped.Inc()
|
||||
metrics.scanned.Set(0)
|
||||
metrics.updated.Set(0)
|
||||
metrics.failed.Set(0)
|
||||
continue
|
||||
}
|
||||
// Update metrics with the new values
|
||||
metrics.total.Inc()
|
||||
metrics.scanned.Set(float64(change.Scanned))
|
||||
metrics.updated.Set(float64(change.Updated))
|
||||
metrics.failed.Set(float64(change.Failed))
|
||||
}
|
||||
}
|
@ -0,0 +1,223 @@
|
||||
package notifications_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/containrrr/watchtower/cmd"
|
||||
"github.com/containrrr/watchtower/internal/flags"
|
||||
"github.com/containrrr/watchtower/pkg/notifications"
|
||||
"github.com/containrrr/watchtower/pkg/types"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestActions(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Notifier Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("notifications", func() {
|
||||
Describe("the notifier", func() {
|
||||
When("only empty notifier types are provided", func() {
|
||||
|
||||
command := cmd.NewRootCommand()
|
||||
flags.RegisterNotificationFlags(command)
|
||||
|
||||
err := command.ParseFlags([]string{
|
||||
"--notifications",
|
||||
"shoutrrr",
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
notif := notifications.NewNotifier(command)
|
||||
|
||||
Expect(notif.String()).To(Equal("none"))
|
||||
})
|
||||
})
|
||||
Describe("the slack notifier", func() {
|
||||
builderFn := notifications.NewSlackNotifier
|
||||
|
||||
When("passing a discord url to the slack notifier", func() {
|
||||
channel := "123456789"
|
||||
token := "abvsihdbau"
|
||||
color := notifications.ColorInt
|
||||
title := url.QueryEscape(notifications.GetTitle())
|
||||
expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&splitlines=Yes&title=%s&username=watchtower", token, channel, color, title)
|
||||
buildArgs := func(url string) []string {
|
||||
return []string{
|
||||
"--notifications",
|
||||
"slack",
|
||||
"--notification-slack-hook-url",
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
It("should return a discord url when using a hook url with the domain discord.com", func() {
|
||||
hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discord.com", channel, token)
|
||||
testURL(builderFn, buildArgs(hookURL), expected)
|
||||
})
|
||||
It("should return a discord url when using a hook url with the domain discordapp.com", func() {
|
||||
hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discordapp.com", channel, token)
|
||||
testURL(builderFn, buildArgs(hookURL), expected)
|
||||
})
|
||||
})
|
||||
When("converting a slack service config into a shoutrrr url", func() {
|
||||
|
||||
It("should return the expected URL", func() {
|
||||
|
||||
username := "containrrrbot"
|
||||
tokenA := "aaa"
|
||||
tokenB := "bbb"
|
||||
tokenC := "ccc"
|
||||
color := url.QueryEscape(notifications.ColorHex)
|
||||
title := url.QueryEscape(notifications.GetTitle())
|
||||
|
||||
hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
|
||||
expectedOutput := fmt.Sprintf("slack://%s@%s/%s/%s?color=%s&title=%s", username, tokenA, tokenB, tokenC, color, title)
|
||||
|
||||
args := []string{
|
||||
"--notification-slack-hook-url",
|
||||
hookURL,
|
||||
"--notification-slack-identifier",
|
||||
username,
|
||||
}
|
||||
|
||||
testURL(builderFn, args, expectedOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("the gotify notifier", func() {
|
||||
When("converting a gotify service config into a shoutrrr url", func() {
|
||||
builderFn := notifications.NewGotifyNotifier
|
||||
|
||||
It("should return the expected URL", func() {
|
||||
token := "aaa"
|
||||
host := "shoutrrr.local"
|
||||
title := url.QueryEscape(notifications.GetTitle())
|
||||
|
||||
expectedOutput := fmt.Sprintf("gotify://%s/%s?title=%s", host, token, title)
|
||||
|
||||
args := []string{
|
||||
"--notification-gotify-url",
|
||||
fmt.Sprintf("https://%s", host),
|
||||
"--notification-gotify-token",
|
||||
token,
|
||||
}
|
||||
|
||||
testURL(builderFn, args, expectedOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("the teams notifier", func() {
|
||||
When("converting a teams service config into a shoutrrr url", func() {
|
||||
builderFn := notifications.NewMsTeamsNotifier
|
||||
|
||||
It("should return the expected URL", func() {
|
||||
|
||||
tokenA := "11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc"
|
||||
tokenB := "33333333012222222222333333333344"
|
||||
tokenC := "44444444-4444-4444-8444-cccccccccccc"
|
||||
color := url.QueryEscape(notifications.ColorHex)
|
||||
title := url.QueryEscape(notifications.GetTitle())
|
||||
|
||||
hookURL := fmt.Sprintf("https://outlook.office.com/webhook/%s/IncomingWebhook/%s/%s", tokenA, tokenB, tokenC)
|
||||
expectedOutput := fmt.Sprintf("teams://%s/%s/%s?color=%s&title=%s", tokenA, tokenB, tokenC, color, title)
|
||||
|
||||
args := []string{
|
||||
"--notification-msteams-hook",
|
||||
hookURL,
|
||||
}
|
||||
|
||||
testURL(builderFn, args, expectedOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("the email notifier", func() {
|
||||
|
||||
builderFn := notifications.NewEmailNotifier
|
||||
|
||||
When("converting an email service config into a shoutrrr url", func() {
|
||||
It("should set the from address in the URL", func() {
|
||||
fromAddress := "lala@example.com"
|
||||
expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, "mail@example.com", "Plain")
|
||||
args := []string{
|
||||
"--notification-email-from",
|
||||
fromAddress,
|
||||
"--notification-email-to",
|
||||
"mail@example.com",
|
||||
"--notification-email-server-user",
|
||||
"containrrrbot",
|
||||
"--notification-email-server-password",
|
||||
"secret-password",
|
||||
"--notification-email-server",
|
||||
"mail.containrrr.dev",
|
||||
}
|
||||
testURL(builderFn, args, expectedOutput)
|
||||
})
|
||||
|
||||
It("should return the expected URL", func() {
|
||||
|
||||
fromAddress := "sender@example.com"
|
||||
toAddress := "receiver@example.com"
|
||||
expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, toAddress, "Plain")
|
||||
|
||||
args := []string{
|
||||
"--notification-email-from",
|
||||
fromAddress,
|
||||
"--notification-email-to",
|
||||
toAddress,
|
||||
"--notification-email-server-user",
|
||||
"containrrrbot",
|
||||
"--notification-email-server-password",
|
||||
"secret-password",
|
||||
"--notification-email-server",
|
||||
"mail.containrrr.dev",
|
||||
}
|
||||
|
||||
testURL(builderFn, args, expectedOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func buildExpectedURL(username string, password string, host string, port int, from string, to string, auth string) string {
|
||||
hostname, err := os.Hostname()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
subject := fmt.Sprintf("Watchtower updates on %s", hostname)
|
||||
|
||||
var template = "smtp://%s:%s@%s:%d/?auth=%s&fromaddress=%s&fromname=Watchtower&subject=%s&toaddresses=%s"
|
||||
return fmt.Sprintf(template,
|
||||
url.QueryEscape(username),
|
||||
url.QueryEscape(password),
|
||||
host, port, auth,
|
||||
url.QueryEscape(from),
|
||||
url.QueryEscape(subject),
|
||||
url.QueryEscape(to))
|
||||
}
|
||||
|
||||
type builderFn = func(c *cobra.Command, acceptedLogLevels []log.Level) types.ConvertibleNotifier
|
||||
|
||||
func testURL(builder builderFn, args []string, expectedURL string) {
|
||||
|
||||
command := cmd.NewRootCommand()
|
||||
flags.RegisterNotificationFlags(command)
|
||||
|
||||
err := command.ParseFlags(args)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
notifier := builder(command, []log.Level{})
|
||||
actualURL, err := notifier.GetURL()
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(actualURL).To(Equal(expectedURL))
|
||||
}
|
@ -0,0 +1,192 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/pkg/registry/helpers"
|
||||
"github.com/containrrr/watchtower/pkg/types"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChallengeHeader is the HTTP Header containing challenge instructions
|
||||
const ChallengeHeader = "WWW-Authenticate"
|
||||
|
||||
// GetToken fetches a token for the registry hosting the provided image
|
||||
func GetToken(container types.Container, registryAuth string) (string, error) {
|
||||
var err error
|
||||
var URL url.URL
|
||||
|
||||
if URL, err = GetChallengeURL(container.ImageName()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logrus.WithField("URL", URL.String()).Debug("Building challenge URL")
|
||||
|
||||
var req *http.Request
|
||||
if req, err = GetChallengeRequest(URL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
var res *http.Response
|
||||
if res, err = client.Do(req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
v := res.Header.Get(ChallengeHeader)
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"status": res.Status,
|
||||
"header": v,
|
||||
}).Debug("Got response to challenge request")
|
||||
|
||||
challenge := strings.ToLower(v)
|
||||
if strings.HasPrefix(challenge, "basic") {
|
||||
if registryAuth == "" {
|
||||
return "", fmt.Errorf("no credentials available")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Basic %s", registryAuth), nil
|
||||
}
|
||||
if strings.HasPrefix(challenge, "bearer") {
|
||||
return GetBearerHeader(challenge, container.ImageName(), err, registryAuth)
|
||||
}
|
||||
|
||||
return "", errors.New("unsupported challenge type from registry")
|
||||
}
|
||||
|
||||
// GetChallengeRequest creates a request for getting challenge instructions
|
||||
func GetChallengeRequest(URL url.URL) (*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", URL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", "Watchtower (Docker)")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetBearerHeader tries to fetch a bearer token from the registry based on the challenge instructions
|
||||
func GetBearerHeader(challenge string, img string, err error, registryAuth string) (string, error) {
|
||||
client := http.Client{}
|
||||
if strings.Contains(img, ":") {
|
||||
img = strings.Split(img, ":")[0]
|
||||
}
|
||||
authURL, err := GetAuthURL(challenge, img)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var r *http.Request
|
||||
if r, err = http.NewRequest("GET", authURL.String(), nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if registryAuth != "" {
|
||||
logrus.Debug("Credentials found.")
|
||||
logrus.Tracef("Credentials: %v", registryAuth)
|
||||
r.Header.Add("Authorization", fmt.Sprintf("Basic %s", registryAuth))
|
||||
} else {
|
||||
logrus.Debug("No credentials found.")
|
||||
}
|
||||
|
||||
var authResponse *http.Response
|
||||
if authResponse, err = client.Do(r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(authResponse.Body)
|
||||
tokenResponse := &types.TokenResponse{}
|
||||
|
||||
err = json.Unmarshal(body, tokenResponse)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Bearer %s", tokenResponse.Token), nil
|
||||
}
|
||||
|
||||
// GetAuthURL from the instructions in the challenge
|
||||
func GetAuthURL(challenge string, img string) (*url.URL, error) {
|
||||
loweredChallenge := strings.ToLower(challenge)
|
||||
raw := strings.TrimPrefix(loweredChallenge, "bearer")
|
||||
|
||||
pairs := strings.Split(raw, ",")
|
||||
values := make(map[string]string, len(pairs))
|
||||
|
||||
for _, pair := range pairs {
|
||||
trimmed := strings.Trim(pair, " ")
|
||||
kv := strings.Split(trimmed, "=")
|
||||
key := kv[0]
|
||||
val := strings.Trim(kv[1], "\"")
|
||||
values[key] = val
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"realm": values["realm"],
|
||||
"service": values["service"],
|
||||
}).Debug("Checking challenge header content")
|
||||
if values["realm"] == "" || values["service"] == "" {
|
||||
|
||||
return nil, fmt.Errorf("challenge header did not include all values needed to construct an auth url")
|
||||
}
|
||||
|
||||
authURL, _ := url.Parse(fmt.Sprintf("%s", values["realm"]))
|
||||
q := authURL.Query()
|
||||
q.Add("service", values["service"])
|
||||
|
||||
scopeImage := GetScopeFromImageName(img, values["service"])
|
||||
|
||||
scope := fmt.Sprintf("repository:%s:pull", scopeImage)
|
||||
logrus.WithFields(logrus.Fields{"scope": scope, "image": img}).Debug("Setting scope for auth token")
|
||||
q.Add("scope", scope)
|
||||
|
||||
authURL.RawQuery = q.Encode()
|
||||
return authURL, nil
|
||||
}
|
||||
|
||||
// GetScopeFromImageName normalizes an image name for use as scope during auth and head requests
|
||||
func GetScopeFromImageName(img, svc string) string {
|
||||
parts := strings.Split(img, "/")
|
||||
|
||||
if len(parts) > 2 {
|
||||
if strings.Contains(svc, "docker.io") {
|
||||
return fmt.Sprintf("%s/%s", parts[1], strings.Join(parts[2:], "/"))
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
if len(parts) == 2 {
|
||||
if strings.Contains(parts[0], "docker.io") {
|
||||
return fmt.Sprintf("library/%s", parts[1])
|
||||
}
|
||||
return strings.Replace(img, svc+"/", "", 1)
|
||||
}
|
||||
|
||||
if strings.Contains(svc, "docker.io") {
|
||||
return fmt.Sprintf("library/%s", parts[0])
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// GetChallengeURL creates a URL object based on the image info
|
||||
func GetChallengeURL(img string) (url.URL, error) {
|
||||
|
||||
normalizedNamed, _ := reference.ParseNormalizedNamed(img)
|
||||
host, err := helpers.NormalizeRegistry(normalizedNamed.String())
|
||||
if err != nil {
|
||||
return url.URL{}, err
|
||||
}
|
||||
|
||||
URL := url.URL{
|
||||
Scheme: "https",
|
||||
Host: host,
|
||||
Path: "/v2/",
|
||||
}
|
||||
return URL, nil
|
||||
}
|
@ -0,0 +1,120 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/internal/actions/mocks"
|
||||
"github.com/containrrr/watchtower/pkg/registry/auth"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
wtTypes "github.com/containrrr/watchtower/pkg/types"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestAuth(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Registry Auth Suite")
|
||||
}
|
||||
func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() {
|
||||
if credentials.Username == "" {
|
||||
return func() {
|
||||
Skip("Username missing. Skipping integration test")
|
||||
}
|
||||
} else if credentials.Password == "" {
|
||||
return func() {
|
||||
Skip("Password missing. Skipping integration test")
|
||||
}
|
||||
} else {
|
||||
return fn
|
||||
}
|
||||
}
|
||||
|
||||
var GHCRCredentials = &wtTypes.RegistryCredentials{
|
||||
Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"),
|
||||
Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"),
|
||||
}
|
||||
|
||||
var _ = Describe("the auth module", func() {
|
||||
mockId := "mock-id"
|
||||
mockName := "mock-container"
|
||||
mockImage := "ghcr.io/k6io/operator:latest"
|
||||
mockCreated := time.Now()
|
||||
mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547"
|
||||
|
||||
mockContainer := mocks.CreateMockContainerWithDigest(
|
||||
mockId,
|
||||
mockName,
|
||||
mockImage,
|
||||
mockCreated,
|
||||
mockDigest)
|
||||
|
||||
When("getting an auth url", func() {
|
||||
It("should parse the token from the response",
|
||||
SkipIfCredentialsEmpty(GHCRCredentials, func() {
|
||||
creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
|
||||
token, err := auth.GetToken(mockContainer, creds)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(token).NotTo(Equal(""))
|
||||
}),
|
||||
)
|
||||
|
||||
It("should create a valid auth url object based on the challenge header supplied", func() {
|
||||
input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"`
|
||||
expected := &url.URL{
|
||||
Host: "ghcr.io",
|
||||
Scheme: "https",
|
||||
Path: "/token",
|
||||
RawQuery: "scope=repository%3Acontainrrr%2Fwatchtower%3Apull&service=ghcr.io",
|
||||
}
|
||||
res, err := auth.GetAuthURL(input, "containrrr/watchtower")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res).To(Equal(expected))
|
||||
})
|
||||
It("should create a valid auth url object based on the challenge header supplied", func() {
|
||||
input := `bearer realm="https://ghcr.io/token"`
|
||||
res, err := auth.GetAuthURL(input, "containrrr/watchtower")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(res).To(BeNil())
|
||||
})
|
||||
})
|
||||
When("getting a challenge url", func() {
|
||||
It("should create a valid challenge url object based on the image ref supplied", func() {
|
||||
expected := url.URL{Host: "ghcr.io", Scheme: "https", Path: "/v2/"}
|
||||
Expect(auth.GetChallengeURL("ghcr.io/containrrr/watchtower:latest")).To(Equal(expected))
|
||||
})
|
||||
It("should assume dockerhub if the image ref is not fully qualified", func() {
|
||||
expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
|
||||
Expect(auth.GetChallengeURL("containrrr/watchtower:latest")).To(Equal(expected))
|
||||
})
|
||||
It("should convert legacy dockerhub hostnames to index.docker.io", func() {
|
||||
expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
|
||||
Expect(auth.GetChallengeURL("docker.io/containrrr/watchtower:latest")).To(Equal(expected))
|
||||
Expect(auth.GetChallengeURL("registry-1.docker.io/containrrr/watchtower:latest")).To(Equal(expected))
|
||||
})
|
||||
})
|
||||
When("getting the auth scope from an image name", func() {
|
||||
It("should prepend official dockerhub images with \"library/\"", func() {
|
||||
Expect(auth.GetScopeFromImageName("docker.io/registry", "index.docker.io")).To(Equal("library/registry"))
|
||||
Expect(auth.GetScopeFromImageName("docker.io/registry", "docker.io")).To(Equal("library/registry"))
|
||||
|
||||
Expect(auth.GetScopeFromImageName("registry", "index.docker.io")).To(Equal("library/registry"))
|
||||
Expect(auth.GetScopeFromImageName("watchtower", "registry-1.docker.io")).To(Equal("library/watchtower"))
|
||||
|
||||
})
|
||||
It("should not include vanity hosts\"", func() {
|
||||
Expect(auth.GetScopeFromImageName("docker.io/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
|
||||
Expect(auth.GetScopeFromImageName("index.docker.io/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
|
||||
})
|
||||
It("should not destroy three segment image names\"", func() {
|
||||
Expect(auth.GetScopeFromImageName("piksel/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
|
||||
Expect(auth.GetScopeFromImageName("piksel/containrrr/watchtower", "ghcr.io")).To(Equal("piksel/containrrr/watchtower"))
|
||||
})
|
||||
It("should not add \"library/\" for one segment image names if they're not on dockerhub", func() {
|
||||
Expect(auth.GetScopeFromImageName("ghcr.io/watchtower", "ghcr.io")).To(Equal("watchtower"))
|
||||
Expect(auth.GetScopeFromImageName("watchtower", "ghcr.io")).To(Equal("watchtower"))
|
||||
})
|
||||
})
|
||||
})
|
@ -0,0 +1,117 @@
|
||||
package digest
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/pkg/registry/auth"
|
||||
"github.com/containrrr/watchtower/pkg/registry/manifest"
|
||||
"github.com/containrrr/watchtower/pkg/types"
|
||||
"github.com/sirupsen/logrus"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ContentDigestHeader is the key for the key-value pair containing the digest header
|
||||
const ContentDigestHeader = "Docker-Content-Digest"
|
||||
|
||||
// CompareDigest ...
|
||||
func CompareDigest(container types.Container, registryAuth string) (bool, error) {
|
||||
var digest string
|
||||
|
||||
registryAuth = TransformAuth(registryAuth)
|
||||
token, err := auth.GetToken(container, registryAuth)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
digestURL, err := manifest.BuildManifestURL(container)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if digest, err = GetDigest(digestURL, token); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
logrus.WithField("remote", digest).Debug("Found a remote digest to compare with")
|
||||
|
||||
for _, dig := range container.ImageInfo().RepoDigests {
|
||||
localDigest := strings.Split(dig, "@")[1]
|
||||
fields := logrus.Fields{"local": localDigest, "remote": digest}
|
||||
logrus.WithFields(fields).Debug("Comparing")
|
||||
|
||||
if localDigest == digest {
|
||||
logrus.Debug("Found a match")
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// TransformAuth from a base64 encoded json object to base64 encoded string
|
||||
func TransformAuth(registryAuth string) string {
|
||||
b, _ := base64.StdEncoding.DecodeString(registryAuth)
|
||||
credentials := &types.RegistryCredentials{}
|
||||
_ = json.Unmarshal(b, credentials)
|
||||
|
||||
if credentials.Username != "" && credentials.Password != "" {
|
||||
ba := []byte(fmt.Sprintf("%s:%s", credentials.Username, credentials.Password))
|
||||
registryAuth = base64.StdEncoding.EncodeToString(ba)
|
||||
}
|
||||
|
||||
return registryAuth
|
||||
}
|
||||
|
||||
// GetDigest from registry using a HEAD request to prevent rate limiting
|
||||
func GetDigest(url string, token string) (string, error) {
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: tr}
|
||||
|
||||
req, _ := http.NewRequest("HEAD", url, nil)
|
||||
|
||||
if token != "" {
|
||||
logrus.WithField("token", token).Trace("Setting request token")
|
||||
} else {
|
||||
return "", errors.New("could not fetch token")
|
||||
}
|
||||
|
||||
req.Header.Add("Authorization", token)
|
||||
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.list.v2+json")
|
||||
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v1+json")
|
||||
|
||||
logrus.WithField("url", url).Debug("Doing a HEAD request to fetch a digest")
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
wwwAuthHeader := res.Header.Get("www-authenticate")
|
||||
if wwwAuthHeader == "" {
|
||||
wwwAuthHeader = "not present"
|
||||
}
|
||||
return "", fmt.Errorf("registry responded to head request with %q, auth: %q", res.Status, wwwAuthHeader)
|
||||
}
|
||||
return res.Header.Get(ContentDigestHeader), nil
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
package digest_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/internal/actions/mocks"
|
||||
"github.com/containrrr/watchtower/pkg/registry/digest"
|
||||
wtTypes "github.com/containrrr/watchtower/pkg/types"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDigest(t *testing.T) {
|
||||
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(GinkgoT(), "Digest Suite")
|
||||
}
|
||||
|
||||
var DockerHubCredentials = &wtTypes.RegistryCredentials{
|
||||
Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_USERNAME"),
|
||||
Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_PASSWORD"),
|
||||
}
|
||||
var GHCRCredentials = &wtTypes.RegistryCredentials{
|
||||
Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"),
|
||||
Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"),
|
||||
}
|
||||
|
||||
func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() {
|
||||
if credentials.Username == "" {
|
||||
return func() {
|
||||
Skip("Username missing. Skipping integration test")
|
||||
}
|
||||
} else if credentials.Password == "" {
|
||||
return func() {
|
||||
Skip("Password missing. Skipping integration test")
|
||||
}
|
||||
} else {
|
||||
return fn
|
||||
}
|
||||
}
|
||||
|
||||
var _ = Describe("Digests", func() {
|
||||
mockId := "mock-id"
|
||||
mockName := "mock-container"
|
||||
mockImage := "ghcr.io/k6io/operator:latest"
|
||||
mockCreated := time.Now()
|
||||
mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547"
|
||||
|
||||
mockContainer := mocks.CreateMockContainerWithDigest(
|
||||
mockId,
|
||||
mockName,
|
||||
mockImage,
|
||||
mockCreated,
|
||||
mockDigest)
|
||||
|
||||
When("a digest comparison is done", func() {
|
||||
It("should return true if digests match",
|
||||
SkipIfCredentialsEmpty(GHCRCredentials, func() {
|
||||
creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
|
||||
matches, err := digest.CompareDigest(mockContainer, creds)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(matches).To(Equal(true))
|
||||
}),
|
||||
)
|
||||
|
||||
It("should return false if digests differ", func() {
|
||||
|
||||
})
|
||||
It("should return an error if the registry isn't available", func() {
|
||||
|
||||
})
|
||||
})
|
||||
When("using different registries", func() {
|
||||
It("should work with DockerHub",
|
||||
SkipIfCredentialsEmpty(DockerHubCredentials, func() {
|
||||
fmt.Println(DockerHubCredentials != nil) // to avoid crying linters
|
||||
}),
|
||||
)
|
||||
It("should work with GitHub Container Registry",
|
||||
SkipIfCredentialsEmpty(GHCRCredentials, func() {
|
||||
fmt.Println(GHCRCredentials != nil) // to avoid crying linters
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
@ -0,0 +1,36 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
url2 "net/url"
|
||||
)
|
||||
|
||||
// ConvertToHostname strips a url from everything but the hostname part
|
||||
func ConvertToHostname(url string) (string, string, error) {
|
||||
urlWithSchema := fmt.Sprintf("x://%s", url)
|
||||
u, err := url2.Parse(urlWithSchema)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
hostName := u.Hostname()
|
||||
port := u.Port()
|
||||
|
||||
return hostName, port, err
|
||||
}
|
||||
|
||||
// NormalizeRegistry makes sure variations of DockerHubs registry
|
||||
func NormalizeRegistry(registry string) (string, error) {
|
||||
hostName, port, err := ConvertToHostname(registry)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if hostName == "registry-1.docker.io" || hostName == "docker.io" {
|
||||
hostName = "index.docker.io"
|
||||
}
|
||||
|
||||
if port != "" {
|
||||
return fmt.Sprintf("%s:%s", hostName, port), nil
|
||||
}
|
||||
return hostName, nil
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHelpers(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Helper Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("the helpers", func() {
|
||||
|
||||
When("converting an url to a hostname", func() {
|
||||
It("should return docker.io given docker.io/containrrr/watchtower:latest", func() {
|
||||
host, port, err := ConvertToHostname("docker.io/containrrr/watchtower:latest")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(host).To(Equal("docker.io"))
|
||||
Expect(port).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
When("normalizing the registry information", func() {
|
||||
It("should return index.docker.io given docker.io", func() {
|
||||
out, err := NormalizeRegistry("docker.io/containrrr/watchtower:latest")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(out).To(Equal("index.docker.io"))
|
||||
})
|
||||
})
|
||||
})
|
@ -0,0 +1,67 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/pkg/registry/auth"
|
||||
"github.com/containrrr/watchtower/pkg/registry/helpers"
|
||||
"github.com/containrrr/watchtower/pkg/types"
|
||||
ref "github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
url2 "net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildManifestURL from raw image data
|
||||
func BuildManifestURL(container types.Container) (string, error) {
|
||||
|
||||
normalizedName, err := ref.ParseNormalizedNamed(container.ImageName())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
host, err := helpers.NormalizeRegistry(normalizedName.String())
|
||||
img, tag := ExtractImageAndTag(strings.TrimPrefix(container.ImageName(), host+"/"))
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"image": img,
|
||||
"tag": tag,
|
||||
"normalized": normalizedName,
|
||||
"host": host,
|
||||
}).Debug("Parsing image ref")
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
img = auth.GetScopeFromImageName(img, host)
|
||||
|
||||
if !strings.Contains(img, "/") {
|
||||
img = "library/" + img
|
||||
}
|
||||
url := url2.URL{
|
||||
Scheme: "https",
|
||||
Host: host,
|
||||
Path: fmt.Sprintf("/v2/%s/manifests/%s", img, tag),
|
||||
}
|
||||
return url.String(), nil
|
||||
}
|
||||
|
||||
// ExtractImageAndTag from a concatenated string
|
||||
func ExtractImageAndTag(imageName string) (string, string) {
|
||||
var img string
|
||||
var tag string
|
||||
|
||||
if strings.Contains(imageName, ":") {
|
||||
parts := strings.Split(imageName, ":")
|
||||
if len(parts) > 2 {
|
||||
img = parts[0]
|
||||
tag = strings.Join(parts[1:], ":")
|
||||
} else {
|
||||
img = parts[0]
|
||||
tag = parts[1]
|
||||
}
|
||||
} else {
|
||||
img = imageName
|
||||
tag = "latest"
|
||||
}
|
||||
return img, tag
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
package manifest_test
|
||||
|
||||
import (
|
||||
"github.com/containrrr/watchtower/internal/actions/mocks"
|
||||
"github.com/containrrr/watchtower/pkg/registry/manifest"
|
||||
apiTypes "github.com/docker/docker/api/types"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManifest(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Manifest Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("the manifest module", func() {
|
||||
mockId := "mock-id"
|
||||
mockName := "mock-container"
|
||||
mockCreated := time.Now()
|
||||
|
||||
When("building a manifest url", func() {
|
||||
It("should return a valid url given a fully qualified image", func() {
|
||||
expected := "https://ghcr.io/v2/containrrr/watchtower/manifests/latest"
|
||||
imageInfo := apiTypes.ImageInspect{
|
||||
RepoTags: []string{
|
||||
"ghcr.io/k6io/operator:latest",
|
||||
},
|
||||
}
|
||||
mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "ghcr.io/containrrr/watchtower:latest", mockCreated, imageInfo)
|
||||
res, err := manifest.BuildManifestURL(mock)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res).To(Equal(expected))
|
||||
})
|
||||
It("should assume dockerhub for non-qualified images", func() {
|
||||
expected := "https://index.docker.io/v2/containrrr/watchtower/manifests/latest"
|
||||
imageInfo := apiTypes.ImageInspect{
|
||||
RepoTags: []string{
|
||||
"containrrr/watchtower:latest",
|
||||
},
|
||||
}
|
||||
|
||||
mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "containrrr/watchtower:latest", mockCreated, imageInfo)
|
||||
res, err := manifest.BuildManifestURL(mock)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res).To(Equal(expected))
|
||||
})
|
||||
It("should assume latest for images that lack an explicit tag", func() {
|
||||
expected := "https://index.docker.io/v2/containrrr/watchtower/manifests/latest"
|
||||
imageInfo := apiTypes.ImageInspect{
|
||||
|
||||
RepoTags: []string{
|
||||
"containrrr/watchtower",
|
||||
},
|
||||
}
|
||||
|
||||
mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "containrrr/watchtower", mockCreated, imageInfo)
|
||||
|
||||
res, err := manifest.BuildManifestURL(mock)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res).To(Equal(expected))
|
||||
})
|
||||
It("should combine the tag name and digest pinning into one digest, given multiple colons", func() {
|
||||
in := "containrrr/watchtower:latest@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7"
|
||||
image, tag := "containrrr/watchtower", "latest@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7"
|
||||
|
||||
imageOut, tagOut := manifest.ExtractImageAndTag(in)
|
||||
|
||||
Expect(imageOut).To(Equal(image))
|
||||
Expect(tagOut).To(Equal(tag))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
@ -0,0 +1,13 @@
|
||||
package registry_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Registry Suite")
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
package registry_test
|
||||
|
||||
import (
|
||||
"github.com/containrrr/watchtower/internal/actions/mocks"
|
||||
unit "github.com/containrrr/watchtower/pkg/registry"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"time"
|
||||
)
|
||||
|
||||
var _ = Describe("Registry", func() {
|
||||
Describe("WarnOnAPIConsumption", func() {
|
||||
When("Given a container with an image from ghcr.io", func() {
|
||||
It("should want to warn", func() {
|
||||
Expect(testContainerWithImage("ghcr.io/containrrr/watchtower")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
When("Given a container with an image implicitly from dockerhub", func() {
|
||||
It("should want to warn", func() {
|
||||
Expect(testContainerWithImage("docker:latest")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
When("Given a container with an image explicitly from dockerhub", func() {
|
||||
It("should want to warn", func() {
|
||||
Expect(testContainerWithImage("registry-1.docker.io/docker:latest")).To(BeTrue())
|
||||
Expect(testContainerWithImage("index.docker.io/docker:latest")).To(BeTrue())
|
||||
Expect(testContainerWithImage("docker.io/docker:latest")).To(BeTrue())
|
||||
})
|
||||
|
||||
})
|
||||
When("Given a container with an image from some other registry", func() {
|
||||
It("should not want to warn", func() {
|
||||
Expect(testContainerWithImage("docker.fsf.org/docker:latest")).To(BeFalse())
|
||||
Expect(testContainerWithImage("altavista.com/docker:latest")).To(BeFalse())
|
||||
Expect(testContainerWithImage("gitlab.com/docker:latest")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func testContainerWithImage(imageName string) bool {
|
||||
container := mocks.CreateMockContainer("", "", imageName, time.Now())
|
||||
return unit.WarnOnAPIConsumption(container)
|
||||
}
|
@ -1,59 +1,65 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodedEnvAuth_ShouldReturnAnErrorIfRepoEnvsAreUnset(t *testing.T) {
|
||||
os.Unsetenv("REPO_USER")
|
||||
os.Unsetenv("REPO_PASS")
|
||||
_, err := EncodedEnvAuth("")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
func TestEncodedEnvAuth_ShouldReturnAuthHashIfRepoEnvsAreSet(t *testing.T) {
|
||||
expectedHash := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
|
||||
|
||||
os.Setenv("REPO_USER", "containrrr-user")
|
||||
os.Setenv("REPO_PASS", "containrrr-pass")
|
||||
config, _ := EncodedEnvAuth("")
|
||||
|
||||
assert.Equal(t, config, expectedHash)
|
||||
}
|
||||
|
||||
func TestEncodedConfigAuth_ShouldReturnAnErrorIfFileIsNotPresent(t *testing.T) {
|
||||
os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
|
||||
_, err := EncodedConfigAuth("")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
/*
|
||||
* TODO:
|
||||
* This part only confirms that it still works in the same way as it did
|
||||
* with the old version of the docker api client sdk. I'd say that
|
||||
* ParseServerAddress likely needs to be elaborated a bit to default to
|
||||
* dockerhub in case no server address was provided.
|
||||
*
|
||||
* ++ @simskij, 2019-04-04
|
||||
*/
|
||||
|
||||
func TestParseServerAddress_ShouldReturnErrorIfPassedEmptyString(t *testing.T) {
|
||||
_, err := ParseServerAddress("")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseServerAddress_ShouldReturnTheRepoNameIfPassedAFullyQualifiedImageName(t *testing.T) {
|
||||
val, _ := ParseServerAddress("github.com/containrrrr/config")
|
||||
assert.Equal(t, val, "github.com")
|
||||
}
|
||||
|
||||
func TestParseServerAddress_ShouldReturnTheOrganizationPartIfPassedAnImageNameMissingServerName(t *testing.T) {
|
||||
val, _ := ParseServerAddress("containrrr/config")
|
||||
assert.Equal(t, val, "containrrr")
|
||||
}
|
||||
|
||||
func TestParseServerAddress_ShouldReturnTheServerNameIfPassedAFullyQualifiedImageName(t *testing.T) {
|
||||
val, _ := ParseServerAddress("github.com/containrrrr/config")
|
||||
assert.Equal(t, val, "github.com")
|
||||
}
|
||||
var _ = Describe("Testing with Ginkgo", func() {
|
||||
It("encoded env auth_ should return an error if repo envs are unset", func() {
|
||||
_ = os.Unsetenv("REPO_USER")
|
||||
_ = os.Unsetenv("REPO_PASS")
|
||||
|
||||
_, err := EncodedEnvAuth("")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
It("encoded env auth_ should return auth hash if repo envs are set", func() {
|
||||
var err error
|
||||
expectedHash := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
|
||||
|
||||
err = os.Setenv("REPO_USER", "containrrr-user")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
err = os.Setenv("REPO_PASS", "containrrr-pass")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
config, err := EncodedEnvAuth("")
|
||||
Expect(config).To(Equal(expectedHash))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
It("encoded config auth_ should return an error if file is not present", func() {
|
||||
var err error
|
||||
|
||||
err = os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = EncodedConfigAuth("")
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
})
|
||||
/*
|
||||
* TODO:
|
||||
* This part only confirms that it still works in the same way as it did
|
||||
* with the old version of the docker api client sdk. I'd say that
|
||||
* ParseServerAddress likely needs to be elaborated a bit to default to
|
||||
* dockerhub in case no server address was provided.
|
||||
*
|
||||
* ++ @simskij, 2019-04-04
|
||||
*/
|
||||
It("parse server address_ should return error if passed empty string", func() {
|
||||
|
||||
_, err := ParseServerAddress("")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
It("parse server address_ should return the organization part if passed an image name missing server name", func() {
|
||||
|
||||
val, _ := ParseServerAddress("containrrr/config")
|
||||
Expect(val).To(Equal("containrrr"))
|
||||
})
|
||||
It("parse server address_ should return the server name if passed a fully qualified image name", func() {
|
||||
|
||||
val, _ := ParseServerAddress("github.com/containrrrr/config")
|
||||
Expect(val).To(Equal("github.com"))
|
||||
})
|
||||
})
|
||||
|
@ -0,0 +1,26 @@
|
||||
package types
|
||||
|
||||
import "github.com/docker/docker/api/types"
|
||||
|
||||
// Container is a docker container running an image
|
||||
type Container interface {
|
||||
ContainerInfo() *types.ContainerJSON
|
||||
ID() string
|
||||
IsRunning() bool
|
||||
Name() string
|
||||
ImageID() string
|
||||
ImageName() string
|
||||
Enabled() (bool, bool)
|
||||
IsMonitorOnly() bool
|
||||
Scope() (string, bool)
|
||||
Links() []string
|
||||
ToRestart() bool
|
||||
IsWatchtower() bool
|
||||
StopSignal() string
|
||||
HasImageInfo() bool
|
||||
ImageInfo() *types.ImageInspect
|
||||
GetLifecyclePreCheckCommand() string
|
||||
GetLifecyclePostCheckCommand() string
|
||||
GetLifecyclePreUpdateCommand() string
|
||||
GetLifecyclePostUpdateCommand() string
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package types
|
||||
|
||||
// ConvertibleNotifier is a notifier capable of creating a shoutrrr URL
|
||||
type ConvertibleNotifier interface {
|
||||
GetURL() (string, error)
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package types
|
||||
|
||||
// RegistryCredentials is a credential pair used for basic auth
|
||||
type RegistryCredentials struct {
|
||||
Username string
|
||||
Password string // usually a token rather than an actual password
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package types
|
||||
|
||||
// TokenResponse is returned by the registry on successful authentication
|
||||
type TokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
scrape_configs:
|
||||
- job_name: watchtower
|
||||
scrape_interval: 5s
|
||||
metrics_path: /v1/metrics
|
||||
bearer_token: demotoken
|
||||
static_configs:
|
||||
- targets:
|
||||
- 'watchtower:8080'
|
||||
|
@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
go test -v -coverprofile coverage.out -covermode atomic ./...
|
||||
|
||||
# Requires CODECOV_TOKEN to be set
|
||||
bash <(curl -s https://codecov.io/bash)
|
@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Simulates a container that will always be updated, checking whether it shuts down it's dependencies correctly.
|
||||
|
||||
docker rm -f parent || true
|
||||
docker rm -f depending || true
|
||||
|
||||
CHANGE=redis:latest
|
||||
KEEP=tutum/hello-world
|
||||
|
||||
docker tag tutum/hello-world:latest redis:latest
|
||||
|
||||
docker run -d --name parent $CHANGE
|
||||
docker run -d --name depending --link parent $KEEP
|
||||
|
||||
go run . --run-once --debug $@
|
Loading…
Reference in New Issue