Configuration transposed into environment variables (#82)

Configuration file not required anymore
DIUN_DB env var renamed DIUN_DB_PATH
Only accept duration as timeout value (10 becomes 10s)
Add getting started doc
Enhanced documentation
Add note about test notifications (#79)
Improve configuration management
Fix telegram init
All fields in configuration now camelCased
Improve configuration validation
Update doc
Update FAQ

Co-authored-by: CrazyMax <crazy-max@users.noreply.github.com>
This commit is contained in:
CrazyMax
2020-06-07 19:58:49 +00:00
committed by GitHub
parent 56d110bdec
commit 349917e7e4
100 changed files with 10336 additions and 885 deletions

View File

@@ -6,9 +6,15 @@ services:
container_name: diun container_name: diun
volumes: volumes:
- "./data:/data" - "./data:/data"
- "./diun.yml:/diun.yml:ro" - "/var/run/docker.sock:/var/run/docker.sock"
environment: environment:
- "TZ=Europe/Paris" - "TZ=Europe/Paris"
- "LOG_LEVEL=info" - "LOG_LEVEL=info"
- "LOG_JSON=false" - "LOG_JSON=false"
- "DIUN_PROVIDERS_DOCKER=true"
- "DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT=false"
- "DIUN_PROVIDERS_DOCKER_WATCHSTOPPED=false"
labels:
- "diun.enable=true"
- "diun.watch_repo=true"
restart: always restart: always

View File

@@ -11,7 +11,7 @@ User=diun
Group=diun Group=diun
ExecStart=/usr/local/bin/diun --config /etc/diun/diun.yml --log-level info ExecStart=/usr/local/bin/diun --config /etc/diun/diun.yml --log-level info
Restart=always Restart=always
Environment=DIUN_DB=/var/lib/diun/diun.db Environment=DIUN_DB_PATH=/var/lib/diun/diun.db
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -55,9 +55,7 @@ COPY --from=builder /app/diun /usr/local/bin/diun
COPY --from=builder /usr/local/go/lib/time/zoneinfo.zip /usr/local/go/lib/time/zoneinfo.zip COPY --from=builder /usr/local/go/lib/time/zoneinfo.zip /usr/local/go/lib/time/zoneinfo.zip
RUN diun --version RUN diun --version
ENV DIUN_DB="/data/diun.db" ENV DIUN_DB_PATH="/data/diun.db"
VOLUME [ "/data" ] VOLUME [ "/data" ]
ENTRYPOINT [ "diun" ] ENTRYPOINT [ "diun" ]
CMD [ "--config", "/diun.yml" ]

View File

@@ -38,7 +38,7 @@
* [With Docker](doc/install/docker.md) * [With Docker](doc/install/docker.md)
* [From binary](doc/install/binary.md) * [From binary](doc/install/binary.md)
* [Linux service](doc/install/linux-service.md) * [Linux service](doc/install/linux-service.md)
* [Usage](doc/usage.md) * [Getting started](doc/getting-started.md)
* [Configuration](doc/configuration.md) * [Configuration](doc/configuration.md)
* Providers * Providers
* [Docker](doc/providers/docker.md) * [Docker](doc/providers/docker.md)

View File

@@ -5,6 +5,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"runtime" "runtime"
"strings"
"syscall" "syscall"
"time" "time"
@@ -20,15 +21,25 @@ var (
diun *app.Diun diun *app.Diun
cli model.Cli cli model.Cli
version = "dev" version = "dev"
meta = model.Meta{
ID: "diun",
Name: "Diun",
Desc: "Docker image update notifier",
URL: "https://github.com/crazy-max/diun",
Logo: "https://raw.githubusercontent.com/crazy-max/diun/master/.res/diun.png",
Author: "CrazyMax",
}
) )
func main() { func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) runtime.GOMAXPROCS(runtime.NumCPU())
meta.Version = version
meta.UserAgent = fmt.Sprintf("%s/%s go/%s %s", meta.ID, meta.Version, runtime.Version()[2:], strings.Title(runtime.GOOS))
// Parse command line // Parse command line
_ = kong.Parse(&cli, _ = kong.Parse(&cli,
kong.Name("diun"), kong.Name(meta.ID),
kong.Description(`Docker image update notifier. More info: https://github.com/crazy-max/diun`), kong.Description(fmt.Sprintf("%s. More info: %s", meta.Desc, meta.URL)),
kong.UsageOnError(), kong.UsageOnError(),
kong.Vars{ kong.Vars{
"version": fmt.Sprintf("%s", version), "version": fmt.Sprintf("%s", version),
@@ -46,7 +57,7 @@ func main() {
// Init // Init
logging.Configure(&cli, location) logging.Configure(&cli, location)
log.Info().Msgf("Starting Diun %s", version) log.Info().Str("version", version).Msgf("Starting %s", meta.Name)
// Handle os signals // Handle os signals
channel := make(chan os.Signal) channel := make(chan os.Signal)
@@ -59,15 +70,15 @@ func main() {
}() }()
// Load configuration // Load configuration
cfg, err := config.Load(cli, version) cfg, err := config.Load(cli.Cfgfile)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Cannot load configuration") log.Fatal().Err(err).Msg("Cannot load configuration")
} }
log.Debug().Msg(cfg.Display()) log.Debug().Msg(cfg.String())
// Init // Init
if diun, err = app.New(cfg, location); err != nil { if diun, err = app.New(meta, cfg, location); err != nil {
log.Fatal().Err(err).Msg("Cannot initialize Diun") log.Fatal().Err(err).Msgf("Cannot initialize %s", meta.Name)
} }
// Test notif // Test notif
@@ -78,6 +89,6 @@ func main() {
// Start // Start
if err = diun.Start(); err != nil { if err = diun.Start(); err != nil {
log.Fatal().Err(err).Msg("Cannot start Diun") log.Fatal().Err(err).Msgf("Cannot start %s", meta.Name)
} }
} }

View File

@@ -1,6 +1,7 @@
# Configuration # Configuration
* [Overview](#overview) * [Overview](#overview)
* [Configuration file](#configuration-file)
* [Reference](#reference) * [Reference](#reference)
* [db](#db) * [db](#db)
* [watch](#watch) * [watch](#watch)
@@ -10,6 +11,21 @@
## Overview ## Overview
There are two different ways to define static configuration options in Diun:
* In a [configuration file](#configuration-file)
* As environment variables
These ways are evaluated in the order listed above.
If no value was provided for a given option, a default value applies. Moreover, if an option has sub-options, and any of these sub-options is not specified, a default value will apply as well.
For example, the `DIUN_PROVIDERS_DOCKER` environment variable is enough by itself to enable the docker provider, even though sub-options like `DIUN_PROVIDERS_DOCKER_ENDPOINT` exist. Once positioned, this option sets (and resets) all the default values of the sub-options of `DIUN_PROVIDERS_DOCKER`.
## Configuration file
You can define a configuration file through the option `--config` with the following content:
```yaml ```yaml
db: db:
path: diun.db path: diun.db
@@ -17,7 +33,7 @@ db:
watch: watch:
workers: 10 workers: 10
schedule: "0 * * * *" schedule: "0 * * * *"
first_check_notif: false firstCheckNotif: false
notif: notif:
amqp: amqp:
@@ -25,65 +41,62 @@ notif:
port: 5672 port: 5672
username: guest username: guest
password: guest password: guest
exchange:
queue: queue queue: queue
gotify: gotify:
endpoint: http://gotify.foo.com endpoint: http://gotify.foo.com
token: Token123456 token: Token123456
priority: 1 priority: 1
timeout: 10 timeout: 10s
mail: mail:
host: localhost host: localhost
port: 25 port: 25
ssl: false ssl: false
insecure_skip_verify: false insecureSkipVerify: false
username: from: diun@example.com
password: to: webmaster@example.com
from:
to:
rocketchat: rocketchat:
endpoint: http://rocket.foo.com:3000 endpoint: http://rocket.foo.com:3000
channel: "#general" channel: "#general"
user_id: abcdEFGH012345678 userID: abcdEFGH012345678
token: Token123456 token: Token123456
timeout: 10 timeout: 10s
script: script:
cmd: "myprogram" cmd: "myprogram"
args: args:
- "--anarg" - "--anarg"
- "another" - "another"
slack: slack:
webhook_url: https://hooks.slack.com/services/ABCD12EFG/HIJK34LMN/01234567890abcdefghij webhookURL: https://hooks.slack.com/services/ABCD12EFG/HIJK34LMN/01234567890abcdefghij
teams: teams:
webhook_url: https://outlook.office.com/webhook/ABCD12EFG/HIJK34LMN/01234567890abcdefghij webhookURL: https://outlook.office.com/webhook/ABCD12EFG/HIJK34LMN/01234567890abcdefghij
telegram: telegram:
token: aabbccdd:11223344 token: aabbccdd:11223344
chat_ids: chatIDs:
- 123456789 - 123456789
- 987654321 - 987654321
webhook: webhook:
endpoint: http://webhook.foo.com/sd54qad89azd5a endpoint: http://webhook.foo.com/sd54qad89azd5a
method: GET method: GET
headers: headers:
Content-Type: application/json content-type: application/json
Authorization: Token123456 authorization: Token123456
timeout: 10 timeout: 10s
regopts: regopts:
someregistryoptions: someregistryoptions:
username: foo username: foo
password: bar password: bar
timeout: 20 timeout: 20s
onemore: onemore:
username: foo2 username: foo2
password: bar2 password: bar2
insecure_tls: true insecureTls: true
providers: providers:
docker: docker:
watch_stopped: true watchStopped: true
swarm: swarm:
watch_by_default: true watchByDefault: true
file: file:
directory: ./imagesdir directory: ./imagesdir
``` ```
@@ -92,13 +105,23 @@ providers:
### db ### db
* `path`: Path to Bolt database file where images manifests are stored (default: `diun.db`). Environment var `DIUN_DB` override this value. * `path`: Path to Bolt database file where images manifests are stored. (default `diun.db`)
You can also use the following environment variables:
* `DIUN_DB_PATH`
### watch ### watch
* `workers`: Maximum number of workers that will execute tasks concurrently (default: `10`). * `workers`: Maximum number of workers that will execute tasks concurrently. (default `10`)
* `schedule`: [CRON expression](https://godoc.org/github.com/robfig/cron#hdr-CRON_Expression_Format) to schedule Diun watcher (default: `0 * * * *`). * `schedule`: [CRON expression](https://godoc.org/github.com/robfig/cron#hdr-CRON_Expression_Format) to schedule Diun watcher. (default `0 * * * *`)
* `first_check_notif`: Send notification at the very first analysis of an image. (default: `false`). * `firstCheckNotif`: Send notification at the very first analysis of an image. (default `false`)
You can also use the following environment variables:
* `DIUN_WATCH_WORKERS`
* `DIUN_WATCH_SCHEDULE`
* `DIUN_WATCH_FIRSTCHECKNOTIF`
### notif ### notif
@@ -107,7 +130,7 @@ providers:
* [mail](notifications.md#mail) * [mail](notifications.md#mail)
* [rocketchat](notifications.md#rocketchat) * [rocketchat](notifications.md#rocketchat)
* [script](notifications.md#script) * [script](notifications.md#script)
* [slack](notifications.md#slack) * [slack](notifications.md#slack--mattermost)
* [teams](notifications.md#teams) * [teams](notifications.md#teams)
* [telegram](notifications.md#telegram) * [telegram](notifications.md#telegram)
* [webhook](notifications.md#webhook) * [webhook](notifications.md#webhook)
@@ -115,11 +138,20 @@ providers:
### regopts ### regopts
* `username`: Registry username. * `username`: Registry username.
* `username_file`: Use content of secret file as registry username if `username` not defined. * `usernameFile`: Use content of secret file as registry username if `username` not defined.
* `password`: Registry password. * `password`: Registry password.
* `password_file`: Use content of secret file as registry password if `password` not defined. * `passwordFile`: Use content of secret file as registry password if `password` not defined.
* `timeout`: Timeout is the maximum amount of time for the TCP connection to establish. 0 means no timeout (default: `10`). * `timeout`: Timeout is the maximum amount of time for the TCP connection to establish. (default `10s`)
* `insecure_tls`: Allow contacting docker registry over HTTP, or HTTPS with failed TLS verification (default: `false`). * `insecureTls`: Allow contacting docker registry over HTTP, or HTTPS with failed TLS verification. (default `false`)
You can also use the following environment variables:
* `DIUN_REGOPTS_<NAME>_USERNAME`
* `DIUN_REGOPTS_<NAME>_USERNAMEFILE`
* `DIUN_REGOPTS_<NAME>_PASSWORD`
* `DIUN_REGOPTS_<NAME>_PASSWORDFILE`
* `DIUN_REGOPTS_<NAME>_TIMEOUT`
* `DIUN_REGOPTS_<NAME>_INSECURETLS`
### providers ### providers

View File

@@ -1,7 +1,74 @@
# FAQ # FAQ
* [Test notifications](#test-notifications)
* [Maximum supported API version is 1.39 error](#maximum-supported-api-version-is-139-error)
* [field docker|swarm uses unsupported type: invalid](#field-dockerswarm-uses-unsupported-type-invalid)
* [No image found in manifest list for architecture [], variant [], OS []](#no-image-found-in-manifest-list-for-architecture--variant--os-) * [No image found in manifest list for architecture [], variant [], OS []](#no-image-found-in-manifest-list-for-architecture--variant--os-)
## Test notifications
Through the [command line](getting-started.md#diun-cli) with:
```
diun --config ./diun.yml --test-notif
```
Or within a container:
```
docker-compose exec diun --test-notif
```
## Maximum supported API version is 1.39 error
The error `Error response from daemon: client version 1.40 is too new. Maximum supported API version is 1.39` indicates that you are using a fairly old version of Docker like:
```shell
$ docker version
Client:
Version: 18.09.8
API version: 1.39
Go version: go1.11
Git commit: bfed4f5
Built: Fri Mar 13 06:46:11 2020
OS/Arch: linux/amd64
Experimental: false
Server:
Engine:
Version: 18.09.8
API version: 1.39 (minimum version 1.12)
Go version: go1.11
Git commit: 3a371f3
Built: Fri Mar 13 06:44:35 2020
OS/Arch: linux/amd64
Experimental: false
```
To solve this, you need to specify the API version in your docker or swarm provider (`apiVersion`) displayed in the error message. In this case:
```yaml
providers:
docker:
apiVersion: 1.39
```
## field docker|swarm uses unsupported type: invalid
If you have the error `failed to decode configuration from file: field docker uses unsupported type: invalid` that's because your `docker` or `swarm` provider is not initialized in your configuration:
```yaml
providers:
docker:
```
should be:
```yaml
providers:
docker: {}
```
## No image found in manifest list for architecture [], variant [], OS [] ## No image found in manifest list for architecture [], variant [], OS []
If you encounter this kind of error, you are probably using the [file provider](providers/file.md) containing an image with an erroneous or empty platform. If the platform is not filled in, it will be deduced automatically from the information of your operating system on which Diun is running. If you encounter this kind of error, you are probably using the [file provider](providers/file.md) containing an image with an erroneous or empty platform. If the platform is not filled in, it will be deduced automatically from the information of your operating system on which Diun is running.

95
doc/getting-started.md Normal file
View File

@@ -0,0 +1,95 @@
# Getting started
* [Diun CLI](#diun-cli)
* [Run with the Docker provider](#run-with-the-docker-provider)
## Diun CLI
```
$ ./diun --help
Usage: diun
Docker image update notifier. More info: https://github.com/crazy-max/diun
Flags:
--help Show context-sensitive help.
--version
--config=STRING Diun configuration file ($CONFIG).
--timezone="UTC" Timezone assigned to Diun ($TZ).
--log-level="info" Set log level ($LOG_LEVEL).
--log-json Enable JSON logging output ($LOG_JSON).
--log-caller Add file:line of the caller to log output ($LOG_CALLER).
--test-notif Test notification settings.
```
Following environment variables can be used in place of flags:
* `CONFIG`: Diun configuration file
* `TZ`: Timezone assigned (default `UTC`)
* `LOG_LEVEL`: Log level output (default `info`)
* `LOG_JSON`: Enable JSON logging output (default `false`)
* `LOG_CALLER`: Enable to add `file:line` of the caller (default `false`)
## Run with the Docker provider
Create a `docker-compose.yml` file that uses the official Diun image:
```yaml
version: "3.5"
services:
diun:
image: crazymax/diun:latest
volumes:
- "./data:/data"
- "/var/run/docker.sock:/var/run/docker.sock"
environment:
- "TZ=Europe/Paris"
- "LOG_LEVEL=info"
- "LOG_JSON=false"
- "DIUN_WATCH_WORKERS=20"
- "DIUN_WATCH_SCHEDULE=*/30 * * * *"
- "DIUN_PROVIDERS_DOCKER=true"
- "DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT=true"
restart: always
```
Here we use a minimal configuration to analyze **all running containers** (watch by default enabled) of your **local Docker** instance **every 30 minutes**.
That's it. Now you can launch Diun with the following command:
```shell
$ docker-compose up -d
```
If you prefer to rely on the configuration file instead of environment variables:
```yaml
version: "3.5"
services:
diun:
image: crazymax/diun:latest
volumes:
- "./data:/data"
- "./diun.yml:/diun.yml:ro"
- "/var/run/docker.sock:/var/run/docker.sock"
environment:
- "CONFIG=/diun.yml"
- "TZ=Europe/Paris"
- "LOG_LEVEL=info"
- "LOG_JSON=false"
restart: always
```
```yaml
# ./diun.yml
watch:
workers: 20
schedule: "*/30 * * * *"
firstCheckNotif: false
providers:
docker: {}
```

View File

@@ -7,29 +7,10 @@ Diun binaries are available in [releases](https://github.com/crazy-max/diun/rele
Choose the archive matching the destination platform and extract diun: Choose the archive matching the destination platform and extract diun:
``` ```
wget -qO- https://github.com/crazy-max/diun/releases/download/v2.6.1/diun_2.6.1_linux_x86_64.tar.gz | tar -zxvf - diun wget -qO- https://github.com/crazy-max/diun/releases/download/v3.0.0/diun_3.0.0_linux_x86_64.tar.gz | tar -zxvf - diun
``` ```
## Test After getting the binary, it can be tested with [`./diun --help`](../getting-started.md#diun-cli) command and moved to a permanent location.
After getting the binary, it can be tested with `./diun --help` or moved to a permanent location.
```
$ ./diun --help
Usage: diun --config=STRING
Docker image update notifier. More info: https://github.com/crazy-max/diun
Flags:
--help Show context-sensitive help.
--version
--config=STRING Diun configuration file ($CONFIG).
--timezone="UTC" Timezone assigned to Diun ($TZ).
--log-level="info" Set log level ($LOG_LEVEL).
--log-json Enable JSON logging output ($LOG_JSON).
--log-caller Add file:line of the caller to log output ($LOG_CALLER).
--test-notif Test notification settings.
```
## Server configuration ## Server configuration
@@ -57,13 +38,15 @@ chmod 770 /etc/diun
### Configuration ### Configuration
You must create your first [configuration](../configuration.md) file in `/etc/diun/diun.yml` and type: Create your first [configuration](../configuration.md) file in `/etc/diun/diun.yml` and type:
``` ```
chown diun:diun /etc/diun/diun.yml chown diun:diun /etc/diun/diun.yml
chmod 644 /etc/diun/diun.yml chmod 644 /etc/diun/diun.yml
``` ```
> 💡 Not required if you want to only rely on environment variables
### Copy binary to global location ### Copy binary to global location
``` ```
@@ -81,7 +64,7 @@ See how to create [Linux service](linux-service.md) to start Diun automatically.
### 2. Running from command-line/terminal ### 2. Running from command-line/terminal
``` ```
DIUN_DB=/var/lib/diun/diun.db /usr/local/bin/diun --config /etc/diun/diun.yml DIUN_DB_PATH=/var/lib/diun/diun.db /usr/local/bin/diun --config /etc/diun/diun.yml
``` ```
## Updating to a new version ## Updating to a new version

View File

@@ -18,37 +18,32 @@ Image: crazymax/diun:latest
- linux/s390x - linux/s390x
``` ```
## Environment variables
* `TZ` : Timezone assigned
* `LOG_LEVEL` : Log level output (default `info`)
* `LOG_JSON`: Enable JSON logging output (default `false`)
* `LOG_CALLER`: Enable to add file:line of the caller (default `false`)
## Volumes ## Volumes
* `/data` : Contains bbolt database which retains Docker images manifests * `/data`: Contains bbolt database which retains Docker images manifests
> :warning: Note that the volume should be owned by uid `1000` and gid `1000`. If you don't give the volume correct permissions, the container may not start.
## Usage ## Usage
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: 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 file with your preferences and run the following commands:
``` ```
docker-compose up -d docker-compose up -d
docker-compose logs -f docker-compose logs -f
``` ```
Or use the following command : Or use the following command:
``` ```
$ docker run -d --name diun \ $ docker run -d --name diun \
-e "TZ=Europe/Paris" \ -e "TZ=Europe/Paris" \
-e "LOG_LEVEL=info" \ -e "LOG_LEVEL=info" \
-e "LOG_JSON=false" \ -e "LOG_JSON=false" \
-e "DIUN_WATCH_WORKERS=20" \
-e "DIUN_WATCH_SCHEDULE=*/30 * * * *" \
-e "DIUN_PROVIDERS_DOCKER=true" \
-e "DIUN_PROVIDERS_DOCKER_WATCHSTOPPED=true" \
-v "$(pwd)/data:/data" \ -v "$(pwd)/data:/data" \
-v "$(pwd)/diun.yml:/diun.yml:ro" \ -v "/var/run/docker.sock:/var/run/docker.sock" \
crazymax/diun:latest crazymax/diun:latest
``` ```

View File

@@ -12,18 +12,33 @@
## Amqp ## Amqp
You can send notifications to any amqp compatible server with the following settings: You can send notifications to any amqp compatible server with the following settings.
### Configuration file
* `amqp` * `amqp`
* `host`: AMQP server host (default: `localhost`). **required** * `host`: AMQP server host (default `localhost`). **required**
* `port`: AMQP server port (default: `5672`). **required** * `port`: AMQP server port (default `5672`). **required**
* `username`: AMQP username. **required** * `username`: AMQP username.
* `username_file`: Use content of secret file as AMQP username if `username` not defined. * `usernameFile`: Use content of secret file as AMQP username if `username` not defined.
* `password`: AMQP password. **required** * `password`: AMQP password.
* `password_file`: Use content of secret file as AMQP password if `password` not defined. * `passwordFile`: Use content of secret file as AMQP password if `password` not defined.
* `exchange`: Name of the exchange the message will be sent to. (default: `empty`) * `exchange`: Name of the exchange the message will be sent to.
* `queue`: Name of the queue the message will be sent to. **required** * `queue`: Name of the queue the message will be sent to. **required**
### Environment variables
* `DIUN_NOTIF_AMQP_HOST`
* `DIUN_NOTIF_AMQP_EXCHANGE`
* `DIUN_NOTIF_AMQP_PORT`
* `DIUN_NOTIF_AMQP_USERNAME`
* `DIUN_NOTIF_AMQP_USERNAMEFILE`
* `DIUN_NOTIF_AMQP_PASSWORD`
* `DIUN_NOTIF_AMQP_PASSWORDFILE`
* `DIUN_NOTIF_AMQP_QUEUE`
### Sample
The JSON response will look like this: The JSON response will look like this:
```json ```json
@@ -41,59 +56,92 @@ The JSON response will look like this:
## Gotify ## Gotify
Notifications can be sent using a [Gotify](https://gotify.net/) instance: Notifications can be sent using a [Gotify](https://gotify.net/) instance.
### Configuration file
* `gotify` * `gotify`
* `endpoint`: Gotify base URL (e.g. `http://gotify.foo.com`). **required** * `endpoint`: Gotify base URL (e.g. `http://gotify.foo.com`). **required**
* `token`: Application token. **required** * `token`: Application token. **required**
* `priority`: The priority of the message. * `priority`: The priority of the message (default `1`).
* `timeout`: Timeout specifies a time limit for the request to be made. (default: `10`). * `timeout`: Timeout specifies a time limit for the request to be made. (default `10s`).
### Environment variables
* `DIUN_NOTIF_GOTIFY_ENDPOINT`
* `DIUN_NOTIF_GOTIFY_TOKEN`
* `DIUN_NOTIF_GOTIFY_PRIORITY`
* `DIUN_NOTIF_GOTIFY_TIMEOUT`
### Sample
![](../.res/notif-gotify.png) ![](../.res/notif-gotify.png)
## Mail ## Mail
Notifications can be sent through SMTP: Notifications can be sent through SMTP.
### Configuration file
* `mail` * `mail`
* `host`: SMTP server host (default: `localhost`). **required** * `host`: SMTP server host. (default `localhost`) **required**
* `port`: SMTP server port (default: `25`). **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`). * `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 hostname (default: `false`). * `insecureSkipVerify`: Controls whether a client verifies the server's certificate chain and hostname. (default `false`)
* `username`: SMTP username. * `username`: SMTP username.
* `username_file`: Use content of secret file as SMTP username if `username` not defined. * `usernameFile`: Use content of secret file as SMTP username if `username` not defined.
* `password`: SMTP password. * `password`: SMTP password.
* `password_file`: Use content of secret file as SMTP password if `password` not defined. * `passwordFile`: Use content of secret file as SMTP password if `password` not defined.
* `from`: Sender email address. **required** * `from`: Sender email address. **required**
* `to`: Recipient email address. **required** * `to`: Recipient email address. **required**
### Environment variables
* `DIUN_NOTIF_MAIL_HOST`
* `DIUN_NOTIF_MAIL_PORT`
* `DIUN_NOTIF_MAIL_SSL`
* `DIUN_NOTIF_MAIL_INSECURESKIPVERIFY`
* `DIUN_NOTIF_MAIL_USERNAME`
* `DIUN_NOTIF_MAIL_USERNAMEFILE`
* `DIUN_NOTIF_MAIL_PASSWORD`
* `DIUN_NOTIF_MAIL_PASSWORDFILE`
* `DIUN_NOTIF_MAIL_FROM`
* `DIUN_NOTIF_MAIL_TO`
### Sample
![](../.res/notif-mail.png) ![](../.res/notif-mail.png)
## Rocket.Chat ## Rocket.Chat
To be able to send notifications to your Rocket.Chat channel: Allow to send notifications to your Rocket.Chat channel.
> You must first create a _Personal Access Token_ through your account settings on your RocketChat instance. > You must first create a _Personal Access Token_ through your account settings on your RocketChat instance.
### Configuration file
* `rocketchat` * `rocketchat`
* `endpoint`: Rocket.Chat base URL (e.g. `http://rocket.foo.com:3000`). **required** * `endpoint`: Rocket.Chat base URL (e.g. `http://rocket.foo.com:3000`). **required**
* `channel`: Channel name with the prefix in front of it. **required** * `channel`: Channel name with the prefix in front of it. **required**
* `user_id`: User ID. **required** * `userID`: User ID. **required**
* `token`: Authentication token. **required** * `token`: Authentication token. **required**
* `timeout`: Timeout specifies a time limit for the request to be made. (default: `10`). * `timeout`: Timeout specifies a time limit for the request to be made. (default `10s`).
### Environment variables
* `DIUN_NOTIF_ROCKETCHAT_ENDPOINT`
* `DIUN_NOTIF_ROCKETCHAT_CHANNEL`
* `DIUN_NOTIF_ROCKETCHAT_USERID`
* `DIUN_NOTIF_ROCKETCHAT_TOKEN`
* `DIUN_NOTIF_ROCKETCHAT_TIMEOUT`
### Sample
![](../.res/notif-rocketchat.png) ![](../.res/notif-rocketchat.png)
## Script ## Script
You can send script notifications with the following settings: You can call a script when a notification occured. Following environment variables will be passed:
* `script`
* `cmd`: Command or script to execute. **required**
* `args`: List of args to pass to `cmd`.
* `dir`: Specifies the working directory of the command.
Following environment variables are passed to the process and will look like this:
``` ```
DIUN_VERSION=3.0.0 DIUN_VERSION=3.0.0
@@ -106,12 +154,33 @@ DIUN_ENTRY_CREATED=2020-03-26 12:23:56 +0000 UTC
DIUN_ENTRY_PLATFORM=linux/adm64 DIUN_ENTRY_PLATFORM=linux/adm64
``` ```
### Configuration file
* `script`
* `cmd`: Command or script to execute. **required**
* `args`: List of args to pass to `cmd`.
* `dir`: Specifies the working directory of the command.
### Environment variables
* `DIUN_NOTIF_SCRIPT_CMD`
* `DIUN_NOTIF_SCRIPT_ARGS`
* `DIUN_NOTIF_SCRIPT_DIR`
## Slack / Mattermost ## Slack / Mattermost
You can send notifications to your Slack channel using an [incoming webhook URL](https://api.slack.com/messaging/webhooks): You can send notifications to your Slack channel using an [incoming webhook URL](https://api.slack.com/messaging/webhooks).
### Configuration file
* `slack` * `slack`
* `webhook_url`: Slack [incoming webhook URL](https://api.slack.com/messaging/webhooks). **required** * `webhookURL`: Slack [incoming webhook URL](https://api.slack.com/messaging/webhooks). **required**
### Environment variables
* `DIUN_NOTIF_SLACK_WEBHOOKURL`
### Sample
![](../.res/notif-slack.png) ![](../.res/notif-slack.png)
@@ -119,10 +188,18 @@ Mattermost webhooks are compatible with Slack notification without any special c
## Teams ## Teams
You can send notifications to your Teams team-channel using an [incoming webhook URL](https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/what-are-webhooks-and-connectors): You can send notifications to your Teams team-channel using an [incoming webhook URL](https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/what-are-webhooks-and-connectors).
* `teams` ### Configuration file
* `webhook_url`: Teams [incoming webhook URL](https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/what-are-webhooks-and-connectors). **required**
* `teams`
* `webhookURL`: Teams [incoming webhook URL](https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/what-are-webhooks-and-connectors). **required**
### Environment variables
* `DIUN_NOTIF_TEAMS_WEBHOOKURL`
### Sample
![](../.res/notif-teams.png) ![](../.res/notif-teams.png)
@@ -135,21 +212,41 @@ Follow the [instructions](https://core.telegram.org/bots#6-botfather) to set up
Message the [GetID bot](https://t.me/getidsbot) to find your chat ID. Message the [GetID bot](https://t.me/getidsbot) to find your chat ID.
Multiple chat IDs can be provided in order to deliver notifications to multiple recipients. Multiple chat IDs can be provided in order to deliver notifications to multiple recipients.
### Configuration file
* `telegram` * `telegram`
* `token`: Telegram bot token. **required** * `token`: Telegram bot token. **required**
* `chat_ids`: List of chat IDs to send notifications to. **required** * `chatIDs`: List of chat IDs to send notifications to. **required**
### Environment variables
* `DIUN_NOTIF_TELEGRAM_TOKEN`
* `DIUN_NOTIF_TELEGRAM_CHATIDS` (comma separated)
### Sample
![](../.res/notif-telegram.png) ![](../.res/notif-telegram.png)
## Webhook ## Webhook
You can send webhook notifications with the following settings: You can send webhook notifications with the following settings.
### Configuration file
* `webhook` * `webhook`
* `endpoint`: URL of the HTTP request. **required** * `endpoint`: URL of the HTTP request. **required**
* `method`: HTTP method (default: `GET`). **required** * `method`: HTTP method (default `GET`). **required**
* `headers`: Map of additional headers to be sent. * `headers`: Map of additional headers to be sent (key is case insensitive).
* `timeout`: Timeout specifies a time limit for the request to be made. (default: `10`). * `timeout`: Timeout specifies a time limit for the request to be made. (default `10s`)
### Environment variables
* `DIUN_NOTIF_WEBHOOK_ENDPOINT`
* `DIUN_NOTIF_WEBHOOK_METHOD`
* `DIUN_NOTIF_WEBHOOK_HEADERS_<KEY>`
* `DIUN_NOTIF_WEBHOOK_TIMEOUT`
### Sample
The JSON response will look like this: The JSON response will look like this:

View File

@@ -3,6 +3,8 @@
* [About](#about) * [About](#about)
* [Quick start](#quick-start) * [Quick start](#quick-start)
* [Provider configuration](#provider-configuration) * [Provider configuration](#provider-configuration)
* [Configuration file](#configuration-file)
* [Environment variables](#environment-variables)
* [Docker labels](#docker-labels) * [Docker labels](#docker-labels)
## About ## About
@@ -13,18 +15,6 @@ The Docker provider allows you to analyze the containers of your Docker instance
In this section we quickly go over a basic docker-compose file using your local docker provider. In this section we quickly go over a basic docker-compose file using your local docker provider.
First of all, let's create a Diun configuration we named `diun.yml`:
```yaml
watch:
workers: 20
schedule: "*/30 * * * *"
providers:
docker:
watch_stopped: true
```
Here we use a single Docker provider with a minimum configuration to analyze labeled containers (watch by default disabled), even stopped ones, of your local Docker instance. Here we use a single Docker provider with a minimum configuration to analyze labeled containers (watch by default disabled), even stopped ones, of your local Docker instance.
Now let's create a simple docker-compose file with Diun and some simple services: Now let's create a simple docker-compose file with Diun and some simple services:
@@ -37,12 +27,15 @@ services:
image: crazymax/diun:latest image: crazymax/diun:latest
volumes: volumes:
- "./data:/data" - "./data:/data"
- "./diun.yml:/diun.yml:ro"
- "/var/run/docker.sock:/var/run/docker.sock" - "/var/run/docker.sock:/var/run/docker.sock"
environment: environment:
- "TZ=Europe/Paris" - "TZ=Europe/Paris"
- "LOG_LEVEL=info" - "LOG_LEVEL=info"
- "LOG_JSON=false" - "LOG_JSON=false"
- "DIUN_WATCH_WORKERS=20"
- "DIUN_WATCH_SCHEDULE=*/30 * * * *"
- "DIUN_PROVIDERS_DOCKER=true"
- "DIUN_PROVIDERS_DOCKER_WATCHSTOPPED=true"
restart: always restart: always
cloudflared: cloudflared:
@@ -90,20 +83,32 @@ diun_1 | Sat, 14 Dec 2019 15:30:13 CET INF Next run in 29 minutes (2019-
## Provider configuration ## Provider configuration
### Configuration file
* `endpoint`: Server address to connect to. Local if empty. * `endpoint`: Server address to connect to. Local if empty.
* `api_version`: Overrides the client version with the specified one. * `apiVersion`: Overrides the client version with the specified one.
* `tls_certs_path`: Path to load the TLS certificates from. * `tlsCertsPath`: Path to load the TLS certificates from.
* `tls_verify`: Controls whether client verifies the server's certificate chain and hostname (default: `true`). * `tlsVerify`: Controls whether client verifies the server's certificate chain and hostname (default `true`).
* `watch_by_default`: Enable watch by default. If false, containers that don't have `diun.enable=true` label will be ignored (default: `false`). * `watchByDefault`: Enable watch by default. If false, containers that don't have `diun.enable=true` label will be ignored (default `false`).
* `watch_stopped`: Include created and exited containers too (default: `false`). * `watchStopped`: Include created and exited containers too (default `false`).
### Environment variables
* `DIUN_PROVIDERS_DOCKER`
* `DIUN_PROVIDERS_DOCKER_ENDPOINT`
* `DIUN_PROVIDERS_DOCKER_APIVERSION`
* `DIUN_PROVIDERS_DOCKER_TLSCERTSPATH`
* `DIUN_PROVIDERS_DOCKER_TLSVERIFY`
* `DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT`
* `DIUN_PROVIDERS_DOCKER_WATCHSTOPPED`
## Docker labels ## Docker labels
You can configure more finely the way to analyze the image of your container through Docker labels: You can configure more finely the way to analyze the image of your container through Docker labels:
* `diun.enable`: Set to true to enable image analysis of this container. Required if `watch_by_default` is disabled for this provider. * `diun.enable`: Set to true to enable image analysis of this container.
* `diun.regopts_id`: Registry options ID from [`regopts`](../configuration.md#regopts) to use. * `diun.regopts_id`: Registry options ID from [`regopts`](../configuration.md#regopts) to use.
* `diun.watch_repo`: Watch all tags of this container image (default: `false`). * `diun.watch_repo`: Watch all tags of this container image (default `false`).
* `diun.max_tags`: Maximum number of tags to watch if `diun.watch_repo` enabled. 0 means all of them (default: `0`). * `diun.max_tags`: Maximum number of tags to watch if `diun.watch_repo` enabled. 0 means all of them (default `0`).
* `diun.include_tags`: Semi-colon separated list of regular expressions to include tags. Can be useful if you enable `diun.watch_repo`. * `diun.include_tags`: Semi-colon separated list of regular expressions to include tags. Can be useful if you enable `diun.watch_repo`.
* `diun.exclude_tags`: Semi-colon separated list of regular expressions to exclude tags. Can be useful if you enable `diun.watch_repo`. * `diun.exclude_tags`: Semi-colon separated list of regular expressions to exclude tags. Can be useful if you enable `diun.watch_repo`.

View File

@@ -4,8 +4,8 @@
* [Example](#example) * [Example](#example)
* [Quick start](#quick-start) * [Quick start](#quick-start)
* [Provider configuration](#provider-configuration) * [Provider configuration](#provider-configuration)
* [filename](#filename) * [Configuration file](#configuration-file)
* [directory](#directory) * [Environment variables](#environment-variables)
* [YAML configuration file](#yaml-configuration-file) * [YAML configuration file](#yaml-configuration-file)
## About ## About
@@ -32,7 +32,7 @@ regopts:
onemore: onemore:
username: foo2 username: foo2
password: bar2 password: bar2
insecure_tls: true insecureTls: true
providers: providers:
file: file:
@@ -131,7 +131,9 @@ Sat, 14 Dec 2019 15:32:28 UTC INF Next run in 31 seconds (2019-12-14 15:33:00 +0
## Provider configuration ## Provider configuration
### filename ### Configuration file
#### filename
Defines the path to the [configuration file](#yaml-configuration-file). Defines the path to the [configuration file](#yaml-configuration-file).
@@ -143,7 +145,7 @@ providers:
filename: /path/to/config/conf.yml filename: /path/to/config/conf.yml
``` ```
### directory #### directory
Defines the path to the directory that contains the [configuration files](#yaml-configuration-file) (`*.yml` or `*.yaml`). Defines the path to the directory that contains the [configuration files](#yaml-configuration-file) (`*.yml` or `*.yaml`).
@@ -155,14 +157,19 @@ providers:
directory: /path/to/config directory: /path/to/config
``` ```
### Environment variables
* `DIUN_PROVIDERS_FILE_DIRECTORY`
* `DIUN_PROVIDERS_FILE_FILENAME`
## YAML configuration file ## YAML configuration file
The configuration file(s) defines a slice of images to analyze with the following fields: The configuration file(s) defines a slice of images to analyze with the following fields:
* `name`: Docker image name to watch using `registry/path:tag` format. If registry omitted, `docker.io` will be used and if tag omitted, `latest` will be used. **required** * `name`: Docker image name to watch using `registry/path:tag` format. If registry omitted, `docker.io` will be used and if tag omitted, `latest` will be used. **required**
* `regopts_id`: Registry options ID from [`regopts`](../configuration.md#regopts) to use. * `regopts_id`: Registry options ID from [`regopts`](../configuration.md#regopts) to use.
* `watch_repo`: Watch all tags of this `image` repository (default: `false`). * `watch_repo`: Watch all tags of this `image` repository (default `false`).
* `max_tags`: Maximum number of tags to watch if `watch_repo` enabled. 0 means all of them (default: `0`). * `max_tags`: Maximum number of tags to watch if `watch_repo` enabled. 0 means all of them (default `0`).
* `include_tags`: List of regular expressions to include tags. Can be useful if you enable `watch_repo`. * `include_tags`: List of regular expressions to include tags. Can be useful if you enable `watch_repo`.
* `exclude_tags`: List of regular expressions to exclude tags. Can be useful if you enable `watch_repo`. * `exclude_tags`: List of regular expressions to exclude tags. Can be useful if you enable `watch_repo`.
* `platform`: Check a custom platform. (default will retrieve platform dynamically based on your operating system). * `platform`: Check a custom platform. (default will retrieve platform dynamically based on your operating system).

View File

@@ -3,6 +3,8 @@
* [About](#about) * [About](#about)
* [Quick start](#quick-start) * [Quick start](#quick-start)
* [Provider configuration](#provider-configuration) * [Provider configuration](#provider-configuration)
* [Configuration file](#configuration-file)
* [Environment variables](#environment-variables)
* [Docker labels](#docker-labels) * [Docker labels](#docker-labels)
## About ## About
@@ -13,17 +15,6 @@ The Swarm provider allows you to analyze the services of your Swarm cluster to e
In this section we quickly go over a basic stack using your local swarm cluster. In this section we quickly go over a basic stack using your local swarm cluster.
First of all, let's create a Diun configuration we named `diun.yml`:
```yaml
watch:
workers: 20
schedule: "*/30 * * * *"
providers:
swarm:
```
Here we use our local Swarm provider with a minimum configuration to analyze labeled containers (watch by default disabled). Here we use our local Swarm provider with a minimum configuration to analyze labeled containers (watch by default disabled).
Now let's create a simple stack for Diun: Now let's create a simple stack for Diun:
@@ -36,12 +27,14 @@ services:
image: crazymax/diun:latest image: crazymax/diun:latest
volumes: volumes:
- "./data:/data" - "./data:/data"
- "./diun.yml:/diun.yml:ro"
- "/var/run/docker.sock:/var/run/docker.sock" - "/var/run/docker.sock:/var/run/docker.sock"
environment: environment:
- "TZ=Europe/Paris" - "TZ=Europe/Paris"
- "LOG_LEVEL=info" - "LOG_LEVEL=info"
- "LOG_JSON=false" - "LOG_JSON=false"
- "DIUN_WATCH_WORKERS=20"
- "DIUN_WATCH_SCHEDULE=*/30 * * * *"
- "DIUN_PROVIDERS_SWARM=true"
deploy: deploy:
placement: placement:
constraints: constraints:
@@ -75,7 +68,7 @@ docker stack deploy -c diun.yml diun
docker stack deploy -c nginx.yml nginx docker stack deploy -c nginx.yml nginx
``` ```
And watch logs of Diun service: Now take a look at the logs:
``` ```
$ docker service logs -f diun_diun $ docker service logs -f diun_diun
@@ -105,19 +98,30 @@ diun_diun.1.i1l4yuiafq6y@docker-desktop | Sat, 14 Dec 2019 16:20:02 CET INF N
## Provider configuration ## Provider configuration
### Configuration file
* `endpoint`: Server address to connect to. Local if empty. * `endpoint`: Server address to connect to. Local if empty.
* `api_version`: Overrides the client version with the specified one. * `apiVersion`: Overrides the client version with the specified one.
* `tls_certs_path`: Path to load the TLS certificates from. * `TLSCertsPath`: Path to load the TLS certificates from.
* `tls_verify`: Controls whether client verifies the server's certificate chain and hostname (default: `true`). * `TLSVerify`: Controls whether client verifies the server's certificate chain and hostname (default `true`).
* `watch_by_default`: Enable watch by default. If false, services that don't have `diun.enable=true` label will be ignored (default: `false`). * `watchByDefault`: Enable watch by default. If false, services that don't have `diun.enable=true` label will be ignored (default `false`).
### Environment variables
* `DIUN_PROVIDERS_SWARM`
* `DIUN_PROVIDERS_SWARM_ENDPOINT`
* `DIUN_PROVIDERS_SWARM_APIVERSION`
* `DIUN_PROVIDERS_SWARM_TLSCERTSPATH`
* `DIUN_PROVIDERS_SWARM_TLSVERIFY`
* `DIUN_PROVIDERS_SWARM_WATCHBYDEFAULT`
## Docker labels ## Docker labels
You can configure more finely the way to analyze the image of your service through Docker labels: You can configure more finely the way to analyze the image of your service through Docker labels:
* `diun.enable`: Set to true to enable image analysis of this container. Required if `watch_by_default` is disabled for this provider. * `diun.enable`: Set to true to enable image analysis of this container.
* `diun.regopts_id`: Registry options ID from [`regopts`](../configuration.md#regopts) to use. * `diun.regopts_id`: Registry options ID from [`regopts`](../configuration.md#regopts) to use.
* `diun.watch_repo`: Watch all tags of this container image (default: `false`). * `diun.watch_repo`: Watch all tags of this container image (default `false`).
* `diun.max_tags`: Maximum number of tags to watch if `diun.watch_repo` enabled. 0 means all of them (default: `0`). * `diun.max_tags`: Maximum number of tags to watch if `diun.watch_repo` enabled. 0 means all of them (default `0`).
* `diun.include_tags`: Semi-colon separated list of regular expressions to include tags. Can be useful if you enable `diun.watch_repo`. * `diun.include_tags`: Semi-colon separated list of regular expressions to include tags. Can be useful if you enable `diun.watch_repo`.
* `diun.exclude_tags`: Semi-colon separated list of regular expressions to exclude tags. Can be useful if you enable `diun.watch_repo`. * `diun.exclude_tags`: Semi-colon separated list of regular expressions to exclude tags. Can be useful if you enable `diun.watch_repo`.

View File

@@ -1,14 +0,0 @@
# Usage
## Command line
`diun --config=STRING`
* `--help`: Show help text and exit.
* `--version`: Show version and exit.
* `--config <path>`: Diun YAML configuration file. **Required**. (e.g. `diun.yml`).
* `--timezone <timezone>`: Timezone assigned to Diun. (default `UTC`).
* `--log-level <level>`: Log level output. (default `info`).
* `--log-json`: Enable JSON logging output. (default `false`).
* `--log-caller`: Add file:line of the caller to log output. (default `false`).
* `--test-notif`: Enable to test notification settings.

1
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/docker/docker v1.4.2-0.20191219165747-a9416c67da9f github.com/docker/docker v1.4.2-0.20191219165747-a9416c67da9f
github.com/docker/go-connections v0.4.0 github.com/docker/go-connections v0.4.0
github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df
github.com/go-playground/validator/v10 v10.3.0
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/hako/durafmt v0.0.0-20190612201238-650ed9f29a84 github.com/hako/durafmt v0.0.0-20190612201238-650ed9f29a84
github.com/imdario/mergo v0.3.9 github.com/imdario/mergo v0.3.9

10
go.sum
View File

@@ -73,6 +73,14 @@ github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df/go.mod h1:GJr+FCS
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 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.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.3.0 h1:nZU+7q+yJoFmwvNgv/LnPUkwPal62+b2xXj0AU1Es7o=
github.com/go-playground/validator/v10 v10.3.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
@@ -133,6 +141,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/matcornic/hermes/v2 v2.1.0 h1:9TDYFBPFv6mcXanaDmRDEp/RTWj0dTTi+LpFnnnfNWc= github.com/matcornic/hermes/v2 v2.1.0 h1:9TDYFBPFv6mcXanaDmRDEp/RTWj0dTTi+LpFnnnfNWc=
github.com/matcornic/hermes/v2 v2.1.0/go.mod h1:2+ziJeoyRfaLiATIL8VZ7f9hpzH4oDHqTmn0bhrsgVI= github.com/matcornic/hermes/v2 v2.1.0/go.mod h1:2+ziJeoyRfaLiATIL8VZ7f9hpzH4oDHqTmn0bhrsgVI=
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4= github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=

View File

@@ -1,9 +1,6 @@
package app package app
import ( import (
"fmt"
"runtime"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -25,42 +22,39 @@ import (
// Diun represents an active diun object // Diun represents an active diun object
type Diun struct { type Diun struct {
cfg *config.Config meta model.Meta
cron *cron.Cron cfg *config.Config
db *db.Client cron *cron.Cron
notif *notif.Client db *db.Client
userAgent string notif *notif.Client
jobID cron.EntryID jobID cron.EntryID
locker uint32 locker uint32
pool *ants.PoolWithFunc pool *ants.PoolWithFunc
wg *sync.WaitGroup wg *sync.WaitGroup
} }
// New creates new diun instance // New creates new diun instance
func New(cfg *config.Config, location *time.Location) (*Diun, error) { func New(meta model.Meta, cfg *config.Config, location *time.Location) (*Diun, error) {
// DB client // DB client
dbcli, err := db.New(cfg.Db) dbcli, err := db.New(*cfg.Db)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// User-Agent
userAgent := fmt.Sprintf("diun/%s go/%s %s", cfg.App.Version, runtime.Version()[2:], strings.Title(runtime.GOOS))
// Notification client // Notification client
notifcli, err := notif.New(cfg.Notif, cfg.App, userAgent) notifcli, err := notif.New(cfg.Notif, meta)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Diun{ return &Diun{
cfg: cfg, meta: meta,
cfg: cfg,
cron: cron.New(cron.WithLocation(location), cron.WithParser(cron.NewParser( cron: cron.New(cron.WithLocation(location), cron.WithParser(cron.NewParser(
cron.SecondOptional|cron.Minute|cron.Hour|cron.Dom|cron.Month|cron.Dow|cron.Descriptor), cron.SecondOptional|cron.Minute|cron.Hour|cron.Dom|cron.Month|cron.Dow|cron.Descriptor),
)), )),
db: dbcli, db: dbcli,
notif: notifcli, notif: notifcli,
userAgent: userAgent,
}, nil }, nil
} }

View File

@@ -3,7 +3,6 @@ package app
import ( import (
"fmt" "fmt"
"regexp" "regexp"
"time"
"github.com/crazy-max/diun/v3/internal/model" "github.com/crazy-max/diun/v3/internal/model"
"github.com/crazy-max/diun/v3/pkg/registry" "github.com/crazy-max/diun/v3/pkg/registry"
@@ -35,7 +34,7 @@ func (di *Diun) createJob(job model.Job) {
} }
// Registry options // Registry options
regOpts, err := di.getRegOpts(job.Image.RegOptsID) regOpts, err := di.cfg.GetRegOpts(job.Image.RegOptsID)
if err != nil { if err != nil {
sublog.Warn().Err(err).Msg("Registry options") sublog.Warn().Err(err).Msg("Registry options")
} }
@@ -76,9 +75,9 @@ func (di *Diun) createJob(job model.Job) {
job.Registry, err = registry.New(registry.Options{ job.Registry, err = registry.New(registry.Options{
Username: regUser, Username: regUser,
Password: regPassword, Password: regPassword,
Timeout: time.Duration(regOpts.Timeout) * time.Second, Timeout: *regOpts.Timeout,
InsecureTLS: regOpts.InsecureTLS, InsecureTLS: *regOpts.InsecureTLS,
UserAgent: di.userAgent, UserAgent: di.meta.UserAgent,
ImageOs: job.Image.Platform.Os, ImageOs: job.Image.Platform.Os,
ImageArch: job.Image.Platform.Arch, ImageArch: job.Image.Platform.Arch,
ImageVariant: job.Image.Platform.Variant, ImageVariant: job.Image.Platform.Variant,
@@ -94,7 +93,7 @@ func (di *Diun) createJob(job model.Job) {
sublog.Error().Err(err).Msgf("Invoking job") sublog.Error().Err(err).Msgf("Invoking job")
} }
if !job.Image.WatchRepo || job.RegImage.Domain == "" { if !job.Image.WatchRepo || len(job.RegImage.Domain) == 0 {
return return
} }
@@ -157,7 +156,7 @@ func (di *Diun) runJob(job model.Job) error {
} }
status := model.ImageStatusUnchange status := model.ImageStatusUnchange
if dbManifest.Name == "" { if len(dbManifest.Name) == 0 {
status = model.ImageStatusNew status = model.ImageStatusNew
sublog.Info().Msg("New image found") sublog.Info().Msg("New image found")
} else if !liveManifest.Created.Equal(*dbManifest.Created) { } else if !liveManifest.Created.Equal(*dbManifest.Created) {
@@ -187,13 +186,3 @@ func (di *Diun) runJob(job model.Job) error {
return nil return nil
} }
func (di *Diun) getRegOpts(id string) (model.RegOpts, error) {
if id == "" {
return model.RegOpts{}, nil
}
if regopts, ok := di.cfg.RegOpts[id]; ok {
return regopts, nil
}
return model.RegOpts{}, fmt.Errorf("%s not found", id)
}

View File

@@ -3,92 +3,91 @@ package config
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil"
"os" "os"
"path"
"github.com/crazy-max/diun/v3/internal/model" "github.com/crazy-max/diun/v3/internal/model"
"github.com/crazy-max/diun/v3/pkg/utl" "github.com/crazy-max/diun/v3/third_party/traefik/config/env"
"github.com/crazy-max/diun/v3/third_party/traefik/config/file"
"github.com/go-playground/validator/v10"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/yaml.v2"
) )
// Config holds configuration details // Config holds configuration details
type Config struct { type Config struct {
Cli model.Cli Db *model.Db `yaml:"db,omitempty" json:"db,omitempty"`
App model.App Watch *model.Watch `yaml:"watch,omitempty" json:"watch,omitempty"`
Db model.Db `yaml:"db,omitempty"` Notif *model.Notif `yaml:"notif,omitempty" json:"notif,omitempty"`
Watch model.Watch `yaml:"watch,omitempty"` RegOpts map[string]*model.RegOpts `yaml:"regopts,omitempty" json:"regopts,omitempty" validate:"unique"`
Notif *model.Notif `yaml:"notif,omitempty"` Providers *model.Providers `yaml:"providers,omitempty" json:"providers,omitempty" validate:"required"`
RegOpts map[string]model.RegOpts `yaml:"regopts,omitempty"`
Providers *model.Providers `yaml:"providers,omitempty"`
} }
// Load returns Configuration struct // Load returns Configuration struct
func Load(cli model.Cli, version string) (*Config, error) { func Load(cfgfile string) (*Config, error) {
var err error cfg := Config{
var cfg = Config{ Db: (&model.Db{}).GetDefaults(),
Cli: cli, Watch: (&model.Watch{}).GetDefaults(),
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{
Workers: 10,
Schedule: "0 * * * *",
FirstCheckNotif: utl.NewFalse(),
},
} }
if _, err = os.Lstat(cli.Cfgfile); err != nil { if err := cfg.loadFile(cfgfile, &cfg); err != nil {
return nil, fmt.Errorf("unable to open config file, %s", err) return nil, err
} }
bytes, err := ioutil.ReadFile(cli.Cfgfile) if err := cfg.loadEnv(&cfg); err != nil {
if err != nil { return nil, err
return nil, fmt.Errorf("unable to read config file, %s", err)
} }
if err := yaml.UnmarshalStrict(bytes, &cfg); err != nil { validate := validator.New()
return nil, fmt.Errorf("unable to decode into struct, %v", err) if err := validate.Struct(&cfg); err != nil {
}
if err := cfg.validate(); err != nil {
return nil, err return nil, err
} }
return &cfg, nil return &cfg, nil
} }
func (cfg *Config) validate() error { func (cfg *Config) loadFile(cfgfile string, out interface{}) error {
cfg.Db.Path = utl.GetEnv("DIUN_DB", cfg.Db.Path) if len(cfgfile) == 0 {
if cfg.Db.Path == "" { return nil
return errors.New("database path is required")
} }
cfg.Db.Path = path.Clean(cfg.Db.Path)
if err := cfg.validateNotif(); err != nil { if _, err := os.Lstat(cfgfile); os.IsNotExist(err) {
return err return fmt.Errorf("config file %s not found", cfgfile)
} }
if err := cfg.validateRegopts(); err != nil {
return err if err := file.Decode(cfgfile, out); err != nil {
} return errors.Wrap(err, "failed to decode configuration from file")
if err := cfg.validateProviders(); err != nil {
return err
} }
return nil return nil
} }
// Display configuration in a pretty JSON format func (cfg *Config) loadEnv(out interface{}) error {
func (cfg *Config) Display() string { var envvars []string
for _, envvar := range env.FindPrefixedEnvVars(os.Environ(), "DIUN_", out) {
envvars = append(envvars, envvar)
}
if len(envvars) == 0 {
return nil
}
if err := env.Decode(envvars, "DIUN_", out); err != nil {
return errors.Wrap(err, "failed to decode configuration from environment variables")
}
return nil
}
func (cfg *Config) GetRegOpts(id string) (*model.RegOpts, error) {
if len(id) == 0 {
return (&model.RegOpts{}).GetDefaults(), nil
}
if regopts, ok := cfg.RegOpts[id]; ok {
return regopts, nil
}
return (&model.RegOpts{}).GetDefaults(), fmt.Errorf("%s not found", id)
}
// String returns the string representation of configuration
func (cfg *Config) String() string {
b, _ := json.MarshalIndent(cfg, "", " ") b, _ := json.MarshalIndent(cfg, "", " ")
return string(b) return string(b)
} }

View File

@@ -1,57 +1,46 @@
package config_test package config_test
import ( import (
"os"
"strings"
"testing" "testing"
"time"
"github.com/crazy-max/diun/v3/internal/config" "github.com/crazy-max/diun/v3/internal/config"
"github.com/crazy-max/diun/v3/internal/model" "github.com/crazy-max/diun/v3/internal/model"
"github.com/crazy-max/diun/v3/pkg/utl" "github.com/crazy-max/diun/v3/pkg/utl"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestLoad(t *testing.T) { func TestLoadFile(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
cli model.Cli cfgfile string
wantData *config.Config wantData *config.Config
wantErr bool wantErr bool
}{ }{
{ {
name: "Fail on non-existing file", name: "Failed on non-existing file",
cli: model.Cli{}, cfgfile: "",
wantErr: true, wantErr: true,
}, },
{ {
name: "Fail on wrong file format", name: "Fail on wrong file format",
cli: model.Cli{ cfgfile: "./fixtures/config.invalid.yml",
Cfgfile: "./test/config.invalid.yml",
},
wantErr: true, wantErr: true,
}, },
{ {
name: "Success", name: "Success",
cli: model.Cli{ cfgfile: "./fixtures/config.test.yml",
Cfgfile: "./test/config.test.yml",
},
wantData: &config.Config{ wantData: &config.Config{
Cli: model.Cli{ Db: &model.Db{
Cfgfile: "./test/config.test.yml",
},
App: model.App{
ID: "diun",
Name: "Diun",
Desc: "Docker image update notifier",
URL: "https://github.com/crazy-max/diun",
Author: "CrazyMax",
Version: "test",
},
Db: model.Db{
Path: "diun.db", Path: "diun.db",
}, },
Watch: model.Watch{ Watch: &model.Watch{
Workers: 100, Workers: 100,
Schedule: "*/30 * * * *", Schedule: "*/30 * * * *",
FirstCheckNotif: utl.NewFalse(), FirstCheckNotif: utl.NewTrue(),
}, },
Notif: &model.Notif{ Notif: &model.Notif{
Amqp: &model.NotifAmqp{ Amqp: &model.NotifAmqp{
@@ -65,7 +54,7 @@ func TestLoad(t *testing.T) {
Endpoint: "http://gotify.foo.com", Endpoint: "http://gotify.foo.com",
Token: "Token123456", Token: "Token123456",
Priority: 1, Priority: 1,
Timeout: 10, Timeout: utl.NewDuration(10 * time.Second),
}, },
Mail: &model.NotifMail{ Mail: &model.NotifMail{
Host: "localhost", Host: "localhost",
@@ -80,12 +69,12 @@ func TestLoad(t *testing.T) {
Channel: "#general", Channel: "#general",
UserID: "abcdEFGH012345678", UserID: "abcdEFGH012345678",
Token: "Token123456", Token: "Token123456",
Timeout: 10, Timeout: utl.NewDuration(10 * time.Second),
}, },
Script: &model.NotifScript{ Script: &model.NotifScript{
Cmd: "go", Cmd: "uname",
Args: []string{ Args: []string{
"version", "-a",
}, },
}, },
Slack: &model.NotifSlack{ Slack: &model.NotifSlack{
@@ -95,30 +84,35 @@ func TestLoad(t *testing.T) {
WebhookURL: "https://outlook.office.com/webhook/ABCD12EFG/HIJK34LMN/01234567890abcdefghij", WebhookURL: "https://outlook.office.com/webhook/ABCD12EFG/HIJK34LMN/01234567890abcdefghij",
}, },
Telegram: &model.NotifTelegram{ Telegram: &model.NotifTelegram{
BotToken: "abcdef123456", Token: "abcdef123456",
ChatIDs: []int64{8547439, 1234567}, ChatIDs: []int64{8547439, 1234567},
}, },
Webhook: &model.NotifWebhook{ Webhook: &model.NotifWebhook{
Endpoint: "http://webhook.foo.com/sd54qad89azd5a", Endpoint: "http://webhook.foo.com/sd54qad89azd5a",
Method: "GET", Method: "GET",
Headers: map[string]string{ Headers: map[string]string{
"Content-Type": "application/json", "content-type": "application/json",
"Authorization": "Token123456", "authorization": "Token123456",
}, },
Timeout: 10, Timeout: utl.NewDuration(10 * time.Second),
}, },
}, },
RegOpts: map[string]model.RegOpts{ RegOpts: map[string]*model.RegOpts{
"someregopts": { "someregopts": {
Timeout: 5, InsecureTLS: utl.NewFalse(),
Timeout: utl.NewDuration(5 * time.Second),
}, },
"bintrayoptions": { "bintrayoptions": {
Username: "foo", Username: "foo",
Password: "bar", Password: "bar",
InsecureTLS: utl.NewFalse(),
Timeout: utl.NewDuration(10 * time.Second),
}, },
"sensitive": { "sensitive": {
UsernameFile: "/run/secrets/username", UsernameFile: "/run/secrets/username",
PasswordFile: "/run/secrets/password", PasswordFile: "/run/secrets/password",
InsecureTLS: utl.NewFalse(),
Timeout: utl.NewDuration(10 * time.Second),
}, },
}, },
Providers: &model.Providers{ Providers: &model.Providers{
@@ -129,10 +123,10 @@ func TestLoad(t *testing.T) {
}, },
Swarm: &model.PrdSwarm{ Swarm: &model.PrdSwarm{
TLSVerify: utl.NewTrue(), TLSVerify: utl.NewTrue(),
WatchByDefault: utl.NewTrue(), WatchByDefault: utl.NewFalse(),
}, },
File: &model.PrdFile{ File: &model.PrdFile{
Filename: "./test/dummy.yml", Filename: "./fixtures/dummy.yml",
}, },
}, },
}, },
@@ -140,14 +134,341 @@ func TestLoad(t *testing.T) {
} }
for _, tt := range cases { for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
cfg, err := config.Load(tt.cli, "test") cfg, err := config.Load(tt.cfgfile)
if !tt.wantErr && err != nil { if tt.wantErr {
t.Error(err) require.Error(t, err)
return
} }
require.NoError(t, err)
assert.Equal(t, tt.wantData, cfg) assert.Equal(t, tt.wantData, cfg)
if !tt.wantErr && cfg != nil { if cfg != nil {
assert.NotEmpty(t, cfg.Display()) assert.NotEmpty(t, cfg.String())
} }
}) })
} }
} }
func TestLoadEnv(t *testing.T) {
defer UnsetEnv("DIUN_")
testCases := []struct {
desc string
cfgfile string
environ []string
expected interface{}
wantErr bool
}{
{
desc: "no env vars",
environ: nil,
expected: nil,
wantErr: true,
},
{
desc: "docker provider",
environ: []string{
"DIUN_PROVIDERS_DOCKER=true",
},
expected: &config.Config{
Db: (&model.Db{}).GetDefaults(),
Watch: (&model.Watch{}).GetDefaults(),
Notif: nil,
RegOpts: nil,
Providers: &model.Providers{
Docker: &model.PrdDocker{
TLSVerify: utl.NewTrue(),
WatchByDefault: utl.NewFalse(),
WatchStopped: utl.NewFalse(),
},
},
},
wantErr: false,
},
{
desc: "docker provider and regopts",
environ: []string{
"DIUN_REGOPTS_SENSITIVE_USERNAMEFILE=/run/secrets/username",
"DIUN_REGOPTS_SENSITIVE_PASSWORDFILE=/run/secrets/password",
"DIUN_REGOPTS_SENSITIVE_TIMEOUT=30s",
"DIUN_PROVIDERS_DOCKER=true",
},
expected: &config.Config{
Db: (&model.Db{}).GetDefaults(),
Watch: (&model.Watch{}).GetDefaults(),
RegOpts: map[string]*model.RegOpts{
"sensitive": {
UsernameFile: "/run/secrets/username",
PasswordFile: "/run/secrets/password",
InsecureTLS: utl.NewFalse(),
Timeout: utl.NewDuration(30 * time.Second),
},
},
Providers: &model.Providers{
Docker: &model.PrdDocker{
TLSVerify: utl.NewTrue(),
WatchByDefault: utl.NewFalse(),
WatchStopped: utl.NewFalse(),
},
},
},
wantErr: false,
},
{
desc: "swarm provider and notif telegram",
environ: []string{
"DIUN_NOTIF_TELEGRAM_TOKEN=abcdef123456",
"DIUN_NOTIF_TELEGRAM_CHATIDS=8547439,1234567",
"DIUN_PROVIDERS_SWARM=true",
},
expected: &config.Config{
Db: (&model.Db{}).GetDefaults(),
Watch: (&model.Watch{}).GetDefaults(),
Notif: &model.Notif{
Telegram: &model.NotifTelegram{
Token: "abcdef123456",
ChatIDs: []int64{8547439, 1234567},
},
},
Providers: &model.Providers{
Swarm: &model.PrdSwarm{
TLSVerify: utl.NewTrue(),
WatchByDefault: utl.NewFalse(),
},
},
},
wantErr: false,
},
{
desc: "file provider and notif script",
environ: []string{
"DIUN_NOTIF_SCRIPT_CMD=uname",
"DIUN_NOTIF_SCRIPT_ARGS=-a",
"DIUN_PROVIDERS_FILE_DIRECTORY=./fixtures",
},
expected: &config.Config{
Db: (&model.Db{}).GetDefaults(),
Watch: (&model.Watch{}).GetDefaults(),
Notif: &model.Notif{
Script: &model.NotifScript{
Cmd: "uname",
Args: []string{
"-a",
},
},
},
Providers: &model.Providers{
File: &model.PrdFile{
Directory: "./fixtures",
},
},
},
wantErr: false,
},
}
for _, tt := range testCases {
t.Run(tt.desc, func(t *testing.T) {
UnsetEnv("DIUN_")
if tt.environ != nil {
for _, environ := range tt.environ {
n := strings.SplitN(environ, "=", 2)
os.Setenv(n[0], n[1])
}
}
cfg, err := config.Load(tt.cfgfile)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, cfg)
})
}
}
func TestLoadMixed(t *testing.T) {
defer UnsetEnv("DIUN_")
testCases := []struct {
desc string
cfgfile string
environ []string
expected interface{}
wantErr bool
}{
{
desc: "env vars and invalid file",
cfgfile: "./fixtures/config.invalid.yml",
environ: []string{
"DIUN_PROVIDERS_DOCKER=true",
},
expected: nil,
wantErr: true,
},
{
desc: "docker provider (file) and notif mails (envs)",
cfgfile: "./fixtures/config.docker.yml",
environ: []string{
"DIUN_NOTIF_MAIL_HOST=127.0.0.1",
"DIUN_NOTIF_MAIL_PORT=25",
"DIUN_NOTIF_MAIL_SSL=false",
"DIUN_NOTIF_MAIL_INSECURESKIPVERIFY=true",
"DIUN_NOTIF_MAIL_FROM=diun@foo.com",
"DIUN_NOTIF_MAIL_TO=webmaster@foo.com",
},
expected: &config.Config{
Db: (&model.Db{}).GetDefaults(),
Watch: (&model.Watch{}).GetDefaults(),
Notif: &model.Notif{
Mail: &model.NotifMail{
Host: "127.0.0.1",
Port: 25,
SSL: utl.NewFalse(),
InsecureSkipVerify: utl.NewTrue(),
From: "diun@foo.com",
To: "webmaster@foo.com",
},
},
RegOpts: nil,
Providers: &model.Providers{
Docker: &model.PrdDocker{
TLSVerify: utl.NewTrue(),
WatchByDefault: utl.NewFalse(),
WatchStopped: utl.NewFalse(),
},
},
},
wantErr: false,
},
{
desc: "file provider, regopts (file) and notif webhook env override",
cfgfile: "./fixtures/config.file-regopts.yml",
environ: []string{
"DIUN_NOTIF_WEBHOOK_ENDPOINT=http://webhook.foo.com/sd54qad89azd5a",
"DIUN_NOTIF_WEBHOOK_HEADERS_AUTHORIZATION=Token78910",
"DIUN_NOTIF_WEBHOOK_HEADERS_CONTENT-TYPE=text/plain",
"DIUN_NOTIF_WEBHOOK_METHOD=GET",
"DIUN_NOTIF_WEBHOOK_TIMEOUT=1m",
},
expected: &config.Config{
Db: (&model.Db{}).GetDefaults(),
Watch: (&model.Watch{}).GetDefaults(),
Notif: &model.Notif{
Webhook: &model.NotifWebhook{
Endpoint: "http://webhook.foo.com/sd54qad89azd5a",
Method: "GET",
Headers: map[string]string{
"content-type": "text/plain",
"authorization": "Token78910",
},
Timeout: utl.NewDuration(1 * time.Minute),
},
},
RegOpts: nil,
Providers: &model.Providers{
File: &model.PrdFile{
Filename: "./fixtures/dummy.yml",
},
},
},
wantErr: false,
},
}
for _, tt := range testCases {
t.Run(tt.desc, func(t *testing.T) {
UnsetEnv("DIUN_")
if tt.environ != nil {
for _, environ := range tt.environ {
n := strings.SplitN(environ, "=", 2)
os.Setenv(n[0], n[1])
}
}
cfg, err := config.Load(tt.cfgfile)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, cfg)
})
}
}
func TestValidation(t *testing.T) {
cases := []struct {
name string
cfgfile string
}{
{
name: "Success",
cfgfile: "./fixtures/config.validate.yml",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
_, err := config.Load(tt.cfgfile)
require.NoError(t, err)
//dec, err := env.Encode(cfg)
//for _, value := range dec {
// fmt.Println(fmt.Sprintf(`%s=%s`, strings.Replace(value.Name, "TRAEFIK_", "DIUN_", 1), value.Default))
//}
})
}
}
func UnsetEnv(prefix string) (restore func()) {
before := map[string]string{}
for _, e := range os.Environ() {
if !strings.HasPrefix(e, prefix) {
continue
}
parts := strings.SplitN(e, "=", 2)
before[parts[0]] = parts[1]
os.Unsetenv(parts[0])
}
return func() {
after := map[string]string{}
for _, e := range os.Environ() {
if !strings.HasPrefix(e, prefix) {
continue
}
parts := strings.SplitN(e, "=", 2)
after[parts[0]] = parts[1]
// Check if the envar previously existed
v, ok := before[parts[0]]
if !ok {
// This is a newly added envar with prefix, zap it
os.Unsetenv(parts[0])
continue
}
if parts[1] != v {
// If the envar value has changed, set it back
os.Setenv(parts[0], v)
}
}
// Still need to check if there have been any deleted envars
for k, v := range before {
if _, ok := after[k]; !ok {
// k is not present in after, so we set it.
os.Setenv(k, v)
}
}
}
}

View File

@@ -0,0 +1,11 @@
notif:
mail:
host: localhost
port: 25
ssl: false
insecureSkipVerify: false
from: diun@example.com
to: webmaster@example.com
providers:
docker: {}

View File

@@ -0,0 +1,11 @@
notif:
webhook:
endpoint: http://webhook.foo.com/sd54qad89azd5a
headers:
content-type: application/json
authorization: Token123456
timeout: 20s
providers:
file:
filename: "./fixtures/dummy.yml"

View File

@@ -4,7 +4,7 @@ db:
watch: watch:
workers: 100 workers: 100
schedule: "*/30 * * * *" schedule: "*/30 * * * *"
first_check_notif: false firstCheckNotif: true
notif: notif:
amqp: amqp:
@@ -17,60 +17,55 @@ notif:
endpoint: http://gotify.foo.com endpoint: http://gotify.foo.com
token: Token123456 token: Token123456
priority: 1 priority: 1
timeout: 10 timeout: 10s
mail: mail:
host: localhost host: localhost
port: 25 port: 25
ssl: false ssl: false
insecure_skip_verify: false insecureSkipVerify: false
username:
username_file:
password:
password_file:
from: diun@example.com from: diun@example.com
to: webmaster@example.com to: webmaster@example.com
rocketchat: rocketchat:
endpoint: http://rocket.foo.com:3000 endpoint: http://rocket.foo.com:3000
channel: "#general" channel: "#general"
user_id: abcdEFGH012345678 userID: abcdEFGH012345678
token: Token123456 token: Token123456
timeout: 10 timeout: 10s
script: script:
cmd: "go" cmd: "uname"
args: args:
- "version" - "-a"
slack: slack:
webhook_url: https://hooks.slack.com/services/ABCD12EFG/HIJK34LMN/01234567890abcdefghij webhookURL: https://hooks.slack.com/services/ABCD12EFG/HIJK34LMN/01234567890abcdefghij
teams: teams:
webhook_url: https://outlook.office.com/webhook/ABCD12EFG/HIJK34LMN/01234567890abcdefghij webhookURL: https://outlook.office.com/webhook/ABCD12EFG/HIJK34LMN/01234567890abcdefghij
telegram: telegram:
token: abcdef123456 token: abcdef123456
chat_ids: chatIDs:
- 8547439 - 8547439
- 1234567 - 1234567
webhook: webhook:
endpoint: http://webhook.foo.com/sd54qad89azd5a endpoint: http://webhook.foo.com/sd54qad89azd5a
method: GET method: GET
headers: headers:
Content-Type: application/json content-type: application/json
Authorization: Token123456 authorization: Token123456
timeout: 10 timeout: 10s
regopts: regopts:
someregopts: someregopts:
timeout: 5 timeout: 5s
bintrayoptions: bintrayoptions:
username: foo username: foo
password: bar password: bar
sensitive: sensitive:
username_file: /run/secrets/username usernameFile: /run/secrets/username
password_file: /run/secrets/password passwordFile: /run/secrets/password
providers: providers:
docker: docker:
watch_by_default: true watchByDefault: true
watch_stopped: true watchStopped: true
swarm: swarm: {}
watch_by_default: true
file: file:
filename: ./test/dummy.yml filename: ./fixtures/dummy.yml

View File

@@ -0,0 +1,71 @@
db:
path: diun.db
watch:
workers: 100
schedule: "*/30 * * * *"
firstCheckNotif: false
notif:
amqp:
host: localhost
port: 5672
username: guest
password: guest
queue: queue
gotify:
endpoint: http://gotify.foo.com
token: Token123456
priority: 1
timeout: 10s
mail:
host: localhost
port: 25
ssl: false
insecureSkipVerify: false
from: diun@example.com
to: webmaster@example.com
rocketchat:
endpoint: http://rocket.foo.com:3000
channel: "#general"
userID: abcdEFGH012345678
token: Token123456
timeout: 10s
script:
cmd: "uname"
args:
- "-a"
slack:
webhookURL: https://hooks.slack.com/services/ABCD12EFG/HIJK34LMN/01234567890abcdefghij
teams:
webhookURL: https://outlook.office.com/webhook/ABCD12EFG/HIJK34LMN/01234567890abcdefghij
telegram:
token: abcdef123456
chatIDs:
- 8547439
- 1234567
webhook:
endpoint: http://webhook.foo.com/sd54qad89azd5a
method: GET
headers:
content-type: application/json
authorization: Token123456
timeout: 10s
regopts:
someregopts:
timeout: 5s
bintrayoptions:
username: foo
password: bar
sensitive:
usernameFile: /run/secrets/username
passwordFile: /run/secrets/password
providers:
docker:
watchByDefault: true
watchStopped: true
swarm: {}
file:
filename: ./fixtures/dummy.yml

View File

@@ -1,160 +0,0 @@
package config
import (
"net/mail"
"os/exec"
"github.com/crazy-max/diun/v3/internal/model"
"github.com/crazy-max/diun/v3/pkg/utl"
"github.com/imdario/mergo"
"github.com/pkg/errors"
)
func (cfg *Config) validateNotif() error {
if cfg.Notif == nil {
return nil
}
if err := cfg.validateNotifAmqp(); err != nil {
return err
}
if err := cfg.validateNotifGotify(); err != nil {
return err
}
if err := cfg.validateNotifMail(); err != nil {
return err
}
if err := cfg.validateNotifRocketChat(); err != nil {
return err
}
if err := cfg.validateNotifScript(); err != nil {
return err
}
if err := cfg.validateNotifSlack(); err != nil {
return err
}
if err := cfg.validateNotifTelegram(); err != nil {
return err
}
if err := cfg.validateNotifWebhook(); err != nil {
return err
}
return nil
}
func (cfg *Config) validateNotifAmqp() error {
if cfg.Notif.Amqp == nil {
return nil
}
if err := mergo.Merge(cfg.Notif.Amqp, model.NotifAmqp{
Host: "localhost",
Port: 5672,
}); err != nil {
return errors.Wrap(err, "cannot set default values for amqp notif")
}
return nil
}
func (cfg *Config) validateNotifGotify() error {
if cfg.Notif.Gotify == nil {
return nil
}
if err := mergo.Merge(cfg.Notif.Gotify, model.NotifGotify{
Timeout: 10,
}); err != nil {
return errors.Wrap(err, "cannot set default values for gotify notif")
}
return nil
}
func (cfg *Config) validateNotifMail() error {
if cfg.Notif.Mail == nil {
return nil
}
if _, err := mail.ParseAddress(cfg.Notif.Mail.From); err != nil {
return errors.Wrap(err, "cannot parse sender mail address")
}
if _, err := mail.ParseAddress(cfg.Notif.Mail.To); err != nil {
return errors.Wrap(err, "cannot parse recipient mail address")
}
if err := mergo.Merge(cfg.Notif.Mail, model.NotifMail{
Host: "localhost",
Port: 25,
SSL: utl.NewFalse(),
InsecureSkipVerify: utl.NewFalse(),
}); err != nil {
return errors.Wrap(err, "cannot set default values for mail notif")
}
return nil
}
func (cfg *Config) validateNotifRocketChat() error {
if cfg.Notif.RocketChat == nil {
return nil
}
if err := mergo.Merge(cfg.Notif.RocketChat, model.NotifRocketChat{
Timeout: 10,
}); err != nil {
return errors.Wrap(err, "cannot set default values for rocketchat notif")
}
return nil
}
func (cfg *Config) validateNotifScript() error {
if cfg.Notif.Script == nil {
return nil
}
if cfg.Notif.Script.Cmd == "" {
return errors.New("command required for script provider")
}
if _, err := exec.LookPath(cfg.Notif.Script.Cmd); err != nil {
return errors.Wrap(err, "command not found for script provider")
}
return nil
}
func (cfg *Config) validateNotifSlack() error {
if cfg.Notif.Slack == nil {
return nil
}
// noop
return nil
}
func (cfg *Config) validateNotifTelegram() error {
if cfg.Notif.Telegram == nil {
return nil
}
// noop
return nil
}
func (cfg *Config) validateNotifWebhook() error {
if cfg.Notif.Webhook == nil {
return nil
}
if err := mergo.Merge(cfg.Notif.Webhook, model.NotifWebhook{
Method: "GET",
Timeout: 10,
}); err != nil {
return errors.Wrap(err, "cannot set default values for webhook notif")
}
return nil
}

View File

@@ -1,80 +0,0 @@
package config
import (
"os"
"github.com/crazy-max/diun/v3/internal/model"
"github.com/crazy-max/diun/v3/pkg/utl"
"github.com/imdario/mergo"
"github.com/pkg/errors"
)
func (cfg *Config) validateProviders() error {
if cfg.Providers == nil || (cfg.Providers.Docker == nil && cfg.Providers.Swarm == nil && cfg.Providers.File == nil) {
return errors.New("At least one provider is required")
}
if err := cfg.validateProviderDocker(); err != nil {
return err
}
if err := cfg.validateProviderSwarm(); err != nil {
return err
}
if err := cfg.validateProviderFile(); err != nil {
return err
}
return nil
}
func (cfg *Config) validateProviderDocker() error {
if cfg.Providers.Docker == nil {
return nil
}
if err := mergo.Merge(cfg.Providers.Docker, model.PrdDocker{
TLSVerify: utl.NewTrue(),
WatchByDefault: utl.NewFalse(),
WatchStopped: utl.NewFalse(),
}); err != nil {
return errors.Wrap(err, "cannot set default values for docker provider")
}
return nil
}
func (cfg *Config) validateProviderSwarm() error {
if cfg.Providers.Swarm == nil {
return nil
}
if err := mergo.Merge(cfg.Providers.Swarm, model.PrdSwarm{
TLSVerify: utl.NewTrue(),
WatchByDefault: utl.NewFalse(),
}); err != nil {
return errors.Wrap(err, "cannot set default values for docker provider")
}
return nil
}
func (cfg *Config) validateProviderFile() error {
if cfg.Providers.File == nil {
return nil
}
switch {
case len(cfg.Providers.File.Directory) > 0:
if _, err := os.Stat(cfg.Providers.File.Directory); os.IsNotExist(err) {
return errors.Wrap(err, "directory not found for file provider")
}
case len(cfg.Providers.File.Filename) > 0:
if _, err := os.Stat(cfg.Providers.File.Filename); os.IsNotExist(err) {
return errors.Wrap(err, "filename not found for file provider")
}
default:
return errors.New("error using file provider, neither filename or directory defined")
}
return nil
}

View File

@@ -1,35 +0,0 @@
package config
import (
"fmt"
"github.com/crazy-max/diun/v3/internal/model"
"github.com/imdario/mergo"
)
func (cfg *Config) validateRegopts() error {
for id, regopt := range cfg.RegOpts {
if err := cfg.validateRegOpt(id, regopt); err != nil {
return err
}
}
return nil
}
func (cfg *Config) validateRegOpt(id string, regopts model.RegOpts) error {
defTimeout := 10
if regopts.Timeout <= 0 {
defTimeout = 0
}
if err := mergo.Merge(&regopts, model.RegOpts{
InsecureTLS: false,
Timeout: defTimeout,
}); err != nil {
return fmt.Errorf("cannot set default values for registry options %s: %v", id, err)
}
cfg.RegOpts[id] = regopts
return nil
}

View File

@@ -1,11 +0,0 @@
package model
// App holds application details
type App struct {
ID string
Name string
Desc string
URL string
Author string
Version string
}

View File

@@ -5,7 +5,7 @@ import "github.com/alecthomas/kong"
// Cli holds command line args, flags and cmds // Cli holds command line args, flags and cmds
type Cli struct { type Cli struct {
Version kong.VersionFlag Version kong.VersionFlag
Cfgfile string `kong:"required,name='config',env='CONFIG',help='Diun configuration file.'"` Cfgfile string `kong:"name='config',env='CONFIG',help='Diun configuration file.'"`
Timezone string `kong:"name='timezone',env='TZ',default='UTC',help='Timezone assigned to Diun.'"` Timezone string `kong:"name='timezone',env='TZ',default='UTC',help='Timezone assigned to Diun.'"`
LogLevel string `kong:"name='log-level',env='LOG_LEVEL',default='info',help='Set log level.'"` LogLevel string `kong:"name='log-level',env='LOG_LEVEL',default='info',help='Set log level.'"`
LogJSON bool `kong:"name='log-json',env='LOG_JSON',default='false',help='Enable JSON logging output.'"` LogJSON bool `kong:"name='log-json',env='LOG_JSON',default='false',help='Enable JSON logging output.'"`

View File

@@ -2,5 +2,17 @@ package model
// Db holds data necessary for database configuration // Db holds data necessary for database configuration
type Db struct { type Db struct {
Path string `yaml:"path,omitempty"` Path string `yaml:"path,omitempty" json:"path,omitempty" validate:"required"`
}
// GetDefaults gets the default values
func (s *Db) GetDefaults() *Db {
n := &Db{}
n.SetDefaults()
return n
}
// SetDefaults sets the default values
func (s *Db) SetDefaults() {
s.Path = "diun.db"
} }

13
internal/model/meta.go Normal file
View File

@@ -0,0 +1,13 @@
package model
// Meta holds application details
type Meta struct {
ID string
Name string
Desc string
URL string
Logo string
Author string
Version string
UserAgent string
}

View File

@@ -14,87 +14,23 @@ type NotifEntry struct {
// Notif holds data necessary for notification configuration // Notif holds data necessary for notification configuration
type Notif struct { type Notif struct {
Amqp *NotifAmqp `yaml:"amqp,omitempty"` Amqp *NotifAmqp `yaml:"amqp,omitempty" json:"amqp,omitempty"`
Gotify *NotifGotify `yaml:"gotify,omitempty"` Gotify *NotifGotify `yaml:"gotify,omitempty" json:"gotify,omitempty"`
Mail *NotifMail `yaml:"mail,omitempty"` Mail *NotifMail `yaml:"mail,omitempty" json:"mail,omitempty"`
RocketChat *NotifRocketChat `yaml:"rocketchat,omitempty"` RocketChat *NotifRocketChat `yaml:"rocketchat,omitempty" json:"rocketchat,omitempty"`
Script *NotifScript `yaml:"script,omitempty"` Script *NotifScript `yaml:"script,omitempty" json:"script,omitempty"`
Slack *NotifSlack `yaml:"slack,omitempty"` Slack *NotifSlack `yaml:"slack,omitempty" json:"slack,omitempty"`
Teams *NotifTeams `yaml:"teams,omitempty"` Teams *NotifTeams `yaml:"teams,omitempty" json:"teams,omitempty"`
Telegram *NotifTelegram `yaml:"telegram,omitempty"` Telegram *NotifTelegram `yaml:"telegram,omitempty" json:"telegram,omitempty"`
Webhook *NotifWebhook `yaml:"webhook,omitempty"` Webhook *NotifWebhook `yaml:"webhook,omitempty" json:"webhook,omitempty"`
} }
// NotifAmqp holds amqp notification configuration details // GetDefaults gets the default values
type NotifAmqp struct { func (s *Notif) GetDefaults() *Notif {
Username string `yaml:"username,omitempty"` return nil
UsernameFile string `yaml:"username_file,omitempty"`
Password string `yaml:"password,omitempty"`
PasswordFile string `yaml:"password_file,omitempty"`
Host string `yaml:"host,omitempty"`
Port int `yaml:"port,omitempty"`
Queue string `yaml:"queue,omitempty"`
Exchange string `yaml:"exchange,omitempty"`
} }
// NotifGotify holds gotify notification configuration details // SetDefaults sets the default values
type NotifGotify struct { func (s *Notif) SetDefaults() {
Endpoint string `yaml:"endpoint,omitempty"` // noop
Token string `yaml:"token,omitempty"`
Priority int `yaml:"priority,omitempty"`
Timeout int `yaml:"timeout,omitempty"`
}
// NotifMail holds mail notification configuration details
type NotifMail struct {
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"`
UsernameFile string `yaml:"username_file,omitempty"`
Password string `yaml:"password,omitempty"`
PasswordFile string `yaml:"password_file,omitempty"`
From string `yaml:"from,omitempty"`
To string `yaml:"to,omitempty"`
}
// NotifRocketChat holds Rocket.Chat notification configuration details
type NotifRocketChat struct {
Endpoint string `yaml:"endpoint,omitempty"`
Channel string `yaml:"channel,omitempty"`
UserID string `yaml:"user_id,omitempty"`
Token string `yaml:"token,omitempty"`
Timeout int `yaml:"timeout,omitempty"`
}
// NotifScript holds script notification configuration details
type NotifScript struct {
Cmd string `yaml:"cmd,omitempty"`
Args []string `yaml:"args,omitempty"`
Dir string `yaml:"dir,omitempty"`
}
// NotifSlack holds slack notification configuration details
type NotifSlack struct {
WebhookURL string `yaml:"webhook_url,omitempty"`
}
// NotifTeams holds Teams notification configuration details
type NotifTeams struct {
WebhookURL string `yaml:"webhook_url,omitempty"`
}
// NotifTelegram holds Telegram notification configuration details
type NotifTelegram struct {
BotToken string `yaml:"token,omitempty"`
ChatIDs []int64 `yaml:"chat_ids,omitempty"`
}
// NotifWebhook holds webhook notification configuration details
type NotifWebhook struct {
Endpoint string `yaml:"endpoint,omitempty"`
Method string `yaml:"method,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Timeout int `yaml:"timeout,omitempty"`
} }

View File

@@ -0,0 +1,26 @@
package model
// NotifAmqp holds amqp notification configuration details
type NotifAmqp struct {
Username string `yaml:"username,omitempty" json:"username,omitempty" validate:"omitempty"`
UsernameFile string `yaml:"usernameFile,omitempty" json:"usernameFile,omitempty" validate:"omitempty,file"`
Password string `yaml:"password,omitempty" json:"password,omitempty" validate:"omitempty"`
PasswordFile string `yaml:"passwordFile,omitempty" json:"passwordFile,omitempty" validate:"omitempty,file"`
Host string `yaml:"host,omitempty" json:"host,omitempty" validate:"required"`
Port int `yaml:"port,omitempty" json:"port,omitempty" validate:"required"`
Queue string `yaml:"queue,omitempty" json:"queue,omitempty" validate:"required"`
Exchange string `yaml:"exchange,omitempty" json:"exchange,omitempty" validate:"omitempty"`
}
// GetDefaults gets the default values
func (s *NotifAmqp) GetDefaults() *NotifAmqp {
n := &NotifAmqp{}
n.SetDefaults()
return n
}
// SetDefaults sets the default values
func (s *NotifAmqp) SetDefaults() {
s.Host = "localhost"
s.Port = 5672
}

View File

@@ -0,0 +1,28 @@
package model
import (
"time"
"github.com/crazy-max/diun/v3/pkg/utl"
)
// NotifGotify holds gotify notification configuration details
type NotifGotify struct {
Endpoint string `yaml:"endpoint,omitempty" json:"endpoint,omitempty" validate:"required"`
Token string `yaml:"token,omitempty" json:"token,omitempty" validate:"required"`
Priority int `yaml:"priority,omitempty" json:"priority,omitempty" validate:"omitempty,min=0"`
Timeout *time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"required"`
}
// GetDefaults gets the default values
func (s *NotifGotify) GetDefaults() *NotifGotify {
n := &NotifGotify{}
n.SetDefaults()
return n
}
// SetDefaults sets the default values
func (s *NotifGotify) SetDefaults() {
s.Priority = 1
s.Timeout = utl.NewDuration(10 * time.Second)
}

View File

@@ -0,0 +1,64 @@
package model
import (
"net/mail"
"github.com/crazy-max/diun/v3/pkg/utl"
"github.com/imdario/mergo"
"github.com/pkg/errors"
)
// NotifMail holds mail notification configuration details
type NotifMail struct {
Host string `yaml:"host,omitempty" json:"host,omitempty" validate:"required"`
Port int `yaml:"port,omitempty" json:"port,omitempty" validate:"required,min=1"`
SSL *bool `yaml:"ssl,omitempty" json:"ssl,omitempty" validate:"required"`
InsecureSkipVerify *bool `yaml:"insecureSkipVerify,omitempty" json:"insecureSkipVerify,omitempty" validate:"required"`
Username string `yaml:"username,omitempty" json:"username,omitempty" validate:"omitempty"`
UsernameFile string `yaml:"usernameFile,omitempty" json:"usernameFile,omitempty" validate:"omitempty,file"`
Password string `yaml:"password,omitempty" json:"password,omitempty" validate:"omitempty"`
PasswordFile string `yaml:"passwordFile,omitempty" json:"passwordFile,omitempty" validate:"omitempty,file"`
From string `yaml:"from,omitempty" json:"from,omitempty" validate:"required,email"`
To string `yaml:"to,omitempty" json:"to,omitempty" validate:"required,email"`
}
// GetDefaults gets the default values
func (s *NotifMail) GetDefaults() *NotifMail {
n := &NotifMail{}
n.SetDefaults()
return n
}
// SetDefaults sets the default values
func (s *NotifMail) SetDefaults() {
s.Host = "localhost"
s.Port = 25
s.SSL = utl.NewFalse()
s.InsecureSkipVerify = utl.NewFalse()
}
// UnmarshalYAML implements the yaml.Unmarshaler interface
func (s *NotifMail) UnmarshalYAML(unmarshal func(interface{}) error) error {
type plain NotifMail
if err := unmarshal((*plain)(s)); err != nil {
return err
}
if _, err := mail.ParseAddress(s.From); err != nil {
return errors.Wrap(err, "cannot parse sender mail address")
}
if _, err := mail.ParseAddress(s.To); err != nil {
return errors.Wrap(err, "cannot parse recipient mail address")
}
if err := mergo.Merge(s, NotifMail{
Host: "localhost",
Port: 25,
SSL: utl.NewFalse(),
InsecureSkipVerify: utl.NewFalse(),
}); err != nil {
return errors.Wrap(err, "cannot set default values for mail notif")
}
return nil
}

View File

@@ -0,0 +1,28 @@
package model
import (
"time"
"github.com/crazy-max/diun/v3/pkg/utl"
)
// NotifRocketChat holds Rocket.Chat notification configuration details
type NotifRocketChat struct {
Endpoint string `yaml:"endpoint,omitempty" json:"endpoint,omitempty" validate:"required"`
Channel string `yaml:"channel,omitempty" json:"channel,omitempty" validate:"required"`
UserID string `yaml:"userID,omitempty" json:"userID,omitempty" validate:"required"`
Token string `yaml:"token,omitempty" json:"token,omitempty" validate:"required"`
Timeout *time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"required"`
}
// GetDefaults gets the default values
func (s *NotifRocketChat) GetDefaults() *NotifRocketChat {
n := &NotifRocketChat{}
n.SetDefaults()
return n
}
// SetDefaults sets the default values
func (s *NotifRocketChat) SetDefaults() {
s.Timeout = utl.NewDuration(10 * time.Second)
}

View File

@@ -0,0 +1,18 @@
package model
// NotifScript holds script notification configuration details
type NotifScript struct {
Cmd string `yaml:"cmd,omitempty" json:"cmd,omitempty" validate:"required"`
Args []string `yaml:"args,omitempty" json:"args,omitempty" validate:"omitempty"`
Dir string `yaml:"dir,omitempty" json:"dir,omitempty" validate:"omitempty,dir"`
}
// GetDefaults gets the default values
func (s *NotifScript) GetDefaults() *NotifScript {
return nil
}
// SetDefaults sets the default values
func (s *NotifScript) SetDefaults() {
// noop
}

View File

@@ -0,0 +1,16 @@
package model
// NotifSlack holds slack notification configuration details
type NotifSlack struct {
WebhookURL string `yaml:"webhookURL,omitempty" json:"webhookURL,omitempty" validate:"required"`
}
// GetDefaults gets the default values
func (s *NotifSlack) GetDefaults() *NotifSlack {
return nil
}
// SetDefaults sets the default values
func (s *NotifSlack) SetDefaults() {
// noop
}

View File

@@ -0,0 +1,16 @@
package model
// NotifTeams holds Teams notification configuration details
type NotifTeams struct {
WebhookURL string `yaml:"webhookURL,omitempty" json:"webhookURL,omitempty" validate:"required"`
}
// GetDefaults gets the default values
func (s *NotifTeams) GetDefaults() *NotifTeams {
return nil
}
// SetDefaults sets the default values
func (s *NotifTeams) SetDefaults() {
// noop
}

View File

@@ -0,0 +1,17 @@
package model
// NotifTelegram holds Telegram notification configuration details
type NotifTelegram struct {
Token string `yaml:"token,omitempty" json:"token,omitempty" validate:"required"`
ChatIDs []int64 `yaml:"chatIDs,omitempty" json:"chatIDs,omitempty" validate:"required"`
}
// GetDefaults gets the default values
func (s *NotifTelegram) GetDefaults() *NotifTelegram {
return nil
}
// SetDefaults sets the default values
func (s *NotifTelegram) SetDefaults() {
// noop
}

View File

@@ -0,0 +1,28 @@
package model
import (
"time"
"github.com/crazy-max/diun/v3/pkg/utl"
)
// NotifWebhook holds webhook notification configuration details
type NotifWebhook struct {
Endpoint string `yaml:"endpoint,omitempty" json:"endpoint,omitempty" validate:"required"`
Method string `yaml:"method,omitempty" json:"method,omitempty" validate:"required"`
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty" validate:"omitempty"`
Timeout *time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"required"`
}
// GetDefaults gets the default values
func (s *NotifWebhook) GetDefaults() *NotifWebhook {
n := &NotifWebhook{}
n.SetDefaults()
return n
}
// SetDefaults sets the default values
func (s *NotifWebhook) SetDefaults() {
s.Method = "GET"
s.Timeout = utl.NewDuration(10 * time.Second)
}

View File

@@ -0,0 +1,29 @@
package model
import (
"github.com/crazy-max/diun/v3/pkg/utl"
)
// PrdDocker holds docker provider configuration
type PrdDocker struct {
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty" validate:"omitempty"`
APIVersion string `yaml:"apiVersion" json:"apiVersion,omitempty" validate:"omitempty"`
TLSCertsPath string `yaml:"tlsCertsPath" json:"tlsCertsPath,omitempty" validate:"omitempty"`
TLSVerify *bool `yaml:"tlsVerify" json:"tlsVerify,omitempty" validate:"required"`
WatchByDefault *bool `yaml:"watchByDefault" json:"watchByDefault,omitempty" validate:"required"`
WatchStopped *bool `yaml:"watchStopped" json:"watchStopped,omitempty" validate:"required"`
}
// GetDefaults gets the default values
func (s *PrdDocker) GetDefaults() *PrdDocker {
n := &PrdDocker{}
n.SetDefaults()
return n
}
// SetDefaults sets the default values
func (s *PrdDocker) SetDefaults() {
s.TLSVerify = utl.NewTrue()
s.WatchByDefault = utl.NewFalse()
s.WatchStopped = utl.NewFalse()
}

View File

@@ -0,0 +1,17 @@
package model
// PrdFile holds file provider configuration
type PrdFile struct {
Filename string `yaml:"filename,omitempty" json:"filename,omitempty" validate:"omitempty,file"`
Directory string `yaml:"directory,omitempty" json:"directory,omitempty" validate:"omitempty,dir"`
}
// GetDefaults gets the default values
func (s *PrdFile) GetDefaults() *PrdFile {
return nil
}
// SetDefaults sets the default values
func (s *PrdFile) SetDefaults() {
// noop
}

View File

@@ -0,0 +1,27 @@
package model
import (
"github.com/crazy-max/diun/v3/pkg/utl"
)
// PrdSwarm holds swarm provider configuration
type PrdSwarm struct {
Endpoint string `yaml:"endpoint,omitempty" json:"endpoint,omitempty" validate:"omitempty"`
APIVersion string `yaml:"apiVersion,omitempty" json:"apiVersion,omitempty" validate:"omitempty"`
TLSCertsPath string `yaml:"tlsCertsPath,omitempty" json:"tlsCertsPath,omitempty" validate:"omitempty"`
TLSVerify *bool `yaml:"tlsVerify,omitempty" json:"tlsVerify,omitempty" validate:"required"`
WatchByDefault *bool `yaml:"watchByDefault,omitempty" json:"watchByDefault,omitempty" validate:"required"`
}
// GetDefaults gets the default values
func (s *PrdSwarm) GetDefaults() *PrdSwarm {
n := &PrdSwarm{}
n.SetDefaults()
return n
}
// SetDefaults sets the default values
func (s *PrdSwarm) SetDefaults() {
s.TLSVerify = utl.NewTrue()
s.WatchByDefault = utl.NewFalse()
}

View File

@@ -2,32 +2,17 @@ package model
// Providers represents a provider configuration // Providers represents a provider configuration
type Providers struct { type Providers struct {
Docker *PrdDocker `yaml:"docker,omitempty" json:",omitempty"` Docker *PrdDocker `yaml:"docker,omitempty" json:"docker,omitempty" label:"allowEmpty"`
Swarm *PrdSwarm `yaml:"swarm,omitempty" json:",omitempty"` Swarm *PrdSwarm `yaml:"swarm,omitempty" json:"swarm,omitempty" label:"allowEmpty"`
File *PrdFile `yaml:"file,omitempty" json:",omitempty"` File *PrdFile `yaml:"file,omitempty" json:"file,omitempty"`
} }
// PrdDocker holds docker provider configuration // GetDefaults gets the default values
type PrdDocker struct { func (s *Providers) GetDefaults() *Providers {
Endpoint string `yaml:"endpoint,omitempty" json:",omitempty"` return nil
APIVersion string `yaml:"api_version,omitempty" json:",omitempty"`
TLSCertsPath string `yaml:"tls_certs_path,omitempty" json:",omitempty"`
TLSVerify *bool `yaml:"tls_verify,omitempty" json:",omitempty"`
WatchByDefault *bool `yaml:"watch_by_default,omitempty" json:",omitempty"`
WatchStopped *bool `yaml:"watch_stopped,omitempty" json:",omitempty"`
} }
// PrdSwarm holds swarm provider configuration // SetDefaults sets the default values
type PrdSwarm struct { func (s *Providers) SetDefaults() {
Endpoint string `yaml:"endpoint,omitempty" json:",omitempty"` // noop
APIVersion string `yaml:"api_version,omitempty" json:",omitempty"`
TLSCertsPath string `yaml:"tls_certs_path,omitempty" json:",omitempty"`
TLSVerify *bool `yaml:"tls_verify,omitempty" json:",omitempty"`
WatchByDefault *bool `yaml:"watch_by_default,omitempty" json:",omitempty"`
}
// PrdFile holds file provider configuration
type PrdFile struct {
Filename string `yaml:"filename,omitempty" json:",omitempty"`
Directory string `yaml:"directory,omitempty" json:",omitempty"`
} }

View File

@@ -1,11 +1,30 @@
package model package model
import (
"time"
"github.com/crazy-max/diun/v3/pkg/utl"
)
// RegOpts holds registry options configuration // RegOpts holds registry options configuration
type RegOpts struct { type RegOpts struct {
Username string `yaml:"username,omitempty" json:",omitempty"` Username string `yaml:"username,omitempty" json:"username,omitempty" validate:"omitempty"`
UsernameFile string `yaml:"username_file,omitempty" json:",omitempty"` UsernameFile string `yaml:"usernameFile,omitempty" json:"usernameFile,omitempty" validate:"omitempty,file"`
Password string `yaml:"password,omitempty" json:",omitempty"` Password string `yaml:"password,omitempty" json:"password,omitempty" validate:"omitempty"`
PasswordFile string `yaml:"password_file,omitempty" json:",omitempty"` PasswordFile string `yaml:"passwordFile,omitempty" json:"passwordFile,omitempty" validate:"omitempty,file"`
InsecureTLS bool `yaml:"insecure_tls,omitempty" json:",omitempty"` InsecureTLS *bool `yaml:"insecureTls,omitempty" json:"insecureTls,omitempty" validate:"required"`
Timeout int `yaml:"timeout,omitempty" json:",omitempty"` Timeout *time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"required"`
}
// GetDefaults gets the default values
func (s *RegOpts) GetDefaults() *RegOpts {
n := &RegOpts{}
n.SetDefaults()
return n
}
// SetDefaults sets the default values
func (s *RegOpts) SetDefaults() {
s.InsecureTLS = utl.NewFalse()
s.Timeout = utl.NewDuration(10 * time.Second)
} }

View File

@@ -1,8 +1,26 @@
package model package model
import (
"github.com/crazy-max/diun/v3/pkg/utl"
)
// Watch holds data necessary for watch configuration // Watch holds data necessary for watch configuration
type Watch struct { type Watch struct {
Workers int `yaml:"workers,omitempty"` Workers int `yaml:"workers,omitempty" json:"workers,omitempty" validate:"required,min=1"`
Schedule string `yaml:"schedule,omitempty"` Schedule string `yaml:"schedule,omitempty" json:"schedule,omitempty" validate:"required"`
FirstCheckNotif *bool `yaml:"first_check_notif,omitempty"` FirstCheckNotif *bool `yaml:"firstCheckNotif,omitempty" json:"firstCheckNotif,omitempty" validate:"required"`
}
// GetDefaults gets the default values
func (s *Watch) GetDefaults() *Watch {
n := &Watch{}
n.SetDefaults()
return n
}
// SetDefaults sets the default values
func (s *Watch) SetDefaults() {
s.Workers = 10
s.Schedule = "0 * * * *"
s.FirstCheckNotif = utl.NewFalse()
} }

View File

@@ -15,16 +15,16 @@ import (
// Client represents an active amqp notification object // Client represents an active amqp notification object
type Client struct { type Client struct {
*notifier.Notifier *notifier.Notifier
cfg *model.NotifAmqp cfg *model.NotifAmqp
app model.App meta model.Meta
} }
// New creates a new amqp notification instance // New creates a new amqp notification instance
func New(config *model.NotifAmqp, app model.App) notifier.Notifier { func New(config *model.NotifAmqp, meta model.Meta) notifier.Notifier {
return notifier.Notifier{ return notifier.Notifier{
Handler: &Client{ Handler: &Client{
cfg: config, cfg: config,
app: app, meta: meta,
}, },
} }
} }
@@ -36,9 +36,7 @@ func (c *Client) Name() string {
// Send creates and sends a amqp notification with an entry // Send creates and sends a amqp notification with an entry
func (c *Client) Send(entry model.NotifEntry) error { func (c *Client) Send(entry model.NotifEntry) error {
username, err := utl.GetSecret(c.cfg.Username, c.cfg.UsernameFile) username, err := utl.GetSecret(c.cfg.Username, c.cfg.UsernameFile)
if err != nil { if err != nil {
return err return err
} }
@@ -48,52 +46,31 @@ func (c *Client) Send(entry model.NotifEntry) error {
return err return err
} }
connString := fmt.Sprintf("amqp://%s:%s@%s:%d/", username, password, c.cfg.Host, c.cfg.Port) conn, err := amqp.Dial(fmt.Sprintf("amqp://%s:%s@%s:%d/", username, password, c.cfg.Host, c.cfg.Port))
conn, err := amqp.Dial(connString)
if err != nil { if err != nil {
return err return err
} }
defer conn.Close() defer conn.Close()
ch, err := conn.Channel() ch, err := conn.Channel()
if err != nil { if err != nil {
return err return err
} }
defer ch.Close() defer ch.Close()
q, err := ch.QueueDeclare( q, err := ch.QueueDeclare(
c.cfg.Queue, // name c.cfg.Queue,
false, // durable false,
false, // delete when unused false,
false, // exclusive false,
false, // no-wait false,
nil, // arguments nil,
) )
if err != nil { if err != nil {
return err return err
} }
body, err := buildBody(entry, c.app) body, err := json.Marshal(struct {
if err != nil {
return err
}
return ch.Publish(
c.cfg.Exchange, // exchange
q.Name, // routing key
false, // mandatory
false, // immediate
amqp.Publishing{
ContentType: "application/json",
Body: body,
})
}
func buildBody(entry model.NotifEntry, app model.App) ([]byte, error) {
return json.Marshal(struct {
Version string `json:"diun_version"` Version string `json:"diun_version"`
Status string `json:"status"` Status string `json:"status"`
Provider string `json:"provider"` Provider string `json:"provider"`
@@ -103,7 +80,7 @@ func buildBody(entry model.NotifEntry, app model.App) ([]byte, error) {
Created *time.Time `json:"created"` Created *time.Time `json:"created"`
Platform string `json:"platform"` Platform string `json:"platform"`
}{ }{
Version: app.Version, Version: c.meta.Version,
Status: string(entry.Status), Status: string(entry.Status),
Provider: entry.Provider, Provider: entry.Provider,
Image: entry.Image.String(), Image: entry.Image.String(),
@@ -112,4 +89,17 @@ func buildBody(entry model.NotifEntry, app model.App) ([]byte, error) {
Created: entry.Manifest.Created, Created: entry.Manifest.Created,
Platform: entry.Manifest.Platform, Platform: entry.Manifest.Platform,
}) })
if err != nil {
return err
}
return ch.Publish(
c.cfg.Exchange,
q.Name,
false,
false,
amqp.Publishing{
ContentType: "application/json",
Body: body,
})
} }

View File

@@ -1,6 +1,8 @@
package notif package notif
import ( import (
"strings"
"github.com/crazy-max/diun/v3/internal/model" "github.com/crazy-max/diun/v3/internal/model"
"github.com/crazy-max/diun/v3/internal/notif/amqp" "github.com/crazy-max/diun/v3/internal/notif/amqp"
"github.com/crazy-max/diun/v3/internal/notif/gotify" "github.com/crazy-max/diun/v3/internal/notif/gotify"
@@ -18,15 +20,15 @@ import (
// Client represents an active webhook notification object // Client represents an active webhook notification object
type Client struct { type Client struct {
cfg *model.Notif cfg *model.Notif
app model.App meta model.Meta
notifiers []notifier.Notifier notifiers []notifier.Notifier
} }
// New creates a new notification instance // New creates a new notification instance
func New(config *model.Notif, app model.App, userAgent string) (*Client, error) { func New(config *model.Notif, meta model.Meta) (*Client, error) {
var c = &Client{ var c = &Client{
cfg: config, cfg: config,
app: app, meta: meta,
notifiers: []notifier.Notifier{}, notifiers: []notifier.Notifier{},
} }
@@ -37,31 +39,31 @@ func New(config *model.Notif, app model.App, userAgent string) (*Client, error)
// Add notifiers // Add notifiers
if config.Amqp != nil { if config.Amqp != nil {
c.notifiers = append(c.notifiers, amqp.New(config.Amqp, app)) c.notifiers = append(c.notifiers, amqp.New(config.Amqp, meta))
} }
if config.Gotify != nil { if config.Gotify != nil {
c.notifiers = append(c.notifiers, gotify.New(config.Gotify, app, userAgent)) c.notifiers = append(c.notifiers, gotify.New(config.Gotify, meta))
} }
if config.Mail != nil { if config.Mail != nil {
c.notifiers = append(c.notifiers, mail.New(config.Mail, app)) c.notifiers = append(c.notifiers, mail.New(config.Mail, meta))
} }
if config.RocketChat != nil { if config.RocketChat != nil {
c.notifiers = append(c.notifiers, rocketchat.New(config.RocketChat, app, userAgent)) c.notifiers = append(c.notifiers, rocketchat.New(config.RocketChat, meta))
} }
if config.Script != nil { if config.Script != nil {
c.notifiers = append(c.notifiers, script.New(config.Script, app)) c.notifiers = append(c.notifiers, script.New(config.Script, meta))
} }
if config.Slack != nil { if config.Slack != nil {
c.notifiers = append(c.notifiers, slack.New(config.Slack, app)) c.notifiers = append(c.notifiers, slack.New(config.Slack, meta))
} }
if config.Teams != nil { if config.Teams != nil {
c.notifiers = append(c.notifiers, teams.New(config.Teams, app, userAgent)) c.notifiers = append(c.notifiers, teams.New(config.Teams, meta))
} }
if config.Telegram != nil { if config.Telegram != nil {
c.notifiers = append(c.notifiers, telegram.New(config.Telegram, app)) c.notifiers = append(c.notifiers, telegram.New(config.Telegram, meta))
} }
if config.Webhook != nil { if config.Webhook != nil {
c.notifiers = append(c.notifiers, webhook.New(config.Webhook, app, userAgent)) c.notifiers = append(c.notifiers, webhook.New(config.Webhook, meta))
} }
log.Debug().Msgf("%d notifier(s) created", len(c.notifiers)) log.Debug().Msgf("%d notifier(s) created", len(c.notifiers))
@@ -73,7 +75,7 @@ func (c *Client) Send(entry model.NotifEntry) {
for _, n := range c.notifiers { for _, n := range c.notifiers {
log.Debug().Str("image", entry.Image.String()).Msgf("Sending %s notification...", n.Name()) log.Debug().Str("image", entry.Image.String()).Msgf("Sending %s notification...", n.Name())
if err := n.Send(entry); err != nil { if err := n.Send(entry); err != nil {
log.Error().Err(err).Str("image", entry.Image.String()).Msgf("%s notification failed", n.Name()) log.Error().Err(err).Str("image", entry.Image.String()).Msgf("%s notification failed", strings.Title(n.Name()))
} }
} }
} }

View File

@@ -10,7 +10,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"text/template" "text/template"
"time"
"github.com/crazy-max/diun/v3/internal/model" "github.com/crazy-max/diun/v3/internal/model"
"github.com/crazy-max/diun/v3/internal/notif/notifier" "github.com/crazy-max/diun/v3/internal/notif/notifier"
@@ -19,18 +18,16 @@ import (
// Client represents an active gotify notification object // Client represents an active gotify notification object
type Client struct { type Client struct {
*notifier.Notifier *notifier.Notifier
cfg *model.NotifGotify cfg *model.NotifGotify
app model.App meta model.Meta
userAgent string
} }
// New creates a new gotify notification instance // New creates a new gotify notification instance
func New(config *model.NotifGotify, app model.App, userAgent string) notifier.Notifier { func New(config *model.NotifGotify, meta model.Meta) notifier.Notifier {
return notifier.Notifier{ return notifier.Notifier{
Handler: &Client{ Handler: &Client{
cfg: config, cfg: config,
app: app, meta: meta,
userAgent: userAgent,
}, },
} }
} }
@@ -43,7 +40,7 @@ func (c *Client) Name() string {
// Send creates and sends a gotify notification with an entry // Send creates and sends a gotify notification with an entry
func (c *Client) Send(entry model.NotifEntry) error { func (c *Client) Send(entry model.NotifEntry) error {
hc := http.Client{ hc := http.Client{
Timeout: time.Duration(c.cfg.Timeout) * time.Second, Timeout: *c.cfg.Timeout,
} }
title := fmt.Sprintf("Image update for %s", entry.Image.String()) title := fmt.Sprintf("Image update for %s", entry.Image.String())
@@ -79,7 +76,7 @@ func (c *Client) Send(entry model.NotifEntry) error {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(data.Encode()))) req.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
req.Header.Set("User-Agent", c.userAgent) req.Header.Set("User-Agent", c.meta.UserAgent)
resp, err := hc.Do(req) resp, err := hc.Do(req)
if err != nil { if err != nil {

View File

@@ -18,16 +18,16 @@ import (
// Client represents an active mail notification object // Client represents an active mail notification object
type Client struct { type Client struct {
*notifier.Notifier *notifier.Notifier
cfg *model.NotifMail cfg *model.NotifMail
app model.App meta model.Meta
} }
// New creates a new mail notification instance // New creates a new mail notification instance
func New(config *model.NotifMail, app model.App) notifier.Notifier { func New(config *model.NotifMail, meta model.Meta) notifier.Notifier {
return notifier.Notifier{ return notifier.Notifier{
Handler: &Client{ Handler: &Client{
cfg: config, cfg: config,
app: app, meta: meta,
}, },
} }
} }
@@ -42,14 +42,14 @@ func (c *Client) Send(entry model.NotifEntry) error {
h := hermes.Hermes{ h := hermes.Hermes{
Theme: new(Theme), Theme: new(Theme),
Product: hermes.Product{ Product: hermes.Product{
Name: c.app.Name, Name: c.meta.Name,
Link: "https://github.com/crazy-max/diun", Link: c.meta.URL,
Logo: "https://raw.githubusercontent.com/crazy-max/diun/master/.res/diun.png", Logo: c.meta.Logo,
Copyright: fmt.Sprintf("%s © %d %s %s", Copyright: fmt.Sprintf("%s © %d %s %s",
c.app.Author, c.meta.Author,
time.Now().Year(), time.Now().Year(),
c.app.Name, c.meta.Name,
c.app.Version), c.meta.Version),
}, },
} }
@@ -75,7 +75,7 @@ Need help, or have questions? Go to https://github.com/crazy-max/diun and leave
} }
email := hermes.Email{ email := hermes.Email{
Body: hermes.Body{ Body: hermes.Body{
Title: fmt.Sprintf("%s 🔔 notification", c.app.Name), Title: fmt.Sprintf("%s 🔔 notification", c.meta.Name),
FreeMarkdown: hermes.Markdown(emailBuf.String()), FreeMarkdown: hermes.Markdown(emailBuf.String()),
Signature: "Thanks for your support", Signature: "Thanks for your support",
}, },
@@ -94,7 +94,7 @@ Need help, or have questions? Go to https://github.com/crazy-max/diun and leave
} }
msg := gomail.NewMessage() msg := gomail.NewMessage()
msg.SetHeader("From", fmt.Sprintf("%s <%s>", c.app.Name, c.cfg.From)) msg.SetHeader("From", fmt.Sprintf("%s <%s>", c.meta.Name, c.cfg.From))
msg.SetHeader("To", c.cfg.To) msg.SetHeader("To", c.cfg.To)
msg.SetHeader("Subject", subject) msg.SetHeader("Subject", subject)
msg.SetBody("text/plain", textpart) msg.SetBody("text/plain", textpart)

View File

@@ -18,18 +18,16 @@ import (
// Client represents an active rocketchat notification object // Client represents an active rocketchat notification object
type Client struct { type Client struct {
*notifier.Notifier *notifier.Notifier
cfg *model.NotifRocketChat cfg *model.NotifRocketChat
app model.App meta model.Meta
userAgent string
} }
// New creates a new rocketchat notification instance // New creates a new rocketchat notification instance
func New(config *model.NotifRocketChat, app model.App, userAgent string) notifier.Notifier { func New(config *model.NotifRocketChat, meta model.Meta) notifier.Notifier {
return notifier.Notifier{ return notifier.Notifier{
Handler: &Client{ Handler: &Client{
cfg: config, cfg: config,
app: app, meta: meta,
userAgent: userAgent,
}, },
} }
} }
@@ -43,7 +41,7 @@ func (c *Client) Name() string {
// https://rocket.chat/docs/developer-guides/rest-api/chat/postmessage/ // https://rocket.chat/docs/developer-guides/rest-api/chat/postmessage/
func (c *Client) Send(entry model.NotifEntry) error { func (c *Client) Send(entry model.NotifEntry) error {
hc := http.Client{ hc := http.Client{
Timeout: time.Duration(c.cfg.Timeout) * time.Second, Timeout: *c.cfg.Timeout,
} }
title := fmt.Sprintf("Image update for %s", entry.Image.String()) title := fmt.Sprintf("Image update for %s", entry.Image.String())
@@ -58,8 +56,8 @@ func (c *Client) Send(entry model.NotifEntry) error {
} }
data := Message{ data := Message{
Alias: c.app.Name, Alias: c.meta.Name,
Avatar: "https://raw.githubusercontent.com/crazy-max/diun/master/.res/diun.png", Avatar: c.meta.Logo,
Channel: c.cfg.Channel, Channel: c.cfg.Channel,
Text: title, Text: title,
Attachments: []Attachment{ Attachments: []Attachment{
@@ -109,7 +107,7 @@ func (c *Client) Send(entry model.NotifEntry) error {
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", c.userAgent) req.Header.Set("User-Agent", c.meta.UserAgent)
req.Header.Add("X-User-Id", c.cfg.UserID) req.Header.Add("X-User-Id", c.cfg.UserID)
req.Header.Add("X-Auth-Token", c.cfg.Token) req.Header.Add("X-Auth-Token", c.cfg.Token)

View File

@@ -16,17 +16,16 @@ import (
// Client represents an active script notification object // Client represents an active script notification object
type Client struct { type Client struct {
*notifier.Notifier *notifier.Notifier
cfg *model.NotifScript cfg *model.NotifScript
app model.App meta model.Meta
userAgent string
} }
// New creates a new script notification instance // New creates a new script notification instance
func New(config *model.NotifScript, app model.App) notifier.Notifier { func New(config *model.NotifScript, meta model.Meta) notifier.Notifier {
return notifier.Notifier{ return notifier.Notifier{
Handler: &Client{ Handler: &Client{
cfg: config, cfg: config,
app: app, meta: meta,
}, },
} }
} }
@@ -53,7 +52,7 @@ func (c *Client) Send(entry model.NotifEntry) error {
// Set env vars // Set env vars
cmd.Env = append(os.Environ(), []string{ cmd.Env = append(os.Environ(), []string{
fmt.Sprintf("DIUN_VERSION=%s", c.app.Version), fmt.Sprintf("DIUN_VERSION=%s", c.meta.Version),
fmt.Sprintf("DIUN_ENTRY_STATUS=%s", string(entry.Status)), fmt.Sprintf("DIUN_ENTRY_STATUS=%s", string(entry.Status)),
fmt.Sprintf("DIUN_ENTRY_PROVIDER=%s", entry.Provider), fmt.Sprintf("DIUN_ENTRY_PROVIDER=%s", entry.Provider),
fmt.Sprintf("DIUN_ENTRY_IMAGE=%s", entry.Image.String()), fmt.Sprintf("DIUN_ENTRY_IMAGE=%s", entry.Image.String()),

View File

@@ -16,16 +16,16 @@ import (
// Client represents an active slack notification object // Client represents an active slack notification object
type Client struct { type Client struct {
*notifier.Notifier *notifier.Notifier
cfg *model.NotifSlack cfg *model.NotifSlack
app model.App meta model.Meta
} }
// New creates a new slack notification instance // New creates a new slack notification instance
func New(config *model.NotifSlack, app model.App) notifier.Notifier { func New(config *model.NotifSlack, meta model.Meta) notifier.Notifier {
return notifier.Notifier{ return notifier.Notifier{
Handler: &Client{ Handler: &Client{
cfg: config, cfg: config,
app: app, meta: meta,
}, },
} }
} }
@@ -52,12 +52,12 @@ func (c *Client) Send(entry model.NotifEntry) error {
Attachments: []slack.Attachment{ Attachments: []slack.Attachment{
{ {
Color: color, Color: color,
AuthorName: "Diun", AuthorName: c.meta.Name,
AuthorSubname: "github.com/crazy-max/diun", AuthorSubname: "github.com/crazy-max/diun",
AuthorLink: "https://github.com/crazy-max/diun", AuthorLink: c.meta.URL,
AuthorIcon: "https://raw.githubusercontent.com/crazy-max/diun/master/.res/diun.png", AuthorIcon: c.meta.Logo,
Text: textBuf.String(), Text: textBuf.String(),
Footer: fmt.Sprintf("%s © %d %s %s", c.app.Author, time.Now().Year(), c.app.Name, c.app.Version), Footer: fmt.Sprintf("%s © %d %s %s", c.meta.Author, time.Now().Year(), c.meta.Name, c.meta.Version),
Fields: []slack.AttachmentField{ Fields: []slack.AttachmentField{
{ {
Title: "Provider", Title: "Provider",

View File

@@ -14,18 +14,16 @@ import (
// Client represents an active webhook notification object // Client represents an active webhook notification object
type Client struct { type Client struct {
*notifier.Notifier *notifier.Notifier
cfg *model.NotifTeams cfg *model.NotifTeams
app model.App meta model.Meta
userAgent string
} }
// New creates a new webhook notification instance // New creates a new webhook notification instance
func New(config *model.NotifTeams, app model.App, userAgent string) notifier.Notifier { func New(config *model.NotifTeams, meta model.Meta) notifier.Notifier {
return notifier.Notifier{ return notifier.Notifier{
Handler: &Client{ Handler: &Client{
cfg: config, cfg: config,
app: app, meta: meta,
userAgent: userAgent,
}, },
} }
} }
@@ -95,8 +93,7 @@ func (c *Client) Send(entry model.NotifEntry) error {
} }
req.Header.Add("Content-Type", "application/json") req.Header.Add("Content-Type", "application/json")
req.Header.Set("User-Agent", c.meta.UserAgent)
req.Header.Set("User-Agent", c.userAgent)
_, err = hc.Do(req) _, err = hc.Do(req)
return err return err

View File

@@ -2,34 +2,26 @@ package telegram
import ( import (
"bytes" "bytes"
"errors"
"text/template" "text/template"
"github.com/crazy-max/diun/v3/internal/model" "github.com/crazy-max/diun/v3/internal/model"
"github.com/crazy-max/diun/v3/internal/notif/notifier" "github.com/crazy-max/diun/v3/internal/notif/notifier"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
"github.com/rs/zerolog/log"
) )
// Client represents an active Telegram notification object // Client represents an active Telegram notification object
type Client struct { type Client struct {
*notifier.Notifier *notifier.Notifier
cfg *model.NotifTelegram cfg *model.NotifTelegram
app model.App meta model.Meta
bot *tgbotapi.BotAPI
} }
// New creates a new Telegram notification instance // New creates a new Telegram notification instance
func New(config *model.NotifTelegram, app model.App) notifier.Notifier { func New(config *model.NotifTelegram, meta model.Meta) notifier.Notifier {
bot, err := tgbotapi.NewBotAPI(config.BotToken)
if err != nil {
log.Err(err).Msgf("Failed to initialize Telegram notifications")
}
return notifier.Notifier{ return notifier.Notifier{
Handler: &Client{ Handler: &Client{
cfg: config, cfg: config,
app: app, meta: meta,
bot: bot,
}, },
} }
} }
@@ -41,8 +33,9 @@ func (c *Client) Name() string {
// Send creates and sends a Telegram notification with an entry // Send creates and sends a Telegram notification with an entry
func (c *Client) Send(entry model.NotifEntry) error { func (c *Client) Send(entry model.NotifEntry) error {
if c.bot == nil { bot, err := tgbotapi.NewBotAPI(c.cfg.Token)
return errors.New("telegram not initialized") if err != nil {
return err
} }
var msgBuf bytes.Buffer var msgBuf bytes.Buffer
@@ -52,9 +45,9 @@ func (c *Client) Send(entry model.NotifEntry) error {
} }
for _, chatID := range c.cfg.ChatIDs { for _, chatID := range c.cfg.ChatIDs {
msg := tgbotapi.NewMessage(chatID, c.bot.Self.UserName) msg := tgbotapi.NewMessage(chatID, bot.Self.UserName)
msg.Text = msgBuf.String() msg.Text = msgBuf.String()
if _, err := c.bot.Send(msg); err != nil { if _, err := bot.Send(msg); err != nil {
return err return err
} }
} }

View File

@@ -14,18 +14,16 @@ import (
// Client represents an active webhook notification object // Client represents an active webhook notification object
type Client struct { type Client struct {
*notifier.Notifier *notifier.Notifier
cfg *model.NotifWebhook cfg *model.NotifWebhook
app model.App meta model.Meta
userAgent string
} }
// New creates a new webhook notification instance // New creates a new webhook notification instance
func New(config *model.NotifWebhook, app model.App, userAgent string) notifier.Notifier { func New(config *model.NotifWebhook, meta model.Meta) notifier.Notifier {
return notifier.Notifier{ return notifier.Notifier{
Handler: &Client{ Handler: &Client{
cfg: config, cfg: config,
app: app, meta: meta,
userAgent: userAgent,
}, },
} }
} }
@@ -38,7 +36,7 @@ func (c *Client) Name() string {
// Send creates and sends a webhook notification with an entry // Send creates and sends a webhook notification with an entry
func (c *Client) Send(entry model.NotifEntry) error { func (c *Client) Send(entry model.NotifEntry) error {
hc := http.Client{ hc := http.Client{
Timeout: time.Duration(c.cfg.Timeout) * time.Second, Timeout: *c.cfg.Timeout,
} }
body, err := json.Marshal(struct { body, err := json.Marshal(struct {
@@ -51,7 +49,7 @@ func (c *Client) Send(entry model.NotifEntry) error {
Created *time.Time `json:"created"` Created *time.Time `json:"created"`
Platform string `json:"platform"` Platform string `json:"platform"`
}{ }{
Version: c.app.Version, Version: c.meta.Version,
Status: string(entry.Status), Status: string(entry.Status),
Provider: entry.Provider, Provider: entry.Provider,
Image: entry.Image.String(), Image: entry.Image.String(),
@@ -75,7 +73,7 @@ func (c *Client) Send(entry model.NotifEntry) error {
} }
} }
req.Header.Set("User-Agent", c.userAgent) req.Header.Set("User-Agent", c.meta.UserAgent)
_, err = hc.Do(req) _, err = hc.Do(req)
return err return err

View File

@@ -114,14 +114,14 @@ var (
func TestListJobFilename(t *testing.T) { func TestListJobFilename(t *testing.T) {
fc := file.New(&model.PrdFile{ fc := file.New(&model.PrdFile{
Filename: "./test/dockerhub.yml", Filename: "./fixtures/dockerhub.yml",
}) })
assert.Equal(t, dockerhubFile, fc.ListJob()) assert.Equal(t, dockerhubFile, fc.ListJob())
} }
func TestListJobDirectory(t *testing.T) { func TestListJobDirectory(t *testing.T) {
fc := file.New(&model.PrdFile{ fc := file.New(&model.PrdFile{
Directory: "./test", Directory: "./fixtures",
}) })
assert.Equal(t, append(append(bintrayFile, dockerhubFile...), quayFile...), fc.ListJob()) assert.Equal(t, append(append(bintrayFile, dockerhubFile...), quayFile...), fc.ListJob())
} }

View File

@@ -44,7 +44,7 @@ func New(opts Options) (*Client, error) {
} }
tlsc, err := tlsconfig.Client(options) tlsc, err := tlsconfig.Client(options)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to create tls config") return nil, errors.Wrap(err, "failed to create TLS config")
} }
httpCli := &http.Client{ httpCli := &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsc}, Transport: &http.Transport{TLSClientConfig: tlsc},

View File

@@ -48,7 +48,7 @@ func (c *Client) Manifest(image Image) (Manifest, error) {
} }
imgTag := imgInspect.Tag imgTag := imgInspect.Tag
if imgTag == "" { if len(imgTag) == 0 {
imgTag = image.Tag imgTag = image.Tag
} }

View File

@@ -4,6 +4,7 @@ import (
"io/ioutil" "io/ioutil"
"os" "os"
"regexp" "regexp"
"time"
) )
// MatchString reports whether a string s // MatchString reports whether a string s
@@ -78,3 +79,8 @@ func NewTrue() *bool {
b := true b := true
return &b return &b
} }
// NewDuration returns a duration pointer
func NewDuration(duration time.Duration) *time.Duration {
return &duration
}

1
third_party/traefik/README.md vendored Normal file
View File

@@ -0,0 +1 @@
Fork of github.com/containous/traefik@v2.2.1

77
third_party/traefik/config/env/env.go vendored Normal file
View File

@@ -0,0 +1,77 @@
// Package env implements encoding and decoding between environment variable and a typed Configuration.
package env
import (
"fmt"
"regexp"
"strings"
"github.com/crazy-max/diun/v3/third_party/traefik/config/parser"
)
// DefaultNamePrefix is the default prefix for environment variable names.
const DefaultNamePrefix = "TRAEFIK_"
// Decode decodes the given environment variables into the given element.
// The operation goes through four stages roughly summarized as:
// env vars -> map
// map -> tree of untyped nodes
// untyped nodes -> nodes augmented with metadata such as kind (inferred from element)
// "typed" nodes -> typed element
func Decode(environ []string, prefix string, element interface{}) error {
if err := checkPrefix(prefix); err != nil {
return err
}
vars := make(map[string]string)
for _, evr := range environ {
n := strings.SplitN(evr, "=", 2)
if strings.HasPrefix(strings.ToUpper(n[0]), prefix) {
key := strings.ReplaceAll(strings.ToLower(n[0]), "_", ".")
vars[key] = n[1]
}
}
rootName := strings.ToLower(prefix[:len(prefix)-1])
return parser.Decode(vars, element, rootName)
}
// Encode encodes the configuration in element into the environment variables represented in the returned Flats.
// The operation goes through three stages roughly summarized as:
// typed configuration in element -> tree of untyped nodes
// untyped nodes -> nodes augmented with metadata such as kind (inferred from element)
// "typed" nodes -> environment variables with default values (determined by type/kind)
func Encode(element interface{}) ([]parser.Flat, error) {
if element == nil {
return nil, nil
}
etnOpts := parser.EncoderToNodeOpts{OmitEmpty: false, TagName: parser.TagLabel, AllowSliceAsStruct: true}
node, err := parser.EncodeToNode(element, parser.DefaultRootName, etnOpts)
if err != nil {
return nil, err
}
metaOpts := parser.MetadataOpts{TagName: parser.TagLabel, AllowSliceAsStruct: true}
err = parser.AddMetadata(element, node, metaOpts)
if err != nil {
return nil, err
}
flatOpts := parser.FlatOpts{Case: "upper", Separator: "_", TagName: parser.TagLabel}
return parser.EncodeToFlat(element, node, flatOpts)
}
func checkPrefix(prefix string) error {
prefixPattern := `[a-zA-Z0-9]+_`
matched, err := regexp.MatchString(prefixPattern, prefix)
if err != nil {
return err
}
if !matched {
return fmt.Errorf("invalid prefix %q, the prefix pattern must match the following pattern: %s", prefix, prefixPattern)
}
return nil
}

View File

@@ -0,0 +1,462 @@
package env
import (
"testing"
"github.com/crazy-max/diun/v3/third_party/traefik/config/generator"
"github.com/crazy-max/diun/v3/third_party/traefik/config/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDecode(t *testing.T) {
testCases := []struct {
desc string
environ []string
element interface{}
expected interface{}
}{
{
desc: "no env vars",
environ: nil,
expected: nil,
},
{
desc: "bool value",
environ: []string{"TRAEFIK_FOO=true"},
element: &struct {
Foo bool
}{},
expected: &struct {
Foo bool
}{
Foo: true,
},
},
{
desc: "equal",
environ: []string{"TRAEFIK_FOO=bar"},
element: &struct {
Foo string
}{},
expected: &struct {
Foo string
}{
Foo: "bar",
},
},
{
desc: "multiple bool flags without value",
environ: []string{"TRAEFIK_FOO=true", "TRAEFIK_BAR=true"},
element: &struct {
Foo bool
Bar bool
}{},
expected: &struct {
Foo bool
Bar bool
}{
Foo: true,
Bar: true,
},
},
{
desc: "map string",
environ: []string{"TRAEFIK_FOO_NAME=bar"},
element: &struct {
Foo map[string]string
}{},
expected: &struct {
Foo map[string]string
}{
Foo: map[string]string{
"name": "bar",
},
},
},
{
desc: "map struct",
environ: []string{"TRAEFIK_FOO_NAME_VALUE=bar"},
element: &struct {
Foo map[string]struct{ Value string }
}{},
expected: &struct {
Foo map[string]struct{ Value string }
}{
Foo: map[string]struct{ Value string }{
"name": {
Value: "bar",
},
},
},
},
{
desc: "map struct with sub-struct",
environ: []string{"TRAEFIK_FOO_NAME_BAR_VALUE=bar"},
element: &struct {
Foo map[string]struct {
Bar *struct{ Value string }
}
}{},
expected: &struct {
Foo map[string]struct {
Bar *struct{ Value string }
}
}{
Foo: map[string]struct {
Bar *struct{ Value string }
}{
"name": {
Bar: &struct {
Value string
}{
Value: "bar",
},
},
},
},
},
{
desc: "map struct with sub-map",
environ: []string{"TRAEFIK_FOO_NAME1_BAR_NAME2_VALUE=bar"},
element: &struct {
Foo map[string]struct {
Bar map[string]struct{ Value string }
}
}{},
expected: &struct {
Foo map[string]struct {
Bar map[string]struct{ Value string }
}
}{
Foo: map[string]struct {
Bar map[string]struct{ Value string }
}{
"name1": {
Bar: map[string]struct{ Value string }{
"name2": {
Value: "bar",
},
},
},
},
},
},
{
desc: "slice",
environ: []string{"TRAEFIK_FOO=bar,baz"},
element: &struct {
Foo []string
}{},
expected: &struct {
Foo []string
}{
Foo: []string{"bar", "baz"},
},
},
{
desc: "struct pointer value",
environ: []string{"TRAEFIK_FOO=true"},
element: &struct {
Foo *struct{ Field string } `label:"allowEmpty"`
}{},
expected: &struct {
Foo *struct{ Field string } `label:"allowEmpty"`
}{
Foo: &struct{ Field string }{},
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
err := Decode(test.environ, DefaultNamePrefix, test.element)
require.NoError(t, err)
assert.Equal(t, test.expected, test.element)
})
}
}
func TestEncode(t *testing.T) {
element := &Ya{
Foo: &Yaa{
FieldIn1: "bar",
FieldIn2: false,
FieldIn3: 1,
FieldIn4: map[string]string{
parser.MapNamePlaceholder: "",
},
FieldIn5: map[string]int{
parser.MapNamePlaceholder: 0,
},
FieldIn6: map[string]struct{ Field string }{
parser.MapNamePlaceholder: {},
},
FieldIn7: map[string]struct{ Field map[string]string }{
parser.MapNamePlaceholder: {
Field: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
FieldIn8: map[string]*struct{ Field string }{
parser.MapNamePlaceholder: {},
},
FieldIn9: map[string]*struct{ Field map[string]string }{
parser.MapNamePlaceholder: {
Field: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
FieldIn10: struct{ Field string }{},
FieldIn11: &struct{ Field string }{},
FieldIn12: func(v string) *string { return &v }(""),
FieldIn13: func(v bool) *bool { return &v }(false),
FieldIn14: func(v int) *int { return &v }(0),
},
Field1: "bir",
Field2: true,
Field3: 0,
Field4: map[string]string{
parser.MapNamePlaceholder: "",
},
Field5: map[string]int{
parser.MapNamePlaceholder: 0,
},
Field6: map[string]struct{ Field string }{
parser.MapNamePlaceholder: {},
},
Field7: map[string]struct{ Field map[string]string }{
parser.MapNamePlaceholder: {
Field: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
Field8: map[string]*struct{ Field string }{
parser.MapNamePlaceholder: {},
},
Field9: map[string]*struct{ Field map[string]string }{
parser.MapNamePlaceholder: {
Field: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
Field10: struct{ Field string }{},
Field11: &struct{ Field string }{},
Field12: func(v string) *string { return &v }(""),
Field13: func(v bool) *bool { return &v }(false),
Field14: func(v int) *int { return &v }(0),
Field15: []int{7},
}
generator.Generate(element)
flats, err := Encode(element)
require.NoError(t, err)
expected := []parser.Flat{
{
Name: "TRAEFIK_FIELD1",
Description: "",
Default: "bir",
},
{
Name: "TRAEFIK_FIELD10",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FIELD10_FIELD",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FIELD11_FIELD",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FIELD12",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FIELD13",
Description: "",
Default: "false",
},
{
Name: "TRAEFIK_FIELD14",
Description: "",
Default: "0",
},
{
Name: "TRAEFIK_FIELD15",
Description: "",
Default: "7",
},
{
Name: "TRAEFIK_FIELD2",
Description: "",
Default: "true",
},
{
Name: "TRAEFIK_FIELD3",
Description: "",
Default: "0",
},
{
Name: "TRAEFIK_FIELD4_\u003cNAME\u003e",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FIELD5_\u003cNAME\u003e",
Description: "",
Default: "0",
},
{
Name: "TRAEFIK_FIELD6_\u003cNAME\u003e",
Description: "",
Default: "false",
},
{
Name: "TRAEFIK_FIELD6_\u003cNAME\u003e_FIELD",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FIELD7_\u003cNAME\u003e",
Description: "",
Default: "false",
},
{
Name: "TRAEFIK_FIELD7_\u003cNAME\u003e_FIELD_\u003cNAME\u003e",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FIELD8_\u003cNAME\u003e",
Description: "",
Default: "false",
},
{
Name: "TRAEFIK_FIELD8_\u003cNAME\u003e_FIELD",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FIELD9_\u003cNAME\u003e",
Description: "",
Default: "false",
},
{
Name: "TRAEFIK_FIELD9_\u003cNAME\u003e_FIELD_\u003cNAME\u003e",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FOO_FIELDIN1",
Description: "",
Default: "bar",
},
{
Name: "TRAEFIK_FOO_FIELDIN10",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FOO_FIELDIN10_FIELD",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FOO_FIELDIN11_FIELD",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FOO_FIELDIN12",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FOO_FIELDIN13",
Description: "",
Default: "false",
},
{
Name: "TRAEFIK_FOO_FIELDIN14",
Description: "",
Default: "0",
},
{
Name: "TRAEFIK_FOO_FIELDIN2",
Description: "",
Default: "false",
},
{
Name: "TRAEFIK_FOO_FIELDIN3",
Description: "",
Default: "1",
},
{
Name: "TRAEFIK_FOO_FIELDIN4_\u003cNAME\u003e",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FOO_FIELDIN5_\u003cNAME\u003e",
Description: "",
Default: "0",
},
{
Name: "TRAEFIK_FOO_FIELDIN6_\u003cNAME\u003e",
Description: "",
Default: "false",
},
{
Name: "TRAEFIK_FOO_FIELDIN6_\u003cNAME\u003e_FIELD",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FOO_FIELDIN7_\u003cNAME\u003e",
Description: "",
Default: "false",
},
{
Name: "TRAEFIK_FOO_FIELDIN7_\u003cNAME\u003e_FIELD_\u003cNAME\u003e",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FOO_FIELDIN8_\u003cNAME\u003e",
Description: "",
Default: "false",
},
{
Name: "TRAEFIK_FOO_FIELDIN8_\u003cNAME\u003e_FIELD",
Description: "",
Default: "",
},
{
Name: "TRAEFIK_FOO_FIELDIN9_\u003cNAME\u003e",
Description: "",
Default: "false",
},
{
Name: "TRAEFIK_FOO_FIELDIN9_\u003cNAME\u003e_FIELD_\u003cNAME\u003e",
Description: "",
Default: "",
},
}
assert.Equal(t, expected, flats)
}

View File

@@ -0,0 +1,64 @@
package env
import (
"reflect"
"strings"
"github.com/crazy-max/diun/v3/third_party/traefik/config/parser"
)
// FindPrefixedEnvVars finds prefixed environment variables.
func FindPrefixedEnvVars(environ []string, prefix string, element interface{}) []string {
prefixes := getRootPrefixes(element, prefix)
var values []string
for _, px := range prefixes {
for _, value := range environ {
if strings.HasPrefix(value, px) {
values = append(values, value)
}
}
}
return values
}
func getRootPrefixes(element interface{}, prefix string) []string {
if element == nil {
return nil
}
rootType := reflect.TypeOf(element)
return getPrefixes(prefix, rootType)
}
func getPrefixes(prefix string, rootType reflect.Type) []string {
var names []string
if rootType.Kind() == reflect.Ptr {
rootType = rootType.Elem()
}
if rootType.Kind() != reflect.Struct {
return nil
}
for i := 0; i < rootType.NumField(); i++ {
field := rootType.Field(i)
if !parser.IsExported(field) {
continue
}
if field.Anonymous &&
(field.Type.Kind() == reflect.Ptr && field.Type.Elem().Kind() == reflect.Struct || field.Type.Kind() == reflect.Struct) {
names = append(names, getPrefixes(prefix, field.Type)...)
continue
}
names = append(names, prefix+strings.ToUpper(field.Name))
}
return names
}

View File

@@ -0,0 +1,87 @@
package env
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFindPrefixedEnvVars(t *testing.T) {
testCases := []struct {
desc string
environ []string
element interface{}
expected []string
}{
{
desc: "exact name",
environ: []string{"TRAEFIK_FOO"},
element: &Yo{},
expected: []string{"TRAEFIK_FOO"},
},
{
desc: "prefixed name",
environ: []string{"TRAEFIK_FII01"},
element: &Yo{},
expected: []string{"TRAEFIK_FII01"},
},
{
desc: "excluded env vars",
environ: []string{"TRAEFIK_NOPE", "TRAEFIK_NO"},
element: &Yo{},
expected: nil,
},
{
desc: "filter",
environ: []string{"TRAEFIK_NOPE", "TRAEFIK_NO", "TRAEFIK_FOO", "TRAEFIK_FII01"},
element: &Yo{},
expected: []string{"TRAEFIK_FOO", "TRAEFIK_FII01"},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
vars := FindPrefixedEnvVars(test.environ, DefaultNamePrefix, test.element)
assert.Equal(t, test.expected, vars)
})
}
}
func Test_getRootFieldNames(t *testing.T) {
testCases := []struct {
desc string
element interface{}
expected []string
}{
{
desc: "simple fields",
element: &Yo{},
expected: []string{"TRAEFIK_FOO", "TRAEFIK_FII", "TRAEFIK_FUU", "TRAEFIK_YI", "TRAEFIK_YU"},
},
{
desc: "embedded struct",
element: &Yu{},
expected: []string{"TRAEFIK_FOO", "TRAEFIK_FII", "TRAEFIK_FUU"},
},
{
desc: "embedded struct pointer",
element: &Ye{},
expected: []string{"TRAEFIK_FOO", "TRAEFIK_FII", "TRAEFIK_FUU"},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
names := getRootPrefixes(test.element, DefaultNamePrefix)
assert.Equal(t, test.expected, names)
})
}
}

View File

@@ -0,0 +1,69 @@
package env
type Ya struct {
Foo *Yaa
Field1 string
Field2 bool
Field3 int
Field4 map[string]string
Field5 map[string]int
Field6 map[string]struct{ Field string }
Field7 map[string]struct{ Field map[string]string }
Field8 map[string]*struct{ Field string }
Field9 map[string]*struct{ Field map[string]string }
Field10 struct{ Field string }
Field11 *struct{ Field string }
Field12 *string
Field13 *bool
Field14 *int
Field15 []int
}
type Yaa struct {
FieldIn1 string
FieldIn2 bool
FieldIn3 int
FieldIn4 map[string]string
FieldIn5 map[string]int
FieldIn6 map[string]struct{ Field string }
FieldIn7 map[string]struct{ Field map[string]string }
FieldIn8 map[string]*struct{ Field string }
FieldIn9 map[string]*struct{ Field map[string]string }
FieldIn10 struct{ Field string }
FieldIn11 *struct{ Field string }
FieldIn12 *string
FieldIn13 *bool
FieldIn14 *int
}
type Yo struct {
Foo string `description:"Foo description"`
Fii string `description:"Fii description"`
Fuu string `description:"Fuu description"`
Yi *Yi `label:"allowEmpty"`
Yu *Yi
}
func (y *Yo) SetDefaults() {
y.Foo = "foo"
y.Fii = "fii"
}
type Yi struct {
Foo string
Fii string
Fuu string
}
func (y *Yi) SetDefaults() {
y.Foo = "foo"
y.Fii = "fii"
}
type Yu struct {
Yi
}
type Ye struct {
*Yi
}

32
third_party/traefik/config/file/file.go vendored Normal file
View File

@@ -0,0 +1,32 @@
// Package file implements decoding between configuration in a file and a typed Configuration.
package file
import (
"github.com/crazy-max/diun/v3/third_party/traefik/config/parser"
)
// Decode decodes the given configuration file into the given element.
// The operation goes through three stages roughly summarized as:
// file contents -> tree of untyped nodes
// untyped nodes -> nodes augmented with metadata such as kind (inferred from element)
// "typed" nodes -> typed element
func Decode(filePath string, element interface{}) error {
if element == nil {
return nil
}
filters := getRootFieldNames(element)
root, err := decodeFileToNode(filePath, filters...)
if err != nil {
return err
}
metaOpts := parser.MetadataOpts{TagName: parser.TagLabel, AllowSliceAsStruct: true}
err = parser.AddMetadata(element, root, metaOpts)
if err != nil {
return err
}
return parser.Fill(element, root, parser.FillerOpts{AllowSliceAsStruct: true})
}

View File

@@ -0,0 +1,90 @@
package file
import (
"fmt"
"io/ioutil"
"path/filepath"
"reflect"
"strings"
"github.com/crazy-max/diun/v3/third_party/traefik/config/parser"
"gopkg.in/yaml.v2"
)
// decodeFileToNode decodes the configuration in filePath in a tree of untyped nodes.
// If filters is not empty, it skips any configuration element whose name is not among filters.
func decodeFileToNode(filePath string, filters ...string) (*parser.Node, error) {
content, err := ioutil.ReadFile(filePath)
if err != nil {
return nil, err
}
data := make(map[string]interface{})
switch strings.ToLower(filepath.Ext(filePath)) {
case ".yml", ".yaml":
err = yaml.Unmarshal(content, data)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported file extension: %s", filePath)
}
if len(data) == 0 {
return nil, fmt.Errorf("no configuration found in file: %s", filePath)
}
node, err := decodeRawToNode(data, parser.DefaultRootName, filters...)
if err != nil {
return nil, err
}
if len(node.Children) == 0 {
return nil, fmt.Errorf("no valid configuration found in file: %s", filePath)
}
return node, nil
}
func getRootFieldNames(element interface{}) []string {
if element == nil {
return nil
}
rootType := reflect.TypeOf(element)
return getFieldNames(rootType)
}
func getFieldNames(rootType reflect.Type) []string {
var names []string
if rootType.Kind() == reflect.Ptr {
rootType = rootType.Elem()
}
if rootType.Kind() != reflect.Struct {
return nil
}
for i := 0; i < rootType.NumField(); i++ {
field := rootType.Field(i)
if !parser.IsExported(field) {
continue
}
if field.Anonymous &&
(field.Type.Kind() == reflect.Ptr && field.Type.Elem().Kind() == reflect.Struct || field.Type.Kind() == reflect.Struct) {
names = append(names, getFieldNames(field.Type)...)
continue
}
names = append(names, field.Name)
}
return names
}

View File

@@ -0,0 +1,276 @@
package file
import (
"testing"
"github.com/crazy-max/diun/v3/third_party/traefik/config/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_getRootFieldNames(t *testing.T) {
testCases := []struct {
desc string
element interface{}
expected []string
}{
{
desc: "simple fields",
element: &Yo{},
expected: []string{"Foo", "Fii", "Fuu", "Yi"},
},
{
desc: "embedded struct",
element: &Yu{},
expected: []string{"Foo", "Fii", "Fuu"},
},
{
desc: "embedded struct pointer",
element: &Ye{},
expected: []string{"Foo", "Fii", "Fuu"},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
names := getRootFieldNames(test.element)
assert.Equal(t, test.expected, names)
})
}
}
func Test_decodeFileToNode_Yaml(t *testing.T) {
node, err := decodeFileToNode("./fixtures/sample.yml")
require.NoError(t, err)
expected := &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "accessLog", Children: []*parser.Node{
{Name: "bufferingSize", Value: "42"},
{Name: "fields", Children: []*parser.Node{
{Name: "defaultMode", Value: "foobar"},
{Name: "headers", Children: []*parser.Node{
{Name: "defaultMode", Value: "foobar"},
{Name: "names", Children: []*parser.Node{
{Name: "name0", Value: "foobar"},
{Name: "name1", Value: "foobar"}}}}},
{Name: "names", Children: []*parser.Node{
{Name: "name0", Value: "foobar"},
{Name: "name1", Value: "foobar"}}}}},
{Name: "filePath", Value: "foobar"},
{Name: "filters", Children: []*parser.Node{
{Name: "minDuration", Value: "42"},
{Name: "retryAttempts", Value: "true"},
{Name: "statusCodes", Value: "foobar,foobar"}}},
{Name: "format", Value: "foobar"}}},
{Name: "api", Children: []*parser.Node{
{Name: "dashboard", Value: "true"},
{Name: "entryPoint", Value: "foobar"},
{Name: "middlewares", Value: "foobar,foobar"},
{Name: "statistics", Children: []*parser.Node{
{Name: "recentErrors", Value: "42"}}}}},
{Name: "certificatesResolvers", Children: []*parser.Node{
{Name: "default", Children: []*parser.Node{
{Name: "acme",
Children: []*parser.Node{
{Name: "acmeLogging", Value: "true"},
{Name: "caServer", Value: "foobar"},
{Name: "dnsChallenge", Children: []*parser.Node{
{Name: "delayBeforeCheck", Value: "42"},
{Name: "disablePropagationCheck", Value: "true"},
{Name: "provider", Value: "foobar"},
{Name: "resolvers", Value: "foobar,foobar"},
}},
{Name: "email", Value: "foobar"},
{Name: "entryPoint", Value: "foobar"},
{Name: "httpChallenge", Children: []*parser.Node{
{Name: "entryPoint", Value: "foobar"}}},
{Name: "keyType", Value: "foobar"},
{Name: "storage", Value: "foobar"},
{Name: "tlsChallenge"},
},
},
}},
}},
{Name: "entryPoints", Children: []*parser.Node{
{Name: "EntryPoint0", Children: []*parser.Node{
{Name: "address", Value: "foobar"},
{Name: "forwardedHeaders", Children: []*parser.Node{
{Name: "insecure", Value: "true"},
{Name: "trustedIPs", Value: "foobar,foobar"}}},
{Name: "proxyProtocol", Children: []*parser.Node{
{Name: "insecure", Value: "true"},
{Name: "trustedIPs", Value: "foobar,foobar"}}},
{Name: "transport", Children: []*parser.Node{
{Name: "lifeCycle", Children: []*parser.Node{
{Name: "graceTimeOut", Value: "42"},
{Name: "requestAcceptGraceTimeout", Value: "42"}}},
{Name: "respondingTimeouts", Children: []*parser.Node{
{Name: "idleTimeout", Value: "42"},
{Name: "readTimeout", Value: "42"},
{Name: "writeTimeout", Value: "42"}}}}}}}}},
{Name: "global", Children: []*parser.Node{
{Name: "checkNewVersion", Value: "true"},
{Name: "sendAnonymousUsage", Value: "true"}}},
{Name: "hostResolver", Children: []*parser.Node{
{Name: "cnameFlattening", Value: "true"},
{Name: "resolvConfig", Value: "foobar"},
{Name: "resolvDepth", Value: "42"}}},
{Name: "log", Children: []*parser.Node{
{Name: "filePath", Value: "foobar"},
{Name: "format", Value: "foobar"},
{Name: "level", Value: "foobar"}}},
{Name: "metrics", Children: []*parser.Node{
{Name: "datadog", Children: []*parser.Node{
{Name: "address", Value: "foobar"},
{Name: "pushInterval", Value: "10s"}}},
{Name: "influxDB", Children: []*parser.Node{
{Name: "address", Value: "foobar"},
{Name: "database", Value: "foobar"},
{Name: "password", Value: "foobar"},
{Name: "protocol", Value: "foobar"},
{Name: "pushInterval", Value: "10s"},
{Name: "retentionPolicy", Value: "foobar"},
{Name: "username", Value: "foobar"}}},
{Name: "prometheus", Children: []*parser.Node{
{Name: "buckets", Value: "42,42"},
{Name: "entryPoint", Value: "foobar"},
{Name: "middlewares", Value: "foobar,foobar"}}},
{Name: "statsD", Children: []*parser.Node{
{Name: "address", Value: "foobar"},
{Name: "pushInterval", Value: "10s"}}}}},
{Name: "ping", Children: []*parser.Node{
{Name: "entryPoint", Value: "foobar"},
{Name: "middlewares", Value: "foobar,foobar"}}},
{Name: "providers", Children: []*parser.Node{
{Name: "docker", Children: []*parser.Node{
{Name: "constraints", Value: "foobar"},
{Name: "defaultRule", Value: "foobar"},
{Name: "endpoint", Value: "foobar"},
{Name: "exposedByDefault", Value: "true"},
{Name: "network", Value: "foobar"},
{Name: "swarmMode", Value: "true"},
{Name: "swarmModeRefreshSeconds", Value: "42"},
{Name: "tls", Children: []*parser.Node{
{Name: "ca", Value: "foobar"},
{Name: "caOptional", Value: "true"},
{Name: "cert", Value: "foobar"},
{Name: "insecureSkipVerify", Value: "true"},
{Name: "key", Value: "foobar"}}},
{Name: "useBindPortIP", Value: "true"},
{Name: "watch", Value: "true"}}},
{Name: "file", Children: []*parser.Node{
{Name: "debugLogGeneratedTemplate", Value: "true"},
{Name: "directory", Value: "foobar"},
{Name: "filename", Value: "foobar"},
{Name: "watch", Value: "true"}}},
{Name: "kubernetesCRD",
Children: []*parser.Node{
{Name: "certAuthFilePath", Value: "foobar"},
{Name: "disablePassHostHeaders", Value: "true"},
{Name: "endpoint", Value: "foobar"},
{Name: "ingressClass", Value: "foobar"},
{Name: "labelSelector", Value: "foobar"},
{Name: "namespaces", Value: "foobar,foobar"},
{Name: "token", Value: "foobar"}}},
{Name: "kubernetesIngress", Children: []*parser.Node{
{Name: "certAuthFilePath", Value: "foobar"},
{Name: "disablePassHostHeaders", Value: "true"},
{Name: "endpoint", Value: "foobar"},
{Name: "ingressClass", Value: "foobar"},
{Name: "ingressEndpoint", Children: []*parser.Node{
{Name: "hostname", Value: "foobar"},
{Name: "ip", Value: "foobar"},
{Name: "publishedService", Value: "foobar"}}},
{Name: "labelSelector", Value: "foobar"},
{Name: "namespaces", Value: "foobar,foobar"},
{Name: "token", Value: "foobar"}}},
{Name: "marathon", Children: []*parser.Node{
{Name: "basic", Children: []*parser.Node{
{Name: "httpBasicAuthUser", Value: "foobar"},
{Name: "httpBasicPassword", Value: "foobar"}}},
{Name: "constraints", Value: "foobar"},
{Name: "dcosToken", Value: "foobar"},
{Name: "defaultRule", Value: "foobar"},
{Name: "dialerTimeout", Value: "42"},
{Name: "endpoint", Value: "foobar"},
{Name: "exposedByDefault", Value: "true"},
{Name: "forceTaskHostname", Value: "true"},
{Name: "keepAlive", Value: "42"},
{Name: "respectReadinessChecks", Value: "true"},
{Name: "responseHeaderTimeout", Value: "42"},
{Name: "tls", Children: []*parser.Node{
{Name: "ca", Value: "foobar"},
{Name: "caOptional", Value: "true"},
{Name: "cert", Value: "foobar"},
{Name: "insecureSkipVerify", Value: "true"},
{Name: "key", Value: "foobar"}}},
{Name: "tlsHandshakeTimeout", Value: "42"},
{Name: "trace", Value: "true"},
{Name: "watch", Value: "true"}}},
{Name: "providersThrottleDuration", Value: "42"},
{Name: "rancher", Children: []*parser.Node{
{Name: "constraints", Value: "foobar"},
{Name: "defaultRule", Value: "foobar"},
{Name: "enableServiceHealthFilter", Value: "true"},
{Name: "exposedByDefault", Value: "true"},
{Name: "intervalPoll", Value: "true"},
{Name: "prefix", Value: "foobar"},
{Name: "refreshSeconds", Value: "42"},
{Name: "watch", Value: "true"}}},
{Name: "rest", Children: []*parser.Node{
{Name: "entryPoint", Value: "foobar"}}}}},
{Name: "serversTransport", Children: []*parser.Node{
{Name: "forwardingTimeouts", Children: []*parser.Node{
{Name: "dialTimeout", Value: "42"},
{Name: "idleConnTimeout", Value: "42"},
{Name: "responseHeaderTimeout", Value: "42"}}},
{Name: "insecureSkipVerify", Value: "true"},
{Name: "maxIdleConnsPerHost", Value: "42"},
{Name: "rootCAs", Value: "foobar,foobar"}}},
{Name: "tracing", Children: []*parser.Node{
{Name: "datadog", Children: []*parser.Node{
{Name: "bagagePrefixHeaderName", Value: "foobar"},
{Name: "debug", Value: "true"},
{Name: "globalTag", Value: "foobar"},
{Name: "localAgentHostPort", Value: "foobar"},
{Name: "parentIDHeaderName", Value: "foobar"},
{Name: "prioritySampling", Value: "true"},
{Name: "samplingPriorityHeaderName", Value: "foobar"},
{Name: "traceIDHeaderName", Value: "foobar"}}},
{Name: "haystack", Children: []*parser.Node{
{Name: "globalTag", Value: "foobar"},
{Name: "localAgentHost", Value: "foobar"},
{Name: "localAgentPort", Value: "42"},
{Name: "parentIDHeaderName", Value: "foobar"},
{Name: "spanIDHeaderName", Value: "foobar"},
{Name: "traceIDHeaderName", Value: "foobar"}}},
{Name: "instana", Children: []*parser.Node{
{Name: "localAgentHost", Value: "foobar"},
{Name: "localAgentPort", Value: "42"},
{Name: "logLevel", Value: "foobar"}}},
{Name: "jaeger", Children: []*parser.Node{
{Name: "gen128Bit", Value: "true"},
{Name: "localAgentHostPort", Value: "foobar"},
{Name: "propagation", Value: "foobar"},
{Name: "samplingParam", Value: "42"},
{Name: "samplingServerURL", Value: "foobar"},
{Name: "samplingType", Value: "foobar"},
{Name: "traceContextHeaderName", Value: "foobar"}}},
{Name: "serviceName", Value: "foobar"},
{Name: "spanNameLimit", Value: "42"},
{Name: "zipkin", Children: []*parser.Node{
{Name: "httpEndpoint", Value: "foobar"},
{Name: "id128Bit", Value: "true"},
{Name: "sameSpan", Value: "true"},
{Name: "sampleRate", Value: "42"}}}}},
},
}
assert.Equal(t, expected, node)
}

View File

@@ -0,0 +1,43 @@
package file
import (
"io/ioutil"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDecode_YAML(t *testing.T) {
f, err := ioutil.TempFile("", "traefik-config-*.yaml")
require.NoError(t, err)
defer func() {
_ = os.Remove(f.Name())
}()
_, err = f.Write([]byte(`
foo: bar
fii: bir
yi: {}
`))
require.NoError(t, err)
element := &Yo{
Fuu: "test",
}
err = Decode(f.Name(), element)
require.NoError(t, err)
expected := &Yo{
Foo: "bar",
Fii: "bir",
Fuu: "test",
Yi: &Yi{
Foo: "foo",
Fii: "fii",
},
}
assert.Equal(t, expected, element)
}

View File

@@ -0,0 +1,235 @@
global:
checkNewVersion: true
sendAnonymousUsage: true
serversTransport:
insecureSkipVerify: true
rootCAs:
- foobar
- foobar
maxIdleConnsPerHost: 42
forwardingTimeouts:
dialTimeout: 42
responseHeaderTimeout: 42
idleConnTimeout: 42
entryPoints:
EntryPoint0:
address: foobar
transport:
lifeCycle:
requestAcceptGraceTimeout: 42
graceTimeOut: 42
respondingTimeouts:
readTimeout: 42
writeTimeout: 42
idleTimeout: 42
proxyProtocol:
insecure: true
trustedIPs:
- foobar
- foobar
forwardedHeaders:
insecure: true
trustedIPs:
- foobar
- foobar
providers:
providersThrottleDuration: 42
docker:
constraints: foobar
watch: true
endpoint: foobar
defaultRule: foobar
tls:
ca: foobar
caOptional: true
cert: foobar
key: foobar
insecureSkipVerify: true
exposedByDefault: true
useBindPortIP: true
swarmMode: true
network: foobar
swarmModeRefreshSeconds: 42
file:
directory: foobar
watch: true
filename: foobar
debugLogGeneratedTemplate: true
marathon:
constraints: foobar
trace: true
watch: true
endpoint: foobar
defaultRule: foobar
exposedByDefault: true
dcosToken: foobar
tls:
ca: foobar
caOptional: true
cert: foobar
key: foobar
insecureSkipVerify: true
dialerTimeout: 42
responseHeaderTimeout: 42
tlsHandshakeTimeout: 42
keepAlive: 42
forceTaskHostname: true
basic:
httpBasicAuthUser: foobar
httpBasicPassword: foobar
respectReadinessChecks: true
kubernetesIngress:
endpoint: foobar
token: foobar
certAuthFilePath: foobar
disablePassHostHeaders: true
namespaces:
- foobar
- foobar
labelSelector: foobar
ingressClass: foobar
ingressEndpoint:
ip: foobar
hostname: foobar
publishedService: foobar
kubernetesCRD:
endpoint: foobar
token: foobar
certAuthFilePath: foobar
disablePassHostHeaders: true
namespaces:
- foobar
- foobar
labelSelector: foobar
ingressClass: foobar
rest:
entryPoint: foobar
rancher:
constraints: foobar
watch: true
defaultRule: foobar
exposedByDefault: true
enableServiceHealthFilter: true
refreshSeconds: 42
intervalPoll: true
prefix: foobar
api:
entryPoint: foobar
dashboard: true
statistics:
recentErrors: 42
middlewares:
- foobar
- foobar
metrics:
prometheus:
buckets:
- 42
- 42
entryPoint: foobar
middlewares:
- foobar
- foobar
datadog:
address: foobar
pushInterval: 10s
statsD:
address: foobar
pushInterval: 10s
influxDB:
address: foobar
protocol: foobar
pushInterval: 10s
database: foobar
retentionPolicy: foobar
username: foobar
password: foobar
ping:
entryPoint: foobar
middlewares:
- foobar
- foobar
log:
level: foobar
filePath: foobar
format: foobar
accessLog:
filePath: foobar
format: foobar
filters:
statusCodes:
- foobar
- foobar
retryAttempts: true
minDuration: 42
fields:
defaultMode: foobar
names:
name0: foobar
name1: foobar
headers:
defaultMode: foobar
names:
name0: foobar
name1: foobar
bufferingSize: 42
tracing:
serviceName: foobar
spanNameLimit: 42
jaeger:
samplingServerURL: foobar
samplingType: foobar
samplingParam: 42
localAgentHostPort: foobar
gen128Bit: true
propagation: foobar
traceContextHeaderName: foobar
zipkin:
httpEndpoint: foobar
sameSpan: true
id128Bit: true
sampleRate: 42
datadog:
localAgentHostPort: foobar
globalTag: foobar
debug: true
prioritySampling: true
traceIDHeaderName: foobar
parentIDHeaderName: foobar
samplingPriorityHeaderName: foobar
bagagePrefixHeaderName: foobar
instana:
localAgentHost: foobar
localAgentPort: 42
logLevel: foobar
haystack:
localAgentHost: foobar
localAgentPort: 42
globalTag: foobar
traceIDHeaderName: foobar
parentIDHeaderName: foobar
spanIDHeaderName: foobar
hostResolver:
cnameFlattening: true
resolvConfig: foobar
resolvDepth: 42
certificatesResolvers:
default:
acme:
email: foobar
acmeLogging: true
caServer: foobar
storage: foobar
entryPoint: foobar
keyType: foobar
dnsChallenge:
provider: foobar
delayBeforeCheck: 42
resolvers:
- foobar
- foobar
disablePropagationCheck: true
httpChallenge:
entryPoint: foobar
tlsChallenge: {}

View File

@@ -0,0 +1,34 @@
package file
type bar string
type Yo struct {
Foo string
Fii string
Fuu string
Yi *Yi `label:"allowEmpty"`
}
func (y *Yo) SetDefaults() {
y.Foo = "foo"
y.Fii = "fii"
}
type Yi struct {
Foo string
Fii string
Fuu string
}
func (y *Yi) SetDefaults() {
y.Foo = "foo"
y.Fii = "fii"
}
type Yu struct {
Yi
}
type Ye struct {
*Yi
}

View File

@@ -0,0 +1,148 @@
package file
import (
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"github.com/crazy-max/diun/v3/third_party/traefik/config/parser"
)
func decodeRawToNode(data map[string]interface{}, rootName string, filters ...string) (*parser.Node, error) {
root := &parser.Node{
Name: rootName,
}
vData := reflect.ValueOf(data)
err := decodeRaw(root, vData, filters...)
if err != nil {
return nil, err
}
return root, nil
}
func decodeRaw(node *parser.Node, vData reflect.Value, filters ...string) error {
sortedKeys := sortKeys(vData, filters)
for _, key := range sortedKeys {
value := reflect.ValueOf(vData.MapIndex(key).Interface())
child := &parser.Node{Name: key.String()}
switch value.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
fallthrough
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
fallthrough
case reflect.Float32, reflect.Float64:
fallthrough
case reflect.Bool:
fallthrough
case reflect.String:
value, err := getSimpleValue(value)
if err != nil {
return err
}
child.Value = value
case reflect.Slice:
var values []string
for i := 0; i < value.Len(); i++ {
item := value.Index(i)
switch item.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
fallthrough
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
fallthrough
case reflect.Bool:
fallthrough
case reflect.String:
fallthrough
case reflect.Map:
fallthrough
case reflect.Interface:
sValue := reflect.ValueOf(item.Interface())
if sValue.Kind() == reflect.Map {
ch := &parser.Node{
Name: "[" + strconv.Itoa(i) + "]",
}
child.Children = append(child.Children, ch)
err := decodeRaw(ch, sValue)
if err != nil {
return err
}
} else {
val, err := getSimpleValue(sValue)
if err != nil {
return err
}
values = append(values, val)
}
default:
return fmt.Errorf("field %s uses unsupported slice type: %s", child.Name, item.Kind().String())
}
}
child.Value = strings.Join(values, ",")
case reflect.Map:
err := decodeRaw(child, value)
if err != nil {
return err
}
default:
return fmt.Errorf("field %s uses unsupported type: %s", child.Name, value.Kind().String())
}
node.Children = append(node.Children, child)
}
return nil
}
func getSimpleValue(item reflect.Value) (string, error) {
switch item.Kind() {
case reflect.String:
return item.String(), nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(item.Int(), 10), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return strconv.FormatUint(item.Uint(), 10), nil
case reflect.Float32, reflect.Float64:
return strings.TrimSuffix(strconv.FormatFloat(item.Float(), 'f', 6, 64), ".000000"), nil
case reflect.Bool:
return strconv.FormatBool(item.Bool()), nil
default:
return "", fmt.Errorf("unsupported simple value type: %s", item.Kind().String())
}
}
func sortKeys(vData reflect.Value, filters []string) []reflect.Value {
var sortedKeys []reflect.Value
for _, v := range vData.MapKeys() {
rValue := reflect.ValueOf(v.Interface())
key := rValue.String()
if len(filters) == 0 {
sortedKeys = append(sortedKeys, rValue)
continue
}
for _, filter := range filters {
if strings.EqualFold(key, filter) {
sortedKeys = append(sortedKeys, rValue)
continue
}
}
}
sort.Slice(sortedKeys, func(i, j int) bool {
return sortedKeys[i].String() < sortedKeys[j].String()
})
return sortedKeys
}

View File

@@ -0,0 +1,564 @@
package file
import (
"testing"
"github.com/crazy-max/diun/v3/third_party/traefik/config/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_decodeRawToNode(t *testing.T) {
testCases := []struct {
desc string
data map[string]interface{}
expected *parser.Node
}{
{
desc: "empty",
data: map[string]interface{}{},
expected: &parser.Node{
Name: "traefik",
},
},
{
desc: "string",
data: map[string]interface{}{
"foo": "bar",
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "bar"},
},
},
},
{
desc: "string named type",
data: map[string]interface{}{
"foo": bar("bar"),
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "bar"},
},
},
},
{
desc: "bool",
data: map[string]interface{}{
"foo": true,
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "true"},
},
},
},
{
desc: "int",
data: map[string]interface{}{
"foo": 1,
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1"},
},
},
},
{
desc: "int8",
data: map[string]interface{}{
"foo": int8(1),
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1"},
},
},
},
{
desc: "int16",
data: map[string]interface{}{
"foo": int16(1),
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1"},
},
},
},
{
desc: "int32",
data: map[string]interface{}{
"foo": int32(1),
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1"},
},
},
},
{
desc: "int64",
data: map[string]interface{}{
"foo": int64(1),
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1"},
},
},
},
{
desc: "uint",
data: map[string]interface{}{
"foo": uint(1),
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1"},
},
},
},
{
desc: "uint8",
data: map[string]interface{}{
"foo": uint8(1),
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1"},
},
},
},
{
desc: "uint16",
data: map[string]interface{}{
"foo": uint16(1),
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1"},
},
},
},
{
desc: "uint32",
data: map[string]interface{}{
"foo": uint32(1),
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1"},
},
},
},
{
desc: "uint64",
data: map[string]interface{}{
"foo": uint64(1),
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1"},
},
},
},
{
desc: "float32",
data: map[string]interface{}{
"foo": float32(1),
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1"},
},
},
},
{
desc: "float64",
data: map[string]interface{}{
"foo": float64(1),
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1"},
},
},
},
{
desc: "string slice",
data: map[string]interface{}{
"foo": []string{"A", "B"},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "A,B"},
},
},
},
{
desc: "int slice",
data: map[string]interface{}{
"foo": []int{1, 2},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1,2"},
},
},
},
{
desc: "int8 slice",
data: map[string]interface{}{
"foo": []int8{1, 2},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1,2"},
},
},
},
{
desc: "int16 slice",
data: map[string]interface{}{
"foo": []int16{1, 2},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1,2"},
},
},
},
{
desc: "int32 slice",
data: map[string]interface{}{
"foo": []int32{1, 2},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1,2"},
},
},
},
{
desc: "int64 slice",
data: map[string]interface{}{
"foo": []int64{1, 2},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1,2"},
},
},
},
{
desc: "bool slice",
data: map[string]interface{}{
"foo": []bool{true, false},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "true,false"},
},
},
},
{
desc: "interface (string) slice",
data: map[string]interface{}{
"foo": []interface{}{"A", "B"},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "A,B"},
},
},
},
{
desc: "interface (int) slice",
data: map[string]interface{}{
"foo": []interface{}{1, 2},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Value: "1,2"},
},
},
},
{
desc: "2 strings",
data: map[string]interface{}{
"foo": "bar",
"fii": "bir",
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "fii", Value: "bir"},
{Name: "foo", Value: "bar"},
},
},
},
{
desc: "string, level 2",
data: map[string]interface{}{
"fii": map[interface{}]interface{}{
"fuu": "bur",
},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "fii", Children: []*parser.Node{{Name: "fuu", Value: "bur"}}},
},
},
},
{
desc: "int, level 2",
data: map[string]interface{}{
"fii": map[interface{}]interface{}{
"fuu": 1,
},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "fii", Children: []*parser.Node{{Name: "fuu", Value: "1"}}},
},
},
},
{
desc: "uint, level 2",
data: map[string]interface{}{
"fii": map[interface{}]interface{}{
"fuu": uint(1),
},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "fii", Children: []*parser.Node{{Name: "fuu", Value: "1"}}},
},
},
},
{
desc: "bool, level 2",
data: map[string]interface{}{
"fii": map[interface{}]interface{}{
"fuu": true,
},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "fii", Children: []*parser.Node{{Name: "fuu", Value: "true"}}},
},
},
},
{
desc: "string, level 3",
data: map[string]interface{}{
"foo": map[interface{}]interface{}{
"fii": map[interface{}]interface{}{
"fuu": "bur",
},
},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Children: []*parser.Node{
{Name: "fii", Children: []*parser.Node{{Name: "fuu", Value: "bur"}}},
}},
},
},
},
{
desc: "int, level 3",
data: map[string]interface{}{
"fii": map[interface{}]interface{}{
"fuu": 1,
},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "fii", Children: []*parser.Node{{Name: "fuu", Value: "1"}}},
},
},
},
{
desc: "uint, level 3",
data: map[string]interface{}{
"fii": map[interface{}]interface{}{
"fuu": uint(1),
},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "fii", Children: []*parser.Node{{Name: "fuu", Value: "1"}}},
},
},
},
{
desc: "bool, level 3",
data: map[string]interface{}{
"fii": map[interface{}]interface{}{
"fuu": true,
},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "fii", Children: []*parser.Node{{Name: "fuu", Value: "true"}}},
},
},
},
{
desc: "struct",
data: map[string]interface{}{
"foo": map[interface{}]interface{}{
"field1": "C",
"field2": "C",
},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Children: []*parser.Node{
{Name: "field1", Value: "C"},
{Name: "field2", Value: "C"},
}},
},
},
},
{
desc: "slice struct 1",
data: map[string]interface{}{
"foo": []map[string]interface{}{
{"field1": "A", "field2": "A"},
{"field1": "B", "field2": "B"},
{"field2": "C", "field1": "C"},
},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Children: []*parser.Node{
{Name: "[0]", Children: []*parser.Node{
{Name: "field1", Value: "A"},
{Name: "field2", Value: "A"},
}},
{Name: "[1]", Children: []*parser.Node{
{Name: "field1", Value: "B"},
{Name: "field2", Value: "B"},
}},
{Name: "[2]", Children: []*parser.Node{
{Name: "field1", Value: "C"},
{Name: "field2", Value: "C"},
}},
}},
},
},
},
{
desc: "slice struct 2",
data: map[string]interface{}{
"foo": []interface{}{
map[interface{}]interface{}{
"field2": "A",
"field1": "A",
},
map[interface{}]interface{}{
"field1": "B",
"field2": "B",
},
map[interface{}]interface{}{
"field1": "C",
"field2": "C",
},
},
},
expected: &parser.Node{
Name: "traefik",
Children: []*parser.Node{
{Name: "foo", Children: []*parser.Node{
{Name: "[0]", Children: []*parser.Node{
{Name: "field1", Value: "A"},
{Name: "field2", Value: "A"},
}},
{Name: "[1]", Children: []*parser.Node{
{Name: "field1", Value: "B"},
{Name: "field2", Value: "B"},
}},
{Name: "[2]", Children: []*parser.Node{
{Name: "field1", Value: "C"},
{Name: "field2", Value: "C"},
}},
}},
},
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
node, err := decodeRawToNode(test.data, parser.DefaultRootName)
require.NoError(t, err)
assert.Equal(t, test.expected, node)
})
}
}
func Test_decodeRawToNode_errors(t *testing.T) {
testCases := []struct {
desc string
data map[string]interface{}
}{
{
desc: "invalid type",
data: map[string]interface{}{
"foo": struct{}{},
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
_, err := decodeRawToNode(test.data, parser.DefaultRootName)
require.Error(t, err)
})
}
}

View File

@@ -0,0 +1,97 @@
// Package generator implements the custom initialization of all the fields of an empty interface.
package generator
import (
"reflect"
"github.com/crazy-max/diun/v3/third_party/traefik/config/parser"
)
type initializer interface {
SetDefaults()
}
// Generate recursively initializes an empty structure, calling SetDefaults on each field, when it applies.
func Generate(element interface{}) {
if element == nil {
return
}
generate(element)
}
func generate(element interface{}) {
field := reflect.ValueOf(element)
fill(field)
}
func fill(field reflect.Value) {
switch field.Kind() {
case reflect.Ptr:
setPtr(field)
case reflect.Struct:
setStruct(field)
case reflect.Map:
setMap(field)
case reflect.Slice:
if field.Type().Elem().Kind() == reflect.Struct ||
field.Type().Elem().Kind() == reflect.Ptr && field.Type().Elem().Elem().Kind() == reflect.Struct {
slice := reflect.MakeSlice(field.Type(), 1, 1)
field.Set(slice)
// use Ptr to allow "SetDefaults"
value := reflect.New(reflect.PtrTo(field.Type().Elem()))
setPtr(value)
elem := value.Elem().Elem()
field.Index(0).Set(elem)
} else if field.Len() == 0 {
slice := reflect.MakeSlice(field.Type(), 0, 0)
field.Set(slice)
}
}
}
func setPtr(field reflect.Value) {
if field.IsNil() {
field.Set(reflect.New(field.Type().Elem()))
}
if field.Type().Implements(reflect.TypeOf((*initializer)(nil)).Elem()) {
method := field.MethodByName("SetDefaults")
if method.IsValid() {
method.Call([]reflect.Value{})
}
}
fill(field.Elem())
}
func setStruct(field reflect.Value) {
for i := 0; i < field.NumField(); i++ {
fd := field.Field(i)
structField := field.Type().Field(i)
if structField.Tag.Get(parser.TagLabel) == "-" {
continue
}
if parser.IsExported(structField) {
fill(fd)
}
}
}
func setMap(field reflect.Value) {
if field.IsNil() {
field.Set(reflect.MakeMap(field.Type()))
}
ptrValue := reflect.New(reflect.PtrTo(field.Type().Elem()))
fill(ptrValue)
value := ptrValue.Elem().Elem()
key := reflect.ValueOf(parser.MapNamePlaceholder)
field.SetMapIndex(key, value)
}

View File

@@ -0,0 +1,439 @@
package generator
import (
"testing"
"github.com/crazy-max/diun/v3/third_party/traefik/config/parser"
"github.com/stretchr/testify/assert"
)
func TestGenerate(t *testing.T) {
testCases := []struct {
desc string
element interface{}
expected interface{}
}{
{
desc: "nil",
},
{
desc: "simple",
element: &Ya{},
expected: &Ya{
Foo: &Yaa{
FieldIn1: "",
FieldIn2: false,
FieldIn3: 0,
FieldIn4: map[string]string{
parser.MapNamePlaceholder: "",
},
FieldIn5: map[string]int{
parser.MapNamePlaceholder: 0,
},
FieldIn6: map[string]struct{ Field string }{
parser.MapNamePlaceholder: {},
},
FieldIn7: map[string]struct{ Field map[string]string }{
parser.MapNamePlaceholder: {
Field: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
FieldIn8: map[string]*struct{ Field string }{
parser.MapNamePlaceholder: {},
},
FieldIn9: map[string]*struct{ Field map[string]string }{
parser.MapNamePlaceholder: {
Field: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
FieldIn10: struct{ Field string }{},
FieldIn11: &struct{ Field string }{},
FieldIn12: func(v string) *string { return &v }(""),
FieldIn13: func(v bool) *bool { return &v }(false),
FieldIn14: func(v int) *int { return &v }(0),
},
Field1: "",
Field2: false,
Field3: 0,
Field4: map[string]string{
parser.MapNamePlaceholder: "",
},
Field5: map[string]int{
parser.MapNamePlaceholder: 0,
},
Field6: map[string]struct{ Field string }{
parser.MapNamePlaceholder: {},
},
Field7: map[string]struct{ Field map[string]string }{
parser.MapNamePlaceholder: {
Field: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
Field8: map[string]*struct{ Field string }{
parser.MapNamePlaceholder: {},
},
Field9: map[string]*struct{ Field map[string]string }{
parser.MapNamePlaceholder: {
Field: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
Field10: struct{ Field string }{},
Field11: &struct{ Field string }{},
Field12: func(v string) *string { return &v }(""),
Field13: func(v bool) *bool { return &v }(false),
Field14: func(v int) *int { return &v }(0),
Field15: []int{},
},
},
{
desc: "with initial state",
element: &Ya{
Foo: &Yaa{
FieldIn1: "bar",
FieldIn2: false,
FieldIn3: 1,
FieldIn4: nil,
FieldIn5: nil,
FieldIn6: nil,
FieldIn7: nil,
FieldIn8: nil,
FieldIn9: nil,
FieldIn10: struct{ Field string }{},
FieldIn11: nil,
FieldIn12: nil,
FieldIn13: nil,
FieldIn14: nil,
},
Field1: "bir",
Field2: true,
Field3: 0,
Field4: nil,
Field5: nil,
Field6: nil,
Field7: nil,
Field8: nil,
Field9: nil,
Field10: struct{ Field string }{},
Field11: nil,
Field12: nil,
Field13: nil,
Field14: nil,
Field15: []int{7},
},
expected: &Ya{
Foo: &Yaa{
FieldIn1: "bar",
FieldIn2: false,
FieldIn3: 1,
FieldIn4: map[string]string{
parser.MapNamePlaceholder: "",
},
FieldIn5: map[string]int{
parser.MapNamePlaceholder: 0,
},
FieldIn6: map[string]struct{ Field string }{
parser.MapNamePlaceholder: {},
},
FieldIn7: map[string]struct{ Field map[string]string }{
parser.MapNamePlaceholder: {
Field: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
FieldIn8: map[string]*struct{ Field string }{
parser.MapNamePlaceholder: {},
},
FieldIn9: map[string]*struct{ Field map[string]string }{
parser.MapNamePlaceholder: {
Field: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
FieldIn10: struct{ Field string }{},
FieldIn11: &struct{ Field string }{},
FieldIn12: func(v string) *string { return &v }(""),
FieldIn13: func(v bool) *bool { return &v }(false),
FieldIn14: func(v int) *int { return &v }(0),
},
Field1: "bir",
Field2: true,
Field3: 0,
Field4: map[string]string{
parser.MapNamePlaceholder: "",
},
Field5: map[string]int{
parser.MapNamePlaceholder: 0,
},
Field6: map[string]struct{ Field string }{
parser.MapNamePlaceholder: {},
},
Field7: map[string]struct{ Field map[string]string }{
parser.MapNamePlaceholder: {
Field: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
Field8: map[string]*struct{ Field string }{
parser.MapNamePlaceholder: {},
},
Field9: map[string]*struct{ Field map[string]string }{
parser.MapNamePlaceholder: {
Field: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
Field10: struct{ Field string }{},
Field11: &struct{ Field string }{},
Field12: func(v string) *string { return &v }(""),
Field13: func(v bool) *bool { return &v }(false),
Field14: func(v int) *int { return &v }(0),
Field15: []int{7},
},
},
{
desc: "setDefault",
element: &Hu{},
expected: &Hu{
Foo: "hu",
Fii: &Hi{
Field: "hi",
},
Fuu: map[string]string{"<name>": ""},
Fee: map[string]Hi{"<name>": {Field: "hi"}},
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
Generate(test.element)
assert.Equal(t, test.expected, test.element)
})
}
}
func Test_generate(t *testing.T) {
testCases := []struct {
desc string
element interface{}
expected interface{}
}{
{
desc: "struct pointer",
element: &struct {
Foo string
Fii *struct{ Field string }
}{},
expected: &struct {
Foo string
Fii *struct{ Field string }
}{
Foo: "",
Fii: &struct{ Field string }{
Field: "",
},
},
},
{
desc: "string slice",
element: &struct {
Foo []string
}{},
expected: &struct {
Foo []string
}{
Foo: []string{},
},
},
{
desc: "int slice",
element: &struct {
Foo []int
}{},
expected: &struct {
Foo []int
}{
Foo: []int{},
},
},
{
desc: "struct slice",
element: &struct {
Foo []struct {
Field string
}
}{},
expected: &struct {
Foo []struct {
Field string
}
}{
Foo: []struct {
Field string
}{
{Field: ""},
},
},
},
{
desc: "map string",
element: &struct {
Foo string
Fii map[string]string
}{},
expected: &struct {
Foo string
Fii map[string]string
}{
Foo: "",
Fii: map[string]string{
parser.MapNamePlaceholder: "",
},
},
},
{
desc: "map struct",
element: &struct {
Foo string
Fii map[string]struct{ Field string }
}{},
expected: &struct {
Foo string
Fii map[string]struct{ Field string }
}{
Foo: "",
Fii: map[string]struct{ Field string }{
parser.MapNamePlaceholder: {},
},
},
},
{
desc: "map struct pointer level 2",
element: &struct {
Foo string
Fuu *struct {
Fii map[string]*struct{ Field string }
}
}{},
expected: &struct {
Foo string
Fuu *struct {
Fii map[string]*struct{ Field string }
}
}{
Foo: "",
Fuu: &struct {
Fii map[string]*struct {
Field string
}
}{
Fii: map[string]*struct{ Field string }{
parser.MapNamePlaceholder: {
Field: "",
},
},
},
},
},
{
desc: "SetDefaults",
element: &Hu{},
expected: &Hu{
Foo: "hu",
Fii: &Hi{
Field: "hi",
},
Fuu: map[string]string{
parser.MapNamePlaceholder: "",
},
Fee: map[string]Hi{
parser.MapNamePlaceholder: {
Field: "hi",
},
},
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
generate(test.element)
assert.Equal(t, test.expected, test.element)
})
}
}
type Hu struct {
Foo string
Fii *Hi
Fuu map[string]string
Fee map[string]Hi
}
func (h *Hu) SetDefaults() {
h.Foo = "hu"
}
type Hi struct {
Field string
}
func (h *Hi) SetDefaults() {
h.Field = "hi"
}
type Ya struct {
Foo *Yaa
Field1 string
Field2 bool
Field3 int
Field4 map[string]string
Field5 map[string]int
Field6 map[string]struct{ Field string }
Field7 map[string]struct{ Field map[string]string }
Field8 map[string]*struct{ Field string }
Field9 map[string]*struct{ Field map[string]string }
Field10 struct{ Field string }
Field11 *struct{ Field string }
Field12 *string
Field13 *bool
Field14 *int
Field15 []int
}
type Yaa struct {
FieldIn1 string
FieldIn2 bool
FieldIn3 int
FieldIn4 map[string]string
FieldIn5 map[string]int
FieldIn6 map[string]struct{ Field string }
FieldIn7 map[string]struct{ Field map[string]string }
FieldIn8 map[string]*struct{ Field string }
FieldIn9 map[string]*struct{ Field map[string]string }
FieldIn10 struct{ Field string }
FieldIn11 *struct{ Field string }
FieldIn12 *string
FieldIn13 *bool
FieldIn14 *int
}

View File

@@ -0,0 +1,341 @@
package parser
import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
"github.com/crazy-max/diun/v3/third_party/traefik/types"
)
type initializer interface {
SetDefaults()
}
// FillerOpts Options for the filler.
type FillerOpts struct {
AllowSliceAsStruct bool
}
// Fill populates the fields of the element using the information in node.
func Fill(element interface{}, node *Node, opts FillerOpts) error {
return filler{FillerOpts: opts}.Fill(element, node)
}
type filler struct {
FillerOpts
}
// Fill populates the fields of the element using the information in node.
func (f filler) Fill(element interface{}, node *Node) error {
if element == nil || node == nil {
return nil
}
if node.Kind == 0 {
return fmt.Errorf("missing node type: %s", node.Name)
}
root := reflect.ValueOf(element)
if root.Kind() == reflect.Struct {
return fmt.Errorf("struct are not supported, use pointer instead")
}
return f.fill(root.Elem(), node)
}
func (f filler) fill(field reflect.Value, node *Node) error {
// related to allow-empty tag
if node.Disabled {
return nil
}
switch field.Kind() {
case reflect.String:
field.SetString(node.Value)
return nil
case reflect.Bool:
val, err := strconv.ParseBool(node.Value)
if err != nil {
return err
}
field.SetBool(val)
return nil
case reflect.Int8:
return setInt(field, node.Value, 8)
case reflect.Int16:
return setInt(field, node.Value, 16)
case reflect.Int32:
return setInt(field, node.Value, 32)
case reflect.Int64, reflect.Int:
return setInt(field, node.Value, 64)
case reflect.Uint8:
return setUint(field, node.Value, 8)
case reflect.Uint16:
return setUint(field, node.Value, 16)
case reflect.Uint32:
return setUint(field, node.Value, 32)
case reflect.Uint64, reflect.Uint:
return setUint(field, node.Value, 64)
case reflect.Float32:
return setFloat(field, node.Value, 32)
case reflect.Float64:
return setFloat(field, node.Value, 64)
case reflect.Struct:
return f.setStruct(field, node)
case reflect.Ptr:
return f.setPtr(field, node)
case reflect.Map:
return f.setMap(field, node)
case reflect.Slice:
return f.setSlice(field, node)
default:
return nil
}
}
func (f filler) setPtr(field reflect.Value, node *Node) error {
if field.IsNil() {
field.Set(reflect.New(field.Type().Elem()))
if field.Type().Implements(reflect.TypeOf((*initializer)(nil)).Elem()) {
method := field.MethodByName("SetDefaults")
if method.IsValid() {
method.Call([]reflect.Value{})
}
}
}
return f.fill(field.Elem(), node)
}
func (f filler) setStruct(field reflect.Value, node *Node) error {
for _, child := range node.Children {
fd := field.FieldByName(child.FieldName)
zeroValue := reflect.Value{}
if fd == zeroValue {
return fmt.Errorf("field not found, node: %s (%s)", child.Name, child.FieldName)
}
err := f.fill(fd, child)
if err != nil {
return err
}
}
return nil
}
func (f filler) setSlice(field reflect.Value, node *Node) error {
if field.Type().Elem().Kind() == reflect.Struct ||
field.Type().Elem().Kind() == reflect.Ptr && field.Type().Elem().Elem().Kind() == reflect.Struct {
return f.setSliceStruct(field, node)
}
if len(node.Value) == 0 {
return nil
}
values := strings.Split(node.Value, ",")
slice := reflect.MakeSlice(field.Type(), len(values), len(values))
field.Set(slice)
for i := 0; i < len(values); i++ {
value := strings.TrimSpace(values[i])
switch field.Type().Elem().Kind() {
case reflect.String:
field.Index(i).SetString(value)
case reflect.Int:
val, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return err
}
field.Index(i).SetInt(val)
case reflect.Int8:
err := setInt(field.Index(i), value, 8)
if err != nil {
return err
}
case reflect.Int16:
err := setInt(field.Index(i), value, 16)
if err != nil {
return err
}
case reflect.Int32:
err := setInt(field.Index(i), value, 32)
if err != nil {
return err
}
case reflect.Int64:
err := setInt(field.Index(i), value, 64)
if err != nil {
return err
}
case reflect.Uint:
val, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return err
}
field.Index(i).SetUint(val)
case reflect.Uint8:
err := setUint(field.Index(i), value, 8)
if err != nil {
return err
}
case reflect.Uint16:
err := setUint(field.Index(i), value, 16)
if err != nil {
return err
}
case reflect.Uint32:
err := setUint(field.Index(i), value, 32)
if err != nil {
return err
}
case reflect.Uint64:
err := setUint(field.Index(i), value, 64)
if err != nil {
return err
}
case reflect.Float32:
err := setFloat(field.Index(i), value, 32)
if err != nil {
return err
}
case reflect.Float64:
err := setFloat(field.Index(i), value, 64)
if err != nil {
return err
}
case reflect.Bool:
val, err := strconv.ParseBool(value)
if err != nil {
return err
}
field.Index(i).SetBool(val)
default:
return fmt.Errorf("unsupported type: %s", field.Type().Elem())
}
}
return nil
}
func (f filler) setSliceStruct(field reflect.Value, node *Node) error {
if f.AllowSliceAsStruct && node.Tag.Get(TagLabelSliceAsStruct) != "" {
return f.setSliceAsStruct(field, node)
}
field.Set(reflect.MakeSlice(field.Type(), len(node.Children), len(node.Children)))
for i, child := range node.Children {
// use Ptr to allow "SetDefaults"
value := reflect.New(reflect.PtrTo(field.Type().Elem()))
err := f.setPtr(value, child)
if err != nil {
return err
}
field.Index(i).Set(value.Elem().Elem())
}
return nil
}
func (f filler) setSliceAsStruct(field reflect.Value, node *Node) error {
if len(node.Children) == 0 {
return fmt.Errorf("invalid slice: node %s", node.Name)
}
// use Ptr to allow "SetDefaults"
value := reflect.New(reflect.PtrTo(field.Type().Elem()))
err := f.setPtr(value, node)
if err != nil {
return err
}
elem := value.Elem().Elem()
field.Set(reflect.MakeSlice(field.Type(), 1, 1))
field.Index(0).Set(elem)
return nil
}
func (f filler) setMap(field reflect.Value, node *Node) error {
if field.IsNil() {
field.Set(reflect.MakeMap(field.Type()))
}
for _, child := range node.Children {
ptrValue := reflect.New(reflect.PtrTo(field.Type().Elem()))
err := f.fill(ptrValue, child)
if err != nil {
return err
}
value := ptrValue.Elem().Elem()
key := reflect.ValueOf(child.Name)
field.SetMapIndex(key, value)
}
return nil
}
func setInt(field reflect.Value, value string, bitSize int) error {
switch field.Type() {
case reflect.TypeOf(types.Duration(0)):
return setDuration(field, value, bitSize, time.Second)
case reflect.TypeOf(time.Duration(0)):
return setDuration(field, value, bitSize, time.Nanosecond)
default:
val, err := strconv.ParseInt(value, 10, bitSize)
if err != nil {
return err
}
field.Set(reflect.ValueOf(val).Convert(field.Type()))
return nil
}
}
func setDuration(field reflect.Value, value string, bitSize int, defaultUnit time.Duration) error {
val, err := strconv.ParseInt(value, 10, bitSize)
if err == nil {
field.Set(reflect.ValueOf(time.Duration(val) * defaultUnit).Convert(field.Type()))
return nil
}
duration, err := time.ParseDuration(value)
if err != nil {
return err
}
field.Set(reflect.ValueOf(duration).Convert(field.Type()))
return nil
}
func setUint(field reflect.Value, value string, bitSize int) error {
val, err := strconv.ParseUint(value, 10, bitSize)
if err != nil {
return err
}
field.Set(reflect.ValueOf(val).Convert(field.Type()))
return nil
}
func setFloat(field reflect.Value, value string, bitSize int) error {
val, err := strconv.ParseFloat(value, bitSize)
if err != nil {
return err
}
field.Set(reflect.ValueOf(val).Convert(field.Type()))
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,210 @@
package parser
import (
"fmt"
"reflect"
"strconv"
"strings"
)
// EncoderToNodeOpts Options for the encoderToNode.
type EncoderToNodeOpts struct {
OmitEmpty bool
TagName string
AllowSliceAsStruct bool
}
// EncodeToNode converts an element to a node.
// element -> nodes
func EncodeToNode(element interface{}, rootName string, opts EncoderToNodeOpts) (*Node, error) {
rValue := reflect.ValueOf(element)
node := &Node{Name: rootName}
encoder := encoderToNode{EncoderToNodeOpts: opts}
err := encoder.setNodeValue(node, rValue)
if err != nil {
return nil, err
}
return node, nil
}
type encoderToNode struct {
EncoderToNodeOpts
}
func (e encoderToNode) setNodeValue(node *Node, rValue reflect.Value) error {
switch rValue.Kind() {
case reflect.String:
node.Value = rValue.String()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
node.Value = strconv.FormatInt(rValue.Int(), 10)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
node.Value = strconv.FormatUint(rValue.Uint(), 10)
case reflect.Float32, reflect.Float64:
node.Value = strconv.FormatFloat(rValue.Float(), 'f', 6, 64)
case reflect.Bool:
node.Value = strconv.FormatBool(rValue.Bool())
case reflect.Struct:
return e.setStructValue(node, rValue)
case reflect.Ptr:
return e.setNodeValue(node, rValue.Elem())
case reflect.Map:
return e.setMapValue(node, rValue)
case reflect.Slice:
return e.setSliceValue(node, rValue)
default:
// noop
}
return nil
}
func (e encoderToNode) setStructValue(node *Node, rValue reflect.Value) error {
rType := rValue.Type()
for i := 0; i < rValue.NumField(); i++ {
field := rType.Field(i)
fieldValue := rValue.Field(i)
if !IsExported(field) {
continue
}
if field.Tag.Get(e.TagName) == "-" {
continue
}
if err := isSupportedType(field); err != nil {
return err
}
if e.isSkippedField(field, fieldValue) {
continue
}
nodeName := field.Name
if e.AllowSliceAsStruct && field.Type.Kind() == reflect.Slice && len(field.Tag.Get(TagLabelSliceAsStruct)) != 0 {
nodeName = field.Tag.Get(TagLabelSliceAsStruct)
}
if field.Anonymous {
if err := e.setNodeValue(node, fieldValue); err != nil {
return err
}
continue
}
child := &Node{Name: nodeName, FieldName: field.Name, Description: field.Tag.Get(TagDescription)}
if err := e.setNodeValue(child, fieldValue); err != nil {
return err
}
if field.Type.Kind() == reflect.Ptr {
if field.Type.Elem().Kind() != reflect.Struct && fieldValue.IsNil() {
continue
}
if field.Type.Elem().Kind() == reflect.Struct && len(child.Children) == 0 {
if field.Tag.Get(e.TagName) != TagLabelAllowEmpty {
continue
}
child.Value = "true"
}
}
node.Children = append(node.Children, child)
}
return nil
}
func (e encoderToNode) setMapValue(node *Node, rValue reflect.Value) error {
for _, key := range rValue.MapKeys() {
child := &Node{Name: key.String(), FieldName: key.String()}
node.Children = append(node.Children, child)
if err := e.setNodeValue(child, rValue.MapIndex(key)); err != nil {
return err
}
}
return nil
}
func (e encoderToNode) setSliceValue(node *Node, rValue reflect.Value) error {
// label-slice-as-struct
if rValue.Type().Elem().Kind() == reflect.Struct && !strings.EqualFold(node.Name, node.FieldName) {
if rValue.Len() > 1 {
return fmt.Errorf("node %s has too many slice entries: %d", node.Name, rValue.Len())
}
return e.setNodeValue(node, rValue.Index(0))
}
if rValue.Type().Elem().Kind() == reflect.Struct ||
rValue.Type().Elem().Kind() == reflect.Ptr && rValue.Type().Elem().Elem().Kind() == reflect.Struct {
for i := 0; i < rValue.Len(); i++ {
child := &Node{Name: "[" + strconv.Itoa(i) + "]"}
eValue := rValue.Index(i)
err := e.setNodeValue(child, eValue)
if err != nil {
return err
}
node.Children = append(node.Children, child)
}
return nil
}
var values []string
for i := 0; i < rValue.Len(); i++ {
eValue := rValue.Index(i)
switch eValue.Kind() {
case reflect.String:
values = append(values, eValue.String())
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
values = append(values, strconv.FormatInt(eValue.Int(), 10))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
values = append(values, strconv.FormatUint(eValue.Uint(), 10))
case reflect.Float32, reflect.Float64:
values = append(values, strconv.FormatFloat(eValue.Float(), 'f', 6, 64))
case reflect.Bool:
values = append(values, strconv.FormatBool(eValue.Bool()))
default:
// noop
}
}
node.Value = strings.Join(values, ", ")
return nil
}
func (e encoderToNode) isSkippedField(field reflect.StructField, fieldValue reflect.Value) bool {
if e.OmitEmpty && field.Type.Kind() == reflect.String && fieldValue.Len() == 0 {
return true
}
if field.Type.Kind() == reflect.Ptr && field.Type.Elem().Kind() == reflect.Struct && fieldValue.IsNil() {
return true
}
if e.OmitEmpty && (field.Type.Kind() == reflect.Slice) &&
(fieldValue.IsNil() || fieldValue.Len() == 0) {
return true
}
if (field.Type.Kind() == reflect.Map) &&
(fieldValue.IsNil() || fieldValue.Len() == 0) {
return true
}
return false
}

View File

@@ -0,0 +1,738 @@
package parser
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEncodeToNode(t *testing.T) {
type expected struct {
node *Node
error bool
}
testCases := []struct {
desc string
element interface{}
expected expected
}{
{
desc: "Description",
element: struct {
Foo string `description:"text"`
}{Foo: "bar"},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "bar", Description: "text"},
}},
},
},
{
desc: "string",
element: struct {
Foo string
}{Foo: "bar"},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "bar"},
}},
},
},
{
desc: "2 string fields",
element: struct {
Foo string
Fii string
}{Foo: "bar", Fii: "hii"},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "bar"},
{Name: "Fii", FieldName: "Fii", Value: "hii"},
}},
},
},
{
desc: "int",
element: struct {
Foo int
}{Foo: 1},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "1"},
}},
},
},
{
desc: "int8",
element: struct {
Foo int8
}{Foo: 2},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "2"},
}},
},
},
{
desc: "int16",
element: struct {
Foo int16
}{Foo: 2},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "2"},
}},
},
},
{
desc: "int32",
element: struct {
Foo int32
}{Foo: 2},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "2"},
}},
},
},
{
desc: "int64",
element: struct {
Foo int64
}{Foo: 2},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "2"},
}},
},
},
{
desc: "uint",
element: struct {
Foo uint
}{Foo: 1},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "1"},
}},
},
},
{
desc: "uint8",
element: struct {
Foo uint8
}{Foo: 2},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "2"},
}},
},
},
{
desc: "uint16",
element: struct {
Foo uint16
}{Foo: 2},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "2"},
}},
},
},
{
desc: "uint32",
element: struct {
Foo uint32
}{Foo: 2},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "2"},
}},
},
},
{
desc: "uint64",
element: struct {
Foo uint64
}{Foo: 2},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "2"},
}},
},
},
{
desc: "float32",
element: struct {
Foo float32
}{Foo: 1.12},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "1.120000"},
}},
},
},
{
desc: "float64",
element: struct {
Foo float64
}{Foo: 1.12},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "1.120000"},
}},
},
},
{
desc: "bool",
element: struct {
Foo bool
}{Foo: true},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "true"},
}},
},
},
{
desc: "struct",
element: struct {
Foo struct {
Fii string
Fuu string
}
}{
Foo: struct {
Fii string
Fuu string
}{
Fii: "hii",
Fuu: "huu",
},
},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Children: []*Node{
{Name: "Fii", FieldName: "Fii", Value: "hii"},
{Name: "Fuu", FieldName: "Fuu", Value: "huu"},
}},
}},
},
},
{
desc: "struct unexported field",
element: struct {
Foo struct {
Fii string
fuu string
}
}{
Foo: struct {
Fii string
fuu string
}{
Fii: "hii",
fuu: "huu",
},
},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Children: []*Node{
{Name: "Fii", FieldName: "Fii", Value: "hii"},
}},
}},
},
},
{
desc: "struct pointer",
element: struct {
Foo *struct {
Fii string
Fuu string
}
}{
Foo: &struct {
Fii string
Fuu string
}{
Fii: "hii",
Fuu: "huu",
},
},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Children: []*Node{
{Name: "Fii", FieldName: "Fii", Value: "hii"},
{Name: "Fuu", FieldName: "Fuu", Value: "huu"},
}},
}},
},
},
{
desc: "string pointer",
element: struct {
Foo *struct {
Fii *string
Fuu string
}
}{
Foo: &struct {
Fii *string
Fuu string
}{
Fii: func(v string) *string { return &v }("hii"),
Fuu: "huu",
},
},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Children: []*Node{
{Name: "Fii", FieldName: "Fii", Value: "hii"},
{Name: "Fuu", FieldName: "Fuu", Value: "huu"},
}},
}},
},
},
{
desc: "string nil pointer",
element: struct {
Foo *struct {
Fii *string
Fuu string
}
}{
Foo: &struct {
Fii *string
Fuu string
}{
Fii: nil,
Fuu: "huu",
},
},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Children: []*Node{
{Name: "Fuu", FieldName: "Fuu", Value: "huu"},
}},
}},
},
},
{
desc: "int pointer",
element: struct {
Foo *struct {
Fii *int
Fuu int
}
}{
Foo: &struct {
Fii *int
Fuu int
}{
Fii: func(v int) *int { return &v }(6),
Fuu: 4,
},
},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Children: []*Node{
{Name: "Fii", FieldName: "Fii", Value: "6"},
{Name: "Fuu", FieldName: "Fuu", Value: "4"},
}},
}},
},
},
{
desc: "bool pointer",
element: struct {
Foo *struct {
Fii *bool
Fuu bool
}
}{
Foo: &struct {
Fii *bool
Fuu bool
}{
Fii: func(v bool) *bool { return &v }(true),
Fuu: true,
},
},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Children: []*Node{
{Name: "Fii", FieldName: "Fii", Value: "true"},
{Name: "Fuu", FieldName: "Fuu", Value: "true"},
}},
}},
},
},
{
desc: "struct nil struct pointer",
element: struct {
Foo *struct {
Fii *string
Fuu string
}
}{
Foo: nil,
},
expected: expected{node: &Node{Name: "traefik"}},
},
{
desc: "struct pointer, not allowEmpty",
element: struct {
Foo *struct {
Fii string
Fuu string
}
}{
Foo: &struct {
Fii string
Fuu string
}{},
},
expected: expected{node: &Node{Name: "traefik"}},
},
{
desc: "struct pointer, allowEmpty",
element: struct {
Foo *struct {
Fii string
Fuu string
} `label:"allowEmpty"`
}{
Foo: &struct {
Fii string
Fuu string
}{},
},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Value: "true"},
}},
},
},
{
desc: "map",
element: struct {
Foo struct {
Bar map[string]string
}
}{
Foo: struct {
Bar map[string]string
}{
Bar: map[string]string{
"name1": "huu",
},
},
},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Children: []*Node{
{Name: "name1", FieldName: "name1", Value: "huu"},
}},
}},
}}},
},
{
desc: "empty map",
element: struct {
Bar map[string]string
}{
Bar: map[string]string{},
},
expected: expected{node: &Node{Name: "traefik"}},
},
{
desc: "map nil",
element: struct {
Bar map[string]string
}{
Bar: nil,
},
expected: expected{node: &Node{Name: "traefik"}},
},
{
desc: "map with non string key",
element: struct {
Foo struct {
Bar map[int]string
}
}{
Foo: struct {
Bar map[int]string
}{
Bar: map[int]string{
1: "huu",
},
},
},
expected: expected{error: true},
},
{
desc: "slice of string",
element: struct{ Bar []string }{Bar: []string{"huu", "hii"}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "huu, hii"},
}},
},
},
{
desc: "slice of int",
element: struct{ Bar []int }{Bar: []int{4, 2, 3}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "4, 2, 3"},
}},
},
},
{
desc: "slice of int8",
element: struct{ Bar []int8 }{Bar: []int8{4, 2, 3}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "4, 2, 3"},
}},
},
},
{
desc: "slice of int16",
element: struct{ Bar []int16 }{Bar: []int16{4, 2, 3}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "4, 2, 3"},
}},
},
},
{
desc: "slice of int32",
element: struct{ Bar []int32 }{Bar: []int32{4, 2, 3}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "4, 2, 3"},
}},
},
},
{
desc: "slice of int64",
element: struct{ Bar []int64 }{Bar: []int64{4, 2, 3}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "4, 2, 3"},
}},
},
},
{
desc: "slice of uint",
element: struct{ Bar []uint }{Bar: []uint{4, 2, 3}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "4, 2, 3"},
}},
},
},
{
desc: "slice of uint8",
element: struct{ Bar []uint8 }{Bar: []uint8{4, 2, 3}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "4, 2, 3"},
}},
},
},
{
desc: "slice of uint16",
element: struct{ Bar []uint16 }{Bar: []uint16{4, 2, 3}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "4, 2, 3"},
}},
},
},
{
desc: "slice of uint32",
element: struct{ Bar []uint32 }{Bar: []uint32{4, 2, 3}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "4, 2, 3"},
}},
},
},
{
desc: "slice of uint64",
element: struct{ Bar []uint64 }{Bar: []uint64{4, 2, 3}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "4, 2, 3"},
}},
},
},
{
desc: "slice of float32",
element: struct{ Bar []float32 }{Bar: []float32{4.1, 2, 3.2}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "4.100000, 2.000000, 3.200000"},
}},
},
},
{
desc: "slice of float64",
element: struct{ Bar []float64 }{Bar: []float64{4.1, 2, 3.2}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "4.100000, 2.000000, 3.200000"},
}},
},
},
{
desc: "slice of bool",
element: struct{ Bar []bool }{Bar: []bool{true, false, true}},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "true, false, true"},
}},
},
},
{
desc: "slice label-slice-as-struct",
element: &struct {
Foo []struct {
Bar string
Bir string
} `label-slice-as-struct:"Fii"`
}{
Foo: []struct {
Bar string
Bir string
}{
{
Bar: "haa",
Bir: "hii",
},
},
},
expected: expected{node: &Node{
Name: "traefik",
Children: []*Node{{
Name: "Fii",
FieldName: "Foo",
Children: []*Node{
{Name: "Bar", FieldName: "Bar", Value: "haa"},
{Name: "Bir", FieldName: "Bir", Value: "hii"},
},
}},
}},
},
{
desc: "slice label-slice-as-struct several slice entries",
element: &struct {
Foo []struct {
Bar string
Bir string
} `label-slice-as-struct:"Fii"`
}{
Foo: []struct {
Bar string
Bir string
}{
{
Bar: "haa",
Bir: "hii",
},
{
Bar: "haa",
Bir: "hii",
},
},
},
expected: expected{error: true},
},
{
desc: "slice of struct",
element: struct {
Foo []struct {
Field string
}
}{
Foo: []struct {
Field string
}{
{
Field: "bar",
},
{
Field: "bir",
},
},
},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Children: []*Node{
{Name: "[0]", Children: []*Node{
{Name: "Field", FieldName: "Field", Value: "bar"},
}},
{Name: "[1]", Children: []*Node{
{Name: "Field", FieldName: "Field", Value: "bir"},
}},
}},
}}},
},
{
desc: "slice of pointer of struct",
element: struct {
Foo []*struct {
Field string
}
}{
Foo: []*struct {
Field string
}{
{Field: "bar"},
{Field: "bir"},
},
},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Children: []*Node{
{Name: "[0]", Children: []*Node{
{Name: "Field", FieldName: "Field", Value: "bar"},
}},
{Name: "[1]", Children: []*Node{
{Name: "Field", FieldName: "Field", Value: "bir"},
}},
}},
}}},
},
{
desc: "empty slice",
element: struct {
Bar []string
}{
Bar: []string{},
},
expected: expected{node: &Node{Name: "traefik"}},
},
{
desc: "nil slice",
element: struct {
Bar []string
}{
Bar: nil,
},
expected: expected{node: &Node{Name: "traefik"}},
},
{
desc: "ignore slice",
element: struct {
Bar []string `label:"-"`
}{
Bar: []string{"huu", "hii"},
},
expected: expected{node: &Node{Name: "traefik"}},
},
{
desc: "embedded",
element: struct {
Foo struct{ FiiFoo }
}{
Foo: struct{ FiiFoo }{
FiiFoo: FiiFoo{
Fii: "hii",
Fuu: "huu",
},
},
},
expected: expected{node: &Node{Name: "traefik", Children: []*Node{
{Name: "Foo", FieldName: "Foo", Children: []*Node{
{Name: "Fii", FieldName: "Fii", Value: "hii"},
{Name: "Fuu", FieldName: "Fuu", Value: "huu"},
}},
}},
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
etnOpts := EncoderToNodeOpts{OmitEmpty: true, TagName: TagLabel, AllowSliceAsStruct: true}
node, err := EncodeToNode(test.element, DefaultRootName, etnOpts)
if test.expected.error {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected.node, node)
}
})
}
}

View File

@@ -0,0 +1,167 @@
package parser
import (
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"time"
"github.com/crazy-max/diun/v3/third_party/traefik/types"
)
const defaultPtrValue = "false"
// FlatOpts holds options used when encoding to Flat.
type FlatOpts struct {
Case string // "lower" or "upper", defaults to "lower".
Separator string
SkipRoot bool
TagName string
}
// Flat is a configuration item representation.
type Flat struct {
Name string
Description string
Default string
}
// EncodeToFlat encodes a node to a Flat representation.
// Even though the given node argument should have already been augmented with metadata such as kind,
// the element (and its type information) is still needed to treat remaining edge cases.
func EncodeToFlat(element interface{}, node *Node, opts FlatOpts) ([]Flat, error) {
if element == nil || node == nil {
return nil, nil
}
if node.Kind == 0 {
return nil, fmt.Errorf("missing node type: %s", node.Name)
}
elem := reflect.ValueOf(element)
if elem.Kind() == reflect.Struct {
return nil, fmt.Errorf("structs are not supported, use pointer instead")
}
encoder := encoderToFlat{FlatOpts: opts}
var entries []Flat
if encoder.SkipRoot {
for _, child := range node.Children {
field := encoder.getField(elem.Elem(), child)
entries = append(entries, encoder.createFlat(field, child.Name, child)...)
}
} else {
entries = encoder.createFlat(elem, strings.ToLower(node.Name), node)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
return entries, nil
}
type encoderToFlat struct {
FlatOpts
}
func (e encoderToFlat) createFlat(field reflect.Value, name string, node *Node) []Flat {
var entries []Flat
if node.Kind != reflect.Map && node.Description != "-" {
if !(node.Kind == reflect.Ptr && len(node.Children) > 0) ||
(node.Kind == reflect.Ptr && node.Tag.Get(e.TagName) == TagLabelAllowEmpty) {
if node.Name[0] != '[' {
entries = append(entries, Flat{
Name: e.getName(name),
Description: node.Description,
Default: e.getNodeValue(e.getField(field, node), node),
})
}
}
}
for _, child := range node.Children {
if node.Kind == reflect.Map {
fChild := e.getField(field, child)
var v string
if child.Kind == reflect.Struct {
v = defaultPtrValue
} else {
v = e.getNodeValue(fChild, child)
}
if node.Description != "-" {
entries = append(entries, Flat{
Name: e.getName(name, child.Name),
Description: node.Description,
Default: v,
})
}
if child.Kind == reflect.Struct || child.Kind == reflect.Ptr {
for _, ch := range child.Children {
f := e.getField(fChild, ch)
n := e.getName(name, child.Name, ch.Name)
entries = append(entries, e.createFlat(f, n, ch)...)
}
}
} else {
f := e.getField(field, child)
n := e.getName(name, child.Name)
entries = append(entries, e.createFlat(f, n, child)...)
}
}
return entries
}
func (e encoderToFlat) getField(field reflect.Value, node *Node) reflect.Value {
switch field.Kind() {
case reflect.Struct:
return field.FieldByName(node.FieldName)
case reflect.Ptr:
if field.Elem().Kind() == reflect.Struct {
return field.Elem().FieldByName(node.FieldName)
}
return field.Elem()
case reflect.Map:
return field.MapIndex(reflect.ValueOf(node.FieldName))
default:
return field
}
}
func (e encoderToFlat) getNodeValue(field reflect.Value, node *Node) string {
if node.Kind == reflect.Ptr && len(node.Children) > 0 {
return defaultPtrValue
}
if field.Kind() == reflect.Int64 {
i, _ := strconv.ParseInt(node.Value, 10, 64)
switch field.Type() {
case reflect.TypeOf(types.Duration(time.Second)):
return strconv.Itoa(int(i) / int(time.Second))
case reflect.TypeOf(time.Second):
return time.Duration(i).String()
}
}
return node.Value
}
func (e encoderToFlat) getName(names ...string) string {
var name string
if names[len(names)-1][0] == '[' {
name = strings.Join(names, "")
} else {
name = strings.Join(names, e.Separator)
}
if strings.EqualFold(e.Case, "upper") {
return strings.ToUpper(name)
}
return strings.ToLower(name)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
package parser
import (
"fmt"
"sort"
"strings"
)
// DecodeToNode converts the labels to a tree of nodes.
// If any filters are present, labels which do not match the filters are skipped.
func DecodeToNode(labels map[string]string, rootName string, filters ...string) (*Node, error) {
sortedKeys := sortKeys(labels, filters)
var node *Node
for i, key := range sortedKeys {
split := strings.Split(key, ".")
if split[0] != rootName {
return nil, fmt.Errorf("invalid label root %s", split[0])
}
var parts []string
for _, v := range split {
if v == "" {
return nil, fmt.Errorf("invalid element: %s", key)
}
if v[0] == '[' {
return nil, fmt.Errorf("invalid leading character '[' in field name (bracket is a slice delimiter): %s", v)
}
if strings.HasSuffix(v, "]") && v[0] != '[' {
indexLeft := strings.Index(v, "[")
parts = append(parts, v[:indexLeft], v[indexLeft:])
} else {
parts = append(parts, v)
}
}
if i == 0 {
node = &Node{}
}
decodeToNode(node, parts, labels[key])
}
return node, nil
}
func decodeToNode(root *Node, path []string, value string) {
if len(root.Name) == 0 {
root.Name = path[0]
}
// it's a leaf or not -> children
if len(path) > 1 {
if n := containsNode(root.Children, path[1]); n != nil {
// the child already exists
decodeToNode(n, path[1:], value)
} else {
// new child
child := &Node{Name: path[1]}
decodeToNode(child, path[1:], value)
root.Children = append(root.Children, child)
}
} else {
root.Value = value
}
}
func containsNode(nodes []*Node, name string) *Node {
for _, n := range nodes {
if strings.EqualFold(name, n.Name) {
return n
}
}
return nil
}
func sortKeys(labels map[string]string, filters []string) []string {
var sortedKeys []string
for key := range labels {
if len(filters) == 0 {
sortedKeys = append(sortedKeys, key)
continue
}
for _, filter := range filters {
if len(key) >= len(filter) && strings.EqualFold(key[:len(filter)], filter) {
sortedKeys = append(sortedKeys, key)
continue
}
}
}
sort.Strings(sortedKeys)
return sortedKeys
}

View File

@@ -0,0 +1,261 @@
package parser
import (
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDecodeToNode(t *testing.T) {
type expected struct {
error bool
node *Node
}
testCases := []struct {
desc string
in map[string]string
filters []string
expected expected
}{
{
desc: "no label",
in: map[string]string{},
expected: expected{node: nil},
},
{
desc: "invalid label, ending by a dot",
in: map[string]string{
"traefik.http.": "bar",
},
expected: expected{
error: true,
},
},
{
desc: "level 1",
in: map[string]string{
"traefik.foo": "bar",
},
expected: expected{node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "foo", Value: "bar"},
},
}},
},
{
desc: "level 1 empty value",
in: map[string]string{
"traefik.foo": "",
},
expected: expected{node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "foo", Value: ""},
},
}},
},
{
desc: "level 2",
in: map[string]string{
"traefik.foo.bar": "bar",
},
expected: expected{node: &Node{
Name: "traefik",
Children: []*Node{{
Name: "foo",
Children: []*Node{
{Name: "bar", Value: "bar"},
},
}},
}},
},
{
desc: "several entries, level 0",
in: map[string]string{
"traefik": "bar",
"traefic": "bur",
},
expected: expected{error: true},
},
{
desc: "several entries, prefix filter",
in: map[string]string{
"traefik.foo": "bar",
"traefik.fii": "bir",
},
filters: []string{"traefik.Foo"},
expected: expected{node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "foo", Value: "bar"},
},
}},
},
{
desc: "several entries, level 1",
in: map[string]string{
"traefik.foo": "bar",
"traefik.fii": "bur",
},
expected: expected{node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "fii", Value: "bur"},
{Name: "foo", Value: "bar"},
},
}},
},
{
desc: "several entries, level 2",
in: map[string]string{
"traefik.foo.aaa": "bar",
"traefik.foo.bbb": "bur",
},
expected: expected{node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "foo", Children: []*Node{
{Name: "aaa", Value: "bar"},
{Name: "bbb", Value: "bur"},
}},
},
}},
},
{
desc: "several entries, level 2, case insensitive",
in: map[string]string{
"traefik.foo.aaa": "bar",
"traefik.Foo.bbb": "bur",
},
expected: expected{node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "Foo", Children: []*Node{
{Name: "bbb", Value: "bur"},
{Name: "aaa", Value: "bar"},
}},
},
}},
},
{
desc: "several entries, level 2, 3 children",
in: map[string]string{
"traefik.foo.aaa": "bar",
"traefik.foo.bbb": "bur",
"traefik.foo.ccc": "bir",
},
expected: expected{node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "foo", Children: []*Node{
{Name: "aaa", Value: "bar"},
{Name: "bbb", Value: "bur"},
{Name: "ccc", Value: "bir"},
}},
},
}},
},
{
desc: "several entries, level 3",
in: map[string]string{
"traefik.foo.bar.aaa": "bar",
"traefik.foo.bar.bbb": "bur",
},
expected: expected{node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "foo", Children: []*Node{
{Name: "bar", Children: []*Node{
{Name: "aaa", Value: "bar"},
{Name: "bbb", Value: "bur"},
}},
}},
},
}},
},
{
desc: "several entries, level 3, 2 children level 1",
in: map[string]string{
"traefik.foo.bar.aaa": "bar",
"traefik.foo.bar.bbb": "bur",
"traefik.bar.foo.bbb": "bir",
},
expected: expected{node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "bar", Children: []*Node{
{Name: "foo", Children: []*Node{
{Name: "bbb", Value: "bir"},
}},
}},
{Name: "foo", Children: []*Node{
{Name: "bar", Children: []*Node{
{Name: "aaa", Value: "bar"},
{Name: "bbb", Value: "bur"},
}},
}},
},
}},
},
{
desc: "several entries, slice syntax",
in: map[string]string{
"traefik.foo[0].aaa": "bar0",
"traefik.foo[0].bbb": "bur0",
"traefik.foo[1].aaa": "bar1",
"traefik.foo[1].bbb": "bur1",
},
expected: expected{node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "foo", Children: []*Node{
{Name: "[0]", Children: []*Node{
{Name: "aaa", Value: "bar0"},
{Name: "bbb", Value: "bur0"},
}},
{Name: "[1]", Children: []*Node{
{Name: "aaa", Value: "bar1"},
{Name: "bbb", Value: "bur1"},
}},
}},
},
}},
},
{
desc: "several entries, invalid slice syntax",
in: map[string]string{
"traefik.foo.[0].aaa": "bar0",
"traefik.foo.[0].bbb": "bur0",
"traefik.foo.[1].aaa": "bar1",
"traefik.foo.[1].bbb": "bur1",
},
expected: expected{error: true},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
out, err := DecodeToNode(test.in, DefaultRootName, test.filters...)
if test.expected.error {
require.Error(t, err)
} else {
require.NoError(t, err)
if !assert.Equal(t, test.expected.node, out) {
bytes, err := json.MarshalIndent(out, "", " ")
require.NoError(t, err)
fmt.Println(string(bytes))
}
}
})
}
}

View File

@@ -0,0 +1,30 @@
package parser
// EncodeNode Converts a node to labels.
// nodes -> labels
func EncodeNode(node *Node) map[string]string {
labels := make(map[string]string)
encodeNode(labels, node.Name, node)
return labels
}
func encodeNode(labels map[string]string, root string, node *Node) {
for _, child := range node.Children {
if child.Disabled {
continue
}
var sep string
if child.Name[0] != '[' {
sep = "."
}
childName := root + sep + child.Name
if len(child.Children) > 0 {
encodeNode(labels, childName, child)
} else if len(child.Name) > 0 {
labels[childName] = child.Value
}
}
}

View File

@@ -0,0 +1,180 @@
package parser
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestEncodeNode(t *testing.T) {
testCases := []struct {
desc string
node *Node
expected map[string]string
}{
{
desc: "1 label",
node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "aaa", Value: "bar"},
},
},
expected: map[string]string{
"traefik.aaa": "bar",
},
},
{
desc: "2 labels",
node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "aaa", Value: "bar"},
{Name: "bbb", Value: "bur"},
},
},
expected: map[string]string{
"traefik.aaa": "bar",
"traefik.bbb": "bur",
},
},
{
desc: "2 labels, 1 disabled",
node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "aaa", Value: "bar"},
{Name: "bbb", Value: "bur", Disabled: true},
},
},
expected: map[string]string{
"traefik.aaa": "bar",
},
},
{
desc: "2 levels",
node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "foo", Children: []*Node{
{Name: "aaa", Value: "bar"},
}},
},
},
expected: map[string]string{
"traefik.foo.aaa": "bar",
},
},
{
desc: "3 levels",
node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "foo", Children: []*Node{
{Name: "bar", Children: []*Node{
{Name: "aaa", Value: "bar"},
}},
}},
},
},
expected: map[string]string{
"traefik.foo.bar.aaa": "bar",
},
},
{
desc: "2 levels, same root",
node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "foo", Children: []*Node{
{Name: "bar", Children: []*Node{
{Name: "aaa", Value: "bar"},
{Name: "bbb", Value: "bur"},
}},
}},
},
},
expected: map[string]string{
"traefik.foo.bar.aaa": "bar",
"traefik.foo.bar.bbb": "bur",
},
},
{
desc: "several levels, different root",
node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "bar", Children: []*Node{
{Name: "ccc", Value: "bir"},
}},
{Name: "foo", Children: []*Node{
{Name: "bar", Children: []*Node{
{Name: "aaa", Value: "bar"},
}},
}},
},
},
expected: map[string]string{
"traefik.foo.bar.aaa": "bar",
"traefik.bar.ccc": "bir",
},
},
{
desc: "multiple labels, multiple levels",
node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "bar", Children: []*Node{
{Name: "ccc", Value: "bir"},
}},
{Name: "foo", Children: []*Node{
{Name: "bar", Children: []*Node{
{Name: "aaa", Value: "bar"},
{Name: "bbb", Value: "bur"},
}},
}},
},
},
expected: map[string]string{
"traefik.foo.bar.aaa": "bar",
"traefik.foo.bar.bbb": "bur",
"traefik.bar.ccc": "bir",
},
},
{
desc: "slice of struct syntax",
node: &Node{
Name: "traefik",
Children: []*Node{
{Name: "foo", Children: []*Node{
{Name: "[0]", Children: []*Node{
{Name: "aaa", Value: "bar0"},
{Name: "bbb", Value: "bur0"},
}},
{Name: "[1]", Children: []*Node{
{Name: "aaa", Value: "bar1"},
{Name: "bbb", Value: "bur1"},
}},
}},
},
},
expected: map[string]string{
"traefik.foo[0].aaa": "bar0",
"traefik.foo[0].bbb": "bur0",
"traefik.foo[1].aaa": "bar1",
"traefik.foo[1].bbb": "bur1",
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
labels := EncodeNode(test.node)
assert.Equal(t, test.expected, labels)
})
}
}

View File

@@ -0,0 +1,21 @@
package parser
import "reflect"
// DefaultRootName is the default name of the root node and the prefix of element name from the resources.
const DefaultRootName = "traefik"
// MapNamePlaceholder is the placeholder for the map name.
const MapNamePlaceholder = "<name>"
// Node is a label node.
type Node struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
FieldName string `json:"fieldName"`
Value string `json:"value,omitempty"`
Disabled bool `json:"disabled,omitempty"`
Kind reflect.Kind `json:"kind,omitempty"`
Tag reflect.StructTag `json:"tag,omitempty"`
Children []*Node `json:"children,omitempty"`
}

View File

@@ -0,0 +1,196 @@
package parser
import (
"errors"
"fmt"
"reflect"
"strings"
)
// MetadataOpts Options for the metadata.
type MetadataOpts struct {
TagName string
AllowSliceAsStruct bool
}
// AddMetadata adds metadata such as type, inferred from element, to a node.
func AddMetadata(element interface{}, node *Node, opts MetadataOpts) error {
return metadata{MetadataOpts: opts}.Add(element, node)
}
type metadata struct {
MetadataOpts
}
// Add adds metadata such as type, inferred from element, to a node.
func (m metadata) Add(element interface{}, node *Node) error {
if node == nil {
return nil
}
if len(node.Children) == 0 {
return fmt.Errorf("invalid node %s: no child", node.Name)
}
if element == nil {
return errors.New("nil structure")
}
rootType := reflect.TypeOf(element)
node.Kind = rootType.Kind()
return m.browseChildren(rootType, node)
}
func (m metadata) browseChildren(fType reflect.Type, node *Node) error {
for _, child := range node.Children {
if err := m.add(fType, child); err != nil {
return err
}
}
return nil
}
func (m metadata) add(rootType reflect.Type, node *Node) error {
rType := rootType
if rootType.Kind() == reflect.Ptr {
rType = rootType.Elem()
}
field, err := m.findTypedField(rType, node)
if err != nil {
return err
}
if err = isSupportedType(field); err != nil {
return err
}
fType := field.Type
node.Kind = fType.Kind()
node.Tag = field.Tag
if fType.Kind() == reflect.Struct || fType.Kind() == reflect.Ptr && fType.Elem().Kind() == reflect.Struct ||
fType.Kind() == reflect.Map {
if len(node.Children) == 0 && field.Tag.Get(m.TagName) != TagLabelAllowEmpty {
return fmt.Errorf("%s cannot be a standalone element (type %s)", node.Name, fType)
}
node.Disabled = len(node.Value) > 0 && !strings.EqualFold(node.Value, "true") && field.Tag.Get(m.TagName) == TagLabelAllowEmpty
}
if len(node.Children) == 0 {
return nil
}
if fType.Kind() == reflect.Struct || fType.Kind() == reflect.Ptr && fType.Elem().Kind() == reflect.Struct {
return m.browseChildren(fType, node)
}
if fType.Kind() == reflect.Map {
for _, child := range node.Children {
// elem is a map entry value type
elem := fType.Elem()
child.Kind = elem.Kind()
if elem.Kind() == reflect.Map || elem.Kind() == reflect.Struct ||
(elem.Kind() == reflect.Ptr && elem.Elem().Kind() == reflect.Struct) {
if err = m.browseChildren(elem, child); err != nil {
return err
}
}
}
return nil
}
if fType.Kind() == reflect.Slice {
if m.AllowSliceAsStruct && field.Tag.Get(TagLabelSliceAsStruct) != "" {
return m.browseChildren(fType.Elem(), node)
}
for _, ch := range node.Children {
ch.Kind = fType.Elem().Kind()
if err = m.browseChildren(fType.Elem(), ch); err != nil {
return err
}
}
return nil
}
return fmt.Errorf("invalid node %s: %v", node.Name, fType.Kind())
}
func (m metadata) findTypedField(rType reflect.Type, node *Node) (reflect.StructField, error) {
for i := 0; i < rType.NumField(); i++ {
cField := rType.Field(i)
fieldName := cField.Tag.Get(TagLabelSliceAsStruct)
if !m.AllowSliceAsStruct || len(fieldName) == 0 {
fieldName = cField.Name
}
if IsExported(cField) {
if cField.Anonymous {
if cField.Type.Kind() == reflect.Struct {
structField, err := m.findTypedField(cField.Type, node)
if err != nil {
continue
}
return structField, nil
}
}
if strings.EqualFold(fieldName, node.Name) {
node.FieldName = cField.Name
return cField, nil
}
}
}
return reflect.StructField{}, fmt.Errorf("field not found, node: %s", node.Name)
}
// IsExported reports whether f is exported.
// https://golang.org/pkg/reflect/#StructField
func IsExported(f reflect.StructField) bool {
return f.PkgPath == ""
}
func isSupportedType(field reflect.StructField) error {
fType := field.Type
if fType.Kind() == reflect.Slice {
switch fType.Elem().Kind() {
case reflect.String,
reflect.Bool,
reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Struct,
reflect.Ptr:
return nil
default:
return fmt.Errorf("unsupported slice type: %v", fType)
}
}
if fType.Kind() == reflect.Map && fType.Key().Kind() != reflect.String {
return fmt.Errorf("unsupported map key type: %v", fType.Key())
}
if fType.Kind() == reflect.Func {
return fmt.Errorf("unsupported type: %v", fType)
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
// Package parser implements decoding and encoding between a flat map of labels and a typed Configuration.
package parser
// Decode decodes the given map of labels into the given element.
// If any filters are present, labels which do not match the filters are skipped.
// The operation goes through three stages roughly summarized as:
// labels -> tree of untyped nodes
// untyped nodes -> nodes augmented with metadata such as kind (inferred from element)
// "typed" nodes -> typed element
func Decode(labels map[string]string, element interface{}, rootName string, filters ...string) error {
node, err := DecodeToNode(labels, rootName, filters...)
if err != nil {
return err
}
metaOpts := MetadataOpts{TagName: TagLabel, AllowSliceAsStruct: true}
err = AddMetadata(element, node, metaOpts)
if err != nil {
return err
}
err = Fill(element, node, FillerOpts{AllowSliceAsStruct: true})
if err != nil {
return err
}
return nil
}
// Encode converts an element to labels.
// element -> node (value) -> label (node)
func Encode(element interface{}, rootName string) (map[string]string, error) {
etnOpts := EncoderToNodeOpts{OmitEmpty: true, TagName: TagLabel, AllowSliceAsStruct: true}
node, err := EncodeToNode(element, rootName, etnOpts)
if err != nil {
return nil, err
}
return EncodeNode(node), nil
}

View File

@@ -0,0 +1,19 @@
package parser
const (
// TagLabel allows to apply a custom behavior.
// - "allowEmpty": allows to create an empty struct.
// - "-": ignore the field.
TagLabel = "label"
// TagLabelSliceAsStruct allows to use a slice of struct by creating one entry into the slice.
// The value is the substitution name used in the label to access the slice.
TagLabelSliceAsStruct = "label-slice-as-struct"
// TagDescription is the documentation for the field.
// - "-": ignore the field.
TagDescription = "description"
// TagLabelAllowEmpty is related to TagLabel.
TagLabelAllowEmpty = "allowEmpty"
)

61
third_party/traefik/types/duration.go vendored Normal file
View File

@@ -0,0 +1,61 @@
package types
import (
"encoding/json"
"strconv"
"time"
)
// Duration is a custom type suitable for parsing duration values.
// It supports `time.ParseDuration`-compatible values and suffix-less digits; in
// the latter case, seconds are assumed.
type Duration time.Duration
// Set sets the duration from the given string value.
func (d *Duration) Set(s string) error {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
*d = Duration(time.Duration(v) * time.Second)
return nil
}
v, err := time.ParseDuration(s)
*d = Duration(v)
return err
}
// String returns a string representation of the duration value.
func (d Duration) String() string { return (time.Duration)(d).String() }
// MarshalText serialize the given duration value into a text.
func (d Duration) MarshalText() ([]byte, error) {
return []byte(d.String()), nil
}
// UnmarshalText deserializes the given text into a duration value.
// It is meant to support TOML decoding of durations.
func (d *Duration) UnmarshalText(text []byte) error {
return d.Set(string(text))
}
// MarshalJSON serializes the given duration value.
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d))
}
// UnmarshalJSON deserializes the given text into a duration value.
func (d *Duration) UnmarshalJSON(text []byte) error {
if v, err := strconv.ParseInt(string(text), 10, 64); err == nil {
*d = Duration(time.Duration(v))
return nil
}
// We use json unmarshal on value because we have the quoted version
var value string
err := json.Unmarshal(text, &value)
if err != nil {
return err
}
v, err := time.ParseDuration(value)
*d = Duration(v)
return err
}