new file: crowdsec/Dockerfile
new file: default-environment.env new file: docker-compose.yml new file: docker-exporter/Dockerfile new file: docker-exporter/Dockerfile.old new file: docker-exporter/TEST_EXAMPLE.md new file: docker-exporter/exporter.py new file: docker-exporter/exporter.py.old new file: services-up.sh new file: telegraf.conf new file: traefik/certs/.gitkeep new file: traefik/dynamic.yml new file: traefik/letsencrypt/acme.json new file: traefik/traefik.yml
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
FROM crowdsecurity/crowdsec:latest
|
||||||
|
|
||||||
|
COPY config/ /etc/crowdsec
|
||||||
|
# Install required components at build time
|
||||||
|
RUN cscli hub update && \
|
||||||
|
cscli collections install crowdsecurity/traefik --force
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
PROJECT_ROOT=/home/raspi/raspi
|
||||||
|
REMOTE_PROJECT_ROOT=/home/nixos/raspi
|
||||||
|
DOMAIN=lan.ddnsgeek.com
|
||||||
|
TZ=Australia/Brisbane
|
||||||
|
EMAIL=wayne.bennett@live.com
|
||||||
|
|
||||||
|
# Docker Socket Proxy
|
||||||
|
DOCKER_SOCKET_PROXY_LOG_LEVEL=info
|
||||||
|
DOCKER_SOCKET_PROXY_HOST=tcp://docker-socket-proxy:2375
|
||||||
|
|
||||||
|
# Docker Update Exporter
|
||||||
|
DOCKER_EXPORTER_LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Crowdsec
|
||||||
|
CROWDSEC_COLLECTIONS=crowdsecurity/traefik
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
profiles: ["all"]
|
||||||
|
image: traefik:3
|
||||||
|
container_name: traefik
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- docker-socket-proxy
|
||||||
|
# environment:
|
||||||
|
# TRAEFIK_PROVIDERS_DOCKER_ENDPOINT: ${DOCKER_SOCKET_PROXY_HOST}
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ${PROJECT_ROOT}/traefik/letsencrypt:/letsencrypt
|
||||||
|
- ${PROJECT_ROOT}/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ${PROJECT_ROOT}/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
|
||||||
|
uptime-kuma:
|
||||||
|
profiles: ["all"]
|
||||||
|
image: louislam/uptime-kuma
|
||||||
|
container_name: uptime-kuma
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- traefik
|
||||||
|
- docker-socket-proxy
|
||||||
|
environment:
|
||||||
|
DOCKER_HOST: ${DOCKER_SOCKET_PROXY_HOST}
|
||||||
|
volumes:
|
||||||
|
- ${PROJECT_ROOT}/uptime-kuma:/app/data
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
|
||||||
|
# Router
|
||||||
|
- traefik.http.routers.kuma.rule=Host(`kuma.lan.ddnsgeek.com`)
|
||||||
|
- traefik.http.routers.kuma.entrypoints=websecure
|
||||||
|
- traefik.http.routers.kuma.tls=true
|
||||||
|
- traefik.http.routers.kuma.tls.certresolver=le
|
||||||
|
|
||||||
|
# Service -> container port
|
||||||
|
- traefik.http.services.kuma.loadbalancer.server.port=3001
|
||||||
|
# - traefik.http.routers.kuma.middlewares=default-chain@file
|
||||||
|
# ports:
|
||||||
|
# - 8080:3001
|
||||||
|
|
||||||
|
portainer-agent:
|
||||||
|
profiles: ["all"]
|
||||||
|
image: portainer/agent
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- docker-socket-proxy
|
||||||
|
ports:
|
||||||
|
- 9001:9001
|
||||||
|
container_name: portainer-agent
|
||||||
|
environment:
|
||||||
|
DOCKER_HOST: ${DOCKER_SOCKET_PROXY_HOST}
|
||||||
|
volumes:
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /var/lib/docker/volumes:/var/lib/docker/volumes
|
||||||
|
- /:/host
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
|
||||||
|
crowdsec:
|
||||||
|
profiles: ["all"]
|
||||||
|
image: crowdsecurity/crowdsec:latest
|
||||||
|
# build: ./crowdsec
|
||||||
|
container_name: crowdsec
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- COLLECTIONS=${CROWDSEC_COLLECTIONS}
|
||||||
|
volumes:
|
||||||
|
- ${PROJECT_ROOT}/data/logs:/logs:ro
|
||||||
|
- ${PROJECT_ROOT}/crowdsec/data:/var/lib/crowdsec/data
|
||||||
|
- ${PROJECT_ROOT}/crowdsec/config:/etc/crowdsec
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
|
||||||
|
docker-update-exporter:
|
||||||
|
profiles: ["all"]
|
||||||
|
build: ${REMOTE_PROJECT_ROOT}/docker-exporter
|
||||||
|
container_name: docker-update-exporter
|
||||||
|
volumes:
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ${PROJECT_ROOT}:/compose:ro
|
||||||
|
# - ${PROJECT_ROOT}/docker-compose.yml:/compose/docker-compose.yml:ro
|
||||||
|
- ${REMOTE_PROJECT_ROOT}/docker-exporter/data:/data:rw
|
||||||
|
- ${PROJECT_ROOT}/.docker/config.json:/root/.docker/config.json:ro
|
||||||
|
environment:
|
||||||
|
LOG_LEVEL: ${DOCKER_EXPORTER_LOG_LEVEL}
|
||||||
|
DOCKER_HOST: ${DOCKER_SOCKET_PROXY_HOST}
|
||||||
|
ports:
|
||||||
|
- "9105:9105"
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- docker-socket-proxy
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9105/metrics')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
telegraf:
|
||||||
|
profiles: ["monitoring","all","prometheus"]
|
||||||
|
image: telegraf:latest
|
||||||
|
# group_add:
|
||||||
|
# - "123"
|
||||||
|
# privileged: true
|
||||||
|
container_name: telegraf
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- docker-socket-proxy
|
||||||
|
# cap_drop:
|
||||||
|
# - ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
volumes:
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ${PROJECT_ROOT}/telegraf.conf:/etc/telegraf/telegraf.conf:ro
|
||||||
|
networks:
|
||||||
|
# - edge
|
||||||
|
- proxy
|
||||||
|
ports:
|
||||||
|
- 9273:9273
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:9273/metrics || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
docker-socket-proxy:
|
||||||
|
profiles: ["monitoring","all","prometheus","prometheus-exporters"]
|
||||||
|
image: tecnativa/docker-socket-proxy:latest
|
||||||
|
container_name: docker-socket-proxy
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
LOG_LEVEL: ${DOCKER_SOCKET_PROXY_LOG_LEVEL}
|
||||||
|
DISTRIBUTION: 1
|
||||||
|
CONTAINERS: 1
|
||||||
|
EVENTS: 1
|
||||||
|
IMAGES: 1
|
||||||
|
INFO: 1
|
||||||
|
NETWORKS: 1
|
||||||
|
PING: 1
|
||||||
|
POST: 1
|
||||||
|
SERVICES: 1
|
||||||
|
TASKS: 1
|
||||||
|
VERSION: 1
|
||||||
|
VOLUMES: 1
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
name: proxy
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
#RUN groupadd -g 1000 appuser || true && \
|
||||||
|
# useradd -m -u 1000 -g 1000 -s /bin/bash appuser
|
||||||
|
#RUN groupadd -g 999 docker || true && usermod -aG docker appuser
|
||||||
|
WORKDIR /app
|
||||||
|
COPY exporter.py .
|
||||||
|
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir docker prometheus_client requests pyyaml
|
||||||
|
|
||||||
|
#USER appuser
|
||||||
|
|
||||||
|
CMD ["python", "-u", "exporter.py"]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY exporter.py .
|
||||||
|
|
||||||
|
RUN pip install docker prometheus_client requests pyyaml
|
||||||
|
|
||||||
|
CMD ["python", "exporter.py"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Exporter image-mapping test example
|
||||||
|
|
||||||
|
Run the exporter in dry-run mode to print the `service -> image:tag` mapping without starting the metrics loop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SERVICES_UP_SCRIPT=/workspace/docker/services-up.sh python monitoring/docker-exporter/exporter.py --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output excerpt:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"crowdsec": "crowdsecurity/crowdsec:latest",
|
||||||
|
"docker-update-exporter": "python:3.11-slim",
|
||||||
|
"nextcloud-webapp": "nextcloud:production",
|
||||||
|
"node-red": "nodered/node-red:latest",
|
||||||
|
"prometheus": "prom/prometheus:latest",
|
||||||
|
"traefik": "traefik:3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This confirms the exporter now reports images for both:
|
||||||
|
- services with explicit `image:` values, and
|
||||||
|
- services using `build:` contexts.
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import docker
|
||||||
|
import yaml
|
||||||
|
from prometheus_client import Gauge, start_http_server
|
||||||
|
|
||||||
|
# --- Logging ---
|
||||||
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper()
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, LOG_LEVEL, logging.DEBUG),
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("docker-update-exporter")
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
EXPORTER_PORT = 9105
|
||||||
|
CHECK_INTERVAL = 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")
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = docker.from_env()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Docker client unavailable at startup: {e}")
|
||||||
|
client = None
|
||||||
|
|
||||||
|
# --- Metrics ---
|
||||||
|
CONTAINER_UPDATE = Gauge(
|
||||||
|
"docker_container_update_available",
|
||||||
|
"1 if container image is out of date (compose drift or registry), 0 otherwise",
|
||||||
|
["container", "compose_image", "running_image", "com_docker_compose_project"]
|
||||||
|
)
|
||||||
|
|
||||||
|
LAST_CHECK = Gauge(
|
||||||
|
"docker_image_update_last_check_timestamp",
|
||||||
|
"Last time the update check ran (unix timestamp)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_container_update_metric(container_name, compose_image, running_image, project_name, update_flag):
|
||||||
|
"""Set update metric for a container and log the emitted metric payload."""
|
||||||
|
metric_labels = {
|
||||||
|
"container": container_name,
|
||||||
|
"compose_image": compose_image or "unknown",
|
||||||
|
"running_image": running_image,
|
||||||
|
"com_docker_compose_project": project_name,
|
||||||
|
}
|
||||||
|
CONTAINER_UPDATE.labels(**metric_labels).set(update_flag)
|
||||||
|
logger.info(
|
||||||
|
"Metric emitted: docker_container_update_available=%s labels=%s",
|
||||||
|
update_flag,
|
||||||
|
metric_labels,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_last_check_metric():
|
||||||
|
"""Set and log the timestamp for the most recent check cycle."""
|
||||||
|
ts = time.time()
|
||||||
|
LAST_CHECK.set(ts)
|
||||||
|
logger.info("Metric emitted: docker_image_update_last_check_timestamp=%s", ts)
|
||||||
|
|
||||||
|
# --- Persistent Cache ---
|
||||||
|
def load_cache():
|
||||||
|
if not os.path.exists(CACHE_FILE):
|
||||||
|
logger.info(f"Cache file does not exist yet: {CACHE_FILE}")
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(CACHE_FILE, "r") as f:
|
||||||
|
cache = json.load(f)
|
||||||
|
logger.info(f"Loaded {len(cache)} cached remote digests")
|
||||||
|
return cache
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load cache: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_cache():
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
|
||||||
|
with open(CACHE_FILE, "w") as f:
|
||||||
|
json.dump(REMOTE_DIGEST_CACHE, f)
|
||||||
|
logger.debug(f"Saved {len(REMOTE_DIGEST_CACHE)} remote digests to cache")
|
||||||
|
except Exception as e:
|
||||||
|
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):
|
||||||
|
prefix = "core-"
|
||||||
|
if not os.path.exists(script_path):
|
||||||
|
return prefix
|
||||||
|
try:
|
||||||
|
with open(script_path) as f:
|
||||||
|
for line in f:
|
||||||
|
m = re.match(r'PROJECT\s*=\s*["\']?([^"\']+)', line)
|
||||||
|
if m:
|
||||||
|
return m.group(1) + "-"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed reading project prefix: {e}")
|
||||||
|
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", [])
|
||||||
|
|
||||||
|
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 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()
|
||||||
|
|
||||||
|
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:
|
||||||
|
registry_data = client.images.get_registry_data(image_name)
|
||||||
|
|
||||||
|
digest = None
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- Dockerfile Image Extraction ---
|
||||||
|
def parse_dockerfile_for_image(dockerfile_path):
|
||||||
|
if not os.path.exists(dockerfile_path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
arg_defaults = {}
|
||||||
|
last_from = None
|
||||||
|
with open(dockerfile_path) as df:
|
||||||
|
for line in df:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.upper().startswith("ARG "):
|
||||||
|
arg_body = line[4:].strip()
|
||||||
|
if "=" in arg_body:
|
||||||
|
key, value = arg_body.split("=", 1)
|
||||||
|
arg_defaults[key.strip()] = value.strip()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Prefer LABEL with image if present.
|
||||||
|
if "LABEL" in line and "image=" in line:
|
||||||
|
match = re.search(r'image=["\']?([^"\']+)["\']?', line)
|
||||||
|
if match:
|
||||||
|
image_name = normalize_image_name(substitute_dockerfile_args(match.group(1), arg_defaults))
|
||||||
|
logger.debug(f"Found LABEL image={image_name} in {dockerfile_path}")
|
||||||
|
return image_name
|
||||||
|
|
||||||
|
if line.upper().startswith("FROM "):
|
||||||
|
from_clause = line[5:].strip()
|
||||||
|
if from_clause.startswith("--"):
|
||||||
|
split_clause = from_clause.split(None, 1)
|
||||||
|
if len(split_clause) < 2:
|
||||||
|
continue
|
||||||
|
from_clause = split_clause[1]
|
||||||
|
|
||||||
|
parts = from_clause.split()
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate = substitute_dockerfile_args(parts[0], arg_defaults)
|
||||||
|
if candidate and candidate.lower() != "scratch":
|
||||||
|
last_from = normalize_image_name(candidate)
|
||||||
|
|
||||||
|
if last_from:
|
||||||
|
logger.debug(f"Found base FROM {last_from} in {dockerfile_path}")
|
||||||
|
return last_from
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error reading Dockerfile {dockerfile_path}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def normalize_image_name(image_name):
|
||||||
|
if not image_name:
|
||||||
|
return None
|
||||||
|
if "@" in image_name:
|
||||||
|
return image_name
|
||||||
|
if ":" in image_name.rsplit("/", 1)[-1]:
|
||||||
|
return image_name
|
||||||
|
return f"{image_name}:latest"
|
||||||
|
|
||||||
|
def is_compose_build_placeholder(image_name, project_name):
|
||||||
|
if not image_name:
|
||||||
|
return False
|
||||||
|
candidate = str(image_name)
|
||||||
|
project_prefix = f"{project_name}-"
|
||||||
|
if candidate.startswith(project_prefix):
|
||||||
|
return True
|
||||||
|
# Keep backward-compatible behavior for historical default project prefix.
|
||||||
|
return candidate.startswith("core-")
|
||||||
|
|
||||||
|
def substitute_dockerfile_args(value, arg_defaults):
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
pattern = re.compile(r"\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)")
|
||||||
|
|
||||||
|
def replacer(match):
|
||||||
|
expr = match.group(1)
|
||||||
|
simple = match.group(2)
|
||||||
|
if simple:
|
||||||
|
return arg_defaults.get(simple, "")
|
||||||
|
|
||||||
|
if ":-" in expr:
|
||||||
|
var_name, default_value = expr.split(":-", 1)
|
||||||
|
return arg_defaults.get(var_name, default_value)
|
||||||
|
if "-" in expr:
|
||||||
|
var_name, default_value = expr.split("-", 1)
|
||||||
|
return arg_defaults.get(var_name, default_value)
|
||||||
|
return arg_defaults.get(expr, "")
|
||||||
|
|
||||||
|
return pattern.sub(replacer, value)
|
||||||
|
|
||||||
|
def expand_compose_path(path_value, project_root):
|
||||||
|
raw = str(path_value)
|
||||||
|
raw = raw.replace("${PROJECT_ROOT}", project_root).replace("$PROJECT_ROOT", project_root)
|
||||||
|
return os.path.expandvars(raw)
|
||||||
|
|
||||||
|
def get_project_root_from_script(script_path):
|
||||||
|
if not script_path:
|
||||||
|
return os.getcwd()
|
||||||
|
return os.path.dirname(os.path.abspath(script_path))
|
||||||
|
|
||||||
|
# --- Compose parsing ---
|
||||||
|
def get_compose_files_from_script(script_path):
|
||||||
|
files = []
|
||||||
|
if not os.path.exists(script_path):
|
||||||
|
return files
|
||||||
|
base_dir = get_project_root_from_script(script_path)
|
||||||
|
try:
|
||||||
|
with open(script_path) as f:
|
||||||
|
content = f.read()
|
||||||
|
match = re.search(r'FILES\s*=\s*\((.*?)\)', content, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
for line in match.group(1).splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("-f"):
|
||||||
|
path = line[2:].strip()
|
||||||
|
if path:
|
||||||
|
full = os.path.normpath(os.path.join(base_dir, path))
|
||||||
|
files.append(full)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed parsing services-up.sh: {e}")
|
||||||
|
return files
|
||||||
|
|
||||||
|
def parse_project_name_from_script(script_path):
|
||||||
|
project = "core"
|
||||||
|
if not os.path.exists(script_path):
|
||||||
|
return project
|
||||||
|
try:
|
||||||
|
with open(script_path) as f:
|
||||||
|
for line in f:
|
||||||
|
m = re.match(r'PROJECT\s*=\s*["\']?([^"\']+)', line)
|
||||||
|
if m:
|
||||||
|
project = m.group(1)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed reading project name: {e}")
|
||||||
|
return project
|
||||||
|
|
||||||
|
def resolve_local_build_image(service_name, project_name):
|
||||||
|
if client is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
images = client.images.list(filters={"label": f"com.docker.compose.service={service_name}"})
|
||||||
|
for image in images:
|
||||||
|
labels = image.attrs.get("Config", {}).get("Labels", {}) or {}
|
||||||
|
if labels.get("com.docker.compose.project") != project_name:
|
||||||
|
continue
|
||||||
|
for tag in image.tags:
|
||||||
|
if tag and "<none>" not in tag:
|
||||||
|
logger.debug(f"Resolved local compose image for {service_name}: {tag}")
|
||||||
|
return normalize_image_name(tag)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not inspect local build metadata for {service_name}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_compose_services(compose_files, project_name, project_root):
|
||||||
|
svc_map = {}
|
||||||
|
for f in compose_files:
|
||||||
|
if not os.path.exists(f):
|
||||||
|
logger.warning(f"Compose file from services-up.sh is missing: {f}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f) as stream:
|
||||||
|
data = yaml.safe_load(stream) or {}
|
||||||
|
for svc_name, svc_def in data.get("services", {}).items():
|
||||||
|
image = normalize_image_name(svc_def.get("image"))
|
||||||
|
profiles = svc_def.get("profiles", [])
|
||||||
|
build_ctx = svc_def.get("build")
|
||||||
|
dockerfile_path = None
|
||||||
|
from_dockerfile = None
|
||||||
|
local_built_image = None
|
||||||
|
|
||||||
|
if build_ctx:
|
||||||
|
if isinstance(build_ctx, dict):
|
||||||
|
context = build_ctx.get("context", ".")
|
||||||
|
dockerfile = build_ctx.get("dockerfile", "Dockerfile")
|
||||||
|
else:
|
||||||
|
context = build_ctx
|
||||||
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
|
compose_dir = os.path.dirname(f)
|
||||||
|
context_expanded = expand_compose_path(context, project_root)
|
||||||
|
if os.path.isabs(context_expanded):
|
||||||
|
context_path = context_expanded
|
||||||
|
else:
|
||||||
|
context_path = os.path.normpath(os.path.join(compose_dir, context_expanded))
|
||||||
|
dockerfile_expanded = expand_compose_path(dockerfile, project_root)
|
||||||
|
dockerfile_path = os.path.normpath(os.path.join(context_path, dockerfile_expanded))
|
||||||
|
from_dockerfile = normalize_image_name(parse_dockerfile_for_image(dockerfile_path))
|
||||||
|
local_built_image = resolve_local_build_image(svc_name, project_name)
|
||||||
|
|
||||||
|
placeholder_image = is_compose_build_placeholder(image, project_name) or is_compose_build_placeholder(local_built_image, project_name)
|
||||||
|
if placeholder_image:
|
||||||
|
resolved_image = from_dockerfile or image or local_built_image or f"{project_name}-{svc_name}:latest"
|
||||||
|
else:
|
||||||
|
resolved_image = image or local_built_image or from_dockerfile or f"{project_name}-{svc_name}:latest"
|
||||||
|
|
||||||
|
svc_map[svc_name] = {
|
||||||
|
"image": resolved_image,
|
||||||
|
"profiles": profiles,
|
||||||
|
"build_context": build_ctx,
|
||||||
|
"compose_file": f,
|
||||||
|
"dockerfile": dockerfile_path
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed parsing {f}: {e}")
|
||||||
|
|
||||||
|
logger.debug(f"Service image mapping: {svc_map}")
|
||||||
|
return svc_map
|
||||||
|
|
||||||
|
# --- Main check ---
|
||||||
|
def check_containers():
|
||||||
|
if client is None:
|
||||||
|
logger.error("Docker client is unavailable; skipping check cycle")
|
||||||
|
return
|
||||||
|
|
||||||
|
set_last_check_metric()
|
||||||
|
CONTAINER_UPDATE.clear()
|
||||||
|
|
||||||
|
project_name = parse_project_name_from_script(SERVICES_UP_SCRIPT)
|
||||||
|
project_root = get_project_root_from_script(SERVICES_UP_SCRIPT)
|
||||||
|
compose_files = get_compose_files_from_script(SERVICES_UP_SCRIPT)
|
||||||
|
svc_map = parse_compose_services(compose_files, project_name, project_root)
|
||||||
|
|
||||||
|
containers = client.containers.list()
|
||||||
|
pending_metrics = []
|
||||||
|
remote_targets = set()
|
||||||
|
|
||||||
|
for container in containers:
|
||||||
|
proj = container.labels.get("com.docker.compose.project")
|
||||||
|
if not proj:
|
||||||
|
continue
|
||||||
|
|
||||||
|
svc = container.labels.get("com.docker.compose.service")
|
||||||
|
running = container.attrs["Config"]["Image"]
|
||||||
|
|
||||||
|
compose_image = None
|
||||||
|
if svc in svc_map:
|
||||||
|
compose_image = svc_map[svc]["image"]
|
||||||
|
|
||||||
|
local_digest = get_local_digest(running)
|
||||||
|
remote_target = compose_image or running
|
||||||
|
|
||||||
|
# If we cannot determine a local digest, we cannot compare and should
|
||||||
|
# avoid spending a registry lookup for this container.
|
||||||
|
if local_digest:
|
||||||
|
remote_targets.add(remote_target)
|
||||||
|
|
||||||
|
pending_metrics.append({
|
||||||
|
"container_name": container.name,
|
||||||
|
"service": svc,
|
||||||
|
"compose_image": compose_image,
|
||||||
|
"running_image": running,
|
||||||
|
"project_name": proj,
|
||||||
|
"remote_target": remote_target,
|
||||||
|
"local_digest": local_digest,
|
||||||
|
})
|
||||||
|
|
||||||
|
remote_digests = {target: get_remote_digest(target) for target in remote_targets}
|
||||||
|
|
||||||
|
for payload in pending_metrics:
|
||||||
|
local_digest = payload["local_digest"]
|
||||||
|
remote_target = payload["remote_target"]
|
||||||
|
remote_digest = remote_digests.get(remote_target)
|
||||||
|
update_flag = 1 if (local_digest and remote_digest and local_digest != remote_digest) else 0
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Digest comparison: container=%s service=%s running=%s target=%s local=%s remote=%s",
|
||||||
|
payload["container_name"],
|
||||||
|
payload["service"],
|
||||||
|
payload["running_image"],
|
||||||
|
remote_target,
|
||||||
|
local_digest,
|
||||||
|
remote_digest,
|
||||||
|
)
|
||||||
|
|
||||||
|
set_container_update_metric(
|
||||||
|
container_name=payload["container_name"],
|
||||||
|
compose_image=payload["compose_image"],
|
||||||
|
running_image=payload["running_image"],
|
||||||
|
project_name=payload["project_name"],
|
||||||
|
update_flag=update_flag,
|
||||||
|
)
|
||||||
|
|
||||||
|
def dump_service_image_mapping():
|
||||||
|
project_name = parse_project_name_from_script(SERVICES_UP_SCRIPT)
|
||||||
|
project_root = get_project_root_from_script(SERVICES_UP_SCRIPT)
|
||||||
|
compose_files = get_compose_files_from_script(SERVICES_UP_SCRIPT)
|
||||||
|
svc_map = parse_compose_services(compose_files, project_name, project_root)
|
||||||
|
mapping = {name: data["image"] for name, data in sorted(svc_map.items())}
|
||||||
|
logger.info("Service to image mapping:")
|
||||||
|
logger.info(json.dumps(mapping, indent=2, sort_keys=True))
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
# --- Runner ---
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Docker image update exporter")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Only print service->image mapping and exit")
|
||||||
|
parser.add_argument(
|
||||||
|
"--services-up-script",
|
||||||
|
default=SERVICES_UP_SCRIPT,
|
||||||
|
help=f"Path to services-up script (default: {SERVICES_UP_SCRIPT})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cache-file",
|
||||||
|
default=CACHE_FILE,
|
||||||
|
help=f"Path to digest cache file (default: {CACHE_FILE})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-level",
|
||||||
|
default=LOG_LEVEL,
|
||||||
|
help=f"Logging level (default: {LOG_LEVEL})",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
effective_log_level = str(args.log_level).upper()
|
||||||
|
logging.getLogger().setLevel(getattr(logging, effective_log_level, logging.DEBUG))
|
||||||
|
logger.setLevel(getattr(logging, effective_log_level, logging.DEBUG))
|
||||||
|
|
||||||
|
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()
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
start_http_server(EXPORTER_PORT)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
check_containers()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"update check failed: {e}")
|
||||||
|
time.sleep(CHECK_INTERVAL)
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import docker
|
||||||
|
import requests
|
||||||
|
import yaml
|
||||||
|
from prometheus_client import Gauge, start_http_server
|
||||||
|
|
||||||
|
# --- Logging ---
|
||||||
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("docker-update-exporter")
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
EXPORTER_PORT = 9105
|
||||||
|
CHECK_INTERVAL = 60
|
||||||
|
CACHE_TTL = 6 * 3600
|
||||||
|
SERVICES_UP_SCRIPT = "/compose/services-up.sh"
|
||||||
|
CACHE_FILE = "/data/remote_digest_cache.json"
|
||||||
|
|
||||||
|
client = docker.from_env()
|
||||||
|
|
||||||
|
# --- Metrics ---
|
||||||
|
CONTAINER_UPDATE = Gauge(
|
||||||
|
"docker_container_update_available",
|
||||||
|
"1 if container image is out of date (compose drift or registry), 0 otherwise",
|
||||||
|
["container", "compose_image", "running_image", "com_docker_compose_project"]
|
||||||
|
)
|
||||||
|
|
||||||
|
LAST_CHECK = Gauge(
|
||||||
|
"docker_image_update_last_check_timestamp",
|
||||||
|
"Last time the update check ran (unix timestamp)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Persistent Cache ---
|
||||||
|
|
||||||
|
def load_cache():
|
||||||
|
if not os.path.exists(CACHE_FILE):
|
||||||
|
logger.info(f"Cache file does not exist yet: {CACHE_FILE}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(CACHE_FILE, "r") as f:
|
||||||
|
cache = json.load(f)
|
||||||
|
logger.info(f"Loaded {len(cache)} cached remote digests")
|
||||||
|
logger.debug(f"Cache contents: {cache}")
|
||||||
|
return cache
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load cache from {CACHE_FILE}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_cache():
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
|
||||||
|
with open(CACHE_FILE, "w") as f:
|
||||||
|
json.dump(REMOTE_DIGEST_CACHE, f)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Saved {len(REMOTE_DIGEST_CACHE)} entries to cache file {CACHE_FILE}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save cache to {CACHE_FILE}: {e}")
|
||||||
|
|
||||||
|
REMOTE_DIGEST_CACHE = load_cache()
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
|
||||||
|
def get_project_prefix_from_script(script_path):
|
||||||
|
project_prefix = "core-" # fallback
|
||||||
|
|
||||||
|
if not os.path.exists(script_path):
|
||||||
|
logger.warning(
|
||||||
|
f"services-up script not found at {script_path}, using fallback project prefix {project_prefix}"
|
||||||
|
)
|
||||||
|
return project_prefix
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(script_path, "r") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
m = re.match(r'PROJECT\s*=\s*["\']?([^"\']+)["\']?', line)
|
||||||
|
if m:
|
||||||
|
project_prefix = m.group(1) + "-"
|
||||||
|
logger.debug(
|
||||||
|
f"Detected compose project prefix from script: {project_prefix}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed reading project prefix from {script_path}: {e}")
|
||||||
|
|
||||||
|
return project_prefix
|
||||||
|
|
||||||
|
def get_local_digest(image_name):
|
||||||
|
try:
|
||||||
|
img = client.images.get(image_name)
|
||||||
|
digests = img.attrs.get("RepoDigests", [])
|
||||||
|
|
||||||
|
logger.debug(f"Local RepoDigests for {image_name}: {digests}")
|
||||||
|
|
||||||
|
if digests:
|
||||||
|
digest = digests[0].split("@")[1]
|
||||||
|
logger.debug(f"Local digest for {image_name}: {digest}")
|
||||||
|
return digest
|
||||||
|
|
||||||
|
logger.info(f"No local digest found for image {image_name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to retrieve local digest for {image_name}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_remote_digest(image_name):
|
||||||
|
now = time.time()
|
||||||
|
original = image_name
|
||||||
|
|
||||||
|
# Cache hit
|
||||||
|
if original in REMOTE_DIGEST_CACHE:
|
||||||
|
digest, ts = REMOTE_DIGEST_CACHE[original]
|
||||||
|
age = now - ts
|
||||||
|
|
||||||
|
if age < CACHE_TTL:
|
||||||
|
logger.debug(
|
||||||
|
f"Using cached remote digest for {original} "
|
||||||
|
f"(age={int(age)}s, ttl={CACHE_TTL}s): {digest}"
|
||||||
|
)
|
||||||
|
return digest
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Cache entry expired for {original} "
|
||||||
|
f"(age={int(age)}s > ttl={CACHE_TTL}s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if ":" in repo:
|
||||||
|
repo, tag = repo.rsplit(":", 1)
|
||||||
|
else:
|
||||||
|
tag = "latest"
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Resolving remote digest for {original}: "
|
||||||
|
f"registry={registry}, repo={repo}, tag={tag}"
|
||||||
|
)
|
||||||
|
|
||||||
|
token = None
|
||||||
|
manifest_url = None
|
||||||
|
|
||||||
|
if registry in ["docker.io", "registry-1.docker.io"]:
|
||||||
|
logger.debug(f"Requesting Docker Hub token for {repo}")
|
||||||
|
|
||||||
|
token_res = requests.get(
|
||||||
|
"https://auth.docker.io/token",
|
||||||
|
params={
|
||||||
|
"service": "registry.docker.io",
|
||||||
|
"scope": f"repository:{repo}:pull"
|
||||||
|
},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Docker Hub token response for {repo}: "
|
||||||
|
f"status={token_res.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
token = token_res.json().get("token")
|
||||||
|
manifest_url = (
|
||||||
|
f"https://registry-1.docker.io/v2/{repo}/manifests/{tag}"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif registry == "ghcr.io":
|
||||||
|
logger.debug(f"Requesting GHCR token for {repo}")
|
||||||
|
|
||||||
|
token_res = requests.get(
|
||||||
|
"https://ghcr.io/token",
|
||||||
|
params={
|
||||||
|
"service": "ghcr.io",
|
||||||
|
"scope": f"repository:{repo}:pull"
|
||||||
|
},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"GHCR token response for {repo}: "
|
||||||
|
f"status={token_res.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {original}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
logger.warning(
|
||||||
|
f"No authentication token returned for {original}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.debug(f"Requesting manifest for {original}: {manifest_url}")
|
||||||
|
|
||||||
|
res = requests.get(
|
||||||
|
manifest_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/vnd.docker.distribution.manifest.v2+json"
|
||||||
|
},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Manifest response for {original}: "
|
||||||
|
f"status={res.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
digest = res.headers.get("Docker-Content-Digest")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Fetched remote digest for {original}: {digest}"
|
||||||
|
)
|
||||||
|
|
||||||
|
REMOTE_DIGEST_CACHE[original] = (digest, now)
|
||||||
|
save_cache()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Cached remote digest for {original}: {digest}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return digest
|
||||||
|
|
||||||
|
if res.status_code == 429:
|
||||||
|
logger.warning(
|
||||||
|
f"Registry rate limit hit while fetching {original}"
|
||||||
|
)
|
||||||
|
elif res.status_code in [401, 403]:
|
||||||
|
logger.warning(
|
||||||
|
f"Authentication failed while fetching {original}: "
|
||||||
|
f"status={res.status_code}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Unexpected manifest response for {original}: "
|
||||||
|
f"status={res.status_code}, body={res.text[:250]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch remote digest for {original}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_compose_files_from_script(script_path):
|
||||||
|
files = []
|
||||||
|
|
||||||
|
if not os.path.exists(script_path):
|
||||||
|
logger.error(f"services-up script not found: {script_path}")
|
||||||
|
return files
|
||||||
|
|
||||||
|
base_dir = os.path.dirname(script_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(script_path, "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
match = re.search(r'FILES\s*=\s*\((.*?)\)', content, re.DOTALL)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
logger.warning(
|
||||||
|
f"No FILES=(...) block found in {script_path}"
|
||||||
|
)
|
||||||
|
return files
|
||||||
|
|
||||||
|
lines = match.group(1).splitlines()
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
if line.startswith("-f"):
|
||||||
|
rel_path = line[2:].strip()
|
||||||
|
|
||||||
|
if rel_path:
|
||||||
|
full_path = os.path.normpath(
|
||||||
|
os.path.join(base_dir, rel_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Resolved compose file: {rel_path} -> {full_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
files.append(full_path)
|
||||||
|
|
||||||
|
logger.info(f"Found {len(files)} compose files")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed parsing compose files from {script_path}: {e}")
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
def parse_compose_files(compose_files):
|
||||||
|
service_to_image = {}
|
||||||
|
|
||||||
|
for f in compose_files:
|
||||||
|
if not os.path.exists(f):
|
||||||
|
logger.warning(f"Compose file missing: {f}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(f, "r") as stream:
|
||||||
|
data = yaml.safe_load(stream) or {}
|
||||||
|
services = data.get("services", {})
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Parsing {len(services)} services from compose file {f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for service_name, service_def in services.items():
|
||||||
|
image = service_def.get("image")
|
||||||
|
is_built = False
|
||||||
|
|
||||||
|
if not image:
|
||||||
|
is_built = True
|
||||||
|
build_ctx = service_def.get("build")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Service {service_name} is build-based, build config={build_ctx}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(build_ctx, dict):
|
||||||
|
context_path = build_ctx.get("context", ".")
|
||||||
|
dockerfile_path = os.path.join(
|
||||||
|
context_path,
|
||||||
|
build_ctx.get("dockerfile", "Dockerfile")
|
||||||
|
)
|
||||||
|
elif isinstance(build_ctx, str):
|
||||||
|
context_path = build_ctx
|
||||||
|
dockerfile_path = os.path.join(
|
||||||
|
context_path, "Dockerfile"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dockerfile_path = None
|
||||||
|
|
||||||
|
if dockerfile_path and os.path.exists(dockerfile_path):
|
||||||
|
try:
|
||||||
|
with open(dockerfile_path, "r") as df:
|
||||||
|
for line in df:
|
||||||
|
line = line.strip()
|
||||||
|
if (
|
||||||
|
line.upper().startswith("LABEL")
|
||||||
|
and "image=" in line
|
||||||
|
):
|
||||||
|
m = re.search(
|
||||||
|
r'image=["\']?([^"\']+)["\']?',
|
||||||
|
line
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
image = m.group(1)
|
||||||
|
logger.debug(
|
||||||
|
f"Found upstream image label for {service_name}: {image}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed reading Dockerfile {dockerfile_path}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not image:
|
||||||
|
image = f"{service_name}:latest"
|
||||||
|
logger.info(
|
||||||
|
f"No image label found for build service {service_name}, "
|
||||||
|
f"defaulting to {image}"
|
||||||
|
)
|
||||||
|
|
||||||
|
service_to_image[service_name] = (image, is_built)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed parsing compose file {f}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Mapped {len(service_to_image)} compose services to images")
|
||||||
|
logger.debug(f"Service/image mapping: {service_to_image}")
|
||||||
|
|
||||||
|
return service_to_image
|
||||||
|
|
||||||
|
def check_containers():
|
||||||
|
logger.info("Starting container update check")
|
||||||
|
|
||||||
|
CONTAINER_UPDATE.clear()
|
||||||
|
|
||||||
|
project_prefix = get_project_prefix_from_script(SERVICES_UP_SCRIPT)
|
||||||
|
compose_files = get_compose_files_from_script(SERVICES_UP_SCRIPT)
|
||||||
|
service_to_image = parse_compose_files(compose_files)
|
||||||
|
|
||||||
|
containers = client.containers.list()
|
||||||
|
logger.info(f"Checking {len(containers)} running containers")
|
||||||
|
|
||||||
|
for container in containers:
|
||||||
|
project_label = container.labels.get("com.docker.compose.project")
|
||||||
|
|
||||||
|
if not project_label:
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping non-compose container {container.name}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
service_label = container.labels.get("com.docker.compose.service")
|
||||||
|
running_image = container.attrs["Config"]["Image"]
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Evaluating container={container.name}, "
|
||||||
|
f"service={service_label}, project={project_label}, "
|
||||||
|
f"running_image={running_image}"
|
||||||
|
)
|
||||||
|
|
||||||
|
compose_image = None
|
||||||
|
is_built = False
|
||||||
|
|
||||||
|
if service_label and service_label in service_to_image:
|
||||||
|
compose_image, is_built = service_to_image[service_label]
|
||||||
|
|
||||||
|
if is_built:
|
||||||
|
compose_image_name, _, _ = compose_image.partition(":")
|
||||||
|
compose_image = f"{project_prefix}{compose_image_name}"
|
||||||
|
|
||||||
|
update_flag = 0
|
||||||
|
|
||||||
|
if is_built:
|
||||||
|
if running_image != compose_image:
|
||||||
|
logger.info(
|
||||||
|
f"Update detected for build-based container {container.name}: "
|
||||||
|
f"running image {running_image} != expected {compose_image}"
|
||||||
|
)
|
||||||
|
update_flag = 1
|
||||||
|
else:
|
||||||
|
local_digest = get_local_digest(running_image)
|
||||||
|
remote_digest = get_remote_digest(
|
||||||
|
service_to_image[service_label][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
if local_digest and remote_digest and local_digest != remote_digest:
|
||||||
|
logger.info(
|
||||||
|
f"Remote image update available for {container.name}: "
|
||||||
|
f"{local_digest} != {remote_digest}"
|
||||||
|
)
|
||||||
|
update_flag = 1
|
||||||
|
else:
|
||||||
|
if running_image != compose_image:
|
||||||
|
logger.info(
|
||||||
|
f"Compose drift detected for {container.name}: "
|
||||||
|
f"running image {running_image} != compose image {compose_image}"
|
||||||
|
)
|
||||||
|
update_flag = 1
|
||||||
|
else:
|
||||||
|
local_digest = get_local_digest(running_image)
|
||||||
|
remote_digest = get_remote_digest(running_image)
|
||||||
|
|
||||||
|
if local_digest and remote_digest and local_digest != remote_digest:
|
||||||
|
logger.info(
|
||||||
|
f"Registry update available for {container.name}: "
|
||||||
|
f"{local_digest} != {remote_digest}"
|
||||||
|
)
|
||||||
|
update_flag = 1
|
||||||
|
|
||||||
|
CONTAINER_UPDATE.labels(
|
||||||
|
container=container.name,
|
||||||
|
compose_image=compose_image if compose_image else "unknown",
|
||||||
|
running_image=running_image,
|
||||||
|
com_docker_compose_project=project_label
|
||||||
|
).set(update_flag)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Container {container.name}: "
|
||||||
|
f"running={running_image}, "
|
||||||
|
f"compose={compose_image}, "
|
||||||
|
f"update_available={update_flag}"
|
||||||
|
)
|
||||||
|
|
||||||
|
LAST_CHECK.set(time.time())
|
||||||
|
logger.info("Container update check complete")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logger.info(
|
||||||
|
f"Starting Docker update exporter on port {EXPORTER_PORT} "
|
||||||
|
f"with LOG_LEVEL={LOG_LEVEL}"
|
||||||
|
)
|
||||||
|
|
||||||
|
start_http_server(EXPORTER_PORT)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
check_containers()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Unhandled error during update check: {e}")
|
||||||
|
|
||||||
|
time.sleep(CHECK_INTERVAL)
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
CONTEXT="raspi"
|
||||||
|
ENV="default-environment.env"
|
||||||
|
PROJECT="raspi"
|
||||||
|
FILES=(
|
||||||
|
-f docker-compose.yml
|
||||||
|
)
|
||||||
|
|
||||||
|
docker --context $CONTEXT compose -p $PROJECT --env-file $ENV "${FILES[@]}" $1 $2 $3 $4 $5 $6 $7 $8 $9
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[agent]
|
||||||
|
interval = "10s"
|
||||||
|
|
||||||
|
[[inputs.docker]]
|
||||||
|
endpoint = "tcp://docker-socket-proxy:2375"
|
||||||
|
gather_services = false
|
||||||
|
|
||||||
|
[[outputs.prometheus_client]]
|
||||||
|
listen = ":9273"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
crowdsec:
|
||||||
|
plugin:
|
||||||
|
crowdsec-bouncer:
|
||||||
|
crowdsecMode: live
|
||||||
|
crowdsecLapiKey: HeneLa2mazFVzl5+DQRKOdchBuJxKdjrHsHBE/03Acs
|
||||||
|
crowdsecLapiHost: crowdsec:8080
|
||||||
|
crowdsecLapiScheme: http
|
||||||
|
|
||||||
|
secHeaders:
|
||||||
|
headers:
|
||||||
|
browserXssFilter: true
|
||||||
|
contentTypeNosniff: true
|
||||||
|
frameDeny: true
|
||||||
|
# sslRedirect: true
|
||||||
|
#HSTS Configuration
|
||||||
|
stsIncludeSubdomains: true
|
||||||
|
stsPreload: true
|
||||||
|
stsSeconds: 15552000
|
||||||
|
forceSTSHeader: true
|
||||||
|
customFrameOptionsValue: "SAMEORIGIN"
|
||||||
|
default-chain:
|
||||||
|
chain:
|
||||||
|
middlewares:
|
||||||
|
- secHeaders
|
||||||
|
- crowdsec
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,58 @@
|
|||||||
|
api:
|
||||||
|
dashboard: true
|
||||||
|
insecure: true # keep false if exposing via router
|
||||||
|
ping: {}
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: INFO
|
||||||
|
|
||||||
|
accessLog:
|
||||||
|
filePath: /logs/access.log
|
||||||
|
format: json
|
||||||
|
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
http:
|
||||||
|
redirections:
|
||||||
|
entryPoint:
|
||||||
|
to: websecure
|
||||||
|
scheme: https
|
||||||
|
|
||||||
|
websecure:
|
||||||
|
address: ":443"
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
- default-chain@file
|
||||||
|
|
||||||
|
providers:
|
||||||
|
docker:
|
||||||
|
endpoint: "tcp://docker-socket-proxy:2375"
|
||||||
|
exposedByDefault: false
|
||||||
|
file:
|
||||||
|
filename: /etc/traefik/dynamic.yml
|
||||||
|
watch: true
|
||||||
|
|
||||||
|
experimental:
|
||||||
|
plugins:
|
||||||
|
crowdsec-bouncer:
|
||||||
|
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||||
|
version: v1.4.2
|
||||||
|
|
||||||
|
certificatesResolvers:
|
||||||
|
le:
|
||||||
|
acme:
|
||||||
|
email: wayne.bennett@live.com
|
||||||
|
storage: /letsencrypt/acme.json
|
||||||
|
httpChallenge:
|
||||||
|
entryPoint: web
|
||||||
|
metrics:
|
||||||
|
prometheus:
|
||||||
|
# entryPoint: metrics:9100 # optional, default is "metrics"
|
||||||
|
buckets:
|
||||||
|
- 0.1
|
||||||
|
- 0.3
|
||||||
|
- 1.2
|
||||||
|
- 5.0
|
||||||
|
addEntryPointsLabels: true # add labels for each entrypoint
|
||||||
|
addServicesLabels: true # add labels for each service
|
||||||
Reference in New Issue
Block a user