Files
NetBirdMSP-Appliance/app/services/image_service.py
twothatIT 848ead0b2c feat(updates): NetBird container image update management
- New image_service.py: Docker Hub digest check (no pull), local digest/ID
  comparison, pull_all_images, per-customer container image status, and
  update_customer_containers (docker compose up -d, data-safe)
- Monitoring endpoints: GET /images/check (hub vs local + per-customer
  needs_update), POST /images/pull (background), POST /customers/update-all
- Deployment endpoint: POST /{id}/update-images (single-customer update)
- Monitoring page: "NetBird Container Updates" card with Check / Pull / Update
  All buttons; image status table and per-customer update table with inline
  update buttons
- i18n: added keys in en.json and de.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 21:01:56 +01:00

252 lines
8.8 KiB
Python

"""NetBird Docker image update service.
Compares locally pulled images against Docker Hub to detect available updates.
Provides pull and per-customer container recreation functions without data loss.
"""
import asyncio
import json
import logging
import os
import subprocess
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# Services that make up a customer's NetBird deployment
NETBIRD_SERVICES = ["management", "signal", "relay", "dashboard"]
async def _run_cmd(cmd: list[str], timeout: int = 300) -> subprocess.CompletedProcess:
"""Run a subprocess command without blocking the event loop."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout),
)
def _parse_image_name(image: str) -> tuple[str, str]:
"""Split 'repo/name:tag' into ('repo/name', 'tag'). Defaults tag to 'latest'."""
if ":" in image:
name, tag = image.rsplit(":", 1)
else:
name, tag = image, "latest"
return name, tag
async def get_hub_digest(image: str) -> str | None:
"""Fetch the current digest from Docker Hub for an image:tag.
Uses the Docker Hub REST API — does NOT pull the image.
Returns the digest string (sha256:...) or None on failure.
"""
name, tag = _parse_image_name(image)
url = f"https://hub.docker.com/v2/repositories/{name}/tags/{tag}/"
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning("Docker Hub API returned %d for %s", resp.status_code, image)
return None
data = resp.json()
images = data.get("images", [])
# Prefer linux/amd64 digest
for img in images:
if img.get("os") == "linux" and img.get("architecture") in ("amd64", ""):
d = img.get("digest")
if d:
return d
# Fallback: first available digest
if images:
return images[0].get("digest")
return None
except Exception as exc:
logger.warning("Failed to fetch Docker Hub digest for %s: %s", image, exc)
return None
def get_local_digest(image: str) -> str | None:
"""Get the RepoDigest for a locally pulled image.
Returns the digest (sha256:...) or None if image not found locally.
"""
try:
result = subprocess.run(
["docker", "image", "inspect", image, "--format", "{{json .RepoDigests}}"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return None
digests = json.loads(result.stdout.strip())
if not digests:
return None
# RepoDigests look like "netbirdio/management@sha256:abc..."
for d in digests:
if "@" in d:
return d.split("@", 1)[1]
return None
except Exception as exc:
logger.warning("Failed to inspect local image %s: %s", image, exc)
return None
def get_container_image_id(container_name: str) -> str | None:
"""Get the full image ID (sha256:...) of a running or stopped container."""
try:
result = subprocess.run(
["docker", "inspect", container_name, "--format", "{{.Image}}"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return None
return result.stdout.strip() or None
except Exception:
return None
def get_local_image_id(image: str) -> str | None:
"""Get the full image ID (sha256:...) of a locally stored image."""
try:
result = subprocess.run(
["docker", "image", "inspect", image, "--format", "{{.Id}}"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return None
return result.stdout.strip() or None
except Exception:
return None
async def check_image_status(image: str) -> dict[str, Any]:
"""Check whether a configured image has an update available on Docker Hub.
Returns a dict with:
image: the image name:tag
local_digest: digest of locally cached image (or None)
hub_digest: latest digest from Docker Hub (or None)
update_available: True if hub_digest differs from local_digest
"""
hub_digest, local_digest = await asyncio.gather(
get_hub_digest(image),
asyncio.get_event_loop().run_in_executor(None, get_local_digest, image),
)
if hub_digest and local_digest:
update_available = hub_digest != local_digest
elif hub_digest and not local_digest:
# Image not pulled locally yet — needs pull
update_available = True
else:
update_available = False
return {
"image": image,
"local_digest": local_digest,
"hub_digest": hub_digest,
"update_available": update_available,
}
async def check_all_images(config) -> dict[str, Any]:
"""Check all 4 configured NetBird images for available updates.
Returns a dict with:
images: dict mapping image name -> status dict
any_update_available: bool
"""
images = [
config.netbird_management_image,
config.netbird_signal_image,
config.netbird_relay_image,
config.netbird_dashboard_image,
]
results = await asyncio.gather(*[check_image_status(img) for img in images])
by_image = {r["image"]: r for r in results}
any_update = any(r["update_available"] for r in results)
return {"images": by_image, "any_update_available": any_update}
async def pull_image(image: str) -> dict[str, Any]:
"""Pull a Docker image. Returns success/error dict."""
logger.info("Pulling image: %s", image)
result = await _run_cmd(["docker", "pull", image], timeout=600)
if result.returncode != 0:
logger.error("Failed to pull %s: %s", image, result.stderr)
return {"image": image, "success": False, "error": result.stderr[:500]}
return {"image": image, "success": True}
async def pull_all_images(config) -> dict[str, Any]:
"""Pull all 4 configured NetBird images. Returns results per image."""
images = [
config.netbird_management_image,
config.netbird_signal_image,
config.netbird_relay_image,
config.netbird_dashboard_image,
]
results = await asyncio.gather(*[pull_image(img) for img in images])
return {
"results": {r["image"]: r for r in results},
"all_success": all(r["success"] for r in results),
}
def get_customer_container_image_status(container_prefix: str, config) -> dict[str, Any]:
"""Check which service containers are running outdated local images.
Compares each running container's image ID against the locally stored image ID
for the configured image tag. This is a local check — no network call.
Returns:
services: dict mapping service name to status info
needs_update: True if any service has a different image ID than locally stored
"""
service_images = {
"management": config.netbird_management_image,
"signal": config.netbird_signal_image,
"relay": config.netbird_relay_image,
"dashboard": config.netbird_dashboard_image,
}
services: dict[str, Any] = {}
for svc, image in service_images.items():
container_name = f"{container_prefix}-{svc}"
container_id = get_container_image_id(container_name)
local_id = get_local_image_id(image)
if container_id and local_id:
up_to_date = container_id == local_id
else:
up_to_date = None # container not running or image not pulled
services[svc] = {
"container": container_name,
"image": image,
"up_to_date": up_to_date,
}
needs_update = any(s["up_to_date"] is False for s in services.values())
return {"services": services, "needs_update": needs_update}
async def update_customer_containers(instance_dir: str, project_name: str) -> dict[str, Any]:
"""Recreate customer containers to pick up newly pulled images.
Runs `docker compose up -d` in the customer's instance directory.
Images must already be pulled. Bind-mounted data is preserved — no data loss.
"""
compose_file = os.path.join(instance_dir, "docker-compose.yml")
if not os.path.isfile(compose_file):
return {"success": False, "error": f"docker-compose.yml not found at {compose_file}"}
cmd = [
"docker", "compose",
"-f", compose_file,
"-p", project_name,
"up", "-d", "--remove-orphans",
]
logger.info("Updating containers for %s", project_name)
result = await _run_cmd(cmd, timeout=300)
if result.returncode != 0:
return {"success": False, "error": result.stderr[:1000]}
return {"success": True}