mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-04 09:40:12 +03:00
Preserve image reconciliation, locking, and regression coverage while integrating upstream release, dependency, typing, and formatting updates. Align feature code with the new Ruff checks.
465 lines
18 KiB
Python
465 lines
18 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Copyright (C) 2021 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 os
|
|
import json
|
|
import uuid
|
|
import pytest
|
|
import hashlib
|
|
|
|
from tests.utils import asyncio_patch
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from fastapi import FastAPI, status
|
|
from httpx import AsyncClient
|
|
from tests.utils import AsyncioMagicMock
|
|
|
|
from gns3server.controller import Controller
|
|
from gns3server.db.repositories.images import ImagesRepository
|
|
from gns3server.db.repositories.templates import TemplatesRepository
|
|
from gns3server.compute.qemu import Qemu
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
@pytest.fixture
|
|
def iou_32_bit_image(tmpdir) -> str:
|
|
"""
|
|
Create a fake IOU image on disk
|
|
"""
|
|
|
|
path = os.path.join(tmpdir, "iou_32bit.bin")
|
|
with open(path, "wb+") as f:
|
|
f.write(b"\x7fELF\x01\x01\x01")
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def iou_64_bit_image(tmpdir) -> str:
|
|
"""
|
|
Create a fake IOU image on disk
|
|
"""
|
|
|
|
path = os.path.join(tmpdir, "iou_64bit.bin")
|
|
with open(path, "wb+") as f:
|
|
f.write(b"\x7fELF\x02\x01\x01")
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def ios_image(tmpdir) -> str:
|
|
"""
|
|
Create a fake IOS image on disk
|
|
"""
|
|
|
|
path = os.path.join(tmpdir, "ios_image.bin")
|
|
with open(path, "wb+") as f:
|
|
f.write(b"\x7fELF\x01\x02\x01")
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def qcow2_image(tmpdir) -> str:
|
|
"""
|
|
Create a fake Qemu qcow2 image on disk
|
|
"""
|
|
|
|
path = os.path.join(tmpdir, "image.qcow2")
|
|
with open(path, "wb+") as f:
|
|
f.write(b"QFI\xfb\x00\x00\x00")
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def invalid_image(tmpdir) -> str:
|
|
"""
|
|
Create a fake invalid image on disk
|
|
"""
|
|
|
|
path = os.path.join(tmpdir, "invalid_image.bin")
|
|
with open(path, "wb+") as f:
|
|
f.write(b"\x01\x01\x01\x01")
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def empty_image(tmpdir) -> str:
|
|
"""
|
|
Create a fake empty image on disk
|
|
"""
|
|
|
|
path = os.path.join(tmpdir, "empty_image.bin")
|
|
with open(path, "wb+") as f:
|
|
f.write(b"")
|
|
return path
|
|
|
|
|
|
class TestImageRoutes:
|
|
async def test_create_image(self, app: FastAPI, client: AsyncClient, images_dir) -> None:
|
|
|
|
async def create_disk(path, options):
|
|
with open(path, "wb") as f:
|
|
f.write(b"QFI\xfb\x00\x00\x00")
|
|
|
|
Qemu.instance().create_disk_image = AsyncioMagicMock(side_effect=create_disk)
|
|
path = os.path.join(os.path.join(images_dir, "QEMU", "new_image.qcow2"))
|
|
image_name = os.path.basename(path)
|
|
response = await client.post(
|
|
app.url_path_for("create_qemu_image", image_path=image_name), json={"format": "qcow2", "size": 30}
|
|
)
|
|
assert response.status_code == status.HTTP_201_CREATED
|
|
assert os.path.isfile(path)
|
|
|
|
@pytest.mark.parametrize(
|
|
"image_type, fixture_name, valid_request",
|
|
(
|
|
("iou", "iou_32_bit_image", True),
|
|
("iou", "iou_64_bit_image", True),
|
|
("iou", "invalid_image", False),
|
|
("ios", "ios_image", True),
|
|
("ios", "invalid_image", False),
|
|
("qemu", "qcow2_image", True),
|
|
("qemu", "empty_image", False),
|
|
),
|
|
)
|
|
async def test_upload_image(
|
|
self,
|
|
app: FastAPI,
|
|
client: AsyncClient,
|
|
images_dir: str,
|
|
image_type: str,
|
|
fixture_name: str,
|
|
valid_request: bool,
|
|
request,
|
|
) -> None:
|
|
|
|
image_path = request.getfixturevalue(fixture_name)
|
|
image_name = os.path.basename(image_path)
|
|
image_checksum = hashlib.md5()
|
|
with open(image_path, "rb") as f:
|
|
image_data = f.read()
|
|
image_checksum.update(image_data)
|
|
|
|
response = await client.post(app.url_path_for("upload_image", image_path=image_name), content=image_data)
|
|
|
|
if valid_request:
|
|
assert response.status_code == status.HTTP_201_CREATED
|
|
assert response.json()["filename"] == image_name
|
|
assert response.json()["checksum"] == image_checksum.hexdigest()
|
|
assert os.path.exists(os.path.join(images_dir, image_type.upper(), image_name))
|
|
else:
|
|
assert response.status_code != status.HTTP_201_CREATED
|
|
|
|
async def test_image_list(self, app: FastAPI, client: AsyncClient) -> None:
|
|
|
|
response = await client.get(app.url_path_for("get_images"))
|
|
assert response.status_code == status.HTTP_200_OK
|
|
assert len(response.json()) == 5 # 4 valid images uploaded before + 1 created
|
|
|
|
async def test_image_get(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None:
|
|
|
|
image_name = os.path.basename(qcow2_image)
|
|
response = await client.get(app.url_path_for("get_image", image_path=image_name))
|
|
assert response.status_code == status.HTTP_200_OK
|
|
assert response.json()["filename"] == image_name
|
|
|
|
async def test_same_image_is_uploaded(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None:
|
|
|
|
with open(qcow2_image, "rb") as f:
|
|
image_data = f.read()
|
|
response = await client.post(app.url_path_for("upload_image", image_path="image1.qcow2"), content=image_data)
|
|
assert response.status_code == status.HTTP_201_CREATED
|
|
|
|
# same image with same name is uploaded again, it should return 409 Conflict
|
|
response = await client.post(app.url_path_for("upload_image", image_path="image1.qcow2"), content=image_data)
|
|
assert response.status_code == status.HTTP_409_CONFLICT
|
|
|
|
# same image with different name but same checksum is uploaded again, it should return 409 Conflict
|
|
response = await client.post(app.url_path_for("upload_image", image_path="image2.qcow2"), content=image_data)
|
|
assert response.status_code == status.HTTP_409_CONFLICT
|
|
|
|
async def test_image_delete(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None:
|
|
|
|
image_name = os.path.basename(qcow2_image)
|
|
response = await client.delete(app.url_path_for("delete_image", image_path=image_name))
|
|
assert response.status_code == status.HTTP_204_NO_CONTENT
|
|
|
|
async def test_not_found_image(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None:
|
|
|
|
image_name = os.path.basename(qcow2_image)
|
|
response = await client.get(app.url_path_for("get_image", image_path=image_name))
|
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
|
|
async def test_image_deleted_on_disk(
|
|
self, app: FastAPI, client: AsyncClient, images_dir: str, qcow2_image: str
|
|
) -> None:
|
|
|
|
image_name = os.path.basename(qcow2_image)
|
|
with open(qcow2_image, "rb") as f:
|
|
image_data = f.read()
|
|
response = await client.post(app.url_path_for("upload_image", image_path=image_name), content=image_data)
|
|
assert response.status_code == status.HTTP_201_CREATED
|
|
|
|
response = await client.delete(app.url_path_for("delete_image", image_path=image_name))
|
|
assert response.status_code == status.HTTP_204_NO_CONTENT
|
|
assert not os.path.exists(os.path.join(images_dir, "QEMU", image_name))
|
|
|
|
@pytest.mark.parametrize(
|
|
"subdir, expected_result",
|
|
(
|
|
("subdir", status.HTTP_201_CREATED),
|
|
("subdir2", status.HTTP_201_CREATED),
|
|
),
|
|
)
|
|
async def test_upload_image_subdir(
|
|
self,
|
|
app: FastAPI,
|
|
client: AsyncClient,
|
|
images_dir: str,
|
|
qcow2_image: str,
|
|
subdir: str,
|
|
expected_result: int,
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
|
|
image_name = os.path.basename(qcow2_image)
|
|
with open(qcow2_image, "rb") as f:
|
|
image_data = f.read()
|
|
image_path = os.path.join(subdir, image_name)
|
|
response = await client.post(app.url_path_for("upload_image", image_path=image_path), content=image_data)
|
|
assert response.status_code == expected_result
|
|
|
|
async def test_image_delete_multiple_match(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None:
|
|
|
|
image_name = os.path.basename(qcow2_image)
|
|
response = await client.delete(app.url_path_for("delete_image", image_path=image_name))
|
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
|
|
|
async def test_image_delete_with_subdir(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None:
|
|
|
|
image_name = os.path.basename(qcow2_image)
|
|
image_path = os.path.join("subdir", image_name)
|
|
response = await client.delete(app.url_path_for("delete_image", image_path=image_path))
|
|
assert response.status_code == status.HTTP_204_NO_CONTENT
|
|
|
|
async def test_prune_images(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None:
|
|
|
|
images_repo = ImagesRepository(db_session)
|
|
images_in_db = await images_repo.get_images()
|
|
assert len(images_in_db) != 0
|
|
|
|
response = await client.delete(app.url_path_for("prune_images"))
|
|
assert response.status_code == status.HTTP_204_NO_CONTENT
|
|
|
|
images_in_db = await images_repo.get_images()
|
|
assert len(images_in_db) == 0
|
|
|
|
@staticmethod
|
|
async def _add_closed_project_with_node(controller: Controller, name: str, node: dict):
|
|
"""
|
|
Register a closed project whose .gns3 contains a single node,
|
|
mirroring how the controller picks up existing projects from disk.
|
|
"""
|
|
|
|
project_dir = os.path.join(controller.projects_directory(), name)
|
|
os.makedirs(project_dir)
|
|
project_id = str(uuid.uuid4())
|
|
topology = {
|
|
"name": name,
|
|
"project_id": project_id,
|
|
"topology": {"computes": [], "links": [], "drawings": [], "nodes": [node]},
|
|
}
|
|
with open(os.path.join(project_dir, f"{name}.gns3"), "w+") as f:
|
|
json.dump(topology, f)
|
|
return await controller.add_project(
|
|
project_id=project_id,
|
|
name=name,
|
|
path=project_dir,
|
|
filename=f"{name}.gns3",
|
|
status="closed",
|
|
)
|
|
|
|
async def test_image_delete_used_by_project(
|
|
self,
|
|
app: FastAPI,
|
|
client: AsyncClient,
|
|
controller: Controller,
|
|
db_session: AsyncSession,
|
|
tmpdir: str,
|
|
) -> None:
|
|
"""
|
|
An image referenced by a node in a project must not be deleted,
|
|
even when the project is closed and no template uses the image.
|
|
"""
|
|
|
|
image_path = os.path.join(tmpdir, "used.qcow2")
|
|
with open(image_path, "wb+") as f:
|
|
f.write(b"\x42\x42\x42\x42")
|
|
|
|
images_repo = ImagesRepository(db_session)
|
|
await images_repo.add_image("used.qcow2", "qemu", 42, image_path, "e342eb86c1229b6c154367a5476969b5", "md5")
|
|
|
|
guarded_project = await self._add_closed_project_with_node(
|
|
controller,
|
|
"Guarded",
|
|
{
|
|
"node_id": str(uuid.uuid4()),
|
|
"node_type": "qemu",
|
|
"compute_id": "local",
|
|
"name": "n1",
|
|
"properties": {"hda_disk_image_backing_file": "used.qcow2"},
|
|
},
|
|
)
|
|
|
|
response = await client.delete(app.url_path_for("delete_image", image_path="used.qcow2"))
|
|
assert response.status_code == status.HTTP_409_CONFLICT
|
|
assert "Guarded" in response.json()["message"]
|
|
assert os.path.exists(image_path)
|
|
|
|
# once no project uses it anymore the deletion goes through
|
|
controller.remove_project(guarded_project)
|
|
response = await client.delete(app.url_path_for("delete_image", image_path="used.qcow2"))
|
|
assert response.status_code == status.HTTP_204_NO_CONTENT
|
|
assert not os.path.exists(image_path)
|
|
|
|
async def test_prune_images_keeps_project_referenced(
|
|
self,
|
|
app: FastAPI,
|
|
client: AsyncClient,
|
|
controller: Controller,
|
|
db_session: AsyncSession,
|
|
tmpdir: str,
|
|
) -> None:
|
|
"""
|
|
Pruning must keep images referenced by a project node while
|
|
removing the unreferenced ones.
|
|
"""
|
|
|
|
images_repo = ImagesRepository(db_session)
|
|
for filename in ("used.qcow2", "unused.qcow2"):
|
|
image_path = os.path.join(tmpdir, filename)
|
|
with open(image_path, "wb+") as f:
|
|
f.write(b"\x42\x42\x42\x42")
|
|
await images_repo.add_image(filename, "qemu", 42, image_path, "e342eb86c1229b6c154367a5476969b5", "md5")
|
|
|
|
await self._add_closed_project_with_node(
|
|
controller,
|
|
"Guarded",
|
|
{
|
|
"node_id": str(uuid.uuid4()),
|
|
"node_type": "qemu",
|
|
"compute_id": "local",
|
|
"name": "n1",
|
|
"properties": {"hda_disk_image_backing_file": "used.qcow2"},
|
|
},
|
|
)
|
|
|
|
response = await client.delete(app.url_path_for("prune_images"))
|
|
assert response.status_code == status.HTTP_204_NO_CONTENT
|
|
|
|
assert await images_repo.get_image(os.path.join(tmpdir, "used.qcow2")) is not None
|
|
assert os.path.exists(os.path.join(tmpdir, "used.qcow2"))
|
|
assert await images_repo.get_image(os.path.join(tmpdir, "unused.qcow2")) is None
|
|
assert not os.path.exists(os.path.join(tmpdir, "unused.qcow2"))
|
|
|
|
async def test_image_upload_create_appliance(
|
|
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, controller: Controller
|
|
) -> None:
|
|
|
|
await controller.appliance_manager.install_builtin_appliances()
|
|
controller.appliance_manager.load_appliances() # make sure appliances are loaded
|
|
image_path = "tests/resources/empty30G.qcow2"
|
|
image_name = os.path.basename(image_path)
|
|
with open(image_path, "rb") as f:
|
|
image_data = f.read()
|
|
response = await client.post(
|
|
app.url_path_for("upload_image", image_path=image_name),
|
|
params={"install_appliances": "true"},
|
|
content=image_data,
|
|
)
|
|
assert response.status_code == status.HTTP_201_CREATED
|
|
|
|
templates_repo = TemplatesRepository(db_session)
|
|
templates = await templates_repo.get_templates()
|
|
assert len(templates) == 1
|
|
assert templates[0].name == "Empty VM"
|
|
assert templates[0].version == "30G"
|
|
await templates_repo.delete_template(templates[0].template_id)
|
|
|
|
async def test_install_all(
|
|
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, controller: Controller
|
|
) -> None:
|
|
|
|
image_path = "tests/resources/empty100G.qcow2"
|
|
image_name = os.path.basename(image_path)
|
|
with open(image_path, "rb") as f:
|
|
image_data = f.read()
|
|
response = await client.post(app.url_path_for("upload_image", image_path=image_name), content=image_data)
|
|
assert response.status_code == status.HTTP_201_CREATED
|
|
|
|
controller.appliance_manager.load_appliances() # make sure appliances are loaded
|
|
with asyncio_patch("gns3server.api.routes.controller.images.get_builtin_disks", return_value=[]) as mock:
|
|
response = await client.post(app.url_path_for("install_images"))
|
|
assert mock.called
|
|
assert response.status_code == status.HTTP_200_OK
|
|
created = response.json()["created"]
|
|
assert len(created) == 1
|
|
assert created[0]["name"] == "Empty VM"
|
|
assert created[0]["version"] == "100G"
|
|
|
|
templates_repo = TemplatesRepository(db_session)
|
|
templates = await templates_repo.get_templates()
|
|
assert len(templates) == 1
|
|
assert templates[0].name == "Empty VM"
|
|
assert templates[0].version == "100G"
|
|
await templates_repo.delete_template(templates[0].template_id)
|
|
|
|
async def test_install_all_skips_existing_template_name(
|
|
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, controller: Controller
|
|
) -> None:
|
|
# two images matching two versions of the same appliance must not
|
|
# produce two templates with the same name
|
|
#
|
|
# earlier tests in this class uploaded the same filenames from different
|
|
# (function-scoped) images directories; drop those stale rows so the
|
|
# install route only sees this test's uploads
|
|
images_repo = ImagesRepository(db_session)
|
|
for image_name in ("empty30G.qcow2", "empty100G.qcow2"):
|
|
await images_repo.delete_image(image_name)
|
|
for image_path in ("tests/resources/empty30G.qcow2", "tests/resources/empty100G.qcow2"):
|
|
with open(image_path, "rb") as f:
|
|
image_data = f.read()
|
|
response = await client.post(
|
|
app.url_path_for("upload_image", image_path=os.path.basename(image_path)), content=image_data
|
|
)
|
|
assert response.status_code == status.HTTP_201_CREATED
|
|
|
|
controller.appliance_manager.load_appliances() # make sure appliances are loaded
|
|
with asyncio_patch("gns3server.api.routes.controller.images.get_builtin_disks", return_value=[]):
|
|
response = await client.post(app.url_path_for("install_images"))
|
|
assert response.status_code == status.HTTP_200_OK
|
|
|
|
manifest = response.json()
|
|
assert len(manifest["created"]) == 1
|
|
assert manifest["created"][0]["name"] == "Empty VM"
|
|
assert any("already exists" in skipped["reason"] for skipped in manifest["skipped"])
|
|
|
|
templates_repo = TemplatesRepository(db_session)
|
|
templates = await templates_repo.get_templates()
|
|
assert len(templates) == 1
|
|
assert templates[0].name == "Empty VM"
|
|
await templates_repo.delete_template(templates[0].template_id)
|