commit 8513d49cc98ca995a8664d283801ab8ca827409a Author: CrazyMax Date: Tue Jun 4 22:11:54 2019 +0200 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5915055c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +/.idea +/*.iml + +/.dev +/.git +/.github +/.res +/bin +/dist +/.editorconfig +/.gitignore +/.goreleaser.yml +/.travis.yml +/build.sh +/CHANGELOG.md +/LICENSE +/README.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d3e60881 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# This file is for unifying the coding style for different editors and IDEs. +# More information at http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_size = 2 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..2e3430e7 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: crazy-max +custom: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=X2NYRW7D9KL4E diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..ba278c3d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug report +about: Create a report to help us improve +--- + +### Behaviour + +#### Steps to reproduce this issue + +1. +2. +3. + +#### Expected behaviour + +> Tell me what should happen + +#### Actual behaviour + +> Tell me what happens instead + +### Configuration + +* Diun version : +* Platform (windows/linux) : + +```yml +# paste your YAML configuration file here and remove sensitive data +``` + +### Logs + +``` +# paste logs here (set log level to debug first) +``` diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 00000000..406efb1e --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,29 @@ +# Support [![](https://isitmaintained.com/badge/resolution/crazy-max/diun.svg)](https://isitmaintained.com/project/crazy-max/diun) + +## Reporting an issue + +Please do a search in [open issues](https://github.com/crazy-max/diun/issues?utf8=%E2%9C%93&q=) to see if the issue or feature request has already been filed. + +If you find your issue already exists, make relevant comments and add your [reaction](https://github.com/blog/2119-add-reactions-to-pull-requests-issues-and-comments). Use a reaction in place of a "+1" comment. + +:+1: - upvote + +:-1: - downvote + +If you cannot find an existing issue that describes your bug or feature, submit an issue using the guidelines below. + +## Writing good bug reports and feature requests + +File a single issue per problem and feature request. + +* Do not enumerate multiple bugs or feature requests in the same issue. +* Do not add your issue as a comment to an existing issue unless it's for the identical input. Many issues look similar, but have different causes. + +The more information you can provide, the more likely someone will be successful reproducing the issue and finding a fix. + +You are now ready to [create a new issue](https://github.com/crazy-max/diun/issues/new/choose)! + +## Closure policy + +* Issues that don't have the information requested above (when applicable) will be closed immediately and the poster directed to the support guidelines. +* Issues that go a week without a response from original poster are subject to closure at my discretion. diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..cb7f9250 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.idea +/*.iml + +/.dev +/bin +/dist diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 00000000..8aa969f2 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,41 @@ +before: + hooks: + - go mod download +builds: + - + main: ./cmd/main.go + ldflags: + - -s -w -X main.version={{.Version}} + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + - freebsd + - windows + goarch: + - 386 + - amd64 + - arm + - arm64 + goarm: + - 6 + - 7 + ignore: + - goos: freebsd + goarch: arm + - goos: freebsd + goarch: arm64 +archive: + replacements: + 386: i386 + amd64: x86_64 + format_overrides: + - goos: windows + format: zip + files: + - LICENSE + - README.md + - CHANGELOG.md +checksum: + name_template: 'checksums.txt' diff --git a/.res/compose/docker-compose.yml b/.res/compose/docker-compose.yml new file mode 100644 index 00000000..468f39ef --- /dev/null +++ b/.res/compose/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.2" + +services: + diun: + image: crazymax/diun:latest + container_name: diun + volumes: + - "./diun.yml:/diun.yml:ro" + environment: + - "TZ=Europe/Paris" + - "LOG_LEVEL=info" + - "LOG_JSON=false" + - "RUN_ONCE=false" + restart: always diff --git a/.res/diun.png b/.res/diun.png new file mode 100644 index 00000000..b1aec53c Binary files /dev/null and b/.res/diun.png differ diff --git a/.res/paypal-donate.png b/.res/paypal-donate.png new file mode 100644 index 00000000..76842ea1 Binary files /dev/null and b/.res/paypal-donate.png differ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..55859eba --- /dev/null +++ b/.travis.yml @@ -0,0 +1,48 @@ +language: go +go: + - 1.12.x + +services: + - docker + +addons: + apt: + packages: + - docker-ce + +env: + global: + - GO111MODULE=on + - DOCKER_LOGIN=crazymax + - DOCKER_USERNAME=crazymax + - DOCKER_REPONAME=diun + - QUAY_LOGIN=crazymax + - QUAY_USERNAME=crazymax + - QUAY_REPONAME=diun + - secure: rn4yR3+WxNN+XAjHqq91GYcimHYMac4Z14zkrPkjD1HiSap8lkEyEzpVFIHAKcRxUauurSD3InB+CYKNXnFNy8l25vYTGsJE4v5gcYeM7xLAArFXAX/fgJte3X5Y985KA8LEj2iL23eKQP/stdHz7Q6532fKNAWpIn/AXMPCcq/OfgTL/IDvub0CdQ+ujB/r4fge45V/ayUcwGybXSfCE9sgfpejF53i1BB4KUTXsTpANuaiQ4P7T2MhFlAZm7PKERZrBqDZ96naGfGMUMbnOVg1G7vixRvicKYrEYfCcY3ynyH7fOAXBUlcRul7k+AnuMCzCzqHfwl79v4vvDRPbKiQW+M3AesTxH20URaJEgopN5AVlN9hgziV9Btcy57QPWuSPq5vJuR359QOu9xQCVzto7y0xCJiynZf8ekWRu6DVUvI9RomFZLNioK2U8nHcCuadkGI2cYSnv9NxnAuvUrdzZhTDeD9OkbYM6r9pjk2UwoxwD/IJOqcHICynOUeZa7zd/HvO+HgEuRalcEwAXfALlljKJWedAJRtQ2GIHAO7I17Y20zipbYR94iJoNc5m5ED60eKGFkVhPPeE3n46D3rex6fl8KVVMexK0QTEenBOgnWW8VJcKCkLvSJ5kvIQlgFBSw2qCCLBSZ84fPgVPCWSOLnTF5/Sh4azyA3yI= # DOCKER_PASSWORD + - secure: KpHMIy36vZDVf9/cOg7USbQmlEjBlz7+PuIC1P2jPSMo1yp+hAhPbqZwQBh3FZXCtaFbaZEVnnm+4jnKgPirWgqFsMLcP3v4KcP5AW70wVn4fymWeveeDRSnAmgaXhJxxzZJaB6Ps+B9ULtZ8vymrJt9vsiaJ6CO79BeCoQZsTJulSiKlNVgZSMGHfylTt/RAGfWJoLAnS9ROFPpOShXyuSjNzxBopOngWsUxvgQIm3U4qg33DqBcxrrxrKwYIAY9NKCh5yLAohVDzZ+iew/bNmQ/8vqfQQlk4773RYKYa5CtTuHhW/a+Vxsk0CBY5Pu4aZBF2uRFal1IiWiwnFA8GgMeCLAea9uu2vOOqQ2nr/N+GVRP9hMLb7lsEO1eUu1951YgbsUy7naPSdT75aW+6XGbRfFMCx1uCDwK7D3VhY5unUSwn1M0E+jexTbkd1dxy5STUPp3kkh3CvD9W3meGN/ASW+Ky79wjOoVyUOwx7i7jRKHP2ZFOQQvV6Ce/Nb1qNJcul60QpS8Me3XbbQn0GbaHEJvYyNjDDMKJ8vQQJTEIJHiUA2m6a11QmSN00qH6NFM17z60LEnOErNiD3oyBErm2JE4cgClpn3DCtF7FqPA/tjzfiDnVSO/rzvEJ9W/34/lmS1BYlStJuEltPcbpmPUDYsqvAwP0MMWZEgvw= # QUAY_PASSWORD + +before_install: + - sudo apt-get update + - docker --version + +install: + - curl -sL https://git.io/goreleaser | head -n -2 | bash + - tar -xf /tmp/goreleaser.tar.gz -C $GOPATH/bin + - goreleaser -v + +script: ./build.sh + +deploy: + provider: releases + api_key: + secure: NkexB33JizM5kiqqxS7qo5xk6vsKp5+jRQT/gpSVt91XBrzOEnTksVdeoIFoJJ5pFfZfmDyV3e7MsGM9hnf6HUe6NKiSpOAjN7egTs3PqfwxOynFG8IyOIrUO6Q8EceVfl3/UeSH2hr86SUkO8tCsI4g7wTlnoIjsqNs0o5MAX24e2TAr9aFpoTAbDMOijE5Oqjgm/dIAik/N2MS+v8kqZP/KRx8/4hcUUhV5jt0D7LyxjqK3uAZN7bg4e3rltEMnfvKJJ/2s1RnJYzT9GqrATTL6CVZZisspFMgUjBT0y8fsiqqCQVAU/XoftiDIoJm1QOricDchqy7MrGTDjmR+IfVvEqlkp/bWaCEJURRESpsqnFU1g6ega1hpllGQsSCzymTA1SGs0ArUurSQY8S4anz3SiZI4mNusCIXO8w1rrAOXJya++6dTDws8svcPzuYbL7F1/Wh2ejyl9po9p8D69YPv3eVTMDjOQC/2PMNfQuTHku3ozToyvIG4hEHIudpTFvcn2IJXlMeYGoc08fAXSR95SFUVpUUgIsrGwCArw4fi7S5qBT27Dux2HDtGCtR8rqMrqxjHy2aME0oFsVq+tXvMkoDYXukMA50QHBuoMHhBGwzXMXj7ujW/eyLShu1hGikFSgiSTeX+Nnw/lixnsRClsf748CjdkJ86DPciw= + file_glob: true + file: + - dist/checksums.txt + - dist/*.tar.gz + - dist/*.zip + skip_cleanup: true + draft: true + on: + tags: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..33ec5e41 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 (2019/06/04) + +* Initial version diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..7e59b667 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM golang:1.12.4 as builder + +ARG BUILD_DATE +ARG VCS_REF +ARG VERSION + +WORKDIR /app +COPY go.mod . +COPY go.sum . +RUN go version +RUN go mod download +COPY . ./ +RUN cp /usr/local/go/lib/time/zoneinfo.zip ./ \ + && CGO_ENABLED=0 GOOS=linux go build \ + -ldflags "-w -s -X 'main.version=${VERSION}'" \ + -v -o diun cmd/main.go + +FROM alpine:latest + +ARG BUILD_DATE +ARG VCS_REF +ARG VERSION + +LABEL maintainer="CrazyMax" \ + org.label-schema.build-date=$BUILD_DATE \ + org.label-schema.name="Diun" \ + org.label-schema.description="Docker image update notifier" \ + org.label-schema.version=$VERSION \ + org.label-schema.url="https://github.com/crazy-max/diun" \ + org.label-schema.vcs-ref=$VCS_REF \ + org.label-schema.vcs-url="https://github.com/crazy-max/diun" \ + org.label-schema.vendor="CrazyMax" \ + org.label-schema.schema-version="1.0" + +RUN apk --update --no-cache add \ + ca-certificates \ + libressl \ + tzdata \ + && rm -rf /tmp/* /var/cache/apk/* + +COPY --from=builder /app/diun /usr/local/bin/diun +COPY --from=builder /app/zoneinfo.zip /usr/local/go/lib/time/zoneinfo.zip + +VOLUME [ "/data" ] + +CMD [ "diun", "--config", "/diun.yml", "--docker" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..89a92d22 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 CrazyMax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..e3b202d0 --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +

+ +

+ GitHub release + Total downloads + Latest Version + Build Status + Docker Stars + Docker Pulls +
Docker Repository on Quay + Go Report + Code Quality + Donate Paypal +

+ +## About + +**Diun** :bell: is a CLI application written in [Go](https://golang.org/) to receive notifications :inbox_tray: when a Docker :whale: image is updated. With Go, this app can be used across many platforms :game_die: and architectures. This support includes Linux, FreeBSD, macOS and Windows on architectures like amd64, i386, ARM and others. + +## Features + +* Allow to watch a full Docker repository and report new tags +* Include and exclude filters with regular expression for tags +* Internal cron implementation through go routines +* Beautiful email report +* Webhook notification +* Enhanced logging +* Timezone can be changed +* :whale: Official [Docker image available](#docker) + +## Download + +Diun binaries are available in [releases](https://github.com/crazy-max/diun/releases) page. + +Choose the archive matching the destination platform and extract diun: + +``` +$ cd /opt +$ wget -qO- https://github.com/crazy-max/diun/releases/download/v0.1.0/diun_0.1.0_linux_x86_64.tar.gz | tar -zxvf - diun +``` + +After getting the binary, it can be tested with `./diun --help` or moved to a permanent location. + +``` +$ ./diun --help +usage: diun --config=CONFIG [] + +Docker image update notifier. More info on https://github.com/crazy-max/diun + +Flags: + --help Show context-sensitive help (also try --help-long and + --help-man). + --config=CONFIG Diun configuration file. + --timezone="UTC" Timezone assigned to Diun. + --log-level="info" Set log level. + --log-json Enable JSON logging output. + --run-once Run once on startup. + --docker Enable Docker mode. + --version Show application version. +``` + +## Usage + +`diun --config=CONFIG []` + +* `--help` : Show help text and exit. _Optional_. +* `--version` : Show version and exit. _Optional_. +* `--config ` : Diun YAML configuration file. **Required**. (example: `diun.yml`). +* `--timezone ` : Timezone assigned to Diun. _Optional_. (default: `UTC`). +* `--log-level ` : Log level output. _Optional_. (default: `info`). +* `--log-json` : Enable JSON logging output. _Optional_. (default: `false`). +* `--run-once` : Run once on startup. _Optional_. (default: `false`). + +## Configuration + +Before running Diun, you must create your first configuration file. Here is a YAML structure example : + +```yml +db: + path: diun.db + +watch: + schedule: 0 */30 * * * * + +notif: + mail: + enable: false + host: localhost + port: 25 + ssl: false + insecure_skip_verify: false + username: + password: + from: + to: + webhook: + enable: false + endpoint: http://webhook.foo.com/sd54qad89azd5a + method: GET + headers: + Content-Type: application/json + Authorization: Token123456 + timeout: 10 + +reg_creds: + aregistrycred: + username: foo + password: bar + another: + username: foo2 + password: bar2 + +items: + - + image: docker.io/crazymax/nextcloud:latest + reg_cred_id: aregistrycred + - + image: jfrog-docker-reg2.bintray.io/jfrog/artifactory-oss:4.0.0 + reg_cred_id: another + - + image: quay.io/coreos/hyperkube + - + image: crazymax/swarm-cronjob + watch_repo: true + include_tags: + - ^1.2.* +``` + +* `db` + * `path`: Path to Bolt database file where images analysis are stored. Flag `--docker` force this path to `/data/diun.db` (default: `diun.db`). +* `watch` + * `schedule`: [CRON expression](https://godoc.org/github.com/crazy-max/cron#hdr-CRON_Expression_Format) to schedule Diun watcher. _Optional_. (default: `0 */30 * * * *`). +* `notif` + * `mail` + * `enable`: Enable email reports (default: `false`). + * `host`: SMTP server host (default: `localhost`). **required** + * `port`: SMTP server port (default: `25`). **required** + * `ssl`: SSL defines whether an SSL connection is used. Should be false in most cases since the auth mechanism should use STARTTLS (default: `false`). + * `insecure_skip_verify`: Controls whether a client verifies the server's certificate chain and host name (default: `false`). + * `username`: SMTP username. + * `password`: SMTP password. + * `from`: Sender email address. **required** + * `to`: Recipient email address. **required** + * `webhook` + * `enable`: Enable webhook notification (default: `false`). + * `endpoint`: URL of the HTTP request. **required** + * `method`: HTTP method (default: `GET`). **required** + * `headers`: Map of additional headers to be sent. + * `timeout`: Timeout specifies a time limit for the request to be made. (default: `10`). +* `reg_creds`: Map of registry credentials to use with items. Key is the ID and value is a struct with the following fields: + * `username`: Registry username. + * `password`: Registry password. +* `items`: Slice of items to watch with the following fields: + * `image`: Docker image to watch using `registry/path:tag` format. If registry is omitted, `docker.io` will be used. If tag is omitted, `latest` will be used. **required** + * `reg_cred_id`: Registry credential ID from `reg_creds` to use. + * `insecure_tls`: Allow contacting docker registries over HTTP, or HTTPS with failed TLS verification (default: `false`). + * `watch_repo`: Watch all tags of this `image` repository (default: `false`). + * `include_tags`: List of regular expressions to include tags. Can be useful if you use `watch_repo`. + * `exclude_tags`: List of regular expressions to exclude tags. Can be useful if you use `watch_repo`. + * `timeout`: Timeout is the maximum amount of time for the TCP connection to establish (default: `5`). + +## Docker + +Diun provides automatically updated Docker :whale: images within [Docker Hub](https://hub.docker.com/r/crazymax/diun) and [Quay](https://quay.io/repository/crazymax/diun). It is possible to always use the latest stable tag or to use another service that handles updating Docker images. + +Environment variables can be used within your container : + +* `TZ` : Timezone assigned +* `LOG_LEVEL` : Log level output (default `info`) +* `LOG_JSON`: Enable JSON logging output (default `false`) +* `RUN_ONCE`: Run once on startup (default `false`) + +Docker compose is the recommended way to run this image. Copy the content of folder [.res/compose](.res/compose) in `/opt/diun/` on your host for example. Edit the compose and config file with your preferences and run the following commands : + +```bash +docker-compose up -d +docker-compose logs -f +``` + +Or use the following command : + +```bash +$ docker run -d --name diun \ + -e "TZ=Europe/Paris" \ + -e "LOG_LEVEL=info" \ + -e "LOG_JSON=false" \ + -e "RUN_ONCE=false" \ + -v "$(pwd)/diun.yml:/diun.yml:ro" \ + crazymax/diun:latest +``` + +## TODO + +* [ ] Scan Dockerfile +* [ ] Watch images from Docker daemon + +## How can I help ? + +All kinds of contributions are welcome :raised_hands:!
+The most basic way to show your support is to star :star2: the project, or to raise issues :speech_balloon:
+But we're not gonna lie to each other, I'd rather you buy me a beer or two :beers:! + +[![Paypal](.res/paypal-donate.png)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=X2NYRW7D9KL4E) + +## License + +MIT. See `LICENSE` for more details. diff --git a/build.sh b/build.sh new file mode 100644 index 00000000..6d45cc4f --- /dev/null +++ b/build.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -e + +PROJECT=diun +VERSION=${TRAVIS_TAG:-dev} +BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +BUILD_TAG=docker_build +BUILD_WORKINGDIR=${BUILD_WORKINGDIR:-.} +DOCKERFILE=${DOCKERFILE:-Dockerfile} +VCS_REF=${TRAVIS_COMMIT::8} + +PUSH_LATEST=${PUSH_LATEST:-true} +DOCKER_USERNAME=${DOCKER_USERNAME:-crazymax} +DOCKER_LOGIN=${DOCKER_LOGIN:-crazymax} +DOCKER_REPONAME=${DOCKER_REPONAME:-diun} +QUAY_USERNAME=${QUAY_USERNAME:-crazymax} +QUAY_LOGIN=${QUAY_LOGIN:-crazymax} +QUAY_REPONAME=${QUAY_REPONAME:-diun} + +# Check dev or travis +BRANCH=${TRAVIS_BRANCH:-local} +if [[ ${TRAVIS_PULL_REQUEST} == "true" ]]; then + BRANCH=${TRAVIS_PULL_REQUEST_BRANCH} +fi +DOCKER_TAG=${BRANCH:-local} +if [[ "$BRANCH" == "local" ]]; then + BUILD_DATE= +else + DOCKER_TAG=latest + VERSION=${VERSION#v} +fi + +echo "PROJECT=${PROJECT}" +echo "VERSION=${VERSION}" +echo "BUILD_DATE=${BUILD_DATE}" +echo "BUILD_TAG=${BUILD_TAG}" +echo "BUILD_WORKINGDIR=${BUILD_WORKINGDIR}" +echo "DOCKERFILE=${DOCKERFILE}" +echo "VCS_REF=${VCS_REF}" +echo "PUSH_LATEST=${PUSH_LATEST}" +echo "DOCKER_LOGIN=${DOCKER_LOGIN}" +echo "DOCKER_USERNAME=${DOCKER_USERNAME}" +echo "DOCKER_REPONAME=${DOCKER_REPONAME}" +echo "QUAY_LOGIN=${QUAY_LOGIN}" +echo "QUAY_USERNAME=${QUAY_USERNAME}" +echo "QUAY_REPONAME=${QUAY_REPONAME}" +echo "TRAVIS_BRANCH=${TRAVIS_BRANCH}" +echo "TRAVIS_PULL_REQUEST=${TRAVIS_PULL_REQUEST}" +echo "BRANCH=${BRANCH}" +echo "DOCKER_TAG=${DOCKER_TAG}" +echo + +echo "### Goreleaser" +if [[ -n "$TRAVIS_TAG" ]]; then + goreleaser release --skip-publish --rm-dist +else + goreleaser release --snapshot --rm-dist +fi + +echo "### Docker build" +docker build \ + --build-arg BUILD_DATE=${BUILD_DATE} \ + --build-arg VCS_REF=${VCS_REF} \ + --build-arg VERSION=${VERSION} \ + -t ${BUILD_TAG} -f ${DOCKERFILE} ${BUILD_WORKINGDIR} +echo + +if [ "${VERSION}" == "dev" -o "${TRAVIS_PULL_REQUEST}" == "true" ]; then + echo "INFO: This is a PR or an untagged build, skipping push..." + exit 0 +fi +if [[ ! -z ${DOCKER_PASSWORD} ]]; then + echo "### Push to Docker Hub..." + echo "$DOCKER_PASSWORD" | docker login --username "$DOCKER_LOGIN" --password-stdin > /dev/null 2>&1 + if [ "${DOCKER_TAG}" == "latest" -a "${PUSH_LATEST}" == "true" ]; then + docker tag ${BUILD_TAG} ${DOCKER_USERNAME}/${DOCKER_REPONAME}:${DOCKER_TAG} + fi + if [[ "${VERSION}" != "latest" ]]; then + docker tag ${BUILD_TAG} ${DOCKER_USERNAME}/${DOCKER_REPONAME}:${VERSION} + fi + docker push ${DOCKER_USERNAME}/${DOCKER_REPONAME} + if [[ ! -z ${MICROBADGER_HOOK} ]]; then + echo "Call MicroBadger hook" + curl -X POST ${MICROBADGER_HOOK} + echo + fi + echo +fi +if [[ ! -z ${QUAY_PASSWORD} ]]; then + echo "### Push to Quay..." + echo "$QUAY_PASSWORD" | docker login quay.io --username "$QUAY_LOGIN" --password-stdin > /dev/null 2>&1 + if [ "${DOCKER_TAG}" == "latest" -a "${PUSH_LATEST}" == "true" ]; then + docker tag ${BUILD_TAG} quay.io/${QUAY_USERNAME}/${QUAY_REPONAME}:${DOCKER_TAG} + fi + if [[ "${VERSION}" != "latest" ]]; then + docker tag ${BUILD_TAG} quay.io/${QUAY_USERNAME}/${QUAY_REPONAME}:${VERSION} + fi + docker push quay.io/${QUAY_USERNAME}/${QUAY_REPONAME} + echo +fi diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 00000000..29bfb4db --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "os" + "os/signal" + "runtime" + "syscall" + "time" + + "github.com/crazy-max/cron" + "github.com/crazy-max/diun/internal/app" + "github.com/crazy-max/diun/internal/config" + "github.com/crazy-max/diun/internal/logging" + "github.com/crazy-max/diun/internal/model" + "github.com/rs/zerolog/log" + "gopkg.in/alecthomas/kingpin.v2" +) + +var ( + diun *app.Diun + flags model.Flags + c *cron.Cron + version = "dev" +) + +func main() { + runtime.GOMAXPROCS(runtime.NumCPU()) + + // Parse command line + kingpin.Flag("config", "Diun configuration file.").Envar("CONFIG").Required().StringVar(&flags.Cfgfile) + kingpin.Flag("timezone", "Timezone assigned to Diun.").Envar("TZ").Default("UTC").StringVar(&flags.Timezone) + kingpin.Flag("log-level", "Set log level.").Envar("LOG_LEVEL").Default("info").StringVar(&flags.LogLevel) + kingpin.Flag("log-json", "Enable JSON logging output.").Envar("LOG_JSON").Default("false").BoolVar(&flags.LogJson) + kingpin.Flag("run-once", "Run once on startup.").Envar("RUN_ONCE").Default("false").BoolVar(&flags.RunOnce) + kingpin.Flag("docker", "Enable Docker mode.").Envar("DOCKER").Default("false").BoolVar(&flags.Docker) + kingpin.UsageTemplate(kingpin.CompactUsageTemplate).Version(version).Author("CrazyMax") + kingpin.CommandLine.Name = "diun" + kingpin.CommandLine.Help = `Docker image update notifier. More info on https://github.com/crazy-max/diun` + kingpin.Parse() + + // Load timezone location + location, err := time.LoadLocation(flags.Timezone) + if err != nil { + log.Panic().Err(err).Msgf("Cannot load timezone %s", flags.Timezone) + } + + // Init + logging.Configure(&flags, location) + log.Info().Msgf("Starting Diun %s", version) + + // Handle os signals + channel := make(chan os.Signal) + signal.Notify(channel, os.Interrupt, syscall.SIGTERM) + go func() { + sig := <-channel + if c != nil { + c.Stop() + } + diun.Close() + log.Warn().Msgf("Caught signal %v", sig) + os.Exit(0) + }() + + // Load and check configuration + cfg, err := config.Load(flags, version) + if err != nil { + log.Fatal().Err(err).Msg("Cannot load configuration") + } + if err := cfg.Check(); err != nil { + cfg.Display() + log.Fatal().Err(err).Msg("Improper configuration") + } + cfg.Display() + + // Init + if diun, err = app.New(cfg); err != nil { + log.Fatal().Err(err).Msg("Cannot initialize Diun") + } + + // Run once + if flags.RunOnce { + diun.Run() + } + + // Start scheduler + c = cron.NewWithLocation(location) + log.Info().Msgf("Start watcher with schedule %s", cfg.Watch.Schedule) + if err := c.AddJob(cfg.Watch.Schedule, diun); err != nil { + log.Fatal().Err(err).Msg("Cannot create cron task") + } + c.Start() + + select {} +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..9077e412 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module github.com/crazy-max/diun + +require ( + github.com/BurntSushi/toml v0.3.1 // indirect + github.com/Microsoft/go-winio v0.4.12 // indirect + github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc // indirect + github.com/containers/image v1.5.1 + github.com/containers/storage v1.12.8 // indirect + github.com/crazy-max/cron v1.2.2 + github.com/docker/distribution v2.7.1+incompatible // indirect + github.com/docker/docker v1.13.1 // indirect + github.com/docker/docker-credential-helpers v0.6.2 // indirect + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82 // indirect + github.com/docker/go-units v0.4.0 // indirect + github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df + github.com/google/go-cmp v0.3.0 // indirect + github.com/gorilla/mux v1.7.2 // indirect + github.com/hako/durafmt v0.0.0-20180520121703-7b7ae1e72ead + github.com/imdario/mergo v0.3.7 + github.com/matcornic/hermes/v2 v2.0.2 + github.com/opencontainers/go-digest v1.0.0-rc1 + github.com/opencontainers/image-spec v1.0.1 // indirect + github.com/opencontainers/runc v0.1.1 // indirect + github.com/prometheus/client_golang v0.9.3 // indirect + github.com/rs/zerolog v1.14.3 + github.com/sirupsen/logrus v1.4.2 // indirect + github.com/stretchr/testify v1.3.0 // indirect + go.etcd.io/bbolt v1.3.2 + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 // indirect + gopkg.in/alecthomas/kingpin.v2 v2.2.6 + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect + gopkg.in/yaml.v2 v2.2.2 + gotest.tools v2.2.0+incompatible // indirect +) + +replace github.com/docker/docker => github.com/docker/engine v0.0.0-20180718150940-a3ef7e9a9bda diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..2a89f6d2 --- /dev/null +++ b/go.sum @@ -0,0 +1,171 @@ +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc= +github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/sprig v2.16.0+incompatible h1:QZbMUPxRQ50EKAq3LFMnxddMu88/EUUG3qmxwtDmPsY= +github.com/Masterminds/sprig v2.16.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/Microsoft/go-winio v0.4.12 h1:xAfWHN1IrQ0NJ9TBC0KBZoqLjzDTr1ML+4MywiUOryc= +github.com/Microsoft/go-winio v0.4.12/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/aokoli/goutils v1.0.1 h1:7fpzNGoJ3VA8qcrm++XEE1QUe0mIwNeLa02Nwq7RDkg= +github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc h1:TP+534wVlf61smEIq1nwLLAjQVEK2EADoW3CX9AuT+8= +github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/containers/image v1.5.1 h1:ssEuj1c24uJvdMkUa2IrawuEFZBP12p6WzrjNBTQxE0= +github.com/containers/image v1.5.1/go.mod h1:8Vtij257IWSanUQKe1tAeNOm2sRVkSqQTVQ1IlwI3+M= +github.com/containers/storage v1.12.8 h1:5js4CV+oEW0E9pOA/cJcMsY2V7xLlMhIqFZmQlVpTuo= +github.com/containers/storage v1.12.8/go.mod h1:+RirK6VQAqskQlaTBrOG6ulDvn4si2QjFE1NZCn06MM= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/crazy-max/cron v1.2.2 h1:DQB06Nbb9Lah7UrFaRthTzJABto+qRThKcIZLlTOczA= +github.com/crazy-max/cron v1.2.2/go.mod h1:1VehsRAaLIq0DQZP1LSRFzKx2+ar2fCpysK7qoqQt1M= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker-credential-helpers v0.6.2 h1:CrW9H1VMf3a4GrtyAi7IUJjkJVpwBBpX0+mvkvYJaus= +github.com/docker/docker-credential-helpers v0.6.2/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= +github.com/docker/engine v0.0.0-20180718150940-a3ef7e9a9bda h1:v1VUX0+ILrFSsGTp2FUvfgHSiQ6wmI1NnCho1MQ9CYU= +github.com/docker/engine v0.0.0-20180718150940-a3ef7e9a9bda/go.mod h1:3CPr2caMgTHxxIAZgEMd3uLYPDlRvPqCpyeRf6ncPcY= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82 h1:X0fj836zx99zFu83v/M79DuBn84IL/Syx1SY6Y5ZEMA= +github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df h1:Bao6dhmbTA1KFVxmJ6nBoMuOJit2yjEgLJpIMYpop0E= +github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df/go.mod h1:GJr+FCSXshIwgHBtLglIg9M2l2kQSi6QjVAngtzI08Y= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/uuid v1.0.0 h1:b4Gk+7WdP/d3HZH8EJsZpvV7EtDOgaZLtnaNGIu1adA= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.7.2 h1:zoNxOV7WjqXptQOVngLmcSQgXmgk4NMz1HibBchjl/I= +github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/hako/durafmt v0.0.0-20180520121703-7b7ae1e72ead h1:Y9WOGZY2nw5ksbEf5AIpk+vK52Tdg/VN/rHFRfEeeGQ= +github.com/hako/durafmt v0.0.0-20180520121703-7b7ae1e72ead/go.mod h1:5Scbynm8dF1XAPwIwkGPqzkM/shndPm79Jd1003hTjE= +github.com/huandu/xstrings v1.2.0 h1:yPeWdRnmynF7p+lLYz0H2tthW9lqhMJrQV/U7yy4wX0= +github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI= +github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ= +github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/matcornic/hermes/v2 v2.0.2 h1:au/C9liIetFg0c8Zv+woDrFPkWk7UGLi9QQuO013/00= +github.com/matcornic/hermes/v2 v2.0.2/go.mod h1:iVsJWSIS4NtMNtgan22sy6lt7pImok7bATGPWCoaKNY= +github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88= +github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y= +github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3 h1:9iH4JKXLzFbOAdtqv/a+j8aewx2Y8lAjAydhbaScPF8= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0 h1:7etb9YClo3a6HjLzfl6rIQaU+FDfi0VSX39io3aQ+DM= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 h1:sofwID9zm4tzrgykg80hfFph1mryUeLRsUfoocVVmRY= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.14.3 h1:4EGfSkR2hJDB0s3oFfrlPqjU1e4WLncergLil3nEKW0= +github.com/rs/zerolog v1.14.3/go.mod h1:3WXPzbXEEliJ+a6UFE4vhIxV8qR1EML6ngzP9ug4eYg= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.etcd.io/bbolt v1.3.2 h1:Z/90sZLPOeCy2PwprqkFa25PdkusRzaj9P8zm/KNyvk= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029175232-7e6ffbd03851/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/internal/app/diun.go b/internal/app/diun.go new file mode 100644 index 00000000..64dbfe18 --- /dev/null +++ b/internal/app/diun.go @@ -0,0 +1,203 @@ +package app + +import ( + "fmt" + "sync/atomic" + "time" + + "github.com/crazy-max/diun/internal/config" + "github.com/crazy-max/diun/internal/db" + "github.com/crazy-max/diun/internal/model" + "github.com/crazy-max/diun/internal/notif" + "github.com/crazy-max/diun/internal/utl" + "github.com/crazy-max/diun/pkg/registry" + "github.com/hako/durafmt" + "github.com/rs/zerolog/log" +) + +// Diun represents an active diun object +type Diun struct { + cfg *config.Config + reg *registry.Client + db *db.Client + notif *notif.Client + locker uint32 +} + +// New creates new diun instance +func New(cfg *config.Config) (*Diun, error) { + // Registry client + regcli, err := registry.New() + if err != nil { + return nil, err + } + + // DB client + dbcli, err := db.New(cfg.Db) + if err != nil { + return nil, err + } + + // Notification client + notifcli, err := notif.New(cfg.Notif, cfg.App) + if err != nil { + return nil, err + } + + return &Diun{ + cfg: cfg, + reg: regcli, + db: dbcli, + notif: notifcli, + }, nil +} + +// Run starts diun process +func (di *Diun) Run() { + if !atomic.CompareAndSwapUint32(&di.locker, 0, 1) { + log.Warn().Msg("Already running") + return + } + defer atomic.StoreUint32(&di.locker, 0) + defer di.trackTime(time.Now(), "Finished, total time spent: ") + + // Iterate items + for _, item := range di.cfg.Items { + image, err := registry.ParseImage(item.Image) + if err != nil { + log.Error().Err(err).Str("image", item.Image).Msg("Cannot parse image") + continue + } + + opts := ®istry.Options{ + Image: image, + Username: item.RegCred.Username, + Password: item.RegCred.Password, + InsecureTLS: item.InsecureTLS, + } + + if err := di.analyzeImage(item, opts); err != nil { + log.Error().Err(err).Str("image", opts.Image.String()).Msg("Cannot analyze image") + continue + } + + if item.WatchRepo { + di.analyzeRepo(item, opts) + } + } +} + +func (di *Diun) analyzeImage(item model.Item, opts *registry.Options) error { + if !di.isIncluded(opts.Image.Tag, item.IncludeTags) { + log.Warn().Str("image", opts.Image.String()).Msgf("Tag %s not included", opts.Image.Tag) + return nil + } else if di.isExcluded(opts.Image.Tag, item.ExcludeTags) { + log.Warn().Str("image", opts.Image.String()).Msgf("Tag %s excluded", opts.Image.Tag) + return nil + } + + log.Debug().Str("image", opts.Image.String()).Msgf("Analyzing") + liveAna, err := di.reg.Inspect(opts) + if err != nil { + return err + } + + dbAna, err := di.db.GetAnalysis(opts.Image) + if err != nil { + return err + } + + status := model.ImageStatusUnchange + if dbAna.Name == "" { + status = model.ImageStatusNew + log.Info().Str("image", opts.Image.String()).Msgf("New image found") + } else if !liveAna.Created.Equal(*dbAna.Created) { + status = model.ImageStatusUpdate + log.Info().Str("image", opts.Image.String()).Msgf("Image update found") + } else { + log.Debug().Str("image", opts.Image.String()).Msgf("No changes") + return nil + } + + if err := di.db.PutAnalysis(opts.Image, liveAna); err != nil { + return err + } + log.Debug().Str("image", opts.Image.String()).Msg("Analysis saved to database") + + di.notif.Send(model.NotifEntry{ + Status: status, + Image: opts.Image, + Analysis: liveAna, + }) + + return nil +} + +func (di *Diun) analyzeRepo(item model.Item, opts *registry.Options) { + tags, err := di.reg.Tags(opts) + if err != nil { + log.Error().Err(err).Str("image", opts.Image.String()).Msg("Cannot retrieve tags") + return + } + log.Debug().Str("image", opts.Image.String()).Msgf("%d tag(s) found", len(tags)) + + for _, tag := range tags { + if tag == opts.Image.Tag { + continue + } + + simage := fmt.Sprintf("%s/%s:%s", opts.Image.Domain, opts.Image.Path, tag) + image, err := registry.ParseImage(simage) + if err != nil { + log.Error().Err(err).Str("image", simage).Msg("Cannot parse image") + continue + } + + opts := ®istry.Options{ + Image: image, + Username: opts.Username, + Password: opts.Password, + InsecureTLS: opts.InsecureTLS, + } + + if err := di.analyzeImage(item, opts); err != nil { + log.Error().Err(err).Str("image", image.String()).Msg("Cannot analyze image") + continue + } + } +} + +// Close closes diun +func (di *Diun) Close() { + if err := di.db.Close(); err != nil { + log.Warn().Err(err).Msg("Cannot close database") + } +} + +func (di *Diun) isIncluded(tag string, includes []string) bool { + if len(includes) == 0 { + return true + } + for _, include := range includes { + if utl.MatchString(include, tag) { + return true + } + } + return false +} + +func (di *Diun) isExcluded(tag string, excludes []string) bool { + if len(excludes) == 0 { + return false + } + for _, exclude := range excludes { + if utl.MatchString(exclude, tag) { + return true + } + } + return false +} + +func (di *Diun) trackTime(start time.Time, prefix string) { + log.Info().Msgf("%s%s", prefix, durafmt.ParseShort(time.Since(start)).String()) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..d9a2b398 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,142 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/mail" + "os" + "path" + "regexp" + + "github.com/crazy-max/diun/internal/model" + "github.com/imdario/mergo" + "github.com/rs/zerolog/log" + "gopkg.in/yaml.v2" +) + +// Config holds configuration details +type Config struct { + Flags model.Flags + App model.App + Db model.Db `yaml:"db,omitempty"` + Watch model.Watch `yaml:"watch,omitempty"` + Notif model.Notif `yaml:"notif,omitempty"` + RegCreds map[string]model.RegCred `yaml:"reg_creds,omitempty"` + Items []model.Item `yaml:"items,omitempty"` +} + +// Load returns Configuration struct +func Load(fl model.Flags, version string) (*Config, error) { + var err error + var cfg = Config{ + Flags: fl, + App: model.App{ + ID: "diun", + Name: "Diun", + Desc: "Docker image update notifier", + URL: "https://github.com/crazy-max/diun", + Author: "CrazyMax", + Version: version, + }, + Db: model.Db{ + Path: "diun.db", + }, + Watch: model.Watch{ + Schedule: "0 */30 * * * *", + }, + Notif: model.Notif{ + Mail: model.Mail{ + Enable: false, + Host: "localhost", + Port: 25, + SSL: false, + InsecureSkipVerify: false, + }, + Webhook: model.Webhook{ + Enable: false, + Method: "GET", + Timeout: 10, + }, + }, + } + + if _, err = os.Lstat(fl.Cfgfile); err != nil { + return nil, fmt.Errorf("unable to open config file, %s", err) + } + + bytes, err := ioutil.ReadFile(fl.Cfgfile) + if err != nil { + return nil, fmt.Errorf("unable to read config file, %s", err) + } + + if err := yaml.Unmarshal(bytes, &cfg); err != nil { + return nil, fmt.Errorf("unable to decode into struct, %v", err) + } + + return &cfg, nil +} + +// Check verifies Config values +func (cfg *Config) Check() error { + if cfg.Flags.Docker { + cfg.Db.Path = "/data/diun.db" + } + + if cfg.Db.Path == "" { + return errors.New("database path is required") + } + cfg.Db.Path = path.Clean(cfg.Db.Path) + + for id, regCred := range cfg.RegCreds { + if regCred.Username == "" || regCred.Password == "" { + return fmt.Errorf("username and password required for registry credentials '%s'", id) + } + } + + for key, item := range cfg.Items { + if item.RegCredID != "" { + regCred, found := cfg.RegCreds[item.RegCredID] + if !found { + return fmt.Errorf("registry credentials '%s' not found", item.RegCredID) + } + cfg.Items[key].RegCred = regCred + } + + for _, includeTag := range item.IncludeTags { + if _, err := regexp.Compile(includeTag); err != nil { + return fmt.Errorf("include tag regex '%s' for '%s' image cannot compile, %v", item.Image, includeTag, err) + } + } + + for _, excludeTag := range item.ExcludeTags { + if _, err := regexp.Compile(excludeTag); err != nil { + return fmt.Errorf("exclude tag regex '%s' for '%s' image cannot compile, %v", item.Image, excludeTag, err) + } + } + + if err := mergo.Merge(&cfg.Items[key], model.Item{ + Timeout: 5, + }); err != nil { + return err + } + } + + if cfg.Notif.Mail.Enable { + if _, err := mail.ParseAddress(cfg.Notif.Mail.From); err != nil { + return fmt.Errorf("cannot parse sender mail address, %v", err) + } + if _, err := mail.ParseAddress(cfg.Notif.Mail.To); err != nil { + return fmt.Errorf("cannot parse recipient mail address, %v", err) + } + } + + return nil +} + +// Display logs configuration in a pretty JSON format +func (cfg *Config) Display() { + b, _ := json.MarshalIndent(cfg, "", " ") + log.Debug().Msg(string(b)) +} diff --git a/internal/db/client.go b/internal/db/client.go new file mode 100644 index 00000000..94471b17 --- /dev/null +++ b/internal/db/client.go @@ -0,0 +1,80 @@ +package db + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/crazy-max/diun/internal/model" + "github.com/crazy-max/diun/pkg/registry" + "github.com/rs/zerolog/log" + bolt "go.etcd.io/bbolt" +) + +// Client represents an active db object +type Client struct { + *bolt.DB + cfg model.Db +} + +const bucket = "analysis" + +// New creates new db instance +func New(cfg model.Db) (*Client, error) { + db, err := bolt.Open(cfg.Path, 0600, &bolt.Options{ + Timeout: 10 * time.Second, + }) + if err != nil { + return nil, err + } + + if err = db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(bucket)) + return err + }); err != nil { + return nil, err + } + + if err = db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bucket)) + stats := b.Stats() + log.Debug().Msgf("%d entries found in database", stats.KeyN) + return nil + }); err != nil { + return nil, fmt.Errorf("cannot count entries in database, %v", err) + } + + return &Client{db, cfg}, nil +} + +// Close closes db connection +func (c *Client) Close() error { + return c.DB.Close() +} + +// GetAnalysis returns Docker image analysis +func (c *Client) GetAnalysis(image registry.Image) (registry.Inspect, error) { + var ana registry.Inspect + + err := c.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bucket)) + if entryBytes := b.Get([]byte(image.String())); entryBytes != nil { + return json.Unmarshal(entryBytes, &ana) + } + return nil + }) + + return ana, err +} + +// PutAnalysis add Docker image analysis in db +func (c *Client) PutAnalysis(image registry.Image, analysis registry.Inspect) error { + entryBytes, _ := json.Marshal(analysis) + + err := c.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bucket)) + return b.Put([]byte(image.String()), entryBytes) + }) + + return err +} diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 00000000..d17b29a9 --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,39 @@ +package logging + +import ( + "io" + "os" + "time" + + "github.com/crazy-max/diun/internal/model" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// Configure configures logger +func Configure(fl *model.Flags, location *time.Location) { + var err error + var w io.Writer + + zerolog.TimestampFunc = func() time.Time { + return time.Now().In(location) + } + + if !fl.LogJson { + w = zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.RFC1123, + } + } else { + w = os.Stdout + } + + log.Logger = zerolog.New(w).With().Timestamp().Logger() + + logLevel, err := zerolog.ParseLevel(fl.LogLevel) + if err != nil { + log.Fatal().Err(err).Msgf("Unknown log level") + } else { + zerolog.SetGlobalLevel(logLevel) + } +} diff --git a/internal/model/app.go b/internal/model/app.go new file mode 100644 index 00000000..12472413 --- /dev/null +++ b/internal/model/app.go @@ -0,0 +1,20 @@ +package model + +// App holds application details +type App struct { + ID string + Name string + Desc string + URL string + Author string + Version string +} + +const ( + ImageStatusNew = ImageStatus("new") + ImageStatusUpdate = ImageStatus("update") + ImageStatusUnchange = ImageStatus("unchange") +) + +// ImageStatus holds Docker image status analysis +type ImageStatus string diff --git a/internal/model/db.go b/internal/model/db.go new file mode 100644 index 00000000..a05c4b64 --- /dev/null +++ b/internal/model/db.go @@ -0,0 +1,6 @@ +package model + +// Db holds data necessary for database configuration +type Db struct { + Path string `yaml:"path,omitempty"` +} diff --git a/internal/model/flags.go b/internal/model/flags.go new file mode 100644 index 00000000..f4ac6a20 --- /dev/null +++ b/internal/model/flags.go @@ -0,0 +1,12 @@ +package model + +// Flags holds flags from command line +type Flags struct { + Cfgfile string + Populate bool + Timezone string + LogLevel string + LogJson bool + RunOnce bool + Docker bool +} diff --git a/internal/model/item.go b/internal/model/item.go new file mode 100644 index 00000000..596148e5 --- /dev/null +++ b/internal/model/item.go @@ -0,0 +1,13 @@ +package model + +// Item holds item configuration for a Docker image +type Item struct { + Image string `yaml:"image,omitempty"` + RegCredID string `yaml:"reg_cred_id,omitempty"` + InsecureTLS bool `yaml:"insecure_tls,omitempty"` + WatchRepo bool `yaml:"watch_repo,omitempty"` + IncludeTags []string `yaml:"include_tags,omitempty"` + ExcludeTags []string `yaml:"exclude_tags,omitempty"` + Timeout int `yaml:"timeout,omitempty"` + RegCred RegCred `json:"-"` +} diff --git a/internal/model/mail.go b/internal/model/mail.go new file mode 100644 index 00000000..5244b81b --- /dev/null +++ b/internal/model/mail.go @@ -0,0 +1,14 @@ +package model + +// Mail holds mail notification configuration details +type Mail struct { + Enable bool `yaml:"enable,omitempty"` + Host string `yaml:"host,omitempty"` + Port int `yaml:"port,omitempty"` + SSL bool `yaml:"ssl,omitempty"` + InsecureSkipVerify bool `yaml:"insecure_skip_verify,omitempty"` + Username string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + From string `yaml:"from,omitempty"` + To string `yaml:"to,omitempty"` +} diff --git a/internal/model/notif.go b/internal/model/notif.go new file mode 100644 index 00000000..816ccf41 --- /dev/null +++ b/internal/model/notif.go @@ -0,0 +1,16 @@ +package model + +import "github.com/crazy-max/diun/pkg/registry" + +// Notif holds data necessary for notification configuration +type Notif struct { + Mail Mail `yaml:"mail,omitempty"` + Webhook Webhook `yaml:"webhook,omitempty"` +} + +// NotifEntry represents a notification entry +type NotifEntry struct { + Status ImageStatus `json:"status,omitempty"` + Image registry.Image `json:"image,omitempty"` + Analysis registry.Inspect `json:"analysis,omitempty"` +} diff --git a/internal/model/regcred.go b/internal/model/regcred.go new file mode 100644 index 00000000..9fd867ed --- /dev/null +++ b/internal/model/regcred.go @@ -0,0 +1,7 @@ +package model + +// RegCred holds registry credential +type RegCred struct { + Username string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` +} diff --git a/internal/model/watch.go b/internal/model/watch.go new file mode 100644 index 00000000..14a7c734 --- /dev/null +++ b/internal/model/watch.go @@ -0,0 +1,6 @@ +package model + +// Watch holds data necessary for watch configuration +type Watch struct { + Schedule string `yaml:"schedule,omitempty"` +} diff --git a/internal/model/webhook.go b/internal/model/webhook.go new file mode 100644 index 00000000..301fbb0e --- /dev/null +++ b/internal/model/webhook.go @@ -0,0 +1,10 @@ +package model + +// Webhook holds webhook notification configuration details +type Webhook struct { + Enable bool `yaml:"enable,omitempty"` + Endpoint string `yaml:"endpoint,omitempty"` + Method string `yaml:"method,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + Timeout int `yaml:"timeout,omitempty"` +} diff --git a/internal/notif/client.go b/internal/notif/client.go new file mode 100644 index 00000000..e381934d --- /dev/null +++ b/internal/notif/client.go @@ -0,0 +1,46 @@ +package notif + +import ( + "github.com/crazy-max/diun/internal/model" + "github.com/crazy-max/diun/internal/notif/mail" + "github.com/crazy-max/diun/internal/notif/notifier" + "github.com/crazy-max/diun/internal/notif/webhook" + "github.com/rs/zerolog/log" +) + +// Client represents an active webhook notification object +type Client struct { + cfg model.Notif + app model.App + notifiers []notifier.Notifier +} + +// New creates a new notification instance +func New(config model.Notif, app model.App) (*Client, error) { + var c = &Client{ + cfg: config, + app: app, + notifiers: []notifier.Notifier{}, + } + + // Add notifiers + if config.Mail.Enable { + c.notifiers = append(c.notifiers, mail.New(config.Mail, app)) + } + if config.Webhook.Enable { + c.notifiers = append(c.notifiers, webhook.New(config.Webhook, app)) + } + + log.Debug().Msgf("%d notifier(s) created", len(c.notifiers)) + return c, nil +} + +// Send creates and sends notifications to notifiers +func (c *Client) Send(entry model.NotifEntry) { + for _, n := range c.notifiers { + log.Debug().Str("image", entry.Image.String()).Msgf("Sending %s notification...", n.Name()) + if err := n.Send(entry); err != nil { + log.Error().Err(err).Str("image", entry.Image.String()).Msgf("%s notification failed", n.Name()) + } + } +} diff --git a/internal/notif/mail/client.go b/internal/notif/mail/client.go new file mode 100644 index 00000000..4a07ed19 --- /dev/null +++ b/internal/notif/mail/client.go @@ -0,0 +1,118 @@ +package mail + +import ( + "bytes" + "crypto/tls" + "fmt" + "text/template" + "time" + + "github.com/crazy-max/diun/internal/model" + "github.com/crazy-max/diun/internal/notif/notifier" + "github.com/go-gomail/gomail" + "github.com/matcornic/hermes/v2" +) + +// Client represents an active mail notification object +type Client struct { + *notifier.Notifier + cfg model.Mail + app model.App +} + +// New creates a new mail notification instance +func New(config model.Mail, app model.App) notifier.Notifier { + return notifier.Notifier{ + Handler: &Client{ + cfg: config, + app: app, + }, + } +} + +// Name returns notifier's name +func (c *Client) Name() string { + return "mail" +} + +// Send creates and sends an email notification with an entry +func (c *Client) Send(entry model.NotifEntry) error { + h := hermes.Hermes{ + Theme: new(Theme), + Product: hermes.Product{ + Name: c.app.Name, + Link: "https://github.com/crazy-max/diun", + Logo: "https://raw.githubusercontent.com/crazy-max/diun/master/.res/diun.png", + Copyright: fmt.Sprintf("%s © %d %s %s", + c.app.Author, + time.Now().Year(), + c.app.Name, + c.app.Version), + }, + } + + // Subject + subject := fmt.Sprintf("Image update for %s", entry.Image.String()) + if entry.Status == model.ImageStatusNew { + subject = fmt.Sprintf("New image %s has been added", entry.Image.String()) + } + + // Body + var emailBuf bytes.Buffer + emailTpl := template.Must(template.New("email").Parse(` + +Docker 🐳 tag **{{ .Image.Domain }}/{{ .Image.Path }}:{{ .Image.Tag }}** which you subscribed to has been {{ if (eq .Status "new") }}newly added{{ else }}updated{{ end }}. + +This image has been {{ if (eq .Status "new") }}created{{ else }}updated{{ end }} at {{ .Analysis.Created }} with digest {{ .Analysis.Digest }} for {{ .Analysis.Os }}/{{ .Analysis.Architecture }} platform. + +Need help, or have questions? Go to https://github.com/crazy-max/diun and leave an issue. + +`)) + if err := emailTpl.Execute(&emailBuf, entry); err != nil { + return err + } + email := hermes.Email{ + Body: hermes.Body{ + Title: fmt.Sprintf("%s 🔔 notification", c.app.Name), + FreeMarkdown: hermes.Markdown(emailBuf.String()), + Signature: "Thanks for your support", + }, + } + + // Generate an HTML email with the provided contents (for modern clients) + htmlpart, err := h.GenerateHTML(email) + if err != nil { + return fmt.Errorf("hermes: %v", err) + } + + // Generate the plaintext version of the e-mail (for clients that do not support xHTML) + textpart, err := h.GeneratePlainText(email) + if err != nil { + return fmt.Errorf("hermes: %v", err) + } + + msg := gomail.NewMessage() + msg.SetHeader("From", fmt.Sprintf("%s <%s>", c.app.Name, c.cfg.From)) + msg.SetHeader("To", c.cfg.To) + msg.SetHeader("Subject", subject) + msg.SetBody("text/plain", textpart) + msg.AddAlternative("text/html", htmlpart) + + var tlsConfig *tls.Config + if c.cfg.InsecureSkipVerify { + tlsConfig = &tls.Config{ + InsecureSkipVerify: c.cfg.InsecureSkipVerify, + } + } + + dialer := &gomail.Dialer{ + Host: c.cfg.Host, + Port: c.cfg.Port, + Username: c.cfg.Username, + Password: c.cfg.Password, + SSL: c.cfg.SSL, + TLSConfig: tlsConfig, + } + + return dialer.DialAndSend(msg) +} diff --git a/internal/notif/mail/theme.go b/internal/notif/mail/theme.go new file mode 100644 index 00000000..348fdf73 --- /dev/null +++ b/internal/notif/mail/theme.go @@ -0,0 +1,505 @@ +package mail + +// Theme is the theme for hermes +type Theme struct{} + +// Name returns the name of the theme +func (t *Theme) Name() string { + return "diun" +} + +// HTMLTemplate returns a Golang template that will generate an HTML email. +func (t *Theme) HTMLTemplate() string { + return ` + + + + + + + + + + + + + + + +` +} + +// PlainTextTemplate returns a Golang template that will generate an plain text email. +func (t *Theme) PlainTextTemplate() string { + return `

{{if .Email.Body.Title }}{{ .Email.Body.Title }}{{ else }}{{ .Email.Body.Greeting }} {{ .Email.Body.Name }},{{ end }}

+{{ with .Email.Body.Intros }} + {{ range $line := . }} +

{{ $line }}

+ {{ end }} +{{ end }} +{{ if (ne .Email.Body.FreeMarkdown "") }} + {{ .Email.Body.FreeMarkdown.ToHTML }} +{{ else }} + {{ with .Email.Body.Dictionary }} +
    + {{ range $entry := . }} +
  • {{ $entry.Key }}: {{ $entry.Value }}
  • + {{ end }} +
+ {{ end }} + {{ with .Email.Body.Table }} + {{ $data := .Data }} + {{ $columns := .Columns }} + {{ if gt (len $data) 0 }} + + + {{ $col := index $data 0 }} + {{ range $entry := $col }} + + {{ end }} + + {{ range $row := $data }} + + {{ range $cell := $row }} + + {{ end }} + + {{ end }} +
{{ $entry.Key }}
+ {{ $cell.Value }} +
+ {{ end }} + {{ end }} + {{ with .Email.Body.Actions }} + {{ range $action := . }} +

{{ $action.Instructions }} {{ $action.Button.Link }}

+ {{ end }} + {{ end }} +{{ end }} +{{ with .Email.Body.Outros }} + {{ range $line := . }} +

{{ $line }}

+ {{ end }} +{{ end }} +

{{.Email.Body.Signature}},
{{.Hermes.Product.Name}} - {{.Hermes.Product.Link}}

+ +

{{.Hermes.Product.Copyright}}

+` +} diff --git a/internal/notif/notifier/notifier.go b/internal/notif/notifier/notifier.go new file mode 100644 index 00000000..d2310620 --- /dev/null +++ b/internal/notif/notifier/notifier.go @@ -0,0 +1,16 @@ +package notifier + +import ( + "github.com/crazy-max/diun/internal/model" +) + +// Handler is a notifier interface +type Handler interface { + Name() string + Send(entry model.NotifEntry) error +} + +// Notifier represents an active notifier object +type Notifier struct { + Handler +} diff --git a/internal/notif/webhook/client.go b/internal/notif/webhook/client.go new file mode 100644 index 00000000..cc05d636 --- /dev/null +++ b/internal/notif/webhook/client.go @@ -0,0 +1,81 @@ +package webhook + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/crazy-max/diun/internal/model" + "github.com/crazy-max/diun/internal/notif/notifier" + "github.com/opencontainers/go-digest" +) + +// Client represents an active webhook notification object +type Client struct { + *notifier.Notifier + cfg model.Webhook + app model.App +} + +// New creates a new webhook notification instance +func New(config model.Webhook, app model.App) notifier.Notifier { + return notifier.Notifier{ + Handler: &Client{ + cfg: config, + app: app, + }, + } +} + +// Name returns notifier's name +func (c *Client) Name() string { + return "webhook" +} + +// Send creates and sends a webhook notification with an entry +func (c *Client) Send(entry model.NotifEntry) error { + hc := http.Client{ + Timeout: time.Duration(c.cfg.Timeout) * time.Second, + } + + body, err := json.Marshal(struct { + Version string `json:"diun_version,omitempty"` + Status string `json:"status,omitempty"` + Image string `json:"image,omitempty"` + MIMEType string `json:"mime_type,omitempty"` + Digest digest.Digest `json:"digest,omitempty"` + Date *time.Time `json:"date,omitempty"` + Architecture string `json:"architecture,omitempty"` + Os string `json:"os,omitempty"` + }{ + Version: c.app.Version, + Status: string(entry.Status), + Image: entry.Image.String(), + MIMEType: entry.Analysis.MIMEType, + Digest: entry.Analysis.Digest, + Date: entry.Analysis.Created, + Architecture: entry.Analysis.Architecture, + Os: entry.Analysis.Os, + }) + if err != nil { + return err + } + + req, err := http.NewRequest(c.cfg.Method, c.cfg.Endpoint, bytes.NewBuffer([]byte(body))) + if err != nil { + return err + } + + if len(c.cfg.Headers) > 0 { + for key, value := range c.cfg.Headers { + req.Header.Add(key, value) + } + } + + req.Header.Set("User-Agent", fmt.Sprintf("%s %s", c.app.Name, c.app.Version)) + + _, err = hc.Do(req) + return err +} diff --git a/internal/utl/utl.go b/internal/utl/utl.go new file mode 100644 index 00000000..110c84af --- /dev/null +++ b/internal/utl/utl.go @@ -0,0 +1,15 @@ +package utl + +import ( + "regexp" +) + +// MatchString reports whether a string s +// contains any match of a regular expression. +func MatchString(exp string, s string) bool { + re, err := regexp.Compile(exp) + if err != nil { + return false + } + return re.MatchString(s) +} diff --git a/pkg/registry/client.go b/pkg/registry/client.go new file mode 100644 index 00000000..c0db1b15 --- /dev/null +++ b/pkg/registry/client.go @@ -0,0 +1,71 @@ +package registry + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/containers/image/docker" + "github.com/containers/image/types" +) + +// Client represents an active registry object +type Client struct{} + +type Options struct { + Image Image + Username string + Password string + InsecureTLS bool + Timeout time.Duration +} + +// New creates new registry instance +func New() (*Client, error) { + return &Client{}, nil +} + +func (c *Client) timeoutContext(timeout time.Duration) (context.Context, context.CancelFunc) { + ctx := context.Background() + var cancel context.CancelFunc = func() {} + if timeout > 0 { + ctx, cancel = context.WithTimeout(ctx, timeout) + } else { + ctx, cancel = context.WithTimeout(ctx, 5*time.Second) + } + return ctx, cancel +} + +func (c *Client) newImage(ctx context.Context, opts *Options) (types.ImageCloser, *types.SystemContext, error) { + image := opts.Image.String() + if !strings.HasPrefix(opts.Image.String(), "//") { + image = fmt.Sprintf("//%s", opts.Image.String()) + } + + ref, err := docker.ParseReference(image) + if err != nil { + return nil, nil, fmt.Errorf("invalid image name %s: %v", image, err) + } + + auth := &types.DockerAuthConfig{} + if opts.Username != "" { + auth = &types.DockerAuthConfig{ + Username: opts.Username, + Password: opts.Password, + } + } + + sys := &types.SystemContext{ + DockerAuthConfig: auth, + DockerDaemonInsecureSkipTLSVerify: opts.InsecureTLS, + DockerInsecureSkipTLSVerify: types.NewOptionalBool(opts.InsecureTLS), + } + + img, err := ref.NewImage(ctx, sys) + if err != nil { + return nil, nil, err + } + + return img, sys, nil +} diff --git a/pkg/registry/image.go b/pkg/registry/image.go new file mode 100644 index 00000000..fb2c1fab --- /dev/null +++ b/pkg/registry/image.go @@ -0,0 +1,69 @@ +// Source: https://github.com/genuinetools/reg/blob/master/registry/image.go + +package registry + +import ( + "fmt" + + "github.com/containers/image/docker/reference" + digest "github.com/opencontainers/go-digest" +) + +// Image holds information about an image. +type Image struct { + Domain string + Path string + Tag string + Digest digest.Digest + named reference.Named +} + +// String returns the string representation of an image. +func (i Image) String() string { + return i.named.String() +} + +// Reference returns either the digest if it is non-empty or the tag for the image. +func (i Image) Reference() string { + if len(i.Digest.String()) > 1 { + return i.Digest.String() + } + + return i.Tag +} + +// WithDigest sets the digest for an image. +func (i *Image) WithDigest(digest digest.Digest) (err error) { + i.Digest = digest + i.named, err = reference.WithDigest(i.named, digest) + return err +} + +// ParseImage returns an Image struct with all the values filled in for a given image. +func ParseImage(image string) (Image, error) { + // Parse the image name and tag. + named, err := reference.ParseNormalizedNamed(image) + if err != nil { + return Image{}, fmt.Errorf("parsing image %q failed: %v", image, err) + } + // Add the latest lag if they did not provide one. + named = reference.TagNameOnly(named) + + i := Image{ + named: named, + Domain: reference.Domain(named), + Path: reference.Path(named), + } + + // Add the tag if there was one. + if tagged, ok := named.(reference.Tagged); ok { + i.Tag = tagged.Tag() + } + + // Add the digest if there was one. + if canonical, ok := named.(reference.Canonical); ok { + i.Digest = canonical.Digest() + } + + return i, nil +} diff --git a/pkg/registry/inspect.go b/pkg/registry/inspect.go new file mode 100644 index 00000000..6c56e959 --- /dev/null +++ b/pkg/registry/inspect.go @@ -0,0 +1,66 @@ +package registry + +import ( + "time" + + "github.com/containers/image/manifest" + "github.com/opencontainers/go-digest" +) + +type Inspect struct { + Name string + Tag string + MIMEType string + Digest digest.Digest + Created *time.Time + DockerVersion string + Labels map[string]string + Architecture string + Os string + Layers []string +} + +// Inspect inspects a Docker image +func (c *Client) Inspect(opts *Options) (Inspect, error) { + ctx, cancel := c.timeoutContext(opts.Timeout) + defer cancel() + + img, _, err := c.newImage(ctx, opts) + if err != nil { + return Inspect{}, err + } + defer img.Close() + + rawManifest, _, err := img.Manifest(ctx) + if err != nil { + return Inspect{}, err + } + + imgInspect, err := img.Inspect(ctx) + if err != nil { + return Inspect{}, err + } + + imgDigest, err := manifest.Digest(rawManifest) + if err != nil { + return Inspect{}, err + } + + imgTag := imgInspect.Tag + if imgTag == "" { + imgTag = opts.Image.Tag + } + + return Inspect{ + Name: img.Reference().DockerReference().Name(), + Tag: imgTag, + MIMEType: manifest.GuessMIMEType(rawManifest), + Digest: imgDigest, + Created: imgInspect.Created, + DockerVersion: imgInspect.DockerVersion, + Labels: imgInspect.Labels, + Architecture: imgInspect.Architecture, + Os: imgInspect.Os, + Layers: imgInspect.Layers, + }, nil +} diff --git a/pkg/registry/tags.go b/pkg/registry/tags.go new file mode 100644 index 00000000..d5705134 --- /dev/null +++ b/pkg/registry/tags.go @@ -0,0 +1,26 @@ +package registry + +import ( + "github.com/containers/image/docker" +) + +type Tags []string + +// Tags returns tags of a Docker repository +func (c *Client) Tags(opts *Options) (Tags, error) { + ctx, cancel := c.timeoutContext(opts.Timeout) + defer cancel() + + img, sys, err := c.newImage(ctx, opts) + if err != nil { + return nil, err + } + defer img.Close() + + tags, err := docker.GetRepositoryTags(ctx, sys, img.Reference()) + if err != nil { + return nil, err + } + + return Tags(tags), err +}