491 lines
11 KiB
Go
491 lines
11 KiB
Go
package helper
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"docker-updater/config"
|
|
"docker-updater/constant"
|
|
"docker-updater/contextutil"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
"github.com/moby/moby/api/types/container"
|
|
"github.com/moby/moby/client"
|
|
"github.com/robfig/cron/v3"
|
|
)
|
|
|
|
type (
|
|
EventType int
|
|
EventContainerFunc func(ev ContainerEvent)
|
|
|
|
DockerHelper struct {
|
|
c *client.Client
|
|
w watcher
|
|
config config.Configuration
|
|
}
|
|
|
|
watcher struct {
|
|
running bool
|
|
ctx context.Context
|
|
cancelFunc context.CancelFunc
|
|
gracefulStop chan struct{}
|
|
cache cache
|
|
containersEventCallback EventContainerFunc
|
|
}
|
|
|
|
cache struct {
|
|
mu sync.Mutex
|
|
containers map[string]cacheEntry
|
|
}
|
|
|
|
cacheEntry struct {
|
|
c Container
|
|
}
|
|
|
|
Container struct {
|
|
id string
|
|
name string
|
|
image Image
|
|
status container.ContainerState
|
|
labels Labels
|
|
appliedConfiguration config.ContainerConfiguration
|
|
}
|
|
|
|
Labels struct {
|
|
labels map[string]string
|
|
hash string
|
|
}
|
|
|
|
Image struct {
|
|
id string
|
|
name string
|
|
hash string
|
|
}
|
|
|
|
ContainerEvent struct {
|
|
ctx context.Context
|
|
EventType EventType
|
|
Data Container
|
|
}
|
|
)
|
|
|
|
const (
|
|
NewContainer EventType = iota
|
|
DeletedContainer
|
|
UpdatedContainer
|
|
)
|
|
|
|
const (
|
|
ua string = "com.thelilfrog.docker-updater/%s"
|
|
)
|
|
|
|
func Open(configuration config.Configuration) (*DockerHelper, error) {
|
|
cli, err := client.New(client.FromEnv, client.WithUserAgent(fmt.Sprintf(ua, constant.ProgramVersion())))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to docker daemon: %s", err)
|
|
}
|
|
|
|
dh := &DockerHelper{
|
|
w: watcher{
|
|
cache: cache{
|
|
containers: make(map[string]cacheEntry),
|
|
},
|
|
running: false,
|
|
gracefulStop: make(chan struct{}),
|
|
containersEventCallback: func(ev ContainerEvent) {},
|
|
},
|
|
config: configuration,
|
|
c: cli,
|
|
}
|
|
|
|
return dh, nil
|
|
}
|
|
|
|
func (dh *DockerHelper) Close() error {
|
|
if dh.w.running {
|
|
dh.w.gracefulStop <- struct{}{}
|
|
<-dh.w.ctx.Done()
|
|
}
|
|
return dh.c.Close()
|
|
}
|
|
|
|
func (dh *DockerHelper) StartWatcher(appCtx context.Context, interval uint) error {
|
|
if dh.w.running {
|
|
return fmt.Errorf("cannot start the watcher: already running")
|
|
}
|
|
|
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
|
|
|
dh.w.ctx = contextutil.WithThreadName(ctx, "watcher")
|
|
dh.w.cancelFunc = cancelFunc
|
|
|
|
// watch a first time
|
|
dh.w.Watch(appCtx, dh)
|
|
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-dh.w.ctx.Done():
|
|
{
|
|
slog.Error("context: watcher closed", "thread", "watcher", "err", dh.w.ctx.Err())
|
|
dh.w.running = false
|
|
return
|
|
}
|
|
case <-dh.w.gracefulStop:
|
|
{
|
|
slog.Info("gracefully stopping the watcher", "thread", "watcher")
|
|
dh.w.cancelFunc()
|
|
return
|
|
}
|
|
case <-time.After(time.Duration(interval) * time.Second):
|
|
{
|
|
dh.w.Watch(dh.w.ctx, dh)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
dh.w.running = true
|
|
return nil
|
|
}
|
|
|
|
func (w *watcher) Watch(ctx context.Context, dh *DockerHelper) {
|
|
w.cache.mu.Lock()
|
|
defer w.cache.mu.Unlock()
|
|
|
|
runningContainers, err := dh.RunningContainers(ctx)
|
|
if err != nil {
|
|
slog.Error("cannot fetch the list of running containers", "thread", contextutil.ThreadName(ctx), "err", err)
|
|
return
|
|
}
|
|
|
|
for _, runningContainer := range runningContainers {
|
|
if foundContainer, ok := dh.w.cache.containers[runningContainer.name]; ok {
|
|
if runningContainer.labels.hash != foundContainer.c.labels.hash {
|
|
foundContainer.c = runningContainer
|
|
dh.w.cache.containers[runningContainer.name] = foundContainer
|
|
|
|
dh.w.containersEventCallback(ContainerEvent{
|
|
ctx: ctx,
|
|
EventType: UpdatedContainer,
|
|
Data: runningContainer,
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
dh.w.cache.containers[runningContainer.name] = cacheEntry{
|
|
c: runningContainer,
|
|
}
|
|
|
|
dh.w.containersEventCallback(ContainerEvent{
|
|
ctx: ctx,
|
|
EventType: NewContainer,
|
|
Data: runningContainer,
|
|
})
|
|
}
|
|
|
|
notFound := make(map[string]Container)
|
|
for containerName, containerData := range w.cache.containers {
|
|
exists := slices.ContainsFunc(runningContainers, func(runningContainer Container) bool {
|
|
return runningContainer.name == containerName
|
|
})
|
|
if !exists {
|
|
notFound[containerName] = containerData.c
|
|
}
|
|
}
|
|
|
|
for containerName, containerData := range notFound {
|
|
delete(w.cache.containers, containerName)
|
|
w.containersEventCallback(ContainerEvent{
|
|
ctx: ctx,
|
|
EventType: DeletedContainer,
|
|
Data: containerData,
|
|
})
|
|
}
|
|
}
|
|
|
|
func (dh *DockerHelper) RunningContainers(ctx context.Context) ([]Container, error) {
|
|
containers, err := dh.c.ContainerList(context.Background(), client.ContainerListOptions{
|
|
All: false,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to get the list of running containers: %s", err)
|
|
}
|
|
|
|
var res []Container
|
|
for _, container := range containers.Items {
|
|
c, err := dh.parseContainer(ctx, container)
|
|
if err != nil {
|
|
slog.Warn("the container metadata contains errors, skipping this container")
|
|
continue
|
|
}
|
|
|
|
res = append(res, c)
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func (dh *DockerHelper) RemoteImageMetadata(ctx context.Context, imageName string) (Image, error) {
|
|
ref, err := name.ParseReference(imageName)
|
|
if err != nil {
|
|
return Image{}, fmt.Errorf("failed to parse image reference: %s", err)
|
|
}
|
|
|
|
image, err := remote.Head(ref)
|
|
if err != nil {
|
|
return Image{}, fmt.Errorf("an error occured while getting the metadata of the image in the remote: %s", err)
|
|
}
|
|
|
|
return Image{
|
|
name: imageName,
|
|
hash: image.Digest.String(),
|
|
}, nil
|
|
}
|
|
|
|
func (dh *DockerHelper) Container(ctx context.Context, containerName string) (Container, error) {
|
|
containers, err := dh.c.ContainerList(context.Background(), client.ContainerListOptions{
|
|
All: true,
|
|
})
|
|
if err != nil {
|
|
return Container{}, fmt.Errorf("unable to get the list of containers: %s", err)
|
|
}
|
|
|
|
var res Container
|
|
for _, container := range containers.Items {
|
|
name := formatName(container.Names)
|
|
if name != containerName {
|
|
continue
|
|
}
|
|
|
|
res, err = dh.parseContainer(ctx, container)
|
|
if err != nil {
|
|
return Container{}, fmt.Errorf("failed to get the container: %s", err)
|
|
}
|
|
break
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func (dh *DockerHelper) StopContainer(ctx context.Context, container Container) error {
|
|
if _, err := dh.c.ContainerStop(ctx, container.id, client.ContainerStopOptions{}); err != nil {
|
|
return fmt.Errorf("failed to stop the container: %s", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (dh *DockerHelper) StartContainer(ctx context.Context, container Container) error {
|
|
if _, err := dh.c.ContainerStart(ctx, container.id, client.ContainerStartOptions{}); err != nil {
|
|
return fmt.Errorf("failed to start the container: %s", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (dh *DockerHelper) PullImage(ctx context.Context, imageName string) error {
|
|
resp, err := dh.c.ImagePull(ctx, imageName, client.ImagePullOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to pull the image: %s", err)
|
|
}
|
|
defer resp.Close()
|
|
|
|
var buf []byte
|
|
for {
|
|
_, err := resp.Read(buf)
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (dh *DockerHelper) ListSimilarContainers(ctx context.Context, containerName string) ([]Container, error) {
|
|
targetContainer, err := dh.Container(ctx, containerName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to get the target container: %s", err)
|
|
}
|
|
|
|
containers, err := dh.RunningContainers(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get running containers: %s", err)
|
|
}
|
|
|
|
var res []Container
|
|
for _, container := range containers {
|
|
if container.image.name == targetContainer.name {
|
|
res = append(res, container)
|
|
}
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func (c Container) ID() string {
|
|
return c.id
|
|
}
|
|
|
|
func (c Container) Name() string {
|
|
return c.name
|
|
}
|
|
|
|
func (c Container) Image() Image {
|
|
return c.image
|
|
}
|
|
|
|
func (c Container) Enabled() bool {
|
|
return c.appliedConfiguration.Enabled
|
|
}
|
|
|
|
func (c Container) Schedule() string {
|
|
return c.appliedConfiguration.Schedule
|
|
}
|
|
|
|
func (i Image) ID() string {
|
|
return i.id
|
|
}
|
|
|
|
func (i Image) Name() string {
|
|
return i.name
|
|
}
|
|
|
|
func (i Image) Hash() string {
|
|
return i.hash
|
|
}
|
|
|
|
func (dh *DockerHelper) SetContainersEventCallback(fn EventContainerFunc) {
|
|
dh.w.cache.mu.Lock()
|
|
defer dh.w.cache.mu.Unlock()
|
|
|
|
dh.w.containersEventCallback = fn
|
|
}
|
|
|
|
func (dh *DockerHelper) parseContainer(ctx context.Context, container container.Summary) (Container, error) {
|
|
name := formatName(container.Names)
|
|
|
|
config, err := dh.parseLocalConfiguration(container.Labels)
|
|
if err != nil {
|
|
slog.Warn("failed to get the local configuration from the labels",
|
|
"thread", contextutil.ThreadName(ctx),
|
|
"container", name,
|
|
"container_id", container.ID,
|
|
"image_name", container.Image,
|
|
"image_id", container.ImageID,
|
|
"labels", container.Labels)
|
|
return Container{}, err
|
|
}
|
|
|
|
imageMetadata, err := dh.c.ImageInspect(ctx, container.ImageID)
|
|
if err != nil {
|
|
slog.Warn("failed to get local image metadata",
|
|
"thread", contextutil.ThreadName(ctx),
|
|
"container", name,
|
|
"container_id", container.ID,
|
|
"image_name", container.Image,
|
|
"image_id", container.ImageID)
|
|
return Container{}, err
|
|
}
|
|
|
|
switch {
|
|
case len(imageMetadata.RepoDigests) == 0:
|
|
{
|
|
slog.Warn("no remote digest found, ignoring",
|
|
"thread", contextutil.ThreadName(ctx),
|
|
"container", name,
|
|
"container_id", container.ID,
|
|
"image_name", container.Image,
|
|
"image_id", container.ImageID)
|
|
return Container{}, err
|
|
}
|
|
case len(imageMetadata.RepoDigests) > 1:
|
|
{
|
|
slog.Warn("ambigous remote image digest",
|
|
"thread", contextutil.ThreadName(ctx),
|
|
"container", name,
|
|
"container_id", container.ID,
|
|
"image_name", container.Image,
|
|
"image_id", container.ImageID,
|
|
"repo_digests_count", len(imageMetadata.RepoDigests),
|
|
"repo_digests", imageMetadata.RepoDigests)
|
|
return Container{}, err
|
|
}
|
|
}
|
|
|
|
// get the first repo digest
|
|
hashes := strings.Split(imageMetadata.RepoDigests[0], "@")
|
|
if len(hashes) != 2 {
|
|
slog.Warn("failed to parse remote hash for this image",
|
|
"thread", contextutil.ThreadName(ctx),
|
|
"container", name,
|
|
"container_id", container.ID,
|
|
"image_name", container.Image,
|
|
"image_id", container.ImageID,
|
|
"repo_digest", imageMetadata.RepoDigests[0])
|
|
return Container{}, err
|
|
}
|
|
hash := hashes[1]
|
|
|
|
return Container{
|
|
id: container.ID,
|
|
name: name,
|
|
status: container.State,
|
|
labels: convertLabels(container.Labels),
|
|
appliedConfiguration: config,
|
|
image: Image{
|
|
id: container.ImageID,
|
|
name: container.Image,
|
|
hash: hash,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (ev ContainerEvent) Context() context.Context {
|
|
return ev.ctx
|
|
}
|
|
|
|
func formatName(names []string) string {
|
|
name := strings.Join(names, "-")
|
|
if after, ok := strings.CutPrefix(name, "/"); ok {
|
|
name = after
|
|
}
|
|
return name
|
|
}
|
|
|
|
func convertLabels(labels map[string]string) Labels {
|
|
var p string
|
|
|
|
for key, value := range labels {
|
|
p += key + ":" + value
|
|
}
|
|
|
|
md5 := crypto.MD5.New()
|
|
hash := md5.Sum([]byte(p))
|
|
|
|
return Labels{
|
|
labels: labels,
|
|
hash: hex.EncodeToString(hash),
|
|
}
|
|
}
|
|
|
|
func (dh *DockerHelper) parseLocalConfiguration(labels map[string]string) (config.ContainerConfiguration, error) {
|
|
c := dh.config.GlobalContainerConfiguration
|
|
|
|
if schedule, ok := labels["com.thelilfrog.image.update.schedule"]; ok {
|
|
_, err := cron.ParseStandard(schedule)
|
|
if err != nil {
|
|
return config.ContainerConfiguration{}, fmt.Errorf("invalid schedule: %s", err)
|
|
}
|
|
c.Schedule = schedule
|
|
}
|
|
|
|
return c, nil
|
|
}
|