Fix docker exporter digest checks for multi-arch images
This commit is contained in:
@@ -6,7 +6,6 @@ import time
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import docker
|
import docker
|
||||||
import requests
|
|
||||||
import yaml
|
import yaml
|
||||||
from prometheus_client import Gauge, start_http_server
|
from prometheus_client import Gauge, start_http_server
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ logger = logging.getLogger("docker-update-exporter")
|
|||||||
# --- Config ---
|
# --- Config ---
|
||||||
EXPORTER_PORT = 9105
|
EXPORTER_PORT = 9105
|
||||||
CHECK_INTERVAL = 60
|
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")
|
SERVICES_UP_SCRIPT = os.getenv("SERVICES_UP_SCRIPT", "/compose/services-up.sh")
|
||||||
CACHE_FILE = os.getenv("CACHE_FILE", "/data/remote_digest_cache.json")
|
CACHE_FILE = os.getenv("CACHE_FILE", "/data/remote_digest_cache.json")
|
||||||
DRY_RUN = os.getenv("DRY_RUN", "false").lower() in ("1", "true", "yes")
|
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}")
|
logger.error(f"Failed to save cache: {e}")
|
||||||
|
|
||||||
REMOTE_DIGEST_CACHE = load_cache()
|
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 ---
|
# --- Helpers ---
|
||||||
def get_project_prefix_from_script(script_path):
|
def get_project_prefix_from_script(script_path):
|
||||||
@@ -108,78 +113,71 @@ def get_project_prefix_from_script(script_path):
|
|||||||
return prefix
|
return prefix
|
||||||
|
|
||||||
def get_local_digest(image_name):
|
def get_local_digest(image_name):
|
||||||
|
"""
|
||||||
|
Return the local digest for the specific image reference.
|
||||||
|
"""
|
||||||
if client is None:
|
if client is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
img = client.images.get(image_name)
|
img = client.images.get(image_name)
|
||||||
digests = img.attrs.get("RepoDigests", [])
|
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}")
|
logger.debug(f"Local digest for {image_name}: {digest}")
|
||||||
return digest
|
return digest
|
||||||
logger.debug(f"No local digest found for {image_name}")
|
|
||||||
except Exception:
|
logger.debug(f"No RepoDigest found for {image_name}")
|
||||||
pass
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not get local digest for {image_name}: {e}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_remote_digest(image_name):
|
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()
|
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:
|
if now - ts < CACHE_TTL:
|
||||||
|
logger.debug(f"Using cached remote digest for {image_name}: {digest}")
|
||||||
return digest
|
return digest
|
||||||
|
|
||||||
|
if client is None:
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if "/" not in image_name:
|
registry_data = client.images.get_registry_data(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
|
|
||||||
|
|
||||||
if ":" in repo:
|
digest = None
|
||||||
repo, tag = repo.rsplit(":", 1)
|
|
||||||
else:
|
|
||||||
tag = "latest"
|
|
||||||
|
|
||||||
if registry in ["docker.io", "registry-1.docker.io"]:
|
# docker SDK versions differ; try the common fields in order
|
||||||
token_res = requests.get(
|
if hasattr(registry_data, "id") and registry_data.id:
|
||||||
"https://auth.docker.io/token",
|
digest = registry_data.id
|
||||||
params={"service": "registry.docker.io", "scope": f"repository:{repo}:pull"},
|
elif hasattr(registry_data, "attrs"):
|
||||||
timeout=10
|
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:
|
if digest:
|
||||||
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")
|
|
||||||
REMOTE_DIGEST_CACHE[image_name] = (digest, now)
|
REMOTE_DIGEST_CACHE[image_name] = (digest, now)
|
||||||
save_cache()
|
save_cache()
|
||||||
|
logger.debug(f"Remote digest for {image_name}: {digest}")
|
||||||
return digest
|
return digest
|
||||||
|
|
||||||
|
logger.warning(f"No remote digest found for {image_name}")
|
||||||
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error fetching remote digest for {image_name}: {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_target = compose_image or running
|
||||||
remote_digest = get_remote_digest(remote_target)
|
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:
|
if local_digest and remote_digest and local_digest != remote_digest:
|
||||||
update_flag = 1
|
update_flag = 1
|
||||||
|
|
||||||
@@ -480,6 +488,12 @@ if __name__ == "__main__":
|
|||||||
SERVICES_UP_SCRIPT = args.services_up_script
|
SERVICES_UP_SCRIPT = args.services_up_script
|
||||||
CACHE_FILE = args.cache_file
|
CACHE_FILE = args.cache_file
|
||||||
REMOTE_DIGEST_CACHE = load_cache()
|
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:
|
if DRY_RUN or args.dry_run:
|
||||||
dump_service_image_mapping()
|
dump_service_image_mapping()
|
||||||
|
|||||||
Reference in New Issue
Block a user