Merge pull request #6 from beatz174-bit/codex/fix-docker-update-exporter-for-multi-arch-images

Fix docker-exporter digest detection for multi-arch images and shorten cache TTL
This commit is contained in:
beatz174-bit
2026-04-05 16:42:51 +10:00
committed by GitHub
+66 -52
View File
@@ -6,7 +6,6 @@ import time
import json
import logging
import docker
import requests
import yaml
from prometheus_client import Gauge, start_http_server
@@ -21,7 +20,7 @@ logger = logging.getLogger("docker-update-exporter")
# --- Config ---
EXPORTER_PORT = 9105
CHECK_INTERVAL = 60
CACHE_TTL = 6 * 3600
CACHE_TTL = int(os.getenv("CACHE_TTL", "300"))
SERVICES_UP_SCRIPT = os.getenv("SERVICES_UP_SCRIPT", "/compose/services-up.sh")
CACHE_FILE = os.getenv("CACHE_FILE", "/data/remote_digest_cache.json")
DRY_RUN = os.getenv("DRY_RUN", "false").lower() in ("1", "true", "yes")
@@ -91,6 +90,12 @@ def save_cache():
logger.error(f"Failed to save cache: {e}")
REMOTE_DIGEST_CACHE = load_cache()
now = time.time()
REMOTE_DIGEST_CACHE = {
image: (digest, ts)
for image, (digest, ts) in REMOTE_DIGEST_CACHE.items()
if now - ts < CACHE_TTL
}
# --- Helpers ---
def get_project_prefix_from_script(script_path):
@@ -108,78 +113,71 @@ def get_project_prefix_from_script(script_path):
return prefix
def get_local_digest(image_name):
"""
Return the local digest for the specific image reference.
"""
if client is None:
return None
try:
img = client.images.get(image_name)
digests = img.attrs.get("RepoDigests", [])
if digests:
digest = digests[0].split("@")[1]
logger.debug(f"RepoDigests for {image_name}: {digests}")
for entry in digests:
if "@" in entry:
digest = entry.split("@", 1)[1]
logger.debug(f"Local digest for {image_name}: {digest}")
return digest
logger.debug(f"No local digest found for {image_name}")
except Exception:
pass
logger.debug(f"No RepoDigest found for {image_name}")
except Exception as e:
logger.debug(f"Could not get local digest for {image_name}: {e}")
return None
def get_remote_digest(image_name):
"""
Return the upstream digest for the exact platform-specific image that Docker
would pull on this host. This avoids false positives with multi-arch images
where the registry manifest-list digest differs from the pulled image digest.
"""
now = time.time()
if image_name in REMOTE_DIGEST_CACHE:
digest, ts = REMOTE_DIGEST_CACHE[image_name]
cached = REMOTE_DIGEST_CACHE.get(image_name)
if cached:
digest, ts = cached
if now - ts < CACHE_TTL:
logger.debug(f"Using cached remote digest for {image_name}: {digest}")
return digest
if client is None:
return None
try:
if "/" not in image_name:
registry = "docker.io"
repo = "library/" + image_name
else:
parts = image_name.split("/")
if "." in parts[0] or ":" in parts[0]:
registry = parts[0]
repo = "/".join(parts[1:])
else:
registry = "docker.io"
repo = image_name
registry_data = client.images.get_registry_data(image_name)
if ":" in repo:
repo, tag = repo.rsplit(":", 1)
else:
tag = "latest"
digest = None
if registry in ["docker.io", "registry-1.docker.io"]:
token_res = requests.get(
"https://auth.docker.io/token",
params={"service": "registry.docker.io", "scope": f"repository:{repo}:pull"},
timeout=10
# docker SDK versions differ; try the common fields in order
if hasattr(registry_data, "id") and registry_data.id:
digest = registry_data.id
elif hasattr(registry_data, "attrs"):
digest = (
registry_data.attrs.get("Descriptor", {}).get("digest")
or registry_data.attrs.get("digest")
)
token = token_res.json().get("token")
manifest_url = f"https://registry-1.docker.io/v2/{repo}/manifests/{tag}"
elif registry == "ghcr.io":
token_res = requests.get(
"https://ghcr.io/token",
params={"service": "ghcr.io", "scope": f"repository:{repo}:pull"},
timeout=10
)
token = token_res.json().get("token")
manifest_url = f"https://ghcr.io/v2/{repo}/manifests/{tag}"
else:
logger.warning(f"Unsupported registry {registry} for {image_name}")
return None
if not token:
return None
res = requests.get(
manifest_url,
headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.docker.distribution.manifest.v2+json"},
timeout=10
)
if res.status_code == 200:
digest = res.headers.get("Docker-Content-Digest")
if digest:
REMOTE_DIGEST_CACHE[image_name] = (digest, now)
save_cache()
logger.debug(f"Remote digest for {image_name}: {digest}")
return digest
logger.warning(f"No remote digest found for {image_name}")
return None
except Exception as e:
logger.debug(f"Error fetching remote digest for {image_name}: {e}")
@@ -431,6 +429,16 @@ def check_containers():
remote_target = compose_image or running
remote_digest = get_remote_digest(remote_target)
logger.info(
"Digest comparison: container=%s service=%s running=%s target=%s local=%s remote=%s",
container.name,
svc,
running,
remote_target,
local_digest,
remote_digest,
)
if local_digest and remote_digest and local_digest != remote_digest:
update_flag = 1
@@ -480,6 +488,12 @@ if __name__ == "__main__":
SERVICES_UP_SCRIPT = args.services_up_script
CACHE_FILE = args.cache_file
REMOTE_DIGEST_CACHE = load_cache()
now = time.time()
REMOTE_DIGEST_CACHE = {
image: (digest, ts)
for image, (digest, ts) in REMOTE_DIGEST_CACHE.items()
if now - ts < CACHE_TTL
}
if DRY_RUN or args.dry_run:
dump_service_image_mapping()