This commit is contained in:
Anders Roos 2023-01-16 23:15:20 +01:00 committed by GitHub
commit beba45c17b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 3951 additions and 33 deletions

View file

@ -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
View 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
View 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)
}

View file

@ -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.")
}

View file

@ -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())

View 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))
}