package main import ( "context" "fmt" "log" "net/http" "os" "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 // Default wird ggf. überschrieben durch CHECK_INTERVAL excludeContainers = map[string]struct{}{} ) // Helper: image ohne ":tag" erhält "latest" func normalizeImageTag(tag string) string { if !strings.Contains(tag, ":") { return tag + ":latest" } return tag } 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() 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 } imageTagToDigest := make(map[string]string) for _, img := range images { for _, tag := range img.RepoTags { normalizedTag := normalizeImageTag(tag) if len(img.RepoDigests) > 0 { imageTagToDigest[normalizedTag] = extractDigest(img.RepoDigests[0]) } else { imageTagToDigest[normalizedTag] = img.ID } } } for tag, digest := range imageTagToDigest { fmt.Printf("imageTagToDigest: %s -> %s\n", tag, digest) } results := make([]ImageStatus, 0) for _, ctr := range containers { containerName := "unknown" if len(ctr.Names) > 0 { containerName = strings.TrimPrefix(ctr.Names[0], "/") } // Überspringen, wenn im EXCLUDE_CONTAINERS enthalten if _, excluded := excludeContainers[containerName]; excluded { fmt.Printf("⏭️ Container '%s' ist ausgeschlossen.\n", containerName) continue } rawTag := normalizeImageTag(ctr.Image) localDigest := imageTagToDigest[rawTag] imageRef, err := toRegistryImage(rawTag) if err != nil { log.Printf("Ungültige Image-Referenz (%s): %v", rawTag, err) continue } refObj, err := ref.New(imageRef) if err != nil { log.Printf("Fehler beim Erzeugen der Referenz (%s): %v", rawTag, err) continue } desc, err := rc.ManifestHead(ctx, refObj) if err != nil { log.Printf("Remote-Manifest nicht gefunden (%s): %v", rawTag, err) continue } remoteDigest := desc.GetDigest().String() update := 0.0 fmt.Printf("Container: %s\n", containerName) fmt.Printf(" Image: %s\n", rawTag) fmt.Printf(" Local Digest: %s\n", localDigest) fmt.Printf(" Remote Digest: %s\n", remoteDigest) if localDigest != remoteDigest { update = 1.0 fmt.Println(" -> ⚠️ Update verfügbar!") } else { fmt.Println(" -> ✅ Kein Update erforderlich.") } imageName, imageTag := rawTag, "latest" if cp := strings.Split(rawTag, ":"); len(cp) == 2 { imageName, imageTag = cp[0], cp[1] } results = append(results, ImageStatus{ ContainerName: containerName, Image: imageName, Tag: imageTag, UpdateAvailable: update, LocalDigest: localDigest, RemoteDigest: remoteDigest, }) } 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 ein Update für das verwendete Image eines laufenden Containers verfügbar ist (1 = Update)", []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 loadIntervalFromEnv() { if val := os.Getenv("CHECK_INTERVAL"); val != "" { dur, err := time.ParseDuration(val) if err != nil { log.Printf("❌ Ungültiger CHECK_INTERVAL: %s – verwende Default (%s)", val, interval) } else { interval = dur } } } func loadExclusionsFromEnv() { raw := os.Getenv("EXCLUDE_CONTAINERS") if raw == "" { return } list := strings.Split(raw, ",") for _, name := range list { clean := strings.TrimSpace(name) if clean != "" { excludeContainers[clean] = struct{}{} } } } func main() { log.Println("🚀 Docker Image Update Exporter startet...") loadIntervalFromEnv() loadExclusionsFromEnv() log.Printf("🔁 Prüfintervall: %v", interval) if len(excludeContainers) > 0 { log.Printf("🙈 Ignorierte Container: %v", keys(excludeContainers)) } go func() { for { checkImageUpdates() time.Sleep(interval) } }() checkImageUpdates() exporter := newImageUpdateCollector() prometheus.MustRegister(exporter) http.Handle("/metrics", promhttp.Handler()) log.Fatal(http.ListenAndServe(":9788", nil)) } func keys(m map[string]struct{}) []string { var result []string for k := range m { result = append(result, k) } return result }