Merge pull request #2829 from cristian-ciobanu/docker-pull-image

Add controller and compute API support for explicitly pulling or updating Docker images
This commit is contained in:
Jeremy Grossmann 2026-07-24 20:14:55 +02:00 committed by GitHub
commit f1be5eae9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 229 additions and 32 deletions

View File

@ -21,7 +21,7 @@ API routes for images.
import os
import urllib.parse
from fastapi import APIRouter, Request, status, Response, HTTPException
from fastapi import APIRouter, Body, Request, status, Response, HTTPException
from fastapi.responses import FileResponse
from typing import List
@ -43,6 +43,16 @@ async def get_docker_images() -> List[dict]:
return await docker_manager.list_images()
@router.post("/docker/images/pull", status_code=status.HTTP_204_NO_CONTENT)
async def pull_docker_image(image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$")) -> None:
"""
Pull or update a Docker image.
"""
docker_manager = Docker.instance()
await docker_manager.pull_image(image, force=True)
@router.get("/dynamips/images")
async def get_dynamips_images() -> List[dict]:
"""

View File

@ -18,7 +18,7 @@
API routes for computes.
"""
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Body, Depends, status
from typing import Any, List, Union, Optional
from uuid import UUID
@ -165,6 +165,25 @@ async def docker_get_images(compute_id: Union[str, UUID]) -> List[schemas.Comput
return result
@router.post(
"/{compute_id}/docker/images/pull",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Compute.Modify"))]
)
async def docker_pull_image(
compute_id: Union[str, UUID],
image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$")
) -> None:
"""
Pull or update a Docker image on a compute.
Required privilege: Compute.Modify
"""
compute = Controller.instance().get_compute(str(compute_id))
await compute.forward("POST", "docker", "images/pull", data={"image": image})
@router.get("/{compute_id}/virtualbox/vms", response_model=List[schemas.ComputeVirtualBoxVM])
async def virtualbox_vms(compute_id: Union[str, UUID]) -> List[schemas.ComputeVirtualBoxVM]:
"""

View File

@ -260,19 +260,21 @@ class Docker(BaseManager):
return connection
@locking
async def pull_image(self, image, progress_callback=None):
async def pull_image(self, image, progress_callback=None, force=False):
"""
Pulls an image from the Docker repository
:params image: Image name
:params progress_callback: A function that receive a log message about image download progress
:params force: Pull the image even if it is already available locally
"""
try:
await self.query("GET", f"images/{image}/json")
return # We already have the image skip the download
except DockerHttp404Error:
pass
if not force:
try:
await self.query("GET", f"images/{image}/json")
return # We already have the image skip the download
except DockerHttp404Error:
pass
if progress_callback:
progress_callback(f"Pulling '{image}' from Docker repository")
@ -285,29 +287,45 @@ class Docker(BaseManager):
)
# The pull api will stream status via an HTTP JSON stream
content = ""
while True:
try:
chunk = await response.content.read(CHUNK_SIZE)
except aiohttp.ServerDisconnectedError:
log.error(f"Disconnected from server while pulling Docker image '{image}' from Docker repository")
break
except asyncio.TimeoutError:
log.error("Timeout while pulling Docker image '{}' from Docker repository".format(image))
break
if not chunk:
break
content += chunk.decode("utf-8")
try:
while True:
try:
chunk = await response.content.read(CHUNK_SIZE)
except aiohttp.ServerDisconnectedError as e:
raise DockerError(
f"Disconnected while pulling Docker image '{image}' from Docker repository"
) from e
except asyncio.TimeoutError as e:
raise DockerError(
f"Timeout while pulling Docker image '{image}' from Docker repository"
) from e
if not chunk:
break
content += chunk.decode("utf-8")
try:
while True:
content = content.lstrip(" \r\n\t")
answer, index = json.JSONDecoder().raw_decode(content)
if not isinstance(answer, dict):
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
error_detail = answer.get("errorDetail")
error = answer.get("error")
if not error and isinstance(error_detail, dict):
error = error_detail.get("message")
if error:
raise DockerError(error)
if "progress" in answer and progress_callback:
progress_callback("Pulling image {}:{}: {}".format(image, answer["id"], answer["progress"]))
content = content[index:]
except ValueError: # Partial JSON
pass
if content.strip():
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
finally:
response.close()
try:
while True:
content = content.lstrip(" \r\n\t")
answer, index = json.JSONDecoder().raw_decode(content)
if "progress" in answer and progress_callback:
progress_callback("Pulling image {}:{}: {}".format(image, answer["id"], answer["progress"]))
content = content[index:]
except ValueError: # Partial JSON
pass
response.close()
if progress_callback:
progress_callback(f"Success pulling image {image}")

View File

@ -0,0 +1,49 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
from tests.utils import asyncio_patch
pytestmark = pytest.mark.asyncio
class TestImagesRoutes:
async def test_pull_docker_image(self, app: FastAPI, compute_client: AsyncClient) -> None:
with asyncio_patch("gns3server.compute.docker.Docker.pull_image") as mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"),
json={"image": "nginx:latest"}
)
mock.assert_called_once_with("nginx:latest", force=True)
assert response.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.parametrize("image", ["", " ", "nginx latest"])
async def test_pull_docker_image_rejects_invalid_name(
self, app: FastAPI, compute_client: AsyncClient, image: str
) -> None:
with asyncio_patch("gns3server.compute.docker.Docker.pull_image") as mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"),
json={"image": image}
)
mock.assert_not_called()
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT

View File

@ -17,6 +17,7 @@
import uuid
import pytest
from unittest.mock import MagicMock, patch
from fastapi import FastAPI, status
from httpx import AsyncClient
@ -25,8 +26,7 @@ from gns3server.schemas.controller.computes import Compute
pytestmark = pytest.mark.asyncio
import unittest
from tests.utils import asyncio_patch
from tests.utils import asyncio_patch, AsyncioMagicMock
class TestComputeRoutes:
@ -128,6 +128,21 @@ class TestComputeFeatures:
mock.assert_called_with("GET", "docker", "images")
assert response.json() == [{"image": "docker1"}, {"image": "docker2"}]
async def test_compute_pull_docker_image(
self, app: FastAPI, client: AsyncClient, test_compute: Compute
) -> None:
compute = MagicMock()
compute.forward = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.post(
app.url_path_for("docker_pull_image", compute_id=test_compute.compute_id),
json={"image": "nginx:latest"}
)
compute.forward.assert_called_with("POST", "docker", "images/pull", data={"image": "nginx:latest"})
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_compute_list_virtualbox_vms(self, app: FastAPI, client: AsyncClient) -> None:
params = {

View File

@ -169,6 +169,92 @@ async def test_pull_image():
mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
@pytest.mark.asyncio
async def test_pull_image_skips_image_available_locally():
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": "existing"}) as query_mock:
with asyncio_patch("gns3server.compute.docker.Docker.http_query") as pull_mock:
await Docker.instance().pull_image("ubuntu")
query_mock.assert_called_once_with("GET", "images/ubuntu/json")
pull_mock.assert_not_called()
@pytest.mark.asyncio
async def test_force_pull_image():
response = MagicMock()
response.content.read = AsyncioMagicMock(return_value=b"")
with asyncio_patch("gns3server.compute.docker.Docker.query") as query_mock:
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response) as pull_mock:
await Docker.instance().pull_image("ubuntu", force=True)
query_mock.assert_not_called()
pull_mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
@pytest.mark.asyncio
async def test_pull_image_error():
class Content:
def __init__(self):
self._chunks = [b'{"error": "image not found"}', b""]
async def read(self, size):
return self._chunks.pop(0)
response = MagicMock()
response.content = Content()
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
with pytest.raises(DockerError, match="image not found"):
await Docker.instance().pull_image("missing")
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_pull_image_rejects_incomplete_response():
class Content:
def __init__(self):
self._read = False
async def read(self, size):
if self._read:
return b""
self._read = True
return b'{"status": "Pulling"'
response = MagicMock()
response.content = Content()
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
with pytest.raises(DockerError, match="Invalid response"):
await Docker.instance().pull_image("ubuntu")
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_pull_image_propagates_timeout():
class Content:
async def read(self, size):
raise asyncio.TimeoutError
response = MagicMock()
response.content = Content()
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
with pytest.raises(DockerError, match="Timeout while pulling"):
await Docker.instance().pull_image("ubuntu")
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_docker_check_connection_docker_minimum_version(vm):