Merge pull request #2983 from yueguobin/fix/docker-image-digest-translation

fix(docker): accept the pinned image id when the two daemons report different ids for the same content
This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-10-13 16:23:39 +02:00
commit b08441b9ad
8 files changed
+259 -19

No files matched your search

+14 -4
View File
@@ -20,9 +20,9 @@ API routes for images.
import os
import urllib.parse
from typing import List
from typing import List, Optional
from fastapi import APIRouter, Body, HTTPException, Request, status
from fastapi import APIRouter, Body, HTTPException, Query, Request, status
from fastapi.responses import FileResponse
from gns3server.compute.docker import Docker
@@ -54,13 +54,23 @@ async def pull_docker_image(image: str = Body(..., embed=True, min_length=1, pat
@router.post("/docker/images/load", status_code=status.HTTP_204_NO_CONTENT)
async def load_docker_image(request: Request) -> None:
async def load_docker_image(
request: Request,
image: Optional[str] = Query(None, pattern=r"^\S+$", description="Tag of the image in the archive"),
digest: Optional[str] = Query(
None, pattern=r"^\S+$", description="Image id reported by the controller host daemon"
),
) -> None:
"""
Load a Docker image into the Docker daemon from a docker save tar stream.
The optional image tag and digest let the compute remember which image the
controller pushed and how the local daemon identifies it (see
Docker.load_image).
"""
docker_manager = Docker.instance()
await docker_manager.load_image(request.stream())
await docker_manager.load_image(request.stream(), image=image, digest=digest)
@router.get("/dynamips/images")
+44 -1
View File
@@ -66,6 +66,11 @@ class Docker(BaseManager):
self._session = None
self._api_version = DOCKER_MINIMUM_API_VERSION
self._host_checked = False
# Image name -> (controller digest, local id) for the images synced from
# the controller. The controller pins the id its own daemon reports,
# which is not comparable with the id this daemon reports for the same
# content (see load_image()).
self._image_id_translations = {}
def _select_node_class(self, **kwargs):
"""
@@ -419,11 +424,16 @@ class Docker(BaseManager):
progress_callback(f"Success pulling image {image}")
@locking
async def load_image(self, stream, progress_callback=None):
async def load_image(self, stream, image=None, digest=None, progress_callback=None):
"""
Load a Docker image into the Docker daemon from a docker save tar stream
:param stream: An async iterable of bytes (the tar produced by docker save)
:param image: Tag of the image in the archive. Together with digest, the
(controller digest, local id) pair is remembered so that node creation
can accept the digest the controller pinned for this image
:param digest: Image id as reported by the Docker daemon on the controller
host (the value the controller pins in the node create payload)
:param progress_callback: A function that receive a log message about image load progress
"""
@@ -470,6 +480,39 @@ class Docker(BaseManager):
if progress_callback:
progress_callback("Docker image loaded")
if image and digest:
await self._remember_image_id_translation(image, digest)
async def _remember_image_id_translation(self, image, controller_digest):
"""
Remember how the local daemon identifies an image that was just loaded
from the controller.
Docker ids are not comparable across image stores: for the same content
a classic store reports the config digest, while a containerd store (or
an image loaded from an OCI archive) reports the manifest digest. The
controller pins the id of its own daemon, so recording the pair lets
DockerVM accept that pin for the image it actually received instead of
asking for another full sync on every node creation.
"""
try:
image_infos = await self.query("GET", f"images/{image}/json")
except DockerError as e:
log.warning(f"Image '{image}' has been loaded but cannot be inspected: {e}")
return
local_id = image_infos.get("Id")
if local_id:
self._image_id_translations[image] = (controller_digest, local_id)
def image_id_translation(self, image):
"""
:returns: the (controller digest, local id) pair recorded the last time
this image was loaded from the controller, None otherwise
"""
return self._image_id_translations.get(image)
async def list_images(self):
"""
Gets Docker image list.
+13 -8
View File
@@ -591,15 +591,20 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode):
if image_infos is None:
raise DockerError(f"Cannot get information for image '{self._image}', please try again.")
if self._image_digest and image_infos.get("Id") != self._image_digest:
local_id = image_infos.get("Id")
if self._image_digest and local_id != self._image_digest:
# the tag exists but points to a different image (e.g. a moved :latest):
# report it as missing so the controller re-syncs the expected version
local_id = image_infos.get("Id")
log.info(
f"Image '{self._image}' version mismatch on this compute: "
f"local id '{local_id}' != expected '{self._image_digest}'"
)
raise ImageMissingError(self._image)
# report it as missing so the controller re-syncs the expected version.
# Image ids are not comparable across image stores (a classic store
# reports the config digest where a containerd store, or an image
# loaded from an OCI archive, reports the manifest digest), so accept
# the pin for the image this compute received from the controller.
if self.manager.image_id_translation(self._image) != (self._image_digest, local_id):
log.info(
f"Image '{self._image}' version mismatch on this compute: "
f"local id '{local_id}' != expected '{self._image_digest}'"
)
raise ImageMissingError(self._image)
available_cpus = psutil.cpu_count(logical=True)
if self._cpus > available_cpus:
+20 -5
View File
@@ -527,7 +527,9 @@ class Node:
res = False
if last_missing_image:
try:
res = await self._upload_missing_image(self._node_type, last_missing_image)
res = await self._upload_missing_image(
self._node_type, last_missing_image, data.get("image_digest")
)
except ControllerError as upload_error:
# For Docker the image is pulled from a registry: a
# failed pull (unknown tag, no network, private image)
@@ -1075,14 +1077,18 @@ class Node:
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}{path}", **kwargs
)
async def _upload_missing_image(self, type, img):
async def _upload_missing_image(self, type, img, image_digest=None):
"""
Search an image on local computer and upload it to remote compute
if the image exists
:param image_digest: digest pinned for this image in the create payload,
forwarded so the compute can translate it (see
_sync_missing_docker_image)
"""
if self._node_type == "docker":
return await self._sync_missing_docker_image(img)
return await self._sync_missing_docker_image(img, image_digest)
image = self._find_local_image(type, img)
if image is None:
@@ -1119,11 +1125,17 @@ class Node:
if image_id:
data["image_digest"] = image_id
async def _sync_missing_docker_image(self, image):
async def _sync_missing_docker_image(self, image, image_digest=None):
"""
Export a Docker image from the Docker daemon on the controller host and
stream it to the remote compute, or ask the compute to pull it when the
image is not available locally
:param image_digest: digest pinned for this image in the create payload.
It is sent along with the archive so the compute can remember how its
own daemon identifies the image it received: image ids are not
comparable across image stores, so the pin alone would keep being
reported as a mismatch after every sync.
"""
from gns3server.compute.docker import Docker
@@ -1147,8 +1159,11 @@ class Node:
self.project.emit_notification(
"log.info", {"message": f"Syncing Docker image '{image}' to compute '{self._compute.name}'"}
)
params = {"image": image}
if image_digest:
params["digest"] = image_digest
try:
await self._compute.post("/docker/images/load", data=response.content, timeout=None)
await self._compute.post("/docker/images/load", data=response.content, timeout=None, params=params)
finally:
response.close()
self.project.emit_notification(