mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 20:40:13 +03:00
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:
commit
f1be5eae9b
@ -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]:
|
||||
"""
|
||||
|
||||
@ -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]:
|
||||
"""
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
49
tests/api/routes/compute/test_images.py
Normal file
49
tests/api/routes/compute/test_images.py
Normal 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
|
||||
@ -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 = {
|
||||
|
||||
@ -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):
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user