diff --git a/docs/api/examples/controller_get_templateidbaseconfig.txt b/docs/api/examples/controller_get_templateidbaseconfig.txt new file mode 100644 index 000000000..9e3d9c25c --- /dev/null +++ b/docs/api/examples/controller_get_templateidbaseconfig.txt @@ -0,0 +1,17 @@ +curl -i -X 'http://localhost:3080/v2/templates/19021f99-e36f-394d-b4a1-8aaa902ab9cc/base-config/vpcs_base_config.txt' + +GET /v2/templates/19021f99-e36f-394d-b4a1-8aaa902ab9cc/base-config/vpcs_base_config.txt HTTP/1.1 + +HTTP/1.1 200 OK +Connection: close +X-Route: /v2/templates/{template_id}/base-config/{filename} +Server: Python/3.14 GNS3/2.2.60.dev1+713dbb7f +Content-Type: application/json +Content-Length: 144 +Date: Mon, 18 May 2026 18:34:10 GMT + +{ + "content": "# test config\n\ndhcp\n", + "filename": "vpcs_base_config.txt", + "template_id": "19021f99-e36f-394d-b4a1-8aaa902ab9cc" +} \ No newline at end of file diff --git a/docs/api/examples/controller_get_templatesbaseconfigs.txt b/docs/api/examples/controller_get_templatesbaseconfigs.txt new file mode 100644 index 000000000..263cbfa1f --- /dev/null +++ b/docs/api/examples/controller_get_templatesbaseconfigs.txt @@ -0,0 +1,44 @@ +curl -i -X 'http://localhost:3080/v2/templates/base-configs' + +GET /v2/templates/base-configs HTTP/1.1 + +HTTP/1.1 200 OK +Connection: close +X-Route: /v2/templates/base-configs +Server: Python/3.14 GNS3/2.2.60.dev1+713dbb7f +Content-Type: application/json +Content-Length: 576 +Date: Mon, 18 May 2026 18:28:53 GMT + +[ + { + "filename": "config1.txt" + }, + { + "filename": "config2.txt" + }, + { + "filename": "ios_base_startup-config.txt" + }, + { + "filename": "ios_etherswitch_startup-config.txt" + }, + { + "filename": "iou_l2_base_startup-config.txt" + }, + { + "filename": "iou_l3_base_startup-config.txt" + }, + { + "filename": "test_config.txt" + }, + { + "filename": "update_test.txt" + }, + { + "filename": "vpcs_base_config.txt" + }, + { + "filename": "vpcs_base_config2.txt" + } +] \ No newline at end of file diff --git a/docs/api/examples/controller_put_templateidbaseconfig.txt b/docs/api/examples/controller_put_templateidbaseconfig.txt new file mode 100644 index 000000000..c70e315b8 --- /dev/null +++ b/docs/api/examples/controller_put_templateidbaseconfig.txt @@ -0,0 +1,17 @@ +curl -i -X 'http://localhost:3080/v2/templates/19021f99-e36f-394d-b4a1-8aaa902ab9cc/base-config/vpcs_base_config.txt' + +PUT /v2/templates/19021f99-e36f-394d-b4a1-8aaa902ab9cc/base-config/vpcs_base_config.txt HTTP/1.1 + +HTTP/1.1 200 OK +Connection: close +X-Route: /v2/templates/{template_id}/base-config/{filename} +Server: Python/3.14 GNS3/2.2.60.dev1+713dbb7f +Content-Type: application/json +Content-Length: 144 +Date: Mon, 18 May 2026 18:37:57 GMT + +{ + "content": "# test config\n\ndhcp\n", + "filename": "vpcs_base_config.txt", + "template_id": "19021f99-e36f-394d-b4a1-8aaa902ab9cc" +} \ No newline at end of file diff --git a/docs/api/v2/controller/template/templatesbaseconfigs.rst b/docs/api/v2/controller/template/templatesbaseconfigs.rst new file mode 100644 index 000000000..dc4de3e8c --- /dev/null +++ b/docs/api/v2/controller/template/templatesbaseconfigs.rst @@ -0,0 +1,38 @@ +GET /v2/templates/base-configs +------------------------------------------------------------------------------------------------------------------------------------------ + +.. contents:: + +GET /v2/templates/base-configs +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +List all available base configuration files + +Response status codes +********************** +- **200**: List of base configuration files returned + +Output +******* + +.. raw:: html + + + + + + + + + + + + + + +
NameTypeDescription
filenamestringName of the base configuration file
+ +Sample session +*************** + +.. literalinclude:: ../../../examples/controller_get_templatesbaseconfigs.txt \ No newline at end of file diff --git a/docs/api/v2/controller/template/templatestemplateidbaseconfigfilename.rst b/docs/api/v2/controller/template/templatestemplateidbaseconfigfilename.rst new file mode 100644 index 000000000..98b356270 --- /dev/null +++ b/docs/api/v2/controller/template/templatestemplateidbaseconfigfilename.rst @@ -0,0 +1,90 @@ +/v2/templates/{template_id}/base-config/{filename} +------------------------------------------------------------------------------------------------------------------------------------------ + +.. contents:: + +GET /v2/templates/**{template_id}**/base-configs/**{filename}** +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +List all available base configuration files + +Response status codes +********************** +- **200**: List of base configuration files returned + +Output +******* + +.. raw:: html + + + + + + + + + + + + + + +
NameTypeDescription
filenamestringName of the base configuration file
+ +Sample session +*************** + +.. literalinclude:: ../../../examples/controller_get_templateidbaseconfig.txt + + +PUT /v2/templates/**{template_id}**/base-config/**{filename}** +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Update base configuration file content + +Response status codes +********************** +- **200**: File updated +- **404**: File not found + +Input +******* + +.. raw:: html + + + + + + + + + + + +
NameMandatoryTypeDescription
contentstringNew file content
+ +Output +******* + +.. raw:: html + + + + + + + + + + + + + +
NameMandatoryTypeDescription
template_idstringTemplate UUID
filenamestringBase configuration filename
contentstringUpdated file content
+ +Sample session +*************** + +.. literalinclude:: ../../../examples/controller_put_templateidbaseconfig.txt \ No newline at end of file diff --git a/gns3server/api/routes/controller/templates.py b/gns3server/api/routes/controller/templates.py index a8d0be8a3..d991ac086 100644 --- a/gns3server/api/routes/controller/templates.py +++ b/gns3server/api/routes/controller/templates.py @@ -35,7 +35,7 @@ from gns3server.db.repositories.templates import TemplatesRepository from gns3server.services.templates import TemplatesService from gns3server.db.repositories.rbac import RbacRepository from gns3server.db.repositories.images import ImagesRepository -from gns3server.controller.controller_error import ControllerError +from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError from gns3server.utils.images import get_builtin_disks from .dependencies.authentication import get_current_active_user @@ -230,3 +230,57 @@ async def duplicate_template( template = await TemplatesService(templates_repo).duplicate_template(template_id) return template + +@router.get("/{template_id}/base-config/{filename}") +async def get_base_config( + template_id: UUID, + filename: str, + templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), +): + service = TemplatesService(templates_repo) + + await service.get_template(template_id) + + content = service.get_file(str(template_id), filename) + + return { + "template_id": str(template_id), + "filename": os.path.basename(filename), + "content": content + } + + +@router.put("/{template_id}/base-config/{filename}") +async def update_base_config( + template_id: UUID, + filename: str, + body: dict, + templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), +): + if not body or "content" not in body: + raise ControllerBadRequestError("Missing 'content' field") + + service = TemplatesService(templates_repo) + + await service.get_template(template_id) + + service.update_file(str(template_id), filename, body["content"]) + + return { + "template_id": str(template_id), + "filename": os.path.basename(filename), + "content": body["content"] + } + + +@router.get("/{template_id}/base-configs") +async def list_base_configs( + template_id: UUID, + templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), +): + service = TemplatesService(templates_repo) + + await service.get_template(template_id) + + return service.list_files(str(template_id)) + diff --git a/gns3server/db/repositories/templates.py b/gns3server/db/repositories/templates.py index ec8215af8..ed835a37a 100644 --- a/gns3server/db/repositories/templates.py +++ b/gns3server/db/repositories/templates.py @@ -49,6 +49,9 @@ class TemplatesRepository(BaseRepository): super().__init__(db_session) + def configs_path(self) -> str: + return os.path.join(os.getcwd(), "configs") + async def get_template(self, template_id: UUID) -> Union[None, models.Template]: query = select(models.Template).\ diff --git a/gns3server/services/templates.py b/gns3server/services/templates.py index 92b78633e..5284e2324 100644 --- a/gns3server/services/templates.py +++ b/gns3server/services/templates.py @@ -174,6 +174,9 @@ class TemplatesService: if builtin_template["template_id"] == template_id: return jsonable_encoder(builtin_template) + def _base_path(self): + return self._templates_repo.configs_path() + async def get_templates(self) -> List[dict]: templates = [] @@ -336,3 +339,45 @@ class TemplatesService: self._controller.notification.controller_emit("template.deleted", {"template_id": str(template_id)}) else: raise ControllerNotFoundError(f"Template '{template_id}' not found") + + def _template_path(self, template_id: str) -> str: + return os.path.join(self._base_path(), str(template_id)) + + def list_files(self, template_id: str): + path = self._template_path(template_id) + + if not os.path.exists(path): + return [] + + return [ + {"filename": f} + for f in sorted(os.listdir(path)) + if os.path.isfile(os.path.join(path, f)) + ] + + def get_file(self, template_id: str, filename: str): + safe_filename = os.path.basename(filename) + path = os.path.join(self._template_path(template_id), safe_filename) + + if not os.path.isfile(path): + raise ControllerNotFoundError(f"File '{safe_filename}' not found") + + try: + with open(path, encoding="utf-8", errors="ignore") as f: + return f.read() + except OSError as e: + raise ControllerError(str(e)) + + def update_file(self, template_id: str, filename: str, content: str): + safe_filename = os.path.basename(filename) + + dir_path = self._template_path(template_id) + path = os.path.join(dir_path, safe_filename) + + os.makedirs(dir_path, exist_ok=True) + + try: + with open(path, "w", encoding="utf-8") as f: + f.write(content) + except OSError as e: + raise ControllerError(str(e)) \ No newline at end of file diff --git a/tests/api/routes/controller/test_templates.py b/tests/api/routes/controller/test_templates.py index 3ae7e9383..0c61b3ab0 100644 --- a/tests/api/routes/controller/test_templates.py +++ b/tests/api/routes/controller/test_templates.py @@ -239,6 +239,80 @@ class TestTemplateRoutes: # mock.assert_called_with(id, x=42, y=12, compute_id=None) # assert response.status_code == status.HTTP_201_CREATED + async def test_get_base_config(self, app: FastAPI, client: AsyncClient): + + create_resp = await client.post(app.url_path_for("create_template"), json={ + "name": "TEST", + "compute_id": "local", + "template_type": "vpcs" + }) + + assert create_resp.status_code == 201 + template_id = create_resp.json()["template_id"] + + #создаём файл перед чтением + await client.put( + app.url_path_for("update_base_config", template_id=template_id, filename="test.txt"), + json={"content": "hello"} + ) + + response = await client.get( + app.url_path_for( + "get_base_config", + template_id=template_id, + filename="test.txt" + ) + ) + + assert response.status_code == 200 + assert response.json()["content"] == "hello" + + async def test_update_base_config(self, app: FastAPI, client: AsyncClient): + template_id = str(uuid.uuid4()) + filename = "test.txt" + + await client.post(app.url_path_for("create_template"), json={ + "template_id": template_id, + "name": "TEST", + "compute_id": "local", + "template_type": "vpcs" + }) + + payload = {"content": "hello world"} + + response = await client.put( + app.url_path_for("update_base_config", template_id=template_id, filename=filename), + json=payload + ) + + assert response.status_code == 200 + assert response.json()["content"] == "hello world" + + async def test_update_base_config_missing_content(self, app: FastAPI, client: AsyncClient): + template_id = str(uuid.uuid4()) + filename = "test.txt" + + await client.post(app.url_path_for("create_template"), json={ + "template_id": template_id, + "name": "TEST", + "compute_id": "local", + "template_type": "vpcs" + }) + + response = await client.put( + app.url_path_for("update_base_config", template_id=template_id, filename=filename), + json={} + ) + + assert response.status_code in (400, 422) + + async def test_base_config_template_not_found(self, app: FastAPI, client: AsyncClient): + response = await client.get( + app.url_path_for("get_base_config", template_id=str(uuid.uuid4()), filename="x.txt") + ) + + assert response.status_code == 404 + class TestDuplicateTemplates: