1733 lines
32 KiB
Python
1733 lines
32 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import os
|
|
import re
|
|
import tarfile
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import subprocess
|
|
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from urllib.parse import quote
|
|
|
|
import requests
|
|
|
|
from prometheus_client import (
|
|
CollectorRegistry,
|
|
Gauge,
|
|
generate_latest,
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Configuration
|
|
# ============================================================
|
|
|
|
GIT_URL = "https://git.kaz.bzh/KAZ/KazV2.git"
|
|
GIT_REF = "master"
|
|
|
|
PORT = 9911
|
|
|
|
INVENTORY_INTERVAL = 3600
|
|
REGISTRY_CACHE_TTL = 6 * 3600
|
|
|
|
DOCKER_HUB_REGISTRY_API = "https://registry-1.docker.io"
|
|
|
|
session = requests.Session()
|
|
|
|
session.headers.update(
|
|
{
|
|
"User-Agent": "KAZ-docker-inventaire/1.0",
|
|
}
|
|
)
|
|
|
|
registry_cache = {}
|
|
|
|
# Cache des tokens :
|
|
#
|
|
# (registry, repository) -> {
|
|
# "token": "...",
|
|
# "expires_at": timestamp
|
|
# }
|
|
#
|
|
registry_tokens = {}
|
|
|
|
print_lock = threading.Lock()
|
|
|
|
|
|
# ============================================================
|
|
# Logs
|
|
# ============================================================
|
|
|
|
_original_print = print
|
|
|
|
|
|
def log(*args, **kwargs):
|
|
timestamp = time.strftime(
|
|
"%Y-%m-%d %H:%M:%S +0000",
|
|
time.gmtime(),
|
|
)
|
|
|
|
with print_lock:
|
|
_original_print(
|
|
f"[{timestamp}]",
|
|
*args,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
print = log
|
|
|
|
|
|
# ============================================================
|
|
# Prometheus
|
|
# ============================================================
|
|
|
|
registry = CollectorRegistry()
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
# Version déclarée dans Git
|
|
# ------------------------------------------------------------
|
|
|
|
git_metric = Gauge(
|
|
"docker_image_git_version_info",
|
|
"Docker image version declared in Git",
|
|
[
|
|
"project",
|
|
"service",
|
|
"image",
|
|
"version",
|
|
],
|
|
registry=registry,
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
# Dernière version stable disponible dans le registry
|
|
# ------------------------------------------------------------
|
|
|
|
source_metric = Gauge(
|
|
"docker_image_source_version_info",
|
|
"Latest stable Docker image version available from source registry",
|
|
[
|
|
"image",
|
|
"version",
|
|
"registry",
|
|
],
|
|
registry=registry,
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
# Métrique complète destinée à Grafana
|
|
# ------------------------------------------------------------
|
|
|
|
version_metric = Gauge(
|
|
"docker_image_version_info",
|
|
"Docker image installed version and latest stable available version",
|
|
[
|
|
"project",
|
|
"service",
|
|
"image",
|
|
"installed_version",
|
|
"available_version",
|
|
"registry",
|
|
],
|
|
registry=registry,
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
# Indicateur de mise à jour disponible
|
|
# ------------------------------------------------------------
|
|
|
|
update_metric = Gauge(
|
|
"docker_image_version_update_available",
|
|
"Docker image has a newer stable version available",
|
|
[
|
|
"project",
|
|
"service",
|
|
"image",
|
|
"installed_version",
|
|
"available_version",
|
|
"registry",
|
|
],
|
|
registry=registry,
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Version parsing
|
|
# ============================================================
|
|
|
|
def parse_version(tag):
|
|
"""
|
|
Extrait une version numérique située au début du tag.
|
|
"""
|
|
|
|
if not tag:
|
|
return None
|
|
|
|
tag = tag.strip()
|
|
|
|
match = re.match(
|
|
r"^v?(\d+(?:[._-]\d+)*)(?![A-Za-z0-9])",
|
|
tag,
|
|
)
|
|
|
|
if not match:
|
|
return None
|
|
|
|
value = match.group(1)
|
|
|
|
numbers = [
|
|
int(x)
|
|
for x in re.split(
|
|
r"[._-]",
|
|
value,
|
|
)
|
|
]
|
|
|
|
if numbers[0] > 999:
|
|
return None
|
|
|
|
return tuple(numbers)
|
|
|
|
|
|
def get_tag_suffix(tag):
|
|
"""
|
|
Retourne ce qui se trouve après la version numérique.
|
|
"""
|
|
|
|
if not tag:
|
|
return ""
|
|
|
|
match = re.match(
|
|
r"^v?(\d+(?:[._-]\d+)*)(?![A-Za-z0-9])(.*)$",
|
|
tag,
|
|
)
|
|
|
|
if not match:
|
|
return ""
|
|
|
|
suffix = match.group(2)
|
|
|
|
suffix = suffix.lstrip(
|
|
"._-"
|
|
)
|
|
|
|
return suffix
|
|
|
|
|
|
def get_build_variant(tag):
|
|
"""
|
|
Identifie les variantes de distribution/build.
|
|
"""
|
|
|
|
suffix = get_tag_suffix(tag)
|
|
|
|
if not suffix:
|
|
return ""
|
|
|
|
known_variants = (
|
|
"alpine",
|
|
"slim",
|
|
"bookworm",
|
|
"bullseye",
|
|
"buster",
|
|
"stretch",
|
|
"trixie",
|
|
"jammy",
|
|
"noble",
|
|
"focal",
|
|
"debian",
|
|
"ubuntu",
|
|
"ubi",
|
|
"distroless",
|
|
)
|
|
|
|
if suffix in known_variants:
|
|
return suffix
|
|
|
|
match = re.match(
|
|
r"^(alpine|bookworm|bullseye|buster|trixie)"
|
|
r"\d+(?:\.\d+)*$",
|
|
suffix,
|
|
)
|
|
|
|
if match:
|
|
return match.group(1)
|
|
|
|
match = re.match(
|
|
r"^(debian|ubuntu|alpine|bookworm|bullseye|trixie)"
|
|
r"(?:[-_]\d+(?:\.\d+)*)?$",
|
|
suffix,
|
|
)
|
|
|
|
if match:
|
|
return match.group(1)
|
|
|
|
return ""
|
|
|
|
|
|
# ============================================================
|
|
# Stable tag filtering
|
|
# ============================================================
|
|
|
|
def is_stable_tag(tag):
|
|
"""
|
|
Détermine si un tag ressemble à une release stable.
|
|
"""
|
|
|
|
if not tag:
|
|
return False
|
|
|
|
lower = tag.lower()
|
|
|
|
if lower in (
|
|
"latest",
|
|
"production",
|
|
"stable",
|
|
"edge",
|
|
"nightly",
|
|
"snapshot",
|
|
"dev",
|
|
"development",
|
|
"testing",
|
|
"test",
|
|
):
|
|
return False
|
|
|
|
if re.search(
|
|
r"(^|[-_.])"
|
|
r"(pr|pull|preview|dev|development|nightly|"
|
|
r"snapshot|master|main|branch|commit|sha)"
|
|
r"([-. _]|$)",
|
|
lower,
|
|
):
|
|
return False
|
|
|
|
if re.search(
|
|
r"(^|[-_.])"
|
|
r"(rc|alpha|beta|pre|preview|canary|"
|
|
r"unstable|experimental)"
|
|
r"(?:[-_.]|\d|$)",
|
|
lower,
|
|
):
|
|
return False
|
|
|
|
if re.search(
|
|
r"(^|[-_.])"
|
|
r"(amd64|x86_64|arm64|aarch64|armv7|armv6|"
|
|
r"386|ppc64le|s390x|riscv64|"
|
|
r"windows|win32|linux|darwin|"
|
|
r"freebsd|solaris)"
|
|
r"([-. _]|$)",
|
|
lower,
|
|
):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
# ============================================================
|
|
# Docker image
|
|
# ============================================================
|
|
|
|
def split_image(image):
|
|
"""
|
|
Sépare registry / repository / tag.
|
|
"""
|
|
|
|
image = image.strip()
|
|
|
|
registry_name = "docker.io"
|
|
|
|
parts = image.split("/")
|
|
|
|
if len(parts) > 1 and (
|
|
"." in parts[0]
|
|
or ":" in parts[0]
|
|
or parts[0] == "localhost"
|
|
):
|
|
registry_name = parts[0]
|
|
parts = parts[1:]
|
|
|
|
if len(parts) == 1:
|
|
repository = (
|
|
"library/" + parts[0]
|
|
)
|
|
else:
|
|
repository = "/".join(parts)
|
|
|
|
tag = None
|
|
|
|
last = repository.rsplit(
|
|
"/",
|
|
1,
|
|
)[-1]
|
|
|
|
if ":" in last:
|
|
repository, tag = repository.rsplit(
|
|
":",
|
|
1,
|
|
)
|
|
|
|
if "@" in repository:
|
|
repository, digest = repository.split(
|
|
"@",
|
|
1,
|
|
)
|
|
|
|
tag = digest
|
|
|
|
return (
|
|
registry_name,
|
|
repository,
|
|
tag,
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Registry token cache
|
|
# ============================================================
|
|
|
|
def get_cached_token(
|
|
registry_name,
|
|
repository,
|
|
):
|
|
"""
|
|
Retourne un token encore valide depuis le cache.
|
|
|
|
On garde une marge de sécurité de 60 secondes avant
|
|
l'expiration réelle du token.
|
|
"""
|
|
|
|
cache_key = (
|
|
registry_name,
|
|
repository,
|
|
)
|
|
|
|
cached = registry_tokens.get(
|
|
cache_key
|
|
)
|
|
|
|
if not cached:
|
|
return None
|
|
|
|
expires_at = cached.get(
|
|
"expires_at",
|
|
0,
|
|
)
|
|
|
|
if time.time() >= expires_at - 60:
|
|
|
|
registry_tokens.pop(
|
|
cache_key,
|
|
None,
|
|
)
|
|
|
|
print(
|
|
f" Registry : token expiré pour "
|
|
f"{registry_name}/{repository}"
|
|
)
|
|
|
|
return None
|
|
|
|
return cached.get(
|
|
"token"
|
|
)
|
|
|
|
|
|
def invalidate_registry_token(
|
|
registry_name,
|
|
repository,
|
|
):
|
|
"""
|
|
Supprime explicitement un token du cache.
|
|
"""
|
|
|
|
cache_key = (
|
|
registry_name,
|
|
repository,
|
|
)
|
|
|
|
if cache_key in registry_tokens:
|
|
|
|
registry_tokens.pop(
|
|
cache_key,
|
|
None,
|
|
)
|
|
|
|
print(
|
|
f" Registry : token invalidé pour "
|
|
f"{registry_name}/{repository}"
|
|
)
|
|
|
|
|
|
def store_registry_token(
|
|
registry_name,
|
|
repository,
|
|
token,
|
|
expires_in,
|
|
):
|
|
"""
|
|
Stocke un token avec sa date d'expiration.
|
|
|
|
expires_in est généralement fourni par le serveur
|
|
d'authentification.
|
|
|
|
Par sécurité, on limite également la durée maximale
|
|
du cache à 24 heures.
|
|
"""
|
|
|
|
try:
|
|
expires_in = int(
|
|
expires_in
|
|
)
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
expires_in = 300
|
|
|
|
expires_in = max(
|
|
60,
|
|
min(
|
|
expires_in,
|
|
24 * 3600,
|
|
),
|
|
)
|
|
|
|
cache_key = (
|
|
registry_name,
|
|
repository,
|
|
)
|
|
|
|
registry_tokens[
|
|
cache_key
|
|
] = {
|
|
"token": token,
|
|
"expires_at": (
|
|
time.time()
|
|
+ expires_in
|
|
),
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Docker Hub authentication
|
|
# ============================================================
|
|
|
|
def get_dockerhub_token(
|
|
repository,
|
|
force_refresh=False,
|
|
):
|
|
"""
|
|
Obtient un token Docker Hub.
|
|
|
|
Le token est conservé jusqu'à son expiration.
|
|
|
|
force_refresh=True permet de forcer une nouvelle
|
|
authentification, notamment après un HTTP 401 du Registry.
|
|
"""
|
|
|
|
if not force_refresh:
|
|
|
|
token = get_cached_token(
|
|
"docker.io",
|
|
repository,
|
|
)
|
|
|
|
if token:
|
|
return token
|
|
|
|
else:
|
|
|
|
invalidate_registry_token(
|
|
"docker.io",
|
|
repository,
|
|
)
|
|
|
|
url = (
|
|
"https://auth.docker.io/token"
|
|
"?service=registry.docker.io"
|
|
f"&scope=repository:{quote(repository, safe='')}:pull"
|
|
)
|
|
|
|
print(
|
|
f" Registry : nouveau token Docker Hub pour "
|
|
f"{repository}"
|
|
)
|
|
|
|
response = session.get(
|
|
url,
|
|
timeout=30,
|
|
)
|
|
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
token = data.get(
|
|
"token"
|
|
)
|
|
|
|
if not token:
|
|
raise RuntimeError(
|
|
"Docker Hub : token absent de la réponse"
|
|
)
|
|
|
|
expires_in = data.get(
|
|
"expires_in",
|
|
300,
|
|
)
|
|
|
|
store_registry_token(
|
|
"docker.io",
|
|
repository,
|
|
token,
|
|
expires_in,
|
|
)
|
|
|
|
return token
|
|
|
|
|
|
# ============================================================
|
|
# GHCR authentication
|
|
# ============================================================
|
|
|
|
def get_ghcr_token(
|
|
repository,
|
|
force_refresh=False,
|
|
):
|
|
"""
|
|
Obtient un token GHCR.
|
|
|
|
Même mécanisme de cache que Docker Hub.
|
|
"""
|
|
|
|
if not force_refresh:
|
|
|
|
token = get_cached_token(
|
|
"ghcr.io",
|
|
repository,
|
|
)
|
|
|
|
if token:
|
|
return token
|
|
|
|
else:
|
|
|
|
invalidate_registry_token(
|
|
"ghcr.io",
|
|
repository,
|
|
)
|
|
|
|
url = (
|
|
"https://ghcr.io/token"
|
|
f"?scope=repository:{quote(repository, safe='')}:pull"
|
|
)
|
|
|
|
print(
|
|
f" Registry : nouveau token GHCR pour "
|
|
f"{repository}"
|
|
)
|
|
|
|
response = session.get(
|
|
url,
|
|
timeout=30,
|
|
)
|
|
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
token = data.get(
|
|
"token"
|
|
)
|
|
|
|
if not token:
|
|
raise RuntimeError(
|
|
"GHCR : token absent de la réponse"
|
|
)
|
|
|
|
expires_in = data.get(
|
|
"expires_in",
|
|
300,
|
|
)
|
|
|
|
store_registry_token(
|
|
"ghcr.io",
|
|
repository,
|
|
token,
|
|
expires_in,
|
|
)
|
|
|
|
return token
|
|
|
|
|
|
# ============================================================
|
|
# Registry tags
|
|
# ============================================================
|
|
|
|
def get_registry_tags(
|
|
registry_name,
|
|
repository,
|
|
):
|
|
"""
|
|
Récupère TOUS les tags du registry.
|
|
|
|
Docker Hub :
|
|
registry-1.docker.io
|
|
|
|
GHCR :
|
|
ghcr.io
|
|
|
|
La pagination Link peut être absolue ou relative.
|
|
|
|
En cas de HTTP 401 sur un registry authentifié :
|
|
1. le token est supprimé ;
|
|
2. un nouveau token est demandé ;
|
|
3. la requête est rejouée une seule fois.
|
|
"""
|
|
|
|
cache_key = (
|
|
registry_name,
|
|
repository,
|
|
)
|
|
|
|
cached = registry_cache.get(
|
|
cache_key
|
|
)
|
|
|
|
if cached:
|
|
|
|
timestamp, tags = cached
|
|
|
|
if (
|
|
time.time() - timestamp
|
|
< REGISTRY_CACHE_TTL
|
|
):
|
|
|
|
print(
|
|
f" Registry : cache pour "
|
|
f"{registry_name}/{repository}"
|
|
)
|
|
|
|
return tags
|
|
|
|
print(
|
|
f" Registry : récupération des tags de "
|
|
f"{registry_name}/{repository}"
|
|
)
|
|
|
|
if registry_name == "docker.io":
|
|
|
|
base_url = (
|
|
DOCKER_HUB_REGISTRY_API
|
|
)
|
|
|
|
token = get_dockerhub_token(
|
|
repository
|
|
)
|
|
|
|
headers = {
|
|
"Authorization": (
|
|
f"Bearer {token}"
|
|
),
|
|
}
|
|
|
|
authenticated = True
|
|
|
|
elif registry_name == "ghcr.io":
|
|
|
|
base_url = "https://ghcr.io"
|
|
|
|
token = get_ghcr_token(
|
|
repository
|
|
)
|
|
|
|
headers = {
|
|
"Authorization": (
|
|
f"Bearer {token}"
|
|
),
|
|
}
|
|
|
|
authenticated = True
|
|
|
|
else:
|
|
|
|
base_url = (
|
|
f"https://{registry_name}"
|
|
)
|
|
|
|
headers = {}
|
|
|
|
authenticated = False
|
|
|
|
url = (
|
|
f"{base_url}/v2/"
|
|
f"{repository}/tags/list?n=100"
|
|
)
|
|
|
|
tags = []
|
|
|
|
# --------------------------------------------------------
|
|
# Une seule tentative normale + un éventuel renouvellement
|
|
# du token après 401.
|
|
# --------------------------------------------------------
|
|
|
|
token_refresh_attempted = False
|
|
|
|
while url:
|
|
|
|
response = session.get(
|
|
url,
|
|
headers=headers,
|
|
timeout=60,
|
|
)
|
|
|
|
# ----------------------------------------------------
|
|
# Token refusé.
|
|
#
|
|
# On ne boucle surtout pas indéfiniment :
|
|
# un seul renouvellement est tenté.
|
|
# ----------------------------------------------------
|
|
|
|
if (
|
|
response.status_code == 401
|
|
and authenticated
|
|
and not token_refresh_attempted
|
|
):
|
|
|
|
token_refresh_attempted = True
|
|
|
|
print(
|
|
f" Registry : HTTP 401 pour "
|
|
f"{registry_name}/{repository}"
|
|
)
|
|
|
|
print(
|
|
f" Registry : renouvellement du token pour "
|
|
f"{registry_name}/{repository}"
|
|
)
|
|
|
|
if registry_name == "docker.io":
|
|
|
|
token = get_dockerhub_token(
|
|
repository,
|
|
force_refresh=True,
|
|
)
|
|
|
|
else:
|
|
|
|
token = get_ghcr_token(
|
|
repository,
|
|
force_refresh=True,
|
|
)
|
|
|
|
headers = {
|
|
"Authorization": (
|
|
f"Bearer {token}"
|
|
),
|
|
}
|
|
|
|
# On rejoue exactement la même requête.
|
|
continue
|
|
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
tags.extend(
|
|
data.get(
|
|
"tags",
|
|
[],
|
|
)
|
|
)
|
|
|
|
next_url = None
|
|
|
|
link = response.headers.get(
|
|
"Link"
|
|
)
|
|
|
|
if link:
|
|
|
|
match = re.search(
|
|
r"<([^>]+)>;\s*rel=\"next\"",
|
|
link,
|
|
)
|
|
|
|
if match:
|
|
|
|
next_url = match.group(1)
|
|
|
|
if next_url.startswith("/"):
|
|
next_url = (
|
|
base_url
|
|
+ next_url
|
|
)
|
|
|
|
url = next_url
|
|
|
|
tags = list(
|
|
dict.fromkeys(tags)
|
|
)
|
|
|
|
registry_cache[
|
|
cache_key
|
|
] = (
|
|
time.time(),
|
|
tags,
|
|
)
|
|
|
|
print(
|
|
f" Registry : {len(tags)} tags pour "
|
|
f"{registry_name}/{repository}"
|
|
)
|
|
|
|
return tags
|
|
|
|
|
|
# ============================================================
|
|
# Bitnami
|
|
# ============================================================
|
|
|
|
def source_version_bitnami(
|
|
repository,
|
|
):
|
|
"""
|
|
Bitnami ne fournit pas toujours une liste de tags
|
|
exploitable via Registry.
|
|
|
|
On cherche les releases dans les PR GitHub.
|
|
"""
|
|
|
|
image_name = repository.rsplit(
|
|
"/",
|
|
1,
|
|
)[-1]
|
|
|
|
print(
|
|
f" GitHub Bitnami : recherche des versions "
|
|
f"de {repository}"
|
|
)
|
|
|
|
url = (
|
|
"https://api.github.com/search/issues"
|
|
)
|
|
|
|
query = (
|
|
f"repo:bitnami/containers "
|
|
f'"[bitnami/{image_name}] Release" '
|
|
f"is:pr"
|
|
)
|
|
|
|
headers = {
|
|
"Accept": (
|
|
"application/vnd.github+json"
|
|
),
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"User-Agent": (
|
|
"KAZ-docker-inventaire/1.0"
|
|
),
|
|
}
|
|
|
|
response = session.get(
|
|
url,
|
|
params={
|
|
"q": query,
|
|
"per_page": 100,
|
|
},
|
|
headers=headers,
|
|
timeout=60,
|
|
)
|
|
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
versions = []
|
|
|
|
for item in data.get(
|
|
"items",
|
|
[],
|
|
):
|
|
|
|
title = item.get(
|
|
"title",
|
|
"",
|
|
)
|
|
|
|
match = re.search(
|
|
rf"\[bitnami/{re.escape(image_name)}\]"
|
|
r"\s+Release\s+([0-9][^ ]*)",
|
|
title,
|
|
)
|
|
|
|
if not match:
|
|
continue
|
|
|
|
full_version = match.group(1)
|
|
|
|
version = parse_version(
|
|
full_version
|
|
)
|
|
|
|
if not version:
|
|
continue
|
|
|
|
if not is_stable_tag(
|
|
full_version
|
|
):
|
|
continue
|
|
|
|
numeric = ".".join(
|
|
str(x)
|
|
for x in version
|
|
)
|
|
|
|
versions.append(
|
|
(
|
|
version,
|
|
numeric,
|
|
)
|
|
)
|
|
|
|
if not versions:
|
|
return None
|
|
|
|
versions.sort(
|
|
key=lambda item: item[0]
|
|
)
|
|
|
|
return versions[-1][1]
|
|
|
|
|
|
# ============================================================
|
|
# Source version
|
|
# ============================================================
|
|
|
|
def source_version_for(
|
|
image,
|
|
):
|
|
"""
|
|
Retourne la dernière version stable disponible.
|
|
"""
|
|
|
|
(
|
|
registry_name,
|
|
repository,
|
|
current_tag,
|
|
) = split_image(image)
|
|
|
|
if not current_tag:
|
|
return None
|
|
|
|
if current_tag in (
|
|
"latest",
|
|
"production",
|
|
):
|
|
return None
|
|
|
|
current_version = parse_version(
|
|
current_tag
|
|
)
|
|
|
|
if not current_version:
|
|
return None
|
|
|
|
current_variant = get_build_variant(
|
|
current_tag
|
|
)
|
|
|
|
# --------------------------------------------------------
|
|
# Bitnami
|
|
# --------------------------------------------------------
|
|
|
|
if (
|
|
registry_name == "docker.io"
|
|
and repository.startswith(
|
|
"bitnami/"
|
|
)
|
|
):
|
|
|
|
try:
|
|
|
|
version = (
|
|
source_version_bitnami(
|
|
repository
|
|
)
|
|
)
|
|
|
|
if version:
|
|
|
|
print(
|
|
f" {repository} -> {version}"
|
|
)
|
|
|
|
return version
|
|
|
|
except Exception as exc:
|
|
|
|
print(
|
|
f" Bitnami erreur: {exc}"
|
|
)
|
|
|
|
return None
|
|
|
|
# --------------------------------------------------------
|
|
# Registry
|
|
# --------------------------------------------------------
|
|
|
|
try:
|
|
|
|
tags = get_registry_tags(
|
|
registry_name,
|
|
repository,
|
|
)
|
|
|
|
except Exception as exc:
|
|
|
|
print(
|
|
f" Registry erreur: {exc}"
|
|
)
|
|
|
|
return None
|
|
|
|
candidates = []
|
|
|
|
for tag in tags:
|
|
|
|
if not tag:
|
|
continue
|
|
|
|
if tag in (
|
|
"latest",
|
|
"production",
|
|
):
|
|
continue
|
|
|
|
if not is_stable_tag(
|
|
tag
|
|
):
|
|
continue
|
|
|
|
version = parse_version(
|
|
tag
|
|
)
|
|
|
|
if not version:
|
|
continue
|
|
|
|
variant = get_build_variant(
|
|
tag
|
|
)
|
|
|
|
if current_variant:
|
|
|
|
if variant != current_variant:
|
|
continue
|
|
|
|
exact = (
|
|
1
|
|
if tag.startswith("v")
|
|
else 0
|
|
)
|
|
|
|
no_suffix = (
|
|
1
|
|
if get_tag_suffix(tag) == ""
|
|
else 0
|
|
)
|
|
|
|
candidates.append(
|
|
(
|
|
version,
|
|
no_suffix,
|
|
exact,
|
|
tag,
|
|
)
|
|
)
|
|
|
|
if not candidates:
|
|
|
|
print(
|
|
f" Source : aucune version compatible pour "
|
|
f"{image}"
|
|
)
|
|
|
|
return None
|
|
|
|
candidates.sort(
|
|
key=lambda item: (
|
|
item[0],
|
|
item[1],
|
|
item[2],
|
|
)
|
|
)
|
|
|
|
source_version = candidates[-1][3]
|
|
|
|
print(
|
|
f" Source : {image} -> "
|
|
f"{source_version}"
|
|
)
|
|
|
|
return source_version
|
|
|
|
|
|
# ============================================================
|
|
# Git archive
|
|
# ============================================================
|
|
|
|
def git_archive():
|
|
|
|
print(
|
|
f"Git : récupération de {GIT_URL}"
|
|
)
|
|
|
|
tempdir = tempfile.mkdtemp(
|
|
prefix="docker-inventaire-"
|
|
)
|
|
|
|
archive_path = os.path.join(
|
|
tempdir,
|
|
"dockers.tar",
|
|
)
|
|
|
|
command = [
|
|
"git",
|
|
"archive",
|
|
"--remote=" + GIT_URL,
|
|
GIT_REF,
|
|
"dockers",
|
|
]
|
|
|
|
with open(
|
|
archive_path,
|
|
"wb",
|
|
) as archive:
|
|
|
|
process = subprocess.run(
|
|
command,
|
|
stdout=archive,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
|
|
if process.returncode != 0:
|
|
|
|
raise RuntimeError(
|
|
"git archive a échoué : "
|
|
+ process.stderr.decode(
|
|
errors="replace"
|
|
)
|
|
)
|
|
|
|
extract_dir = os.path.join(
|
|
tempdir,
|
|
"src",
|
|
)
|
|
|
|
os.makedirs(
|
|
extract_dir,
|
|
exist_ok=True,
|
|
)
|
|
|
|
with tarfile.open(
|
|
archive_path,
|
|
"r",
|
|
) as tar:
|
|
|
|
for member in tar.getmembers():
|
|
|
|
if (
|
|
member.issym()
|
|
or member.islnk()
|
|
):
|
|
continue
|
|
|
|
tar.extract(
|
|
member,
|
|
extract_dir,
|
|
filter="data",
|
|
)
|
|
|
|
print(
|
|
"Git : archive extraite"
|
|
)
|
|
|
|
return extract_dir
|
|
|
|
|
|
# ============================================================
|
|
# Compose parser
|
|
# ============================================================
|
|
|
|
def parse_compose_file(
|
|
path,
|
|
):
|
|
"""
|
|
Parse le compose comme du texte.
|
|
"""
|
|
|
|
result = []
|
|
|
|
try:
|
|
|
|
with open(
|
|
path,
|
|
"r",
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
) as file:
|
|
|
|
lines = file.readlines()
|
|
|
|
except Exception:
|
|
|
|
return result
|
|
|
|
current_service = None
|
|
service_indent = None
|
|
|
|
for line in lines:
|
|
|
|
if not line.strip():
|
|
continue
|
|
|
|
if line.lstrip().startswith("#"):
|
|
continue
|
|
|
|
indent = (
|
|
len(line)
|
|
- len(line.lstrip())
|
|
)
|
|
|
|
stripped = line.strip()
|
|
|
|
match_service = re.match(
|
|
r"^([A-Za-z0-9_.-]+):\s*$",
|
|
stripped,
|
|
)
|
|
|
|
if (
|
|
match_service
|
|
and indent >= 2
|
|
):
|
|
|
|
current_service = (
|
|
match_service.group(1)
|
|
)
|
|
|
|
service_indent = indent
|
|
|
|
continue
|
|
|
|
if (
|
|
current_service
|
|
and service_indent is not None
|
|
and indent > service_indent
|
|
):
|
|
|
|
match_image = re.match(
|
|
r"^image:\s*['\"]?([^'\"\s]+)",
|
|
stripped,
|
|
)
|
|
|
|
if match_image:
|
|
|
|
image = match_image.group(
|
|
1
|
|
)
|
|
|
|
if (
|
|
image.endswith(
|
|
":latest"
|
|
)
|
|
or image.endswith(
|
|
":production"
|
|
)
|
|
):
|
|
continue
|
|
|
|
if ":" not in image:
|
|
continue
|
|
|
|
(
|
|
registry_name,
|
|
repository,
|
|
tag,
|
|
) = split_image(image)
|
|
|
|
if not tag:
|
|
continue
|
|
|
|
if not parse_version(tag):
|
|
continue
|
|
|
|
result.append(
|
|
(
|
|
current_service,
|
|
image,
|
|
)
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
# ============================================================
|
|
# Inventaire
|
|
# ============================================================
|
|
|
|
def inventaire():
|
|
|
|
print(
|
|
"=" * 50
|
|
)
|
|
|
|
print(
|
|
"Début inventaire"
|
|
)
|
|
|
|
# --------------------------------------------------------
|
|
# Nettoyage des anciennes métriques
|
|
# --------------------------------------------------------
|
|
|
|
git_metric._metrics.clear()
|
|
source_metric._metrics.clear()
|
|
version_metric._metrics.clear()
|
|
update_metric._metrics.clear()
|
|
|
|
# --------------------------------------------------------
|
|
# Git
|
|
# --------------------------------------------------------
|
|
|
|
try:
|
|
|
|
source_dir = git_archive()
|
|
|
|
except Exception as exc:
|
|
|
|
print(
|
|
f"Git erreur : {exc}"
|
|
)
|
|
|
|
return
|
|
|
|
compose_files = []
|
|
|
|
for root, dirs, files in os.walk(
|
|
os.path.join(
|
|
source_dir,
|
|
"dockers",
|
|
)
|
|
):
|
|
|
|
for filename in files:
|
|
|
|
if filename in (
|
|
"docker-compose.yml",
|
|
"docker-compose.yaml",
|
|
"docker-compose.yml.dist",
|
|
"docker-compose.yaml.dist",
|
|
"docker-compose.tmpl.yml.dist",
|
|
"docker-compose.tmpl.yaml.dist",
|
|
):
|
|
|
|
compose_files.append(
|
|
os.path.join(
|
|
root,
|
|
filename,
|
|
)
|
|
)
|
|
|
|
print(
|
|
f"Git : {len(compose_files)} fichiers compose"
|
|
)
|
|
|
|
# --------------------------------------------------------
|
|
# Images
|
|
# --------------------------------------------------------
|
|
|
|
images = {}
|
|
|
|
for compose_file in compose_files:
|
|
|
|
relative = os.path.relpath(
|
|
compose_file,
|
|
os.path.join(
|
|
source_dir,
|
|
"dockers",
|
|
),
|
|
)
|
|
|
|
project = relative.split(
|
|
os.sep,
|
|
1,
|
|
)[0]
|
|
|
|
entries = parse_compose_file(
|
|
compose_file
|
|
)
|
|
|
|
for service, image in entries:
|
|
|
|
(
|
|
registry_name,
|
|
repository,
|
|
tag,
|
|
) = split_image(image)
|
|
|
|
if not tag:
|
|
continue
|
|
|
|
git_metric.labels(
|
|
project=project,
|
|
service=service,
|
|
image=image,
|
|
version=tag,
|
|
).set(1)
|
|
|
|
images.setdefault(
|
|
image,
|
|
[],
|
|
).append(
|
|
{
|
|
"project": project,
|
|
"service": service,
|
|
"registry": registry_name,
|
|
"installed_version": tag,
|
|
}
|
|
)
|
|
|
|
# --------------------------------------------------------
|
|
# Suppression des doublons
|
|
# --------------------------------------------------------
|
|
|
|
for image in images:
|
|
|
|
unique_entries = []
|
|
seen = set()
|
|
|
|
for entry in images[image]:
|
|
|
|
key = (
|
|
entry["project"],
|
|
entry["service"],
|
|
entry["registry"],
|
|
entry["installed_version"],
|
|
)
|
|
|
|
if key in seen:
|
|
continue
|
|
|
|
seen.add(key)
|
|
|
|
unique_entries.append(
|
|
entry
|
|
)
|
|
|
|
images[image] = unique_entries
|
|
|
|
print(
|
|
f"Git : {len(images)} images distinctes"
|
|
)
|
|
|
|
# --------------------------------------------------------
|
|
# Source
|
|
# --------------------------------------------------------
|
|
|
|
print(
|
|
"Recherche des versions Source..."
|
|
)
|
|
|
|
for image in sorted(
|
|
images
|
|
):
|
|
|
|
source_version = None
|
|
|
|
try:
|
|
|
|
(
|
|
registry_name,
|
|
repository,
|
|
current_tag,
|
|
) = split_image(image)
|
|
|
|
source_version = (
|
|
source_version_for(
|
|
image
|
|
)
|
|
)
|
|
|
|
if source_version:
|
|
|
|
source_metric.labels(
|
|
image=image,
|
|
version=source_version,
|
|
registry=registry_name,
|
|
).set(1)
|
|
|
|
except Exception as exc:
|
|
|
|
print(
|
|
f" Erreur Source {image}: "
|
|
f"{exc}"
|
|
)
|
|
|
|
# ----------------------------------------------------
|
|
# Métrique complète pour Grafana
|
|
# ----------------------------------------------------
|
|
|
|
available_version = (
|
|
source_version
|
|
if source_version
|
|
else ""
|
|
)
|
|
|
|
for entry in images[image]:
|
|
|
|
installed_version = entry[
|
|
"installed_version"
|
|
]
|
|
|
|
version_metric.labels(
|
|
project=entry["project"],
|
|
service=entry["service"],
|
|
image=image,
|
|
installed_version=installed_version,
|
|
available_version=available_version,
|
|
registry=entry["registry"],
|
|
).set(1)
|
|
|
|
update_metric.labels(
|
|
project=entry["project"],
|
|
service=entry["service"],
|
|
image=image,
|
|
installed_version=installed_version,
|
|
available_version=available_version,
|
|
registry=entry["registry"],
|
|
).set(
|
|
1 if (
|
|
available_version
|
|
and installed_version != available_version
|
|
) else 0
|
|
)
|
|
|
|
print(
|
|
"Fin inventaire"
|
|
)
|
|
|
|
print(
|
|
"=" * 50
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# HTTP Prometheus
|
|
# ============================================================
|
|
|
|
class MetricsHandler(
|
|
BaseHTTPRequestHandler
|
|
):
|
|
|
|
def do_GET(self):
|
|
|
|
if self.path != "/metrics":
|
|
|
|
self.send_response(
|
|
404
|
|
)
|
|
|
|
self.end_headers()
|
|
|
|
return
|
|
|
|
output = generate_latest(
|
|
registry
|
|
)
|
|
|
|
self.send_response(
|
|
200
|
|
)
|
|
|
|
self.send_header(
|
|
"Content-Type",
|
|
"text/plain; version=0.0.4",
|
|
)
|
|
|
|
self.send_header(
|
|
"Content-Length",
|
|
str(len(output)),
|
|
)
|
|
|
|
self.end_headers()
|
|
|
|
self.wfile.write(
|
|
output
|
|
)
|
|
|
|
def log_message(
|
|
self,
|
|
format,
|
|
*args,
|
|
):
|
|
return
|
|
|
|
|
|
# ============================================================
|
|
# Main
|
|
# ============================================================
|
|
|
|
def main():
|
|
|
|
print(
|
|
"Démarrage docker-inventaire "
|
|
f"sur le port {PORT}"
|
|
)
|
|
|
|
server = HTTPServer(
|
|
(
|
|
"0.0.0.0",
|
|
PORT,
|
|
),
|
|
MetricsHandler,
|
|
)
|
|
|
|
def run_inventory():
|
|
|
|
while True:
|
|
|
|
try:
|
|
|
|
inventaire()
|
|
|
|
except Exception as exc:
|
|
|
|
print(
|
|
f"ERREUR inventaire : {exc}"
|
|
)
|
|
|
|
time.sleep(
|
|
INVENTORY_INTERVAL
|
|
)
|
|
|
|
thread = threading.Thread(
|
|
target=run_inventory,
|
|
daemon=True,
|
|
)
|
|
|
|
thread.start()
|
|
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|