mirror of
https://github.com/containrrr/watchtower.git
synced 2026-02-26 17:04:08 +01:00
Merge 4b494ded86 into c16ac967c5
This commit is contained in:
commit
beba45c17b
40 changed files with 3951 additions and 33 deletions
|
|
@ -23,6 +23,22 @@ func New(token string) *API {
|
|||
}
|
||||
}
|
||||
|
||||
// EnableCors is a middleware that enables CORS for the API
|
||||
func (api *API) EnableCors(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Add("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
w.Header().Add("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
http.Error(w, "No Content", http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
fn(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
@ -40,7 +56,7 @@ func (api *API) RequireToken(fn http.HandlerFunc) http.HandlerFunc {
|
|||
// 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))
|
||||
http.HandleFunc(path, api.EnableCors(api.RequireToken(fn)))
|
||||
}
|
||||
|
||||
// RegisterHandler is a wrapper around http.Handler that also sets the flag used to determine whether to launch the API
|
||||
|
|
|
|||
105
pkg/api/check/check.go
Normal file
105
pkg/api/check/check.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package check
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/containrrr/watchtower/pkg/container"
|
||||
"github.com/containrrr/watchtower/pkg/types"
|
||||
)
|
||||
|
||||
// Handler is an HTTP handle for serving check data
|
||||
type Handler struct {
|
||||
Path string
|
||||
Client container.Client
|
||||
}
|
||||
|
||||
type checkRequest struct {
|
||||
ContainerID string
|
||||
}
|
||||
|
||||
type checkResponse struct {
|
||||
ContainerID string
|
||||
HasUpdate bool
|
||||
NewVersion string
|
||||
NewVersionCreated string
|
||||
}
|
||||
|
||||
// New is a factory function creating a new List instance
|
||||
func New(client container.Client) *Handler {
|
||||
return &Handler{
|
||||
Path: "/v1/check",
|
||||
Client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePost is the actual http.HandlePost function doing all the heavy lifting
|
||||
func (handle *Handler) HandlePost(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
log.Info("Calling Check API with unsupported method")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("Check for update triggered by HTTP API request.")
|
||||
|
||||
var request checkRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&request)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
client := handle.Client
|
||||
container, err := client.GetContainer(types.ContainerID(request.ContainerID))
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
hasUpdate := false
|
||||
newVersion := ""
|
||||
newVersionCreated := ""
|
||||
|
||||
matches, err := client.ContainerDigestMatchesWithRegistry(container)
|
||||
hasUpdate = !matches
|
||||
|
||||
if err != nil {
|
||||
stale, newestImage, created, err := client.IsContainerStale(container)
|
||||
hasUpdate = stale
|
||||
newVersion = newestImage.ShortID()
|
||||
newVersionCreated = created
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data := checkResponse{
|
||||
ContainerID: request.ContainerID,
|
||||
HasUpdate: hasUpdate,
|
||||
NewVersion: newVersion,
|
||||
NewVersionCreated: newVersionCreated,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Fatalf("Error happened in JSON marshal. Err: %s", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonData)
|
||||
}
|
||||
81
pkg/api/list/list.go
Normal file
81
pkg/api/list/list.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/containrrr/watchtower/pkg/container"
|
||||
filters "github.com/containrrr/watchtower/pkg/filters"
|
||||
)
|
||||
|
||||
// Handler is an HTTP handle for serving list data
|
||||
type Handler struct {
|
||||
Path string
|
||||
Client container.Client
|
||||
}
|
||||
|
||||
type containerListEntry struct {
|
||||
ContainerID string
|
||||
ContainerName string
|
||||
ImageName string
|
||||
ImageNameShort string
|
||||
ImageVersion string
|
||||
ImageCreatedDate string
|
||||
}
|
||||
|
||||
type listResponse struct {
|
||||
Containers []containerListEntry
|
||||
}
|
||||
|
||||
// New is a factory function creating a new List instance
|
||||
func New(client container.Client) *Handler {
|
||||
return &Handler{
|
||||
Path: "/v1/list",
|
||||
Client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGet is the actual http.HandleGet function doing all the heavy lifting
|
||||
func (handle *Handler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
log.Info("Calling List API with unsupported method")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("List containers triggered by HTTP API request.")
|
||||
|
||||
client := handle.Client
|
||||
filter := filters.NoFilter
|
||||
containers, err := client.ListContainers(filter)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
data := listResponse{Containers: []containerListEntry{}}
|
||||
|
||||
for _, c := range containers {
|
||||
data.Containers = append(data.Containers, containerListEntry{
|
||||
ContainerID: c.ID().ShortID(),
|
||||
ContainerName: c.Name()[1:],
|
||||
ImageName: c.ImageName(),
|
||||
ImageNameShort: strings.Split(c.ImageName(), ":")[0],
|
||||
ImageCreatedDate: c.ImageInfo().Created,
|
||||
ImageVersion: c.ImageID().ShortID(),
|
||||
})
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Fatalf("Error happened in JSON marshal. Err: %s", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonData)
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ var (
|
|||
)
|
||||
|
||||
// New is a factory function creating a new Handler instance
|
||||
func New(updateFn func(images []string), updateLock chan bool) *Handler {
|
||||
func New(updateImages func(images []string), updateContainers func(containerNames []string), updateLock chan bool) *Handler {
|
||||
if updateLock != nil {
|
||||
lock = updateLock
|
||||
} else {
|
||||
|
|
@ -23,15 +23,17 @@ func New(updateFn func(images []string), updateLock chan bool) *Handler {
|
|||
}
|
||||
|
||||
return &Handler{
|
||||
fn: updateFn,
|
||||
Path: "/v1/update",
|
||||
updateImages: updateImages,
|
||||
updateContainers: updateContainers,
|
||||
Path: "/v1/update",
|
||||
}
|
||||
}
|
||||
|
||||
// Handler is an API handler used for triggering container update scans
|
||||
type Handler struct {
|
||||
fn func(images []string)
|
||||
Path string
|
||||
updateImages func(images []string)
|
||||
updateContainers func(containerNames []string)
|
||||
Path string
|
||||
}
|
||||
|
||||
// Handle is the actual http.Handle function doing all the heavy lifting
|
||||
|
|
@ -55,15 +57,30 @@ func (handle *Handler) Handle(w http.ResponseWriter, r *http.Request) {
|
|||
images = nil
|
||||
}
|
||||
|
||||
var containers []string
|
||||
containerQueries, found := r.URL.Query()["container"]
|
||||
if found {
|
||||
for _, container := range containerQueries {
|
||||
containers = append(containers, strings.Split(container, ",")...)
|
||||
}
|
||||
|
||||
} else {
|
||||
containers = nil
|
||||
}
|
||||
|
||||
if len(images) > 0 {
|
||||
chanValue := <-lock
|
||||
defer func() { lock <- chanValue }()
|
||||
handle.fn(images)
|
||||
handle.updateImages(images)
|
||||
} else if len(containers) > 0 {
|
||||
chanValue := <-lock
|
||||
defer func() { lock <- chanValue }()
|
||||
handle.updateContainers(containers)
|
||||
} else {
|
||||
select {
|
||||
case chanValue := <-lock:
|
||||
defer func() { lock <- chanValue }()
|
||||
handle.fn(images)
|
||||
handle.updateImages(images)
|
||||
default:
|
||||
log.Debug("Skipped. Another update already running.")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,18 +30,19 @@ type Client interface {
|
|||
StopContainer(Container, time.Duration) error
|
||||
StartContainer(Container) (t.ContainerID, error)
|
||||
RenameContainer(Container, string) error
|
||||
IsContainerStale(Container) (stale bool, latestImage t.ImageID, err error)
|
||||
IsContainerStale(Container) (stale bool, latestImage t.ImageID, created string, err error)
|
||||
ExecuteCommand(containerID t.ContainerID, command string, timeout int) (SkipUpdate bool, err error)
|
||||
RemoveImageByID(t.ImageID) error
|
||||
WarnOnHeadPullFailed(container Container) bool
|
||||
ContainerDigestMatchesWithRegistry(container Container) (matches bool, err error)
|
||||
}
|
||||
|
||||
// NewClient returns a new Client instance which can be used to interact with
|
||||
// the Docker API.
|
||||
// The client reads its configuration from the following environment variables:
|
||||
// * 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
|
||||
// - 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(opts ClientOptions) Client {
|
||||
cli, err := sdkClient.NewClientWithOpts(sdkClient.FromEnv)
|
||||
|
||||
|
|
@ -277,35 +278,35 @@ func (client dockerClient) RenameContainer(c Container, newName string) error {
|
|||
return client.api.ContainerRename(bg, string(c.ID()), newName)
|
||||
}
|
||||
|
||||
func (client dockerClient) IsContainerStale(container Container) (stale bool, latestImage t.ImageID, err error) {
|
||||
func (client dockerClient) IsContainerStale(container Container) (stale bool, latestImage t.ImageID, created string, err error) {
|
||||
ctx := context.Background()
|
||||
|
||||
if !client.PullImages {
|
||||
log.Debugf("Skipping image pull.")
|
||||
} else if err := client.PullImage(ctx, container); err != nil {
|
||||
return false, container.SafeImageID(), err
|
||||
return false, container.SafeImageID(), "", err
|
||||
}
|
||||
|
||||
return client.HasNewImage(ctx, container)
|
||||
}
|
||||
|
||||
func (client dockerClient) HasNewImage(ctx context.Context, container Container) (hasNew bool, latestImage t.ImageID, err error) {
|
||||
func (client dockerClient) HasNewImage(ctx context.Context, container Container) (hasNew bool, latestImage t.ImageID, created string, err error) {
|
||||
currentImageID := t.ImageID(container.containerInfo.ContainerJSONBase.Image)
|
||||
imageName := container.ImageName()
|
||||
|
||||
newImageInfo, _, err := client.api.ImageInspectWithRaw(ctx, imageName)
|
||||
if err != nil {
|
||||
return false, currentImageID, err
|
||||
return false, currentImageID, "", err
|
||||
}
|
||||
|
||||
newImageID := t.ImageID(newImageInfo.ID)
|
||||
if newImageID == currentImageID {
|
||||
log.Debugf("No new images found for %s", container.Name())
|
||||
return false, currentImageID, nil
|
||||
return false, currentImageID, "", nil
|
||||
}
|
||||
|
||||
log.Infof("Found new %s image (%s)", imageName, newImageID.ShortID())
|
||||
return true, newImageID, nil
|
||||
return true, newImageID, newImageInfo.Created, nil
|
||||
}
|
||||
|
||||
// PullImage pulls the latest image for the supplied container, optionally skipping if it's digest can be confirmed
|
||||
|
|
@ -335,21 +336,13 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
|
|||
|
||||
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 {
|
||||
matches, err := client.digestMatchesWithRegistry(ctx, container, opts.RegistryAuth)
|
||||
if matches == true {
|
||||
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")
|
||||
log.WithFields(fields).Debugf("Digests did not match, pulling image")
|
||||
|
||||
response, err := client.api.ImagePull(ctx, imageName, opts)
|
||||
if err != nil {
|
||||
|
|
@ -366,6 +359,40 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
|
|||
return nil
|
||||
}
|
||||
|
||||
func (client dockerClient) ContainerDigestMatchesWithRegistry(container Container) (matches bool, err error) {
|
||||
ctx := context.Background()
|
||||
imageName := container.ImageName()
|
||||
opts, err := registry.GetPullOptions(imageName)
|
||||
if err != nil {
|
||||
log.Debugf("Error loading authentication credentials %s", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
return client.digestMatchesWithRegistry(ctx, container, opts.RegistryAuth)
|
||||
}
|
||||
|
||||
func (client dockerClient) digestMatchesWithRegistry(ctx context.Context, container Container, registryAuth string) (matches bool, err error) {
|
||||
containerName := container.Name()
|
||||
imageName := container.ImageName()
|
||||
fields := log.Fields{
|
||||
"image": imageName,
|
||||
"container": containerName,
|
||||
}
|
||||
|
||||
match, err := digest.CompareDigest(container, registryAuth)
|
||||
if 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)
|
||||
return false, err
|
||||
}
|
||||
|
||||
return match, nil
|
||||
}
|
||||
|
||||
func (client dockerClient) RemoveImageByID(id t.ImageID) error {
|
||||
log.Infof("Removing image %s", id.ShortID())
|
||||
|
||||
|
|
|
|||
82
pkg/dashboard/dashboard.go
Normal file
82
pkg/dashboard/dashboard.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package dashboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Dashboard is the http server responsible for serving the static Dashboard files
|
||||
type Dashboard struct {
|
||||
port string
|
||||
rootDir string
|
||||
apiPort string
|
||||
apiScheme string
|
||||
apiVersion string
|
||||
}
|
||||
|
||||
// New is a factory function creating a new Dashboard instance
|
||||
func New() *Dashboard {
|
||||
const webRootDir = "./web/dist" // Todo: needs to work in containerized environment
|
||||
const webPort = "8001" // Todo: make configurable?
|
||||
const apiPort = "8080" // Todo: make configurable?
|
||||
|
||||
return &Dashboard{
|
||||
apiPort: apiPort,
|
||||
apiScheme: "http",
|
||||
apiVersion: "v1",
|
||||
rootDir: webRootDir,
|
||||
port: webPort,
|
||||
}
|
||||
}
|
||||
|
||||
// Start the Dashboard and serve over HTTP
|
||||
func (d *Dashboard) Start() error {
|
||||
go func() {
|
||||
d.runHTTPServer()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Dashboard) templatedHTTPHandler(h http.Handler) http.HandlerFunc {
|
||||
const apiURLTemplate = "%s://%s:%s/%s/"
|
||||
indexTemplate, err := template.ParseFiles(d.rootDir + "/index.html")
|
||||
if err != nil {
|
||||
log.Error("Error when parsing index template")
|
||||
log.Error(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
hostName := strings.Split(r.Host, ":")[0]
|
||||
apiURL := fmt.Sprintf(apiURLTemplate, d.apiScheme, hostName, d.apiPort, d.apiVersion)
|
||||
err = indexTemplate.Execute(w, struct{ APIURL string }{
|
||||
APIURL: apiURL,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error when executing index template")
|
||||
log.Error(err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dashboard) getHandler() http.Handler {
|
||||
return d.templatedHTTPHandler(http.FileServer(http.Dir(d.rootDir)))
|
||||
}
|
||||
|
||||
func (d *Dashboard) runHTTPServer() {
|
||||
serveMux := http.NewServeMux()
|
||||
serveMux.Handle("/", d.getHandler())
|
||||
|
||||
log.Debug("Starting http dashboard server")
|
||||
log.Fatal(http.ListenAndServe(":"+d.port, serveMux))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue