diff --git a/gns3server/api/routes/compute/images.py b/gns3server/api/routes/compute/images.py index 3c3293934..2bde73906 100644 --- a/gns3server/api/routes/compute/images.py +++ b/gns3server/api/routes/compute/images.py @@ -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]: """ diff --git a/gns3server/api/routes/controller/computes.py b/gns3server/api/routes/controller/computes.py index 7385026bf..59c347ef7 100644 --- a/gns3server/api/routes/controller/computes.py +++ b/gns3server/api/routes/controller/computes.py @@ -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]: """ diff --git a/gns3server/compute/docker/__init__.py b/gns3server/compute/docker/__init__.py index 4a64a4d3a..f076e556c 100644 --- a/gns3server/compute/docker/__init__.py +++ b/gns3server/compute/docker/__init__.py @@ -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}") diff --git a/tests/api/routes/compute/test_images.py b/tests/api/routes/compute/test_images.py new file mode 100644 index 000000000..d4331557c --- /dev/null +++ b/tests/api/routes/compute/test_images.py @@ -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 . + +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 diff --git a/tests/api/routes/controller/test_computes.py b/tests/api/routes/controller/test_computes.py index 13c99234e..c92bc1a14 100644 --- a/tests/api/routes/controller/test_computes.py +++ b/tests/api/routes/controller/test_computes.py @@ -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 = { diff --git a/tests/compute/docker/test_docker.py b/tests/compute/docker/test_docker.py index fd5cf81fd..0a28a273b 100644 --- a/tests/compute/docker/test_docker.py +++ b/tests/compute/docker/test_docker.py @@ -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):