From 8513d49cc98ca995a8664d283801ab8ca827409a Mon Sep 17 00:00:00 2001 From: CrazyMax Date: Tue, 4 Jun 2019 22:11:54 +0200 Subject: [PATCH] Initial commit --- .dockerignore | 17 + .editorconfig | 18 + .github/FUNDING.yml | 2 + .github/ISSUE_TEMPLATE/bug_report.md | 35 ++ .github/SUPPORT.md | 29 ++ .gitignore | 6 + .goreleaser.yml | 41 +++ .res/compose/docker-compose.yml | 14 + .res/diun.png | Bin 0 -> 12635 bytes .res/paypal-donate.png | Bin 0 -> 7643 bytes .travis.yml | 48 +++ CHANGELOG.md | 5 + Dockerfile | 46 +++ LICENSE | 21 ++ README.md | 207 +++++++++++ build.sh | 100 ++++++ cmd/main.go | 94 +++++ go.mod | 40 +++ go.sum | 171 +++++++++ internal/app/diun.go | 203 +++++++++++ internal/config/config.go | 142 ++++++++ internal/db/client.go | 80 +++++ internal/logging/logger.go | 39 +++ internal/model/app.go | 20 ++ internal/model/db.go | 6 + internal/model/flags.go | 12 + internal/model/item.go | 13 + internal/model/mail.go | 14 + internal/model/notif.go | 16 + internal/model/regcred.go | 7 + internal/model/watch.go | 6 + internal/model/webhook.go | 10 + internal/notif/client.go | 46 +++ internal/notif/mail/client.go | 118 +++++++ internal/notif/mail/theme.go | 505 +++++++++++++++++++++++++++ internal/notif/notifier/notifier.go | 16 + internal/notif/webhook/client.go | 81 +++++ internal/utl/utl.go | 15 + pkg/registry/client.go | 71 ++++ pkg/registry/image.go | 69 ++++ pkg/registry/inspect.go | 66 ++++ pkg/registry/tags.go | 26 ++ 42 files changed, 2475 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/SUPPORT.md create mode 100644 .gitignore create mode 100644 .goreleaser.yml create mode 100644 .res/compose/docker-compose.yml create mode 100644 .res/diun.png create mode 100644 .res/paypal-donate.png create mode 100644 .travis.yml create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.sh create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/diun.go create mode 100644 internal/config/config.go create mode 100644 internal/db/client.go create mode 100644 internal/logging/logger.go create mode 100644 internal/model/app.go create mode 100644 internal/model/db.go create mode 100644 internal/model/flags.go create mode 100644 internal/model/item.go create mode 100644 internal/model/mail.go create mode 100644 internal/model/notif.go create mode 100644 internal/model/regcred.go create mode 100644 internal/model/watch.go create mode 100644 internal/model/webhook.go create mode 100644 internal/notif/client.go create mode 100644 internal/notif/mail/client.go create mode 100644 internal/notif/mail/theme.go create mode 100644 internal/notif/notifier/notifier.go create mode 100644 internal/notif/webhook/client.go create mode 100644 internal/utl/utl.go create mode 100644 pkg/registry/client.go create mode 100644 pkg/registry/image.go create mode 100644 pkg/registry/inspect.go create mode 100644 pkg/registry/tags.go 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 0000000000000000000000000000000000000000..b1aec53cc7e9abba4a94eb8835eea52886bb006d GIT binary patch literal 12635 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 xf7psJ{)P%mc_^FP;S{|Ep8 literal 0 HcmV?d00001 diff --git a/.res/paypal-donate.png b/.res/paypal-donate.png new file mode 100644 index 0000000000000000000000000000000000000000..76842ea133ed15c15ee904594e6b50500def29a8 GIT binary patch literal 7643 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 JPDHLkV1mzqba4Ox literal 0 HcmV?d00001 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 +}