Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8665ea22e1 | |||
| 93b35eb1d9 | |||
| d0a8c5b3a8 | |||
| d3530314c2 | |||
| 64a96fa4cd | |||
| e7e52ca3b5 | |||
| 157f79240b | |||
| f8df6c2250 |
@@ -0,0 +1,21 @@
|
||||
steps:
|
||||
- name: build and push docker image
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
repo: git.kolspace.cc/victor.kolomin/controlla
|
||||
registry: git.kolspace.cc
|
||||
platforms: linux/amd64
|
||||
username:
|
||||
from_secret: gitea_registry_user
|
||||
password:
|
||||
from_secret: gitea_registry_pass
|
||||
tags:
|
||||
- ${CI_COMMIT_TAG}
|
||||
- latest
|
||||
cache_from:
|
||||
- git.kolspace.cc/victor.kolomin/controlla:latest
|
||||
cache_to: type=inline
|
||||
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM public.ecr.aws/docker/library/golang:1.25.1-alpine3.22 AS builder
|
||||
FROM docker.io/library/golang:1.25.1-alpine3.22 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
@@ -7,7 +7,7 @@ COPY . .
|
||||
RUN go build -o controlla .
|
||||
|
||||
# Final minimal image
|
||||
FROM public.ecr.aws/docker/library/alpine:3.22.1
|
||||
FROM docker.io/library/alpine:3.22.1
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the binary and config file from the builder stage
|
||||
|
||||
+27
-20
@@ -2,10 +2,12 @@ package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -24,35 +26,38 @@ type ServiceInfo struct {
|
||||
ImageInfo DockerImageInfo
|
||||
}
|
||||
|
||||
// FindRelevantFiles рекурсивно ищет файлы docker-compose.yml и docker-compose.yaml в директории.
|
||||
func FindRelevantFiles(searchDir string) ([]string, error) {
|
||||
var filePaths []string
|
||||
// FindRelevantFiles looking for relevant files in the repository or specified watchFolder.
|
||||
func FindRelevantFiles(repoPath, watchFolder string) ([]string, error) {
|
||||
var relevantFiles []string
|
||||
searchPath := repoPath
|
||||
|
||||
err := filepath.WalkDir(searchDir, func(path string, d os.DirEntry, err error) error {
|
||||
// Если указана watchFolder и она не пуста, добавляем ее к пути поиска
|
||||
if watchFolder != "" {
|
||||
searchPath = filepath.Join(repoPath, watchFolder)
|
||||
logrus.Debugf("Looking for relevant files in folder: %s", searchPath)
|
||||
} else {
|
||||
logrus.Debugf("Looking for relevant files in repository: %s", searchPath)
|
||||
}
|
||||
|
||||
err := filepath.WalkDir(searchPath, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("error accessing path %q: %v", path, err)
|
||||
logrus.Errorf("Error with path %s: %v", path, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
if !d.IsDir() && (strings.HasSuffix(d.Name(), "docker-compose.yaml") || strings.HasSuffix(d.Name(), "docker-compose.yml")) {
|
||||
relevantFiles = append(relevantFiles, path)
|
||||
}
|
||||
|
||||
fileName := d.Name()
|
||||
if fileName == "docker-compose.yml" || fileName == "docker-compose.yaml" {
|
||||
filePaths = append(filePaths, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("error in search in directory: %w", err)
|
||||
}
|
||||
|
||||
return filePaths, nil
|
||||
return relevantFiles, nil
|
||||
}
|
||||
|
||||
// ParseRelevantFiles считывает и парсит указанные файлы, возвращая список ServiceInfo.
|
||||
// Serarch and Parse relevant files and extract service and image information.
|
||||
func ParseRelevantFiles(filePaths []string) ([]ServiceInfo, error) {
|
||||
var services []ServiceInfo
|
||||
|
||||
@@ -69,12 +74,12 @@ func ParseRelevantFiles(filePaths []string) ([]ServiceInfo, error) {
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &composeData); err != nil {
|
||||
continue // Игнорируем файлы с ошибками YAML
|
||||
continue // Ignore files that cannot be parsed
|
||||
}
|
||||
|
||||
for serviceName, service := range composeData.Services {
|
||||
if service.Image == "" {
|
||||
continue // Пропускаем сервисы без указанного образа
|
||||
continue // Skip services without an image
|
||||
}
|
||||
|
||||
parsedImage := ParseDockerImage(service.Image)
|
||||
@@ -91,7 +96,9 @@ func ParseRelevantFiles(filePaths []string) ([]ServiceInfo, error) {
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// ParseDockerImage разбивает строку образа на компоненты.
|
||||
// ParseDockerImage decomposes a Docker image string into its components:
|
||||
// registry, image name, tag, and digest.
|
||||
// It handles various formats of Docker image strings.
|
||||
func ParseDockerImage(imageString string) DockerImageInfo {
|
||||
parsed := DockerImageInfo{}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type CommonRegistryClient struct {
|
||||
@@ -15,13 +18,71 @@ type CommonRegistryClient struct {
|
||||
// NewCommonRegistryClient creates a new CommonRegistryClient for the specified registry host.
|
||||
func NewCommonRegistryClient(host string) *CommonRegistryClient {
|
||||
return &CommonRegistryClient{
|
||||
Client: &http.Client{},
|
||||
Host: host,
|
||||
Client: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
Host: host,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTags fetches the list of tags for a given image from the registry.
|
||||
// GetTags retrieves a list of tags for a given image from the registry.
|
||||
// It first tries the standard Docker Registry API, and if that fails,
|
||||
// it falls back to a Gitea-like Web API.
|
||||
func (c *CommonRegistryClient) GetTags(image string) ([]string, error) {
|
||||
tags, err := c.getTagsFromDockerAPI(image)
|
||||
if err != nil {
|
||||
logrus.Warnf("Standard Docker API failed for %s (error: %v). Trying Web API fallback...", c.Host, err)
|
||||
|
||||
// Try the Web API fallback
|
||||
fallbackTags, fallbackErr := c.getTagsFromWebAPI(image)
|
||||
if fallbackErr != nil {
|
||||
logrus.Errorf("Web API fallback also failed for %s (error: %v).", c.Host, fallbackErr)
|
||||
// Возвращаем оригинальную, более информативную ошибку от Docker API.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logrus.Infof("Successfully fetched tags for %s using Web API fallback.", c.Host)
|
||||
return fallbackTags, nil
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// getTagsFromWebAPI fetches tags using a Gitea-like Web API endpoint.
|
||||
func (c *CommonRegistryClient) getTagsFromWebAPI(image string) ([]string, error) {
|
||||
url := fmt.Sprintf("https://%s/%s/tags/list", c.Host, image)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("web api request for %s failed with status %d: %s", c.Host, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Gitea uses "results", some others might use "tags". Let's try to be flexible.
|
||||
var result struct {
|
||||
Tags []string `json:"tags"`
|
||||
Results []string `json:"results"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result.Results) > 0 {
|
||||
return result.Results, nil
|
||||
}
|
||||
return result.Tags, nil
|
||||
}
|
||||
|
||||
// getTagsFromDockerAPI fetches tags using the standard Docker Registry v2 API.
|
||||
func (c *CommonRegistryClient) getTagsFromDockerAPI(image string) ([]string, error) {
|
||||
url := fmt.Sprintf("https://%s/v2/%s/tags/list", c.Host, image)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
@@ -34,7 +95,7 @@ func (c *CommonRegistryClient) GetTags(image string) ([]string, error) {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("registry %s request failed: %s", c.Host, string(body))
|
||||
return nil, fmt.Errorf("registry %s request failed with status %d: %s", c.Host, resp.StatusCode, string(body))
|
||||
}
|
||||
var result struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
+34
-22
@@ -14,12 +14,38 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// UpdateAndPushService updates the image tag of a specified service in a docker-compose.yaml file,
|
||||
// commits the change to a Git repository, and pushes the commit to the remote repository.
|
||||
func UpdateAndPushService(repoPath, filePath, serviceName, newTag, SSHKeyPath, authorName, authorEmail string) error {
|
||||
// UpdateAndPushService updates a service's image tag in its docker-compose file and pushes the change to the Git repository.
|
||||
func UpdateAndPushService(repoPath, filePath, serviceName, newTag, sshKeyPath, authorName, authorEmail string) error {
|
||||
logrus.Infof("Starting update for service '%s' in file '%s' to tag '%s'", serviceName, filePath, newTag)
|
||||
|
||||
// Reading and updating the docker-compose.yaml file
|
||||
// --- CORRECT LOGIC: Open repo and pull first to ensure we are up-to-date ---
|
||||
repo, err := git.PlainOpen(repoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not open repository: %w", err)
|
||||
}
|
||||
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get worktree: %w", err)
|
||||
}
|
||||
|
||||
auth, err := ssh.NewPublicKeysFromFile("git", sshKeyPath, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot generate public keys from file: %w", err)
|
||||
}
|
||||
|
||||
logrus.Info("Pulling latest changes before modifying files...")
|
||||
pullOpts := &git.PullOptions{
|
||||
RemoteName: "origin",
|
||||
Auth: auth,
|
||||
}
|
||||
err = worktree.Pull(pullOpts)
|
||||
if err != nil && err != git.NoErrAlreadyUpToDate {
|
||||
return fmt.Errorf("could not pull changes: %w", err)
|
||||
}
|
||||
// --- END CORRECT LOGIC ---
|
||||
|
||||
// 1. Reading and updating the docker-compose.yaml file
|
||||
yamlFile, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not read file: %w", err)
|
||||
@@ -30,7 +56,7 @@ func UpdateAndPushService(repoPath, filePath, serviceName, newTag, SSHKeyPath, a
|
||||
return fmt.Errorf("could not unmarshal YAML: %w", err)
|
||||
}
|
||||
|
||||
// Finding and updating the service image tag
|
||||
// 2. Finding and updating the service image tag
|
||||
services, ok := data["services"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid services structure in YAML")
|
||||
@@ -55,16 +81,6 @@ func UpdateAndPushService(repoPath, filePath, serviceName, newTag, SSHKeyPath, a
|
||||
logrus.Infof("docker-compose.yaml file updated successfully.")
|
||||
|
||||
// 4. Committing and pushing the changes to the Git repository
|
||||
repo, err := git.PlainOpen(repoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open Git repository: %w", err)
|
||||
}
|
||||
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get worktree: %w", err)
|
||||
}
|
||||
|
||||
relativePath, err := filepath.Rel(repoPath, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get relative path: %w", err)
|
||||
@@ -83,22 +99,18 @@ func UpdateAndPushService(repoPath, filePath, serviceName, newTag, SSHKeyPath, a
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not commit changes: %w", err)
|
||||
return fmt.Errorf("could not create commit: %w", err)
|
||||
}
|
||||
logrus.Infof("New commit created: %s", commit)
|
||||
|
||||
publicKeys, err := ssh.NewPublicKeysFromFile("git", SSHKeyPath, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH key: %w", err)
|
||||
}
|
||||
auth := publicKeys
|
||||
// Push the changes to the remote repository
|
||||
logrus.Info("Pushing changes to remote repository...")
|
||||
err = repo.Push(&git.PushOptions{
|
||||
Auth: auth,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not push changes: %w", err)
|
||||
}
|
||||
logrus.Infof("Changes pushed to remote repository successfully.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,8 +29,13 @@ func SortAndFilterTags(tags []string, n int, ignoreKeywords []string) []string {
|
||||
if isWeirdTag(tag) {
|
||||
continue
|
||||
}
|
||||
cleanTag := strings.TrimPrefix(tag, "v")
|
||||
|
||||
// --- MODIFICATION: Try to normalize tag before parsing as SemVer ---
|
||||
normalizedTag := TryNormalizeTagForSemVer(tag)
|
||||
cleanTag := strings.TrimPrefix(normalizedTag, "v")
|
||||
v, err := semver.NewVersion(cleanTag)
|
||||
// --- END MODIFICATION ---
|
||||
|
||||
if err == nil {
|
||||
uniqueVersions[v.String()] = v
|
||||
tagMap[v.String()] = tag // сохраняем оригинальный тег!
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package versionutils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
@@ -77,3 +79,30 @@ func GetLatestTags(tags []string, count int) []string {
|
||||
func NormalizeTag(tag string) string {
|
||||
return strings.TrimPrefix(tag, "v")
|
||||
}
|
||||
|
||||
// TryNormalizeTagForSemVer attempts to normalize a non-standard tag into a SemVer-compatible format.
|
||||
// For example, "1.2.3.4" might become "1.2.3-rev.4".
|
||||
// This is a heuristic approach for specific non-SemVer patterns.
|
||||
func TryNormalizeTagForSemVer(tag string) string {
|
||||
// If it already parses as SemVer, no need to normalize
|
||||
if _, err := semver.NewVersion(strings.TrimPrefix(tag, "v")); err == nil {
|
||||
return tag
|
||||
}
|
||||
|
||||
// Pattern: X.Y.Z.A (e.g., 1.2.3.4)
|
||||
parts := strings.Split(tag, ".")
|
||||
if len(parts) == 4 {
|
||||
// Try to form X.Y.Z-rev.A
|
||||
if _, err := strconv.Atoi(parts[3]); err == nil { // Check if the last part is a number
|
||||
normalized := fmt.Sprintf("%s.%s.%s-rev.%s", parts[0], parts[1], parts[2], parts[3])
|
||||
if _, err := semver.NewVersion(normalized); err == nil {
|
||||
logrus.Debugf("Normalized tag '%s' to '%s' for SemVer parsing.", tag, normalized)
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add other normalization patterns here if needed
|
||||
|
||||
return tag // Return original tag if no normalization helps
|
||||
}
|
||||
|
||||
@@ -36,15 +36,15 @@ type UpdateInfo struct {
|
||||
NewTag string
|
||||
}
|
||||
|
||||
var commonClients = map[string]*registry.CommonRegistryClient{
|
||||
"docker.gitea.com": registry.NewCommonRegistryClient("docker.gitea.com"),
|
||||
"quay.io": registry.NewCommonRegistryClient("quay.io"),
|
||||
}
|
||||
|
||||
// updateCache stores information about found updates.
|
||||
var updateCache = make(map[string]UpdateInfo)
|
||||
var cacheMutex sync.Mutex
|
||||
|
||||
// sentNotifications stores a record of notifications already sent to Telegram
|
||||
// to prevent sending duplicates for the same image/tag combination.
|
||||
var sentNotifications = make(map[string]bool)
|
||||
var sentNotificationsMutex sync.Mutex // Add mutex for thread-safe access
|
||||
|
||||
// For hash-mode needed map hash → {fullImageName, tag}
|
||||
var callbackHashMap = make(map[string][2]string) // [fullImageName, tag]
|
||||
|
||||
@@ -85,9 +85,14 @@ func handleCallbackQuery(bot *tgbotapi.BotAPI, callback *tgbotapi.CallbackQuery,
|
||||
|
||||
logrus.Infof("Received update request for image: %s, tag: %s", fullImageName, newTag)
|
||||
|
||||
// Remove from sentNotifications when acted upon ---
|
||||
sentNotificationsMutex.Lock()
|
||||
delete(sentNotifications, fullImageName+":"+newTag)
|
||||
sentNotificationsMutex.Unlock()
|
||||
|
||||
// Looking for servies to update
|
||||
repoPath := appConfig.Git.LocalRepoPath
|
||||
relevantFiles, err := checker.FindRelevantFiles(repoPath)
|
||||
relevantFiles, err := checker.FindRelevantFiles(repoPath, appConfig.Checker.WatchFolder)
|
||||
if err != nil {
|
||||
logrus.Errorf("Error finding relevant files: %v", err)
|
||||
notifier.SendTelegramMessage(bot, callback.Message.Chat.ID, fmt.Sprintf("❌ Error finding relevant files: %v", err))
|
||||
@@ -144,6 +149,11 @@ func handleCallbackQuery(bot *tgbotapi.BotAPI, callback *tgbotapi.CallbackQuery,
|
||||
|
||||
logrus.Infof("Received ignore request for image: %s, version: %s", fullImageName, tagToIgnore)
|
||||
|
||||
// Remove from sentNotifications when acted upon ---
|
||||
sentNotificationsMutex.Lock()
|
||||
delete(sentNotifications, fullImageName+":"+tagToIgnore)
|
||||
sentNotificationsMutex.Unlock()
|
||||
|
||||
err := sl.Add(fullImageName, tagToIgnore)
|
||||
if err != nil {
|
||||
logrus.Errorf("Error adding update to skip list: %v", err)
|
||||
@@ -151,6 +161,25 @@ func handleCallbackQuery(bot *tgbotapi.BotAPI, callback *tgbotapi.CallbackQuery,
|
||||
logrus.Infof("Update for %s with tag %s has been added to the skip list.", fullImageName, tagToIgnore)
|
||||
}
|
||||
}
|
||||
// Clear ALL related notifications/warnings for this image ---
|
||||
var fullImageName string
|
||||
if strings.HasPrefix(callbackData, "update_to:") {
|
||||
fullImageName, _, _ = decodeCallbackData(callbackData, "update_to")
|
||||
} else if strings.HasPrefix(callbackData, "ignore:") {
|
||||
fullImageName, _, _ = decodeCallbackData(callbackData, "ignore")
|
||||
}
|
||||
|
||||
if fullImageName != "" {
|
||||
sentNotificationsMutex.Lock()
|
||||
for key := range sentNotifications {
|
||||
if strings.HasPrefix(key, fullImageName+":") {
|
||||
delete(sentNotifications, key)
|
||||
}
|
||||
}
|
||||
sentNotificationsMutex.Unlock()
|
||||
logrus.Debugf("Cleared all pending notifications/warnings for %s after user action.", fullImageName)
|
||||
}
|
||||
|
||||
// Send confirmation to user and delete original message
|
||||
deleteConfig := tgbotapi.NewDeleteMessage(callback.Message.Chat.ID, callback.Message.MessageID)
|
||||
_, err := bot.Request(deleteConfig)
|
||||
@@ -248,23 +277,23 @@ func main() {
|
||||
// Initizle registry clients
|
||||
dockerHubClient := registry.NewDockerHubClient(appConfig.DockerUser, appConfig.DockerPass)
|
||||
ghcrClient := registry.NewGhcrClient()
|
||||
commonClients := make(map[string]*registry.CommonRegistryClient) // Created on-the-fly
|
||||
|
||||
runCheck(ctx, appConfig, bot, sl, dockerHubClient, ghcrClient)
|
||||
runCheck(ctx, appConfig, bot, sl, dockerHubClient, ghcrClient, commonClients)
|
||||
|
||||
// Main loop: periodic checks
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logrus.Infof("Shutting down...")
|
||||
return
|
||||
case <-ticker.C:
|
||||
runCheck(ctx, appConfig, bot, sl, dockerHubClient, ghcrClient)
|
||||
runCheck(ctx, appConfig, bot, sl, dockerHubClient, ghcrClient, commonClients)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runCheck checks all container images for updates and sends notifications if updates are available.
|
||||
func runCheck(ctx context.Context, appConfig *config.AppConfig, bot *tgbotapi.BotAPI, sl *skiplist.SkipList, dockerHubClient *registry.DockerHubClient, ghcrClient *registry.GhcrClient) {
|
||||
func runCheck(ctx context.Context, appConfig *config.AppConfig, bot *tgbotapi.BotAPI, sl *skiplist.SkipList, dockerHubClient *registry.DockerHubClient, ghcrClient *registry.GhcrClient, commonClients map[string]*registry.CommonRegistryClient) {
|
||||
logrus.Infof("Starting image update check...")
|
||||
|
||||
repoPath := appConfig.Git.LocalRepoPath
|
||||
@@ -286,12 +315,12 @@ func runCheck(ctx context.Context, appConfig *config.AppConfig, bot *tgbotapi.Bo
|
||||
logrus.Infof("Repository cloned or pulled successfully.")
|
||||
|
||||
// Get list of relevant files in the repository
|
||||
relevantFiles, err := checker.FindRelevantFiles(repoPath)
|
||||
relevantFiles, err := checker.FindRelevantFiles(repoPath, appConfig.Checker.WatchFolder)
|
||||
if err != nil {
|
||||
logrus.Errorf("Error finding relevant files: %v", err)
|
||||
return
|
||||
}
|
||||
logrus.Infof("Found %d relevant files.", len(relevantFiles))
|
||||
logrus.Infof("Found %d relevant files in watch folder '%s'.", len(relevantFiles), appConfig.Checker.WatchFolder) // Debug log
|
||||
|
||||
// Parse relevant files to extract services and their images
|
||||
services, err := checker.ParseRelevantFiles(relevantFiles)
|
||||
@@ -315,40 +344,62 @@ func runCheck(ctx context.Context, appConfig *config.AppConfig, bot *tgbotapi.Bo
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
logrus.Infof("Parsing image: Registry=%s, Image=%s, Tag=%s", services[0].ImageInfo.Registry, services[0].ImageInfo.Image, services[0].ImageInfo.Tag)
|
||||
currentImageTag := services[0].ImageInfo.Tag
|
||||
logrus.Infof("Parsing image: Registry=%s, Image=%s, Tag=%s", services[0].ImageInfo.Registry, services[0].ImageInfo.Image, currentImageTag)
|
||||
|
||||
if sl.IsSkipped(fullImageName, services[0].ImageInfo.Tag) {
|
||||
logrus.Infof("Skipping update for %s. It's in the skip list.", fullImageName)
|
||||
continue
|
||||
}
|
||||
|
||||
if sl.IsImageIgnored(fullImageName) {
|
||||
logrus.Infof("Image %s is globally ignored (skipped.yaml), skipping.", fullImageName)
|
||||
// Clear notifications if image is skipped/ignored ---
|
||||
if sl.IsSkipped(fullImageName, currentImageTag) || sl.IsImageIgnored(fullImageName) {
|
||||
if sl.IsSkipped(fullImageName, currentImageTag) {
|
||||
logrus.Infof("Skipping update for %s. It's in the skip list.", fullImageName)
|
||||
} else {
|
||||
logrus.Infof("Image %s is globally ignored (skipped.yaml), skipping.", fullImageName)
|
||||
}
|
||||
sentNotificationsMutex.Lock()
|
||||
for key := range sentNotifications {
|
||||
if strings.HasPrefix(key, fullImageName+":") {
|
||||
delete(sentNotifications, key)
|
||||
}
|
||||
}
|
||||
sentNotificationsMutex.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
var tags []string
|
||||
var err error
|
||||
registryName := services[0].ImageInfo.Registry
|
||||
|
||||
switch services[0].ImageInfo.Registry {
|
||||
switch registryName {
|
||||
case "docker.io":
|
||||
tags, err = dockerHubClient.GetTags(services[0].ImageInfo.Image)
|
||||
case "ghcr.io":
|
||||
tags, err = ghcrClient.GetTags(services[0].ImageInfo.Image)
|
||||
default:
|
||||
client, ok := commonClients[services[0].ImageInfo.Registry]
|
||||
if ok {
|
||||
tags, err = client.GetTags(services[0].ImageInfo.Image)
|
||||
} else {
|
||||
logrus.Infof("Skipping unsupported registry: %s", services[0].ImageInfo.Registry)
|
||||
continue
|
||||
// Create client on-the-fly ---
|
||||
client, ok := commonClients[registryName]
|
||||
if !ok {
|
||||
logrus.Debugf("Creating new common client for registry: %s", registryName)
|
||||
client = registry.NewCommonRegistryClient(registryName)
|
||||
commonClients[registryName] = client
|
||||
}
|
||||
tags, err = client.GetTags(services[0].ImageInfo.Image)
|
||||
}
|
||||
|
||||
logrus.Debugf("Avaliable tags for %s: %v", fullImageName, tags)
|
||||
|
||||
if err != nil {
|
||||
logrus.Errorf("Error getting tags for %s: %v", fullImageName, err)
|
||||
// --- MODIFICATION: Suppress duplicate error messages ---
|
||||
notificationKey := fullImageName + ":error_getting_tags"
|
||||
sentNotificationsMutex.Lock()
|
||||
if sentNotifications[notificationKey] {
|
||||
sentNotificationsMutex.Unlock()
|
||||
continue
|
||||
}
|
||||
sentNotifications[notificationKey] = true
|
||||
sentNotificationsMutex.Unlock()
|
||||
chatID, _ := strconv.ParseInt(appConfig.TelegramChatID, 10, 64)
|
||||
notifier.SendTelegramMessage(bot, chatID, fmt.Sprintf("❌ Error getting tags for %s: %v", fullImageName, err))
|
||||
// --- END MODIFICATION ---
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -356,7 +407,7 @@ func runCheck(ctx context.Context, appConfig *config.AppConfig, bot *tgbotapi.Bo
|
||||
latestTags := versionutils.SortAndFilterTags(tags, appConfig.Checker.ProposedTagsCount, appConfig.Checker.IgnoreKeywords)
|
||||
if len(latestTags) == 0 {
|
||||
logrus.Warnf("⚠️ Can't check updates for %s. Current tag: %s, it is not use SemVer template. Available tags: %v",
|
||||
fullImageName, services[0].ImageInfo.Tag, tags)
|
||||
fullImageName, currentImageTag, tags)
|
||||
|
||||
currentDigest, err1 := getDigestForImage(
|
||||
services[0].ImageInfo.Registry,
|
||||
@@ -364,6 +415,7 @@ func runCheck(ctx context.Context, appConfig *config.AppConfig, bot *tgbotapi.Bo
|
||||
services[0].ImageInfo.Tag,
|
||||
dockerHubClient,
|
||||
ghcrClient,
|
||||
commonClients, // Pass it here
|
||||
)
|
||||
latestDigest, err2 := getDigestForImage(
|
||||
services[0].ImageInfo.Registry,
|
||||
@@ -371,6 +423,7 @@ func runCheck(ctx context.Context, appConfig *config.AppConfig, bot *tgbotapi.Bo
|
||||
"latest",
|
||||
dockerHubClient,
|
||||
ghcrClient,
|
||||
commonClients, // And here
|
||||
)
|
||||
|
||||
logrus.Debugf("Digest fallback for %s: currentDigest=%s (err=%v), latestDigest=%s (err=%v)",
|
||||
@@ -378,9 +431,21 @@ func runCheck(ctx context.Context, appConfig *config.AppConfig, bot *tgbotapi.Bo
|
||||
|
||||
if err1 == nil && err2 == nil && currentDigest != "" && latestDigest != "" {
|
||||
if currentDigest != latestDigest {
|
||||
logrus.Warnf("🚨 Digest update for %s. Current tag: %s, 'latest' tag digest differs!", fullImageName, services[0].ImageInfo.Tag)
|
||||
logrus.Warnf("🚨 Digest update for %s. Current tag: %s, 'latest' tag digest differs!", fullImageName, currentImageTag)
|
||||
// --- MODIFICATION: Suppress duplicate digest update messages ---
|
||||
notificationKey := fullImageName + ":digest_update"
|
||||
sentNotificationsMutex.Lock()
|
||||
if sentNotifications[notificationKey] {
|
||||
sentNotificationsMutex.Unlock()
|
||||
continue
|
||||
}
|
||||
sentNotifications[notificationKey] = true
|
||||
sentNotificationsMutex.Unlock()
|
||||
chatID, _ := strconv.ParseInt(appConfig.TelegramChatID, 10, 64)
|
||||
notifier.SendTelegramMessage(bot, chatID, fmt.Sprintf("🚨 Digest update for %s. Current tag: %s, 'latest' tag digest differs!", fullImageName, currentImageTag))
|
||||
// --- END MODIFICATION ---
|
||||
} else {
|
||||
logrus.Infof("%s is actual by digest. Current tag: %s", fullImageName, services[0].ImageInfo.Tag)
|
||||
logrus.Infof("%s is actual by digest. Current tag: %s", fullImageName, currentImageTag)
|
||||
}
|
||||
} else {
|
||||
logrus.Errorf("Can't check updates for %s. No SemVer tags and no digest fallback.", fullImageName)
|
||||
@@ -390,15 +455,28 @@ func runCheck(ctx context.Context, appConfig *config.AppConfig, bot *tgbotapi.Bo
|
||||
|
||||
// Check the latest tag is newer than the current tag
|
||||
latestAvailable := latestTags[0]
|
||||
currentVersion, err := semver.NewVersion(services[0].ImageInfo.Tag)
|
||||
// --- MODIFICATION: Try to normalize current tag before parsing ---
|
||||
normalizedCurrentTag := versionutils.TryNormalizeTagForSemVer(currentImageTag)
|
||||
currentVersion, err := semver.NewVersion(strings.TrimPrefix(normalizedCurrentTag, "v"))
|
||||
// --- END MODIFICATION ---
|
||||
if err != nil {
|
||||
logrus.Warnf("⚠️ Can't check updates for %s. Current tag: %s, it is not use SemVer template. Available tags: %v",
|
||||
fullImageName, services[0].ImageInfo.Tag, tags)
|
||||
fullImageName, currentImageTag, tags)
|
||||
// --- MODIFICATION: Suppress duplicate warning messages ---
|
||||
notificationKey := fullImageName + ":" + currentImageTag + ":non_semver_warning"
|
||||
sentNotificationsMutex.Lock()
|
||||
if sentNotifications[notificationKey] {
|
||||
sentNotificationsMutex.Unlock()
|
||||
continue
|
||||
}
|
||||
sentNotifications[notificationKey] = true
|
||||
sentNotificationsMutex.Unlock()
|
||||
chatID, _ := strconv.ParseInt(appConfig.TelegramChatID, 10, 64)
|
||||
notifier.SendTelegramMessage(bot, chatID, fmt.Sprintf("⚠️ Can't check updates for %s. Current tag: %s, it is not use SemVer template.", fullImageName, services[0].ImageInfo.Tag))
|
||||
notifier.SendTelegramMessage(bot, chatID, fmt.Sprintf("⚠️ Can't check updates for %s. Current tag: %s, it is not use SemVer template.", fullImageName, currentImageTag))
|
||||
// --- END MODIFICATION ---
|
||||
continue
|
||||
}
|
||||
latestVersion, _ := semver.NewVersion(latestAvailable)
|
||||
latestVersion, _ := semver.NewVersion(strings.TrimPrefix(latestAvailable, "v"))
|
||||
|
||||
if latestVersion.GreaterThan(currentVersion) {
|
||||
for _, tag := range latestTags {
|
||||
@@ -411,6 +489,19 @@ func runCheck(ctx context.Context, appConfig *config.AppConfig, bot *tgbotapi.Bo
|
||||
logrus.Infof("Tag %s for %s is already in use. Skipping notification.", tag, fullImageName)
|
||||
continue
|
||||
}
|
||||
|
||||
// --- NEW LOGIC: Check if notification already sent ---
|
||||
notificationKey := fullImageName + ":" + tag
|
||||
sentNotificationsMutex.Lock()
|
||||
if sentNotifications[notificationKey] {
|
||||
logrus.Infof("Notification for %s with tag %s already sent. Skipping duplicate.", fullImageName, tag)
|
||||
sentNotificationsMutex.Unlock()
|
||||
continue
|
||||
}
|
||||
sentNotifications[notificationKey] = true // Mark as sent
|
||||
sentNotificationsMutex.Unlock()
|
||||
// --- END NEW LOGIC ---
|
||||
|
||||
logrus.Infof("🚨 New update for %s. Current tag: %s, Available: %s", fullImageName, services[0].ImageInfo.Tag, tag)
|
||||
// Generate unique ID for this update
|
||||
// Using timestamp for simplicity; in production, consider a more robust method
|
||||
@@ -443,8 +534,22 @@ func runCheck(ctx context.Context, appConfig *config.AppConfig, bot *tgbotapi.Bo
|
||||
}
|
||||
notifier.SendInteractiveMessage(bot, appConfig.TelegramChatID, message, []string{tag}, callbackData)
|
||||
}
|
||||
// --- MODIFICATION: Clear warnings if an update is found ---
|
||||
sentNotificationsMutex.Lock()
|
||||
delete(sentNotifications, fullImageName+":"+currentImageTag+":non_semver_warning")
|
||||
sentNotificationsMutex.Unlock()
|
||||
// --- END MODIFICATION ---
|
||||
} else {
|
||||
logrus.Infof("✅ %s is actual. Current tag: %s", fullImageName, services[0].ImageInfo.Tag)
|
||||
logrus.Infof("✅ %s is actual. Current tag: %s", fullImageName, currentImageTag)
|
||||
// --- NEW LOGIC: Clear any pending notifications for this image if it's now actual ---
|
||||
sentNotificationsMutex.Lock()
|
||||
for key := range sentNotifications {
|
||||
if strings.HasPrefix(key, fullImageName+":") {
|
||||
delete(sentNotifications, key)
|
||||
}
|
||||
}
|
||||
sentNotificationsMutex.Unlock()
|
||||
// --- END NEW LOGIC ---
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,7 +571,7 @@ func encodeCallbackData(prefix, fullImageName, tag string) string {
|
||||
}
|
||||
|
||||
// Get image digest for given registry, image and tag
|
||||
func getDigestForImage(registryName, image, tag string, dockerHubClient *registry.DockerHubClient, ghcrClient *registry.GhcrClient) (string, error) {
|
||||
func getDigestForImage(registryName, image, tag string, dockerHubClient *registry.DockerHubClient, ghcrClient *registry.GhcrClient, commonClients map[string]*registry.CommonRegistryClient) (string, error) {
|
||||
switch registryName {
|
||||
case "docker.io":
|
||||
return dockerHubClient.GetDigest(image, tag)
|
||||
|
||||
-1
Submodule repos/myrepo deleted from 27608766af
Reference in New Issue
Block a user