mirror of
https://github.com/containrrr/watchtower.git
synced 2026-02-06 15:41:49 +01:00
Merge branch 'main' into fix-container-downtime
This commit is contained in:
commit
24276cfbc6
85 changed files with 3816 additions and 1098 deletions
105
pkg/api/api.go
105
pkg/api/api.go
|
|
@ -1,63 +1,76 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"fmt"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
lock chan bool
|
||||
)
|
||||
const tokenMissingMsg = "api token is empty or has not been set. exiting"
|
||||
|
||||
func init() {
|
||||
lock = make(chan bool, 1)
|
||||
lock <- true
|
||||
// API is the http server responsible for serving the HTTP API endpoints
|
||||
type API struct {
|
||||
Token string
|
||||
hasHandlers bool
|
||||
}
|
||||
|
||||
// SetupHTTPUpdates configures the endpoint needed for triggering updates via http
|
||||
func SetupHTTPUpdates(apiToken string, updateFunction func()) error {
|
||||
if apiToken == "" {
|
||||
return errors.New("api token is empty or has not been set. not starting api")
|
||||
// New is a factory function creating a new API instance
|
||||
func New(token string) *API {
|
||||
return &API{
|
||||
Token: token,
|
||||
hasHandlers: false,
|
||||
}
|
||||
}
|
||||
|
||||
// RequireToken is wrapper around http.HandleFunc that checks token validity
|
||||
func (api *API) RequireToken(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", api.Token) {
|
||||
log.Tracef("Invalid token \"%s\"", r.Header.Get("Authorization"))
|
||||
log.Tracef("Expected token to be \"%s\"", api.Token)
|
||||
return
|
||||
}
|
||||
log.Debug("Valid token found.")
|
||||
fn(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterFunc is a wrapper around http.HandleFunc that also sets the flag used to determine whether to launch the API
|
||||
func (api *API) RegisterFunc(path string, fn http.HandlerFunc) {
|
||||
api.hasHandlers = true
|
||||
http.HandleFunc(path, api.RequireToken(fn))
|
||||
}
|
||||
|
||||
// RegisterHandler is a wrapper around http.Handler that also sets the flag used to determine whether to launch the API
|
||||
func (api *API) RegisterHandler(path string, handler http.Handler) {
|
||||
api.hasHandlers = true
|
||||
http.Handle(path, api.RequireToken(handler.ServeHTTP))
|
||||
}
|
||||
|
||||
// Start the API and serve over HTTP. Requires an API Token to be set.
|
||||
func (api *API) Start(block bool) error {
|
||||
|
||||
if !api.hasHandlers {
|
||||
log.Debug("Watchtower HTTP API skipped.")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("Watchtower HTTP API started.")
|
||||
|
||||
http.HandleFunc("/v1/update", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Info("Updates triggered by HTTP API request.")
|
||||
|
||||
_, err := io.Copy(os.Stdout, r.Body)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("Token") != apiToken {
|
||||
log.Println("Invalid token. Not updating.")
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Valid token found. Attempting to update.")
|
||||
|
||||
select {
|
||||
case chanValue := <-lock:
|
||||
defer func() { lock <- chanValue }()
|
||||
updateFunction()
|
||||
default:
|
||||
log.Debug("Skipped. Another update already running.")
|
||||
}
|
||||
|
||||
})
|
||||
if api.Token == "" {
|
||||
log.Fatal(tokenMissingMsg)
|
||||
}
|
||||
|
||||
log.Info("Watchtower HTTP API started.")
|
||||
if block {
|
||||
runHTTPServer()
|
||||
} else {
|
||||
go func() {
|
||||
runHTTPServer()
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitForHTTPUpdates starts the http server and listens for requests.
|
||||
func WaitForHTTPUpdates() error {
|
||||
func runHTTPServer() {
|
||||
log.Info("Serving HTTP")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
27
pkg/api/metrics/metrics.go
Normal file
27
pkg/api/metrics/metrics.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/containrrr/watchtower/pkg/metrics"
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// Handler is an HTTP handle for serving metric data
|
||||
type Handler struct {
|
||||
Path string
|
||||
Handle http.HandlerFunc
|
||||
Metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// New is a factory function creating a new Metrics instance
|
||||
func New() *Handler {
|
||||
m := metrics.Default()
|
||||
handler := promhttp.Handler()
|
||||
|
||||
return &Handler{
|
||||
Path: "/v1/metrics",
|
||||
Handle: handler.ServeHTTP,
|
||||
Metrics: m,
|
||||
}
|
||||
}
|
||||
79
pkg/api/metrics/metrics_test.go
Normal file
79
pkg/api/metrics/metrics_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package metrics_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/pkg/metrics"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/containrrr/watchtower/pkg/api"
|
||||
metricsAPI "github.com/containrrr/watchtower/pkg/api/metrics"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const Token = "123123123"
|
||||
|
||||
func TestContainer(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Metrics Suite")
|
||||
}
|
||||
|
||||
func runTestServer(m *metricsAPI.Handler) {
|
||||
http.Handle(m.Path, m.Handle)
|
||||
go func() {
|
||||
http.ListenAndServe(":8080", nil)
|
||||
}()
|
||||
}
|
||||
|
||||
func getWithToken(c http.Client, url string) (*http.Response, error) {
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", Token))
|
||||
return c.Do(req)
|
||||
}
|
||||
|
||||
var _ = Describe("the metrics", func() {
|
||||
httpAPI := api.New(Token)
|
||||
m := metricsAPI.New()
|
||||
|
||||
httpAPI.RegisterHandler(m.Path, m.Handle)
|
||||
httpAPI.Start(false)
|
||||
|
||||
It("should serve metrics", func() {
|
||||
metric := &metrics.Metric{
|
||||
Scanned: 4,
|
||||
Updated: 3,
|
||||
Failed: 1,
|
||||
}
|
||||
metrics.RegisterScan(metric)
|
||||
Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue())
|
||||
|
||||
c := http.Client{}
|
||||
|
||||
res, err := getWithToken(c, "http://localhost:8080/v1/metrics")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
contents, err := ioutil.ReadAll(res.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_containers_updated 3"))
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_containers_failed 1"))
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_containers_scanned 4"))
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_scans_total 1"))
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_scans_skipped 0"))
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
metrics.RegisterScan(nil)
|
||||
}
|
||||
Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue())
|
||||
|
||||
res, err = getWithToken(c, "http://localhost:8080/v1/metrics")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
contents, err = ioutil.ReadAll(res.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_scans_total 4"))
|
||||
Expect(string(contents)).To(ContainSubstring("watchtower_scans_skipped 3"))
|
||||
})
|
||||
})
|
||||
50
pkg/api/update/update.go
Normal file
50
pkg/api/update/update.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package update
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
lock chan bool
|
||||
)
|
||||
|
||||
// New is a factory function creating a new Handler instance
|
||||
func New(updateFn func()) *Handler {
|
||||
lock = make(chan bool, 1)
|
||||
lock <- true
|
||||
|
||||
return &Handler{
|
||||
fn: updateFn,
|
||||
Path: "/v1/update",
|
||||
}
|
||||
}
|
||||
|
||||
// Handler is an API handler used for triggering container update scans
|
||||
type Handler struct {
|
||||
fn func()
|
||||
Path string
|
||||
}
|
||||
|
||||
// Handle is the actual http.Handle function doing all the heavy lifting
|
||||
func (handle *Handler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
log.Info("Updates triggered by HTTP API request.")
|
||||
|
||||
_, err := io.Copy(os.Stdout, r.Body)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case chanValue := <-lock:
|
||||
defer func() { lock <- chanValue }()
|
||||
handle.fn()
|
||||
default:
|
||||
log.Debug("Skipped. Another update already running.")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/containrrr/watchtower/pkg/registry"
|
||||
"github.com/containrrr/watchtower/pkg/registry/digest"
|
||||
|
||||
t "github.com/containrrr/watchtower/pkg/types"
|
||||
"github.com/docker/docker/api/types"
|
||||
|
|
@ -32,6 +33,7 @@ type Client interface {
|
|||
IsContainerStale(Container) (bool, error)
|
||||
ExecuteCommand(containerID string, command string, timeout int) error
|
||||
RemoveImageByID(string) error
|
||||
WarnOnHeadPullFailed(container Container) bool
|
||||
}
|
||||
|
||||
// NewClient returns a new Client instance which can be used to interact with
|
||||
|
|
@ -40,7 +42,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, reviveStopped bool, removeVolumes bool, includeRestarting bool) Client {
|
||||
func NewClient(pullImages, includeStopped, reviveStopped, removeVolumes, includeRestarting bool, warnOnHeadFailed string) Client {
|
||||
cli, err := sdkClient.NewClientWithOpts(sdkClient.FromEnv)
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -54,6 +56,7 @@ func NewClient(pullImages bool, includeStopped bool, reviveStopped bool, removeV
|
|||
includeStopped: includeStopped,
|
||||
reviveStopped: reviveStopped,
|
||||
includeRestarting: includeRestarting,
|
||||
warnOnHeadFailed: warnOnHeadFailed,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +67,18 @@ type dockerClient struct {
|
|||
includeStopped bool
|
||||
reviveStopped bool
|
||||
includeRestarting bool
|
||||
warnOnHeadFailed string
|
||||
}
|
||||
|
||||
func (client dockerClient) WarnOnHeadPullFailed(container Container) bool {
|
||||
if client.warnOnHeadFailed == "always" {
|
||||
return true
|
||||
}
|
||||
if client.warnOnHeadFailed == "never" {
|
||||
return false
|
||||
}
|
||||
|
||||
return registry.WarnOnAPIConsumption(container)
|
||||
}
|
||||
|
||||
func (client dockerClient) ListContainers(fn t.Filter) ([]Container, error) {
|
||||
|
|
@ -146,8 +161,10 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
|
|||
signal = defaultStopSignal
|
||||
}
|
||||
|
||||
shortID := ShortID(c.ID())
|
||||
|
||||
if c.IsRunning() {
|
||||
log.Infof("Stopping %s (%s) with %s", c.Name(), c.ID(), signal)
|
||||
log.Infof("Stopping %s (%s) with %s", c.Name(), shortID, signal)
|
||||
if err := client.api.ContainerKill(bg, c.ID(), signal); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -157,9 +174,9 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
|
|||
_ = client.waitForStopOrTimeout(c, timeout)
|
||||
|
||||
if c.containerInfo.HostConfig.AutoRemove {
|
||||
log.Debugf("AutoRemove container %s, skipping ContainerRemove call.", c.ID())
|
||||
log.Debugf("AutoRemove container %s, skipping ContainerRemove call.", shortID)
|
||||
} else {
|
||||
log.Debugf("Removing container %s", c.ID())
|
||||
log.Debugf("Removing container %s", shortID)
|
||||
|
||||
if err := client.api.ContainerRemove(bg, c.ID(), types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.removeVolumes}); err != nil {
|
||||
return err
|
||||
|
|
@ -168,7 +185,7 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
|
|||
|
||||
// Wait for container to be removed. In this case an error is a good thing
|
||||
if err := client.waitForStopOrTimeout(c, timeout); err == nil {
|
||||
return fmt.Errorf("container %s (%s) could not be removed", c.Name(), c.ID())
|
||||
return fmt.Errorf("container %s (%s) could not be removed", c.Name(), shortID)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -228,7 +245,7 @@ func (client dockerClient) StartContainer(c Container) (string, error) {
|
|||
func (client dockerClient) doStartContainer(bg context.Context, c Container, creation container.ContainerCreateCreatedBody) error {
|
||||
name := c.Name()
|
||||
|
||||
log.Debugf("Starting container %s (%s)", name, creation.ID)
|
||||
log.Debugf("Starting container %s (%s)", name, ShortID(creation.ID))
|
||||
err := client.api.ContainerStart(bg, creation.ID, types.ContainerStartOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -238,7 +255,7 @@ func (client dockerClient) doStartContainer(bg context.Context, c Container, cre
|
|||
|
||||
func (client dockerClient) RenameContainer(c Container, newName string) error {
|
||||
bg := context.Background()
|
||||
log.Debugf("Renaming container %s (%s) to %s", c.Name(), c.ID(), newName)
|
||||
log.Debugf("Renaming container %s (%s) to %s", c.Name(), ShortID(c.ID()), newName)
|
||||
return client.api.ContainerRename(bg, c.ID(), newName)
|
||||
}
|
||||
|
||||
|
|
@ -268,20 +285,48 @@ func (client dockerClient) HasNewImage(ctx context.Context, container Container)
|
|||
return false, nil
|
||||
}
|
||||
|
||||
log.Infof("Found new %s image (%s)", imageName, newImageInfo.ID)
|
||||
log.Infof("Found new %s image (%s)", imageName, ShortID(newImageInfo.ID))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// PullImage pulls the latest image for the supplied container, optionally skipping if it's digest can be confirmed
|
||||
// to match the one that the registry reports via a HEAD request
|
||||
func (client dockerClient) PullImage(ctx context.Context, container Container) error {
|
||||
containerName := container.Name()
|
||||
imageName := container.ImageName()
|
||||
log.Debugf("Pulling %s for %s", imageName, containerName)
|
||||
|
||||
fields := log.Fields{
|
||||
"image": imageName,
|
||||
"container": containerName,
|
||||
}
|
||||
|
||||
log.WithFields(fields).Debugf("Trying to load authentication credentials.")
|
||||
opts, err := registry.GetPullOptions(imageName)
|
||||
if err != nil {
|
||||
log.Debugf("Error loading authentication credentials %s", err)
|
||||
return err
|
||||
}
|
||||
if opts.RegistryAuth != "" {
|
||||
log.Debug("Credentials loaded")
|
||||
}
|
||||
|
||||
log.WithFields(fields).Debugf("Checking if pull is needed")
|
||||
|
||||
if match, err := digest.CompareDigest(container, opts.RegistryAuth); err != nil {
|
||||
headLevel := log.DebugLevel
|
||||
if client.WarnOnHeadPullFailed(container) {
|
||||
headLevel = log.WarnLevel
|
||||
}
|
||||
log.WithFields(fields).Logf(headLevel, "Could not do a head request for %q, falling back to regular pull.", imageName)
|
||||
log.WithFields(fields).Log(headLevel, "Reason: ", err)
|
||||
} else if match {
|
||||
log.Debug("No pull needed. Skipping image.")
|
||||
return nil
|
||||
} else {
|
||||
log.Debug("Digests did not match, doing a pull.")
|
||||
}
|
||||
|
||||
log.WithFields(fields).Debugf("Pulling image")
|
||||
|
||||
response, err := client.api.ImagePull(ctx, imageName, opts)
|
||||
if err != nil {
|
||||
|
|
@ -299,7 +344,7 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
|
|||
}
|
||||
|
||||
func (client dockerClient) RemoveImageByID(id string) error {
|
||||
log.Infof("Removing image %s", id)
|
||||
log.Infof("Removing image %s", ShortID(id))
|
||||
|
||||
_, err := client.api.ImageRemove(
|
||||
context.Background(),
|
||||
|
|
@ -377,6 +422,7 @@ func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, e
|
|||
for {
|
||||
execInspect, err := client.api.ContainerExecInspect(ctx, ID)
|
||||
|
||||
//goland:noinspection GoNilness
|
||||
log.WithFields(log.Fields{
|
||||
"exit-code": execInspect.ExitCode,
|
||||
"exec-id": execInspect.ExecID,
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ func NewContainer(containerInfo *types.ContainerJSON, imageInfo *types.ImageInsp
|
|||
|
||||
// Container represents a running Docker container.
|
||||
type Container struct {
|
||||
Linked bool
|
||||
Stale bool
|
||||
LinkedToRestarting bool
|
||||
Stale bool
|
||||
|
||||
containerInfo *types.ContainerJSON
|
||||
imageInfo *types.ImageInspect
|
||||
|
|
@ -142,7 +142,7 @@ func (c Container) Links() []string {
|
|||
// 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
|
||||
return c.Stale || c.LinkedToRestarting
|
||||
}
|
||||
|
||||
// IsWatchtower returns a boolean flag indicating whether or not the current
|
||||
|
|
@ -253,3 +253,37 @@ func (c Container) hostConfig() *dockercontainer.HostConfig {
|
|||
func (c Container) HasImageInfo() bool {
|
||||
return c.imageInfo != nil
|
||||
}
|
||||
|
||||
// ImageInfo fetches the ImageInspect data of the current container
|
||||
func (c Container) ImageInfo() *types.ImageInspect {
|
||||
return c.imageInfo
|
||||
}
|
||||
|
||||
// VerifyConfiguration checks the container and image configurations for nil references to make sure
|
||||
// that the container can be recreated once deleted
|
||||
func (c Container) VerifyConfiguration() error {
|
||||
if c.imageInfo == nil {
|
||||
return errorNoImageInfo
|
||||
}
|
||||
|
||||
containerInfo := c.ContainerInfo()
|
||||
if containerInfo == nil {
|
||||
return errorInvalidConfig
|
||||
}
|
||||
|
||||
containerConfig := containerInfo.Config
|
||||
if containerConfig == nil {
|
||||
return errorInvalidConfig
|
||||
}
|
||||
|
||||
hostConfig := containerInfo.HostConfig
|
||||
if hostConfig == nil {
|
||||
return errorInvalidConfig
|
||||
}
|
||||
|
||||
if len(hostConfig.PortBindings) > 0 && containerConfig.ExposedPorts == nil {
|
||||
return errorNoExposedPorts
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
13
pkg/container/container_suite_test.go
Normal file
13
pkg/container/container_suite_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package container_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestContainer(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Container Suite")
|
||||
}
|
||||
|
|
@ -1,22 +1,16 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/containrrr/watchtower/pkg/container/mocks"
|
||||
"github.com/containrrr/watchtower/pkg/filters"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
cli "github.com/docker/docker/client"
|
||||
"github.com/docker/go-connections/nat"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestContainer(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Container Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("the container", func() {
|
||||
Describe("the client", func() {
|
||||
var docker *cli.Client
|
||||
|
|
@ -34,6 +28,35 @@ var _ = Describe("the container", func() {
|
|||
It("should return a client for the api", func() {
|
||||
Expect(client).NotTo(BeNil())
|
||||
})
|
||||
Describe("WarnOnHeadPullFailed", func() {
|
||||
containerUnknown := *mockContainerWithImageName("unknown.repo/prefix/imagename:latest")
|
||||
containerKnown := *mockContainerWithImageName("docker.io/prefix/imagename:latest")
|
||||
|
||||
When("warn on head failure is set to \"always\"", func() {
|
||||
c := newClientNoAPI(false, false, false, false, false, "always")
|
||||
It("should always return true", func() {
|
||||
Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeTrue())
|
||||
Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
When("warn on head failure is set to \"auto\"", func() {
|
||||
c := newClientNoAPI(false, false, false, false, false, "auto")
|
||||
It("should always return true", func() {
|
||||
Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
|
||||
})
|
||||
It("should", func() {
|
||||
Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
When("warn on head failure is set to \"never\"", func() {
|
||||
c := newClientNoAPI(false, false, false, false, false, "never")
|
||||
It("should never return true", func() {
|
||||
Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
|
||||
Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
When("listing containers without any filter", func() {
|
||||
It("should return all available containers", func() {
|
||||
containers, err := client.ListContainers(filters.NoFilter)
|
||||
|
|
@ -108,6 +131,63 @@ var _ = Describe("the container", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
Describe("VerifyConfiguration", func() {
|
||||
When("verifying a container with no image info", func() {
|
||||
It("should return an error", func() {
|
||||
c := mockContainerWithPortBindings()
|
||||
c.imageInfo = nil
|
||||
err := c.VerifyConfiguration()
|
||||
Expect(err).To(Equal(errorNoImageInfo))
|
||||
})
|
||||
})
|
||||
When("verifying a container with no container info", func() {
|
||||
It("should return an error", func() {
|
||||
c := mockContainerWithPortBindings()
|
||||
c.containerInfo = nil
|
||||
err := c.VerifyConfiguration()
|
||||
Expect(err).To(Equal(errorInvalidConfig))
|
||||
})
|
||||
})
|
||||
When("verifying a container with no config", func() {
|
||||
It("should return an error", func() {
|
||||
c := mockContainerWithPortBindings()
|
||||
c.containerInfo.Config = nil
|
||||
err := c.VerifyConfiguration()
|
||||
Expect(err).To(Equal(errorInvalidConfig))
|
||||
})
|
||||
})
|
||||
When("verifying a container with no host config", func() {
|
||||
It("should return an error", func() {
|
||||
c := mockContainerWithPortBindings()
|
||||
c.containerInfo.HostConfig = nil
|
||||
err := c.VerifyConfiguration()
|
||||
Expect(err).To(Equal(errorInvalidConfig))
|
||||
})
|
||||
})
|
||||
When("verifying a container with no port bindings", func() {
|
||||
It("should not return an error", func() {
|
||||
c := mockContainerWithPortBindings()
|
||||
err := c.VerifyConfiguration()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
When("verifying a container with port bindings, but no exposed ports", func() {
|
||||
It("should return an error", func() {
|
||||
c := mockContainerWithPortBindings("80/tcp")
|
||||
c.containerInfo.Config.ExposedPorts = nil
|
||||
err := c.VerifyConfiguration()
|
||||
Expect(err).To(Equal(errorNoExposedPorts))
|
||||
})
|
||||
})
|
||||
When("verifying a container with port bindings and exposed ports is non-nil", func() {
|
||||
It("should return an error", func() {
|
||||
c := mockContainerWithPortBindings("80/tcp")
|
||||
c.containerInfo.Config.ExposedPorts = map[nat.Port]struct{}{"80/tcp": {}}
|
||||
err := c.VerifyConfiguration()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
When("asked for metadata", func() {
|
||||
var c *Container
|
||||
BeforeEach(func() {
|
||||
|
|
@ -259,10 +339,23 @@ var _ = Describe("the container", func() {
|
|||
})
|
||||
})
|
||||
|
||||
func mockContainerWithPortBindings(portBindingSources ...string) *Container {
|
||||
mockContainer := mockContainerWithLabels(nil)
|
||||
mockContainer.imageInfo = &types.ImageInspect{}
|
||||
hostConfig := &container.HostConfig{
|
||||
PortBindings: nat.PortMap{},
|
||||
}
|
||||
for _, pbs := range portBindingSources {
|
||||
hostConfig.PortBindings[nat.Port(pbs)] = []nat.PortBinding{}
|
||||
}
|
||||
mockContainer.containerInfo.HostConfig = hostConfig
|
||||
return mockContainer
|
||||
}
|
||||
|
||||
func mockContainerWithImageName(name string) *Container {
|
||||
container := mockContainerWithLabels(nil)
|
||||
container.containerInfo.Config.Image = name
|
||||
return container
|
||||
mockContainer := mockContainerWithLabels(nil)
|
||||
mockContainer.containerInfo.Config.Image = name
|
||||
return mockContainer
|
||||
}
|
||||
|
||||
func mockContainerWithLinks(links []string) *Container {
|
||||
|
|
@ -295,3 +388,15 @@ func mockContainerWithLabels(labels map[string]string) *Container {
|
|||
}
|
||||
return NewContainer(&content, nil)
|
||||
}
|
||||
|
||||
func newClientNoAPI(pullImages, includeStopped, reviveStopped, removeVolumes, includeRestarting bool, warnOnHeadFailed string) Client {
|
||||
return dockerClient{
|
||||
api: nil,
|
||||
pullImages: pullImages,
|
||||
removeVolumes: removeVolumes,
|
||||
includeStopped: includeStopped,
|
||||
reviveStopped: reviveStopped,
|
||||
includeRestarting: includeRestarting,
|
||||
warnOnHeadFailed: warnOnHeadFailed,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
pkg/container/errors.go
Normal file
7
pkg/container/errors.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package container
|
||||
|
||||
import "errors"
|
||||
|
||||
var errorNoImageInfo = errors.New("no available image info")
|
||||
var errorNoExposedPorts = errors.New("exposed ports does not match port bindings")
|
||||
var errorInvalidConfig = errors.New("container configuration missing or invalid")
|
||||
23
pkg/container/util.go
Normal file
23
pkg/container/util.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package container
|
||||
|
||||
import "strings"
|
||||
|
||||
// ShortID returns the 12-character (hex) short version of an image ID hash, removing any "sha256:" prefix if present
|
||||
func ShortID(imageID string) (short string) {
|
||||
prefixSep := strings.IndexRune(imageID, ':')
|
||||
offset := 0
|
||||
length := 12
|
||||
if prefixSep >= 0 {
|
||||
if imageID[0:prefixSep] == "sha256" {
|
||||
offset = prefixSep + 1
|
||||
} else {
|
||||
length += prefixSep + 1
|
||||
}
|
||||
}
|
||||
|
||||
if len(imageID) >= offset+length {
|
||||
return imageID[offset : offset+length]
|
||||
}
|
||||
|
||||
return imageID
|
||||
}
|
||||
46
pkg/container/util_test.go
Normal file
46
pkg/container/util_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package container_test
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
. "github.com/containrrr/watchtower/pkg/container"
|
||||
)
|
||||
|
||||
var _ = Describe("container utils", func() {
|
||||
Describe("ShortID", func() {
|
||||
When("given a normal image ID", func() {
|
||||
When("it contains a sha256 prefix", func() {
|
||||
It("should return that ID in short version", func() {
|
||||
actual := ShortID("sha256:0123456789abcd00000000001111111111222222222233333333334444444444")
|
||||
Expect(actual).To(Equal("0123456789ab"))
|
||||
})
|
||||
})
|
||||
When("it doesn't contain a prefix", func() {
|
||||
It("should return that ID in short version", func() {
|
||||
actual := ShortID("0123456789abcd00000000001111111111222222222233333333334444444444")
|
||||
Expect(actual).To(Equal("0123456789ab"))
|
||||
})
|
||||
})
|
||||
})
|
||||
When("given a short image ID", func() {
|
||||
When("it contains no prefix", func() {
|
||||
It("should return the same string", func() {
|
||||
Expect(ShortID("0123456789ab")).To(Equal("0123456789ab"))
|
||||
})
|
||||
})
|
||||
When("it contains a the sha256 prefix", func() {
|
||||
It("should return the ID without the prefix", func() {
|
||||
Expect(ShortID("sha256:0123456789ab")).To(Equal("0123456789ab"))
|
||||
})
|
||||
})
|
||||
})
|
||||
When("given an ID with an unknown prefix", func() {
|
||||
It("should return a short version of that ID including the prefix", func() {
|
||||
Expect(ShortID("md5:0123456789ab")).To(Equal("md5:0123456789ab"))
|
||||
Expect(ShortID("md5:0123456789abcdefg")).To(Equal("md5:0123456789ab"))
|
||||
Expect(ShortID("md5:01")).To(Equal("md5:01"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package filters
|
||||
|
||||
import t "github.com/containrrr/watchtower/pkg/types"
|
||||
import (
|
||||
t "github.com/containrrr/watchtower/pkg/types"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WatchtowerContainersFilter filters only watchtower containers
|
||||
func WatchtowerContainersFilter(c t.FilterableContainer) bool { return c.IsWatchtower() }
|
||||
|
|
@ -68,19 +71,45 @@ func FilterByScope(scope string, baseFilter t.Filter) t.Filter {
|
|||
}
|
||||
|
||||
// BuildFilter creates the needed filter of containers
|
||||
func BuildFilter(names []string, enableLabel bool, scope string) t.Filter {
|
||||
func BuildFilter(names []string, enableLabel bool, scope string) (t.Filter, string) {
|
||||
sb := strings.Builder{}
|
||||
filter := NoFilter
|
||||
filter = FilterByNames(names, filter)
|
||||
|
||||
if len(names) > 0 {
|
||||
sb.WriteString("with name \"")
|
||||
for i, n := range names {
|
||||
sb.WriteString(n)
|
||||
if i < len(names)-1 {
|
||||
sb.WriteString(`" or "`)
|
||||
}
|
||||
}
|
||||
sb.WriteString(`", `)
|
||||
}
|
||||
|
||||
if enableLabel {
|
||||
// If label filtering is enabled, containers should only be considered
|
||||
// if the label is specifically set.
|
||||
filter = FilterByEnableLabel(filter)
|
||||
sb.WriteString("using enable label, ")
|
||||
}
|
||||
if scope != "" {
|
||||
// If a scope has been defined, containers should only be considered
|
||||
// if the scope is specifically set.
|
||||
filter = FilterByScope(scope, filter)
|
||||
sb.WriteString(`in scope "`)
|
||||
sb.WriteString(scope)
|
||||
sb.WriteString(`", `)
|
||||
}
|
||||
filter = FilterByDisabledLabel(filter)
|
||||
return filter
|
||||
|
||||
filterDesc := "Checking all containers (except explicitly disabled with label)"
|
||||
if sb.Len() > 0 {
|
||||
filterDesc = "Only checking containers " + sb.String()
|
||||
|
||||
// Remove the last ", "
|
||||
filterDesc = filterDesc[:len(filterDesc)-2]
|
||||
}
|
||||
|
||||
return filter, filterDesc
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,8 @@ func TestBuildFilter(t *testing.T) {
|
|||
var names []string
|
||||
names = append(names, "test")
|
||||
|
||||
filter := BuildFilter(names, false, "")
|
||||
filter, desc := BuildFilter(names, false, "")
|
||||
assert.Contains(t, desc, "test")
|
||||
|
||||
container := new(mocks.FilterableContainer)
|
||||
container.On("Name").Return("Invalid")
|
||||
|
|
@ -150,7 +151,8 @@ func TestBuildFilterEnableLabel(t *testing.T) {
|
|||
var names []string
|
||||
names = append(names, "test")
|
||||
|
||||
filter := BuildFilter(names, true, "")
|
||||
filter, desc := BuildFilter(names, true, "")
|
||||
assert.Contains(t, desc, "using enable label")
|
||||
|
||||
container := new(mocks.FilterableContainer)
|
||||
container.On("Enabled").Return(false, false)
|
||||
|
|
|
|||
96
pkg/metrics/metrics.go
Normal file
96
pkg/metrics/metrics.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var metrics *Metrics
|
||||
|
||||
// Metric is the data points of a single scan
|
||||
type Metric struct {
|
||||
Scanned int
|
||||
Updated int
|
||||
Failed int
|
||||
}
|
||||
|
||||
// Metrics is the handler processing all individual scan metrics
|
||||
type Metrics struct {
|
||||
channel chan *Metric
|
||||
scanned prometheus.Gauge
|
||||
updated prometheus.Gauge
|
||||
failed prometheus.Gauge
|
||||
total prometheus.Counter
|
||||
skipped prometheus.Counter
|
||||
}
|
||||
|
||||
// QueueIsEmpty checks whether any messages are enqueued in the channel
|
||||
func (metrics *Metrics) QueueIsEmpty() bool {
|
||||
return len(metrics.channel) == 0
|
||||
}
|
||||
|
||||
// Register registers metrics for an executed scan
|
||||
func (metrics *Metrics) Register(metric *Metric) {
|
||||
metrics.channel <- metric
|
||||
}
|
||||
|
||||
// Default creates a new metrics handler if none exists, otherwise returns the existing one
|
||||
func Default() *Metrics {
|
||||
if metrics != nil {
|
||||
return metrics
|
||||
}
|
||||
|
||||
metrics = &Metrics{
|
||||
scanned: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "watchtower_containers_scanned",
|
||||
Help: "Number of containers scanned for changes by watchtower during the last scan",
|
||||
}),
|
||||
updated: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "watchtower_containers_updated",
|
||||
Help: "Number of containers updated by watchtower during the last scan",
|
||||
}),
|
||||
failed: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "watchtower_containers_failed",
|
||||
Help: "Number of containers where update failed during the last scan",
|
||||
}),
|
||||
total: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "watchtower_scans_total",
|
||||
Help: "Number of scans since the watchtower started",
|
||||
}),
|
||||
skipped: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "watchtower_scans_skipped",
|
||||
Help: "Number of skipped scans since watchtower started",
|
||||
}),
|
||||
channel: make(chan *Metric, 10),
|
||||
}
|
||||
|
||||
go metrics.HandleUpdate(metrics.channel)
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
// RegisterScan fetches a metric handler and enqueues a metric
|
||||
func RegisterScan(metric *Metric) {
|
||||
metrics := Default()
|
||||
metrics.Register(metric)
|
||||
}
|
||||
|
||||
// HandleUpdate dequeue the metric channel and processes it
|
||||
func (metrics *Metrics) HandleUpdate(channel <-chan *Metric) {
|
||||
for change := range channel {
|
||||
if change == nil {
|
||||
// Update was skipped and rescheduled
|
||||
metrics.total.Inc()
|
||||
metrics.skipped.Inc()
|
||||
metrics.scanned.Set(0)
|
||||
metrics.updated.Set(0)
|
||||
metrics.failed.Set(0)
|
||||
continue
|
||||
}
|
||||
// Update metrics with the new values
|
||||
metrics.total.Inc()
|
||||
metrics.scanned.Set(float64(change.Scanned))
|
||||
metrics.updated.Set(float64(change.Updated))
|
||||
metrics.failed.Set(float64(change.Failed))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +1,21 @@
|
|||
package notifications
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
shoutrrrSmtp "github.com/containrrr/shoutrrr/pkg/services/smtp"
|
||||
t "github.com/containrrr/watchtower/pkg/types"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
emailType = "email"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
url string
|
||||
From, To string
|
||||
Server, User, Password, SubjectTag string
|
||||
Port int
|
||||
|
|
@ -33,7 +25,12 @@ type emailTypeNotifier struct {
|
|||
delay time.Duration
|
||||
}
|
||||
|
||||
func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
|
||||
// NewEmailNotifier is a factory method creating a new email notifier instance
|
||||
func NewEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
|
||||
return newEmailNotifier(c, acceptedLogLevels)
|
||||
}
|
||||
|
||||
func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
|
||||
flags := c.PersistentFlags()
|
||||
|
||||
from, _ := flags.GetString("notification-email-from")
|
||||
|
|
@ -47,6 +44,7 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie
|
|||
subjecttag, _ := flags.GetString("notification-email-subjecttag")
|
||||
|
||||
n := &emailTypeNotifier{
|
||||
entries: []*log.Entry{},
|
||||
From: from,
|
||||
To: to,
|
||||
Server: server,
|
||||
|
|
@ -59,99 +57,42 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie
|
|||
SubjectTag: subjecttag,
|
||||
}
|
||||
|
||||
log.AddHook(n)
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
func (e *emailTypeNotifier) buildMessage(entries []*log.Entry) []byte {
|
||||
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
|
||||
}
|
||||
body := ""
|
||||
for _, entry := range entries {
|
||||
body += entry.Time.Format("2006-01-02 15:04:05") + " (" + entry.Level.String() + "): " + entry.Message + "\r\n"
|
||||
// We don't use fields in watchtower, so don't bother sending them.
|
||||
func (e *emailTypeNotifier) GetURL() (string, error) {
|
||||
conf := &shoutrrrSmtp.Config{
|
||||
FromAddress: e.From,
|
||||
FromName: "Watchtower",
|
||||
ToAddresses: []string{e.To},
|
||||
Port: uint16(e.Port),
|
||||
Host: e.Server,
|
||||
Subject: e.getSubject(),
|
||||
Username: e.User,
|
||||
Password: e.Password,
|
||||
UseStartTLS: !e.tlsSkipVerify,
|
||||
UseHTML: false,
|
||||
Encryption: shoutrrrSmtp.EncMethods.Auto,
|
||||
Auth: shoutrrrSmtp.AuthTypes.None,
|
||||
}
|
||||
|
||||
t := time.Now()
|
||||
|
||||
header := make(map[string]string)
|
||||
header["From"] = e.From
|
||||
header["To"] = e.To
|
||||
header["Subject"] = emailSubject
|
||||
header["Date"] = t.Format(time.RFC1123Z)
|
||||
header["MIME-Version"] = "1.0"
|
||||
header["Content-Type"] = "text/plain; charset=\"utf-8\""
|
||||
header["Content-Transfer-Encoding"] = "base64"
|
||||
|
||||
message := ""
|
||||
for k, v := range header {
|
||||
message += fmt.Sprintf("%s: %s\r\n", k, v)
|
||||
if len(e.User) > 0 {
|
||||
conf.Auth = shoutrrrSmtp.AuthTypes.Plain
|
||||
}
|
||||
|
||||
encodedBody := base64.StdEncoding.EncodeToString([]byte(body))
|
||||
//RFC 2045 base64 encoding demands line no longer than 76 characters.
|
||||
for _, line := range SplitSubN(encodedBody, 76) {
|
||||
message += "\r\n" + line
|
||||
if e.tlsSkipVerify {
|
||||
conf.Encryption = shoutrrrSmtp.EncMethods.None
|
||||
}
|
||||
|
||||
return []byte(message)
|
||||
return conf.GetURL().String(), nil
|
||||
}
|
||||
|
||||
func (e *emailTypeNotifier) sendEntries(entries []*log.Entry) {
|
||||
// Do the sending in a separate goroutine so we don't block the main process.
|
||||
msg := e.buildMessage(entries)
|
||||
go func() {
|
||||
if e.delay > 0 {
|
||||
time.Sleep(e.delay)
|
||||
}
|
||||
func (e *emailTypeNotifier) getSubject() string {
|
||||
subject := GetTitle()
|
||||
|
||||
var auth smtp.Auth
|
||||
if e.User != "" {
|
||||
auth = smtp.PlainAuth("", e.User, e.Password, e.Server)
|
||||
}
|
||||
err := SendMail(e.Server+":"+strconv.Itoa(e.Port), e.tlsSkipVerify, auth, e.From, strings.Split(e.To, ","), msg)
|
||||
if err != nil {
|
||||
// Use fmt so it doesn't trigger another email.
|
||||
fmt.Println("Failed to send notification email: ", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *emailTypeNotifier) StartNotification() {
|
||||
if e.entries == nil {
|
||||
e.entries = make([]*log.Entry, 0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *emailTypeNotifier) SendNotification() {
|
||||
if e.entries == nil || len(e.entries) <= 0 {
|
||||
return
|
||||
if e.SubjectTag != "" {
|
||||
subject = e.SubjectTag + " " + subject
|
||||
}
|
||||
|
||||
e.sendEntries(e.entries)
|
||||
e.entries = nil
|
||||
return subject
|
||||
}
|
||||
|
||||
func (e *emailTypeNotifier) Levels() []log.Level {
|
||||
return e.logLevels
|
||||
}
|
||||
|
||||
func (e *emailTypeNotifier) Fire(entry *log.Entry) error {
|
||||
if e.entries != nil {
|
||||
e.entries = append(e.entries, entry)
|
||||
} else {
|
||||
e.sendEntries([]*log.Entry{entry})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *emailTypeNotifier) Close() {}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
shoutrrrGotify "github.com/containrrr/shoutrrr/pkg/services/gotify"
|
||||
t "github.com/containrrr/watchtower/pkg/types"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -24,10 +22,40 @@ type gotifyTypeNotifier struct {
|
|||
logLevels []log.Level
|
||||
}
|
||||
|
||||
func newGotifyNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
|
||||
// NewGotifyNotifier is a factory method creating a new gotify notifier instance
|
||||
func NewGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertibleNotifier {
|
||||
return newGotifyNotifier(c, levels)
|
||||
}
|
||||
|
||||
func newGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertibleNotifier {
|
||||
flags := c.PersistentFlags()
|
||||
|
||||
apiURL := getGotifyURL(flags)
|
||||
token := getGotifyToken(flags)
|
||||
|
||||
skipVerify, _ := flags.GetBool("notification-gotify-tls-skip-verify")
|
||||
|
||||
n := &gotifyTypeNotifier{
|
||||
gotifyURL: apiURL,
|
||||
gotifyAppToken: token,
|
||||
gotifyInsecureSkipVerify: skipVerify,
|
||||
logLevels: levels,
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
func getGotifyToken(flags *pflag.FlagSet) string {
|
||||
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.")
|
||||
}
|
||||
return gotifyToken
|
||||
}
|
||||
|
||||
func getGotifyURL(flags *pflag.FlagSet) string {
|
||||
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://")) {
|
||||
|
|
@ -36,82 +64,22 @@ func newGotifyNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifi
|
|||
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.")
|
||||
return gotifyURL
|
||||
}
|
||||
|
||||
func (n *gotifyTypeNotifier) GetURL() (string, error) {
|
||||
apiURL, err := url.Parse(n.gotifyURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gotifyInsecureSkipVerify, _ := flags.GetBool("notification-gotify-tls-skip-verify")
|
||||
|
||||
n := &gotifyTypeNotifier{
|
||||
gotifyURL: gotifyURL,
|
||||
gotifyAppToken: gotifyToken,
|
||||
gotifyInsecureSkipVerify: gotifyInsecureSkipVerify,
|
||||
logLevels: acceptedLogLevels,
|
||||
config := &shoutrrrGotify.Config{
|
||||
Host: apiURL.Host,
|
||||
Path: apiURL.Path,
|
||||
DisableTLS: apiURL.Scheme == "http",
|
||||
Title: GetTitle(),
|
||||
Token: n.gotifyAppToken,
|
||||
}
|
||||
|
||||
log.AddHook(n)
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *gotifyTypeNotifier) StartNotification() {}
|
||||
|
||||
func (n *gotifyTypeNotifier) SendNotification() {}
|
||||
|
||||
func (n *gotifyTypeNotifier) Close() {}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Explicitly define the client so we can set InsecureSkipVerify to the desired value.
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: n.gotifyInsecureSkipVerify,
|
||||
},
|
||||
},
|
||||
}
|
||||
jsonBodyBuffer := bytes.NewBuffer([]byte(jsonBody))
|
||||
resp, err := client.Post(n.getURL(), "application/json", jsonBodyBuffer)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to send Gotify notification: ", err)
|
||||
return
|
||||
}
|
||||
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"`
|
||||
return config.GetURL().String(), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"net/http"
|
||||
|
||||
shoutrrrTeams "github.com/containrrr/shoutrrr/pkg/services/teams"
|
||||
t "github.com/containrrr/watchtower/pkg/types"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"io/ioutil"
|
||||
"github.com/spf13/cobra"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -22,7 +18,12 @@ type msTeamsTypeNotifier struct {
|
|||
data bool
|
||||
}
|
||||
|
||||
func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
|
||||
// NewMsTeamsNotifier is a factory method creating a new teams notifier instance
|
||||
func NewMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
|
||||
return newMsTeamsNotifier(cmd, acceptedLogLevels)
|
||||
}
|
||||
|
||||
func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
|
||||
|
||||
flags := cmd.PersistentFlags()
|
||||
|
||||
|
|
@ -38,103 +39,22 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Not
|
|||
data: withData,
|
||||
}
|
||||
|
||||
log.AddHook(n)
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *msTeamsTypeNotifier) StartNotification() {}
|
||||
func (n *msTeamsTypeNotifier) GetURL() (string, error) {
|
||||
webhookURL, err := url.Parse(n.webHookURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
func (n *msTeamsTypeNotifier) SendNotification() {}
|
||||
config, err := shoutrrrTeams.ConfigFromWebhookURL(*webhookURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
func (n *msTeamsTypeNotifier) Close() {}
|
||||
config.Color = ColorHex
|
||||
config.Title = GetTitle()
|
||||
|
||||
func (n *msTeamsTypeNotifier) Levels() []log.Level {
|
||||
return n.levels
|
||||
}
|
||||
|
||||
func (n *msTeamsTypeNotifier) Fire(entry *log.Entry) error {
|
||||
|
||||
message := "(" + entry.Level.String() + "): " + entry.Message
|
||||
|
||||
go func() {
|
||||
webHookBody := messageCard{
|
||||
CardType: "MessageCard",
|
||||
Context: "http://schema.org/extensions",
|
||||
Markdown: true,
|
||||
Text: message,
|
||||
}
|
||||
|
||||
if n.data && entry.Data != nil && len(entry.Data) > 0 {
|
||||
section := messageCardSection{
|
||||
Facts: make([]messageCardSectionFact, len(entry.Data)),
|
||||
Text: "",
|
||||
}
|
||||
|
||||
index := 0
|
||||
for k, v := range entry.Data {
|
||||
section.Facts[index] = messageCardSectionFact{
|
||||
Name: k,
|
||||
Value: fmt.Sprint(v),
|
||||
}
|
||||
index++
|
||||
}
|
||||
|
||||
webHookBody.Sections = []messageCardSection{section}
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(webHookBody)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to build JSON body for MSTeams notificattion: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.Post(n.webHookURL, "application/json", bytes.NewBuffer([]byte(jsonBody)))
|
||||
if err != nil {
|
||||
fmt.Println("Failed to send MSTeams notificattion: ", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
fmt.Println("Failed to send MSTeams notificattion. HTTP RESPONSE STATUS: ", resp.StatusCode)
|
||||
if resp.Body != nil {
|
||||
bodyBytes, err := ioutil.ReadAll(resp.Body)
|
||||
if err == nil {
|
||||
bodyString := string(bodyBytes)
|
||||
fmt.Println(bodyString)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type messageCard struct {
|
||||
CardType string `json:"@type"`
|
||||
Context string `json:"@context"`
|
||||
CorrelationID string `json:"correlationId,omitempty"`
|
||||
ThemeColor string `json:"themeColor,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Markdown bool `json:"markdown,bool"`
|
||||
Sections []messageCardSection `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
type messageCardSection struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ActivityTitle string `json:"activityTitle,omitempty"`
|
||||
ActivitySubtitle string `json:"activitySubtitle,omitempty"`
|
||||
ActivityImage string `json:"activityImage,omitempty"`
|
||||
ActivityText string `json:"activityText,omitempty"`
|
||||
HeroImage string `json:"heroImage,omitempty"`
|
||||
Facts []messageCardSectionFact `json:"facts,omitempty"`
|
||||
}
|
||||
|
||||
type messageCardSectionFact struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
return config.GetURL().String(), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"github.com/johntdyer/slackrus"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Notifier can send log output as notification to admins, with optional batching.
|
||||
|
|
@ -25,34 +27,96 @@ func NewNotifier(c *cobra.Command) *Notifier {
|
|||
}
|
||||
|
||||
acceptedLogLevels := slackrus.LevelThreshold(logLevel)
|
||||
// slackrus does not allow log level TRACE, even though it's an accepted log level for logrus
|
||||
if len(acceptedLogLevels) == 0 {
|
||||
log.Fatalf("Unsupported notification log level provided: %s", level)
|
||||
}
|
||||
|
||||
// Parse types and create notifiers.
|
||||
types, err := f.GetStringSlice("notifications")
|
||||
if err != nil {
|
||||
log.WithField("could not read notifications argument", log.Fields{"Error": err}).Fatal()
|
||||
}
|
||||
for _, t := range types {
|
||||
var tn ty.Notifier
|
||||
switch t {
|
||||
case emailType:
|
||||
tn = newEmailNotifier(c, acceptedLogLevels)
|
||||
case slackType:
|
||||
tn = newSlackNotifier(c, acceptedLogLevels)
|
||||
case msTeamsType:
|
||||
tn = newMsTeamsNotifier(c, acceptedLogLevels)
|
||||
case gotifyType:
|
||||
tn = newGotifyNotifier(c, acceptedLogLevels)
|
||||
case shoutrrrType:
|
||||
tn = newShoutrrrNotifier(c, acceptedLogLevels)
|
||||
default:
|
||||
log.Fatalf("Unknown notification type %q", t)
|
||||
}
|
||||
n.types = append(n.types, tn)
|
||||
}
|
||||
|
||||
n.types = n.getNotificationTypes(c, acceptedLogLevels, types)
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *Notifier) String() string {
|
||||
if len(n.types) < 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
sb := strings.Builder{}
|
||||
for _, notif := range n.types {
|
||||
for _, name := range notif.GetNames() {
|
||||
sb.WriteString(name)
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
}
|
||||
|
||||
if sb.Len() < 2 {
|
||||
// No notification services are configured, return early as the separator strip is not applicable
|
||||
return "none"
|
||||
}
|
||||
|
||||
names := sb.String()
|
||||
|
||||
// remove the last separator
|
||||
names = names[:len(names)-2]
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// getNotificationTypes produces an array of notifiers from a list of types
|
||||
func (n *Notifier) getNotificationTypes(cmd *cobra.Command, levels []log.Level, types []string) []ty.Notifier {
|
||||
output := make([]ty.Notifier, 0)
|
||||
|
||||
for _, t := range types {
|
||||
|
||||
if t == shoutrrrType {
|
||||
output = append(output, newShoutrrrNotifier(cmd, levels))
|
||||
continue
|
||||
}
|
||||
|
||||
var legacyNotifier ty.ConvertibleNotifier
|
||||
var err error
|
||||
|
||||
switch t {
|
||||
case emailType:
|
||||
legacyNotifier = newEmailNotifier(cmd, []log.Level{})
|
||||
case slackType:
|
||||
legacyNotifier = newSlackNotifier(cmd, []log.Level{})
|
||||
case msTeamsType:
|
||||
legacyNotifier = newMsTeamsNotifier(cmd, levels)
|
||||
case gotifyType:
|
||||
legacyNotifier = newGotifyNotifier(cmd, []log.Level{})
|
||||
default:
|
||||
log.Fatalf("Unknown notification type %q", t)
|
||||
// Not really needed, used for nil checking static analysis
|
||||
continue
|
||||
}
|
||||
|
||||
shoutrrrURL, err := legacyNotifier.GetURL()
|
||||
if err != nil {
|
||||
log.Fatal("failed to create notification config:", err)
|
||||
}
|
||||
|
||||
log.WithField("URL", shoutrrrURL).Trace("created Shoutrrr URL from legacy notifier")
|
||||
|
||||
notifier := newShoutrrrNotifierFromURL(
|
||||
cmd,
|
||||
shoutrrrURL,
|
||||
levels,
|
||||
)
|
||||
|
||||
output = append(output, notifier)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
// StartNotification starts a log batch. Notifications will be accumulated after this point and only sent when SendNotification() is called.
|
||||
func (n *Notifier) StartNotification() {
|
||||
for _, t := range n.types {
|
||||
|
|
@ -73,3 +137,20 @@ func (n *Notifier) Close() {
|
|||
t.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// GetTitle returns a common notification title with hostname appended
|
||||
func GetTitle() (title string) {
|
||||
title = "Watchtower updates"
|
||||
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
title += " on " + hostname
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ColorHex is the default notification color used for services that support it (formatted as a CSS hex string)
|
||||
const ColorHex = "#406170"
|
||||
|
||||
// ColorInt is the default notification color used for services that support it (as an int value)
|
||||
const ColorInt = 0x406170
|
||||
|
|
|
|||
223
pkg/notifications/notifier_test.go
Normal file
223
pkg/notifications/notifier_test.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
package notifications_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/containrrr/watchtower/cmd"
|
||||
"github.com/containrrr/watchtower/internal/flags"
|
||||
"github.com/containrrr/watchtower/pkg/notifications"
|
||||
"github.com/containrrr/watchtower/pkg/types"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestActions(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Notifier Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("notifications", func() {
|
||||
Describe("the notifier", func() {
|
||||
When("only empty notifier types are provided", func() {
|
||||
|
||||
command := cmd.NewRootCommand()
|
||||
flags.RegisterNotificationFlags(command)
|
||||
|
||||
err := command.ParseFlags([]string{
|
||||
"--notifications",
|
||||
"shoutrrr",
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
notif := notifications.NewNotifier(command)
|
||||
|
||||
Expect(notif.String()).To(Equal("none"))
|
||||
})
|
||||
})
|
||||
Describe("the slack notifier", func() {
|
||||
builderFn := notifications.NewSlackNotifier
|
||||
|
||||
When("passing a discord url to the slack notifier", func() {
|
||||
channel := "123456789"
|
||||
token := "abvsihdbau"
|
||||
color := notifications.ColorInt
|
||||
title := url.QueryEscape(notifications.GetTitle())
|
||||
expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&splitlines=Yes&title=%s&username=watchtower", token, channel, color, title)
|
||||
buildArgs := func(url string) []string {
|
||||
return []string{
|
||||
"--notifications",
|
||||
"slack",
|
||||
"--notification-slack-hook-url",
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
It("should return a discord url when using a hook url with the domain discord.com", func() {
|
||||
hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discord.com", channel, token)
|
||||
testURL(builderFn, buildArgs(hookURL), expected)
|
||||
})
|
||||
It("should return a discord url when using a hook url with the domain discordapp.com", func() {
|
||||
hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discordapp.com", channel, token)
|
||||
testURL(builderFn, buildArgs(hookURL), expected)
|
||||
})
|
||||
})
|
||||
When("converting a slack service config into a shoutrrr url", func() {
|
||||
|
||||
It("should return the expected URL", func() {
|
||||
|
||||
username := "containrrrbot"
|
||||
tokenA := "aaa"
|
||||
tokenB := "bbb"
|
||||
tokenC := "ccc"
|
||||
color := url.QueryEscape(notifications.ColorHex)
|
||||
title := url.QueryEscape(notifications.GetTitle())
|
||||
|
||||
hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
|
||||
expectedOutput := fmt.Sprintf("slack://%s@%s/%s/%s?color=%s&title=%s", username, tokenA, tokenB, tokenC, color, title)
|
||||
|
||||
args := []string{
|
||||
"--notification-slack-hook-url",
|
||||
hookURL,
|
||||
"--notification-slack-identifier",
|
||||
username,
|
||||
}
|
||||
|
||||
testURL(builderFn, args, expectedOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("the gotify notifier", func() {
|
||||
When("converting a gotify service config into a shoutrrr url", func() {
|
||||
builderFn := notifications.NewGotifyNotifier
|
||||
|
||||
It("should return the expected URL", func() {
|
||||
token := "aaa"
|
||||
host := "shoutrrr.local"
|
||||
title := url.QueryEscape(notifications.GetTitle())
|
||||
|
||||
expectedOutput := fmt.Sprintf("gotify://%s/%s?title=%s", host, token, title)
|
||||
|
||||
args := []string{
|
||||
"--notification-gotify-url",
|
||||
fmt.Sprintf("https://%s", host),
|
||||
"--notification-gotify-token",
|
||||
token,
|
||||
}
|
||||
|
||||
testURL(builderFn, args, expectedOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("the teams notifier", func() {
|
||||
When("converting a teams service config into a shoutrrr url", func() {
|
||||
builderFn := notifications.NewMsTeamsNotifier
|
||||
|
||||
It("should return the expected URL", func() {
|
||||
|
||||
tokenA := "11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc"
|
||||
tokenB := "33333333012222222222333333333344"
|
||||
tokenC := "44444444-4444-4444-8444-cccccccccccc"
|
||||
color := url.QueryEscape(notifications.ColorHex)
|
||||
title := url.QueryEscape(notifications.GetTitle())
|
||||
|
||||
hookURL := fmt.Sprintf("https://outlook.office.com/webhook/%s/IncomingWebhook/%s/%s", tokenA, tokenB, tokenC)
|
||||
expectedOutput := fmt.Sprintf("teams://%s/%s/%s?color=%s&title=%s", tokenA, tokenB, tokenC, color, title)
|
||||
|
||||
args := []string{
|
||||
"--notification-msteams-hook",
|
||||
hookURL,
|
||||
}
|
||||
|
||||
testURL(builderFn, args, expectedOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("the email notifier", func() {
|
||||
|
||||
builderFn := notifications.NewEmailNotifier
|
||||
|
||||
When("converting an email service config into a shoutrrr url", func() {
|
||||
It("should set the from address in the URL", func() {
|
||||
fromAddress := "lala@example.com"
|
||||
expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, "mail@example.com", "Plain")
|
||||
args := []string{
|
||||
"--notification-email-from",
|
||||
fromAddress,
|
||||
"--notification-email-to",
|
||||
"mail@example.com",
|
||||
"--notification-email-server-user",
|
||||
"containrrrbot",
|
||||
"--notification-email-server-password",
|
||||
"secret-password",
|
||||
"--notification-email-server",
|
||||
"mail.containrrr.dev",
|
||||
}
|
||||
testURL(builderFn, args, expectedOutput)
|
||||
})
|
||||
|
||||
It("should return the expected URL", func() {
|
||||
|
||||
fromAddress := "sender@example.com"
|
||||
toAddress := "receiver@example.com"
|
||||
expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, toAddress, "Plain")
|
||||
|
||||
args := []string{
|
||||
"--notification-email-from",
|
||||
fromAddress,
|
||||
"--notification-email-to",
|
||||
toAddress,
|
||||
"--notification-email-server-user",
|
||||
"containrrrbot",
|
||||
"--notification-email-server-password",
|
||||
"secret-password",
|
||||
"--notification-email-server",
|
||||
"mail.containrrr.dev",
|
||||
}
|
||||
|
||||
testURL(builderFn, args, expectedOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func buildExpectedURL(username string, password string, host string, port int, from string, to string, auth string) string {
|
||||
hostname, err := os.Hostname()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
subject := fmt.Sprintf("Watchtower updates on %s", hostname)
|
||||
|
||||
var template = "smtp://%s:%s@%s:%d/?auth=%s&fromaddress=%s&fromname=Watchtower&subject=%s&toaddresses=%s"
|
||||
return fmt.Sprintf(template,
|
||||
url.QueryEscape(username),
|
||||
url.QueryEscape(password),
|
||||
host, port, auth,
|
||||
url.QueryEscape(from),
|
||||
url.QueryEscape(subject),
|
||||
url.QueryEscape(to))
|
||||
}
|
||||
|
||||
type builderFn = func(c *cobra.Command, acceptedLogLevels []log.Level) types.ConvertibleNotifier
|
||||
|
||||
func testURL(builder builderFn, args []string, expectedURL string) {
|
||||
|
||||
command := cmd.NewRootCommand()
|
||||
flags.RegisterNotificationFlags(command)
|
||||
|
||||
err := command.ParseFlags(args)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
notifier := builder(command, []log.Level{})
|
||||
actualURL, err := notifier.GetURL()
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(actualURL).To(Equal(expectedURL))
|
||||
}
|
||||
|
|
@ -3,11 +3,12 @@ package notifications
|
|||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/containrrr/shoutrrr/pkg/types"
|
||||
stdlog "log"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/containrrr/shoutrrr"
|
||||
"github.com/containrrr/shoutrrr/pkg/types"
|
||||
t "github.com/containrrr/watchtower/pkg/types"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -33,11 +34,35 @@ type shoutrrrTypeNotifier struct {
|
|||
done chan bool
|
||||
}
|
||||
|
||||
func (n *shoutrrrTypeNotifier) GetNames() []string {
|
||||
names := make([]string, len(n.Urls))
|
||||
for i, u := range n.Urls {
|
||||
schemeEnd := strings.Index(u, ":")
|
||||
if schemeEnd <= 0 {
|
||||
names[i] = "invalid"
|
||||
continue
|
||||
}
|
||||
names[i] = u[:schemeEnd]
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
|
||||
flags := c.PersistentFlags()
|
||||
|
||||
urls, _ := flags.GetStringArray("notification-url")
|
||||
r, err := shoutrrr.CreateSender(urls...)
|
||||
tpl := getShoutrrrTemplate(c)
|
||||
return createSender(urls, acceptedLogLevels, tpl)
|
||||
}
|
||||
|
||||
func newShoutrrrNotifierFromURL(c *cobra.Command, url string, levels []log.Level) t.Notifier {
|
||||
tpl := getShoutrrrTemplate(c)
|
||||
return createSender([]string{url}, levels, tpl)
|
||||
}
|
||||
|
||||
func createSender(urls []string, levels []log.Level, template *template.Template) t.Notifier {
|
||||
|
||||
traceWriter := log.StandardLogger().WriterLevel(log.TraceLevel)
|
||||
r, err := shoutrrr.NewSender(stdlog.New(traceWriter, "Shoutrrr: ", 0), urls...)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error())
|
||||
}
|
||||
|
|
@ -45,10 +70,10 @@ func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Noti
|
|||
n := &shoutrrrTypeNotifier{
|
||||
Urls: urls,
|
||||
Router: r,
|
||||
logLevels: acceptedLogLevels,
|
||||
template: getShoutrrrTemplate(c),
|
||||
messages: make(chan string, 1),
|
||||
done: make(chan bool),
|
||||
logLevels: levels,
|
||||
template: template,
|
||||
}
|
||||
|
||||
log.AddHook(n)
|
||||
|
|
@ -74,54 +99,54 @@ func sendNotifications(n *shoutrrrTypeNotifier) {
|
|||
n.done <- true
|
||||
}
|
||||
|
||||
func (e *shoutrrrTypeNotifier) buildMessage(entries []*log.Entry) string {
|
||||
func (n *shoutrrrTypeNotifier) buildMessage(entries []*log.Entry) string {
|
||||
var body bytes.Buffer
|
||||
if err := e.template.Execute(&body, entries); err != nil {
|
||||
if err := n.template.Execute(&body, entries); err != nil {
|
||||
fmt.Printf("Failed to execute Shoutrrrr template: %s\n", err.Error())
|
||||
}
|
||||
|
||||
return body.String()
|
||||
}
|
||||
|
||||
func (e *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry) {
|
||||
msg := e.buildMessage(entries)
|
||||
e.messages <- msg
|
||||
func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry) {
|
||||
msg := n.buildMessage(entries)
|
||||
n.messages <- msg
|
||||
}
|
||||
|
||||
func (e *shoutrrrTypeNotifier) StartNotification() {
|
||||
if e.entries == nil {
|
||||
e.entries = make([]*log.Entry, 0, 10)
|
||||
func (n *shoutrrrTypeNotifier) StartNotification() {
|
||||
if n.entries == nil {
|
||||
n.entries = make([]*log.Entry, 0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *shoutrrrTypeNotifier) SendNotification() {
|
||||
if e.entries == nil || len(e.entries) <= 0 {
|
||||
func (n *shoutrrrTypeNotifier) SendNotification() {
|
||||
if n.entries == nil || len(n.entries) <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
e.sendEntries(e.entries)
|
||||
e.entries = nil
|
||||
n.sendEntries(n.entries)
|
||||
n.entries = nil
|
||||
}
|
||||
|
||||
func (e *shoutrrrTypeNotifier) Close() {
|
||||
close(e.messages)
|
||||
func (n *shoutrrrTypeNotifier) Close() {
|
||||
close(n.messages)
|
||||
|
||||
// Use fmt so it doesn't trigger another notification.
|
||||
fmt.Println("Waiting for the notification goroutine to finish")
|
||||
|
||||
_ = <-e.done
|
||||
_ = <-n.done
|
||||
}
|
||||
|
||||
func (e *shoutrrrTypeNotifier) Levels() []log.Level {
|
||||
return e.logLevels
|
||||
func (n *shoutrrrTypeNotifier) Levels() []log.Level {
|
||||
return n.logLevels
|
||||
}
|
||||
|
||||
func (e *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
|
||||
if e.entries != nil {
|
||||
e.entries = append(e.entries, entry)
|
||||
func (n *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
|
||||
if n.entries != nil {
|
||||
n.entries = append(n.entries, entry)
|
||||
} else {
|
||||
// Log output generated outside a cycle is sent immediately.
|
||||
e.sendEntries([]*log.Entry{entry})
|
||||
n.sendEntries([]*log.Entry{entry})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package notifications
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
shoutrrrDisco "github.com/containrrr/shoutrrr/pkg/services/discord"
|
||||
shoutrrrSlack "github.com/containrrr/shoutrrr/pkg/services/slack"
|
||||
t "github.com/containrrr/watchtower/pkg/types"
|
||||
"github.com/johntdyer/slackrus"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
|
@ -15,7 +19,12 @@ type slackTypeNotifier struct {
|
|||
slackrus.SlackrusHook
|
||||
}
|
||||
|
||||
func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
|
||||
// NewSlackNotifier is a factory function used to generate new instance of the slack notifier type
|
||||
func NewSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
|
||||
return newSlackNotifier(c, acceptedLogLevels)
|
||||
}
|
||||
|
||||
func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
|
||||
flags := c.PersistentFlags()
|
||||
|
||||
hookURL, _ := flags.GetString("notification-slack-hook-url")
|
||||
|
|
@ -34,13 +43,36 @@ func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie
|
|||
AcceptedLevels: acceptedLogLevels,
|
||||
},
|
||||
}
|
||||
|
||||
log.AddHook(n)
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *slackTypeNotifier) StartNotification() {}
|
||||
func (s *slackTypeNotifier) GetURL() (string, error) {
|
||||
trimmedURL := strings.TrimRight(s.HookURL, "/")
|
||||
trimmedURL = strings.TrimLeft(trimmedURL, "https://")
|
||||
parts := strings.Split(trimmedURL, "/")
|
||||
|
||||
func (s *slackTypeNotifier) SendNotification() {}
|
||||
if parts[0] == "discord.com" || parts[0] == "discordapp.com" {
|
||||
log.Debug("Detected a discord slack wrapper URL, using shoutrrr discord service")
|
||||
conf := &shoutrrrDisco.Config{
|
||||
Channel: parts[len(parts)-3],
|
||||
Token: parts[len(parts)-2],
|
||||
Color: ColorInt,
|
||||
Title: GetTitle(),
|
||||
SplitLines: true,
|
||||
Username: s.Username,
|
||||
}
|
||||
return conf.GetURL().String(), nil
|
||||
}
|
||||
|
||||
func (s *slackTypeNotifier) Close() {}
|
||||
rawTokens := strings.Replace(s.HookURL, "https://hooks.slack.com/services/", "", 1)
|
||||
tokens := strings.Split(rawTokens, "/")
|
||||
|
||||
conf := &shoutrrrSlack.Config{
|
||||
BotName: s.Username,
|
||||
Token: tokens,
|
||||
Color: ColorHex,
|
||||
Title: GetTitle(),
|
||||
}
|
||||
|
||||
return conf.GetURL().String(), nil
|
||||
}
|
||||
|
|
|
|||
192
pkg/registry/auth/auth.go
Normal file
192
pkg/registry/auth/auth.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/pkg/registry/helpers"
|
||||
"github.com/containrrr/watchtower/pkg/types"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChallengeHeader is the HTTP Header containing challenge instructions
|
||||
const ChallengeHeader = "WWW-Authenticate"
|
||||
|
||||
// GetToken fetches a token for the registry hosting the provided image
|
||||
func GetToken(container types.Container, registryAuth string) (string, error) {
|
||||
var err error
|
||||
var URL url.URL
|
||||
|
||||
if URL, err = GetChallengeURL(container.ImageName()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logrus.WithField("URL", URL.String()).Debug("Building challenge URL")
|
||||
|
||||
var req *http.Request
|
||||
if req, err = GetChallengeRequest(URL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
var res *http.Response
|
||||
if res, err = client.Do(req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
v := res.Header.Get(ChallengeHeader)
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"status": res.Status,
|
||||
"header": v,
|
||||
}).Debug("Got response to challenge request")
|
||||
|
||||
challenge := strings.ToLower(v)
|
||||
if strings.HasPrefix(challenge, "basic") {
|
||||
if registryAuth == "" {
|
||||
return "", fmt.Errorf("no credentials available")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Basic %s", registryAuth), nil
|
||||
}
|
||||
if strings.HasPrefix(challenge, "bearer") {
|
||||
return GetBearerHeader(challenge, container.ImageName(), err, registryAuth)
|
||||
}
|
||||
|
||||
return "", errors.New("unsupported challenge type from registry")
|
||||
}
|
||||
|
||||
// GetChallengeRequest creates a request for getting challenge instructions
|
||||
func GetChallengeRequest(URL url.URL) (*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", URL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", "Watchtower (Docker)")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetBearerHeader tries to fetch a bearer token from the registry based on the challenge instructions
|
||||
func GetBearerHeader(challenge string, img string, err error, registryAuth string) (string, error) {
|
||||
client := http.Client{}
|
||||
if strings.Contains(img, ":") {
|
||||
img = strings.Split(img, ":")[0]
|
||||
}
|
||||
authURL, err := GetAuthURL(challenge, img)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var r *http.Request
|
||||
if r, err = http.NewRequest("GET", authURL.String(), nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if registryAuth != "" {
|
||||
logrus.Debug("Credentials found.")
|
||||
logrus.Tracef("Credentials: %v", registryAuth)
|
||||
r.Header.Add("Authorization", fmt.Sprintf("Basic %s", registryAuth))
|
||||
} else {
|
||||
logrus.Debug("No credentials found.")
|
||||
}
|
||||
|
||||
var authResponse *http.Response
|
||||
if authResponse, err = client.Do(r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(authResponse.Body)
|
||||
tokenResponse := &types.TokenResponse{}
|
||||
|
||||
err = json.Unmarshal(body, tokenResponse)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Bearer %s", tokenResponse.Token), nil
|
||||
}
|
||||
|
||||
// GetAuthURL from the instructions in the challenge
|
||||
func GetAuthURL(challenge string, img string) (*url.URL, error) {
|
||||
loweredChallenge := strings.ToLower(challenge)
|
||||
raw := strings.TrimPrefix(loweredChallenge, "bearer")
|
||||
|
||||
pairs := strings.Split(raw, ",")
|
||||
values := make(map[string]string, len(pairs))
|
||||
|
||||
for _, pair := range pairs {
|
||||
trimmed := strings.Trim(pair, " ")
|
||||
kv := strings.Split(trimmed, "=")
|
||||
key := kv[0]
|
||||
val := strings.Trim(kv[1], "\"")
|
||||
values[key] = val
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"realm": values["realm"],
|
||||
"service": values["service"],
|
||||
}).Debug("Checking challenge header content")
|
||||
if values["realm"] == "" || values["service"] == "" {
|
||||
|
||||
return nil, fmt.Errorf("challenge header did not include all values needed to construct an auth url")
|
||||
}
|
||||
|
||||
authURL, _ := url.Parse(fmt.Sprintf("%s", values["realm"]))
|
||||
q := authURL.Query()
|
||||
q.Add("service", values["service"])
|
||||
|
||||
scopeImage := GetScopeFromImageName(img, values["service"])
|
||||
|
||||
scope := fmt.Sprintf("repository:%s:pull", scopeImage)
|
||||
logrus.WithFields(logrus.Fields{"scope": scope, "image": img}).Debug("Setting scope for auth token")
|
||||
q.Add("scope", scope)
|
||||
|
||||
authURL.RawQuery = q.Encode()
|
||||
return authURL, nil
|
||||
}
|
||||
|
||||
// GetScopeFromImageName normalizes an image name for use as scope during auth and head requests
|
||||
func GetScopeFromImageName(img, svc string) string {
|
||||
parts := strings.Split(img, "/")
|
||||
|
||||
if len(parts) > 2 {
|
||||
if strings.Contains(svc, "docker.io") {
|
||||
return fmt.Sprintf("%s/%s", parts[1], strings.Join(parts[2:], "/"))
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
if len(parts) == 2 {
|
||||
if strings.Contains(parts[0], "docker.io") {
|
||||
return fmt.Sprintf("library/%s", parts[1])
|
||||
}
|
||||
return strings.Replace(img, svc+"/", "", 1)
|
||||
}
|
||||
|
||||
if strings.Contains(svc, "docker.io") {
|
||||
return fmt.Sprintf("library/%s", parts[0])
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// GetChallengeURL creates a URL object based on the image info
|
||||
func GetChallengeURL(img string) (url.URL, error) {
|
||||
|
||||
normalizedNamed, _ := reference.ParseNormalizedNamed(img)
|
||||
host, err := helpers.NormalizeRegistry(normalizedNamed.String())
|
||||
if err != nil {
|
||||
return url.URL{}, err
|
||||
}
|
||||
|
||||
URL := url.URL{
|
||||
Scheme: "https",
|
||||
Host: host,
|
||||
Path: "/v2/",
|
||||
}
|
||||
return URL, nil
|
||||
}
|
||||
120
pkg/registry/auth/auth_test.go
Normal file
120
pkg/registry/auth/auth_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/internal/actions/mocks"
|
||||
"github.com/containrrr/watchtower/pkg/registry/auth"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
wtTypes "github.com/containrrr/watchtower/pkg/types"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestAuth(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Registry Auth Suite")
|
||||
}
|
||||
func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() {
|
||||
if credentials.Username == "" {
|
||||
return func() {
|
||||
Skip("Username missing. Skipping integration test")
|
||||
}
|
||||
} else if credentials.Password == "" {
|
||||
return func() {
|
||||
Skip("Password missing. Skipping integration test")
|
||||
}
|
||||
} else {
|
||||
return fn
|
||||
}
|
||||
}
|
||||
|
||||
var GHCRCredentials = &wtTypes.RegistryCredentials{
|
||||
Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"),
|
||||
Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"),
|
||||
}
|
||||
|
||||
var _ = Describe("the auth module", func() {
|
||||
mockId := "mock-id"
|
||||
mockName := "mock-container"
|
||||
mockImage := "ghcr.io/k6io/operator:latest"
|
||||
mockCreated := time.Now()
|
||||
mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547"
|
||||
|
||||
mockContainer := mocks.CreateMockContainerWithDigest(
|
||||
mockId,
|
||||
mockName,
|
||||
mockImage,
|
||||
mockCreated,
|
||||
mockDigest)
|
||||
|
||||
When("getting an auth url", func() {
|
||||
It("should parse the token from the response",
|
||||
SkipIfCredentialsEmpty(GHCRCredentials, func() {
|
||||
creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
|
||||
token, err := auth.GetToken(mockContainer, creds)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(token).NotTo(Equal(""))
|
||||
}),
|
||||
)
|
||||
|
||||
It("should create a valid auth url object based on the challenge header supplied", func() {
|
||||
input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"`
|
||||
expected := &url.URL{
|
||||
Host: "ghcr.io",
|
||||
Scheme: "https",
|
||||
Path: "/token",
|
||||
RawQuery: "scope=repository%3Acontainrrr%2Fwatchtower%3Apull&service=ghcr.io",
|
||||
}
|
||||
res, err := auth.GetAuthURL(input, "containrrr/watchtower")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res).To(Equal(expected))
|
||||
})
|
||||
It("should create a valid auth url object based on the challenge header supplied", func() {
|
||||
input := `bearer realm="https://ghcr.io/token"`
|
||||
res, err := auth.GetAuthURL(input, "containrrr/watchtower")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(res).To(BeNil())
|
||||
})
|
||||
})
|
||||
When("getting a challenge url", func() {
|
||||
It("should create a valid challenge url object based on the image ref supplied", func() {
|
||||
expected := url.URL{Host: "ghcr.io", Scheme: "https", Path: "/v2/"}
|
||||
Expect(auth.GetChallengeURL("ghcr.io/containrrr/watchtower:latest")).To(Equal(expected))
|
||||
})
|
||||
It("should assume dockerhub if the image ref is not fully qualified", func() {
|
||||
expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
|
||||
Expect(auth.GetChallengeURL("containrrr/watchtower:latest")).To(Equal(expected))
|
||||
})
|
||||
It("should convert legacy dockerhub hostnames to index.docker.io", func() {
|
||||
expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
|
||||
Expect(auth.GetChallengeURL("docker.io/containrrr/watchtower:latest")).To(Equal(expected))
|
||||
Expect(auth.GetChallengeURL("registry-1.docker.io/containrrr/watchtower:latest")).To(Equal(expected))
|
||||
})
|
||||
})
|
||||
When("getting the auth scope from an image name", func() {
|
||||
It("should prepend official dockerhub images with \"library/\"", func() {
|
||||
Expect(auth.GetScopeFromImageName("docker.io/registry", "index.docker.io")).To(Equal("library/registry"))
|
||||
Expect(auth.GetScopeFromImageName("docker.io/registry", "docker.io")).To(Equal("library/registry"))
|
||||
|
||||
Expect(auth.GetScopeFromImageName("registry", "index.docker.io")).To(Equal("library/registry"))
|
||||
Expect(auth.GetScopeFromImageName("watchtower", "registry-1.docker.io")).To(Equal("library/watchtower"))
|
||||
|
||||
})
|
||||
It("should not include vanity hosts\"", func() {
|
||||
Expect(auth.GetScopeFromImageName("docker.io/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
|
||||
Expect(auth.GetScopeFromImageName("index.docker.io/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
|
||||
})
|
||||
It("should not destroy three segment image names\"", func() {
|
||||
Expect(auth.GetScopeFromImageName("piksel/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
|
||||
Expect(auth.GetScopeFromImageName("piksel/containrrr/watchtower", "ghcr.io")).To(Equal("piksel/containrrr/watchtower"))
|
||||
})
|
||||
It("should not add \"library/\" for one segment image names if they're not on dockerhub", func() {
|
||||
Expect(auth.GetScopeFromImageName("ghcr.io/watchtower", "ghcr.io")).To(Equal("watchtower"))
|
||||
Expect(auth.GetScopeFromImageName("watchtower", "ghcr.io")).To(Equal("watchtower"))
|
||||
})
|
||||
})
|
||||
})
|
||||
117
pkg/registry/digest/digest.go
Normal file
117
pkg/registry/digest/digest.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package digest
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/pkg/registry/auth"
|
||||
"github.com/containrrr/watchtower/pkg/registry/manifest"
|
||||
"github.com/containrrr/watchtower/pkg/types"
|
||||
"github.com/sirupsen/logrus"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ContentDigestHeader is the key for the key-value pair containing the digest header
|
||||
const ContentDigestHeader = "Docker-Content-Digest"
|
||||
|
||||
// CompareDigest ...
|
||||
func CompareDigest(container types.Container, registryAuth string) (bool, error) {
|
||||
var digest string
|
||||
|
||||
registryAuth = TransformAuth(registryAuth)
|
||||
token, err := auth.GetToken(container, registryAuth)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
digestURL, err := manifest.BuildManifestURL(container)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if digest, err = GetDigest(digestURL, token); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
logrus.WithField("remote", digest).Debug("Found a remote digest to compare with")
|
||||
|
||||
for _, dig := range container.ImageInfo().RepoDigests {
|
||||
localDigest := strings.Split(dig, "@")[1]
|
||||
fields := logrus.Fields{"local": localDigest, "remote": digest}
|
||||
logrus.WithFields(fields).Debug("Comparing")
|
||||
|
||||
if localDigest == digest {
|
||||
logrus.Debug("Found a match")
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// TransformAuth from a base64 encoded json object to base64 encoded string
|
||||
func TransformAuth(registryAuth string) string {
|
||||
b, _ := base64.StdEncoding.DecodeString(registryAuth)
|
||||
credentials := &types.RegistryCredentials{}
|
||||
_ = json.Unmarshal(b, credentials)
|
||||
|
||||
if credentials.Username != "" && credentials.Password != "" {
|
||||
ba := []byte(fmt.Sprintf("%s:%s", credentials.Username, credentials.Password))
|
||||
registryAuth = base64.StdEncoding.EncodeToString(ba)
|
||||
}
|
||||
|
||||
return registryAuth
|
||||
}
|
||||
|
||||
// GetDigest from registry using a HEAD request to prevent rate limiting
|
||||
func GetDigest(url string, token string) (string, error) {
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: tr}
|
||||
|
||||
req, _ := http.NewRequest("HEAD", url, nil)
|
||||
|
||||
if token != "" {
|
||||
logrus.WithField("token", token).Trace("Setting request token")
|
||||
} else {
|
||||
return "", errors.New("could not fetch token")
|
||||
}
|
||||
|
||||
req.Header.Add("Authorization", token)
|
||||
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.list.v2+json")
|
||||
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v1+json")
|
||||
|
||||
logrus.WithField("url", url).Debug("Doing a HEAD request to fetch a digest")
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
wwwAuthHeader := res.Header.Get("www-authenticate")
|
||||
if wwwAuthHeader == "" {
|
||||
wwwAuthHeader = "not present"
|
||||
}
|
||||
return "", fmt.Errorf("registry responded to head request with %q, auth: %q", res.Status, wwwAuthHeader)
|
||||
}
|
||||
return res.Header.Get(ContentDigestHeader), nil
|
||||
}
|
||||
87
pkg/registry/digest/digest_test.go
Normal file
87
pkg/registry/digest/digest_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package digest_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/internal/actions/mocks"
|
||||
"github.com/containrrr/watchtower/pkg/registry/digest"
|
||||
wtTypes "github.com/containrrr/watchtower/pkg/types"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDigest(t *testing.T) {
|
||||
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(GinkgoT(), "Digest Suite")
|
||||
}
|
||||
|
||||
var DockerHubCredentials = &wtTypes.RegistryCredentials{
|
||||
Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_USERNAME"),
|
||||
Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_PASSWORD"),
|
||||
}
|
||||
var GHCRCredentials = &wtTypes.RegistryCredentials{
|
||||
Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"),
|
||||
Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"),
|
||||
}
|
||||
|
||||
func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() {
|
||||
if credentials.Username == "" {
|
||||
return func() {
|
||||
Skip("Username missing. Skipping integration test")
|
||||
}
|
||||
} else if credentials.Password == "" {
|
||||
return func() {
|
||||
Skip("Password missing. Skipping integration test")
|
||||
}
|
||||
} else {
|
||||
return fn
|
||||
}
|
||||
}
|
||||
|
||||
var _ = Describe("Digests", func() {
|
||||
mockId := "mock-id"
|
||||
mockName := "mock-container"
|
||||
mockImage := "ghcr.io/k6io/operator:latest"
|
||||
mockCreated := time.Now()
|
||||
mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547"
|
||||
|
||||
mockContainer := mocks.CreateMockContainerWithDigest(
|
||||
mockId,
|
||||
mockName,
|
||||
mockImage,
|
||||
mockCreated,
|
||||
mockDigest)
|
||||
|
||||
When("a digest comparison is done", func() {
|
||||
It("should return true if digests match",
|
||||
SkipIfCredentialsEmpty(GHCRCredentials, func() {
|
||||
creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
|
||||
matches, err := digest.CompareDigest(mockContainer, creds)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(matches).To(Equal(true))
|
||||
}),
|
||||
)
|
||||
|
||||
It("should return false if digests differ", func() {
|
||||
|
||||
})
|
||||
It("should return an error if the registry isn't available", func() {
|
||||
|
||||
})
|
||||
})
|
||||
When("using different registries", func() {
|
||||
It("should work with DockerHub",
|
||||
SkipIfCredentialsEmpty(DockerHubCredentials, func() {
|
||||
fmt.Println(DockerHubCredentials != nil) // to avoid crying linters
|
||||
}),
|
||||
)
|
||||
It("should work with GitHub Container Registry",
|
||||
SkipIfCredentialsEmpty(GHCRCredentials, func() {
|
||||
fmt.Println(GHCRCredentials != nil) // to avoid crying linters
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
36
pkg/registry/helpers/helpers.go
Normal file
36
pkg/registry/helpers/helpers.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
url2 "net/url"
|
||||
)
|
||||
|
||||
// ConvertToHostname strips a url from everything but the hostname part
|
||||
func ConvertToHostname(url string) (string, string, error) {
|
||||
urlWithSchema := fmt.Sprintf("x://%s", url)
|
||||
u, err := url2.Parse(urlWithSchema)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
hostName := u.Hostname()
|
||||
port := u.Port()
|
||||
|
||||
return hostName, port, err
|
||||
}
|
||||
|
||||
// NormalizeRegistry makes sure variations of DockerHubs registry
|
||||
func NormalizeRegistry(registry string) (string, error) {
|
||||
hostName, port, err := ConvertToHostname(registry)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if hostName == "registry-1.docker.io" || hostName == "docker.io" {
|
||||
hostName = "index.docker.io"
|
||||
}
|
||||
|
||||
if port != "" {
|
||||
return fmt.Sprintf("%s:%s", hostName, port), nil
|
||||
}
|
||||
return hostName, nil
|
||||
}
|
||||
31
pkg/registry/helpers/helpers_test.go
Normal file
31
pkg/registry/helpers/helpers_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package helpers
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHelpers(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Helper Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("the helpers", func() {
|
||||
|
||||
When("converting an url to a hostname", func() {
|
||||
It("should return docker.io given docker.io/containrrr/watchtower:latest", func() {
|
||||
host, port, err := ConvertToHostname("docker.io/containrrr/watchtower:latest")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(host).To(Equal("docker.io"))
|
||||
Expect(port).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
When("normalizing the registry information", func() {
|
||||
It("should return index.docker.io given docker.io", func() {
|
||||
out, err := NormalizeRegistry("docker.io/containrrr/watchtower:latest")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(out).To(Equal("index.docker.io"))
|
||||
})
|
||||
})
|
||||
})
|
||||
67
pkg/registry/manifest/manifest.go
Normal file
67
pkg/registry/manifest/manifest.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/pkg/registry/auth"
|
||||
"github.com/containrrr/watchtower/pkg/registry/helpers"
|
||||
"github.com/containrrr/watchtower/pkg/types"
|
||||
ref "github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
url2 "net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildManifestURL from raw image data
|
||||
func BuildManifestURL(container types.Container) (string, error) {
|
||||
|
||||
normalizedName, err := ref.ParseNormalizedNamed(container.ImageName())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
host, err := helpers.NormalizeRegistry(normalizedName.String())
|
||||
img, tag := ExtractImageAndTag(strings.TrimPrefix(container.ImageName(), host+"/"))
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"image": img,
|
||||
"tag": tag,
|
||||
"normalized": normalizedName,
|
||||
"host": host,
|
||||
}).Debug("Parsing image ref")
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
img = auth.GetScopeFromImageName(img, host)
|
||||
|
||||
if !strings.Contains(img, "/") {
|
||||
img = "library/" + img
|
||||
}
|
||||
url := url2.URL{
|
||||
Scheme: "https",
|
||||
Host: host,
|
||||
Path: fmt.Sprintf("/v2/%s/manifests/%s", img, tag),
|
||||
}
|
||||
return url.String(), nil
|
||||
}
|
||||
|
||||
// ExtractImageAndTag from a concatenated string
|
||||
func ExtractImageAndTag(imageName string) (string, string) {
|
||||
var img string
|
||||
var tag string
|
||||
|
||||
if strings.Contains(imageName, ":") {
|
||||
parts := strings.Split(imageName, ":")
|
||||
if len(parts) > 2 {
|
||||
img = parts[0]
|
||||
tag = strings.Join(parts[1:], ":")
|
||||
} else {
|
||||
img = parts[0]
|
||||
tag = parts[1]
|
||||
}
|
||||
} else {
|
||||
img = imageName
|
||||
tag = "latest"
|
||||
}
|
||||
return img, tag
|
||||
}
|
||||
75
pkg/registry/manifest/manifest_test.go
Normal file
75
pkg/registry/manifest/manifest_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package manifest_test
|
||||
|
||||
import (
|
||||
"github.com/containrrr/watchtower/internal/actions/mocks"
|
||||
"github.com/containrrr/watchtower/pkg/registry/manifest"
|
||||
apiTypes "github.com/docker/docker/api/types"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManifest(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Manifest Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("the manifest module", func() {
|
||||
mockId := "mock-id"
|
||||
mockName := "mock-container"
|
||||
mockCreated := time.Now()
|
||||
|
||||
When("building a manifest url", func() {
|
||||
It("should return a valid url given a fully qualified image", func() {
|
||||
expected := "https://ghcr.io/v2/containrrr/watchtower/manifests/latest"
|
||||
imageInfo := apiTypes.ImageInspect{
|
||||
RepoTags: []string{
|
||||
"ghcr.io/k6io/operator:latest",
|
||||
},
|
||||
}
|
||||
mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "ghcr.io/containrrr/watchtower:latest", mockCreated, imageInfo)
|
||||
res, err := manifest.BuildManifestURL(mock)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res).To(Equal(expected))
|
||||
})
|
||||
It("should assume dockerhub for non-qualified images", func() {
|
||||
expected := "https://index.docker.io/v2/containrrr/watchtower/manifests/latest"
|
||||
imageInfo := apiTypes.ImageInspect{
|
||||
RepoTags: []string{
|
||||
"containrrr/watchtower:latest",
|
||||
},
|
||||
}
|
||||
|
||||
mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "containrrr/watchtower:latest", mockCreated, imageInfo)
|
||||
res, err := manifest.BuildManifestURL(mock)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res).To(Equal(expected))
|
||||
})
|
||||
It("should assume latest for images that lack an explicit tag", func() {
|
||||
expected := "https://index.docker.io/v2/containrrr/watchtower/manifests/latest"
|
||||
imageInfo := apiTypes.ImageInspect{
|
||||
|
||||
RepoTags: []string{
|
||||
"containrrr/watchtower",
|
||||
},
|
||||
}
|
||||
|
||||
mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "containrrr/watchtower", mockCreated, imageInfo)
|
||||
|
||||
res, err := manifest.BuildManifestURL(mock)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res).To(Equal(expected))
|
||||
})
|
||||
It("should combine the tag name and digest pinning into one digest, given multiple colons", func() {
|
||||
in := "containrrr/watchtower:latest@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7"
|
||||
image, tag := "containrrr/watchtower", "latest@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7"
|
||||
|
||||
imageOut, tagOut := manifest.ExtractImageAndTag(in)
|
||||
|
||||
Expect(imageOut).To(Equal(image))
|
||||
Expect(tagOut).To(Equal(tag))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package registry
|
||||
|
||||
import (
|
||||
"github.com/containrrr/watchtower/pkg/registry/helpers"
|
||||
watchtowerTypes "github.com/containrrr/watchtower/pkg/types"
|
||||
ref "github.com/docker/distribution/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
|
@ -31,3 +34,26 @@ func DefaultAuthHandler() (string, error) {
|
|||
log.Debug("Authentication request was rejected. Trying again without authentication")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// WarnOnAPIConsumption will return true if the registry is known-expected
|
||||
// to respond well to HTTP HEAD in checking the container digest -- or if there
|
||||
// are problems parsing the container hostname.
|
||||
// Will return false if behavior for container is unknown.
|
||||
func WarnOnAPIConsumption(container watchtowerTypes.Container) bool {
|
||||
|
||||
normalizedName, err := ref.ParseNormalizedNamed(container.ImageName())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
containerHost, err := helpers.NormalizeRegistry(normalizedName.String())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if containerHost == "index.docker.io" || containerHost == "ghcr.io" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
13
pkg/registry/registry_suite_test.go
Normal file
13
pkg/registry/registry_suite_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package registry_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Registry Suite")
|
||||
}
|
||||
45
pkg/registry/registry_test.go
Normal file
45
pkg/registry/registry_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package registry_test
|
||||
|
||||
import (
|
||||
"github.com/containrrr/watchtower/internal/actions/mocks"
|
||||
unit "github.com/containrrr/watchtower/pkg/registry"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"time"
|
||||
)
|
||||
|
||||
var _ = Describe("Registry", func() {
|
||||
Describe("WarnOnAPIConsumption", func() {
|
||||
When("Given a container with an image from ghcr.io", func() {
|
||||
It("should want to warn", func() {
|
||||
Expect(testContainerWithImage("ghcr.io/containrrr/watchtower")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
When("Given a container with an image implicitly from dockerhub", func() {
|
||||
It("should want to warn", func() {
|
||||
Expect(testContainerWithImage("docker:latest")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
When("Given a container with an image explicitly from dockerhub", func() {
|
||||
It("should want to warn", func() {
|
||||
Expect(testContainerWithImage("registry-1.docker.io/docker:latest")).To(BeTrue())
|
||||
Expect(testContainerWithImage("index.docker.io/docker:latest")).To(BeTrue())
|
||||
Expect(testContainerWithImage("docker.io/docker:latest")).To(BeTrue())
|
||||
})
|
||||
|
||||
})
|
||||
When("Given a container with an image from some other registry", func() {
|
||||
It("should not want to warn", func() {
|
||||
Expect(testContainerWithImage("docker.fsf.org/docker:latest")).To(BeFalse())
|
||||
Expect(testContainerWithImage("altavista.com/docker:latest")).To(BeFalse())
|
||||
Expect(testContainerWithImage("gitlab.com/docker:latest")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func testContainerWithImage(imageName string) bool {
|
||||
container := mocks.CreateMockContainer("", "", imageName, time.Now())
|
||||
return unit.WarnOnAPIConsumption(container)
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ func EncodedConfigAuth(ref string) (string, error) {
|
|||
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)
|
||||
log.WithField("config_file", configFile.Filename).Debugf("No credentials for %s found", server)
|
||||
return "", nil
|
||||
}
|
||||
log.Debugf("Loaded auth credentials for user %s, on registry %s, from file %s", auth.Username, ref, configFile.Filename)
|
||||
|
|
|
|||
|
|
@ -1,59 +1,65 @@
|
|||
package registry
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodedEnvAuth_ShouldReturnAnErrorIfRepoEnvsAreUnset(t *testing.T) {
|
||||
os.Unsetenv("REPO_USER")
|
||||
os.Unsetenv("REPO_PASS")
|
||||
_, err := EncodedEnvAuth("")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
func TestEncodedEnvAuth_ShouldReturnAuthHashIfRepoEnvsAreSet(t *testing.T) {
|
||||
expectedHash := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
|
||||
var _ = Describe("Testing with Ginkgo", func() {
|
||||
It("encoded env auth_ should return an error if repo envs are unset", func() {
|
||||
_ = os.Unsetenv("REPO_USER")
|
||||
_ = os.Unsetenv("REPO_PASS")
|
||||
|
||||
os.Setenv("REPO_USER", "containrrr-user")
|
||||
os.Setenv("REPO_PASS", "containrrr-pass")
|
||||
config, _ := EncodedEnvAuth("")
|
||||
_, err := EncodedEnvAuth("")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
It("encoded env auth_ should return auth hash if repo envs are set", func() {
|
||||
var err error
|
||||
expectedHash := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
|
||||
|
||||
assert.Equal(t, config, expectedHash)
|
||||
}
|
||||
err = os.Setenv("REPO_USER", "containrrr-user")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
func TestEncodedConfigAuth_ShouldReturnAnErrorIfFileIsNotPresent(t *testing.T) {
|
||||
os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
|
||||
_, err := EncodedConfigAuth("")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
err = os.Setenv("REPO_PASS", "containrrr-pass")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
/*
|
||||
* TODO:
|
||||
* This part only confirms that it still works in the same way as it did
|
||||
* with the old version of the docker api client sdk. I'd say that
|
||||
* ParseServerAddress likely needs to be elaborated a bit to default to
|
||||
* dockerhub in case no server address was provided.
|
||||
*
|
||||
* ++ @simskij, 2019-04-04
|
||||
*/
|
||||
config, err := EncodedEnvAuth("")
|
||||
Expect(config).To(Equal(expectedHash))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
It("encoded config auth_ should return an error if file is not present", func() {
|
||||
var err error
|
||||
|
||||
func TestParseServerAddress_ShouldReturnErrorIfPassedEmptyString(t *testing.T) {
|
||||
_, err := ParseServerAddress("")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
err = os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
func TestParseServerAddress_ShouldReturnTheRepoNameIfPassedAFullyQualifiedImageName(t *testing.T) {
|
||||
val, _ := ParseServerAddress("github.com/containrrrr/config")
|
||||
assert.Equal(t, val, "github.com")
|
||||
}
|
||||
_, err = EncodedConfigAuth("")
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
func TestParseServerAddress_ShouldReturnTheOrganizationPartIfPassedAnImageNameMissingServerName(t *testing.T) {
|
||||
val, _ := ParseServerAddress("containrrr/config")
|
||||
assert.Equal(t, val, "containrrr")
|
||||
}
|
||||
})
|
||||
/*
|
||||
* TODO:
|
||||
* This part only confirms that it still works in the same way as it did
|
||||
* with the old version of the docker api client sdk. I'd say that
|
||||
* ParseServerAddress likely needs to be elaborated a bit to default to
|
||||
* dockerhub in case no server address was provided.
|
||||
*
|
||||
* ++ @simskij, 2019-04-04
|
||||
*/
|
||||
It("parse server address_ should return error if passed empty string", func() {
|
||||
|
||||
func TestParseServerAddress_ShouldReturnTheServerNameIfPassedAFullyQualifiedImageName(t *testing.T) {
|
||||
val, _ := ParseServerAddress("github.com/containrrrr/config")
|
||||
assert.Equal(t, val, "github.com")
|
||||
}
|
||||
_, err := ParseServerAddress("")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
It("parse server address_ should return the organization part if passed an image name missing server name", func() {
|
||||
|
||||
val, _ := ParseServerAddress("containrrr/config")
|
||||
Expect(val).To(Equal("containrrr"))
|
||||
})
|
||||
It("parse server address_ should return the server name if passed a fully qualified image name", func() {
|
||||
|
||||
val, _ := ParseServerAddress("github.com/containrrrr/config")
|
||||
Expect(val).To(Equal("github.com"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
26
pkg/types/container.go
Normal file
26
pkg/types/container.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package types
|
||||
|
||||
import "github.com/docker/docker/api/types"
|
||||
|
||||
// Container is a docker container running an image
|
||||
type Container interface {
|
||||
ContainerInfo() *types.ContainerJSON
|
||||
ID() string
|
||||
IsRunning() bool
|
||||
Name() string
|
||||
ImageID() string
|
||||
ImageName() string
|
||||
Enabled() (bool, bool)
|
||||
IsMonitorOnly() bool
|
||||
Scope() (string, bool)
|
||||
Links() []string
|
||||
ToRestart() bool
|
||||
IsWatchtower() bool
|
||||
StopSignal() string
|
||||
HasImageInfo() bool
|
||||
ImageInfo() *types.ImageInspect
|
||||
GetLifecyclePreCheckCommand() string
|
||||
GetLifecyclePostCheckCommand() string
|
||||
GetLifecyclePreUpdateCommand() string
|
||||
GetLifecyclePostUpdateCommand() string
|
||||
}
|
||||
6
pkg/types/convertible_notifier.go
Normal file
6
pkg/types/convertible_notifier.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package types
|
||||
|
||||
// ConvertibleNotifier is a notifier capable of creating a shoutrrr URL
|
||||
type ConvertibleNotifier interface {
|
||||
GetURL() (string, error)
|
||||
}
|
||||
|
|
@ -4,5 +4,6 @@ package types
|
|||
type Notifier interface {
|
||||
StartNotification()
|
||||
SendNotification()
|
||||
GetNames() []string
|
||||
Close()
|
||||
}
|
||||
|
|
|
|||
7
pkg/types/registry_credentials.go
Normal file
7
pkg/types/registry_credentials.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package types
|
||||
|
||||
// RegistryCredentials is a credential pair used for basic auth
|
||||
type RegistryCredentials struct {
|
||||
Username string
|
||||
Password string // usually a token rather than an actual password
|
||||
}
|
||||
6
pkg/types/token_response.go
Normal file
6
pkg/types/token_response.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package types
|
||||
|
||||
// TokenResponse is returned by the registry on successful authentication
|
||||
type TokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue