package main import ( "context" "fmt" "log" "net/http" "regexp" "strings" "sync" "time" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" "github.com/docker/docker/client" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/regclient/regclient" "github.com/regclient/regclient/types/ref" ) type ImageStatus struct { ContainerName string Image string Tag string UpdateAvailable float64 LocalDigest string RemoteDigest string } type StatusCache struct { sync.RWMutex Data []ImageStatus LastCheck time.Time } var ( cache = &StatusCache{Data: []ImageStatus{}, LastCheck: time.Time{}} interval = 6 * time.Hour ) func toRegistryImage(imageTag string) (string, error) { r := regexp.MustCompile(`^(?:(?P[^/]+)/)?(?P[^:]+)(?::(?P.+))?$`) match := r.FindStringSubmatch(imageTag) if len(match) == 0 { return "", fmt.Errorf("Image-Tag nicht erkannt: %s", imageTag) } registry := match[r.SubexpIndex("registry")] repo := match[r.SubexpIndex("repo")] tag := match[r.SubexpIndex("tag")] if registry == "" { registry = "registry-1.docker.io" } if tag == "" { tag = "latest" } return fmt.Sprintf("%s/%s:%s", registry, repo, tag), nil } func extractDigest(s string) string { for _, part := range strings.Split(s, "@") { if strings.HasPrefix(part, "sha256:") { return part } } return s } func checkImageUpdates() { ctx := context.Background() cli, err := client.NewClientWithOpts(client.FromEnv) if err != nil { log.Printf("Fehler bei Docker-Client: %v", err) return } defer cli.Close() rc := regclient.New() // Nur laufende Container abrufen containers, err := cli.ContainerList(ctx, container.ListOptions{All: false}) if err != nil { log.Printf("Fehler beim ContainerList: %v", err) return } images, err := cli.ImageList(ctx, image.ListOptions{All: true}) if err != nil { log.Printf("Fehler beim ImageList: %v", err) return } // RepoTag → Digest map imageTagToDigest := make(map[string]string) for _, img := range images { for _, tag := range img.RepoTags { if len(img.RepoDigests) > 0 { imageTagToDigest[tag] = extractDigest(img.RepoDigests[0]) } else { imageTagToDigest[tag] = img.ID } } } for tag, digest := range imageTagToDigest { log.Printf("imageTagToDigest: %s -> %s\n", tag, digest) } results := make([]ImageStatus, 0) for _, ctr := range containers { tag := ctr.Image containerName := "unknown" if len(ctr.Names) > 0 { containerName = ctr.Names[0] } imageRef, err := toRegistryImage(tag) if err != nil { log.Printf("Fehler beim Parsen des Image-Namens (%s): %v", tag, err) continue } localDigest := imageTagToDigest[tag] refObj, err := ref.New(imageRef) if err != nil { log.Printf("Ungültige Image-Referenz (%s): %v", tag, err) continue } desc, err := rc.ManifestHead(ctx, refObj) if err != nil { log.Printf("Fehler beim Abrufen des Remote-Manifests (%s): %v", tag, err) continue } remoteDigest := desc.GetDigest().String() update := 0.0 if localDigest != remoteDigest { update = 1.0 fmt.Printf("Container: %s\n Image: %s\n Local Digest: %s\n Remote Digest: %s\n -> Update verfügbar!\n", containerName, tag, localDigest, remoteDigest) } else { fmt.Printf("Container: %s\n Image: %s\n Local Digest: %s\n Remote Digest: %s\n -> Kein Update verfügbar.\n", containerName, tag, localDigest, remoteDigest) } labelImg, labelTag := tag, "latest" if cp := strings.Split(tag, ":"); len(cp) == 2 { labelImg, labelTag = cp[0], cp[1] } results = append(results, ImageStatus{ ContainerName: containerName, Image: labelImg, Tag: labelTag, UpdateAvailable: update, LocalDigest: localDigest, RemoteDigest: remoteDigest, }) } // Speichern in Cache cache.Lock() cache.Data = results cache.LastCheck = time.Now() cache.Unlock() } type imageUpdateCollector struct { metric *prometheus.Desc } func newImageUpdateCollector() *imageUpdateCollector { return &imageUpdateCollector{ metric: prometheus.NewDesc( "docker_image_update_available", "Ob Update für das lokale Docker-Image eines laufenden Containers verfügbar ist (1 = Update, 0 = aktuell)", []string{"container_name", "image", "tag"}, nil, ), } } func (c *imageUpdateCollector) Describe(ch chan<- *prometheus.Desc) { ch <- c.metric } func (c *imageUpdateCollector) Collect(ch chan<- prometheus.Metric) { cache.RLock() defer cache.RUnlock() for _, stat := range cache.Data { ch <- prometheus.MustNewConstMetric( c.metric, prometheus.GaugeValue, stat.UpdateAvailable, stat.ContainerName, stat.Image, stat.Tag, ) } } func main() { log.Printf("🚀 Docker Image Update Exporter gestartet (Intervall = %v)", interval) // Hintergrundprozess zum Aktualisieren des Caches go func() { for { checkImageUpdates() time.Sleep(interval) } }() checkImageUpdates() // initial exporter := newImageUpdateCollector() prometheus.MustRegister(exporter) http.Handle("/metrics", promhttp.Handler()) log.Fatal(http.ListenAndServe(":9788", nil)) }