diff --git a/.all-contributorsrc b/.all-contributorsrc index 45af97f..043cacc 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -293,6 +293,16 @@ "doc" ] }, + { + "login": "lukapeschke", + "name": "Luka Peschke", + "avatar_url": "https://avatars1.githubusercontent.com/u/17085536?v=4", + "profile": "https://github.com/lukapeschke", + "contributions": [ + "code", + "doc" + ] + }, { "login": "zoispag", "name": "Zois Pagoulatos", @@ -302,6 +312,97 @@ "code", "review" ] + }, + { + "login": "alexandremenif", + "name": "Alexandre Menif", + "avatar_url": "https://avatars0.githubusercontent.com/u/16152103?v=4", + "profile": "https://alexandre.menif.name", + "contributions": [ + "code" + ] + }, + { + "login": "chugunov", + "name": "Andrey", + "avatar_url": "https://avatars1.githubusercontent.com/u/4140479?v=4", + "profile": "https://github.com/chugunov", + "contributions": [ + "doc" + ] + }, + { + "login": "noplanman", + "name": "Armando LΓΌscher", + "avatar_url": "https://avatars3.githubusercontent.com/u/9423417?v=4", + "profile": "https://noplanman.ch", + "contributions": [ + "doc" + ] + }, + { + "login": "rjbudke", + "name": "Ryan Budke", + "avatar_url": "https://avatars2.githubusercontent.com/u/273485?v=4", + "profile": "https://github.com/rjbudke", + "contributions": [ + "doc" + ] + }, + { + "login": "kaloyan-raev", + "name": "Kaloyan Raev", + "avatar_url": "https://avatars2.githubusercontent.com/u/468091?v=4", + "profile": "http://kaloyan.raev.name", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "sixth", + "name": "sixth", + "avatar_url": "https://avatars3.githubusercontent.com/u/11591445?v=4", + "profile": "https://github.com/sixth", + "contributions": [ + "doc" + ] + }, + { + "login": "foosel", + "name": "Gina HΓ€ußge", + "avatar_url": "https://avatars0.githubusercontent.com/u/83657?v=4", + "profile": "https://foosel.net", + "contributions": [ + "code" + ] + }, + { + "login": "8ear", + "name": "Max H.", + "avatar_url": "https://avatars0.githubusercontent.com/u/10329648?v=4", + "profile": "https://github.com/8ear", + "contributions": [ + "code" + ] + }, + { + "login": "pjknkda", + "name": "Jungkook Park", + "avatar_url": "https://avatars0.githubusercontent.com/u/4986524?v=4", + "profile": "https://pjknkda.github.io", + "contributions": [ + "doc" + ] + }, + { + "login": "jnidzwetzki", + "name": "Jan Kristof Nidzwetzki", + "avatar_url": "https://avatars1.githubusercontent.com/u/5753622?v=4", + "profile": "https://achfrag.net", + "contributions": [ + "doc" + ] } ], "contributorsPerLine": 7, diff --git a/.circleci/config.yml b/.circleci/config.yml index 5b134e5..41183b5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -87,8 +87,7 @@ jobs: at: . - run: go build ./... - run: go get github.com/schrej/godacov - - run: go get -u github.com/haya14busa/goverage - - run: goverage -v -coverprofile=coverage.out ./... + - run: go test ./... -coverprofile coverage.out - run: godacov -t $CODACY_TOKEN -r ./coverage.out -c $CIRCLE_SHA1 build: executor: go @@ -224,10 +223,16 @@ jobs: - run: name: Install prerequisites command: | - pip install \ + sudo pip install \ mkdocs \ mkdocs-material \ md-toc + - add_ssh_keys: + fingerprints: + - "91:75:47:15:b2:8e:85:e5:67:0e:63:7f:22:d2:b4:6e" - run: name: Generate and publish - command: mkdocs gh-deploy + command: | + mkdir ~/.ssh && touch ~/.ssh/known_hosts; + ssh-keyscan -H github.com >> ~/.ssh/known_hosts && \ + mkdocs gh-deploy diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 364d198..419e96c 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ custom: https://www.amazon.com/hz/wishlist/ls/F94JJV822VX6 +github: simskij diff --git a/.github/config.yml b/.github/config.yml deleted file mode 100644 index 62993b5..0000000 --- a/.github/config.yml +++ /dev/null @@ -1,11 +0,0 @@ -newIssueWelcomeComment: > - Hi there! - - Thanks a bunch for opening your first issue! :pray: - As you're new to this repo, we'd like to suggest that you read our [code of conduct](https://github.com/containrrr/watchtower/blob/master/CODE_OF_CONDUCT.md) - -newPRWelcomeComment: > - Thanks for opening this pull request! Please check out our [contributing guidelines](https://github.com/containrrr/watchtower/blob/master/CONTRIBUTING.md) as well as our [code of conduct](https://github.com/containrrr/watchtower/blob/master/CODE_OF_CONDUCT.md). - -firstPRMergeComment: > - Congrats on merging your first pull request! We are all very proud of you! :sparkles: diff --git a/.github/stale.yml b/.github/stale.yml index f59106c..1b245b9 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -2,8 +2,10 @@ daysUntilStale: 60 daysUntilClose: 7 exemptMilestones: true exemptLabels: - - pinned - - security + - "Public Service Announcement" + - "Do not close" + - "Type: Bug" + - "Type: Security" staleLabel: "Status: Stale" markComment: > This issue has been automatically marked as stale because it has not had diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml new file mode 100644 index 0000000..7034c9e --- /dev/null +++ b/.github/workflows/greetings.yml @@ -0,0 +1,18 @@ +name: Greetings + +on: [pull_request, issues] + +jobs: + greeting: + runs-on: ubuntu-latest + steps: + - uses: actions/first-interaction@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-message: > + Hi there! πŸ‘‹πŸΌ + As you're new to this repo, we'd like to suggest that you read our [code of conduct](https://github.com/containrrr/watchtower/blob/master/CODE_OF_CONDUCT.md) + as well as our [contribution guidelines](https://github.com/containrrr/watchtower/blob/master/CONTRIBUTING.md). + Thanks a bunch for opening your first issue! πŸ™ + pr-message: > + Congratulations on opening your first pull request! We'll get back to you as soon as possible. In the meantime, please make sure you've updated the documentation to reflect your changes and have added test automation as needed. Thanks! πŸ™πŸΌ diff --git a/README.md b/README.md index 9c18264..37a06a8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,9 @@ All Contributors + + Pulls from DockerHub +

## Quick Start @@ -102,7 +105,22 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Mark Woodbridge

πŸ’»
Simon Aronsson

πŸ’» 🚧 πŸ‘€
Ansem93

πŸ“– +
Luka Peschke

πŸ’» πŸ“–
Zois Pagoulatos

πŸ’» πŸ‘€ +
Alexandre Menif

πŸ’» +
Andrey

πŸ“– + + +
Armando LΓΌscher

πŸ“– +
Ryan Budke

πŸ“– +
Kaloyan Raev

πŸ’» ⚠️ +
sixth

πŸ“– +
Gina HÀußge

πŸ’» +
Max H.

πŸ’» +
Jungkook Park

πŸ“– + + +
Jan Kristof Nidzwetzki

πŸ“– diff --git a/actions/update.go b/actions/update.go deleted file mode 100644 index a18ea4c..0000000 --- a/actions/update.go +++ /dev/null @@ -1,129 +0,0 @@ -package actions - -import ( - "math/rand" - "time" - - "github.com/containrrr/watchtower/container" - log "github.com/sirupsen/logrus" -) - -var ( - letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") -) - -// UpdateParams contains all different options available to alter the behavior of the Update func -type UpdateParams struct { - Filter container.Filter - Cleanup bool - NoRestart bool - Timeout time.Duration - MonitorOnly bool -} - -// Update looks at the running Docker containers to see if any of the images -// used to start those containers have been updated. If a change is detected in -// any of the images, the associated containers are stopped and restarted with -// the new image. -func Update(client container.Client, params UpdateParams) error { - log.Debug("Checking containers for updated images") - - containers, err := client.ListContainers(params.Filter) - if err != nil { - return err - } - - for i, container := range containers { - stale, err := client.IsContainerStale(container) - if err != nil { - log.Infof("Unable to update container %s. Proceeding to next.", containers[i].Name()) - log.Debug(err) - stale = false - } - containers[i].Stale = stale - } - - containers, err = container.SortByDependencies(containers) - if err != nil { - return err - } - - checkDependencies(containers) - - if params.MonitorOnly { - return nil - } - - // Stop stale containers in reverse order - for i := len(containers) - 1; i >= 0; i-- { - container := containers[i] - - if container.IsWatchtower() { - log.Debugf("This is the watchtower container %s", containers[i].Name()) - continue - } - - if container.Stale { - if err := client.StopContainer(container, params.Timeout); err != nil { - log.Error(err) - } - } - } - - // Restart stale containers in sorted order - for _, container := range containers { - if container.Stale { - // Since we can't shutdown a watchtower container immediately, we need to - // start the new one while the old one is still running. This prevents us - // from re-using the same container name so we first rename the current - // instance so that the new one can adopt the old name. - if container.IsWatchtower() { - if err := client.RenameContainer(container, randName()); err != nil { - log.Error(err) - continue - } - } - - if !params.NoRestart { - if err := client.StartContainer(container); err != nil { - log.Error(err) - } - } - - if params.Cleanup { - client.RemoveImage(container) - } - } - } - - return nil -} - -func checkDependencies(containers []container.Container) { - - for i, parent := range containers { - if parent.Stale { - continue - } - - LinkLoop: - for _, linkName := range parent.Links() { - for _, child := range containers { - if child.Name() == linkName && child.Stale { - containers[i].Stale = true - break LinkLoop - } - } - } - } -} - -// Generates a random, 32-character, Docker-compatible container name. -func randName() string { - b := make([]rune, 32) - for i := range b { - b[i] = letters[rand.Intn(len(letters))] - } - - return string(b) -} diff --git a/app/app.go b/app/app.go deleted file mode 100644 index 6a20e4f..0000000 --- a/app/app.go +++ /dev/null @@ -1,168 +0,0 @@ -package app - -import ( - "time" - - "github.com/urfave/cli" -) - -// SetupCliFlags registers flags on the supplied urfave app -func SetupCliFlags(app *cli.App) { - app.Flags = []cli.Flag{ - cli.StringFlag{ - Name: "host, H", - Usage: "daemon socket to connect to", - Value: "unix:///var/run/docker.sock", - EnvVar: "DOCKER_HOST", - }, - cli.IntFlag{ - Name: "interval, i", - Usage: "poll interval (in seconds)", - Value: 300, - EnvVar: "WATCHTOWER_POLL_INTERVAL", - }, - cli.StringFlag{ - Name: "schedule, s", - Usage: "the cron expression which defines when to update", - EnvVar: "WATCHTOWER_SCHEDULE", - }, - cli.BoolFlag{ - Name: "no-pull", - Usage: "do not pull new images", - EnvVar: "WATCHTOWER_NO_PULL", - }, - cli.BoolFlag{ - Name: "no-restart", - Usage: "do not restart containers", - EnvVar: "WATCHTOWER_NO_RESTART", - }, - cli.BoolFlag{ - Name: "cleanup", - Usage: "remove old images after updating", - EnvVar: "WATCHTOWER_CLEANUP", - }, - cli.BoolFlag{ - Name: "tlsverify", - Usage: "use TLS and verify the remote", - EnvVar: "DOCKER_TLS_VERIFY", - }, - cli.DurationFlag{ - Name: "stop-timeout", - Usage: "timeout before container is forcefully stopped", - Value: time.Second * 10, - EnvVar: "WATCHTOWER_TIMEOUT", - }, - cli.BoolFlag{ - Name: "label-enable", - Usage: "watch containers where the com.centurylinklabs.watchtower.enable label is true", - EnvVar: "WATCHTOWER_LABEL_ENABLE", - }, - cli.BoolFlag{ - Name: "debug", - Usage: "enable debug mode with verbose logging", - }, - cli.StringSliceFlag{ - Name: "notifications", - Value: &cli.StringSlice{}, - Usage: "notification types to send (valid: email, slack, msteams)", - EnvVar: "WATCHTOWER_NOTIFICATIONS", - }, - cli.StringFlag{ - Name: "notifications-level", - Usage: "The log level used for sending notifications. Possible values: \"panic\", \"fatal\", \"error\", \"warn\", \"info\" or \"debug\"", - EnvVar: "WATCHTOWER_NOTIFICATIONS_LEVEL", - Value: "info", - }, - cli.StringFlag{ - Name: "notification-email-from", - Usage: "Address to send notification e-mails from", - EnvVar: "WATCHTOWER_NOTIFICATION_EMAIL_FROM", - }, - cli.StringFlag{ - Name: "notification-email-to", - Usage: "Address to send notification e-mails to", - EnvVar: "WATCHTOWER_NOTIFICATION_EMAIL_TO", - }, - cli.StringFlag{ - Name: "notification-email-server", - Usage: "SMTP server to send notification e-mails through", - EnvVar: "WATCHTOWER_NOTIFICATION_EMAIL_SERVER", - }, - cli.IntFlag{ - Name: "notification-email-server-port", - Usage: "SMTP server port to send notification e-mails through", - Value: 25, - EnvVar: "WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT", - }, - cli.BoolFlag{ - Name: "notification-email-server-tls-skip-verify", - Usage: "Controls whether watchtower verifies the SMTP server's certificate chain and host name. " + - "If set, TLS accepts any certificate " + - "presented by the server and any host name in that certificate. " + - "In this mode, TLS is susceptible to man-in-the-middle attacks. " + - "This should be used only for testing.", - EnvVar: "WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY", - }, - cli.StringFlag{ - Name: "notification-email-server-user", - Usage: "SMTP server user for sending notifications", - EnvVar: "WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER", - }, - cli.StringFlag{ - Name: "notification-email-server-password", - Usage: "SMTP server password for sending notifications", - EnvVar: "WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD", - }, - cli.StringFlag{ - Name: "notification-slack-hook-url", - Usage: "The Slack Hook URL to send notifications to", - EnvVar: "WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL", - }, - cli.StringFlag{ - Name: "notification-slack-identifier", - Usage: "A string which will be used to identify the messages coming from this watchtower instance. Default if omitted is \"watchtower\"", - EnvVar: "WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER", - Value: "watchtower", - }, - cli.StringFlag{ - Name: "notification-slack-channel", - Usage: "A string which overrides the webhook's default channel. Example: #my-custom-channel", - EnvVar: "WATCHTOWER_NOTIFICATION_SLACK_CHANNEL", - }, - cli.StringFlag{ - Name: "notification-slack-icon-emoji", - Usage: "An emoji code string to use in place of the default icon", - EnvVar: "WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI", - }, - cli.StringFlag{ - Name: "notification-slack-icon-url", - Usage: "An icon image URL string to use in place of the default icon", - EnvVar: "WATCHTOWER_NOTIFICATION_SLACK_ICON_URL", - }, - cli.StringFlag{ - Name: "notification-msteams-hook", - Usage: "The MSTeams WebHook URL to send notifications to", - EnvVar: "WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL", - }, - cli.BoolFlag{ - Name: "notification-msteams-data", - Usage: "The MSTeams notifier will try to extract log entry fields as MSTeams message facts", - EnvVar: "WATCHTOWER_NOTIFICATION_MSTEAMS_USE_LOG_DATA", - }, - cli.BoolFlag{ - Name: "monitor-only", - Usage: "Will only monitor for new images, not update the containers", - EnvVar: "WATCHTOWER_MONITOR_ONLY", - }, - cli.BoolFlag{ - Name: "run-once", - Usage: "Run once now and exit", - EnvVar: "WATCHTOWER_RUN_ONCE", - }, - cli.BoolFlag{ - Name: "include-stopped", - Usage: "Will also include created and exited containers", - EnvVar: "WATCHTOWER_INCLUDE_STOPPED", - }, - } -} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..453196a --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,188 @@ +package cmd + +import ( + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/containrrr/watchtower/internal/actions" + "github.com/containrrr/watchtower/internal/flags" + "github.com/containrrr/watchtower/pkg/container" + "github.com/containrrr/watchtower/pkg/notifications" + t "github.com/containrrr/watchtower/pkg/types" + "github.com/robfig/cron" + log "github.com/sirupsen/logrus" + + "github.com/spf13/cobra" +) + +var ( + client container.Client + scheduleSpec string + cleanup bool + noRestart bool + monitorOnly bool + enableLabel bool + notifier *notifications.Notifier + timeout time.Duration + lifecycleHooks bool +) + +var rootCmd = &cobra.Command{ + Use: "watchtower", + Short: "Automatically updates running Docker containers", + Long: ` +Watchtower automatically updates running Docker containers whenever a new image is released. +More information available at https://github.com/containrrr/watchtower/. +`, + Run: Run, + PreRun: PreRun, +} + +func init() { + flags.SetDefaults() + flags.RegisterDockerFlags(rootCmd) + flags.RegisterSystemFlags(rootCmd) + flags.RegisterNotificationFlags(rootCmd) +} + +// Execute the root func and exit in case of errors +func Execute() { + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } +} + +// PreRun is a lifecycle hook that runs before the command is executed. +func PreRun(cmd *cobra.Command, args []string) { + f := cmd.PersistentFlags() + + if enabled, _ := f.GetBool("debug"); enabled { + log.SetLevel(log.DebugLevel) + } + + pollingSet := f.Changed("interval") + schedule, _ := f.GetString("schedule") + cronLen := len(schedule) + + if pollingSet && cronLen > 0 { + log.Fatal("Only schedule or interval can be defined, not both.") + } else if cronLen > 0 { + scheduleSpec, _ = f.GetString("schedule") + } else { + interval, _ := f.GetInt("interval") + scheduleSpec = "@every " + strconv.Itoa(interval) + "s" + } + + cleanup, noRestart, monitorOnly, timeout = flags.ReadFlags(cmd) + + if timeout < 0 { + log.Fatal("Please specify a positive value for timeout value.") + } + + enableLabel, _ = f.GetBool("label-enable") + lifecycleHooks, _ = f.GetBool("enable-lifecycle-hooks") + + // configure environment vars for client + err := flags.EnvConfig(cmd) + if err != nil { + log.Fatal(err) + } + + noPull, _ := f.GetBool("no-pull") + includeStopped, _ := f.GetBool("include-stopped") + reviveStopped, _ := f.GetBool("revive-stopped") + removeVolumes, _ := f.GetBool("remove-volumes") + + client = container.NewClient( + !noPull, + includeStopped, + reviveStopped, + removeVolumes, + ) + + notifier = notifications.NewNotifier(cmd) +} + +// Run is the main execution flow of the command +func Run(c *cobra.Command, names []string) { + filter := container.BuildFilter(names, enableLabel) + runOnce, _ := c.PersistentFlags().GetBool("run-once") + + if runOnce { + log.Info("Running a one time update.") + runUpdatesWithNotifications(filter) + os.Exit(0) + return + } + + if err := actions.CheckForMultipleWatchtowerInstances(client, cleanup); err != nil { + log.Fatal(err) + } + + if err := runUpgradesOnSchedule(filter); err != nil { + log.Error(err) + } + + os.Exit(1) +} + +func runUpgradesOnSchedule(filter t.Filter) error { + tryLockSem := make(chan bool, 1) + tryLockSem <- true + + cron := cron.New() + err := cron.AddFunc( + scheduleSpec, + func() { + select { + case v := <-tryLockSem: + defer func() { tryLockSem <- v }() + runUpdatesWithNotifications(filter) + default: + log.Debug("Skipped another update already running.") + } + + nextRuns := cron.Entries() + if len(nextRuns) > 0 { + log.Debug("Scheduled next run: " + nextRuns[0].Next.String()) + } + }) + + if err != nil { + return err + } + + log.Debug("Starting Watchtower and scheduling first run: " + cron.Entries()[0].Schedule.Next(time.Now()).String()) + cron.Start() + + // Graceful shut-down on SIGINT/SIGTERM + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + signal.Notify(interrupt, syscall.SIGTERM) + + <-interrupt + cron.Stop() + log.Info("Waiting for running update to be finished...") + <-tryLockSem + return nil +} + +func runUpdatesWithNotifications(filter t.Filter) { + notifier.StartNotification() + updateParams := actions.UpdateParams{ + Filter: filter, + Cleanup: cleanup, + NoRestart: noRestart, + Timeout: timeout, + MonitorOnly: monitorOnly, + LifecycleHooks: lifecycleHooks, + } + err := actions.Update(client, updateParams) + if err != nil { + log.Println(err) + } + notifier.SendNotification() +} diff --git a/docs/arguments.md b/docs/arguments.md index b62fd50..938dffa 100644 --- a/docs/arguments.md +++ b/docs/arguments.md @@ -12,8 +12,8 @@ $ docker run -d \ ``` In the example above, watchtower will only monitor the containers named "nginx" and "redis" for updates -- all of the other -running containers will be ignored. If you do not want watchtower to run as a daemon you can pass a run-once flag and remove -the watchtower container after it's execution. +running containers will be ignored. If you do not want watchtower to run as a daemon you can pass the `--run-once` flag and remove +the watchtower container after its execution. ```bash $ docker run --rm \ @@ -23,7 +23,7 @@ $ docker run --rm \ nginx redis ``` -In the example above, watchtower will execute an upgrade attempt on the containers named "nginx" and "redis". Using this mode will enable debugging output showing all actions performed as usage is intended for interactive users. Once the attempt is completed, the container will exit and remove itself due to the "--rm" flag. +In the example above, watchtower will execute an upgrade attempt on the containers named "nginx" and "redis". Using this mode will enable debugging output showing all actions performed, as usage is intended for interactive users. Once the attempt is completed, the container will exit and remove itself due to the `--rm` flag. When no arguments are specified, watchtower will monitor all running containers. @@ -47,12 +47,22 @@ Environment Variable: WATCHTOWER_CLEANUP Default: false ``` +## Remove attached volumes +Removes attached volumes after updating. When this flag is specified, watchtower will remove all attached volumes from the container before restarting with a new image. Use this option to force new volumes to be populated as containers are updated. + +``` + Argument: --remove-volumes +Environment Variable: WATCHTOWER_REMOVE_VOLUMES + Type: Boolean + Default: false +``` + ## Debug Enable debug mode with verbose logging. ``` Argument: --debug -Environment Variable: N/A +Environment Variable: WATCHTOWER_DEBUG Type: Boolean Default: false ``` @@ -65,7 +75,17 @@ Docker daemon socket to connect to. Can be pointed at a remote Docker host by sp Environment Variable: DOCKER_HOST Type: String Default: "unix:///var/run/docker.sock" -``` +``` + +## Docker API version +The API version to use by the Docker client for connecting to the Docker daemon. The minimum supported version is 1.24. + +``` + Argument: --api-version, -a +Environment Variable: DOCKER_API_VERSION + Type: String + Default: "1.24" +``` ## Include stopped Will also include created and exited containers. @@ -77,18 +97,28 @@ Environment Variable: WATCHTOWER_INCLUDE_STOPPED Default: false ``` -## Poll Interval +## Revive stopped +Start any stopped containers that have had their image updated. This argument is only usable with the `--include-stopped` argument. + +``` + Argument: --revive-stopped +Environment Variable: WATCHTOWER_REVIVE_STOPPED + Type: Boolean + Default: false +``` + +## Poll interval Poll interval (in seconds). This value controls how frequently watchtower will poll for new images. ``` - Argument: ---interval, -i + Argument: --interval, -i Environment Variable: WATCHTOWER_POLL_INTERVAL Type: Integer Default: 300 ``` ## Filter by enable label -Watch containers where the `com.centurylinklabs.watchtower.enable` label is set to true. +Update containers that have a `com.centurylinklabs.watchtower.enable` label set to true. ``` Argument: --label-enable @@ -107,6 +137,16 @@ Environment Variable: WATCHTOWER_MONITOR_ONLY Default: false ``` +## Without restarting containers +Do not restart containers after updating. This option can be useful when the start of the containers +is managed by an external system such as systemd. +``` + Argument: --no-restart +Environment Variable: WATCHTOWER_NO_RESTART + Type: Boolean + Default: false +``` + ## Without pulling new images Do not pull new images. When this flag is specified, watchtower will not attempt to pull new images from the registry. Instead it will only monitor the local image cache for changes. diff --git a/docs/container-selection.md b/docs/container-selection.md index 0e2cd20..4c3312c 100644 --- a/docs/container-selection.md +++ b/docs/container-selection.md @@ -12,7 +12,7 @@ Or, it can be specified as part of the `docker run` command line: docker run -d --label=com.centurylinklabs.watchtower.enable=false someimage ``` -If you need to only include only some containers, pass the --label-enable flag on startup and set the _com.centurylinklabs.watchtower.enable_ label with a value of true for the containers you want to watch. +If you need to include only some containers, pass the `--label-enable` flag on startup and set the _com.centurylinklabs.watchtower.enable_ label with a value of `true` for the containers you want to watch. ```docker LABEL com.centurylinklabs.watchtower.enable="true" diff --git a/docs/credential-helpers.md b/docs/credential-helpers.md new file mode 100644 index 0000000..c86ef7c --- /dev/null +++ b/docs/credential-helpers.md @@ -0,0 +1,66 @@ +Some private docker registries (the most prominent probably being AWS ECR) use non-standard ways of authentication. +To be able to use this together with watchtower, we need to use a credential helper. + +To keep the image size small we've decided to not include any helpers in the watchtower image, instead we'll put the +helper in a separate container and mount it using volumes. + +### Example +Example implementation for use with [amazon-ecr-credential-helper](https://github.com/awslabs/amazon-ecr-credential-helper): + +```Dockerfile +FROM golang:latest + +ENV CGO_ENABLED 0 +ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login + +RUN go get -u $REPO + +RUN rm /go/bin/docker-credential-ecr-login + +RUN go build \ + -o /go/bin/docker-credential-ecr-login \ + /go/src/$REPO + +WORKDIR /go/bin/ +``` + +and the docker-compose definition: +```yaml +version: "3" + +services: + watchtower: + image: index.docker.io/containrrr/watchtower:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /.docker/config.json:/config.json + - helper:/go/bin + environment: + - HOME=/ + - PATH=$PATH:/go/bin + - AWS_REGION= + - AWS_ACCESS_KEY_ID= + - AWS_SECRET_ACCESS_KEY= +volumes: + helper: {} +``` + +and for `/.docker/config.json`: +```json + { + "HttpHeaders" : { + "User-Agent" : "Docker-Client/19.03.1 (XXXXXX)" + }, + "credsStore" : "osxkeychain", + "auths" : { + "xyzxyzxyz.dkr.ecr.eu-north-1.amazonaws.com" : {}, + "https://index.docker.io/v1/": {} + }, + "credHelpers": { + "xyzxyzxyz.dkr.ecr.eu-north-1.amazonaws.com" : "ecr-login", + "index.docker.io": "osxkeychain" + } + } +``` + +*Note:* `osxkeychain` can be changed to your prefered credentials helper. diff --git a/docs/lifecycle-hooks.md b/docs/lifecycle-hooks.md new file mode 100644 index 0000000..071726c --- /dev/null +++ b/docs/lifecycle-hooks.md @@ -0,0 +1,53 @@ +## Executing commands before and after updating + +> **DO NOTE**: These are shell commands executed with `sh`, and therefore require the +> container to provide the `sh` executable. + +It is possible to execute _pre/post\-check_ and _pre/post\-update_ commands +**inside** every container updated by watchtower. + +- The _pre-check_ command is executed for each container prior to every update cycle. +- The _pre-update_ command is executed before stopping the container when an update is about to start. +- The _post-update_ command is executed after restarting the updated container +- The _post-check_ command is executed for each container post every update cycle. + +This feature is disabled by default. To enable it, you need to set the option +`--enable-lifecycle-hooks` on the command line, or set the environment variable +`WATCHTOWER_LIFECYCLE_HOOKS` to `true`. + +### Specifying update commands + +The commands are specified using docker container labels, the following are currently available: + +| Type | Docker Container Label | +| ----------- | ------------------------------------------------------ | +| Pre Check | `com.centurylinklabs.watchtower.lifecycle.pre-check` | +| Pre Update | `com.centurylinklabs.watchtower.lifecycle.pre-update` | +| Post Update | `com.centurylinklabs.watchtower.lifecycle.post-update` | +| Post Check | `com.centurylinklabs.watchtower.lifecycle.post-check` | + +These labels can be declared as instructions in a Dockerfile (with some example .sh files): + +```docker +LABEL com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh" +LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh" +LABEL com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh" +LABEL com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh" +``` + +Or be specified as part of the `docker run` command line: + +```bash +docker run -d \ + --label=com.centurylinklabs.watchtower.lifecycle.pre-check="/sync.sh" \ + --label=com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh" \ + --label=com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh" \ + someimage + --label=com.centurylinklabs.watchtower.lifecycle.post-check="/send-heartbeat.sh" \ +``` + +### Execution failure + +The failure of a command to execute, identified by an exit code different than +0, will not prevent watchtower from updating the container. Only an error +log statement containing the exit code will be reported. diff --git a/docs/linked-containers.md b/docs/linked-containers.md index 133d3ca..6960b5b 100644 --- a/docs/linked-containers.md +++ b/docs/linked-containers.md @@ -1,3 +1,3 @@ -Watchtower will detect if there are links between any of the running containers and ensure that things are stopped/started in a way that won't break any of the links. If an update is detected for one of the dependencies in a group of linked containers, watchtower will stop and start all of the containers in the correct order so that the application comes back up correctly. +Watchtower will detect if there are links between any of the running containers and ensures that things are stopped/started in a way that won't break any of the links. If an update is detected for one of the dependencies in a group of linked containers, watchtower will stop and start all of the containers in the correct order so that the application comes back up correctly. For example, imagine you were running a _mysql_ container and a _wordpress_ container which had been linked to the _mysql_ container. If watchtower were to detect that the _mysql_ container required an update, it would first shut down the linked _wordpress_ container followed by the _mysql_ container. When restarting the containers it would handle _mysql_ first and then _wordpress_ to ensure that the link continued to work. \ No newline at end of file diff --git a/docs/notifications.md b/docs/notifications.md index 02f144a..b95e95e 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -1,12 +1,19 @@ - + # Notifications Watchtower can send notifications when containers are updated. Notifications are sent via hooks in the logging system, [logrus](http://github.com/sirupsen/logrus). -The types of notifications to send are passed via the comma-separated option `--notifications` (or corresponding environment variable `WATCHTOWER_NOTIFICATIONS`), which has the following valid values: +The types of notifications to send are set by passing a comma-separated list of values to the `--notifications` option (or corresponding environment variable `WATCHTOWER_NOTIFICATIONS`), which has the following valid values: - `email` to send notifications via e-mail - `slack` to send notifications through a Slack webhook - `msteams` to send notifications via MSTeams webhook +- `gotify` to send notifications via Gotify + +> There is currently a [bug](https://github.com/spf13/viper/issues/380) in Viper, which prevents comma-separated slices to be used when using the environment variable. A workaround is available where we instead put quotes around the environment variable value and replace the commas with spaces, as `WATCHTOWER_NOTIFICATIONS="slack msteams"` + +> If you're a `docker-compose` user, make sure to specify environment variables' values in your `.yml` file without double quotes (`"`). +> +> This prevents unexpected errors when watchtower starts. ## Settings @@ -25,6 +32,8 @@ To receive notifications by email, the following command-line options, or their - `--notification-email-server-port` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT`): The port used to connect to the SMTP server to send e-mails through. Defaults to `25`. - `--notification-email-server-user` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER`): The username to authenticate with the SMTP server with. - `--notification-email-server-password` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD`): The password to authenticate with the SMTP server with. +- `--notification-email-delay` (env. `WATCHTOWER_NOTIFICATION_EMAIL_DELAY`): Delay before sending notifications expressed in seconds. +- `--notification-email-subjecttag` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG`): Prefix to include in the subject tag. Useful when running multiple watchtowers. Example: @@ -38,6 +47,7 @@ docker run -d \ -e WATCHTOWER_NOTIFICATION_EMAIL_SERVER=smtp.gmail.com \ -e WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER=fromaddress@gmail.com \ -e WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD=app_password \ + -e WATCHTOWER_NOTIFICATION_EMAIL_DELAY=2 \ containrrr/watchtower ``` @@ -46,7 +56,7 @@ If watchtower is monitoring the same Docker daemon under which the watchtower co To receive notifications in Slack, add `slack` to the `--notifications` option or the `WATCHTOWER_NOTIFICATIONS` environment variable. -Additionally, you should set the Slack webhook url using the `--notification-slack-hook-url` option or the `WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL` environment variable. +Additionally, you should set the Slack webhook URL using the `--notification-slack-hook-url` option or the `WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL` environment variable. By default, watchtower will send messages under the name `watchtower`, you can customize this string through the `--notification-slack-identifier` option or the `WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER` environment variable. @@ -75,7 +85,7 @@ docker run -d \ To receive notifications in MSTeams channel, add `msteams` to the `--notifications` option or the `WATCHTOWER_NOTIFICATIONS` environment variable. -Additionally, you should set the MSTeams webhook url using the `--notification-msteams-hook` option or the `WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL` environment variable. +Additionally, you should set the MSTeams webhook URL using the `--notification-msteams-hook` option or the `WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL` environment variable. MSTeams notifier could send keys/values filled by `log.WithField` or `log.WithFields` as MSTeams message facts. To enable this feature add `--notification-msteams-data` flag or set `WATCHTOWER_NOTIFICATION_MSTEAMS_USE_LOG_DATA=true` environment variable. @@ -90,3 +100,18 @@ docker run -d \ -e WATCHTOWER_NOTIFICATION_MSTEAMS_USE_LOG_DATA=true \ containrrr/watchtower ``` + +### Gotify + +To push a notification to your Gotify instance, register a Gotify app and specify the Gotify URL and app token: + + +```bash +docker run -d \ + --name watchtower \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e WATCHTOWER_NOTIFICATIONS=gotify \ + -e WATCHTOWER_NOTIFICATION_GOTIFY_URL="https://my.gotify.tld/" \ + -e WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN="SuperSecretToken" \ + containrrr/watchtower +``` diff --git a/docs/usage-overview.md b/docs/usage-overview.md index fcc2039..f74a9a7 100644 --- a/docs/usage-overview.md +++ b/docs/usage-overview.md @@ -1,6 +1,6 @@ Watchtower is itself packaged as a Docker container so installation is as simple as pulling the `containrrr/watchtower` image. If you are using ARM based architecture, pull the appropriate `containrrr/watchtower:armhf-` image from the [containrrr Docker Hub](https://hub.docker.com/r/containrrr/watchtower/tags/). -Since the watchtower code needs to interact with the Docker API in order to monitor the running containers, you need to mount _/var/run/docker.sock_ into the container with the -v flag when you run it. +Since the watchtower code needs to interact with the Docker API in order to monitor the running containers, you need to mount _/var/run/docker.sock_ into the container with the `-v` flag when you run it. Run the `watchtower` container with the following command: @@ -37,7 +37,7 @@ docker run -d \ containrrr/watchtower container_to_watch --debug ``` -If you mount the config file as described above, be sure to also prepend the url for the registry when starting up your watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container from a private repo at dockerhub and monitors it with watchtower. Note the command argument changing the interval to 30s rather than the default 5 minutes. +If you mount the config file as described above, be sure to also prepend the URL for the registry when starting up your watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container from a private repo at Docker Hub and monitors it with watchtower. Note the command argument changing the interval to 30s rather than the default 5 minutes. ```json version: "3" diff --git a/go.mod b/go.mod index 0d7ae0e..b5be153 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 github.com/Microsoft/go-winio v0.4.12 github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 - github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 + github.com/beorn7/perks v1.0.0 github.com/brysgo/gomock_ginkgo v0.0.0-20180512161304-be2c1b0e4111 github.com/containerd/containerd v1.2.6 // indirect github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 @@ -23,7 +23,6 @@ require ( github.com/gogo/protobuf v1.2.1 github.com/golang/mock v1.1.1 github.com/golang/protobuf v1.3.1 - github.com/google/go-cmp v0.2.0 // indirect github.com/gorilla/mux v1.7.0 github.com/hashicorp/go-memdb v1.0.0 // indirect github.com/hashicorp/go-version v1.1.0 @@ -44,24 +43,23 @@ require ( github.com/opencontainers/selinux v1.2.1 // indirect github.com/pkg/errors v0.8.1 github.com/pmezard/go-difflib v1.0.0 - github.com/prometheus/client_golang v0.9.2 + github.com/prometheus/client_golang v0.9.3 github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 - github.com/prometheus/common v0.2.0 - github.com/prometheus/procfs v0.0.0-20190403104016-ea9eea638872 + github.com/prometheus/common v0.4.0 + github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 github.com/sirupsen/logrus v1.4.1 github.com/spf13/cobra v0.0.3 github.com/spf13/pflag v1.0.3 + github.com/spf13/viper v1.4.0 github.com/stretchr/objx v0.1.1 github.com/stretchr/testify v1.3.0 github.com/theupdateframework/notary v0.6.1 - github.com/urfave/cli v1.20.0 github.com/vbatts/tar-split v0.11.1 // indirect golang.org/x/crypto v0.0.0-20190403202508-8e1b8d32e692 - golang.org/x/net v0.0.0-20190403144856-b630fd6fe46b + golang.org/x/net v0.0.0-20190522155817-f3200d17e092 golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e - golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect google.golang.org/genproto v0.0.0-20190401181712-f467c93bbac2 - google.golang.org/grpc v1.19.1 + google.golang.org/grpc v1.21.0 gotest.tools v2.2.0+incompatible // indirect ) diff --git a/go.sum b/go.sum index b0415fc..2107c46 100644 --- a/go.sum +++ b/go.sum @@ -4,22 +4,34 @@ github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Microsoft/go-winio v0.4.12 h1:xAfWHN1IrQ0NJ9TBC0KBZoqLjzDTr1ML+4MywiUOryc= github.com/Microsoft/go-winio v0.4.12/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI= github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/brysgo/gomock_ginkgo v0.0.0-20180512161304-be2c1b0e4111 h1:gRfsoKtF1tba+hVsNgo7OKG7a35hBK30ouOTHPgqFf8= github.com/brysgo/gomock_ginkgo v0.0.0-20180512161304-be2c1b0e4111/go.mod h1:H1ipqq0hhUWJgVeQ5dbUe/C8YptJrE/VGDQp9bI+qTo= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/containerd/containerd v1.2.6 h1:K38ZSAA9oKSrX3iFNY+4SddZ8hH1TCMCerc8NHfcKBQ= github.com/containerd/containerd v1.2.6/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 h1:4BX8f882bXEDKfWIf0wa8HRvpnBoPszJJXL+TVbBw4M= github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/docker/cli v0.0.0-20190327152802-57b27434ea29 h1:ciaXDHaWQda0nvevWqcjtXX/buQY3e0lga1vq8Batq0= github.com/docker/cli v0.0.0-20190327152802-57b27434ea29/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= @@ -40,21 +52,30 @@ github.com/docker/swarmkit v1.12.0 h1:vcbNXevt9xOod0miQxkp9WZ70IsOCe8geXkmFnXP2e github.com/docker/swarmkit v1.12.0/go.mod h1:n3Z4lIEl7g261ptkGDBcYi/3qBMDl9csaAhwi2MPejs= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U= github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-memdb v1.0.0 h1:K1O4N2VPndZiTrdH3lmmf5bemr9Xw81KjVwhReIUjTQ= github.com/hashicorp/go-memdb v1.0.0/go.mod h1:I6dKdmYhZqU0RJSheVEWgTNWdVQH5QvTgIUQ0t/t32M= @@ -62,6 +83,8 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/go-version v1.1.0 h1:bPIoEKD27tNdebFGGxxYwcL4nepeY4j1QP23PFRGzg0= github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= @@ -70,6 +93,7 @@ github.com/johntdyer/slack-go v0.0.0-20180213144715-95fac1160b22 h1:jKUP9TQ0c7X3 github.com/johntdyer/slack-go v0.0.0-20180213144715-95fac1160b22/go.mod h1:u0Jo4f2dNlTJeeOywkM6bLwxq6gC3pZ9rEFHn3AhTdk= github.com/johntdyer/slackrus v0.0.0-20180518184837-f7aae3243a07 h1:+kBG/8rjCa6vxJZbUjAiE4MQmBEBYc8nLEb51frnvBY= github.com/johntdyer/slackrus v0.0.0-20180518184837-f7aae3243a07/go.mod h1:j1kV/8f3jowErEq4XyeypkCdvg5EeHkf0YCKCcq5Ybo= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -77,13 +101,21 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-shellwords v1.0.5/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/pkcs11 v0.0.0-20190401114359-553cfdd26aaa/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -98,6 +130,8 @@ github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59P github.com/opencontainers/runtime-spec v1.0.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/selinux v1.2.1 h1:Svlc+L67YcjN4K2bqD8Wlw9jtMlmZ+1FEGn6zsm8am0= github.com/opencontainers/selinux v1.2.1/go.mod h1:+BLncwf63G4dgOzykXAxcmnFlUaOlkDdmw/CqsW6pjs= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -106,25 +140,44 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740= github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= +github.com/prometheus/client_golang v0.9.3 h1:9iH4JKXLzFbOAdtqv/a+j8aewx2Y8lAjAydhbaScPF8= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.2.0 h1:kUZDBDTdBVBYBj5Tmh2NZLlF60mfjA27rM34b+cVwNU= github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.0 h1:7etb9YClo3a6HjLzfl6rIQaU+FDfi0VSX39io3aQ+DM= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190403104016-ea9eea638872 h1:0aNv3xC7DmQoy1/x1sMh18g+fihWW68LL13i8ao9kl4= github.com/prometheus/procfs v0.0.0-20190403104016-ea9eea638872/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 h1:sofwID9zm4tzrgykg80hfFph1mryUeLRsUfoocVVmRY= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 h1:x7xEyJDP7Hv3LVgvWhzioQqbC/KtuUhTigKlH/8ehhE= github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -133,9 +186,15 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/theupdateframework/notary v0.6.1 h1:7wshjstgS9x9F5LuB1L5mBI2xNMObWqjz+cjWoom6l0= github.com/theupdateframework/notary v0.6.1/go.mod h1:MOfgIfmox8s7/7fduvB2xyPPMJCrjRLRizA8OFwpnKY= -github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= -github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/vbatts/tar-split v0.11.1/go.mod h1:LEuURwDEiWjRjwu46yU3KVGuUdVv/dcnpcEPSzR8z6g= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190403202508-8e1b8d32e692 h1:GRhHqDOgeDr6QDTtq9gn2O4iKvm5dsbfqD/TXb0KLX0= @@ -143,14 +202,19 @@ golang.org/x/crypto v0.0.0-20190403202508-8e1b8d32e692/go.mod h1:WFFai1msRO1wXaE golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190403144856-b630fd6fe46b h1:/zjbcJPEGAyu6Is/VBOALsgdi4z9+kz/Vtdm6S+beD0= golang.org/x/net v0.0.0-20190403144856-b630fd6fe46b/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092 h1:4QSRKanuywn15aTZvI/mIDEgPQpswuFndXpOj3rKEco= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -160,16 +224,19 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e h1:nFYrTHrdrAOpShe27kaFHjsqYSEQ0KWqdWLu3xuZJts= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -178,12 +245,20 @@ google.golang.org/genproto v0.0.0-20190401181712-f467c93bbac2/go.mod h1:VzzqZJRn google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.1 h1:TrBcJ1yqAl1G++wO39nD/qtgpsW9/1+QGrluyMGEYgM= google.golang.org/grpc v1.19.1/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0 h1:G+97AoqBnmZIT91cLG/EkCoK9NSelj64P8bOHHNmGn0= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/actions/actions_suite_test.go b/internal/actions/actions_suite_test.go similarity index 80% rename from actions/actions_suite_test.go rename to internal/actions/actions_suite_test.go index 850fd4e..76d2be5 100644 --- a/actions/actions_suite_test.go +++ b/internal/actions/actions_suite_test.go @@ -2,14 +2,15 @@ package actions_test import ( "errors" + "github.com/containrrr/watchtower/internal/actions" "testing" "time" - "github.com/containrrr/watchtower/actions" - "github.com/containrrr/watchtower/container" - "github.com/containrrr/watchtower/container/mocks" + "github.com/containrrr/watchtower/pkg/container" + "github.com/containrrr/watchtower/pkg/container/mocks" "github.com/docker/docker/api/types" + t "github.com/containrrr/watchtower/pkg/types" cli "github.com/docker/docker/client" . "github.com/onsi/ginkgo" @@ -32,9 +33,10 @@ var _ = Describe("the actions package", func() { }) BeforeEach(func() { client = mockClient{ - api: dockerClient, - pullImages: false, - TestData: &TestData{}, + api: dockerClient, + pullImages: false, + removeVolumes: false, + TestData: &TestData{}, } }) @@ -62,8 +64,9 @@ var _ = Describe("the actions package", func() { When("given multiple containers", func() { BeforeEach(func() { client = mockClient{ - api: dockerClient, - pullImages: false, + api: dockerClient, + pullImages: false, + removeVolumes: false, TestData: &TestData{ NameOfContainerToKeep: "test-container-02", Containers: []container.Container{ @@ -89,8 +92,9 @@ var _ = Describe("the actions package", func() { When("deciding whether to cleanup images", func() { BeforeEach(func() { client = mockClient{ - api: dockerClient, - pullImages: false, + api: dockerClient, + pullImages: false, + removeVolumes: false, TestData: &TestData{ Containers: []container.Container{ createMockContainer( @@ -134,9 +138,10 @@ func createMockContainer(id string, name string, image string, created time.Time } type mockClient struct { - TestData *TestData - api cli.CommonAPIClient - pullImages bool + TestData *TestData + api cli.CommonAPIClient + pullImages bool + removeVolumes bool } type TestData struct { @@ -145,7 +150,7 @@ type TestData struct { Containers []container.Container } -func (client mockClient) ListContainers(f container.Filter) ([]container.Container, error) { +func (client mockClient) ListContainers(f t.Filter) ([]container.Container, error) { return client.TestData.Containers, nil } @@ -155,7 +160,7 @@ func (client mockClient) StopContainer(c container.Container, d time.Duration) e } return nil } -func (client mockClient) StartContainer(c container.Container) error { +func (client mockClient) StartContainer(c container.Container) (string, error) { panic("Not implemented") } @@ -168,6 +173,14 @@ func (client mockClient) RemoveImage(c container.Container) error { return nil } +func (client mockClient) GetContainer(containerID string) (container.Container, error) { + return container.Container{}, nil +} + +func (client mockClient) ExecuteCommand(containerID string, command string) error { + return nil +} + func (client mockClient) IsContainerStale(c container.Container) (bool, error) { panic("Not implemented") } diff --git a/actions/check.go b/internal/actions/check.go similarity index 92% rename from actions/check.go rename to internal/actions/check.go index 16fd42e..8574300 100644 --- a/actions/check.go +++ b/internal/actions/check.go @@ -11,7 +11,7 @@ import ( log "github.com/sirupsen/logrus" - "github.com/containrrr/watchtower/container" + "github.com/containrrr/watchtower/pkg/container" ) // CheckForMultipleWatchtowerInstances will ensure that there are not multiple instances of the @@ -50,7 +50,7 @@ func cleanupExcessWatchtowers(containers []container.Container, client container continue } - if cleanup == true { + if cleanup { if err := client.RemoveImage(c); err != nil { // logging the original here as we're just returning a count logrus.Error(err) @@ -79,6 +79,6 @@ func createErrorIfAnyHaveOccurred(c int, i int) error { } func awaitDockerClient() { - log.Debug("Sleeping for a seconds to ensure the docker api client has been properly initialized.") + log.Debug("Sleeping for a second to ensure the docker api client has been properly initialized.") time.Sleep(1 * time.Second) } diff --git a/internal/actions/update.go b/internal/actions/update.go new file mode 100644 index 0000000..02013d6 --- /dev/null +++ b/internal/actions/update.go @@ -0,0 +1,207 @@ +package actions + +import ( + "github.com/containrrr/watchtower/internal/util" + "github.com/containrrr/watchtower/pkg/container" + log "github.com/sirupsen/logrus" +) + +// Update looks at the running Docker containers to see if any of the images +// used to start those containers have been updated. If a change is detected in +// any of the images, the associated containers are stopped and restarted with +// the new image. +func Update(client container.Client, params UpdateParams) error { + log.Debug("Checking containers for updated images") + + executePreCheck(client, params) + + containers, err := client.ListContainers(params.Filter) + if err != nil { + return err + } + + for i, container := range containers { + stale, err := client.IsContainerStale(container) + if err != nil { + log.Infof("Unable to update container %s. Proceeding to next.", containers[i].Name()) + log.Debug(err) + stale = false + } + containers[i].Stale = stale + } + + containers, err = container.SortByDependencies(containers) + if err != nil { + return err + } + + checkDependencies(containers) + + if params.MonitorOnly { + executePostCheck(client, params) + return nil + } + + stopContainersInReversedOrder(containers, client, params) + restartContainersInSortedOrder(containers, client, params) + + executePostCheck(client, params) + return nil +} + +func stopContainersInReversedOrder(containers []container.Container, client container.Client, params UpdateParams) { + for i := len(containers) - 1; i >= 0; i-- { + stopStaleContainer(containers[i], client, params) + } +} + +func stopStaleContainer(container container.Container, client container.Client, params UpdateParams) { + if container.IsWatchtower() { + log.Debugf("This is the watchtower container %s", container.Name()) + return + } + + if !container.Stale { + return + } + + executePreUpdateCommand(client, container) + + if err := client.StopContainer(container, params.Timeout); err != nil { + log.Error(err) + } +} + +func restartContainersInSortedOrder(containers []container.Container, client container.Client, params UpdateParams) { + for _, container := range containers { + if !container.Stale { + continue + } + restartStaleContainer(container, client, params) + } +} + +func restartStaleContainer(container container.Container, client container.Client, params UpdateParams) { + // Since we can't shutdown a watchtower container immediately, we need to + // start the new one while the old one is still running. This prevents us + // from re-using the same container name so we first rename the current + // instance so that the new one can adopt the old name. + if container.IsWatchtower() { + if err := client.RenameContainer(container, util.RandName()); err != nil { + log.Error(err) + return + } + } + + if !params.NoRestart { + if newContainerID, err := client.StartContainer(container); err != nil { + log.Error(err) + } else if container.Stale && params.LifecycleHooks { + executePostUpdateCommand(client, newContainerID) + } + } + + if params.Cleanup { + if err := client.RemoveImage(container); err != nil { + log.Error(err) + } + } +} + +func checkDependencies(containers []container.Container) { + + for i, parent := range containers { + if parent.ToRestart() { + continue + } + + LinkLoop: + for _, linkName := range parent.Links() { + for _, child := range containers { + if child.Name() == linkName && child.ToRestart() { + containers[i].Linked = true + break LinkLoop + } + } + } + } +} + +func executePreCheck(client container.Client, params UpdateParams) { + containers, err := client.ListContainers(params.Filter) + if err != nil { + return + } + for _, container := range containers { + executePreCheckCommand(client, container) + } +} + +func executePostCheck(client container.Client, params UpdateParams) { + containers, err := client.ListContainers(params.Filter) + if err != nil { + return + } + for _, container := range containers { + executePostCheckCommand(client, container) + } +} + +func executePreCheckCommand(client container.Client, container container.Container) { + command := container.GetLifecyclePreCheckCommand() + if len(command) == 0 { + log.Debug("No pre-check command supplied. Skipping") + return + } + + log.Info("Executing pre-check command.") + if err := client.ExecuteCommand(container.ID(), command); err != nil { + log.Error(err) + } +} + +func executePostCheckCommand(client container.Client, container container.Container) { + command := container.GetLifecyclePostCheckCommand() + if len(command) == 0 { + log.Debug("No post-check command supplied. Skipping") + return + } + + log.Info("Executing post-check command.") + if err := client.ExecuteCommand(container.ID(), command); err != nil { + log.Error(err) + } +} + +func executePreUpdateCommand(client container.Client, container container.Container) { + + command := container.GetLifecyclePreUpdateCommand() + if len(command) == 0 { + log.Debug("No pre-update command supplied. Skipping") + return + } + + log.Info("Executing pre-update command.") + if err := client.ExecuteCommand(container.ID(), command); err != nil { + log.Error(err) + } +} + +func executePostUpdateCommand(client container.Client, newContainerID string) { + newContainer, err := client.GetContainer(newContainerID) + if err != nil { + log.Error(err) + return + } + + command := newContainer.GetLifecyclePostUpdateCommand() + if len(command) == 0 { + log.Debug("No post-update command supplied. Skipping") + return + } + + log.Info("Executing post-update command.") + if err := client.ExecuteCommand(newContainerID, command); err != nil { + log.Error(err) + } +} diff --git a/internal/actions/update_params.go b/internal/actions/update_params.go new file mode 100644 index 0000000..ff586c6 --- /dev/null +++ b/internal/actions/update_params.go @@ -0,0 +1,16 @@ +package actions + +import ( + t "github.com/containrrr/watchtower/pkg/types" + "time" +) + +// UpdateParams contains all different options available to alter the behavior of the Update func +type UpdateParams struct { + Filter t.Filter + Cleanup bool + NoRestart bool + Timeout time.Duration + MonitorOnly bool + LifecycleHooks bool +} diff --git a/internal/flags/flags.go b/internal/flags/flags.go new file mode 100644 index 0000000..7ab09bd --- /dev/null +++ b/internal/flags/flags.go @@ -0,0 +1,324 @@ +package flags + +import ( + "os" + "time" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// DockerAPIMinVersion is the minimum version of the docker api required to +// use watchtower +const DockerAPIMinVersion string = "1.24" + +// RegisterDockerFlags that are used directly by the docker api client +func RegisterDockerFlags(rootCmd *cobra.Command) { + flags := rootCmd.PersistentFlags() + flags.StringP("host", "H", viper.GetString("DOCKER_HOST"), "daemon socket to connect to") + flags.BoolP("tlsverify", "v", viper.GetBool("DOCKER_TLS_VERIFY"), "use TLS and verify the remote") + flags.StringP("api-version", "a", viper.GetString("DOCKER_API_VERSION"), "api version to use by docker client") +} + +// RegisterSystemFlags that are used by watchtower to modify the program flow +func RegisterSystemFlags(rootCmd *cobra.Command) { + flags := rootCmd.PersistentFlags() + flags.IntP( + "interval", + "i", + viper.GetInt("WATCHTOWER_POLL_INTERVAL"), + "poll interval (in seconds)") + + flags.StringP("schedule", + "s", + viper.GetString("WATCHTOWER_SCHEDULE"), + "the cron expression which defines when to update") + + flags.DurationP("stop-timeout", + "t", + viper.GetDuration("WATCHTOWER_TIMEOUT"), + "timeout before a container is forcefully stopped") + + flags.BoolP( + "no-pull", + "", + viper.GetBool("WATCHTOWER_NO_PULL"), + "do not pull any new images") + + flags.BoolP( + "no-restart", + "", + viper.GetBool("WATCHTOWER_NO_RESTART"), + "do not restart any containers") + + flags.BoolP( + "cleanup", + "c", + viper.GetBool("WATCHTOWER_CLEANUP"), + "remove previously used images after updating") + + flags.BoolP( + "remove-volumes", + "", + viper.GetBool("WATCHTOWER_REMOVE_VOLUMES"), + "remove attached volumes before updating") + + flags.BoolP( + "label-enable", + "e", + viper.GetBool("WATCHTOWER_LABEL_ENABLE"), + "watch containers where the com.centurylinklabs.watchtower.enable label is true") + + flags.BoolP( + "debug", + "d", + viper.GetBool("WATCHTOWER_DEBUG"), + "enable debug mode with verbose logging") + + flags.BoolP( + "monitor-only", + "m", + viper.GetBool("WATCHTOWER_MONITOR_ONLY"), + "Will only monitor for new images, not update the containers") + + flags.BoolP( + "run-once", + "R", + viper.GetBool("WATCHTOWER_RUN_ONCE"), + "Run once now and exit") + + flags.BoolP( + "include-stopped", + "S", + viper.GetBool("WATCHTOWER_INCLUDE_STOPPED"), + "Will also include created and exited containers") + + flags.BoolP( + "revive-stopped", + "", + viper.GetBool("WATCHTOWER_REVIVE_STOPPED"), + "Will also start stopped containers that were updated, if include-stopped is active") + + flags.BoolP( + "enable-lifecycle-hooks", + "", + viper.GetBool("WATCHTOWER_LIFECYCLE_HOOKS"), + "Enable the execution of commands triggered by pre- and post-update lifecycle hooks") +} + +// RegisterNotificationFlags that are used by watchtower to send notifications +func RegisterNotificationFlags(rootCmd *cobra.Command) { + flags := rootCmd.PersistentFlags() + + flags.StringSliceP( + "notifications", + "n", + viper.GetStringSlice("WATCHTOWER_NOTIFICATIONS"), + " notification types to send (valid: email, slack, msteams, gotify)") + + flags.StringP( + "notifications-level", + "", + viper.GetString("WATCHTOWER_NOTIFICATIONS_LEVEL"), + "The log level used for sending notifications. Possible values: panic, fatal, error, warn, info or debug") + + flags.StringP( + "notification-email-from", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_FROM"), + "Address to send notification emails from") + + flags.StringP( + "notification-email-to", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_TO"), + "Address to send notification emails to") + + flags.IntP( + "notification-email-delay", + "", + viper.GetInt("WATCHTOWER_NOTIFICATION_EMAIL_DELAY"), + "Delay before sending notifications, expressed in seconds") + + flags.StringP( + "notification-email-server", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER"), + "SMTP server to send notification emails through") + + flags.IntP( + "notification-email-server-port", + "", + viper.GetInt("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT"), + "SMTP server port to send notification emails through") + + flags.BoolP( + "notification-email-server-tls-skip-verify", + "", + viper.GetBool("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY"), + ` +Controls whether watchtower verifies the SMTP server's certificate chain and host name. +Should only be used for testing. +`) + + flags.StringP( + "notification-email-server-user", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER"), + "SMTP server user for sending notifications") + + flags.StringP( + "notification-email-server-password", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD"), + "SMTP server password for sending notifications") + + flags.StringP( + "notification-email-subjecttag", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG"), + "Subject prefix tag for notifications via mail") + + flags.StringP( + "notification-slack-hook-url", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL"), + "The Slack Hook URL to send notifications to") + + flags.StringP( + "notification-slack-identifier", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER"), + "A string which will be used to identify the messages coming from this watchtower instance") + + flags.StringP( + "notification-slack-channel", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_SLACK_CHANNEL"), + "A string which overrides the webhook's default channel. Example: #my-custom-channel") + + flags.StringP( + "notification-slack-icon-emoji", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI"), + "An emoji code string to use in place of the default icon") + + flags.StringP( + "notification-slack-icon-url", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_SLACK_ICON_URL"), + "An icon image URL string to use in place of the default icon") + + flags.StringP( + "notification-msteams-hook", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL"), + "The MSTeams WebHook URL to send notifications to") + + flags.BoolP( + "notification-msteams-data", + "", + viper.GetBool("WATCHTOWER_NOTIFICATION_MSTEAMS_USE_LOG_DATA"), + "The MSTeams notifier will try to extract log entry fields as MSTeams message facts") + + flags.StringP( + "notification-gotify-url", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_GOTIFY_URL"), + "The Gotify URL to send notifications to") + flags.StringP( + "notification-gotify-token", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN"), + "The Gotify Application required to query the Gotify API") +} + +// SetDefaults provides default values for environment variables +func SetDefaults() { + viper.AutomaticEnv() + viper.SetDefault("DOCKER_HOST", "unix:///var/run/docker.sock") + viper.SetDefault("DOCKER_API_VERSION", DockerAPIMinVersion) + viper.SetDefault("WATCHTOWER_POLL_INTERVAL", 300) + viper.SetDefault("WATCHTOWER_TIMEOUT", time.Second*10) + viper.SetDefault("WATCHTOWER_NOTIFICATIONS", []string{}) + viper.SetDefault("WATCHTOWER_NOTIFICATIONS_LEVEL", "info") + viper.SetDefault("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT", 25) + viper.SetDefault("WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG", "") + viper.SetDefault("WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER", "watchtower") +} + +// EnvConfig translates the command-line options into environment variables +// that will initialize the api client +func EnvConfig(cmd *cobra.Command) error { + var err error + var host string + var tls bool + var version string + + flags := cmd.PersistentFlags() + + if host, err = flags.GetString("host"); err != nil { + return err + } + if tls, err = flags.GetBool("tlsverify"); err != nil { + return err + } + if version, err = flags.GetString("api-version"); err != nil { + return err + } + if err = setEnvOptStr("DOCKER_HOST", host); err != nil { + return err + } + if err = setEnvOptBool("DOCKER_TLS_VERIFY", tls); err != nil { + return err + } + if err = setEnvOptStr("DOCKER_API_VERSION", version); err != nil { + return err + } + return nil +} + +// ReadFlags reads common flags used in the main program flow of watchtower +func ReadFlags(cmd *cobra.Command) (bool, bool, bool, time.Duration) { + flags := cmd.PersistentFlags() + + var err error + var cleanup bool + var noRestart bool + var monitorOnly bool + var timeout time.Duration + + if cleanup, err = flags.GetBool("cleanup"); err != nil { + log.Fatal(err) + } + if noRestart, err = flags.GetBool("no-restart"); err != nil { + log.Fatal(err) + } + if monitorOnly, err = flags.GetBool("monitor-only"); err != nil { + log.Fatal(err) + } + if timeout, err = flags.GetDuration("stop-timeout"); err != nil { + log.Fatal(err) + } + + return cleanup, noRestart, monitorOnly, timeout +} + +func setEnvOptStr(env string, opt string) error { + if opt == "" || opt == os.Getenv(env) { + return nil + } + err := os.Setenv(env, opt) + if err != nil { + return err + } + return nil +} + +func setEnvOptBool(env string, opt bool) error { + if opt { + return setEnvOptStr(env, "1") + } + return nil +} diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go new file mode 100644 index 0000000..ac57b30 --- /dev/null +++ b/internal/flags/flags_test.go @@ -0,0 +1,39 @@ +package flags + +import ( + "os" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnvConfig_Defaults(t *testing.T) { + cmd := new(cobra.Command) + SetDefaults() + RegisterDockerFlags(cmd) + + err := EnvConfig(cmd) + require.NoError(t, err) + + assert.Equal(t, "unix:///var/run/docker.sock", os.Getenv("DOCKER_HOST")) + assert.Equal(t, "", os.Getenv("DOCKER_TLS_VERIFY")) + assert.Equal(t, DockerAPIMinVersion, os.Getenv("DOCKER_API_VERSION")) +} + +func TestEnvConfig_Custom(t *testing.T) { + cmd := new(cobra.Command) + SetDefaults() + RegisterDockerFlags(cmd) + + err := cmd.ParseFlags([]string{"--host", "some-custom-docker-host", "--tlsverify", "--api-version", "1.99"}) + require.NoError(t, err) + + err = EnvConfig(cmd) + require.NoError(t, err) + + assert.Equal(t, "some-custom-docker-host", os.Getenv("DOCKER_HOST")) + assert.Equal(t, "1", os.Getenv("DOCKER_TLS_VERIFY")) + assert.Equal(t, "1.99", os.Getenv("DOCKER_API_VERSION")) +} diff --git a/internal/util/rand_name.go b/internal/util/rand_name.go new file mode 100644 index 0000000..76f6a3f --- /dev/null +++ b/internal/util/rand_name.go @@ -0,0 +1,15 @@ +package util + +import "math/rand" + +var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +// RandName Generates a random, 32-character, Docker-compatible container name. +func RandName() string { + b := make([]rune, 32) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + + return string(b) +} diff --git a/container/util.go b/internal/util/util.go similarity index 54% rename from container/util.go rename to internal/util/util.go index 3db01d1..08c88bc 100644 --- a/container/util.go +++ b/internal/util/util.go @@ -1,6 +1,7 @@ -package container +package util -func sliceEqual(s1, s2 []string) bool { +// SliceEqual compares two slices and checks whether they have equal content +func SliceEqual(s1, s2 []string) bool { if len(s1) != len(s2) { return false } @@ -14,7 +15,8 @@ func sliceEqual(s1, s2 []string) bool { return true } -func sliceSubtract(a1, a2 []string) []string { +// SliceSubtract subtracts the content of slice a2 from slice a1 +func SliceSubtract(a1, a2 []string) []string { a := []string{} for _, e1 := range a1 { @@ -35,7 +37,8 @@ func sliceSubtract(a1, a2 []string) []string { return a } -func stringMapSubtract(m1, m2 map[string]string) map[string]string { +// StringMapSubtract subtracts the content of structmap m2 from structmap m1 +func StringMapSubtract(m1, m2 map[string]string) map[string]string { m := map[string]string{} for k1, v1 := range m1 { @@ -51,7 +54,8 @@ func stringMapSubtract(m1, m2 map[string]string) map[string]string { return m } -func structMapSubtract(m1, m2 map[string]struct{}) map[string]struct{} { +// StructMapSubtract subtracts the content of structmap m2 from structmap m1 +func StructMapSubtract(m1, m2 map[string]struct{}) map[string]struct{} { m := map[string]struct{}{} for k1, v1 := range m1 { diff --git a/container/util_test.go b/internal/util/util_test.go similarity index 86% rename from container/util_test.go rename to internal/util/util_test.go index 8c4eef9..a6dd657 100644 --- a/container/util_test.go +++ b/internal/util/util_test.go @@ -1,17 +1,15 @@ -package container +package util import ( - "testing" "github.com/stretchr/testify/assert" + "testing" ) - - func TestSliceEqual_True(t *testing.T) { s1 := []string{"a", "b", "c"} s2 := []string{"a", "b", "c"} - result := sliceEqual(s1, s2) + result := SliceEqual(s1, s2) assert.True(t, result) } @@ -20,7 +18,7 @@ func TestSliceEqual_DifferentLengths(t *testing.T) { s1 := []string{"a", "b", "c"} s2 := []string{"a", "b", "c", "d"} - result := sliceEqual(s1, s2) + result := SliceEqual(s1, s2) assert.False(t, result) } @@ -29,7 +27,7 @@ func TestSliceEqual_DifferentContents(t *testing.T) { s1 := []string{"a", "b", "c"} s2 := []string{"a", "b", "d"} - result := sliceEqual(s1, s2) + result := SliceEqual(s1, s2) assert.False(t, result) } @@ -38,7 +36,7 @@ func TestSliceSubtract(t *testing.T) { a1 := []string{"a", "b", "c"} a2 := []string{"a", "c"} - result := sliceSubtract(a1, a2) + result := SliceSubtract(a1, a2) assert.Equal(t, []string{"b"}, result) assert.Equal(t, []string{"a", "b", "c"}, a1) assert.Equal(t, []string{"a", "c"}, a2) @@ -48,7 +46,7 @@ func TestStringMapSubtract(t *testing.T) { m1 := map[string]string{"a": "a", "b": "b", "c": "sea"} m2 := map[string]string{"a": "a", "c": "c"} - result := stringMapSubtract(m1, m2) + result := StringMapSubtract(m1, m2) assert.Equal(t, map[string]string{"b": "b", "c": "sea"}, result) assert.Equal(t, map[string]string{"a": "a", "b": "b", "c": "sea"}, m1) assert.Equal(t, map[string]string{"a": "a", "c": "c"}, m2) @@ -59,7 +57,7 @@ func TestStructMapSubtract(t *testing.T) { m1 := map[string]struct{}{"a": x, "b": x, "c": x} m2 := map[string]struct{}{"a": x, "c": x} - result := structMapSubtract(m1, m2) + result := StructMapSubtract(m1, m2) assert.Equal(t, map[string]struct{}{"b": x}, result) assert.Equal(t, map[string]struct{}{"a": x, "b": x, "c": x}, m1) assert.Equal(t, map[string]struct{}{"a": x, "c": x}, m2) diff --git a/main.go b/main.go index faf35b6..9f8a012 100644 --- a/main.go +++ b/main.go @@ -1,39 +1,8 @@ -package main // import "github.com/containrrr/watchtower" +package main import ( - "os" - "os/signal" - "syscall" - "time" - - "strconv" - - "github.com/containrrr/watchtower/actions" - cliApp "github.com/containrrr/watchtower/app" - "github.com/containrrr/watchtower/container" - "github.com/containrrr/watchtower/notifications" - "github.com/robfig/cron" + "github.com/containrrr/watchtower/cmd" log "github.com/sirupsen/logrus" - "github.com/urfave/cli" -) - -// DockerAPIMinVersion is the version of the docker API, which is minimally required by -// watchtower. Currently we require at least API 1.24 and therefore Docker 1.12 or later. -const DockerAPIMinVersion string = "1.24" - -var version = "master" -var commit = "unknown" -var date = "unknown" - -var ( - client container.Client - scheduleSpec string - cleanup bool - noRestart bool - monitorOnly bool - enableLabel bool - notifier *notifications.Notifier - timeout time.Duration ) func init() { @@ -41,173 +10,5 @@ func init() { } func main() { - app := cli.NewApp() - InitApp(app) - cliApp.SetupCliFlags(app) - - if err := app.Run(os.Args); err != nil { - log.Fatal(err) - } -} - -// InitApp initializes urfave app metadata and sets up entrypoints -func InitApp(app *cli.App) { - app.Name = "watchtower" - app.Version = version + " - " + commit + " - " + date - app.Usage = "Automatically update running Docker containers" - app.Before = before - app.Action = start -} - -func before(c *cli.Context) error { - if c.GlobalBool("debug") { - log.SetLevel(log.DebugLevel) - } - - pollingSet := c.IsSet("interval") - cronSet := c.IsSet("schedule") - cronLen := len(c.String("schedule")) - - if pollingSet && cronSet && cronLen > 0 { - log.Fatal("Only schedule or interval can be defined, not both.") - } else if cronSet && cronLen > 0 { - scheduleSpec = c.String("schedule") - } else { - scheduleSpec = "@every " + strconv.Itoa(c.Int("interval")) + "s" - } - - readFlags(c) - - if timeout < 0 { - log.Fatal("Please specify a positive value for timeout value.") - } - enableLabel = c.GlobalBool("label-enable") - - // configure environment vars for client - err := envConfig(c) - if err != nil { - return err - } - - client = container.NewClient( - !c.GlobalBool("no-pull"), - c.GlobalBool("include-stopped"), - ) - notifier = notifications.NewNotifier(c) - - return nil -} - -func start(c *cli.Context) error { - names := c.Args() - filter := container.BuildFilter(names, enableLabel) - - if c.GlobalBool("run-once") { - log.Info("Running a one time update.") - runUpdatesWithNotifications(filter) - os.Exit(1) - return nil - } - - if err := actions.CheckForMultipleWatchtowerInstances(client, cleanup); err != nil { - log.Fatal(err) - } - - runUpgradesOnSchedule(filter) - os.Exit(1) - return nil -} - -func runUpgradesOnSchedule(filter container.Filter) error { - tryLockSem := make(chan bool, 1) - tryLockSem <- true - - cron := cron.New() - err := cron.AddFunc( - scheduleSpec, - func() { - select { - case v := <-tryLockSem: - defer func() { tryLockSem <- v }() - runUpdatesWithNotifications(filter) - default: - log.Debug("Skipped another update already running.") - } - - nextRuns := cron.Entries() - if len(nextRuns) > 0 { - log.Debug("Scheduled next run: " + nextRuns[0].Next.String()) - } - }) - - if err != nil { - return err - } - - log.Debug("Starting Watchtower and scheduling first run: " + cron.Entries()[0].Schedule.Next(time.Now()).String()) - cron.Start() - - // Graceful shut-down on SIGINT/SIGTERM - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, os.Interrupt) - signal.Notify(interrupt, syscall.SIGTERM) - - <-interrupt - cron.Stop() - log.Info("Waiting for running update to be finished...") - <-tryLockSem - return nil -} - -func runUpdatesWithNotifications(filter container.Filter) { - notifier.StartNotification() - updateParams := actions.UpdateParams{ - Filter: filter, - Cleanup: cleanup, - NoRestart: noRestart, - Timeout: timeout, - MonitorOnly: monitorOnly, - } - err := actions.Update(client, updateParams) - if err != nil { - log.Println(err) - } - notifier.SendNotification() -} - -func setEnvOptStr(env string, opt string) error { - if opt == "" || opt == os.Getenv(env) { - return nil - } - err := os.Setenv(env, opt) - if err != nil { - return err - } - return nil -} - -func setEnvOptBool(env string, opt bool) error { - if opt == true { - return setEnvOptStr(env, "1") - } - return nil -} - -// envConfig translates the command-line options into environment variables -// that will initialize the api client -func envConfig(c *cli.Context) error { - var err error - - err = setEnvOptStr("DOCKER_HOST", c.GlobalString("host")) - err = setEnvOptBool("DOCKER_TLS_VERIFY", c.GlobalBool("tlsverify")) - err = setEnvOptStr("DOCKER_API_VERSION", DockerAPIMinVersion) - - return err -} - -func readFlags(c *cli.Context) { - cleanup = c.GlobalBool("cleanup") - noRestart = c.GlobalBool("no-restart") - monitorOnly = c.GlobalBool("monitor-only") - timeout = c.GlobalDuration("stop-timeout") + cmd.Execute() } diff --git a/mkdocs.yml b/mkdocs.yml index d64041b..e5e7c34 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,9 +14,11 @@ nav: - 'Arguments': 'arguments.md' - 'Notifications': 'notifications.md' - 'Container selection': 'container-selection.md' + - 'Credential helpers': 'credential-helpers.md' - 'Linked containers': 'linked-containers.md' - 'Remote hosts': 'remote-hosts.md' - 'Secure connections': 'secure-connections.md' - 'Stop signals': 'stop-signals.md' + - 'Lifecycle hooks': 'lifecycle-hooks.md' plugins: - - search \ No newline at end of file + - search diff --git a/notifications/slack.go b/notifications/slack.go deleted file mode 100644 index 08f8d3b..0000000 --- a/notifications/slack.go +++ /dev/null @@ -1,36 +0,0 @@ -package notifications - -import ( - "github.com/johntdyer/slackrus" - log "github.com/sirupsen/logrus" - "github.com/urfave/cli" -) - -const ( - slackType = "slack" -) - -type slackTypeNotifier struct { - slackrus.SlackrusHook -} - -func newSlackNotifier(c *cli.Context, acceptedLogLevels []log.Level) typeNotifier { - n := &slackTypeNotifier{ - SlackrusHook: slackrus.SlackrusHook{ - HookURL: c.GlobalString("notification-slack-hook-url"), - Username: c.GlobalString("notification-slack-identifier"), - Channel: c.GlobalString("notification-slack-channel"), - IconEmoji: c.GlobalString("notification-slack-icon-emoji"), - IconURL: c.GlobalString("notification-slack-icon-url"), - AcceptedLevels: acceptedLogLevels, - }, - } - - log.AddHook(n) - - return n -} - -func (s *slackTypeNotifier) StartNotification() {} - -func (s *slackTypeNotifier) SendNotification() {} diff --git a/container/client.go b/pkg/container/client.go similarity index 64% rename from container/client.go rename to pkg/container/client.go index 70a6fb1..5877eb4 100644 --- a/container/client.go +++ b/pkg/container/client.go @@ -1,31 +1,34 @@ package container import ( + "bytes" "fmt" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/filters" "io/ioutil" + "strings" "time" + t "github.com/containrrr/watchtower/pkg/types" "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/network" dockerclient "github.com/docker/docker/client" log "github.com/sirupsen/logrus" "golang.org/x/net/context" ) -const ( - defaultStopSignal = "SIGTERM" -) +const defaultStopSignal = "SIGTERM" // A Client is the interface through which watchtower interacts with the // Docker API. type Client interface { - ListContainers(Filter) ([]Container, error) + ListContainers(t.Filter) ([]Container, error) + GetContainer(containerID string) (Container, error) StopContainer(Container, time.Duration) error - StartContainer(Container) error + StartContainer(Container) (string, error) RenameContainer(Container, string) error IsContainerStale(Container) (bool, error) + ExecuteCommand(containerID string, command string) error RemoveImage(Container) error } @@ -35,7 +38,7 @@ type Client interface { // * DOCKER_HOST the docker-engine host to send api requests to // * DOCKER_TLS_VERIFY whether to verify tls certificates // * DOCKER_API_VERSION the minimum docker api version to work with -func NewClient(pullImages bool, includeStopped bool) Client { +func NewClient(pullImages bool, includeStopped bool, reviveStopped bool, removeVolumes bool) Client { cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv) if err != nil { @@ -45,17 +48,21 @@ func NewClient(pullImages bool, includeStopped bool) Client { return dockerClient{ api: cli, pullImages: pullImages, + removeVolumes: removeVolumes, includeStopped: includeStopped, + reviveStopped: reviveStopped, } } type dockerClient struct { api dockerclient.CommonAPIClient pullImages bool + removeVolumes bool includeStopped bool + reviveStopped bool } -func (client dockerClient) ListContainers(fn Filter) ([]Container, error) { +func (client dockerClient) ListContainers(fn t.Filter) ([]Container, error) { cs := []Container{} bg := context.Background() @@ -71,24 +78,18 @@ func (client dockerClient) ListContainers(fn Filter) ([]Container, error) { types.ContainerListOptions{ Filters: filter, }) - + if err != nil { return nil, err } for _, runningContainer := range containers { - containerInfo, err := client.api.ContainerInspect(bg, runningContainer.ID) - if err != nil { - return nil, err - } - imageInfo, _, err := client.api.ImageInspectWithRaw(bg, containerInfo.Image) + c, err := client.GetContainer(runningContainer.ID) if err != nil { return nil, err } - c := Container{containerInfo: &containerInfo, imageInfo: &imageInfo} - if fn(c) { cs = append(cs, c) } @@ -109,6 +110,23 @@ func (client dockerClient) createListFilter() filters.Args { return filterArgs } +func (client dockerClient) GetContainer(containerID string) (Container, error) { + bg := context.Background() + + containerInfo, err := client.api.ContainerInspect(bg, containerID) + if err != nil { + return Container{}, err + } + + imageInfo, _, err := client.api.ImageInspectWithRaw(bg, containerInfo.Image) + if err != nil { + return Container{}, err + } + + container := Container{containerInfo: &containerInfo, imageInfo: &imageInfo} + return container, nil +} + func (client dockerClient) StopContainer(c Container, timeout time.Duration) error { bg := context.Background() signal := c.StopSignal() @@ -123,28 +141,28 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err } } - // Wait for container to exit, but proceed anyway after the timeout elapses - client.waitForStop(c, timeout) + // TODO: This should probably be checked. + _ = client.waitForStopOrTimeout(c, timeout) if c.containerInfo.HostConfig.AutoRemove { log.Debugf("AutoRemove container %s, skipping ContainerRemove call.", c.ID()) } else { log.Debugf("Removing container %s", c.ID()) - if err := client.api.ContainerRemove(bg, c.ID(), types.ContainerRemoveOptions{Force: true, RemoveVolumes: false}); err != nil { + if err := client.api.ContainerRemove(bg, c.ID(), types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.removeVolumes}); err != nil { return err } } // Wait for container to be removed. In this case an error is a good thing - if err := client.waitForStop(c, timeout); err == nil { + if err := client.waitForStopOrTimeout(c, timeout); err == nil { return fmt.Errorf("Container %s (%s) could not be removed", c.Name(), c.ID()) } return nil } -func (client dockerClient) StartContainer(c Container) error { +func (client dockerClient) StartContainer(c Container) (string, error) { bg := context.Background() config := c.runtimeConfig() hostConfig := c.hostConfig() @@ -164,40 +182,40 @@ func (client dockerClient) StartContainer(c Container) error { name := c.Name() log.Infof("Creating %s", name) - creation, err := client.api.ContainerCreate(bg, config, hostConfig, simpleNetworkConfig, name) + createdContainer, err := client.api.ContainerCreate(bg, config, hostConfig, simpleNetworkConfig, name) if err != nil { - return err + return "", err } if !(hostConfig.NetworkMode.IsHost()) { for k := range simpleNetworkConfig.EndpointsConfig { - err = client.api.NetworkDisconnect(bg, k, creation.ID, true) + err = client.api.NetworkDisconnect(bg, k, createdContainer.ID, true) if err != nil { - return err + return "", err } } for k, v := range networkConfig.EndpointsConfig { - err = client.api.NetworkConnect(bg, k, creation.ID, v) + err = client.api.NetworkConnect(bg, k, createdContainer.ID, v) if err != nil { - return err + return "", err } } } - return client.startContainerIfPreviouslyRunning(bg, c, creation) + if !c.IsRunning() && !client.reviveStopped { + return createdContainer.ID, nil + } + + return createdContainer.ID, client.doStartContainer(bg, c, createdContainer) } -func (client dockerClient) startContainerIfPreviouslyRunning(bg context.Context, c Container, creation container.ContainerCreateCreatedBody) error { +func (client dockerClient) doStartContainer(bg context.Context, c Container, creation container.ContainerCreateCreatedBody) error { name := c.Name() - if !c.IsRunning() { - return nil - } - log.Debugf("Starting container %s (%s)", name, creation.ID) err := client.api.ContainerStart(bg, creation.ID, types.ContainerStartOptions{}) if err != nil { @@ -242,7 +260,9 @@ func (client dockerClient) IsContainerStale(c Container) (bool, error) { defer response.Close() // the pull request will be aborted prematurely unless the response is read - _, err = ioutil.ReadAll(response) + if _, err = ioutil.ReadAll(response); err != nil { + log.Error(err) + } } newImageInfo, _, err := client.api.ImageInspectWithRaw(bg, imageName) @@ -266,7 +286,68 @@ func (client dockerClient) RemoveImage(c Container) error { return err } -func (client dockerClient) waitForStop(c Container, waitTime time.Duration) error { +func (client dockerClient) ExecuteCommand(containerID string, command string) error { + bg := context.Background() + + // Create the exec + execConfig := types.ExecConfig{ + Tty: true, + Detach: false, + Cmd: []string{"sh", "-c", command}, + } + + exec, err := client.api.ContainerExecCreate(bg, containerID, execConfig) + if err != nil { + return err + } + + response, attachErr := client.api.ContainerExecAttach(bg, exec.ID, types.ExecStartCheck{ + Tty: true, + Detach: false, + }) + if attachErr != nil { + log.Errorf("Failed to extract command exec logs: %v", attachErr) + } + + // Run the exec + execStartCheck := types.ExecStartCheck{Detach: false, Tty: true} + err = client.api.ContainerExecStart(bg, exec.ID, execStartCheck) + if err != nil { + return err + } + + var execOutput string + if attachErr == nil { + defer response.Close() + var writer bytes.Buffer + written, err := writer.ReadFrom(response.Reader) + if err != nil { + log.Error(err) + } else if written > 0 { + execOutput = strings.TrimSpace(writer.String()) + } + } + + // Inspect the exec to get the exit code and print a message if the + // exit code is not success. + execInspect, err := client.api.ContainerExecInspect(bg, exec.ID) + if err != nil { + return err + } + + if execInspect.ExitCode > 0 { + log.Errorf("Command exited with code %v.", execInspect.ExitCode) + log.Error(execOutput) + } else { + if len(execOutput) > 0 { + log.Infof("Command output:\n%v", execOutput) + } + } + + return nil +} + +func (client dockerClient) waitForStopOrTimeout(c Container, waitTime time.Duration) error { bg := context.Background() timeout := time.After(waitTime) diff --git a/container/container.go b/pkg/container/container.go similarity index 82% rename from container/container.go rename to pkg/container/container.go index 66ae505..09e4225 100644 --- a/container/container.go +++ b/pkg/container/container.go @@ -2,6 +2,7 @@ package container import ( "fmt" + "github.com/containrrr/watchtower/internal/util" "strconv" "strings" @@ -9,13 +10,6 @@ import ( dockercontainer "github.com/docker/docker/api/types/container" ) -const ( - watchtowerLabel = "com.centurylinklabs.watchtower" - signalLabel = "com.centurylinklabs.watchtower.stop-signal" - enableLabel = "com.centurylinklabs.watchtower.enable" - zodiacLabel = "com.centurylinklabs.zodiac.original-image" -) - // NewContainer returns a new Container instance instantiated with the // specified ContainerInfo and ImageInfo structs. func NewContainer(containerInfo *types.ContainerJSON, imageInfo *types.ImageInspect) *Container { @@ -27,7 +21,8 @@ func NewContainer(containerInfo *types.ContainerJSON, imageInfo *types.ImageInsp // Container represents a running Docker container. type Container struct { - Stale bool + Linked bool + Stale bool containerInfo *types.ContainerJSON imageInfo *types.ImageInspect @@ -61,7 +56,7 @@ func (c Container) ImageID() string { // "latest" tag is assumed. func (c Container) ImageName() string { // Compatibility w/ Zodiac deployments - imageName, ok := c.containerInfo.Config.Labels[zodiacLabel] + imageName, ok := c.getLabelValue(zodiacLabel) if !ok { imageName = c.containerInfo.Config.Image } @@ -76,7 +71,7 @@ func (c Container) ImageName() string { // Enabled returns the value of the container enabled label and if the label // was set. func (c Container) Enabled() (bool, bool) { - rawBool, ok := c.containerInfo.Config.Labels[enableLabel] + rawBool, ok := c.getLabelValue(enableLabel) if !ok { return false, false } @@ -104,6 +99,12 @@ func (c Container) Links() []string { return links } +// ToRestart return whether the container should be restarted, either because +// is stale or linked to another stale container. +func (c Container) ToRestart() bool { + return c.Stale || c.Linked +} + // IsWatchtower returns a boolean flag indicating whether or not the current // container is the watchtower container itself. The watchtower container is // identified by the presence of the "com.centurylinklabs.watchtower" label in @@ -116,11 +117,7 @@ func (c Container) IsWatchtower() bool { // container's metadata. If the container has not specified a custom stop // signal, the empty string "" is returned. func (c Container) StopSignal() string { - if val, ok := c.containerInfo.Config.Labels[signalLabel]; ok { - return val - } - - return "" + return c.getLabelValueOrEmpty(signalLabel) } // Ideally, we'd just be able to take the ContainerConfig from the old container @@ -146,19 +143,19 @@ func (c Container) runtimeConfig() *dockercontainer.Config { config.User = "" } - if sliceEqual(config.Cmd, imageConfig.Cmd) { + if util.SliceEqual(config.Cmd, imageConfig.Cmd) { config.Cmd = nil } - if sliceEqual(config.Entrypoint, imageConfig.Entrypoint) { + if util.SliceEqual(config.Entrypoint, imageConfig.Entrypoint) { config.Entrypoint = nil } - config.Env = sliceSubtract(config.Env, imageConfig.Env) + config.Env = util.SliceSubtract(config.Env, imageConfig.Env) - config.Labels = stringMapSubtract(config.Labels, imageConfig.Labels) + config.Labels = util.StringMapSubtract(config.Labels, imageConfig.Labels) - config.Volumes = structMapSubtract(config.Volumes, imageConfig.Volumes) + config.Volumes = util.StructMapSubtract(config.Volumes, imageConfig.Volumes) // subtract ports exposed in image from container for k := range config.ExposedPorts { @@ -188,10 +185,3 @@ func (c Container) hostConfig() *dockercontainer.HostConfig { return hostConfig } - -// ContainsWatchtowerLabel takes a map of labels and values and tells -// the consumer whether it contains a valid watchtower instance label -func ContainsWatchtowerLabel(labels map[string]string) bool { - val, ok := labels[watchtowerLabel] - return ok && val == "true" -} diff --git a/container/container_test.go b/pkg/container/container_test.go similarity index 98% rename from container/container_test.go rename to pkg/container/container_test.go index f9dd540..9e1b213 100644 --- a/container/container_test.go +++ b/pkg/container/container_test.go @@ -1,7 +1,7 @@ package container import ( - "github.com/containrrr/watchtower/container/mocks" + "github.com/containrrr/watchtower/pkg/container/mocks" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" cli "github.com/docker/docker/client" @@ -23,8 +23,7 @@ var _ = Describe("the container", func() { server := mocks.NewMockAPIServer() docker, _ = cli.NewClientWithOpts( cli.WithHost(server.URL), - cli.WithHTTPClient(server.Client(), - )) + cli.WithHTTPClient(server.Client())) client = dockerClient{ api: docker, pullImages: false, diff --git a/container/filters.go b/pkg/container/filters.go similarity index 58% rename from container/filters.go rename to pkg/container/filters.go index 1b3fbbd..b4d4911 100644 --- a/container/filters.go +++ b/pkg/container/filters.go @@ -1,30 +1,20 @@ package container -// A Filter is a prototype for a function that can be used to filter the -// results from a call to the ListContainers() method on the Client. -type Filter func(FilterableContainer) bool - -// A FilterableContainer is the interface which is used to filter -// containers. -type FilterableContainer interface { - Name() string - IsWatchtower() bool - Enabled() (bool, bool) -} +import t "github.com/containrrr/watchtower/pkg/types" // WatchtowerContainersFilter filters only watchtower containers -func WatchtowerContainersFilter(c FilterableContainer) bool { return c.IsWatchtower() } +func WatchtowerContainersFilter(c t.FilterableContainer) bool { return c.IsWatchtower() } // Filter no containers and returns all -func noFilter(FilterableContainer) bool { return true } +func noFilter(t.FilterableContainer) bool { return true } // Filters containers which don't have a specified name -func filterByNames(names []string, baseFilter Filter) Filter { +func filterByNames(names []string, baseFilter t.Filter) t.Filter { if len(names) == 0 { return baseFilter } - return func(c FilterableContainer) bool { + return func(c t.FilterableContainer) bool { for _, name := range names { if (name == c.Name()) || (name == c.Name()[1:]) { return baseFilter(c) @@ -35,8 +25,8 @@ func filterByNames(names []string, baseFilter Filter) Filter { } // Filters out containers that don't have the 'enableLabel' -func filterByEnableLabel(baseFilter Filter) Filter { - return func(c FilterableContainer) bool { +func filterByEnableLabel(baseFilter t.Filter) t.Filter { + return func(c t.FilterableContainer) bool { // If label filtering is enabled, containers should only be considered // if the label is specifically set. _, ok := c.Enabled() @@ -49,8 +39,8 @@ func filterByEnableLabel(baseFilter Filter) Filter { } // Filters out containers that have a 'enableLabel' and is set to disable. -func filterByDisabledLabel(baseFilter Filter) Filter { - return func(c FilterableContainer) bool { +func filterByDisabledLabel(baseFilter t.Filter) t.Filter { + return func(c t.FilterableContainer) bool { enabledLabel, ok := c.Enabled() if ok && !enabledLabel { // If the label has been set and it demands a disable @@ -62,7 +52,7 @@ func filterByDisabledLabel(baseFilter Filter) Filter { } // BuildFilter creates the needed filter of containers -func BuildFilter(names []string, enableLabel bool) Filter { +func BuildFilter(names []string, enableLabel bool) t.Filter { filter := noFilter filter = filterByNames(names, filter) if enableLabel { diff --git a/container/filters_test.go b/pkg/container/filters_test.go similarity index 98% rename from container/filters_test.go rename to pkg/container/filters_test.go index 0db0a62..4118335 100644 --- a/container/filters_test.go +++ b/pkg/container/filters_test.go @@ -3,8 +3,8 @@ package container import ( "testing" + "github.com/containrrr/watchtower/pkg/container/mocks" "github.com/stretchr/testify/assert" - "github.com/containrrr/watchtower/container/mocks" ) func TestWatchtowerContainersFilter(t *testing.T) { diff --git a/pkg/container/metadata.go b/pkg/container/metadata.go new file mode 100644 index 0000000..0e04350 --- /dev/null +++ b/pkg/container/metadata.go @@ -0,0 +1,51 @@ +package container + +const ( + watchtowerLabel = "com.centurylinklabs.watchtower" + signalLabel = "com.centurylinklabs.watchtower.stop-signal" + enableLabel = "com.centurylinklabs.watchtower.enable" + zodiacLabel = "com.centurylinklabs.zodiac.original-image" + preCheckLabel = "com.centurylinklabs.watchtower.lifecycle.pre-check" + postCheckLabel = "com.centurylinklabs.watchtower.lifecycle.post-check" + preUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.pre-update" + postUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.post-update" +) + +// GetLifecyclePreCheckCommand returns the pre-check command set in the container metadata or an empty string +func (c Container) GetLifecyclePreCheckCommand() string { + return c.getLabelValueOrEmpty(preCheckLabel) +} + +// GetLifecyclePostCheckCommand returns the post-check command set in the container metadata or an empty string +func (c Container) GetLifecyclePostCheckCommand() string { + return c.getLabelValueOrEmpty(postCheckLabel) +} + +// GetLifecyclePreUpdateCommand returns the pre-update command set in the container metadata or an empty string +func (c Container) GetLifecyclePreUpdateCommand() string { + return c.getLabelValueOrEmpty(preUpdateLabel) +} + +// GetLifecyclePostUpdateCommand returns the post-update command set in the container metadata or an empty string +func (c Container) GetLifecyclePostUpdateCommand() string { + return c.getLabelValueOrEmpty(postUpdateLabel) +} + +// ContainsWatchtowerLabel takes a map of labels and values and tells +// the consumer whether it contains a valid watchtower instance label +func ContainsWatchtowerLabel(labels map[string]string) bool { + val, ok := labels[watchtowerLabel] + return ok && val == "true" +} + +func (c Container) getLabelValueOrEmpty(label string) string { + if val, ok := c.containerInfo.Config.Labels[label]; ok { + return val + } + return "" +} + +func (c Container) getLabelValue(label string) (string, bool) { + val, ok := c.containerInfo.Config.Labels[label] + return val, ok +} diff --git a/container/mocks/ApiServer.go b/pkg/container/mocks/ApiServer.go similarity index 100% rename from container/mocks/ApiServer.go rename to pkg/container/mocks/ApiServer.go diff --git a/container/mocks/FilterableContainer.go b/pkg/container/mocks/FilterableContainer.go similarity index 100% rename from container/mocks/FilterableContainer.go rename to pkg/container/mocks/FilterableContainer.go diff --git a/container/mocks/data/container_running.json b/pkg/container/mocks/data/container_running.json similarity index 100% rename from container/mocks/data/container_running.json rename to pkg/container/mocks/data/container_running.json diff --git a/container/mocks/data/container_stopped.json b/pkg/container/mocks/data/container_stopped.json similarity index 100% rename from container/mocks/data/container_stopped.json rename to pkg/container/mocks/data/container_stopped.json diff --git a/container/mocks/data/containers.json b/pkg/container/mocks/data/containers.json similarity index 100% rename from container/mocks/data/containers.json rename to pkg/container/mocks/data/containers.json diff --git a/container/mocks/data/image01.json b/pkg/container/mocks/data/image01.json similarity index 100% rename from container/mocks/data/image01.json rename to pkg/container/mocks/data/image01.json diff --git a/container/mocks/data/image02.json b/pkg/container/mocks/data/image02.json similarity index 100% rename from container/mocks/data/image02.json rename to pkg/container/mocks/data/image02.json diff --git a/container/sort.go b/pkg/container/sort.go similarity index 100% rename from container/sort.go rename to pkg/container/sort.go diff --git a/container/trust.go b/pkg/container/trust.go similarity index 94% rename from container/trust.go rename to pkg/container/trust.go index 92ab696..63b76a6 100644 --- a/container/trust.go +++ b/pkg/container/trust.go @@ -48,6 +48,10 @@ func EncodedEnvAuth(ref string) (string, error) { // The docker config must be mounted on the container func EncodedConfigAuth(ref string) (string, error) { server, err := ParseServerAddress(ref) + if err != nil { + log.Errorf("Unable to parse the image ref %s", err) + return "", err + } configDir := os.Getenv("DOCKER_CONFIG") if configDir == "" { configDir = "/" @@ -58,7 +62,8 @@ func EncodedConfigAuth(ref string) (string, error) { return "", err } credStore := CredentialsStore(*configFile) - auth, err := credStore.Get(server) // returns (types.AuthConfig{}) if server not in credStore + auth, _ := credStore.Get(server) // returns (types.AuthConfig{}) if server not in credStore + if auth == (types.AuthConfig{}) { log.Debugf("No credentials for %s in %s", server, configFile.Filename) return "", nil diff --git a/container/trust_test.go b/pkg/container/trust_test.go similarity index 99% rename from container/trust_test.go rename to pkg/container/trust_test.go index 6aa807b..7d2ac96 100644 --- a/container/trust_test.go +++ b/pkg/container/trust_test.go @@ -1,15 +1,11 @@ package container import ( + "github.com/stretchr/testify/assert" "os" "testing" - "github.com/stretchr/testify/assert" ) - - - - func TestEncodedEnvAuth_ShouldReturnAnErrorIfRepoEnvsAreUnset(t *testing.T) { os.Unsetenv("REPO_USER") os.Unsetenv("REPO_PASS") diff --git a/notifications/email.go b/pkg/notifications/email.go similarity index 61% rename from notifications/email.go rename to pkg/notifications/email.go index 5f84ca5..ca54499 100644 --- a/notifications/email.go +++ b/pkg/notifications/email.go @@ -3,44 +3,59 @@ package notifications import ( "encoding/base64" "fmt" + "github.com/spf13/cobra" "net/smtp" "os" "time" - "strconv" - + t "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" - "github.com/urfave/cli" + "strconv" ) const ( emailType = "email" ) -// Implements typeNotifier, logrus.Hook +// Implements Notifier, logrus.Hook // The default logrus email integration would have several issues: // - It would send one email per log output // - It would only send errors // We work around that by holding on to log entries until the update cycle is done. type emailTypeNotifier struct { - From, To string - Server, User, Password string - Port int - tlsSkipVerify bool - entries []*log.Entry - logLevels []log.Level + From, To string + Server, User, Password, SubjectTag string + Port int + tlsSkipVerify bool + entries []*log.Entry + logLevels []log.Level + delay time.Duration } -func newEmailNotifier(c *cli.Context, acceptedLogLevels []log.Level) typeNotifier { +func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier { + flags := c.PersistentFlags() + + from, _ := flags.GetString("notification-email-from") + to, _ := flags.GetString("notification-email-to") + server, _ := flags.GetString("notification-email-server") + user, _ := flags.GetString("notification-email-server-user") + password, _ := flags.GetString("notification-email-server-password") + port, _ := flags.GetInt("notification-email-server-port") + tlsSkipVerify, _ := flags.GetBool("notification-email-server-tls-skip-verify") + delay, _ := flags.GetInt("notification-email-delay") + subjecttag, _ := flags.GetString("notification-email-subjecttag") + n := &emailTypeNotifier{ - From: c.GlobalString("notification-email-from"), - To: c.GlobalString("notification-email-to"), - Server: c.GlobalString("notification-email-server"), - User: c.GlobalString("notification-email-server-user"), - Password: c.GlobalString("notification-email-server-password"), - Port: c.GlobalInt("notification-email-server-port"), - tlsSkipVerify: c.GlobalBool("notification-email-server-tls-skip-verify"), + From: from, + To: to, + Server: server, + User: user, + Password: password, + Port: port, + tlsSkipVerify: tlsSkipVerify, logLevels: acceptedLogLevels, + delay: time.Duration(delay) * time.Second, + SubjectTag: subjecttag, } log.AddHook(n) @@ -49,7 +64,13 @@ func newEmailNotifier(c *cli.Context, acceptedLogLevels []log.Level) typeNotifie } func (e *emailTypeNotifier) buildMessage(entries []*log.Entry) []byte { - emailSubject := "Watchtower updates" + var emailSubject string + + if e.SubjectTag == "" { + emailSubject = "Watchtower updates" + } else { + emailSubject = e.SubjectTag + " Watchtower updates" + } if hostname, err := os.Hostname(); err == nil { emailSubject += " on " + hostname } @@ -60,7 +81,7 @@ func (e *emailTypeNotifier) buildMessage(entries []*log.Entry) []byte { } t := time.Now() - + header := make(map[string]string) header["From"] = e.From header["To"] = e.To @@ -107,9 +128,15 @@ func (e *emailTypeNotifier) StartNotification() { } func (e *emailTypeNotifier) SendNotification() { - if e.entries != nil && len(e.entries) != 0 { - e.sendEntries(e.entries) + if e.entries == nil || len(e.entries) <= 0 { + return } + + if e.delay > 0 { + time.Sleep(e.delay) + } + + e.sendEntries(e.entries) e.entries = nil } diff --git a/pkg/notifications/gotify.go b/pkg/notifications/gotify.go new file mode 100644 index 0000000..c9c6af8 --- /dev/null +++ b/pkg/notifications/gotify.go @@ -0,0 +1,101 @@ +package notifications + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + + t "github.com/containrrr/watchtower/pkg/types" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +const ( + gotifyType = "gotify" +) + +type gotifyTypeNotifier struct { + gotifyURL string + gotifyAppToken string + logLevels []log.Level +} + +func newGotifyNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier { + flags := c.PersistentFlags() + + gotifyURL, _ := flags.GetString("notification-gotify-url") + if len(gotifyURL) < 1 { + log.Fatal("Required argument --notification-gotify-url(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_URL(env) is empty.") + } else if !(strings.HasPrefix(gotifyURL, "http://") || strings.HasPrefix(gotifyURL, "https://")) { + log.Fatal("Gotify URL must start with \"http://\" or \"https://\"") + } else if strings.HasPrefix(gotifyURL, "http://") { + log.Warn("Using an HTTP url for Gotify is insecure") + } + + gotifyToken, _ := flags.GetString("notification-gotify-token") + if len(gotifyToken) < 1 { + log.Fatal("Required argument --notification-gotify-token(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN(env) is empty.") + } + + n := &gotifyTypeNotifier{ + gotifyURL: gotifyURL, + gotifyAppToken: gotifyToken, + logLevels: acceptedLogLevels, + } + + log.AddHook(n) + + return n +} + +func (n *gotifyTypeNotifier) StartNotification() {} + +func (n *gotifyTypeNotifier) SendNotification() {} + +func (n *gotifyTypeNotifier) Levels() []log.Level { + return n.logLevels +} + +func (n *gotifyTypeNotifier) getURL() string { + url := n.gotifyURL + if !strings.HasSuffix(url, "/") { + url += "/" + } + return url + "message?token=" + n.gotifyAppToken +} + +func (n *gotifyTypeNotifier) Fire(entry *log.Entry) error { + + go func() { + jsonBody, err := json.Marshal(gotifyMessage{ + Message: "(" + entry.Level.String() + "): " + entry.Message, + Title: "Watchtower", + Priority: 0, + }) + if err != nil { + fmt.Println("Failed to create JSON body for Gotify notification: ", err) + return + } + + jsonBodyBuffer := bytes.NewBuffer([]byte(jsonBody)) + resp, err := http.Post(n.getURL(), "application/json", jsonBodyBuffer) + if err != nil { + fmt.Println("Failed to send Gotify notification: ", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + fmt.Printf("Gotify notification returned %d HTTP status code", resp.StatusCode) + } + + }() + return nil +} + +type gotifyMessage struct { + Message string `json:"message"` + Title string `json:"title"` + Priority int `json:"priority"` +} diff --git a/notifications/msteams.go b/pkg/notifications/msteams.go similarity index 90% rename from notifications/msteams.go rename to pkg/notifications/msteams.go index 8bb9d7a..b356814 100644 --- a/notifications/msteams.go +++ b/pkg/notifications/msteams.go @@ -4,10 +4,11 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/spf13/cobra" "net/http" + t "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" - "github.com/urfave/cli" "io/ioutil" ) @@ -21,17 +22,20 @@ type msTeamsTypeNotifier struct { data bool } -func newMsTeamsNotifier(c *cli.Context, acceptedLogLevels []log.Level) typeNotifier { +func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Notifier { - webHookURL := c.GlobalString("notification-msteams-hook") + flags := cmd.PersistentFlags() + + webHookURL, _ := flags.GetString("notification-msteams-hook") if len(webHookURL) <= 0 { log.Fatal("Required argument --notification-msteams-hook(cli) or WATCHTOWER_NOTIFICATION_MSTEAMS_HOOK_URL(env) is empty.") } + withData, _ := flags.GetBool("notification-msteams-data") n := &msTeamsTypeNotifier{ levels: acceptedLogLevels, webHookURL: webHookURL, - data: c.GlobalBool("notification-msteams-data"), + data: withData, } log.AddHook(n) diff --git a/notifications/notifier.go b/pkg/notifications/notifier.go similarity index 76% rename from notifications/notifier.go rename to pkg/notifications/notifier.go index 62e8ebc..2f25824 100644 --- a/notifications/notifier.go +++ b/pkg/notifications/notifier.go @@ -1,26 +1,25 @@ package notifications import ( + ty "github.com/containrrr/watchtower/pkg/types" "github.com/johntdyer/slackrus" log "github.com/sirupsen/logrus" - "github.com/urfave/cli" + "github.com/spf13/cobra" ) -type typeNotifier interface { - StartNotification() - SendNotification() -} - // Notifier can send log output as notification to admins, with optional batching. type Notifier struct { - types []typeNotifier + types []ty.Notifier } // NewNotifier creates and returns a new Notifier, using global configuration. -func NewNotifier(c *cli.Context) *Notifier { +func NewNotifier(c *cobra.Command) *Notifier { n := &Notifier{} - logLevel, err := log.ParseLevel(c.GlobalString("notifications-level")) + f := c.PersistentFlags() + + level, _ := f.GetString("notifications-level") + logLevel, err := log.ParseLevel(level) if err != nil { log.Fatalf("Notifications invalid log level: %s", err.Error()) } @@ -28,9 +27,10 @@ func NewNotifier(c *cli.Context) *Notifier { acceptedLogLevels := slackrus.LevelThreshold(logLevel) // Parse types and create notifiers. - types := c.GlobalStringSlice("notifications") + types, _ := f.GetStringSlice("notifications") + for _, t := range types { - var tn typeNotifier + var tn ty.Notifier switch t { case emailType: tn = newEmailNotifier(c, acceptedLogLevels) @@ -38,6 +38,8 @@ func NewNotifier(c *cli.Context) *Notifier { tn = newSlackNotifier(c, acceptedLogLevels) case msTeamsType: tn = newMsTeamsNotifier(c, acceptedLogLevels) + case gotifyType: + tn = newGotifyNotifier(c, acceptedLogLevels) default: log.Fatalf("Unknown notification type %q", t) } diff --git a/pkg/notifications/slack.go b/pkg/notifications/slack.go new file mode 100644 index 0000000..42b7915 --- /dev/null +++ b/pkg/notifications/slack.go @@ -0,0 +1,44 @@ +package notifications + +import ( + t "github.com/containrrr/watchtower/pkg/types" + "github.com/johntdyer/slackrus" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +const ( + slackType = "slack" +) + +type slackTypeNotifier struct { + slackrus.SlackrusHook +} + +func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier { + flags := c.PersistentFlags() + + hookURL, _ := flags.GetString("notification-slack-hook-url") + userName, _ := flags.GetString("notification-slack-identifier") + channel, _ := flags.GetString("notification-slack-channel") + emoji, _ := flags.GetString("notification-slack-icon-emoji") + iconURL, _ := flags.GetString("notification-slack-icon-url") + + n := &slackTypeNotifier{ + SlackrusHook: slackrus.SlackrusHook{ + HookURL: hookURL, + Username: userName, + Channel: channel, + IconEmoji: emoji, + IconURL: iconURL, + AcceptedLevels: acceptedLogLevels, + }, + } + + log.AddHook(n) + return n +} + +func (s *slackTypeNotifier) StartNotification() {} + +func (s *slackTypeNotifier) SendNotification() {} diff --git a/notifications/smtp.go b/pkg/notifications/smtp.go similarity index 100% rename from notifications/smtp.go rename to pkg/notifications/smtp.go diff --git a/notifications/util.go b/pkg/notifications/util.go similarity index 100% rename from notifications/util.go rename to pkg/notifications/util.go diff --git a/pkg/types/filter.go b/pkg/types/filter.go new file mode 100644 index 0000000..514e4bd --- /dev/null +++ b/pkg/types/filter.go @@ -0,0 +1,5 @@ +package types + +// A Filter is a prototype for a function that can be used to filter the +// results from a call to the ListContainers() method on the Client. +type Filter func(FilterableContainer) bool diff --git a/pkg/types/filterable_container.go b/pkg/types/filterable_container.go new file mode 100644 index 0000000..d89b910 --- /dev/null +++ b/pkg/types/filterable_container.go @@ -0,0 +1,9 @@ +package types + +// A FilterableContainer is the interface which is used to filter +// containers. +type FilterableContainer interface { + Name() string + IsWatchtower() bool + Enabled() (bool, bool) +} diff --git a/pkg/types/notifier.go b/pkg/types/notifier.go new file mode 100644 index 0000000..c8d07d0 --- /dev/null +++ b/pkg/types/notifier.go @@ -0,0 +1,7 @@ +package types + +// Notifier is the interface that all notification services have in common +type Notifier interface { + StartNotification() + SendNotification() +} diff --git a/scripts/lifecycle-tests.sh b/scripts/lifecycle-tests.sh new file mode 100755 index 0000000..dd41823 --- /dev/null +++ b/scripts/lifecycle-tests.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash + +set -e + +IMAGE=server +CONTAINER=server +LINKED_IMAGE=linked +LINKED_CONTAINER=linked +WATCHTOWER_INTERVAL=2 + +function remove_container { + docker kill $1 >> /dev/null || true && docker rm -v $1 >> /dev/null || true +} + +function cleanup { + # Do cleanup on exit or error + echo "Final cleanup" + sleep 2 + remove_container $CONTAINER + remove_container $LINKED_CONTAINER + pkill -9 -f watchtower >> /dev/null || true +} +trap cleanup EXIT + +DEFAULT_WATCHTOWER="$(dirname "${BASH_SOURCE[0]}")/../watchtower" +WATCHTOWER=$1 +WATCHTOWER=${WATCHTOWER:-$DEFAULT_WATCHTOWER} +echo "watchtower path is $WATCHTOWER" + +################################################################################## +##### PREPARATION ################################################################ +################################################################################## + +# Create Dockerfile template +DOCKERFILE=$(cat << EOF +FROM node:alpine + +LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="cat /opt/test/value.txt" +LABEL com.centurylinklabs.watchtower.lifecycle.post-update="echo image > /opt/test/value.txt" + +ENV IMAGE_TIMESTAMP=TIMESTAMP + +WORKDIR /opt/test +ENTRYPOINT ["/usr/local/bin/node", "/opt/test/server.js"] + +EXPOSE 8888 + +RUN mkdir -p /opt/test && echo "default" > /opt/test/value.txt +COPY server.js /opt/test/server.js +EOF +) + +# Create temporary directory to build docker image +TMP_DIR="/tmp/watchtower-commands-test" +mkdir -p $TMP_DIR + +# Create simple http server +cat > $TMP_DIR/server.js << EOF +const http = require("http"); +const fs = require("fs"); + +http.createServer(function(request, response) { + const fileContent = fs.readFileSync("/opt/test/value.txt"); + response.writeHead(200, {"Content-Type": "text/plain"}); + response.write(fileContent); + response.end(); +}).listen(8888, () => { console.log('server is listening on 8888'); }); +EOF + +function builddocker { + TIMESTAMP=$(date +%s) + echo "Building image $TIMESTAMP" + echo "${DOCKERFILE/TIMESTAMP/$TIMESTAMP}" > $TMP_DIR/Dockerfile + docker build $TMP_DIR -t $IMAGE >> /dev/null +} + +# Start watchtower +echo "Starting watchtower" +$WATCHTOWER -i $WATCHTOWER_INTERVAL --no-pull --stop-timeout 2s --enable-lifecycle-hooks $CONTAINER $LINKED_CONTAINER & +sleep 3 + +echo "#################################################################" +echo "##### TEST CASE 1: Execute commands from base image" +echo "#################################################################" + +# Build base image +builddocker + +# Run container +docker run -d -p 0.0.0.0:8888:8888 --name $CONTAINER $IMAGE:latest >> /dev/null +sleep 1 +echo "Container $CONTAINER is runnning" + +# Test default value +RESP=$(curl -s http://localhost:8888) +if [ $RESP != "default" ]; then + echo "Default value of container response is invalid" 1>&2 + exit 1 +fi + +# Build updated image to trigger watchtower update +builddocker + +WAIT_AMOUNT=$(($WATCHTOWER_INTERVAL * 3)) +echo "Wait for $WAIT_AMOUNT seconds" +sleep $WAIT_AMOUNT + +# Test value after post-update-command +RESP=$(curl -s http://localhost:8888) +if [[ $RESP != "image" ]]; then + echo "Value of container response is invalid. Expected: image. Actual: $RESP" + exit 1 +fi + +remove_container $CONTAINER + +echo "#################################################################" +echo "##### TEST CASE 2: Execute commands from container and base image" +echo "#################################################################" + +# Build base image +builddocker + +# Run container +docker run -d -p 0.0.0.0:8888:8888 \ + --label=com.centurylinklabs.watchtower.lifecycle.post-update="echo container > /opt/test/value.txt" \ + --name $CONTAINER $IMAGE:latest >> /dev/null +sleep 1 +echo "Container $CONTAINER is runnning" + +# Test default value +RESP=$(curl -s http://localhost:8888) +if [ $RESP != "default" ]; then + echo "Default value of container response is invalid" 1>&2 + exit 1 +fi + +# Build updated image to trigger watchtower update +builddocker + +WAIT_AMOUNT=$(($WATCHTOWER_INTERVAL * 3)) +echo "Wait for $WAIT_AMOUNT seconds" +sleep $WAIT_AMOUNT + +# Test value after post-update-command +RESP=$(curl -s http://localhost:8888) +if [[ $RESP != "container" ]]; then + echo "Value of container response is invalid. Expected: container. Actual: $RESP" + exit 1 +fi + +remove_container $CONTAINER + +echo "#################################################################" +echo "##### TEST CASE 3: Execute commands with a linked container" +echo "#################################################################" + +# Tag the current image to keep a version for the linked container +docker tag $IMAGE:latest $LINKED_IMAGE:latest + +# Build base image +builddocker + +# Run container +docker run -d -p 0.0.0.0:8888:8888 \ + --label=com.centurylinklabs.watchtower.lifecycle.post-update="echo container > /opt/test/value.txt" \ + --name $CONTAINER $IMAGE:latest >> /dev/null +docker run -d -p 0.0.0.0:8989:8888 \ + --label=com.centurylinklabs.watchtower.lifecycle.post-update="echo container > /opt/test/value.txt" \ + --link $CONTAINER \ + --name $LINKED_CONTAINER $LINKED_IMAGE:latest >> /dev/null +sleep 1 +echo "Container $CONTAINER and $LINKED_CONTAINER are runnning" + +# Test default value +RESP=$(curl -s http://localhost:8888) +if [ $RESP != "default" ]; then + echo "Default value of container response is invalid" 1>&2 + exit 1 +fi + +# Test default value for linked container +RESP=$(curl -s http://localhost:8989) +if [ $RESP != "default" ]; then + echo "Default value of linked container response is invalid" 1>&2 + exit 1 +fi + +# Build updated image to trigger watchtower update +builddocker + +WAIT_AMOUNT=$(($WATCHTOWER_INTERVAL * 3)) +echo "Wait for $WAIT_AMOUNT seconds" +sleep $WAIT_AMOUNT + +# Test value after post-update-command +RESP=$(curl -s http://localhost:8888) +if [[ $RESP != "container" ]]; then + echo "Value of container response is invalid. Expected: container. Actual: $RESP" + exit 1 +fi + +# Test that linked container did not execute pre/post-update-command +RESP=$(curl -s http://localhost:8989) +if [[ $RESP != "default" ]]; then + echo "Value of linked container response is invalid. Expected: default. Actual: $RESP" + exit 1 +fi \ No newline at end of file