fix: dedupe and report automatic template creation from images

install_appliances_from_image relied on the name+version pair check in
TemplatesService, so the same appliance reached through a second image
(the CSR1000v case) created a second template sharing the name. The auto
path now skips when any template with the same name exists, whatever the
version, and returns a manifest of created and skipped candidates;
POST /images/install replies 200 with that manifest instead of an empty
204, and the image_install MCP tool surfaces it.
This commit is contained in:
YueGuobin committed 2026-08-26 00:04:57 +08:00
1 parent 888afdccbd
commit 8a8314ab29
7 files changed
+154 -11

No files matched your search

+30
View File
@@ -523,6 +523,36 @@ class TestTemplate:
assert "deleted" in str(result).lower()
# ── Image ───────────────────────────────────────────────────────────────
class TestImage:
mod = "images"
def test_install_manifest(self, ctx):
from gns3server.agent.mcp.images import install_images_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({
"created": [{"template_id": "t1", "name": "Empty VM", "version": "100G", "template_type": "qemu"}],
"skipped": [{"name": "csr1000v.qcow2", "reason": "image is already used by one or more templates"}],
})
m.return_value = conn
result = install_images_handler({}, ctx)
assert result["created"][0]["name"] == "Empty VM"
assert result["skipped"][0]["name"] == "csr1000v.qcow2"
def test_install_empty_body(self, ctx):
from gns3server.agent.mcp.images import install_images_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn()
conn.http_call.return_value.content = b""
conn.http_call.return_value.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
m.return_value = conn
result = install_images_handler({}, ctx)
assert result == {"message": "Image installation completed"}
# ── Marker (traffic-insight) ────────────────────────────────────────────
+46 -2
View File
@@ -328,10 +328,54 @@ class TestImageRoutes:
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_204_NO_CONTENT
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"
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)