From 57fe54977333b1f6dbcae498c9c53737a1d9b346 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 16 Aug 2026 12:26:55 +0800 Subject: [PATCH 01/28] appliance: implement install support for registry version 8 new_template() now converts the v8 settings[] format per the spec in gns3-registry#734: settings selection (version name reference, then the default set, then a single set), inherit_default_properties merging, and template_properties expansion with category/usage/symbol resolved from template_properties > version > appliance levels. Undefined properties are left out so controller template defaults apply. Registry versions 1-6 keep the existing top-level emulator block path. --- .../controller/appliance_to_template.py | 106 +++++++ .../controller/test_appliance_to_template.py | 285 ++++++++++++++++++ 2 files changed, 391 insertions(+) create mode 100644 tests/controller/test_appliance_to_template.py diff --git a/gns3server/controller/appliance_to_template.py b/gns3server/controller/appliance_to_template.py index 658dc42c7..79a9fb37e 100644 --- a/gns3server/controller/appliance_to_template.py +++ b/gns3server/controller/appliance_to_template.py @@ -18,6 +18,8 @@ import logging +from .controller_error import ControllerError + log = logging.getLogger(__name__) @@ -31,6 +33,9 @@ class ApplianceToTemplate: Creates a new template from an appliance. """ + if appliance_config.get("registry_version", 0) >= 8: + return self._new_template_v8(appliance_config, version, server) + new_template = { "compute_id": server, "name": appliance_config["name"], @@ -133,3 +138,104 @@ class ApplianceToTemplate: new_config.update(appliance_config["iou"]) new_config["path"] = version.get("images").get("image") + + def _new_template_v8(self, appliance_config, version, server): + """ + Creates a new template from an appliance using the registry version 8 format. + """ + + settings = self._select_v8_settings(appliance_config, version) + properties = self._merge_v8_properties(settings, appliance_config) + + new_template = { + "compute_id": server, + "template_type": settings["template_type"], + "name": appliance_config["name"], + } + + if version: + new_template["version"] = version.get("name") + + # category/usage/symbol can be defined in template_properties (already merged above), + # otherwise at the version level, otherwise at the appliance level + for prop in ("category", "usage", "symbol"): + if prop not in properties: + if version and version.get(prop) is not None: + properties[prop] = version[prop] + elif appliance_config.get(prop) is not None: + properties[prop] = appliance_config[prop] + + if properties.get("category") == "multilayer_switch": + properties["category"] = "switch" + + new_template.update(properties) + if "tags" in appliance_config: + new_template["tags"] = appliance_config.get("tags") + + if not new_template.get("symbol"): + # apply a default symbol based on the category and template type + if appliance_config["category"] == "guest": + if settings["template_type"] == "docker": + new_template["symbol"] = ":/symbols/docker_guest.svg" + else: + new_template["symbol"] = ":/symbols/qemu_guest.svg" + else: + symbols = { + "router": ":/symbols/router.svg", + "switch": ":/symbols/ethernet_switch.svg", + "multilayer_switch": ":/symbols/multilayer_switch.svg", + "firewall": ":/symbols/firewall.svg", + } + new_template["symbol"] = symbols.get(appliance_config["category"]) + + if version and version.get("images"): + new_template.update(version["images"]) + + return new_template + + def _select_v8_settings(self, appliance_config, version): + """ + Selects the settings set to use: the one referenced by the version, + otherwise the default set, otherwise the only set present. + """ + + settings_list = appliance_config.get("settings") or [] + if not settings_list: + raise ControllerError(f"Appliance '{appliance_config['name']}' has no settings") + + if version and version.get("settings"): + settings_name = version["settings"] + for settings in settings_list: + if settings.get("name") == settings_name: + return settings + raise ControllerError( + f"Could not find settings '{settings_name}' referenced by " + f"version '{version.get('name')}' in appliance '{appliance_config['name']}'" + ) + + for settings in settings_list: + if settings.get("default"): + return settings + + if len(settings_list) == 1: + return settings_list[0] + + raise ControllerError( + f"Appliance '{appliance_config['name']}' has multiple settings " + f"but none is marked as default" + ) + + def _merge_v8_properties(self, settings, appliance_config): + """ + Merges the template properties of the selected settings with the default + settings properties, unless inheritance is disabled or the default set + is selected. + """ + + properties = {} + if not settings.get("default") and settings.get("inherit_default_properties", True): + for other_settings in appliance_config.get("settings") or []: + if other_settings.get("default"): + properties.update(other_settings.get("template_properties") or {}) + properties.update(settings.get("template_properties") or {}) + return properties diff --git a/tests/controller/test_appliance_to_template.py b/tests/controller/test_appliance_to_template.py new file mode 100644 index 000000000..4a06b27d8 --- /dev/null +++ b/tests/controller/test_appliance_to_template.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python +# +# 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 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 gns3server.controller.appliance_to_template import ApplianceToTemplate +from gns3server.controller.controller_error import ControllerError + + +# reduced mirror of the upstream vyos.gns3a (registry version 8, qemu, 2 settings sets) +VYOS_V8 = { + "registry_version": 8, + "appliance_id": "f82b74c4-0f30-456f-a582-63daca528502", + "name": "VyOS Universal Router", + "category": "router", + "description": "VyOS", + "vendor_name": "VyOS Inc.", + "vendor_url": "https://vyos.io/", + "product_name": "VyOS Universal Router", + "status": "stable", + "maintainer": "VyOS Inc.", + "maintainer_email": "support@vyos.io", + "usage": "appliance usage", + "symbol": "vyos.svg", + "settings": [ + { + "name": "default x86_64", + "default": True, + "template_type": "qemu", + "template_properties": { + "adapter_type": "virtio-net-pci", + "adapters": 10, + "port_name_format": "eth{0}", + "ram": 2048, + "cpus": 4, + "hda_disk_interface": "virtio", + "platform": "x86_64", + "console_type": "telnet", + "boot_priority": "c", + "uefi": False, + "on_close": "shutdown_signal", + }, + }, + { + "name": "1.5 x86_64", + "inherit_default_properties": True, + "template_type": "qemu", + "template_properties": { + "ram": 8192, + "cpus": 4, + }, + }, + ], + "images": [ + { + "filename": "vyos-1.5.1-kvm-amd64.qcow2", + "version": "1.5.1", + "md5sum": "816ec7c3699a9e4f19e2b8765fd3d7eb", + "filesize": 667549696, + }, + { + "filename": "vyos-1.4.5-kvm-amd64.qcow2", + "version": "1.4.5", + "md5sum": "06ccf7e3ed3f948a23c995133b5fbfce", + "filesize": 557645824, + }, + ], + "versions": [ + { + "name": "1.5.1", + "settings": "1.5 x86_64", + "images": {"hda_disk_image": "vyos-1.5.1-kvm-amd64.qcow2"}, + }, + { + "name": "1.4.5", + "images": {"hda_disk_image": "vyos-1.4.5-kvm-amd64.qcow2"}, + }, + ], +} + + +def test_v8_version_referenced_settings_with_inheritance(): + """ + A version referencing a named settings set must select it and inherit + the default set properties (vyos 1.5.1 -> "1.5 x86_64", ram overridden to 8192). + """ + + version = VYOS_V8["versions"][0] + template = ApplianceToTemplate().new_template(VYOS_V8, version, "local") + + assert template["template_type"] == "qemu" + assert template["version"] == "1.5.1" + # inherited from the default settings + assert template["adapters"] == 10 + assert template["adapter_type"] == "virtio-net-pci" + assert template["platform"] == "x86_64" + # overridden by the selected settings + assert template["ram"] == 8192 + # appliance level fields + assert template["name"] == "VyOS Universal Router" + assert template["category"] == "router" + assert template["usage"] == "appliance usage" + assert template["symbol"] == "vyos.svg" + # version images are injected + assert template["hda_disk_image"] == "vyos-1.5.1-kvm-amd64.qcow2" + + +def test_v8_default_settings_selected_when_version_has_no_reference(): + """ + A version without a settings reference falls back to the default settings set. + """ + + version = VYOS_V8["versions"][1] + template = ApplianceToTemplate().new_template(VYOS_V8, version, "local") + + assert template["version"] == "1.4.5" + assert template["ram"] == 2048 + assert template["hda_disk_image"] == "vyos-1.4.5-kvm-amd64.qcow2" + + +def test_v8_version_level_overrides(): + """ + category/usage/symbol defined at the version level override the appliance level. + """ + + version = dict(VYOS_V8["versions"][1], category="firewall", usage="version usage", symbol="firewall.svg") + template = ApplianceToTemplate().new_template(VYOS_V8, version, "local") + + assert template["category"] == "firewall" + assert template["usage"] == "version usage" + assert template["symbol"] == "firewall.svg" + + +def test_v8_properties_take_precedence_over_version_and_appliance(): + """ + Fields defined in template_properties win over the version and appliance levels. + """ + + settings = dict( + VYOS_V8["settings"][0], + template_properties=dict(VYOS_V8["settings"][0]["template_properties"], usage="settings usage"), + ) + appliance = dict(VYOS_V8, settings=[settings]) + version = dict(VYOS_V8["versions"][1], usage="version usage") + + template = ApplianceToTemplate().new_template(appliance, version, "local") + assert template["usage"] == "settings usage" + + +def test_v8_template_properties_name_and_category(): + """ + name/category defined in template_properties are used for the template. + """ + + settings = dict( + VYOS_V8["settings"][0], + template_properties=dict(VYOS_V8["settings"][0]["template_properties"], name="VyOS 1.4", category="guest"), + ) + appliance = dict(VYOS_V8, settings=[settings]) + + template = ApplianceToTemplate().new_template(appliance, None, "local") + assert template["name"] == "VyOS 1.4" + assert template["category"] == "guest" + + +def test_v8_multilayer_switch_category_mapping(): + settings = dict( + VYOS_V8["settings"][0], + template_properties=dict(VYOS_V8["settings"][0]["template_properties"]), + ) + settings["template_properties"].pop("name", None) + appliance = dict(VYOS_V8, category="multilayer_switch", settings=[settings]) + + template = ApplianceToTemplate().new_template(appliance, None, "local") + assert template["category"] == "switch" + + +def test_v8_default_symbol_fallback(): + """ + Without any symbol, a docker guest gets the docker symbol, other guests the qemu one. + """ + + appliance = { + "registry_version": 8, + "name": "Test", + "category": "guest", + "settings": [{"name": "only", "template_type": "docker", "template_properties": {"image": "test:latest"}}], + } + template = ApplianceToTemplate().new_template(appliance, None, "local") + assert template["symbol"] == ":/symbols/docker_guest.svg" + assert template["template_type"] == "docker" + assert template["image"] == "test:latest" + + appliance["settings"][0]["template_type"] = "qemu" + appliance["settings"][0]["template_properties"] = {"ram": 512} + template = ApplianceToTemplate().new_template(appliance, None, "local") + assert template["symbol"] == ":/symbols/qemu_guest.svg" + + +def test_v8_no_inheritance_when_disabled(): + settings = dict( + VYOS_V8["settings"][1], + inherit_default_properties=False, + template_properties={"ram": 4096, "adapters": 2}, + ) + appliance = dict(VYOS_V8, settings=[VYOS_V8["settings"][0], settings]) + version = dict(VYOS_V8["versions"][0], settings="1.5 x86_64") + + template = ApplianceToTemplate().new_template(appliance, version, "local") + assert template["ram"] == 4096 + assert template["adapters"] == 2 + # not inherited + assert "adapter_type" not in template + assert "platform" not in template + + +def test_v8_unknown_settings_reference_raises(): + version = dict(VYOS_V8["versions"][0], settings="does not exist") + + with pytest.raises(ControllerError, match="Could not find settings 'does not exist'"): + ApplianceToTemplate().new_template(VYOS_V8, version, "local") + + +def test_v8_multiple_settings_without_default_raises(): + appliance = dict(VYOS_V8) + appliance["settings"] = [ + dict(VYOS_V8["settings"][0], default=None), + dict(VYOS_V8["settings"][1]), + ] + + with pytest.raises(ControllerError, match="none is marked as default"): + ApplianceToTemplate().new_template(appliance, VYOS_V8["versions"][1], "local") + + +def test_v8_single_settings_selected_without_default_flag(): + appliance = { + "registry_version": 8, + "name": "Test", + "category": "router", + "settings": [{"name": "only", "template_type": "qemu", "template_properties": {"ram": 1024}}], + } + template = ApplianceToTemplate().new_template(appliance, None, "local") + assert template["ram"] == 1024 + assert template["symbol"] == ":/symbols/router.svg" + + +def test_v6_path_unchanged(): + """ + Registry versions 1-6 keep using the top-level emulator blocks (regression check). + """ + + appliance = { + "registry_version": 6, + "name": "SRLinux", + "category": "router", + "symbol": ":/symbols/router.svg", + "usage": "v6 usage", + "docker": { + "adapters": 35, + "image": "ghcr.io/nokia/srlinux:latest", + "console_type": "docker_exec", + "environment": "GNS3_SKIP_INIT=1", + }, + } + template = ApplianceToTemplate().new_template(appliance, None, "local") + + assert template["template_type"] == "docker" + assert template["image"] == "ghcr.io/nokia/srlinux:latest" + assert template["console_type"] == "docker_exec" + assert template["adapters"] == 35 + assert template["usage"] == "v6 usage" From 254721dbda02f31b8dc9a6839f74b7b24090631a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 16 Aug 2026 12:29:04 +0800 Subject: [PATCH 02/28] appliance: close the v8 gaps for Docker vendor appliances DockerPropertiesV8 now accepts custom_adapters (already available to v1-6 top-level appliances and to Qemu v8 properties), so port-named Docker appliances (XRd, SR Linux) can move to the v8 format without losing their interface naming. Appliance.type resolves the node type from the v8 settings template_type (default set first) instead of misclassifying every v8 appliance as qemu, and _get_default_symbol applies the docker guest symbol to v8 Docker guest appliances. --- gns3server/controller/appliance.py | 11 ++ gns3server/controller/appliance_manager.py | 9 +- gns3server/schemas/controller/appliances.py | 1 + tests/controller/test_appliance.py | 129 ++++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 tests/controller/test_appliance.py diff --git a/gns3server/controller/appliance.py b/gns3server/controller/appliance.py index 629765b8b..fa964abfa 100644 --- a/gns3server/controller/appliance.py +++ b/gns3server/controller/appliance.py @@ -71,6 +71,17 @@ class Appliance: @property def type(self): + if self._data.get("registry_version", 0) >= 8: + # registry version 8: the node type comes from the settings template_type, + # the default settings take precedence over the other sets + settings_list = self._data.get("settings") or [] + for settings in settings_list: + if settings.get("default"): + return settings.get("template_type", "qemu") + if settings_list: + return settings_list[0].get("template_type", "qemu") + return "qemu" + if "iou" in self._data: return "iou" elif "dynamips" in self._data: diff --git a/gns3server/controller/appliance_manager.py b/gns3server/controller/appliance_manager.py index 0420e78cc..5f78196fb 100644 --- a/gns3server/controller/appliance_manager.py +++ b/gns3server/controller/appliance_manager.py @@ -362,7 +362,14 @@ class ApplianceManager: symbol_theme = controller.symbols.theme category = appliance["category"] if category == "guest": - if "docker" in appliance: + if appliance.get("registry_version", 0) >= 8: + # registry version 8: look for a Docker settings set instead of a top-level block + settings_types = [ + settings.get("template_type") for settings in appliance.get("settings") or [] + ] + if "docker" in settings_types: + return controller.symbols.get_default_symbol("docker_guest", symbol_theme) + elif "docker" in appliance: return controller.symbols.get_default_symbol("docker_guest", symbol_theme) elif "qemu" in appliance: return controller.symbols.get_default_symbol("qemu_guest", symbol_theme) diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py index 44906df18..593fcc810 100644 --- a/gns3server/schemas/controller/appliances.py +++ b/gns3server/schemas/controller/appliances.py @@ -481,6 +481,7 @@ class DockerPropertiesV8(BaseModel): extra_volumes: Optional[List[str]] = Field( None, title='Additional directories to make persistent' ) + custom_adapters: Optional[List[CustomAdapterItem]] = Field(None, title='Custom adapters') extra_configs: Optional[List[ExtraConfig]] = Field( None, title='Configuration files injected into the container (bind-mounted read-only)' ) diff --git a/tests/controller/test_appliance.py b/tests/controller/test_appliance.py new file mode 100644 index 000000000..98a1ca3b0 --- /dev/null +++ b/tests/controller/test_appliance.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python +# +# 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 . + +from gns3server.controller.appliance import Appliance +from gns3server.controller.appliance_to_template import ApplianceToTemplate +from gns3server.schemas.controller.appliances import ApplianceModel + + +# v8 mirror of the XRd Control Plane appliance shape (docker, custom_adapters, no versions) +XRD_V8 = { + "registry_version": 8, + "appliance_id": "e4a3a5fe-3a13-521b-abd1-ab9483e83aa2", + "name": "XRd Control Plane", + "category": "router", + "description": "Cisco IOS XRd Control Plane", + "vendor_name": "Cisco", + "vendor_url": "https://www.cisco.com/", + "product_name": "XRd Control Plane", + "status": "experimental", + "availability": "service-contract", + "maintainer": "GNS3 Team", + "maintainer_email": "developers@gns3.net", + "usage": "XRd usage", + "symbol": ":/symbols/router.svg", + "settings": [ + { + "name": "Default template settings", + "default": True, + "template_type": "docker", + "template_properties": { + "adapters": 24, + "image": "ios-xr/xrd-control-plane:24.4.1", + "console_type": "docker_exec", + "environment": "GNS3_SKIP_INIT=1\nGNS3_CONSOLE_CMD=/pkg/bin/xr_cli.sh", + "extra_volumes": ["/xr-storage"], + "custom_adapters": [ + {"adapter_number": 0, "port_name": "MgmtEth0/RP0/CPU0/0"}, + {"adapter_number": 1, "port_name": "Gi0/0/0/0"}, + ], + "extra_configs": [ + {"target": "/firstboot.cfg", "content": "!\nend\n"} + ], + }, + } + ], +} + + +def _appliance(data, builtin=True): + return Appliance("test.gns3a", data, builtin=builtin) + + +def test_v8_docker_appliance_validates_with_custom_adapters(): + # the discriminated union routes to ApplianceV8 and accepts custom_adapters + model = ApplianceModel.model_validate(XRD_V8) + assert model.registry_version == 8 + assert model.settings[0].template_properties.custom_adapters[0].port_name == "MgmtEth0/RP0/CPU0/0" + + +def test_v8_docker_appliance_type(): + assert _appliance(XRD_V8).type == "docker" + + +def test_v8_type_from_default_settings(): + # the default set wins over the other sets + appliance = { + "registry_version": 8, + "name": "mixed", + "status": "stable", + "settings": [ + {"name": "a", "template_type": "docker", "template_properties": {"image": "x"}}, + {"name": "b", "default": True, "template_type": "qemu", "template_properties": {"ram": 512}}, + ], + } + assert _appliance(appliance).type == "qemu" + + +def test_v8_qemu_appliance_type_without_default(): + appliance = { + "registry_version": 8, + "name": "single", + "status": "stable", + "settings": [{"name": "a", "template_type": "qemu", "template_properties": {"ram": 512}}], + } + assert _appliance(appliance).type == "qemu" + + +def test_v6_docker_appliance_type(): + appliance = { + "registry_version": 6, + "name": "v6 docker", + "status": "stable", + "docker": {"image": "test:latest"}, + } + assert _appliance(appliance).type == "docker" + + +def test_v8_docker_install_conversion(): + """ + The vendor NOS v8 shape (docker_exec console, env knobs, extra volumes, + custom adapters, extra configs) converts into a docker template. + """ + + template = ApplianceToTemplate().new_template(_appliance(XRD_V8).asdict(), None, "local") + + assert template["template_type"] == "docker" + assert template["image"] == "ios-xr/xrd-control-plane:24.4.1" + assert template["console_type"] == "docker_exec" + assert template["environment"] == "GNS3_SKIP_INIT=1\nGNS3_CONSOLE_CMD=/pkg/bin/xr_cli.sh" + assert template["extra_volumes"] == ["/xr-storage"] + assert template["custom_adapters"] == [ + {"adapter_number": 0, "port_name": "MgmtEth0/RP0/CPU0/0"}, + {"adapter_number": 1, "port_name": "Gi0/0/0/0"}, + ] + assert template["extra_configs"] == [{"target": "/firstboot.cfg", "content": "!\nend\n"}] From 9260108f533b2f0a391cdbf24cde0aee67e61e92 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 16 Aug 2026 12:39:47 +0800 Subject: [PATCH 03/28] templates: add netmiko_device_type for automation tools Common template field (schema + templates table column + Alembic migration) holding the Netmiko device type (e.g. 'cisco_xr', 'nokia_srl') so Netmiko/Nornir based tooling can look up how to reach a node's CLI without hard-coded vendor mappings. Free-form lowercase string on purpose: Netmiko's platform list evolves independently of GNS3. --- gns3server/db/models/templates.py | 1 + ...4f_add_netmiko_device_type_to_templates.py | 26 +++++++++++++++++++ .../schemas/controller/templates/__init__.py | 5 ++++ tests/api/routes/controller/test_templates.py | 7 +++-- 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 gns3server/db_migrations/versions/b3c7e2a91d4f_add_netmiko_device_type_to_templates.py diff --git a/gns3server/db/models/templates.py b/gns3server/db/models/templates.py index 5c271e436..6afb4f7cd 100644 --- a/gns3server/db/models/templates.py +++ b/gns3server/db/models/templates.py @@ -35,6 +35,7 @@ class Template(BaseTable): symbol = Column(String) builtin = Column(Boolean, default=False) usage = Column(String) + netmiko_device_type = Column(String) template_type = Column(String) tags = Column(JSON) compute_id = Column(String) diff --git a/gns3server/db_migrations/versions/b3c7e2a91d4f_add_netmiko_device_type_to_templates.py b/gns3server/db_migrations/versions/b3c7e2a91d4f_add_netmiko_device_type_to_templates.py new file mode 100644 index 000000000..bc0241c12 --- /dev/null +++ b/gns3server/db_migrations/versions/b3c7e2a91d4f_add_netmiko_device_type_to_templates.py @@ -0,0 +1,26 @@ +"""add netmiko_device_type to templates table + +Revision ID: b3c7e2a91d4f +Revises: 8f2a1c4e9d3b +Create Date: 2026-08-16 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b3c7e2a91d4f' +down_revision = '8f2a1c4e9d3b' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + + op.add_column('templates', sa.Column('netmiko_device_type', sa.String())) + + +def downgrade() -> None: + + op.drop_column('templates', 'netmiko_device_type') diff --git a/gns3server/schemas/controller/templates/__init__.py b/gns3server/schemas/controller/templates/__init__.py index dee74b7b9..2cd3b53c6 100644 --- a/gns3server/schemas/controller/templates/__init__.py +++ b/gns3server/schemas/controller/templates/__init__.py @@ -48,6 +48,11 @@ class TemplateBase(BaseModel): template_type: Optional[NodeType] = None compute_id: Optional[str] = None usage: Optional[str] = "" + netmiko_device_type: Optional[str] = Field( + None, + description="Device type for Netmiko-based automation tools (e.g. 'cisco_xr' or 'nokia_srl')", + pattern=r"^[a-z0-9_]+$", + ) tags: Optional[List[str]] = Field( default_factory=list, description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" diff --git a/tests/api/routes/controller/test_templates.py b/tests/api/routes/controller/test_templates.py index cd1745783..773be1459 100644 --- a/tests/api/routes/controller/test_templates.py +++ b/tests/api/routes/controller/test_templates.py @@ -146,7 +146,8 @@ class TestTemplateRoutes: "version": "3.0", "compute_id": "local", "template_type": "vpcs", - "tags": ["tag1", "tag2"] + "tags": ["tag1", "tag2"], + "netmiko_device_type": "generic_termserver_telnet" } response = await client.post(app.url_path_for("create_template"), json=params) @@ -156,12 +157,14 @@ class TestTemplateRoutes: assert response.status_code == status.HTTP_200_OK assert response.json()["template_id"] == template_id assert response.json()["tags"] == ["tag1", "tag2"] + assert response.json()["netmiko_device_type"] == "generic_termserver_telnet" - params = {"name": "VPCS_TEST_RENAMED", "console_auto_start": True} + params = {"name": "VPCS_TEST_RENAMED", "console_auto_start": True, "netmiko_device_type": "cisco_ios"} response = await client.put(app.url_path_for("update_template", template_id=template_id), json=params) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "VPCS_TEST_RENAMED" + assert response.json()["netmiko_device_type"] == "cisco_ios" async def test_template_delete(self, app: FastAPI, client: AsyncClient) -> None: From d64f47afa4e5a69255beb27b7262183d9cacc710 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 16 Aug 2026 12:45:52 +0800 Subject: [PATCH 04/28] nodes: per-node netmiko_device_type (controller-only) netmiko_device_type follows the CONTROLLER_ONLY_PROPERTIES pattern (like console_auto_start): a node created from a template inherits the template value, PUT /nodes can override it inside a topology, updates never round-trip to the compute, and the value persists in the project topology file. --- gns3server/controller/node.py | 11 +++++++++ gns3server/schemas/controller/nodes.py | 5 ++++ tests/controller/test_node.py | 32 ++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index 2046c7cd0..6135456d1 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -57,6 +57,7 @@ class Node: "ports", "category", "console_auto_start", + "netmiko_device_type", ] def __init__(self, project, compute, name, node_id=None, node_type=None, template_id=None, **kwargs): @@ -112,6 +113,7 @@ class Node: self._port_segment_size = 0 self._first_port_name = None self._console_auto_start = False + self._netmiko_device_type = None # This properties will be recomputed ignore_properties = ("width", "height", "hover_symbol") @@ -212,6 +214,14 @@ class Node: def console_auto_start(self, val): self._console_auto_start = val + @property + def netmiko_device_type(self): + return self._netmiko_device_type + + @netmiko_device_type.setter + def netmiko_device_type(self, val): + self._netmiko_device_type = val + @property def properties(self): return self._properties @@ -833,6 +843,7 @@ class Node: "console": self._console, "console_type": self._console_type, "console_auto_start": self._console_auto_start, + "netmiko_device_type": self._netmiko_device_type, "aux": self._aux, "aux_type": self._aux_type, "properties": self._properties, diff --git a/gns3server/schemas/controller/nodes.py b/gns3server/schemas/controller/nodes.py index 840bfdce6..14bb81c9e 100644 --- a/gns3server/schemas/controller/nodes.py +++ b/gns3server/schemas/controller/nodes.py @@ -116,6 +116,11 @@ class NodeBase(BaseModel): console_auto_start: Optional[bool] = Field( False, description="Automatically start the console when the node has started" ) + netmiko_device_type: Optional[str] = Field( + None, + description="Device type for Netmiko-based automation tools, overrides the template value", + pattern=r"^[a-z0-9_]+$", + ) aux: Optional[int] = Field(None, gt=0, le=65535, description="Auxiliary console TCP port") aux_type: Optional[ConsoleType] = None properties: Optional[dict] = Field(default_factory=dict, description="Properties specific to an emulator") diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index 27a02d4d7..2b9b9659a 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -142,6 +142,7 @@ def test_json(node, compute): "tags": [], "custom_adapters": [], "console_auto_start": False, + "netmiko_device_type": None, "ports": [ { "adapter_number": 0, @@ -179,6 +180,7 @@ def test_json(node, compute): "custom_adapters": [], "tags": [], "console_auto_start": False, + "netmiko_device_type": None, } @@ -383,6 +385,36 @@ async def test_update_only_controller(node, compute): assert not node._project.emit_notification.called +@pytest.mark.asyncio +async def test_update_netmiko_device_type(node, compute): + """ + netmiko_device_type is a controller-only property: updating it must not + call the compute and must be visible in the node json. + """ + + compute.put = AsyncioMagicMock() + node._project.emit_notification = AsyncioMagicMock() + node._project.dump = MagicMock() + + await node.update(netmiko_device_type="cisco_ios_telnet") + assert not compute.put.called + assert node.netmiko_device_type == "cisco_ios_telnet" + assert node.asdict()["netmiko_device_type"] == "cisco_ios_telnet" + + +def test_netmiko_device_type_from_template_kwargs(compute, project): + """ + A node created with the template properties as kwargs inherits + netmiko_device_type without sending it to the compute. + """ + + node = Node(project, compute, "test", node_type="vpcs", netmiko_device_type="nokia_srl") + assert node.netmiko_device_type == "nokia_srl" + # controller-only: must not leak into the compute properties + assert "netmiko_device_type" not in node.properties + assert node.asdict(topology_dump=True)["netmiko_device_type"] == "nokia_srl" + + @pytest.mark.asyncio async def test_update_no_changes(node, compute): """ From f524e9a71380a94c84ad37fded743683d35464a3 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 16 Aug 2026 12:50:01 +0800 Subject: [PATCH 05/28] appliance: seed netmiko_device_type from the appliance file Both the v1-6 and v8 appliance models accept an optional top-level netmiko_device_type, and ApplianceToTemplate copies it into the created template so installed appliances carry the automation hint end to end. --- .../controller/appliance_to_template.py | 6 ++++ gns3server/schemas/controller/appliances.py | 6 ++++ .../controller/test_appliance_to_template.py | 31 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/gns3server/controller/appliance_to_template.py b/gns3server/controller/appliance_to_template.py index 79a9fb37e..6f7d74e76 100644 --- a/gns3server/controller/appliance_to_template.py +++ b/gns3server/controller/appliance_to_template.py @@ -58,6 +58,9 @@ class ApplianceToTemplate: if "tags" in appliance_config: new_template["tags"] = appliance_config.get("tags") + if appliance_config.get("netmiko_device_type"): + new_template["netmiko_device_type"] = appliance_config["netmiko_device_type"] + if new_template.get("symbol") is None: if appliance_config["category"] == "guest": if "docker" in appliance_config: @@ -172,6 +175,9 @@ class ApplianceToTemplate: if "tags" in appliance_config: new_template["tags"] = appliance_config.get("tags") + if appliance_config.get("netmiko_device_type"): + new_template["netmiko_device_type"] = appliance_config["netmiko_device_type"] + if not new_template.get("symbol"): # apply a default symbol based on the category and template type if appliance_config["category"] == "guest": diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py index 593fcc810..a98544a19 100644 --- a/gns3server/schemas/controller/appliances.py +++ b/gns3server/schemas/controller/appliances.py @@ -632,6 +632,9 @@ class ApplianceV1_6(BaseModel): maintainer_email: Optional[Union[EmailStr, Annotated[str, Field(max_length=0)]]] = Field(None, title='Maintainer email') usage: Optional[str] = Field(None, title='How to use the appliance') symbol: Optional[str] = Field(None, title='An optional symbol for the appliance') + netmiko_device_type: Optional[str] = Field( + None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$' + ) first_port_name: Optional[str] = Field(None, title='Optional name of the first networking port example: eth0') port_name_format: Optional[str] = Field(None, title='Optional formating of the networking port example: eth{0}') port_segment_size: Optional[int] = Field( @@ -685,6 +688,9 @@ class ApplianceV8(BaseModel): default_username: Optional[str] = Field(None, title='Default username for the appliance') default_password: Optional[str] = Field(None, title='Default password for the appliance') symbol: Optional[str] = Field(None, title='An optional symbol for the appliance') + netmiko_device_type: Optional[str] = Field( + None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$' + ) tags: Optional[List[str]] = Field(None, title='User-defined metadata tags for the appliance') settings: List[TemplateSetting] = Field(..., title='Settings for running the appliance') images: Optional[List[ApplianceImage]] = Field(None, title='Images for this appliance') diff --git a/tests/controller/test_appliance_to_template.py b/tests/controller/test_appliance_to_template.py index 4a06b27d8..d3713d67b 100644 --- a/tests/controller/test_appliance_to_template.py +++ b/tests/controller/test_appliance_to_template.py @@ -16,9 +16,11 @@ # along with this program. If not, see . import pytest +import pydantic from gns3server.controller.appliance_to_template import ApplianceToTemplate from gns3server.controller.controller_error import ControllerError +from gns3server.schemas.controller.appliances import ApplianceModel # reduced mirror of the upstream vyos.gns3a (registry version 8, qemu, 2 settings sets) @@ -93,6 +95,21 @@ VYOS_V8 = { } +def test_v8_netmiko_device_type_copied_to_template(): + appliance = dict(VYOS_V8, netmiko_device_type="vyos_ssh") + + template = ApplianceToTemplate().new_template(appliance, VYOS_V8["versions"][1], "local") + assert template["netmiko_device_type"] == "vyos_ssh" + + +def test_v8_netmiko_device_type_validates(): + model = ApplianceModel.model_validate(dict(VYOS_V8, netmiko_device_type="vyos_ssh")) + assert model.netmiko_device_type == "vyos_ssh" + + with pytest.raises(pydantic.ValidationError): + ApplianceModel.model_validate(dict(VYOS_V8, netmiko_device_type="Not Valid!")) + + def test_v8_version_referenced_settings_with_inheritance(): """ A version referencing a named settings set must select it and inherit @@ -283,3 +300,17 @@ def test_v6_path_unchanged(): assert template["console_type"] == "docker_exec" assert template["adapters"] == 35 assert template["usage"] == "v6 usage" + + +def test_v6_netmiko_device_type_copied_to_template(): + appliance = { + "registry_version": 6, + "name": "SRLinux", + "category": "router", + "symbol": ":/symbols/router.svg", + "netmiko_device_type": "nokia_srl", + "docker": {"adapters": 35, "image": "ghcr.io/nokia/srlinux:latest"}, + } + + template = ApplianceToTemplate().new_template(appliance, None, "local") + assert template["netmiko_device_type"] == "nokia_srl" From 435aa4c25707b4ba737fa7405b10a4ab5bcd400a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 16 Aug 2026 13:51:49 +0800 Subject: [PATCH 06/28] copilot: prefer netmiko_device_type over the device_type: tag The vendored gns3fy Node model and its nodes_inventory() now carry the node's netmiko_device_type field, and get_device_ports_from_topology() resolves the Netmiko device type from it first, falling back to the device_type: tag. Nodes created from a template inherit the value from the template automatically, so automation tooling gets the correct Netmiko driver without tags. --- .../gns3_copilot/gns3_client/custom_gns3fy.py | 9 ++- .../utils/get_gns3_device_port.py | 20 +++-- tests/agent/test_custom_gns3fy.py | 81 +++++++++++++++++++ 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py index 582e7100b..83a5cc191 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py +++ b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py @@ -535,6 +535,8 @@ class Gns3Connector: - `tags` (list): List of tags for the template (e.g., ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) + - `netmiko_device_type` (str): Netmiko device type (e.g. "cisco_ios_telnet"); + preferred over the device_type: tag - Any other template attributes supported by GNS3 API """ # Get existing template @@ -571,6 +573,8 @@ class Gns3Connector: - `tags` (list): List of tags for the template (e.g., ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) + - `netmiko_device_type` (str): Netmiko device type (e.g. "cisco_ios_telnet"); + preferred over the device_type: tag - Any other template attributes supported by GNS3 API **Example:** @@ -579,7 +583,8 @@ class Gns3Connector: >>> connector.create_template( ... name="cisco_router", ... template_type="dynamips", - ... tags=["device_type:cisco_ios_telnet", "platform:cisco_ios"] + ... tags=["device_type:cisco_ios_telnet", "platform:cisco_ios"], + ... netmiko_device_type="cisco_ios_telnet" ... ) ``` """ @@ -1366,6 +1371,7 @@ class Node: template_id: str | None = None properties: Any | None = None tags: list[str] | None = None + netmiko_device_type: str | None = None template: str | None = None links: list[Link] = field(default_factory=list, repr=False) @@ -2482,6 +2488,7 @@ class Project: "x": _n.x, "y": _n.y, "tags": _n.tags if _n.tags else [], + "netmiko_device_type": _n.netmiko_device_type, } } ) diff --git a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py index 597fd9f64..fe7401770 100644 --- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -62,7 +62,7 @@ def get_device_ports_from_topology( "groups": ["network_devices"], # For inheriting shared settings "connection_options": { "netmiko": { - "extras": {"device_type": "huawei_telnet"} # Extracted from tags + "extras": {"device_type": "huawei_telnet"} # netmiko_device_type field, tag fallback } } } @@ -102,18 +102,21 @@ def get_device_ports_from_topology( logger.warning("Device '%s' missing console_port", device_name) continue - # Extract device_type and platform from tags - device_type = None + # Extract device_type and platform. + # Precedence: the netmiko_device_type field (node/template/appliance + # level, set in GNS3 server >= 3.x) wins over the device_type: + # tag, which remains as a fallback. + device_type = node_info.get("netmiko_device_type") platform = None tags = node_info.get("tags", []) for tag in tags: - if tag.startswith("device_type:"): + if tag.startswith("device_type:") and device_type is None: device_type = tag.split(":", 1)[1].strip() elif tag.startswith("platform:"): platform = tag.split(":", 1)[1].strip() - # Return error if device_type not found in tags + # Return error if device_type not found anywhere # Using a default would cause command execution errors if device_type is None: tested_device_types = ( @@ -121,8 +124,9 @@ def get_device_ports_from_topology( "gns3_ruijie_telnet (custom Ruijie)" ) error_msg = ( - f"Device '{device_name}': device_type tag not found. " - f"Please add 'device_type:' tag to this device in GNS3. " + f"Device '{device_name}': no device type found. " + f"Set the template/node 'netmiko_device_type' field (e.g. 'cisco_ios_telnet'), " + f"or add a 'device_type:' tag to this device in GNS3. " f"To configure via Web UI: right-click the device -> Configure -> Tags -> add 'device_type:'. " f"Tested types: {tested_device_types}. " f"Current tags: {tags}" @@ -134,7 +138,7 @@ def get_device_ports_from_topology( continue logger.debug( - "Device '%s': extracted device_type=%s from tags", + "Device '%s': device_type=%s", device_name, device_type, ) diff --git a/tests/agent/test_custom_gns3fy.py b/tests/agent/test_custom_gns3fy.py index a07266432..29aec0592 100644 --- a/tests/agent/test_custom_gns3fy.py +++ b/tests/agent/test_custom_gns3fy.py @@ -69,3 +69,84 @@ def test_node_accepts_docker_exec_console(): status="started", ) assert node.console_type == "docker_exec" + + +def test_node_accepts_netmiko_device_type(): + """ + The vendored Node model must keep the netmiko_device_type field so the + device-port tools can prefer it over the device_type: tag. + """ + pytest.importorskip("jwt", reason="ai-features extras not installed") + from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node + + node = Node( + name="SR1", + project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7", + node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e", + node_type="docker", + console_type="docker_exec", + status="started", + netmiko_device_type="nokia_srl", + ) + assert node.netmiko_device_type == "nokia_srl" + + +def test_device_ports_prefer_netmiko_field_over_tag(monkeypatch): + """ + netmiko_device_type on the node wins over the device_type: tag; + the tag stays as fallback when the field is missing. + """ + pytest.importorskip("jwt", reason="ai-features extras not installed") + from gns3server.agent.gns3_copilot.utils import get_gns3_device_port + from gns3server.agent.gns3_copilot import gns3_client + + class _FakeTopology: + def _run(self, project_id=None, jwt_token=None, url=None): + return { + "nodes": { + "SR1": { + "console_port": 5000, + "tags": ["device_type:cisco_ios_telnet"], + "netmiko_device_type": "nokia_srl", + }, + "R1": { + "console_port": 5001, + "tags": ["device_type:cisco_ios_telnet", "platform:cisco_ios"], + "netmiko_device_type": None, + }, + } + } + + # the function does a lazy from-import inside the body + monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology) + hosts = get_gns3_device_port.get_device_ports_from_topology(["SR1", "R1"]) + + # field wins over tag + assert hosts["SR1"]["connection_options"]["netmiko"]["extras"]["device_type"] == "nokia_srl" + # tag fallback when the field is absent + assert hosts["R1"]["connection_options"]["netmiko"]["extras"]["device_type"] == "cisco_ios_telnet" + assert hosts["R1"]["platform"] == "cisco_ios" + + +def test_device_ports_error_without_any_device_type(monkeypatch): + pytest.importorskip("jwt", reason="ai-features extras not installed") + from gns3server.agent.gns3_copilot.utils import get_gns3_device_port + from gns3server.agent.gns3_copilot import gns3_client + + class _FakeTopology: + def _run(self, project_id=None, jwt_token=None, url=None): + return { + "nodes": { + "R2": { + "console_port": 5002, + "tags": ["platform:cisco_ios"], + }, + } + } + + # the function does a lazy from-import inside the body + monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology) + hosts = get_gns3_device_port.get_device_ports_from_topology(["R2"]) + + assert "error" in hosts["R2"] + assert "netmiko_device_type" in hosts["R2"]["error"] From 1c68a5285682b467a222319403df680f69d24afc Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 16 Aug 2026 16:52:29 +0800 Subject: [PATCH 07/28] fix: address appliance v8 install review findings - install: resolve the image directory from the version's settings type and skip image handling for docker appliances; guard appliance.images - appliance schema: validate template_properties against template_type, align cpu_throttling with the qemu template, add kvm and version idlepc - conversion: map IOU image to path, kvm disable to accel=tcg, inherit only same-type default settings, symbol fallback from the effective category, template_properties cannot override structural fields - allow clearing netmiko_device_type with an empty string - download the template symbol regardless of the level it is defined at and give qemu guests a default symbol --- gns3server/controller/appliance_manager.py | 34 ++- .../controller/appliance_to_template.py | 55 +++- gns3server/schemas/controller/appliances.py | 39 ++- gns3server/schemas/controller/nodes.py | 2 +- .../schemas/controller/templates/__init__.py | 2 +- tests/api/routes/controller/test_templates.py | 7 + tests/controller/test_appliance.py | 107 +++++++- tests/controller/test_appliance_manager.py | 150 +++++++++++ .../controller/test_appliance_to_template.py | 241 ++++++++++++++++++ tests/controller/test_node.py | 5 + 10 files changed, 603 insertions(+), 39 deletions(-) create mode 100644 tests/controller/test_appliance_manager.py diff --git a/gns3server/controller/appliance_manager.py b/gns3server/controller/appliance_manager.py index 5f78196fb..25b0c366e 100644 --- a/gns3server/controller/appliance_manager.py +++ b/gns3server/controller/appliance_manager.py @@ -174,7 +174,7 @@ class ApplianceManager: version_images = version.get("images") if version_images: for appliance_key, appliance_file in version_images.items(): - for image in appliance.images: + for image in appliance.images or []: if appliance_file == image.get("filename"): image_checksum = image.get("md5sum") image_in_db = await images_repo.get_image_by_checksum(image_checksum) @@ -225,12 +225,15 @@ class ApplianceManager: from . import Controller - # downloading missing custom symbol for this appliance - if appliance.symbol and not appliance.symbol.startswith(":/symbols/"): - destination_path = os.path.join(Controller.instance().symbols.symbols_path(), appliance.symbol) + template_data = ApplianceToTemplate().new_template(appliance.asdict(), version, "local") # FIXME: "local" + # download the custom symbol used by the template if it is missing; + # the symbol can be defined at the appliance, version or settings level + symbol = template_data.get("symbol") + if symbol and not symbol.startswith(":/symbols/"): + destination_path = os.path.join(Controller.instance().symbols.symbols_path(), symbol) if not os.path.exists(destination_path): - await self._download_symbol(appliance.symbol, destination_path) - return ApplianceToTemplate().new_template(appliance.asdict(), version, "local") # FIXME: "local" + await self._download_symbol(symbol, destination_path) + return template_data async def install_appliances_from_image( self, @@ -290,11 +293,14 @@ class ApplianceManager: if not appliance.versions: raise ControllerBadRequestError(message=f"Appliance '{appliance_id}' do not have versions") - image_dir = default_images_directory(appliance.type) for appliance_version_info in appliance.versions: if appliance_version_info.get("name") == version: try: - await self._find_appliance_version_images(appliance, appliance_version_info, images_repo, image_dir) + template_type = ApplianceToTemplate().get_template_type(appliance.asdict(), appliance_version_info) + if template_type != "docker": + # docker appliances have no image files to find or download + image_dir = default_images_directory(template_type) + await self._find_appliance_version_images(appliance, appliance_version_info, images_repo, image_dir) except InvalidImageError as e: raise ControllerError(message=f"Image error: {e}") template_data = await self._appliance_to_template(appliance, appliance_version_info) @@ -363,12 +369,14 @@ class ApplianceManager: category = appliance["category"] if category == "guest": if appliance.get("registry_version", 0) >= 8: - # registry version 8: look for a Docker settings set instead of a top-level block - settings_types = [ - settings.get("template_type") for settings in appliance.get("settings") or [] - ] - if "docker" in settings_types: + # registry version 8: the emulator type comes from the default + # settings set (or the only one present), not a top-level block + settings = appliance.get("settings") or [] + selected = next((s for s in settings if s.get("default")), settings[0] if settings else None) + if selected and selected.get("template_type") == "docker": return controller.symbols.get_default_symbol("docker_guest", symbol_theme) + if selected: + return controller.symbols.get_default_symbol("qemu_guest", symbol_theme) elif "docker" in appliance: return controller.symbols.get_default_symbol("docker_guest", symbol_theme) elif "qemu" in appliance: diff --git a/gns3server/controller/appliance_to_template.py b/gns3server/controller/appliance_to_template.py index 6f7d74e76..a07f3a807 100644 --- a/gns3server/controller/appliance_to_template.py +++ b/gns3server/controller/appliance_to_template.py @@ -168,9 +168,23 @@ class ApplianceToTemplate: elif appliance_config.get(prop) is not None: properties[prop] = appliance_config[prop] - if properties.get("category") == "multilayer_switch": + category_before_remap = properties.get("category") + if category_before_remap == "multilayer_switch": properties["category"] = "switch" + if settings["template_type"] == "qemu": + # kvm is not a valid template property: convert it to the + # equivalent qemu options like for registry versions 1-6 + kvm = properties.pop("kvm", None) or "allow" + options = properties.get("options") or "" + if kvm == "disable" and "-machine accel=tcg" not in options: + options += " -machine accel=tcg" + properties["options"] = options.strip() + + # template_properties must not override the structural fields + for reserved in ("template_type", "compute_id", "version"): + properties.pop(reserved, None) + new_template.update(properties) if "tags" in appliance_config: new_template["tags"] = appliance_config.get("tags") @@ -179,8 +193,8 @@ class ApplianceToTemplate: new_template["netmiko_device_type"] = appliance_config["netmiko_device_type"] if not new_template.get("symbol"): - # apply a default symbol based on the category and template type - if appliance_config["category"] == "guest": + # apply a default symbol based on the effective category and template type + if category_before_remap == "guest": if settings["template_type"] == "docker": new_template["symbol"] = ":/symbols/docker_guest.svg" else: @@ -192,13 +206,38 @@ class ApplianceToTemplate: "multilayer_switch": ":/symbols/multilayer_switch.svg", "firewall": ":/symbols/firewall.svg", } - new_template["symbol"] = symbols.get(appliance_config["category"]) + new_template["symbol"] = symbols.get(category_before_remap) if version and version.get("images"): - new_template.update(version["images"]) + if settings["template_type"] == "iou": + # IOU templates take the image path, not an image name + new_template["path"] = version["images"].get("image") + else: + new_template.update(version["images"]) + + if version and settings["template_type"] == "dynamips" and version.get("idlepc"): + # settings level idlepc takes precedence over the version level + new_template.setdefault("idlepc", version["idlepc"]) return new_template + def get_template_type(self, appliance_config, version): + """ + Returns the template type of the settings set used to install the given + version: for registry versions 1-6 it comes from the emulator block, for + version 8 from the settings set selected for the version. + """ + + if appliance_config.get("registry_version", 0) >= 8: + return self._select_v8_settings(appliance_config, version)["template_type"] + if "iou" in appliance_config: + return "iou" + if "dynamips" in appliance_config: + return "dynamips" + if "docker" in appliance_config: + return "docker" + return "qemu" + def _select_v8_settings(self, appliance_config, version): """ Selects the settings set to use: the one referenced by the version, @@ -235,13 +274,15 @@ class ApplianceToTemplate: """ Merges the template properties of the selected settings with the default settings properties, unless inheritance is disabled or the default set - is selected. + is selected. Only a default set of the same emulator type is inherited + from, so properties of a different type never pollute the template. """ properties = {} if not settings.get("default") and settings.get("inherit_default_properties", True): for other_settings in appliance_config.get("settings") or []: - if other_settings.get("default"): + if other_settings.get("default") and other_settings.get("template_type") == settings["template_type"]: properties.update(other_settings.get("template_properties") or {}) + break properties.update(settings.get("template_properties") or {}) return properties diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py index a98544a19..9fb818b8a 100644 --- a/gns3server/schemas/controller/appliances.py +++ b/gns3server/schemas/controller/appliances.py @@ -19,7 +19,7 @@ from enum import Enum from typing import Annotated, List, Literal, Optional, Union from uuid import UUID -from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag +from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag, model_validator from ..common import ExtraConfig @@ -567,14 +567,23 @@ class QemuPropertiesV8(BaseModel): title='Optional define the disk boot priory. Refer to -boot option in qemu manual for more details.', ) kernel_command_line: Optional[str] = Field(None, title='Command line parameters send to the kernel') + kvm: Optional[Kvm] = Field(None, title='KVM requirements') options: Optional[str] = Field(None, title='Optional additional qemu command line options') - cpu_throttling: Optional[Annotated[float, Field(ge=0.0, le=100.0)]] = Field(None, title='Throttle the CPU') + cpu_throttling: Optional[Annotated[int, Field(ge=0, le=800)]] = Field(None, title='Throttle the CPU') tpm: Optional[bool] = Field(None, title='Enable the Trusted Platform Module (TPM)') uefi: Optional[bool] = Field(None, title='Enable the UEFI boot mode') on_close: Optional[QemuOnClose] = Field(None, title='Action to execute on the VM is closed') process_priority: Optional[QemuProcessPriority] = Field(None, title='Process priority for QEMU') +_V8_PROPERTIES_MODELS = { + TemplateType.qemu: QemuPropertiesV8, + TemplateType.dynamips: DynamipsPropertiesV8, + TemplateType.iou: IouPropertiesV8, + TemplateType.docker: DockerPropertiesV8, +} + + class TemplateSetting(BaseModel): """Emulator settings configuration (v8)""" @@ -587,12 +596,34 @@ class TemplateSetting(BaseModel): title='Properties for the template' ) + @model_validator(mode='before') + @classmethod + def _validate_template_properties(cls, data): + """ + Validate template_properties against the model matching template_type. + The template_type discriminator lives at the settings level (not inside + template_properties), so the union cannot be discriminated by pydantic + alone and would misroute properties between the per-type models. + """ + + if isinstance(data, dict): + # work on a copy: replacing template_properties with the validated + # model must not mutate the caller's data + data = data.copy() + template_type = data.get("template_type") + template_properties = data.get("template_properties") + model = _V8_PROPERTIES_MODELS.get(template_type) + if model is not None and isinstance(template_properties, dict): + data["template_properties"] = model.model_validate(template_properties) + return data + class ApplianceVersionV8(BaseModel): """Appliance version definition (v8)""" name: str = Field(..., title='Name of the version') settings: Optional[str] = Field(None, title='Template settings to use to run the version') + idlepc: Optional[str] = Field(None, pattern=r'^0x[0-9a-f]{8}') category: Optional[Category] = Field(None, title='Category of the version') installation_instructions: Optional[str] = Field(None, title='Optional installation instructions for the version') usage: Optional[str] = Field(None, title='Optional instructions about using the version') @@ -633,7 +664,7 @@ class ApplianceV1_6(BaseModel): usage: Optional[str] = Field(None, title='How to use the appliance') symbol: Optional[str] = Field(None, title='An optional symbol for the appliance') netmiko_device_type: Optional[str] = Field( - None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$' + None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$|^$' ) first_port_name: Optional[str] = Field(None, title='Optional name of the first networking port example: eth0') port_name_format: Optional[str] = Field(None, title='Optional formating of the networking port example: eth{0}') @@ -689,7 +720,7 @@ class ApplianceV8(BaseModel): default_password: Optional[str] = Field(None, title='Default password for the appliance') symbol: Optional[str] = Field(None, title='An optional symbol for the appliance') netmiko_device_type: Optional[str] = Field( - None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$' + None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$|^$' ) tags: Optional[List[str]] = Field(None, title='User-defined metadata tags for the appliance') settings: List[TemplateSetting] = Field(..., title='Settings for running the appliance') diff --git a/gns3server/schemas/controller/nodes.py b/gns3server/schemas/controller/nodes.py index 14bb81c9e..7347cacd7 100644 --- a/gns3server/schemas/controller/nodes.py +++ b/gns3server/schemas/controller/nodes.py @@ -119,7 +119,7 @@ class NodeBase(BaseModel): netmiko_device_type: Optional[str] = Field( None, description="Device type for Netmiko-based automation tools, overrides the template value", - pattern=r"^[a-z0-9_]+$", + pattern=r"^[a-z0-9_]+$|^$", ) aux: Optional[int] = Field(None, gt=0, le=65535, description="Auxiliary console TCP port") aux_type: Optional[ConsoleType] = None diff --git a/gns3server/schemas/controller/templates/__init__.py b/gns3server/schemas/controller/templates/__init__.py index 2cd3b53c6..f2d5dfd13 100644 --- a/gns3server/schemas/controller/templates/__init__.py +++ b/gns3server/schemas/controller/templates/__init__.py @@ -51,7 +51,7 @@ class TemplateBase(BaseModel): netmiko_device_type: Optional[str] = Field( None, description="Device type for Netmiko-based automation tools (e.g. 'cisco_xr' or 'nokia_srl')", - pattern=r"^[a-z0-9_]+$", + pattern=r"^[a-z0-9_]+$|^$", ) tags: Optional[List[str]] = Field( default_factory=list, diff --git a/tests/api/routes/controller/test_templates.py b/tests/api/routes/controller/test_templates.py index 773be1459..21bc5c82c 100644 --- a/tests/api/routes/controller/test_templates.py +++ b/tests/api/routes/controller/test_templates.py @@ -166,6 +166,13 @@ class TestTemplateRoutes: assert response.json()["name"] == "VPCS_TEST_RENAMED" assert response.json()["netmiko_device_type"] == "cisco_ios" + # the field can also be cleared with an empty string + params = {"netmiko_device_type": ""} + response = await client.put(app.url_path_for("update_template", template_id=template_id), json=params) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["netmiko_device_type"] == "" + async def test_template_delete(self, app: FastAPI, client: AsyncClient) -> None: template_id = str(uuid.uuid4()) diff --git a/tests/controller/test_appliance.py b/tests/controller/test_appliance.py index 98a1ca3b0..74f5c3aec 100644 --- a/tests/controller/test_appliance.py +++ b/tests/controller/test_appliance.py @@ -15,6 +15,9 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import pydantic +import pytest + from gns3server.controller.appliance import Appliance from gns3server.controller.appliance_to_template import ApplianceToTemplate from gns3server.schemas.controller.appliances import ApplianceModel @@ -77,25 +80,24 @@ def test_v8_docker_appliance_type(): def test_v8_type_from_default_settings(): # the default set wins over the other sets - appliance = { - "registry_version": 8, - "name": "mixed", - "status": "stable", - "settings": [ - {"name": "a", "template_type": "docker", "template_properties": {"image": "x"}}, + appliance = dict( + XRD_V8, + settings=[ + {"name": "a", "template_type": "docker", "template_properties": {"image": "test:latest"}}, {"name": "b", "default": True, "template_type": "qemu", "template_properties": {"ram": 512}}, ], - } + ) + # the fixture must be a loadable appliance, not an unreachable shape + ApplianceModel.model_validate(appliance) assert _appliance(appliance).type == "qemu" def test_v8_qemu_appliance_type_without_default(): - appliance = { - "registry_version": 8, - "name": "single", - "status": "stable", - "settings": [{"name": "a", "template_type": "qemu", "template_properties": {"ram": 512}}], - } + appliance = dict( + XRD_V8, + settings=[{"name": "a", "template_type": "qemu", "template_properties": {"ram": 512}}], + ) + ApplianceModel.model_validate(appliance) assert _appliance(appliance).type == "qemu" @@ -127,3 +129,82 @@ def test_v8_docker_install_conversion(): {"adapter_number": 1, "port_name": "Gi0/0/0/0"}, ] assert template["extra_configs"] == [{"target": "/firstboot.cfg", "content": "!\nend\n"}] + + +def test_v8_docker_settings_require_image(): + """ + template_properties must be validated against the model matching + template_type: a docker settings set without the required image is + rejected instead of being silently misrouted to another union member. + """ + + appliance = dict( + XRD_V8, + settings=[ + {"name": "only", "default": True, "template_type": "docker", + "template_properties": {"adapters": 2}}, + ], + ) + with pytest.raises(pydantic.ValidationError): + ApplianceModel.model_validate(appliance) + + +def test_v8_template_properties_validated_against_template_type(): + """ + Invalid enum values in qemu properties must be rejected at load time, + not silently discarded by a misrouted union member. + """ + + appliance = dict( + XRD_V8, + settings=[ + {"name": "only", "default": True, "template_type": "qemu", + "template_properties": {"ram": 512, "boot_priority": "zzz"}}, + ], + ) + with pytest.raises(pydantic.ValidationError): + ApplianceModel.model_validate(appliance) + + +def test_v8_qemu_kvm_property_validates(): + appliance = dict( + XRD_V8, + settings=[ + {"name": "only", "default": True, "template_type": "qemu", + "template_properties": {"ram": 512, "kvm": "disable"}}, + ], + ) + model = ApplianceModel.model_validate(appliance) + assert model.settings[0].template_properties.kvm == "disable" + + +def test_v8_qemu_cpu_throttling_range(): + """ + cpu_throttling in v8 properties uses the same type and range as the + qemu template (int, 0-800). + """ + + settings = {"name": "only", "default": True, "template_type": "qemu", + "template_properties": {"ram": 512, "cpu_throttling": 500}} + model = ApplianceModel.model_validate(dict(XRD_V8, settings=[settings])) + assert model.settings[0].template_properties.cpu_throttling == 500 + + for bad in (150.5, 900): + bad_settings = dict(settings, template_properties={"ram": 512, "cpu_throttling": bad}) + with pytest.raises(pydantic.ValidationError): + ApplianceModel.model_validate(dict(XRD_V8, settings=[bad_settings])) + + +def test_v8_version_idlepc_validates(): + appliance = dict(XRD_V8, versions=[{"name": "1.0", "idlepc": "0x613080c0"}]) + ApplianceModel.model_validate(appliance) + + bad = dict(XRD_V8, versions=[{"name": "1.0", "idlepc": "not-an-idlepc"}]) + with pytest.raises(pydantic.ValidationError): + ApplianceModel.model_validate(bad) + + +def test_v8_netmiko_device_type_empty_string_clears(): + appliance = dict(XRD_V8, netmiko_device_type="") + model = ApplianceModel.model_validate(appliance) + assert model.netmiko_device_type == "" diff --git a/tests/controller/test_appliance_manager.py b/tests/controller/test_appliance_manager.py new file mode 100644 index 000000000..c69a10999 --- /dev/null +++ b/tests/controller/test_appliance_manager.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python +# +# 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 uuid +import pytest + +from gns3server.controller.appliance import Appliance +from gns3server.controller.appliance_manager import ApplianceManager + + +def _v8_appliance(settings, versions=None): + """ + A minimal but fully valid registry version 8 appliance (it must pass + ApplianceModel validation, like appliances loaded by load_appliances). + """ + + data = { + "registry_version": 8, + "appliance_id": str(uuid.uuid4()), + "name": "Test appliance", + "category": "router", + "description": "Appliance description", + "vendor_name": "Test vendor", + "vendor_url": "https://example.com/", + "product_name": "Test product", + "status": "stable", + "maintainer": "Test maintainer", + "maintainer_email": "maintainer@example.com", + "symbol": ":/symbols/router.svg", + "settings": settings, + } + if versions: + data["versions"] = versions + return data + + +class _FakeTemplatesService: + """ + Stands in for TemplatesService so install_appliance can be exercised + without a controller instance or database. + """ + + created = [] + + def __init__(self, templates_repo): + self._templates_repo = templates_repo + + async def create_template(self, template_create): + _FakeTemplatesService.created.append(template_create) + return {"name": template_create.name} + + +@pytest.mark.asyncio +async def test_install_docker_version_skips_image_resolution(monkeypatch): + """ + v8 docker appliances have no image files: installing a version must not + resolve an image directory (default_images_directory does not support + docker) nor iterate the appliance images list. + """ + + _FakeTemplatesService.created = [] + appliance_data = _v8_appliance( + [ + {"name": "default", "default": True, "template_type": "docker", + "template_properties": {"image": "xrd:latest"}}, + ], + versions=[{"name": "1.0", "images": {"image": "xrd:1.0"}}], + ) + manager = ApplianceManager() + appliance = Appliance("test.gns3a", appliance_data) + manager._appliances[appliance.id] = appliance + + def _boom(image_type): + raise AssertionError(f"default_images_directory must not be called for docker (got '{image_type}')") + + monkeypatch.setattr("gns3server.controller.appliance_manager.default_images_directory", _boom) + monkeypatch.setattr("gns3server.controller.appliance_manager.TemplatesService", _FakeTemplatesService) + + await manager.install_appliance(uuid.UUID(appliance.id), "1.0", None, None, None, None) + + assert len(_FakeTemplatesService.created) == 1 + template = _FakeTemplatesService.created[0].model_dump() + assert template["template_type"] == "docker" + # the version image name is injected into the template + assert template["image"] == "xrd:1.0" + + +@pytest.mark.asyncio +async def test_install_iou_version_maps_image_to_path(monkeypatch, tmp_path): + """ + v8 IOU versions install: the version image is mapped to the template + path (an IOU template has no 'image' field). + """ + + _FakeTemplatesService.created = [] + appliance_data = _v8_appliance( + [ + {"name": "default", "default": True, "template_type": "iou", + "template_properties": {"ethernet_adapters": 4, "ram": 256}}, + ], + versions=[{"name": "15.9", "images": {"image": "i86bi-linux-l3-15.9.bin"}}], + ) + manager = ApplianceManager() + appliance = Appliance("test.gns3a", appliance_data) + manager._appliances[appliance.id] = appliance + + monkeypatch.setattr( + "gns3server.controller.appliance_manager.default_images_directory", + lambda image_type: str(tmp_path), + ) + monkeypatch.setattr("gns3server.controller.appliance_manager.TemplatesService", _FakeTemplatesService) + + await manager.install_appliance(uuid.UUID(appliance.id), "15.9", None, None, None, None) + + assert len(_FakeTemplatesService.created) == 1 + template = _FakeTemplatesService.created[0].model_dump() + assert template["template_type"] == "iou" + assert template["path"] == "i86bi-linux-l3-15.9.bin" + assert "image" not in template + + +@pytest.mark.asyncio +async def test_install_version_not_found(monkeypatch): + manager = ApplianceManager() + appliance_data = _v8_appliance( + [{"name": "only", "default": True, "template_type": "docker", + "template_properties": {"image": "xrd:latest"}}], + versions=[{"name": "1.0", "images": {"image": "xrd:1.0"}}], + ) + appliance = Appliance("test.gns3a", appliance_data) + manager._appliances[appliance.id] = appliance + + from gns3server.controller.controller_error import ControllerNotFoundError + + with pytest.raises(ControllerNotFoundError): + await manager.install_appliance(uuid.UUID(appliance.id), "9.9", None, None, None, None) diff --git a/tests/controller/test_appliance_to_template.py b/tests/controller/test_appliance_to_template.py index d3713d67b..e00e7c885 100644 --- a/tests/controller/test_appliance_to_template.py +++ b/tests/controller/test_appliance_to_template.py @@ -314,3 +314,244 @@ def test_v6_netmiko_device_type_copied_to_template(): template = ApplianceToTemplate().new_template(appliance, None, "local") assert template["netmiko_device_type"] == "nokia_srl" + + +def test_v8_iou_version_images_mapped_to_path(): + """ + An IOU template takes the image as a path: the version image name must be + mapped to 'path', never to an 'image' key. + """ + + appliance = { + "registry_version": 8, + "name": "IOU L3", + "category": "router", + "settings": [ + {"name": "only", "template_type": "iou", + "template_properties": {"ethernet_adapters": 4, "ram": 256}}, + ], + } + version = {"name": "15.9", "images": {"image": "i86bi-linux-l3-15.9.bin"}} + + template = ApplianceToTemplate().new_template(appliance, version, "local") + + assert template["template_type"] == "iou" + assert template["path"] == "i86bi-linux-l3-15.9.bin" + assert "image" not in template + + +def test_v8_dynamips_idlepc_from_version(): + appliance = { + "registry_version": 8, + "name": "Cisco 7200", + "category": "router", + "settings": [ + {"name": "only", "template_type": "dynamips", + "template_properties": {"ram": 512, "platform": "c7200"}}, + ], + } + version = {"name": "12.4", "idlepc": "0x613080c0", "images": {"image": "c7200.bin"}} + + template = ApplianceToTemplate().new_template(appliance, version, "local") + + assert template["idlepc"] == "0x613080c0" + assert template["image"] == "c7200.bin" + + +def test_v8_dynamips_settings_idlepc_precedence(): + """ + An idlepc defined in template_properties wins over the version level. + """ + + appliance = { + "registry_version": 8, + "name": "Cisco 7200", + "category": "router", + "settings": [ + {"name": "only", "template_type": "dynamips", + "template_properties": {"ram": 512, "idlepc": "0x6142da40"}}, + ], + } + version = {"name": "12.4", "idlepc": "0x613080c0", "images": {"image": "c7200.bin"}} + + template = ApplianceToTemplate().new_template(appliance, version, "local") + + assert template["idlepc"] == "0x6142da40" + + +def test_v8_qemu_kvm_disable_forces_accel_tcg(): + """ + kvm: disable is not a valid template property: it is converted to the + equivalent qemu options like for registry versions 1-6. + """ + + appliance = { + "registry_version": 8, + "name": "QEMU VM", + "category": "guest", + "settings": [ + {"name": "only", "template_type": "qemu", + "template_properties": {"ram": 512, "options": "-m 512", "kvm": "disable"}}, + ], + } + + template = ApplianceToTemplate().new_template(appliance, None, "local") + + assert template["options"] == "-m 512 -machine accel=tcg" + assert "kvm" not in template + + +def test_v8_qemu_kvm_allow_keeps_options(): + appliance = { + "registry_version": 8, + "name": "QEMU VM", + "category": "guest", + "settings": [ + {"name": "only", "template_type": "qemu", + "template_properties": {"ram": 512, "options": "-m 512", "kvm": "allow"}}, + ], + } + + template = ApplianceToTemplate().new_template(appliance, None, "local") + + assert template["options"] == "-m 512" + assert "kvm" not in template + + +def test_v8_no_cross_type_inheritance(): + """ + A version referencing a docker settings set must not inherit properties + from a default qemu settings set. + """ + + appliance = { + "registry_version": 8, + "name": "Mixed", + "category": "router", + "settings": [ + {"name": "default qemu", "default": True, "template_type": "qemu", + "template_properties": {"ram": 2048, "adapters": 10}}, + {"name": "docker alt", "template_type": "docker", + "template_properties": {"image": "xrd:latest", "adapters": 2}}, + ], + } + version = {"name": "1.0", "settings": "docker alt", "images": {"image": "xrd:1.0"}} + + template = ApplianceToTemplate().new_template(appliance, version, "local") + + assert template["template_type"] == "docker" + assert template["adapters"] == 2 + assert "ram" not in template + assert template["image"] == "xrd:1.0" + + +def test_v8_multiple_defaults_inherit_first_same_type(): + """ + With several default settings sets of the same type, the first one is + inherited from, matching the selection rule. + """ + + appliance = { + "registry_version": 8, + "name": "Multi", + "category": "router", + "settings": [ + {"name": "default one", "default": True, "template_type": "qemu", + "template_properties": {"ram": 1024}}, + {"name": "default two", "default": True, "template_type": "qemu", + "template_properties": {"ram": 2048}}, + {"name": "alt", "template_type": "qemu", "template_properties": {"cpus": 2}}, + ], + } + version = {"name": "1.0", "settings": "alt"} + + template = ApplianceToTemplate().new_template(appliance, version, "local") + + assert template["ram"] == 1024 + assert template["cpus"] == 2 + + +def test_v8_symbol_fallback_uses_effective_category(): + """ + The default symbol must reflect the effective category (template_properties, + then version, then appliance), not the appliance-level one. + """ + + appliance = { + "registry_version": 8, + "name": "Test", + "category": "router", + "settings": [ + {"name": "only", "template_type": "qemu", + "template_properties": {"ram": 512, "category": "guest"}}, + ], + } + template = ApplianceToTemplate().new_template(appliance, None, "local") + assert template["category"] == "guest" + assert template["symbol"] == ":/symbols/qemu_guest.svg" + + appliance = { + "registry_version": 8, + "name": "Test", + "category": "guest", + "settings": [ + {"name": "only", "template_type": "qemu", "template_properties": {"ram": 512}}, + ], + } + version = {"name": "1.0", "category": "router"} + template = ApplianceToTemplate().new_template(appliance, version, "local") + assert template["category"] == "router" + assert template["symbol"] == ":/symbols/router.svg" + + +def test_v8_reserved_keys_cannot_be_overridden(): + """ + template_properties must not override the structural template fields. + """ + + appliance = { + "registry_version": 8, + "name": "Test", + "category": "router", + "settings": [ + {"name": "only", "template_type": "docker", + "template_properties": { + "image": "xrd:latest", + "template_type": "qemu", + "compute_id": "evil-compute", + "version": "9.9", + }}, + ], + } + + template = ApplianceToTemplate().new_template(appliance, None, "local") + + assert template["template_type"] == "docker" + assert template["compute_id"] == "local" + assert "version" not in template + + +def test_v8_get_template_type(): + """ + get_template_type resolves the emulator type from the settings set + selected for the version, like the install flow does. + """ + + converter = ApplianceToTemplate() + appliance = { + "registry_version": 8, + "name": "Mixed", + "category": "router", + "settings": [ + {"name": "default", "default": True, "template_type": "qemu", + "template_properties": {"ram": 512}}, + {"name": "alt", "template_type": "docker", + "template_properties": {"image": "xrd:latest"}}, + ], + } + + assert converter.get_template_type(appliance, {"name": "1.0", "settings": "alt"}) == "docker" + assert converter.get_template_type(appliance, None) == "qemu" + + v6 = {"registry_version": 6, "docker": {"image": "test:latest"}} + assert converter.get_template_type(v6, None) == "docker" diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index 2b9b9659a..0edb4a0f9 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -401,6 +401,11 @@ async def test_update_netmiko_device_type(node, compute): assert node.netmiko_device_type == "cisco_ios_telnet" assert node.asdict()["netmiko_device_type"] == "cisco_ios_telnet" + # the field can also be cleared with an empty string + await node.update(netmiko_device_type="") + assert node.netmiko_device_type == "" + assert node.asdict()["netmiko_device_type"] == "" + def test_netmiko_device_type_from_template_kwargs(compute, project): """ From 300c53e6fb46dbeba6aab93330fb51f13044f41f Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 16 Aug 2026 23:30:33 +0800 Subject: [PATCH 08/28] templates: persist appliance metadata on install Appliance fields that describe the appliance (vendor information, default credentials, installation instructions...) were dropped when installing a template. Keep them in a new appliance_metadata JSON column on the templates table, filled by the appliance-to-template conversion for both registry v1-6 and v8 (version level values override the appliance level ones). The nested schema allows extra fields so future registry fields persist without a migration. --- .../controller/appliance_to_template.py | 49 ++++++++++++++ gns3server/db/models/templates.py | 1 + ...2b6_add_appliance_metadata_to_templates.py | 26 ++++++++ .../schemas/controller/templates/__init__.py | 32 +++++++++ tests/api/routes/controller/test_templates.py | 38 +++++++++++ tests/controller/test_appliance_manager.py | 4 ++ .../controller/test_appliance_to_template.py | 66 +++++++++++++++++++ 7 files changed, 216 insertions(+) create mode 100644 gns3server/db_migrations/versions/c7e4a9f1d2b6_add_appliance_metadata_to_templates.py diff --git a/gns3server/controller/appliance_to_template.py b/gns3server/controller/appliance_to_template.py index a07f3a807..3602a7a7e 100644 --- a/gns3server/controller/appliance_to_template.py +++ b/gns3server/controller/appliance_to_template.py @@ -23,6 +23,27 @@ from .controller_error import ControllerError log = logging.getLogger(__name__) +# appliance fields that describe the appliance (vendor information, default +# credentials...) and are kept on the template as metadata instead of being +# dropped at installation time +_APPLIANCE_METADATA_FIELDS = ( + "description", + "vendor_name", + "vendor_url", + "vendor_logo_url", + "documentation_url", + "product_name", + "product_url", + "status", + "availability", + "maintainer", + "maintainer_email", + "installation_instructions", + "default_username", + "default_password", +) + + class ApplianceToTemplate: """ Appliance installation. @@ -61,6 +82,10 @@ class ApplianceToTemplate: if appliance_config.get("netmiko_device_type"): new_template["netmiko_device_type"] = appliance_config["netmiko_device_type"] + appliance_metadata = self._build_appliance_metadata(appliance_config, version) + if appliance_metadata: + new_template["appliance_metadata"] = appliance_metadata + if new_template.get("symbol") is None: if appliance_config["category"] == "guest": if "docker" in appliance_config: @@ -192,6 +217,10 @@ class ApplianceToTemplate: if appliance_config.get("netmiko_device_type"): new_template["netmiko_device_type"] = appliance_config["netmiko_device_type"] + appliance_metadata = self._build_appliance_metadata(appliance_config, version) + if appliance_metadata: + new_template["appliance_metadata"] = appliance_metadata + if not new_template.get("symbol"): # apply a default symbol based on the effective category and template type if category_before_remap == "guest": @@ -221,6 +250,26 @@ class ApplianceToTemplate: return new_template + def _build_appliance_metadata(self, appliance_config, version): + """ + Builds the appliance metadata kept on the template: the fields that + describe the appliance, with version level values (e.g. credentials + specific to the installed version) overriding the appliance level ones. + """ + + version = version or {} + metadata = {} + for field in _APPLIANCE_METADATA_FIELDS: + value = version.get(field) + if value is None: + value = appliance_config.get(field) + if value is not None: + metadata[field] = value + appliance_id = appliance_config.get("appliance_id") + if appliance_id: + metadata["appliance_id"] = str(appliance_id) + return metadata or None + def get_template_type(self, appliance_config, version): """ Returns the template type of the settings set used to install the given diff --git a/gns3server/db/models/templates.py b/gns3server/db/models/templates.py index 6afb4f7cd..759cc0fc8 100644 --- a/gns3server/db/models/templates.py +++ b/gns3server/db/models/templates.py @@ -36,6 +36,7 @@ class Template(BaseTable): builtin = Column(Boolean, default=False) usage = Column(String) netmiko_device_type = Column(String) + appliance_metadata = Column(JSON) template_type = Column(String) tags = Column(JSON) compute_id = Column(String) diff --git a/gns3server/db_migrations/versions/c7e4a9f1d2b6_add_appliance_metadata_to_templates.py b/gns3server/db_migrations/versions/c7e4a9f1d2b6_add_appliance_metadata_to_templates.py new file mode 100644 index 000000000..7c7b18fbb --- /dev/null +++ b/gns3server/db_migrations/versions/c7e4a9f1d2b6_add_appliance_metadata_to_templates.py @@ -0,0 +1,26 @@ +"""add appliance_metadata to templates table + +Revision ID: c7e4a9f1d2b6 +Revises: b3c7e2a91d4f +Create Date: 2026-08-16 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c7e4a9f1d2b6' +down_revision = 'b3c7e2a91d4f' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + + op.add_column('templates', sa.Column('appliance_metadata', sa.JSON())) + + +def downgrade() -> None: + + op.drop_column('templates', 'appliance_metadata') diff --git a/gns3server/schemas/controller/templates/__init__.py b/gns3server/schemas/controller/templates/__init__.py index f2d5dfd13..3c75c6b1f 100644 --- a/gns3server/schemas/controller/templates/__init__.py +++ b/gns3server/schemas/controller/templates/__init__.py @@ -34,6 +34,34 @@ class Category(str, Enum): firewall = "firewall" +class ApplianceMetadata(BaseModel): + """ + Metadata kept on a template installed from an appliance: vendor + information, default credentials and other fields that describe + the appliance but are not node properties. + """ + + model_config = ConfigDict(extra="allow") + + appliance_id: Optional[str] = Field( + None, description="ID of the appliance the template was installed from" + ) + description: Optional[str] = None + vendor_name: Optional[str] = None + vendor_url: Optional[str] = None + vendor_logo_url: Optional[str] = None + documentation_url: Optional[str] = None + product_name: Optional[str] = None + product_url: Optional[str] = None + status: Optional[str] = None + availability: Optional[str] = None + maintainer: Optional[str] = None + maintainer_email: Optional[str] = None + installation_instructions: Optional[str] = None + default_username: Optional[str] = None + default_password: Optional[str] = None + + class TemplateBase(BaseModel): """ Common template properties. @@ -57,6 +85,10 @@ class TemplateBase(BaseModel): default_factory=list, description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" ) + appliance_metadata: Optional[ApplianceMetadata] = Field( + None, + description="Metadata inherited from the appliance the template was installed from" + ) class TemplateCreate(TemplateBase): diff --git a/tests/api/routes/controller/test_templates.py b/tests/api/routes/controller/test_templates.py index 21bc5c82c..75cae55d5 100644 --- a/tests/api/routes/controller/test_templates.py +++ b/tests/api/routes/controller/test_templates.py @@ -173,6 +173,44 @@ class TestTemplateRoutes: assert response.status_code == status.HTTP_200_OK assert response.json()["netmiko_device_type"] == "" + async def test_template_appliance_metadata_roundtrip(self, app: FastAPI, client: AsyncClient) -> None: + """ + Appliance metadata persists with the template: create, read back, + and replace on update. Unknown fields are kept (extra=allow) so that + future appliance registry fields do not vanish. + """ + + template_id = str(uuid.uuid4()) + params = { + "template_id": template_id, + "name": "VPCS_METADATA", + "compute_id": "local", + "template_type": "vpcs", + "appliance_metadata": { + "vendor_name": "Test vendor", + "default_username": "admin", + "future_field": "kept", + }, + } + + response = await client.post(app.url_path_for("create_template"), json=params) + assert response.status_code == status.HTTP_201_CREATED + metadata = response.json()["appliance_metadata"] + assert metadata["vendor_name"] == "Test vendor" + assert metadata["default_username"] == "admin" + assert metadata["future_field"] == "kept" + + # read back from the database + response = await client.get(app.url_path_for("get_template", template_id=template_id)) + assert response.status_code == status.HTTP_200_OK + assert response.json()["appliance_metadata"] == metadata + + # the metadata object is replaced as a whole on update + params = {"appliance_metadata": {"default_username": "root"}} + response = await client.put(app.url_path_for("update_template", template_id=template_id), json=params) + assert response.status_code == status.HTTP_200_OK + assert response.json()["appliance_metadata"] == {"default_username": "root"} + async def test_template_delete(self, app: FastAPI, client: AsyncClient) -> None: template_id = str(uuid.uuid4()) diff --git a/tests/controller/test_appliance_manager.py b/tests/controller/test_appliance_manager.py index c69a10999..2df0cedc4 100644 --- a/tests/controller/test_appliance_manager.py +++ b/tests/controller/test_appliance_manager.py @@ -97,6 +97,10 @@ async def test_install_docker_version_skips_image_resolution(monkeypatch): assert template["template_type"] == "docker" # the version image name is injected into the template assert template["image"] == "xrd:1.0" + # appliance metadata survives the install and validates through TemplateCreate + metadata = template["appliance_metadata"] + assert metadata["vendor_name"] == "Test vendor" + assert metadata["appliance_id"] == appliance.id @pytest.mark.asyncio diff --git a/tests/controller/test_appliance_to_template.py b/tests/controller/test_appliance_to_template.py index e00e7c885..0ce40cef4 100644 --- a/tests/controller/test_appliance_to_template.py +++ b/tests/controller/test_appliance_to_template.py @@ -555,3 +555,69 @@ def test_v8_get_template_type(): v6 = {"registry_version": 6, "docker": {"image": "test:latest"}} assert converter.get_template_type(v6, None) == "docker" + + +def test_v8_appliance_metadata_copied_to_template(): + """ + Appliance metadata (vendor information, default credentials...) is kept + on the template instead of being dropped, with version level values + overriding the appliance level ones. + """ + + appliance = dict(VYOS_V8, default_username="vyos", default_password="vyospass") + version = dict(VYOS_V8["versions"][1], default_username="vyos145") + + template = ApplianceToTemplate().new_template(appliance, version, "local") + + metadata = template["appliance_metadata"] + assert metadata["appliance_id"] == VYOS_V8["appliance_id"] + assert metadata["vendor_name"] == "VyOS Inc." + assert metadata["status"] == "stable" + # the version level default_username overrides the appliance level one + assert metadata["default_username"] == "vyos145" + assert metadata["default_password"] == "vyospass" + + +def test_v8_appliance_metadata_version_level_overrides(): + appliance = dict(VYOS_V8, installation_instructions="appliance instructions") + version = dict(VYOS_V8["versions"][1], installation_instructions="version instructions") + + template = ApplianceToTemplate().new_template(appliance, version, "local") + + assert template["appliance_metadata"]["installation_instructions"] == "version instructions" + + +def test_v6_appliance_metadata_copied_to_template(): + """ + Registry versions 1-6 appliances keep their metadata too (the fields + they have: the v8-only ones are simply absent). + """ + + appliance = { + "registry_version": 6, + "name": "SRLinux", + "category": "router", + "description": "Nokia SR Linux", + "vendor_name": "Nokia", + "docker": {"adapters": 35, "image": "ghcr.io/nokia/srlinux:latest"}, + } + + template = ApplianceToTemplate().new_template(appliance, None, "local") + + metadata = template["appliance_metadata"] + assert metadata["description"] == "Nokia SR Linux" + assert metadata["vendor_name"] == "Nokia" + assert "default_username" not in metadata + + +def test_no_appliance_metadata_when_appliance_has_none(): + appliance = { + "registry_version": 8, + "name": "Test", + "category": "guest", + "settings": [{"name": "only", "template_type": "qemu", "template_properties": {"ram": 512}}], + } + + template = ApplianceToTemplate().new_template(appliance, None, "local") + + assert "appliance_metadata" not in template From 92d4e3d37851c5951cca1efdb9bd5918ca23f4b8 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 17 Aug 2026 00:25:33 +0800 Subject: [PATCH 09/28] nodes: per-node default credentials seeded from the template Add default_username/default_password as controller-only node properties (the netmiko_device_type pattern): they are not sent to the compute, persist with the project topology and can be updated or cleared per node. Creating a node from a template seeds them from the template appliance metadata, and the metadata itself is dropped there so it never leaks into the node properties. --- gns3server/controller/node.py | 22 ++++++++++++ gns3server/controller/project.py | 6 ++++ gns3server/schemas/controller/nodes.py | 8 +++++ tests/controller/test_node.py | 47 ++++++++++++++++++++++++++ tests/controller/test_project.py | 42 +++++++++++++++++++++++ 5 files changed, 125 insertions(+) diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index 6135456d1..cd474912f 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -58,6 +58,8 @@ class Node: "category", "console_auto_start", "netmiko_device_type", + "default_username", + "default_password", ] def __init__(self, project, compute, name, node_id=None, node_type=None, template_id=None, **kwargs): @@ -114,6 +116,8 @@ class Node: self._first_port_name = None self._console_auto_start = False self._netmiko_device_type = None + self._default_username = None + self._default_password = None # This properties will be recomputed ignore_properties = ("width", "height", "hover_symbol") @@ -222,6 +226,22 @@ class Node: def netmiko_device_type(self, val): self._netmiko_device_type = val + @property + def default_username(self): + return self._default_username + + @default_username.setter + def default_username(self, val): + self._default_username = val + + @property + def default_password(self): + return self._default_password + + @default_password.setter + def default_password(self, val): + self._default_password = val + @property def properties(self): return self._properties @@ -844,6 +864,8 @@ class Node: "console_type": self._console_type, "console_auto_start": self._console_auto_start, "netmiko_device_type": self._netmiko_device_type, + "default_username": self._default_username, + "default_password": self._default_password, "aux": self._aux, "aux_type": self._aux_type, "properties": self._properties, diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 566e49047..f056f4dab 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -584,6 +584,12 @@ class Project: default_name_format = template.pop("default_name_format", "{name}-{0}") if name is None: name = default_name_format.replace("{name}", template_name) + # the appliance metadata stays template level: only the default + # credentials are seeded on the node (where they can be overridden) + appliance_metadata = template.pop("appliance_metadata", None) or {} + for field in ("default_username", "default_password"): + if appliance_metadata.get(field): + template[field] = appliance_metadata[field] node_id = str(uuid.uuid4()) node = await self.add_node(compute, name, node_id, node_type=node_type, **template) return node diff --git a/gns3server/schemas/controller/nodes.py b/gns3server/schemas/controller/nodes.py index 7347cacd7..85672ffb8 100644 --- a/gns3server/schemas/controller/nodes.py +++ b/gns3server/schemas/controller/nodes.py @@ -121,6 +121,14 @@ class NodeBase(BaseModel): description="Device type for Netmiko-based automation tools, overrides the template value", pattern=r"^[a-z0-9_]+$|^$", ) + default_username: Optional[str] = Field( + None, + description="Default username to log into the node, seeded from the template appliance metadata", + ) + default_password: Optional[str] = Field( + None, + description="Default password to log into the node, seeded from the template appliance metadata", + ) aux: Optional[int] = Field(None, gt=0, le=65535, description="Auxiliary console TCP port") aux_type: Optional[ConsoleType] = None properties: Optional[dict] = Field(default_factory=dict, description="Properties specific to an emulator") diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index 0edb4a0f9..a20405637 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -143,6 +143,8 @@ def test_json(node, compute): "custom_adapters": [], "console_auto_start": False, "netmiko_device_type": None, + "default_username": None, + "default_password": None, "ports": [ { "adapter_number": 0, @@ -181,6 +183,8 @@ def test_json(node, compute): "tags": [], "console_auto_start": False, "netmiko_device_type": None, + "default_username": None, + "default_password": None, } @@ -420,6 +424,49 @@ def test_netmiko_device_type_from_template_kwargs(compute, project): assert node.asdict(topology_dump=True)["netmiko_device_type"] == "nokia_srl" +@pytest.mark.asyncio +async def test_update_default_credentials(node, compute): + """ + default_username/default_password are controller-only properties: updating + them must not call the compute and must be persisted in the node json. + """ + + compute.put = AsyncioMagicMock() + node._project.emit_notification = AsyncioMagicMock() + node._project.dump = MagicMock() + + await node.update(default_username="admin", default_password="secret") + assert not compute.put.called + assert node.default_username == "admin" + assert node.default_password == "secret" + assert node.asdict()["default_username"] == "admin" + assert node.asdict(topology_dump=True)["default_password"] == "secret" + + # credentials never leak into the compute properties + assert "default_username" not in node.properties + assert "default_password" not in node.properties + + # both fields can be cleared with an empty string + await node.update(default_username="", default_password="") + assert node.default_username == "" + assert node.default_password == "" + + +def test_default_credentials_from_template_kwargs(compute, project): + """ + A node created from a template with appliance metadata inherits the + default credentials without sending them to the compute. + """ + + node = Node(project, compute, "test", node_type="vpcs", + default_username="root", default_password="cisco123") + assert node.default_username == "root" + assert node.default_password == "cisco123" + assert "default_username" not in node.properties + assert "default_password" not in node.properties + assert node.asdict(topology_dump=True)["default_username"] == "root" + + @pytest.mark.asyncio async def test_update_no_changes(node, compute): """ diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index d099c019a..5b5b823e9 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -204,6 +204,48 @@ async def test_add_node_local(controller): project.emit_notification.assert_any_call("node.created", node.asdict()) +@pytest.mark.asyncio +async def test_add_node_from_template_seeds_default_credentials(controller): + """ + The appliance metadata stays template level: creating a node from a + template seeds the default credentials on the node and must not leak + the metadata into the node properties sent to the compute. + """ + + compute = MagicMock() + compute.id = "local" + controller._computes["local"] = compute + project = Project(controller=controller, name="Test") + project.emit_notification = MagicMock() + + response = MagicMock() + response.json = {"console": 2048} + compute.post = AsyncioMagicMock(return_value=response) + + template = { + "name": "VPCS_TEST", + "template_type": "vpcs", + "compute_id": "local", + "default_name_format": "PC{0}", + "properties": {"startup_script": "test.cfg"}, + "appliance_metadata": { + "vendor_name": "Test vendor", + "default_username": "admin", + "default_password": "secret", + }, + } + + node = await project.add_node_from_template(template) + + # credentials seeded from the appliance metadata + assert node.default_username == "admin" + assert node.default_password == "secret" + # the metadata itself never reaches the node properties + assert "appliance_metadata" not in node.properties + assert "default_username" not in node.properties + assert "default_password" not in node.properties + + @pytest.mark.asyncio async def test_add_node_non_local(controller): """ From 7e8c515a3c7107f029ed9f612b695674342402c0 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 17 Aug 2026 21:24:47 +0800 Subject: [PATCH 10/28] api: expose installed netmiko device types for the web UI New GET /v3/netmiko/device_types endpoint returns the device types supported by the netmiko library installed on the server, including the gns3-copilot custom drivers, so the web UI can populate the netmiko_device_type dropdown on templates and nodes. The list is read at runtime from netmiko's ssh_dispatcher.CLASS_MAPPER registry (the same table ConnectHandler dispatches on), filtered to drop the '_ssh' aliases and the 'autodetect' pseudo type, and cached for the process lifetime. Returns 501 when netmiko is not installed (ai-features extra). --- docs/index.html | 44 ++++---- docs/openapi.json | 2 +- docs/redoc.html | 46 ++++----- gns3server/api/routes/controller/__init__.py | 7 ++ gns3server/api/routes/controller/netmiko.py | 102 +++++++++++++++++++ gns3server/schemas/__init__.py | 1 + gns3server/schemas/controller/netmiko.py | 38 +++++++ tests/api/routes/controller/test_netmiko.py | 77 ++++++++++++++ 8 files changed, 269 insertions(+), 48 deletions(-) create mode 100644 gns3server/api/routes/controller/netmiko.py create mode 100644 gns3server/schemas/controller/netmiko.py create mode 100644 tests/api/routes/controller/test_netmiko.py diff --git a/docs/index.html b/docs/index.html index 6f2b1184c..3a7d78f73 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,32 +1,32 @@ - - GNS3 controller API - ReDoc - - - - - - + + - - + GNS3 controller API - Swagger UI - - +
+
+ + + \ No newline at end of file diff --git a/docs/openapi.json b/docs/openapi.json index 2c5e03ade..fcd28742a 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1 +1 @@ -{"openapi": "3.0.2", "info": {"title": "GNS3 controller API", "description": "This page describes the public controller API for GNS3", "version": "v2"}, "paths": {"/": {"get": {"tags": ["controller"], "summary": "Root", "operationId": "root__get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/debug": {"get": {"tags": ["controller"], "summary": "Debug", "operationId": "debug_debug_get", "responses": {"200": {"description": "Successful Response", "content": {"text/html": {"schema": {"type": "string"}}}}}, "deprecated": true}}, "/static/web-ui/{file_path}": {"get": {"tags": ["controller"], "summary": "Web Ui", "description": "Web user interface", "operationId": "web_ui_static_web_ui__file_path__get", "parameters": [{"required": true, "schema": {"title": "File Path", "type": "string"}, "name": "file_path", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/shutdown": {"post": {"tags": ["Controller"], "summary": "Shutdown", "description": "Shutdown the local server", "operationId": "shutdown_v2_shutdown_post", "responses": {"204": {"description": "Successful Response"}, "403": {"description": "Server shutdown not allowed", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}}}}, "/v2/version": {"get": {"tags": ["Controller"], "summary": "Version", "description": "Return the server version number.", "operationId": "version_v2_version_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}}}}, "post": {"tags": ["Controller"], "summary": "Check Version", "description": "Check if version is the same as the server.\n\n:param request:\n:param response:\n:return:", "operationId": "check_version_v2_version_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}}, "409": {"description": "Invalid version", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/iou_license": {"get": {"tags": ["Controller"], "summary": "Get Iou License", "description": "Return the IOU license settings", "operationId": "get_iou_license_v2_iou_license_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}}}}, "put": {"tags": ["Controller"], "summary": "Update Iou License", "description": "Update the IOU license settings.", "operationId": "update_iou_license_v2_iou_license_put", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/statistics": {"get": {"tags": ["Controller"], "summary": "Statistics", "description": "Return server statistics.", "operationId": "statistics_v2_statistics_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/v2/appliances": {"get": {"tags": ["Appliances"], "summary": "Get Appliances", "description": "Return all appliances known by the controller.", "operationId": "get_appliances_v2_appliances_get", "parameters": [{"required": false, "schema": {"title": "Update", "type": "boolean"}, "name": "update", "in": "query"}, {"required": false, "schema": {"title": "Symbol Theme", "type": "string", "default": "Classic"}, "name": "symbol_theme", "in": "query"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/computes": {"get": {"tags": ["Computes"], "summary": "Get Computes", "description": "Return all computes known by the controller.", "operationId": "get_computes_v2_computes_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Get Computes V2 Computes Get", "type": "array", "items": {"$ref": "#/components/schemas/Compute"}}}}}}}, "post": {"tags": ["Computes"], "summary": "Create Compute", "description": "Create a new compute on the controller.", "operationId": "create_compute_v2_computes_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ComputeCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Compute"}}}}, "404": {"description": "Could not connect to compute", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not create compute", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "401": {"description": "Invalid authentication for compute", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/computes/{compute_id}": {"get": {"tags": ["Computes"], "summary": "Get Compute", "description": "Return a compute from the controller.", "operationId": "get_compute_v2_computes__compute_id__get", "parameters": [{"required": true, "schema": {"title": "Compute Id", "anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}]}, "name": "compute_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Compute"}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Computes"], "summary": "Update Compute", "description": "Update a compute on the controller.", "operationId": "update_compute_v2_computes__compute_id__put", "parameters": [{"required": true, "schema": {"title": "Compute Id", "anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}]}, "name": "compute_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ComputeUpdate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Compute"}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Computes"], "summary": "Delete Compute", "description": "Delete a compute from the controller.", "operationId": "delete_compute_v2_computes__compute_id__delete", "parameters": [{"required": true, "schema": {"title": "Compute Id", "anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}]}, "name": "compute_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/computes/{compute_id}/{emulator}/images": {"get": {"tags": ["Computes"], "summary": "Get Images", "description": "Return the list of images available on a compute for a given emulator type.", "operationId": "get_images_v2_computes__compute_id___emulator__images_get", "parameters": [{"required": true, "schema": {"title": "Compute Id", "anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}]}, "name": "compute_id", "in": "path"}, {"required": true, "schema": {"title": "Emulator", "type": "string"}, "name": "emulator", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/computes/{compute_id}/{emulator}/{endpoint_path}": {"get": {"tags": ["Computes"], "summary": "Forward Get", "description": "Forward a GET request to a compute.\nRead the full compute API documentation for available endpoints.", "operationId": "forward_get_v2_computes__compute_id___emulator___endpoint_path__get", "parameters": [{"required": true, "schema": {"title": "Compute Id", "anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}]}, "name": "compute_id", "in": "path"}, {"required": true, "schema": {"title": "Emulator", "type": "string"}, "name": "emulator", "in": "path"}, {"required": true, "schema": {"title": "Endpoint Path", "type": "string"}, "name": "endpoint_path", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Computes"], "summary": "Forward Put", "description": "Forward a PUT request to a compute.\nRead the full compute API documentation for available endpoints.", "operationId": "forward_put_v2_computes__compute_id___emulator___endpoint_path__put", "parameters": [{"required": true, "schema": {"title": "Compute Id", "anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}]}, "name": "compute_id", "in": "path"}, {"required": true, "schema": {"title": "Emulator", "type": "string"}, "name": "emulator", "in": "path"}, {"required": true, "schema": {"title": "Endpoint Path", "type": "string"}, "name": "endpoint_path", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"title": "Compute Data", "type": "object"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Computes"], "summary": "Forward Post", "description": "Forward a POST request to a compute.\nRead the full compute API documentation for available endpoints.", "operationId": "forward_post_v2_computes__compute_id___emulator___endpoint_path__post", "parameters": [{"required": true, "schema": {"title": "Compute Id", "anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}]}, "name": "compute_id", "in": "path"}, {"required": true, "schema": {"title": "Emulator", "type": "string"}, "name": "emulator", "in": "path"}, {"required": true, "schema": {"title": "Endpoint Path", "type": "string"}, "name": "endpoint_path", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"title": "Compute Data", "type": "object"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/computes/{compute_id}/auto_idlepc": {"post": {"tags": ["Computes"], "summary": "Autoidlepc", "description": "Find a suitable Idle-PC value for a given IOS image. This may take a few minutes.", "operationId": "autoidlepc_v2_computes__compute_id__auto_idlepc_post", "parameters": [{"required": true, "schema": {"title": "Compute Id", "anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}]}, "name": "compute_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoIdlePC"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/computes/{compute_id}/ports": {"get": {"tags": ["Computes"], "summary": "Ports", "description": "Return ports information for a given compute.", "operationId": "ports_v2_computes__compute_id__ports_get", "parameters": [{"required": true, "schema": {"title": "Compute Id", "anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}]}, "name": "compute_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "deprecated": true}}, "/v2/projects/{project_id}/drawings": {"get": {"tags": ["Drawings"], "summary": "Get Drawings", "description": "Return the list of all drawings for a given project.", "operationId": "get_drawings_v2_projects__project_id__drawings_get", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Get Drawings V2 Projects Project Id Drawings Get", "type": "array", "items": {"$ref": "#/components/schemas/Drawing"}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Drawings"], "summary": "Create Drawing", "description": "Create a new drawing.", "operationId": "create_drawing_v2_projects__project_id__drawings_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Drawing"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Drawing"}}}}, "404": {"description": "Project or drawing not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/drawings/{drawing_id}": {"get": {"tags": ["Drawings"], "summary": "Get Drawing", "description": "Return a drawing.", "operationId": "get_drawing_v2_projects__project_id__drawings__drawing_id__get", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": true, "schema": {"title": "Drawing Id", "type": "string", "format": "uuid"}, "name": "drawing_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Drawing"}}}}, "404": {"description": "Project or drawing not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Drawings"], "summary": "Update Drawing", "description": "Update a drawing.", "operationId": "update_drawing_v2_projects__project_id__drawings__drawing_id__put", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": true, "schema": {"title": "Drawing Id", "type": "string", "format": "uuid"}, "name": "drawing_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Drawing"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Drawing"}}}}, "404": {"description": "Project or drawing not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Drawings"], "summary": "Delete Drawing", "description": "Delete a drawing.", "operationId": "delete_drawing_v2_projects__project_id__drawings__drawing_id__delete", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": true, "schema": {"title": "Drawing Id", "type": "string", "format": "uuid"}, "name": "drawing_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Project or drawing not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/gns3vm/engines": {"get": {"tags": ["GNS3 VM"], "summary": "Get Engines", "description": "Return the list of supported engines for the GNS3VM.", "operationId": "get_engines_v2_gns3vm_engines_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/v2/gns3vm/engines/{engine}/vms": {"get": {"tags": ["GNS3 VM"], "summary": "Get Vms", "description": "Return all the available VMs for a specific virtualization engine.", "operationId": "get_vms_v2_gns3vm_engines__engine__vms_get", "parameters": [{"required": true, "schema": {"title": "Engine", "type": "string"}, "name": "engine", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/gns3vm": {"get": {"tags": ["GNS3 VM"], "summary": "Get Gns3Vm Settings", "description": "Return the GNS3 VM settings.", "operationId": "get_gns3vm_settings_v2_gns3vm_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GNS3VM"}}}}}}, "put": {"tags": ["GNS3 VM"], "summary": "Update Gns3Vm Settings", "description": "Update the GNS3 VM settings.", "operationId": "update_gns3vm_settings_v2_gns3vm_put", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GNS3VM"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GNS3VM"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/links": {"get": {"tags": ["Links"], "summary": "Get Links", "description": "Return all links for a given project.", "operationId": "get_links_v2_projects__project_id__links_get", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Get Links V2 Projects Project Id Links Get", "type": "array", "items": {"$ref": "#/components/schemas/Link"}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Links"], "summary": "Create Link", "description": "Create a new link.", "operationId": "create_link_v2_projects__project_id__links_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not create link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/links/{link_id}/available_filters": {"get": {"tags": ["Links"], "summary": "Get Filters", "description": "Return all filters available for a given link.", "operationId": "get_filters_v2_projects__project_id__links__link_id__available_filters_get", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": true, "schema": {"title": "Link Id", "type": "string", "format": "uuid"}, "name": "link_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/links/{link_id}": {"get": {"tags": ["Links"], "summary": "Get Link", "description": "Return a link.", "operationId": "get_link_v2_projects__project_id__links__link_id__get", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": true, "schema": {"title": "Link Id", "type": "string", "format": "uuid"}, "name": "link_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Links"], "summary": "Update Link", "description": "Update a link.", "operationId": "update_link_v2_projects__project_id__links__link_id__put", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": true, "schema": {"title": "Link Id", "type": "string", "format": "uuid"}, "name": "link_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Links"], "summary": "Delete Link", "description": "Delete a link.", "operationId": "delete_link_v2_projects__project_id__links__link_id__delete", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": true, "schema": {"title": "Link Id", "type": "string", "format": "uuid"}, "name": "link_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/links/{link_id}/start_capture": {"post": {"tags": ["Links"], "summary": "Start Capture", "description": "Start packet capture on the link.", "operationId": "start_capture_v2_projects__project_id__links__link_id__start_capture_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": true, "schema": {"title": "Link Id", "type": "string", "format": "uuid"}, "name": "link_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"title": "Capture Data", "type": "object"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/links/{link_id}/stop_capture": {"post": {"tags": ["Links"], "summary": "Stop Capture", "description": "Stop packet capture on the link.", "operationId": "stop_capture_v2_projects__project_id__links__link_id__stop_capture_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": true, "schema": {"title": "Link Id", "type": "string", "format": "uuid"}, "name": "link_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/links/{link_id}/reset": {"post": {"tags": ["Links"], "summary": "Reset Link", "description": "Reset a link.", "operationId": "reset_link_v2_projects__project_id__links__link_id__reset_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": true, "schema": {"title": "Link Id", "type": "string", "format": "uuid"}, "name": "link_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/links/{link_id}/pcap": {"get": {"tags": ["Links"], "summary": "Pcap", "description": "Stream the PCAP capture file from compute.", "operationId": "pcap_v2_projects__project_id__links__link_id__pcap_get", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": true, "schema": {"title": "Link Id", "type": "string", "format": "uuid"}, "name": "link_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes": {"get": {"tags": ["Nodes"], "summary": "Get Nodes", "description": "Return all nodes belonging to a given project.", "operationId": "get_nodes_v2_projects__project_id__nodes_get", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Get Nodes V2 Projects Project Id Nodes Get", "type": "array", "items": {"$ref": "#/components/schemas/Node"}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Nodes"], "summary": "Create Node", "description": "Create a new node.", "operationId": "create_node_v2_projects__project_id__nodes_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Node"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Node"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not create node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/start": {"post": {"tags": ["Nodes"], "summary": "Start All Nodes", "description": "Start all nodes belonging to a given project.", "operationId": "start_all_nodes_v2_projects__project_id__nodes_start_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/stop": {"post": {"tags": ["Nodes"], "summary": "Stop All Nodes", "description": "Stop all nodes belonging to a given project.", "operationId": "stop_all_nodes_v2_projects__project_id__nodes_stop_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/suspend": {"post": {"tags": ["Nodes"], "summary": "Suspend All Nodes", "description": "Suspend all nodes belonging to a given project.", "operationId": "suspend_all_nodes_v2_projects__project_id__nodes_suspend_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/reload": {"post": {"tags": ["Nodes"], "summary": "Reload All Nodes", "description": "Reload all nodes belonging to a given project.", "operationId": "reload_all_nodes_v2_projects__project_id__nodes_reload_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/{node_id}": {"get": {"tags": ["Nodes"], "summary": "Get Node", "description": "Return a node from a given project.", "operationId": "get_node_v2_projects__project_id__nodes__node_id__get", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Node"}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Nodes"], "summary": "Update Node", "description": "Update a node.", "operationId": "update_node_v2_projects__project_id__nodes__node_id__put", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/NodeUpdate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Node"}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Nodes"], "summary": "Delete Node", "description": "Delete a node from a project.", "operationId": "delete_node_v2_projects__project_id__nodes__node_id__delete", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Cannot delete node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/{node_id}/duplicate": {"post": {"tags": ["Nodes"], "summary": "Duplicate Node", "description": "Duplicate a node.", "operationId": "duplicate_node_v2_projects__project_id__nodes__node_id__duplicate_post", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/NodeDuplicate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Node"}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/{node_id}/start": {"post": {"tags": ["Nodes"], "summary": "Start Node", "description": "Start a node.", "operationId": "start_node_v2_projects__project_id__nodes__node_id__start_post", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"title": "Start Data", "type": "object"}}}, "required": true}, "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/{node_id}/stop": {"post": {"tags": ["Nodes"], "summary": "Stop Node", "description": "Stop a node.", "operationId": "stop_node_v2_projects__project_id__nodes__node_id__stop_post", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/{node_id}/suspend": {"post": {"tags": ["Nodes"], "summary": "Suspend Node", "description": "Suspend a node.", "operationId": "suspend_node_v2_projects__project_id__nodes__node_id__suspend_post", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/{node_id}/reload": {"post": {"tags": ["Nodes"], "summary": "Reload Node", "description": "Reload a node.", "operationId": "reload_node_v2_projects__project_id__nodes__node_id__reload_post", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/{node_id}/links": {"get": {"tags": ["Nodes"], "summary": "Get Node Links", "description": "Return all the links connected to a node.", "operationId": "get_node_links_v2_projects__project_id__nodes__node_id__links_get", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Get Node Links V2 Projects Project Id Nodes Node Id Links Get", "type": "array", "items": {"$ref": "#/components/schemas/Link"}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/{node_id}/dynamips/auto_idlepc": {"get": {"tags": ["Nodes"], "summary": "Auto Idlepc", "description": "Compute an Idle-PC value for a Dynamips node", "operationId": "auto_idlepc_v2_projects__project_id__nodes__node_id__dynamips_auto_idlepc_get", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/{node_id}/dynamips/idlepc_proposals": {"get": {"tags": ["Nodes"], "summary": "Idlepc Proposals", "description": "Compute a list of potential idle-pc values for a Dynamips node", "operationId": "idlepc_proposals_v2_projects__project_id__nodes__node_id__dynamips_idlepc_proposals_get", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/{node_id}/resize_disk": {"post": {"tags": ["Nodes"], "summary": "Resize Disk", "description": "Resize a disk image.", "operationId": "resize_disk_v2_projects__project_id__nodes__node_id__resize_disk_post", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"title": "Resize Data", "type": "object"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/{node_id}/files/{file_path}": {"get": {"tags": ["Nodes"], "summary": "Get File", "description": "Return a file in the node directory", "operationId": "get_file_v2_projects__project_id__nodes__node_id__files__file_path__get", "parameters": [{"required": true, "schema": {"title": "File Path", "type": "string"}, "name": "file_path", "in": "path"}, {"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Nodes"], "summary": "Post File", "description": "Write a file in the node directory.", "operationId": "post_file_v2_projects__project_id__nodes__node_id__files__file_path__post", "parameters": [{"required": true, "schema": {"title": "File Path", "type": "string"}, "name": "file_path", "in": "path"}, {"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/console/reset": {"post": {"tags": ["Nodes"], "summary": "Reset Console All", "description": "Reset console for all nodes belonging to the project.", "operationId": "reset_console_all_v2_projects__project_id__nodes_console_reset_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/nodes/{node_id}/console/reset": {"post": {"tags": ["Nodes"], "summary": "Console Reset", "operationId": "console_reset_v2_projects__project_id__nodes__node_id__console_reset_post", "parameters": [{"required": true, "schema": {"title": "Node Id", "type": "string", "format": "uuid"}, "name": "node_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/notifications": {"get": {"tags": ["Notifications"], "summary": "Http Notification", "description": "Receive controller notifications about the controller from HTTP stream.", "operationId": "http_notification_v2_notifications_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/v2/projects": {"get": {"tags": ["Projects"], "summary": "Get Projects", "description": "Return all projects.", "operationId": "get_projects_v2_projects_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Get Projects V2 Projects Get", "type": "array", "items": {"$ref": "#/components/schemas/Project"}}}}}}}, "post": {"tags": ["Projects"], "summary": "Create Project", "description": "Create a new project.", "operationId": "create_project_v2_projects_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "409": {"description": "Could not create project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}": {"get": {"tags": ["Projects"], "summary": "Get Project", "description": "Return a project.", "operationId": "get_project_v2_projects__project_id__get", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Projects"], "summary": "Update Project", "description": "Update a project.", "operationId": "update_project_v2_projects__project_id__put", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectUpdate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Projects"], "summary": "Delete Project", "description": "Delete a project.", "operationId": "delete_project_v2_projects__project_id__delete", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/stats": {"get": {"tags": ["Projects"], "summary": "Get Project Stats", "description": "Return a project statistics.", "operationId": "get_project_stats_v2_projects__project_id__stats_get", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/close": {"post": {"tags": ["Projects"], "summary": "Close Project", "description": "Close a project.", "operationId": "close_project_v2_projects__project_id__close_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not close project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/open": {"post": {"tags": ["Projects"], "summary": "Open Project", "description": "Open a project.", "operationId": "open_project_v2_projects__project_id__open_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not open project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/load": {"post": {"tags": ["Projects"], "summary": "Load Project", "description": "Load a project (local server only).", "operationId": "load_project_v2_projects_load_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_load_project_v2_projects_load_post"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not load project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/notifications": {"get": {"tags": ["Projects"], "summary": "Notification", "description": "Receive project notifications about the controller from HTTP stream.", "operationId": "notification_v2_projects__project_id__notifications_get", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/export": {"get": {"tags": ["Projects"], "summary": "Export Project", "description": "Export a project as a portable archive.", "operationId": "export_project_v2_projects__project_id__export_get", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": false, "schema": {"title": "Include Snapshots", "type": "boolean", "default": false}, "name": "include_snapshots", "in": "query"}, {"required": false, "schema": {"title": "Include Images", "type": "boolean", "default": false}, "name": "include_images", "in": "query"}, {"required": false, "schema": {"title": "Reset Mac Addresses", "type": "boolean", "default": false}, "name": "reset_mac_addresses", "in": "query"}, {"required": false, "schema": {"title": "Compression", "type": "string", "default": "zip"}, "name": "compression", "in": "query"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/import": {"post": {"tags": ["Projects"], "summary": "Import Project", "description": "Import a project from a portable archive.", "operationId": "import_project_v2_projects__project_id__import_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": false, "schema": {"title": "Path", "type": "string", "format": "path"}, "name": "path", "in": "query"}, {"required": false, "schema": {"title": "Name", "type": "string"}, "name": "name", "in": "query"}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/duplicate": {"post": {"tags": ["Projects"], "summary": "Duplicate", "description": "Duplicate a project.", "operationId": "duplicate_v2_projects__project_id__duplicate_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectDuplicate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not duplicate project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/files/{file_path}": {"get": {"tags": ["Projects"], "summary": "Get File", "description": "Return a file from a project.", "operationId": "get_file_v2_projects__project_id__files__file_path__get", "parameters": [{"required": true, "schema": {"title": "File Path", "type": "string"}, "name": "file_path", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Projects"], "summary": "Write File", "description": "Write a file from a project.", "operationId": "write_file_v2_projects__project_id__files__file_path__post", "parameters": [{"required": true, "schema": {"title": "File Path", "type": "string"}, "name": "file_path", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/snapshots": {"get": {"tags": ["Snapshots"], "summary": "Get Snapshots", "description": "Return all snapshots belonging to a given project.", "operationId": "get_snapshots_v2_projects__project_id__snapshots_get", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Get Snapshots V2 Projects Project Id Snapshots Get", "type": "array", "items": {"$ref": "#/components/schemas/Snapshot"}}}}}, "404": {"description": "Could not find project or snapshot", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Snapshots"], "summary": "Create Snapshot", "description": "Create a new snapshot of a project.", "operationId": "create_snapshot_v2_projects__project_id__snapshots_post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SnapshotCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Snapshot"}}}}, "404": {"description": "Could not find project or snapshot", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/snapshots/{snapshot_id}": {"delete": {"tags": ["Snapshots"], "summary": "Delete Snapshot", "description": "Delete a snapshot.", "operationId": "delete_snapshot_v2_projects__project_id__snapshots__snapshot_id__delete", "parameters": [{"required": true, "schema": {"title": "Snapshot Id", "type": "string", "format": "uuid"}, "name": "snapshot_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or snapshot", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/snapshots/{snapshot_id}/restore": {"post": {"tags": ["Snapshots"], "summary": "Restore Snapshot", "description": "Restore a snapshot.", "operationId": "restore_snapshot_v2_projects__project_id__snapshots__snapshot_id__restore_post", "parameters": [{"required": true, "schema": {"title": "Snapshot Id", "type": "string", "format": "uuid"}, "name": "snapshot_id", "in": "path"}, {"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project or snapshot", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/symbols": {"get": {"tags": ["Symbols"], "summary": "Get Symbols", "operationId": "get_symbols_v2_symbols_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/v2/symbols/{symbol_id}/raw": {"get": {"tags": ["Symbols"], "summary": "Get Symbol", "description": "Download a symbol file.", "operationId": "get_symbol_v2_symbols__symbol_id__raw_get", "parameters": [{"required": true, "schema": {"title": "Symbol Id", "type": "string"}, "name": "symbol_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find symbol", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Symbols"], "summary": "Upload Symbol", "description": "Upload a symbol file.", "operationId": "upload_symbol_v2_symbols__symbol_id__raw_post", "parameters": [{"required": true, "schema": {"title": "Symbol Id", "type": "string"}, "name": "symbol_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/symbols/default_symbols": {"get": {"tags": ["Symbols"], "summary": "Get Default Symbols", "description": "Return all default symbols.", "operationId": "get_default_symbols_v2_symbols_default_symbols_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/v2/templates": {"get": {"tags": ["Templates"], "summary": "Get Templates", "description": "Return all templates.", "operationId": "get_templates_v2_templates_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Get Templates V2 Templates Get", "type": "array", "items": {"$ref": "#/components/schemas/Template"}}}}}}}, "post": {"tags": ["Templates"], "summary": "Create Template", "description": "Create a new template.", "operationId": "create_template_v2_templates_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/TemplateCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Template"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/templates/{template_id}": {"get": {"tags": ["Templates"], "summary": "Get Template", "description": "Return a template.", "operationId": "get_template_v2_templates__template_id__get", "parameters": [{"required": true, "schema": {"title": "Template Id", "type": "string", "format": "uuid"}, "name": "template_id", "in": "path"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Template"}}}}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Templates"], "summary": "Update Template", "description": "Update a template.", "operationId": "update_template_v2_templates__template_id__put", "parameters": [{"required": true, "schema": {"title": "Template Id", "type": "string", "format": "uuid"}, "name": "template_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/TemplateUpdate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Template"}}}}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Templates"], "summary": "Delete Template", "description": "Delete a template.", "operationId": "delete_template_v2_templates__template_id__delete", "parameters": [{"required": true, "schema": {"title": "Template Id", "type": "string", "format": "uuid"}, "name": "template_id", "in": "path"}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/templates/{template_id}/duplicate": {"post": {"tags": ["Templates"], "summary": "Duplicate Template", "description": "Duplicate a template.", "operationId": "duplicate_template_v2_templates__template_id__duplicate_post", "parameters": [{"required": true, "schema": {"title": "Template Id", "type": "string", "format": "uuid"}, "name": "template_id", "in": "path"}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Template"}}}}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/projects/{project_id}/templates/{template_id}": {"post": {"tags": ["Templates"], "summary": "Create Node From Template", "description": "Create a new node from a template.", "operationId": "create_node_from_template_v2_projects__project_id__templates__template_id__post", "parameters": [{"required": true, "schema": {"title": "Project Id", "type": "string", "format": "uuid"}, "name": "project_id", "in": "path"}, {"required": true, "schema": {"title": "Template Id", "type": "string", "format": "uuid"}, "name": "template_id", "in": "path"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/TemplateUsage"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Node"}}}}, "404": {"description": "Could not find project or template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}}, "components": {"schemas": {"AutoIdlePC": {"title": "AutoIdlePC", "required": ["platform", "image", "ram"], "type": "object", "properties": {"platform": {"title": "Platform", "type": "string", "description": "Cisco platform"}, "image": {"title": "Image", "type": "string", "description": "Image path"}, "ram": {"title": "Ram", "type": "integer", "description": "Amount of RAM in MB"}}, "description": "Data for auto Idle-PC request.", "example": {"platform": "c7200", "image": "/path/to/c7200_image.bin", "ram": 256}}, "Body_load_project_v2_projects_load_post": {"title": "Body_load_project_v2_projects_load_post", "required": ["path"], "type": "object", "properties": {"path": {"title": "Path", "type": "string"}}}, "Capabilities": {"title": "Capabilities", "required": ["version", "node_types", "platform", "cpus", "memory", "disk_size"], "type": "object", "properties": {"version": {"title": "Version", "type": "string", "description": "Compute version number"}, "node_types": {"title": "Node Types", "type": "array", "items": {"$ref": "#/components/schemas/NodeType"}, "description": "Node types supported by the compute"}, "platform": {"title": "Platform", "type": "string", "description": "Platform where the compute is running (Linux, Windows or macOS)"}, "cpus": {"title": "Cpus", "type": "integer", "description": "Number of CPUs on this compute"}, "memory": {"title": "Memory", "type": "integer", "description": "Amount of memory on this compute"}, "disk_size": {"title": "Disk Size", "type": "integer", "description": "Disk size on this compute"}}, "description": "Capabilities supported by a compute."}, "Category": {"title": "Category", "enum": ["router", "switch", "guest", "firewall"], "type": "string", "description": "Supported categories"}, "Compute": {"title": "Compute", "required": ["compute_id", "name", "protocol", "host", "port", "connected", "cpu_usage_percent", "memory_usage_percent", "disk_usage_percent", "capabilities"], "type": "object", "properties": {"compute_id": {"title": "Compute Id", "anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}]}, "name": {"title": "Name", "type": "string"}, "protocol": {"$ref": "#/components/schemas/Protocol"}, "host": {"title": "Host", "type": "string"}, "port": {"title": "Port", "maximum": 65535.0, "exclusiveMinimum": 0.0, "type": "integer"}, "user": {"title": "User", "type": "string"}, "connected": {"title": "Connected", "type": "boolean", "description": "Whether the controller is connected to the compute or not"}, "cpu_usage_percent": {"title": "Cpu Usage Percent", "maximum": 100.0, "minimum": 0.0, "type": "number", "description": "CPU usage of the compute"}, "memory_usage_percent": {"title": "Memory Usage Percent", "maximum": 100.0, "minimum": 0.0, "type": "number", "description": "Memory usage of the compute"}, "disk_usage_percent": {"title": "Disk Usage Percent", "maximum": 100.0, "minimum": 0.0, "type": "number", "description": "Disk usage of the compute"}, "last_error": {"title": "Last Error", "type": "string", "description": "Last error found on the compute"}, "capabilities": {"$ref": "#/components/schemas/Capabilities"}}, "description": "Data returned for a compute."}, "ComputeCreate": {"title": "ComputeCreate", "required": ["protocol", "host", "port"], "type": "object", "properties": {"compute_id": {"title": "Compute Id", "anyOf": [{"type": "string", "format": "uuid"}, {"type": "string"}]}, "name": {"title": "Name", "type": "string"}, "protocol": {"$ref": "#/components/schemas/Protocol"}, "host": {"title": "Host", "type": "string"}, "port": {"title": "Port", "maximum": 65535.0, "exclusiveMinimum": 0.0, "type": "integer"}, "user": {"title": "User", "type": "string"}, "password": {"title": "Password", "type": "string"}}, "description": "Data to create a compute.", "example": {"name": "My compute", "host": "127.0.0.1", "port": 3080, "user": "user", "password": "password"}}, "ComputeUpdate": {"title": "ComputeUpdate", "type": "object", "properties": {"compute_id": {"title": "Compute Id", "anyOf": [{"type": "string", "format": "uuid"}, {"type": "string"}]}, "name": {"title": "Name", "type": "string"}, "protocol": {"$ref": "#/components/schemas/Protocol"}, "host": {"title": "Host", "type": "string"}, "port": {"title": "Port", "maximum": 65535.0, "exclusiveMinimum": 0.0, "type": "integer"}, "user": {"title": "User", "type": "string"}, "password": {"title": "Password", "type": "string"}}, "description": "Data to update a compute.", "example": {"host": "10.0.0.1", "port": 8080}}, "ConsoleType": {"title": "ConsoleType", "enum": ["vnc", "telnet", "http", "https", "spice", "spice+agent", "none"], "type": "string", "description": "Supported console types."}, "CustomAdapter": {"title": "CustomAdapter", "required": ["adapter_number"], "type": "object", "properties": {"adapter_number": {"title": "Adapter Number", "type": "integer"}, "port_name": {"title": "Port Name", "type": "string"}, "adapter_type": {"title": "Adapter Type", "type": "string"}, "mac_address": {"title": "Mac Address", "pattern": "^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$", "type": "string"}}, "description": "Custom adapter data."}, "Drawing": {"title": "Drawing", "type": "object", "properties": {"drawing_id": {"title": "Drawing Id", "type": "string", "format": "uuid"}, "project_id": {"title": "Project Id", "type": "string", "format": "uuid"}, "x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}, "z": {"title": "Z", "type": "integer"}, "locked": {"title": "Locked", "type": "boolean"}, "rotation": {"title": "Rotation", "maximum": 360.0, "minimum": -359.0, "type": "integer"}, "svg": {"title": "Svg", "type": "string"}}, "description": "Drawing data."}, "Engine": {"title": "Engine", "enum": ["vmware", "virtualbox", "hyper-v", "none"], "type": "string", "description": "\"The engine to use for the GNS3 VM."}, "ErrorMessage": {"title": "ErrorMessage", "required": ["message"], "type": "object", "properties": {"message": {"title": "Message", "type": "string"}}, "description": "Error message."}, "GNS3VM": {"title": "GNS3VM", "type": "object", "properties": {"enable": {"title": "Enable", "type": "boolean", "description": "Enable/disable the GNS3 VM"}, "vmname": {"title": "Vmname", "type": "string", "description": "GNS3 VM name"}, "when_exit": {"$ref": "#/components/schemas/WhenExit"}, "headless": {"title": "Headless", "type": "boolean", "description": "Start the GNS3 VM GUI or not"}, "engine": {"$ref": "#/components/schemas/Engine"}, "vcpus": {"title": "Vcpus", "type": "integer", "description": "Number of CPUs to allocate for the GNS3 VM"}, "ram": {"title": "Ram", "type": "integer", "description": "Amount of memory to allocate for the GNS3 VM"}, "port": {"title": "Port", "maximum": 65535.0, "exclusiveMinimum": 0.0, "type": "integer"}}, "description": "GNS3 VM data."}, "HTTPValidationError": {"title": "HTTPValidationError", "type": "object", "properties": {"detail": {"title": "Detail", "type": "array", "items": {"$ref": "#/components/schemas/ValidationError"}}}}, "IOULicense": {"title": "IOULicense", "required": ["iourc_content", "license_check"], "type": "object", "properties": {"iourc_content": {"title": "Iourc Content", "type": "string", "description": "Content of iourc file"}, "license_check": {"title": "License Check", "type": "boolean", "description": "Whether the license must be checked or not"}}}, "Label": {"title": "Label", "required": ["text"], "type": "object", "properties": {"text": {"title": "Text", "type": "string"}, "style": {"title": "Style", "type": "string", "description": "SVG style attribute. Apply default style if null"}, "x": {"title": "X", "type": "integer", "description": "Relative X position of the label. Center it if null"}, "y": {"title": "Y", "type": "integer", "description": "Relative Y position of the label"}, "rotation": {"title": "Rotation", "maximum": 360.0, "minimum": -359.0, "type": "integer", "description": "Rotation of the label"}}, "description": "Label data."}, "Link": {"title": "Link", "type": "object", "properties": {"link_id": {"title": "Link Id", "type": "string", "format": "uuid"}, "project_id": {"title": "Project Id", "type": "string", "format": "uuid"}, "nodes": {"title": "Nodes", "type": "array", "items": {"$ref": "#/components/schemas/LinkNode"}}, "suspend": {"title": "Suspend", "type": "boolean"}, "filters": {"title": "Filters", "type": "object"}, "capturing": {"title": "Capturing", "type": "boolean", "description": "Read only property. True if a capture running on the link"}, "capture_file_name": {"title": "Capture File Name", "type": "string", "description": "Read only property. The name of the capture file if a capture is running"}, "capture_file_path": {"title": "Capture File Path", "type": "string", "description": "Read only property. The full path of the capture file if a capture is running"}, "capture_compute_id": {"title": "Capture Compute Id", "type": "string", "description": "Read only property. The compute identifier where a capture is running"}, "link_type": {"$ref": "#/components/schemas/LinkType"}}, "description": "Link data."}, "LinkNode": {"title": "LinkNode", "required": ["node_id", "adapter_number", "port_number"], "type": "object", "properties": {"node_id": {"title": "Node Id", "type": "string", "format": "uuid"}, "adapter_number": {"title": "Adapter Number", "type": "integer"}, "port_number": {"title": "Port Number", "type": "integer"}, "label": {"$ref": "#/components/schemas/Label"}}, "description": "Link node data."}, "LinkType": {"title": "LinkType", "enum": ["ethernet", "serial"], "type": "string", "description": "Link type."}, "Node": {"title": "Node", "required": ["compute_id", "name", "node_type"], "type": "object", "properties": {"compute_id": {"title": "Compute Id", "anyOf": [{"type": "string", "format": "uuid"}, {"type": "string"}]}, "name": {"title": "Name", "type": "string"}, "node_type": {"$ref": "#/components/schemas/NodeType"}, "project_id": {"title": "Project Id", "type": "string", "format": "uuid"}, "node_id": {"title": "Node Id", "type": "string", "format": "uuid"}, "template_id": {"title": "Template Id", "type": "string", "description": "Template UUID from which the node has been created. Read only", "format": "uuid"}, "node_directory": {"title": "Node Directory", "type": "string", "description": "Working directory of the node. Read only"}, "command_line": {"title": "Command Line", "type": "string", "description": "Command line use to start the node"}, "console": {"title": "Console", "maximum": 65535.0, "exclusiveMinimum": 0.0, "type": "integer", "description": "Console TCP port"}, "console_host": {"title": "Console Host", "type": "string", "description": "Console host. Warning if the host is 0.0.0.0 or :: (listen on all interfaces) you need to use the same address you use to connect to the controller"}, "console_type": {"$ref": "#/components/schemas/ConsoleType"}, "console_auto_start": {"title": "Console Auto Start", "type": "boolean", "description": "Automatically start the console when the node has started"}, "aux": {"title": "Aux", "maximum": 65535.0, "exclusiveMinimum": 0.0, "type": "integer", "description": "Auxiliary console TCP port"}, "aux_type": {"$ref": "#/components/schemas/ConsoleType"}, "properties": {"title": "Properties", "type": "object", "description": "Properties specific to an emulator"}, "status": {"$ref": "#/components/schemas/NodeStatus"}, "label": {"$ref": "#/components/schemas/Label"}, "symbol": {"title": "Symbol", "type": "string"}, "width": {"title": "Width", "type": "integer", "description": "Width of the node (Read only)"}, "height": {"title": "Height", "type": "integer", "description": "Height of the node (Read only)"}, "x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}, "z": {"title": "Z", "type": "integer"}, "locked": {"title": "Locked", "type": "boolean", "description": "Whether the element locked or not"}, "port_name_format": {"title": "Port Name Format", "type": "string", "description": "Formatting for port name {0} will be replace by port number"}, "port_segment_size": {"title": "Port Segment Size", "type": "integer", "description": "Size of the port segment"}, "first_port_name": {"title": "First Port Name", "type": "string", "description": "Name of the first port"}, "custom_adapters": {"title": "Custom Adapters", "type": "array", "items": {"$ref": "#/components/schemas/CustomAdapter"}}, "ports": {"title": "Ports", "type": "array", "items": {"$ref": "#/components/schemas/NodePort"}, "description": "List of node ports (read only)"}}, "description": "Node data."}, "NodeDuplicate": {"title": "NodeDuplicate", "required": ["x", "y"], "type": "object", "properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}, "z": {"title": "Z", "type": "integer", "default": 0}}, "description": "Data to duplicate a node."}, "NodePort": {"title": "NodePort", "required": ["name", "short_name", "adapter_number", "port_number", "link_type", "data_link_types"], "type": "object", "properties": {"name": {"title": "Name", "type": "string", "description": "Port name"}, "short_name": {"title": "Short Name", "type": "string", "description": "Port name"}, "adapter_number": {"title": "Adapter Number", "type": "integer", "description": "Adapter slot"}, "adapter_type": {"title": "Adapter Type", "type": "string", "description": "Adapter type"}, "port_number": {"title": "Port Number", "type": "integer", "description": "Port slot"}, "link_type": {"$ref": "#/components/schemas/LinkType"}, "data_link_types": {"title": "Data Link Types", "type": "object", "description": "Available PCAP types for capture"}, "mac_address": {"title": "Mac Address", "pattern": "^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$", "type": "string"}}, "description": "Node port data."}, "NodeStatus": {"title": "NodeStatus", "enum": ["stopped", "started", "suspended"], "type": "string", "description": "Supported node statuses."}, "NodeType": {"title": "NodeType", "enum": ["cloud", "nat", "ethernet_hub", "ethernet_switch", "frame_relay_switch", "atm_switch", "docker", "dynamips", "vpcs", "traceng", "virtualbox", "vmware", "iou", "qemu"], "type": "string", "description": "Supported node types."}, "NodeUpdate": {"title": "NodeUpdate", "type": "object", "properties": {"compute_id": {"title": "Compute Id", "anyOf": [{"type": "string", "format": "uuid"}, {"type": "string"}]}, "name": {"title": "Name", "type": "string"}, "node_type": {"$ref": "#/components/schemas/NodeType"}, "project_id": {"title": "Project Id", "type": "string", "format": "uuid"}, "node_id": {"title": "Node Id", "type": "string", "format": "uuid"}, "template_id": {"title": "Template Id", "type": "string", "description": "Template UUID from which the node has been created. Read only", "format": "uuid"}, "node_directory": {"title": "Node Directory", "type": "string", "description": "Working directory of the node. Read only"}, "command_line": {"title": "Command Line", "type": "string", "description": "Command line use to start the node"}, "console": {"title": "Console", "maximum": 65535.0, "exclusiveMinimum": 0.0, "type": "integer", "description": "Console TCP port"}, "console_host": {"title": "Console Host", "type": "string", "description": "Console host. Warning if the host is 0.0.0.0 or :: (listen on all interfaces) you need to use the same address you use to connect to the controller"}, "console_type": {"$ref": "#/components/schemas/ConsoleType"}, "console_auto_start": {"title": "Console Auto Start", "type": "boolean", "description": "Automatically start the console when the node has started"}, "aux": {"title": "Aux", "maximum": 65535.0, "exclusiveMinimum": 0.0, "type": "integer", "description": "Auxiliary console TCP port"}, "aux_type": {"$ref": "#/components/schemas/ConsoleType"}, "properties": {"title": "Properties", "type": "object", "description": "Properties specific to an emulator"}, "status": {"$ref": "#/components/schemas/NodeStatus"}, "label": {"$ref": "#/components/schemas/Label"}, "symbol": {"title": "Symbol", "type": "string"}, "width": {"title": "Width", "type": "integer", "description": "Width of the node (Read only)"}, "height": {"title": "Height", "type": "integer", "description": "Height of the node (Read only)"}, "x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}, "z": {"title": "Z", "type": "integer"}, "locked": {"title": "Locked", "type": "boolean", "description": "Whether the element locked or not"}, "port_name_format": {"title": "Port Name Format", "type": "string", "description": "Formatting for port name {0} will be replace by port number"}, "port_segment_size": {"title": "Port Segment Size", "type": "integer", "description": "Size of the port segment"}, "first_port_name": {"title": "First Port Name", "type": "string", "description": "Name of the first port"}, "custom_adapters": {"title": "Custom Adapters", "type": "array", "items": {"$ref": "#/components/schemas/CustomAdapter"}}, "ports": {"title": "Ports", "type": "array", "items": {"$ref": "#/components/schemas/NodePort"}, "description": "List of node ports (read only)"}}, "description": "Data to update a node."}, "Project": {"title": "Project", "required": ["project_id"], "type": "object", "properties": {"name": {"title": "Name", "type": "string"}, "project_id": {"title": "Project Id", "type": "string", "format": "uuid"}, "path": {"title": "Path", "type": "string", "description": "Project directory", "format": "path"}, "auto_close": {"title": "Auto Close", "type": "boolean", "description": "Close project when last client leaves"}, "auto_open": {"title": "Auto Open", "type": "boolean", "description": "Project opens when GNS3 starts"}, "auto_start": {"title": "Auto Start", "type": "boolean", "description": "Project starts when opened"}, "scene_height": {"title": "Scene Height", "type": "integer", "description": "Height of the drawing area"}, "scene_width": {"title": "Scene Width", "type": "integer", "description": "Width of the drawing area"}, "zoom": {"title": "Zoom", "type": "integer", "description": "Zoom of the drawing area"}, "show_layers": {"title": "Show Layers", "type": "boolean", "description": "Show layers on the drawing area"}, "snap_to_grid": {"title": "Snap To Grid", "type": "boolean", "description": "Snap to grid on the drawing area"}, "show_grid": {"title": "Show Grid", "type": "boolean", "description": "Show the grid on the drawing area"}, "grid_size": {"title": "Grid Size", "type": "integer", "description": "Grid size for the drawing area for nodes"}, "drawing_grid_size": {"title": "Drawing Grid Size", "type": "integer", "description": "Grid size for the drawing area for drawings"}, "show_interface_labels": {"title": "Show Interface Labels", "type": "boolean", "description": "Show interface labels on the drawing area"}, "supplier": {"title": "Supplier", "allOf": [{"$ref": "#/components/schemas/Supplier"}], "description": "Supplier of the project"}, "variables": {"title": "Variables", "type": "array", "items": {"$ref": "#/components/schemas/Variable"}, "description": "Variables required to run the project"}, "status": {"$ref": "#/components/schemas/ProjectStatus"}, "filename": {"title": "Filename", "type": "string"}}, "description": "Common properties for projects."}, "ProjectCreate": {"title": "ProjectCreate", "required": ["name"], "type": "object", "properties": {"name": {"title": "Name", "type": "string"}, "project_id": {"title": "Project Id", "type": "string", "format": "uuid"}, "path": {"title": "Path", "type": "string", "description": "Project directory", "format": "path"}, "auto_close": {"title": "Auto Close", "type": "boolean", "description": "Close project when last client leaves"}, "auto_open": {"title": "Auto Open", "type": "boolean", "description": "Project opens when GNS3 starts"}, "auto_start": {"title": "Auto Start", "type": "boolean", "description": "Project starts when opened"}, "scene_height": {"title": "Scene Height", "type": "integer", "description": "Height of the drawing area"}, "scene_width": {"title": "Scene Width", "type": "integer", "description": "Width of the drawing area"}, "zoom": {"title": "Zoom", "type": "integer", "description": "Zoom of the drawing area"}, "show_layers": {"title": "Show Layers", "type": "boolean", "description": "Show layers on the drawing area"}, "snap_to_grid": {"title": "Snap To Grid", "type": "boolean", "description": "Snap to grid on the drawing area"}, "show_grid": {"title": "Show Grid", "type": "boolean", "description": "Show the grid on the drawing area"}, "grid_size": {"title": "Grid Size", "type": "integer", "description": "Grid size for the drawing area for nodes"}, "drawing_grid_size": {"title": "Drawing Grid Size", "type": "integer", "description": "Grid size for the drawing area for drawings"}, "show_interface_labels": {"title": "Show Interface Labels", "type": "boolean", "description": "Show interface labels on the drawing area"}, "supplier": {"title": "Supplier", "allOf": [{"$ref": "#/components/schemas/Supplier"}], "description": "Supplier of the project"}, "variables": {"title": "Variables", "type": "array", "items": {"$ref": "#/components/schemas/Variable"}, "description": "Variables required to run the project"}}, "description": "Properties for project creation."}, "ProjectDuplicate": {"title": "ProjectDuplicate", "required": ["name"], "type": "object", "properties": {"name": {"title": "Name", "type": "string"}, "project_id": {"title": "Project Id", "type": "string", "format": "uuid"}, "path": {"title": "Path", "type": "string", "description": "Project directory", "format": "path"}, "auto_close": {"title": "Auto Close", "type": "boolean", "description": "Close project when last client leaves"}, "auto_open": {"title": "Auto Open", "type": "boolean", "description": "Project opens when GNS3 starts"}, "auto_start": {"title": "Auto Start", "type": "boolean", "description": "Project starts when opened"}, "scene_height": {"title": "Scene Height", "type": "integer", "description": "Height of the drawing area"}, "scene_width": {"title": "Scene Width", "type": "integer", "description": "Width of the drawing area"}, "zoom": {"title": "Zoom", "type": "integer", "description": "Zoom of the drawing area"}, "show_layers": {"title": "Show Layers", "type": "boolean", "description": "Show layers on the drawing area"}, "snap_to_grid": {"title": "Snap To Grid", "type": "boolean", "description": "Snap to grid on the drawing area"}, "show_grid": {"title": "Show Grid", "type": "boolean", "description": "Show the grid on the drawing area"}, "grid_size": {"title": "Grid Size", "type": "integer", "description": "Grid size for the drawing area for nodes"}, "drawing_grid_size": {"title": "Drawing Grid Size", "type": "integer", "description": "Grid size for the drawing area for drawings"}, "show_interface_labels": {"title": "Show Interface Labels", "type": "boolean", "description": "Show interface labels on the drawing area"}, "supplier": {"title": "Supplier", "allOf": [{"$ref": "#/components/schemas/Supplier"}], "description": "Supplier of the project"}, "variables": {"title": "Variables", "type": "array", "items": {"$ref": "#/components/schemas/Variable"}, "description": "Variables required to run the project"}, "reset_mac_addresses": {"title": "Reset Mac Addresses", "type": "boolean", "description": "Reset MAC addresses for this project", "default": false}}, "description": "Properties for project duplication."}, "ProjectStatus": {"title": "ProjectStatus", "enum": ["opened", "closed"], "type": "string", "description": "Supported project statuses."}, "ProjectUpdate": {"title": "ProjectUpdate", "type": "object", "properties": {"name": {"title": "Name", "type": "string"}, "project_id": {"title": "Project Id", "type": "string", "format": "uuid"}, "path": {"title": "Path", "type": "string", "description": "Project directory", "format": "path"}, "auto_close": {"title": "Auto Close", "type": "boolean", "description": "Close project when last client leaves"}, "auto_open": {"title": "Auto Open", "type": "boolean", "description": "Project opens when GNS3 starts"}, "auto_start": {"title": "Auto Start", "type": "boolean", "description": "Project starts when opened"}, "scene_height": {"title": "Scene Height", "type": "integer", "description": "Height of the drawing area"}, "scene_width": {"title": "Scene Width", "type": "integer", "description": "Width of the drawing area"}, "zoom": {"title": "Zoom", "type": "integer", "description": "Zoom of the drawing area"}, "show_layers": {"title": "Show Layers", "type": "boolean", "description": "Show layers on the drawing area"}, "snap_to_grid": {"title": "Snap To Grid", "type": "boolean", "description": "Snap to grid on the drawing area"}, "show_grid": {"title": "Show Grid", "type": "boolean", "description": "Show the grid on the drawing area"}, "grid_size": {"title": "Grid Size", "type": "integer", "description": "Grid size for the drawing area for nodes"}, "drawing_grid_size": {"title": "Drawing Grid Size", "type": "integer", "description": "Grid size for the drawing area for drawings"}, "show_interface_labels": {"title": "Show Interface Labels", "type": "boolean", "description": "Show interface labels on the drawing area"}, "supplier": {"title": "Supplier", "allOf": [{"$ref": "#/components/schemas/Supplier"}], "description": "Supplier of the project"}, "variables": {"title": "Variables", "type": "array", "items": {"$ref": "#/components/schemas/Variable"}, "description": "Variables required to run the project"}}, "description": "Properties for project update."}, "Protocol": {"title": "Protocol", "enum": ["http", "https"], "type": "string", "description": "Protocol supported to communicate with a compute."}, "Snapshot": {"title": "Snapshot", "required": ["name", "snapshot_id", "project_id", "created_at"], "type": "object", "properties": {"name": {"title": "Name", "type": "string"}, "snapshot_id": {"title": "Snapshot Id", "type": "string", "format": "uuid"}, "project_id": {"title": "Project Id", "type": "string", "format": "uuid"}, "created_at": {"title": "Created At", "type": "integer", "description": "Date of the snapshot (UTC timestamp)"}}, "description": "Common properties for snapshot."}, "SnapshotCreate": {"title": "SnapshotCreate", "required": ["name"], "type": "object", "properties": {"name": {"title": "Name", "type": "string"}}, "description": "Properties for snapshot creation."}, "Supplier": {"title": "Supplier", "required": ["logo", "url"], "type": "object", "properties": {"logo": {"title": "Logo", "type": "string", "description": "Path to the project supplier logo"}, "url": {"title": "Url", "maxLength": 2083, "minLength": 1, "type": "string", "description": "URL to the project supplier site", "format": "uri"}}}, "Template": {"title": "Template", "required": ["template_id", "name", "category", "symbol", "builtin", "template_type"], "type": "object", "properties": {"template_id": {"title": "Template Id", "type": "string"}, "name": {"title": "Name", "type": "string"}, "category": {"$ref": "#/components/schemas/Category"}, "default_name_format": {"title": "Default Name Format", "type": "string"}, "symbol": {"title": "Symbol", "type": "string"}, "builtin": {"title": "Builtin", "type": "boolean"}, "template_type": {"$ref": "#/components/schemas/NodeType"}, "usage": {"title": "Usage", "type": "string"}, "compute_id": {"title": "Compute Id", "type": "string"}}, "description": "Common template properties."}, "TemplateCreate": {"title": "TemplateCreate", "required": ["name", "template_type", "compute_id"], "type": "object", "properties": {"template_id": {"title": "Template Id", "type": "string"}, "name": {"title": "Name", "type": "string"}, "category": {"$ref": "#/components/schemas/Category"}, "default_name_format": {"title": "Default Name Format", "type": "string"}, "symbol": {"title": "Symbol", "type": "string"}, "builtin": {"title": "Builtin", "type": "boolean"}, "template_type": {"$ref": "#/components/schemas/NodeType"}, "usage": {"title": "Usage", "type": "string"}, "compute_id": {"title": "Compute Id", "type": "string"}}, "description": "Properties to create a template."}, "TemplateUpdate": {"title": "TemplateUpdate", "type": "object", "properties": {"template_id": {"title": "Template Id", "type": "string"}, "name": {"title": "Name", "type": "string"}, "category": {"$ref": "#/components/schemas/Category"}, "default_name_format": {"title": "Default Name Format", "type": "string"}, "symbol": {"title": "Symbol", "type": "string"}, "builtin": {"title": "Builtin", "type": "boolean"}, "template_type": {"$ref": "#/components/schemas/NodeType"}, "usage": {"title": "Usage", "type": "string"}, "compute_id": {"title": "Compute Id", "type": "string"}}, "description": "Common template properties."}, "TemplateUsage": {"title": "TemplateUsage", "required": ["x", "y"], "type": "object", "properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}, "name": {"title": "Name", "type": "string", "description": "Use this name to create a new node"}, "compute_id": {"title": "Compute Id", "type": "string", "description": "Used if the template doesn't have a default compute"}}}, "ValidationError": {"title": "ValidationError", "required": ["loc", "msg", "type"], "type": "object", "properties": {"loc": {"title": "Location", "type": "array", "items": {"type": "string"}}, "msg": {"title": "Message", "type": "string"}, "type": {"title": "Error Type", "type": "string"}}}, "Variable": {"title": "Variable", "required": ["name"], "type": "object", "properties": {"name": {"title": "Name", "type": "string", "description": "Variable name"}, "value": {"title": "Value", "type": "string", "description": "Variable value"}}}, "Version": {"title": "Version", "required": ["version"], "type": "object", "properties": {"version": {"title": "Version", "type": "string", "description": "Version number"}, "local": {"title": "Local", "type": "boolean", "description": "Whether this is a local server or not"}}}, "WhenExit": {"title": "WhenExit", "enum": ["stop", "suspend", "keep"], "type": "string", "description": "What to do with the VM when GNS3 VM exits."}, "gns3server__endpoints__schemas__links__LinkType": {"title": "LinkType", "enum": ["ethernet", "serial"], "type": "string", "description": "Link type."}, "gns3server__endpoints__schemas__nodes__LinkType": {"title": "LinkType", "enum": ["ethernet", "serial"], "type": "string", "description": "Supported link types."}}}} \ No newline at end of file +{"openapi": "3.1.0", "info": {"title": "GNS3 controller API", "description": "This page describes the public controller API for GNS3", "version": "3.0.0"}, "paths": {"/v3/version": {"get": {"tags": ["Controller"], "summary": "Get Version", "description": "Return the server version number.", "operationId": "get_version_v3_version_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}}}}, "post": {"tags": ["Controller"], "summary": "Check Version", "description": "Check if version is the same as the server.", "operationId": "check_version_v3_version_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}}, "409": {"description": "Invalid version", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/reload": {"post": {"tags": ["Controller"], "summary": "Reload", "description": "Reload the controller", "operationId": "reload_v3_reload_post", "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/shutdown": {"post": {"tags": ["Controller"], "summary": "Shutdown", "description": "Shutdown the server", "operationId": "shutdown_v3_shutdown_post", "responses": {"204": {"description": "Successful Response"}, "403": {"description": "Server shutdown not allowed", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/iou_license": {"get": {"tags": ["Controller"], "summary": "Get Iou License", "description": "Return the IOU license settings", "operationId": "get_iou_license_v3_iou_license_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "put": {"tags": ["Controller"], "summary": "Update Iou License", "description": "Update the IOU license settings.", "operationId": "update_iou_license_v3_iou_license_put", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/statistics": {"get": {"tags": ["Controller"], "summary": "Statistics", "description": "Return server statistics including compute resources, projects, and nodes.", "operationId": "statistics_v3_statistics_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"additionalProperties": true, "type": "object", "title": "Response Statistics V3 Statistics Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/notifications": {"get": {"tags": ["Controller"], "summary": "Controller Http Notifications", "description": "Receive controller notifications about the controller from HTTP stream.", "operationId": "controller_http_notifications_v3_notifications_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/access/users/login": {"post": {"tags": ["Users"], "summary": "Login", "description": "Default user login method using forms (x-www-form-urlencoded).\nExample: curl -X POST http://host:port/v3/access/users/login -H \"Content-Type: application/x-www-form-urlencoded\" -d \"username=admin&password=admin\"", "operationId": "login_v3_access_users_login_post", "requestBody": {"content": {"application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/Body_login_v3_access_users_login_post"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Token"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/users/authenticate": {"post": {"tags": ["Users"], "summary": "Authenticate", "description": "Alternative authentication method using json.\nExample: curl -X POST http://host:port/v3/access/users/authenticate -d '{\"username\": \"admin\", \"password\": \"admin\"}' -H \"Content-Type: application/json\"", "operationId": "authenticate_v3_access_users_authenticate_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Credentials"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Token"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/users/refresh": {"post": {"tags": ["Users"], "summary": "Refresh Access Token", "description": "Exchange a refresh token for a new access token.\n\nPublic endpoint \u2014 the refresh token itself proves identity. Respects the\nuser's token_version, so logout (which increments it) invalidates all\noutstanding refresh tokens. Refresh tokens are stateless JWTs with a\nlonger expiry (default 30 days). Stolen tokens remain valid until their\n`exp` or until logout \u2014 no replay protection without a server-side table.", "operationId": "refresh_access_token_v3_access_users_refresh_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefreshTokenRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Token"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/users/logout": {"post": {"tags": ["Users"], "summary": "Logout", "description": "Logout the current user by revoking all existing tokens.", "operationId": "logout_v3_access_users_logout_post", "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/access/users/me": {"get": {"tags": ["Users"], "summary": "Get Logged In User", "description": "Get the current active user.", "operationId": "get_logged_in_user_v3_access_users_me_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/User"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "put": {"tags": ["Users"], "summary": "Update Logged In User", "description": "Update the current active user.", "operationId": "update_logged_in_user_v3_access_users_me_put", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/LoggedInUserUpdate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/User"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/access/users": {"get": {"tags": ["Users"], "summary": "Get Users", "description": "Get all users.\n\nRequired privilege: User.Audit", "operationId": "get_users_v3_access_users_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/User"}, "type": "array", "title": "Response Get Users V3 Access Users Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "post": {"tags": ["Users"], "summary": "Create User", "description": "Create a new user.\n\nRequired privilege: User.Allocate", "operationId": "create_user_v3_access_users_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/User"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/access/users/{user_id}": {"get": {"tags": ["Users"], "summary": "Get User", "description": "Get a user.\n\nRequired privilege: User.Audit", "operationId": "get_user_v3_access_users__user_id__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/User"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Users"], "summary": "Update User", "description": "Update a user.\n\nRequired privilege: User.Modify", "operationId": "update_user_v3_access_users__user_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/User"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Users"], "summary": "Delete User", "description": "Delete a user.\n\nRequired privilege: User.Allocate", "operationId": "delete_user_v3_access_users__user_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/users/{user_id}/groups": {"get": {"tags": ["Users"], "summary": "Get User Memberships", "description": "Get user memberships.\n\nRequired privilege: Group.Audit", "operationId": "get_user_memberships_v3_access_users__user_id__groups_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/UserGroup"}, "title": "Response Get User Memberships V3 Access Users User Id Groups Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/groups": {"get": {"tags": ["Users groups"], "summary": "Get User Groups", "description": "Get all user groups.\n\nRequired privilege: Group.Audit", "operationId": "get_user_groups_v3_access_groups_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/UserGroup"}, "type": "array", "title": "Response Get User Groups V3 Access Groups Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "post": {"tags": ["Users groups"], "summary": "Create User Group", "description": "Create a new user group.\n\nRequired privilege: Group.Allocate", "operationId": "create_user_group_v3_access_groups_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserGroupCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserGroup"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/access/groups/{user_group_id}": {"get": {"tags": ["Users groups"], "summary": "Get User Group", "description": "Get a user group.\n\nRequired privilege: Group.Audit", "operationId": "get_user_group_v3_access_groups__user_group_id__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_group_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserGroup"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Users groups"], "summary": "Update User Group", "description": "Update a user group.\n\nRequired privilege: Group.Modify", "operationId": "update_user_group_v3_access_groups__user_group_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_group_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Group Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserGroupUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserGroup"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Users groups"], "summary": "Delete User Group", "description": "Delete a user group.\n\nRequired privilege: Group.Allocate", "operationId": "delete_user_group_v3_access_groups__user_group_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_group_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Group Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/groups/{user_group_id}/members": {"get": {"tags": ["Users groups"], "summary": "Get User Group Members", "description": "Get all user group members.\n\nRequired privilege: Group.Audit", "operationId": "get_user_group_members_v3_access_groups__user_group_id__members_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_group_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/User"}, "title": "Response Get User Group Members V3 Access Groups User Group Id Members Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/groups/{user_group_id}/members/{user_id}": {"put": {"tags": ["Users groups"], "summary": "Add Member To Group", "description": "Add member to a user group.\n\nRequired privilege: Group.Modify", "operationId": "add_member_to_group_v3_access_groups__user_group_id__members__user_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_group_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Group Id"}}, {"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Users groups"], "summary": "Remove Member From Group", "description": "Remove member from a user group.\n\nRequired privilege: Group.Modify", "operationId": "remove_member_from_group_v3_access_groups__user_group_id__members__user_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_group_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Group Id"}}, {"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/roles": {"get": {"tags": ["Roles"], "summary": "Get Roles", "description": "Get all roles.\n\nRequired privilege: Role.Audit", "operationId": "get_roles_v3_access_roles_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/Role"}, "type": "array", "title": "Response Get Roles V3 Access Roles Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "post": {"tags": ["Roles"], "summary": "Create Role", "description": "Create a new role.\n\nRequired privilege: Role.Allocate", "operationId": "create_role_v3_access_roles_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RoleCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Role"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/access/roles/{role_id}": {"get": {"tags": ["Roles"], "summary": "Get Role", "description": "Get a role.\n\nRequired privilege: Role.Audit", "operationId": "get_role_v3_access_roles__role_id__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "role_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Role Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Role"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Roles"], "summary": "Update Role", "description": "Update a role.\n\nRequired privilege: Role.Modify", "operationId": "update_role_v3_access_roles__role_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "role_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Role Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RoleUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Role"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Roles"], "summary": "Delete Role", "description": "Delete a role.\n\nRequired privilege: Role.Allocate", "operationId": "delete_role_v3_access_roles__role_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "role_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Role Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/roles/{role_id}/privileges": {"get": {"tags": ["Roles"], "summary": "Get Role Privileges", "description": "Get all role privileges.\n\nRequired privilege: Role.Audit", "operationId": "get_role_privileges_v3_access_roles__role_id__privileges_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "role_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Role Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Privilege"}, "title": "Response Get Role Privileges V3 Access Roles Role Id Privileges Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/roles/{role_id}/privileges/{privilege_id}": {"put": {"tags": ["Roles"], "summary": "Add Privilege To Role", "description": "Add a privilege to a role.\n\nRequired privilege: Role.Modify", "operationId": "add_privilege_to_role_v3_access_roles__role_id__privileges__privilege_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "role_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Role Id"}}, {"name": "privilege_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Privilege Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Roles"], "summary": "Remove Privilege From Role", "description": "Remove privilege from a role.\n\nRequired privilege: Role.Modify", "operationId": "remove_privilege_from_role_v3_access_roles__role_id__privileges__privilege_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "role_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Role Id"}}, {"name": "privilege_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Privilege Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/privileges": {"get": {"tags": ["Privileges"], "summary": "Get Privileges", "description": "Get all privileges.\n\nRequired privilege: None", "operationId": "get_privileges_v3_access_privileges_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/Privilege"}, "type": "array", "title": "Response Get Privileges V3 Access Privileges Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/access/acl/endpoints": {"get": {"tags": ["ACL"], "summary": "Endpoints", "description": "List all endpoints to be used in ACL entries.", "operationId": "endpoints_v3_access_acl_endpoints_get", "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"additionalProperties": true, "type": "object"}, "type": "array", "title": "Response Endpoints V3 Access Acl Endpoints Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/access/acl": {"get": {"tags": ["ACL"], "summary": "Get Aces", "description": "Get all ACL entries.\n\nRequired privilege: ACE.Audit", "operationId": "get_aces_v3_access_acl_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ACE"}, "type": "array", "title": "Response Get Aces V3 Access Acl Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "post": {"tags": ["ACL"], "summary": "Create Ace", "description": "Create a new ACL entry.\n\nRequired privilege: ACE.Allocate", "operationId": "create_ace_v3_access_acl_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ACECreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ACE"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/access/acl/{ace_id}": {"get": {"tags": ["ACL"], "summary": "Get Ace", "description": "Get an ACL entry.\n\nRequired privilege: ACE.Audit", "operationId": "get_ace_v3_access_acl__ace_id__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "ace_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Ace Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ACE"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["ACL"], "summary": "Update Ace", "description": "Update an ACL entry.\n\nRequired privilege: ACE.Modify", "operationId": "update_ace_v3_access_acl__ace_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "ace_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Ace Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ACEUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ACE"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["ACL"], "summary": "Delete Ace", "description": "Delete an ACL entry.\n\nRequired privilege: ACE.Allocate", "operationId": "delete_ace_v3_access_acl__ace_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "ace_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Ace Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/images/qemu/{image_path}": {"post": {"tags": ["Images"], "summary": "Create Qemu Image", "description": "Create a new blank Qemu image.\n\nRequired privilege: Image.Allocate", "operationId": "create_qemu_image_v3_images_qemu__image_path__post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "image_path", "in": "path", "required": true, "schema": {"type": "string", "title": "Image Path"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QemuDiskImageCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Image"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/images": {"get": {"tags": ["Images"], "summary": "Get Images", "description": "Return all images.\n\nRequired privilege: Image.Audit", "operationId": "get_images_v3_images_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "image_type", "in": "query", "required": false, "schema": {"anyOf": [{"$ref": "#/components/schemas/ImageType"}, {"type": "null"}], "title": "Image Type"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Image"}, "title": "Response Get Images V3 Images Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/images/upload/{image_path}": {"post": {"tags": ["Images"], "summary": "Upload Image", "description": "Upload an image.\n\nExample: curl -X POST http://host:port/v3/images/upload/my_image_name.qcow2 -H 'Authorization: Bearer ' --data-binary @\"/path/to/image.qcow2\"\n\nRequired privilege: Image.Allocate", "operationId": "upload_image_v3_images_upload__image_path__post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "image_path", "in": "path", "required": true, "schema": {"type": "string", "title": "Image Path"}}, {"name": "install_appliances", "in": "query", "required": false, "schema": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "title": "Install Appliances"}}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Image"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/images/prune": {"delete": {"tags": ["Images"], "summary": "Prune Images", "description": "Prune images not attached to any template.\n\nRequired privilege: Image.Allocate", "operationId": "prune_images_v3_images_prune_delete", "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/images/install": {"post": {"tags": ["Images"], "summary": "Install Images", "description": "Attempt to automatically create templates based on image checksums.\n\nRequired privilege: Image.Allocate", "operationId": "install_images_v3_images_install_post", "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/images/{image_path}": {"get": {"tags": ["Images"], "summary": "Get Image", "description": "Return an image.\n\nRequired privilege: Image.Audit", "operationId": "get_image_v3_images__image_path__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "image_path", "in": "path", "required": true, "schema": {"type": "string", "title": "Image Path"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Image"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Images"], "summary": "Delete Image", "description": "Delete an image.\n\nRequired privilege: Image.Allocate", "operationId": "delete_image_v3_images__image_path__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "image_path", "in": "path", "required": true, "schema": {"type": "string", "title": "Image Path"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/templates": {"post": {"tags": ["Templates"], "summary": "Create Template", "description": "Create a new template.\n\nRequired privilege: Template.Allocate", "operationId": "create_template_v3_templates_post", "security": [{"OAuth2PasswordBearer": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TemplateCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Template"}}}}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["Templates"], "summary": "Get Templates", "description": "Return all templates.\n\nRequired privilege: Template.Audit\n\nQuery Parameters:\n- tags: Filter by tags. Multiple tags are ANDed together.\n Example: ?tags=vendor:cisco&tags=model:7200", "operationId": "get_templates_v3_templates_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "tags", "in": "query", "required": false, "schema": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}], "description": "Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)", "title": "Tags"}, "description": "Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Template"}, "title": "Response Get Templates V3 Templates Get"}}}}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/templates/{template_id}": {"get": {"tags": ["Templates"], "summary": "Get Template", "description": "Return a template.\n\nRequired privilege: Template.Audit", "operationId": "get_template_v3_templates__template_id__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "template_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Template Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Template"}}}}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Templates"], "summary": "Update Template", "description": "Update a template.\n\nRequired privilege: Template.Modify", "operationId": "update_template_v3_templates__template_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "template_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Template Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TemplateUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Template"}}}}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Templates"], "summary": "Delete Template", "description": "Delete a template.\n\nRequired privilege: Template.Allocate", "operationId": "delete_template_v3_templates__template_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "template_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Template Id"}}, {"name": "prune_images", "in": "query", "required": false, "schema": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "title": "Prune Images"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/templates/{template_id}/duplicate": {"post": {"tags": ["Templates"], "summary": "Duplicate Template", "description": "Duplicate a template.\n\nRequired privilege: Template.Allocate", "operationId": "duplicate_template_v3_templates__template_id__duplicate_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "template_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Template Id"}}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Template"}}}}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/templates/{template_id}/base-config/{filename}": {"get": {"tags": ["Templates"], "summary": "Get Base Config", "operationId": "get_base_config_v3_templates__template_id__base_config__filename__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "template_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Template Id"}}, {"name": "filename", "in": "path", "required": true, "schema": {"type": "string", "title": "Filename"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Templates"], "summary": "Update Base Config", "operationId": "update_base_config_v3_templates__template_id__base_config__filename__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "template_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Template Id"}}, {"name": "filename", "in": "path", "required": true, "schema": {"type": "string", "title": "Filename"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Body"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/templates/{template_id}/base-configs": {"get": {"tags": ["Templates"], "summary": "List Base Configs", "operationId": "list_base_configs_v3_templates__template_id__base_configs_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "template_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Template Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects": {"get": {"tags": ["Projects"], "summary": "Get Projects", "description": "Return all projects.\n\nRequired privilege: Project.Audit", "operationId": "get_projects_v3_projects_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/Project"}, "type": "array", "title": "Response Get Projects V3 Projects Get"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "post": {"tags": ["Projects"], "summary": "Create Project", "description": "Create a new project.\n\nRequired privilege: Project.Allocate", "operationId": "create_project_v3_projects_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not create project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/projects/{project_id}": {"get": {"tags": ["Projects"], "summary": "Get Project", "description": "Return a project.\n\nRequired privilege: Project.Audit", "operationId": "get_project_v3_projects__project_id__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Projects"], "summary": "Update Project", "description": "Update a project.\n\nRequired privilege: Project.Modify", "operationId": "update_project_v3_projects__project_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Projects"], "summary": "Delete Project", "description": "Delete a project.\n\nRequired privilege: Project.Allocate", "operationId": "delete_project_v3_projects__project_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/stats": {"get": {"tags": ["Projects"], "summary": "Get Project Stats", "description": "Return a project statistics.\n\nRequired privilege: Project.Audit", "operationId": "get_project_stats_v3_projects__project_id__stats_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Get Project Stats V3 Projects Project Id Stats Get"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/markers": {"get": {"tags": ["Projects"], "summary": "Get Project Markers", "description": "Return all traffic-insight markers across every link in the project.\n\nEach entry is keyed ``\"{link_id}/{marker_name}\"`` and carries the\nmarker's BPF, tag, color, enabled flag, plus its parent ``link_id``\nand capture-side ``node_id`` for frontend filtering / grouping.\n\nRequired privilege: Project.Audit", "operationId": "get_project_markers_v3_projects__project_id__markers_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Get Project Markers V3 Projects Project Id Markers Get"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/marker-definitions": {"get": {"tags": ["Projects"], "summary": "Get Marker Definitions", "description": "Return all project-level marker definitions with their bound link IDs.\n\nRequired privilege: Project.Audit", "operationId": "get_marker_definitions_v3_projects__project_id__marker_definitions_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Get Marker Definitions V3 Projects Project Id Marker Definitions Get"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Projects"], "summary": "Create Marker Definition", "description": "Create a project-level marker definition and fan out to every link.\n\nRequired privilege: Project.Modify", "operationId": "create_marker_definition_v3_projects__project_id__marker_definitions_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MarkerDefinitionCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Create Marker Definition V3 Projects Project Id Marker Definitions Post"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/marker-definitions/{def_name}": {"put": {"tags": ["Projects"], "summary": "Update Marker Definition", "description": "Update a marker definition and sync all inherited copies on every link.\n\nRequired privilege: Project.Modify", "operationId": "update_marker_definition_v3_projects__project_id__marker_definitions__def_name__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "def_name", "in": "path", "required": true, "schema": {"type": "string", "title": "Def Name"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MarkerDefinitionCreate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Update Marker Definition V3 Projects Project Id Marker Definitions Def Name Put"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Projects"], "summary": "Delete Marker Definition", "description": "Delete a marker definition and remove all inherited copies from every link.\n\nRequired privilege: Project.Modify", "operationId": "delete_marker_definition_v3_projects__project_id__marker_definitions__def_name__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "def_name", "in": "path", "required": true, "schema": {"type": "string", "title": "Def Name"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/marker-definitions/{def_name}/pause": {"post": {"tags": ["Projects"], "summary": "Pause Marker Definition", "description": "Pause a definition: toggle off every inherited ``global-{def_name}`` copy\non every link (uBridge ``enable_packet_filter off``, instant \u2014 no NIO\nrebuild). The definition's ``paused`` flag is persisted, so links created\nlater inherit it already paused.\n\nRequired privilege: Project.Modify", "operationId": "pause_marker_definition_v3_projects__project_id__marker_definitions__def_name__pause_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "def_name", "in": "path", "required": true, "schema": {"type": "string", "title": "Def Name"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/marker-definitions/{def_name}/resume": {"post": {"tags": ["Projects"], "summary": "Resume Marker Definition", "description": "Resume a paused definition (toggle on every inherited copy).\n\nRequired privilege: Project.Modify", "operationId": "resume_marker_definition_v3_projects__project_id__marker_definitions__def_name__resume_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "def_name", "in": "path", "required": true, "schema": {"type": "string", "title": "Def Name"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/close": {"post": {"tags": ["Projects"], "summary": "Close Project", "description": "Close a project.\n\nRequired privilege: Project.Allocate", "operationId": "close_project_v3_projects__project_id__close_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not close project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/open": {"post": {"tags": ["Projects"], "summary": "Open Project", "description": "Open a project.\n\nRequired privilege: Project.Allocate", "operationId": "open_project_v3_projects__project_id__open_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not open project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/load": {"post": {"tags": ["Projects"], "summary": "Load Project", "description": "Load a project (local server only).\n\nRequired privilege: Project.Allocate", "operationId": "load_project_v3_projects_load_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_load_project_v3_projects_load_post"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not load project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/projects/{project_id}/notifications": {"get": {"tags": ["Projects"], "summary": "Project Http Notifications", "description": "Receive project notifications about the controller from HTTP stream.\n\nRequired privilege: Project.Audit", "operationId": "project_http_notifications_v3_projects__project_id__notifications_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/export": {"get": {"tags": ["Projects"], "summary": "Export Project", "description": "Export a project as a portable archive.\n\nRequired privilege: Project.Audit", "operationId": "export_project_v3_projects__project_id__export_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "include_snapshots", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Include Snapshots"}}, {"name": "include_images", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Include Images"}}, {"name": "reset_mac_addresses", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Reset Mac Addresses"}}, {"name": "keep_compute_ids", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Keep Compute Ids"}}, {"name": "compression", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/ProjectCompression", "default": "zstd"}}, {"name": "compression_level", "in": "query", "required": false, "schema": {"type": "integer", "title": "Compression Level"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/import": {"post": {"tags": ["Projects"], "summary": "Import Project", "description": "Import a project from a portable archive.\n\nRequired privilege: Project.Allocate", "operationId": "import_project_v3_projects__project_id__import_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "name", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/duplicate": {"post": {"tags": ["Projects"], "summary": "Duplicate Project", "description": "Duplicate a project.\n\nRequired privilege: Project.Audit", "operationId": "duplicate_project_v3_projects__project_id__duplicate_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectDuplicate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not duplicate project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/locked": {"get": {"tags": ["Projects"], "summary": "Locked Project", "description": "Returns whether a project is locked or not.\n\nRequired privilege: Project.Audit", "operationId": "locked_project_v3_projects__project_id__locked_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "boolean", "title": "Response Locked Project V3 Projects Project Id Locked Get"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/lock": {"post": {"tags": ["Projects"], "summary": "Lock Project", "description": "Lock all drawings and nodes in a given project.\n\nRequired privilege: Project.Audit", "operationId": "lock_project_v3_projects__project_id__lock_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/unlock": {"post": {"tags": ["Projects"], "summary": "Unlock Project", "description": "Unlock all drawings and nodes in a given project.\n\nRequired privilege: Project.Modify", "operationId": "unlock_project_v3_projects__project_id__unlock_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/files/{file_path}": {"get": {"tags": ["Projects"], "summary": "Get File", "description": "Return a file from a project.\n\nRequired privilege: Project.Audit", "operationId": "get_file_v3_projects__project_id__files__file_path__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "file_path", "in": "path", "required": true, "schema": {"type": "string", "title": "File Path"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Projects"], "summary": "Write File", "description": "Write a file to a project.\n\nRequired privilege: Project.Modify", "operationId": "write_file_v3_projects__project_id__files__file_path__post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "file_path", "in": "path", "required": true, "schema": {"type": "string", "title": "File Path"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/templates/{template_id}": {"post": {"tags": ["Projects"], "summary": "Create Node From Template", "description": "Create a new node from a template.\n\nRequired privilege: Node.Allocate", "operationId": "create_node_from_template_v3_projects__project_id__templates__template_id__post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "template_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Template Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TemplateUsage"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Node"}}}}, "404": {"description": "Could not find project or template", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes": {"post": {"tags": ["Nodes"], "summary": "Create Node", "description": "Create a new node.\n\nRequired privilege: Node.Allocate", "operationId": "create_node_v3_projects__project_id__nodes_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NodeCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Node"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not create node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["Nodes"], "summary": "Get Nodes", "description": "Return all nodes belonging to a given project.\n\nRequired privilege: Node.Audit\n\nQuery Parameters:\n- tags: Filter by tags. Multiple tags are ANDed together.\n Example: ?tags=vendor:cisco&tags=model:7200", "operationId": "get_nodes_v3_projects__project_id__nodes_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "tags", "in": "query", "required": false, "schema": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}], "description": "Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)", "title": "Tags"}, "description": "Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Node"}, "title": "Response Get Nodes V3 Projects Project Id Nodes Get"}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/start": {"post": {"tags": ["Nodes"], "summary": "Start All Nodes", "description": "Start all nodes belonging to a given project.\n\nRequired privilege: Node.PowerMgmt", "operationId": "start_all_nodes_v3_projects__project_id__nodes_start_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/stop": {"post": {"tags": ["Nodes"], "summary": "Stop All Nodes", "description": "Stop all nodes belonging to a given project.\n\nRequired privilege: Node.PowerMgmt", "operationId": "stop_all_nodes_v3_projects__project_id__nodes_stop_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/suspend": {"post": {"tags": ["Nodes"], "summary": "Suspend All Nodes", "description": "Suspend all nodes belonging to a given project.\n\nRequired privilege: Node.PowerMgmt", "operationId": "suspend_all_nodes_v3_projects__project_id__nodes_suspend_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/reload": {"post": {"tags": ["Nodes"], "summary": "Reload All Nodes", "description": "Reload all nodes belonging to a given project.\n\nRequired privilege: Node.PowerMgmt", "operationId": "reload_all_nodes_v3_projects__project_id__nodes_reload_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}": {"get": {"tags": ["Nodes"], "summary": "Get Node", "description": "Return a node from a given project.\n\nRequired privilege: Node.Audit", "operationId": "get_node_v3_projects__project_id__nodes__node_id__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Node"}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Nodes"], "summary": "Update Node", "description": "Update a node.\n\nRequired privilege: Node.Modify", "operationId": "update_node_v3_projects__project_id__nodes__node_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NodeUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Node"}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Nodes"], "summary": "Delete Node", "description": "Delete a node from a project.\n\nRequired privilege: Node.Allocate", "operationId": "delete_node_v3_projects__project_id__nodes__node_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Cannot delete node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/duplicate": {"post": {"tags": ["Nodes"], "summary": "Duplicate Node", "description": "Duplicate a node.\n\nRequired privilege: Node.Allocate", "operationId": "duplicate_node_v3_projects__project_id__nodes__node_id__duplicate_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NodeDuplicate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Node"}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/start": {"post": {"tags": ["Nodes"], "summary": "Start Node", "description": "Start a node.\n\nRequired privilege: Node.PowerMgmt", "operationId": "start_node_v3_projects__project_id__nodes__node_id__start_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"content": {"application/json": {"schema": {"anyOf": [{"type": "object", "additionalProperties": true}, {"type": "null"}], "title": "Start Data"}}}}, "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/stop": {"post": {"tags": ["Nodes"], "summary": "Stop Node", "description": "Stop a node.\n\nRequired privilege: Node.PowerMgmt", "operationId": "stop_node_v3_projects__project_id__nodes__node_id__stop_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/suspend": {"post": {"tags": ["Nodes"], "summary": "Suspend Node", "description": "Suspend a node.\n\nRequired privilege: Node.PowerMgmt", "operationId": "suspend_node_v3_projects__project_id__nodes__node_id__suspend_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/reload": {"post": {"tags": ["Nodes"], "summary": "Reload Node", "description": "Reload a node.\n\nRequired privilege: Node.PowerMgmt", "operationId": "reload_node_v3_projects__project_id__nodes__node_id__reload_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/isolate": {"post": {"tags": ["Nodes"], "summary": "Isolate Node", "description": "Isolate a node (suspend all attached links).\n\nRequired privilege: Link.Modify", "operationId": "isolate_node_v3_projects__project_id__nodes__node_id__isolate_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/unisolate": {"post": {"tags": ["Nodes"], "summary": "Unisolate Node", "description": "Un-isolate a node (resume all attached suspended links).\n\nRequired privilege: Link.Modify", "operationId": "unisolate_node_v3_projects__project_id__nodes__node_id__unisolate_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/links": {"get": {"tags": ["Nodes"], "summary": "Get Node Links", "description": "Return all the links connected to a node.\n\nRequired privilege: Link.Audit", "operationId": "get_node_links_v3_projects__project_id__nodes__node_id__links_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Link"}, "title": "Response Get Node Links V3 Projects Project Id Nodes Node Id Links Get"}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/dynamips/auto_idlepc": {"get": {"tags": ["Nodes"], "summary": "Auto Idlepc", "description": "Compute an Idle-PC value for a Dynamips node\n\nRequired privilege: Node.Audit", "operationId": "auto_idlepc_v3_projects__project_id__nodes__node_id__dynamips_auto_idlepc_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Auto Idlepc V3 Projects Project Id Nodes Node Id Dynamips Auto Idlepc Get"}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/dynamips/idlepc_proposals": {"get": {"tags": ["Nodes"], "summary": "Idlepc Proposals", "description": "Compute a list of potential idle-pc values for a Dynamips node\n\nRequired privilege: Node.Audit", "operationId": "idlepc_proposals_v3_projects__project_id__nodes__node_id__dynamips_idlepc_proposals_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}, "title": "Response Idlepc Proposals V3 Projects Project Id Nodes Node Id Dynamips Idlepc Proposals Get"}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/qemu/disk_image/{disk_name}": {"post": {"tags": ["Nodes"], "summary": "Create Disk Image", "description": "Create a Qemu disk image.\n\nRequired privilege: Node.Allocate", "operationId": "create_disk_image_v3_projects__project_id__nodes__node_id__qemu_disk_image__disk_name__post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "disk_name", "in": "path", "required": true, "schema": {"type": "string", "title": "Disk Name"}}, {"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QemuDiskImageCreate"}}}}, "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Nodes"], "summary": "Update Disk Image", "description": "Update a Qemu disk image.\n\nRequired privilege: Node.Allocate", "operationId": "update_disk_image_v3_projects__project_id__nodes__node_id__qemu_disk_image__disk_name__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "disk_name", "in": "path", "required": true, "schema": {"type": "string", "title": "Disk Name"}}, {"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QemuDiskImageUpdate"}}}}, "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Nodes"], "summary": "Delete Disk Image", "description": "Delete a Qemu disk image.\n\nRequired privilege: Node.Allocate", "operationId": "delete_disk_image_v3_projects__project_id__nodes__node_id__qemu_disk_image__disk_name__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "disk_name", "in": "path", "required": true, "schema": {"type": "string", "title": "Disk Name"}}, {"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/files": {"get": {"tags": ["Nodes"], "summary": "List Node Files", "description": "List files in a node directory with detailed metadata.\n\nBy default lists only the current directory level (non-recursive).\nUse recursive=true for a full recursive listing.\n\nRequired privilege: Node.Audit", "operationId": "list_node_files_v3_projects__project_id__nodes__node_id__files_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "path", "in": "query", "required": false, "schema": {"type": "string", "description": "Subdirectory path within node directory", "default": "", "title": "Path"}, "description": "Subdirectory path within node directory"}, {"name": "recursive", "in": "query", "required": false, "schema": {"type": "boolean", "description": "Recursively list all files", "default": false, "title": "Recursive"}, "description": "Recursively list all files"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/NodeFile"}, "title": "Response List Node Files V3 Projects Project Id Nodes Node Id Files Get"}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/files/{file_path}": {"get": {"tags": ["Nodes"], "summary": "Get File", "description": "Return a file from the node directory.\n\nRequired privilege: Node.Audit", "operationId": "get_file_v3_projects__project_id__nodes__node_id__files__file_path__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "file_path", "in": "path", "required": true, "schema": {"type": "string", "title": "File Path"}}, {"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Nodes"], "summary": "Post File", "description": "Write a file in the node directory.\n\nRequired privilege: Node.Modify", "operationId": "post_file_v3_projects__project_id__nodes__node_id__files__file_path__post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "file_path", "in": "path", "required": true, "schema": {"type": "string", "title": "File Path"}}, {"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Nodes"], "summary": "Delete Node File", "description": "Delete a file from the node directory.\n\nRequired privilege: Node.Modify", "operationId": "delete_node_file_v3_projects__project_id__nodes__node_id__files__file_path__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "file_path", "in": "path", "required": true, "schema": {"type": "string", "title": "File Path"}}, {"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/console/reset": {"post": {"tags": ["Nodes"], "summary": "Reset Console All Nodes", "description": "Reset console for all nodes belonging to the project.\n\nRequired privilege: Node.Console", "operationId": "reset_console_all_nodes_v3_projects__project_id__nodes_console_reset_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/nodes/{node_id}/console/reset": {"post": {"tags": ["Nodes"], "summary": "Console Reset", "description": "Reset a console for a given node.\n\nRequired privilege: Node.Console", "operationId": "console_reset_v3_projects__project_id__nodes__node_id__console_reset_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Node Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or node", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/links": {"get": {"tags": ["Links"], "summary": "Get Links", "description": "Return all links for a given project.\n\nRequired privilege: Link.Audit", "operationId": "get_links_v3_projects__project_id__links_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Link"}, "title": "Response Get Links V3 Projects Project Id Links Get"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Links"], "summary": "Create Link", "description": "Create a new link.\n\nRequired privilege: Link.Allocate", "operationId": "create_link_v3_projects__project_id__links_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LinkCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "404": {"description": "Could not find project", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not create link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/links/{link_id}/available_filters": {"get": {"tags": ["Links"], "summary": "Get Filters", "description": "Return all filters available for a given link.\n\nRequired privilege: Link.Audit", "operationId": "get_filters_v3_projects__project_id__links__link_id__available_filters_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"type": "object", "additionalProperties": true}, "title": "Response Get Filters V3 Projects Project Id Links Link Id Available Filters Get"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/links/{link_id}": {"get": {"tags": ["Links"], "summary": "Get Link", "description": "Return a link.\n\nRequired privilege: Link.Audit", "operationId": "get_link_v3_projects__project_id__links__link_id__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Links"], "summary": "Update Link", "description": "Update a link.\n\nRequired privilege: Link.Modify", "operationId": "update_link_v3_projects__project_id__links__link_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LinkUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Links"], "summary": "Delete Link", "description": "Delete a link.\n\nRequired privilege: Link.Allocate", "operationId": "delete_link_v3_projects__project_id__links__link_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/links/{link_id}/reset": {"post": {"tags": ["Links"], "summary": "Reset Link", "description": "Reset a link.\n\nRequired privilege: Link.Modify", "operationId": "reset_link_v3_projects__project_id__links__link_id__reset_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/links/{link_id}/capture/start": {"post": {"tags": ["Links"], "summary": "Start Capture", "description": "Start packet capture on the link.\n\nRequired privilege: Link.Capture", "operationId": "start_capture_v3_projects__project_id__links__link_id__capture_start_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LinkCapture"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/links/{link_id}/capture/stop": {"post": {"tags": ["Links"], "summary": "Stop Capture", "description": "Stop packet capture on the link.\n\nRequired privilege: Link.Capture", "operationId": "stop_capture_v3_projects__project_id__links__link_id__capture_stop_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/links/{link_id}/capture/wireshark/restart": {"post": {"tags": ["Links"], "summary": "Restart Wireshark", "description": "Restart Wireshark window without stopping the capture.\n\nThis allows recovery after accidentally closing the Wireshark window.\n\nRequired privilege: Link.Capture", "operationId": "restart_wireshark_v3_projects__project_id__links__link_id__capture_wireshark_restart_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Restart Wireshark V3 Projects Project Id Links Link Id Capture Wireshark Restart Post"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/links/{link_id}/capture/stream": {"get": {"tags": ["Links"], "summary": "Stream Pcap", "description": "Stream the PCAP capture file from compute.\n\nRequired privilege: Link.Capture", "operationId": "stream_pcap_v3_projects__project_id__links__link_id__capture_stream_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/links/{link_id}/capture/file": {"get": {"tags": ["Links"], "summary": "Download Capture File", "description": "Download the PCAP capture file.\n\nThis endpoint allows downloading the capture file even while capture is active.\nThe file is streamed directly, so partial data may be received if capture is still running.\n\nRequired privilege: Link.Capture", "operationId": "download_capture_file_v3_projects__project_id__links__link_id__capture_file_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "responses": {"200": {"description": "Successful Response"}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/links/{link_id}/markers": {"get": {"tags": ["Links"], "summary": "Get Markers", "description": "Return all traffic-insight markers configured on this link.\n\nRequired privilege: Link.Audit", "operationId": "get_markers_v3_projects__project_id__links__link_id__markers_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Get Markers V3 Projects Project Id Links Link Id Markers Get"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Links"], "summary": "Create Marker", "description": "Attach a traffic-insight marker to the link.\nOn BPF match uBridge emits MARK signals and appends packets to a pcap.\n\nRequired privilege: Link.Modify", "operationId": "create_marker_v3_projects__project_id__links__link_id__markers_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MarkerCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Create Marker V3 Projects Project Id Links Link Id Markers Post"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/links/{link_id}/markers/{marker_name}": {"delete": {"tags": ["Links"], "summary": "Delete Marker", "description": "Remove a traffic-insight marker from the link.\n\nRequired privilege: Link.Modify", "operationId": "delete_marker_v3_projects__project_id__links__link_id__markers__marker_name__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "marker_name", "in": "path", "required": true, "schema": {"type": "string", "title": "Marker Name"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Links"], "summary": "Update Marker", "description": "Update a traffic-insight marker (change BPF, tag, or enabled).\n\nRequired privilege: Link.Modify", "operationId": "update_marker_v3_projects__project_id__links__link_id__markers__marker_name__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "marker_name", "in": "path", "required": true, "schema": {"type": "string", "title": "Marker Name"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MarkerUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Update Marker V3 Projects Project Id Links Link Id Markers Marker Name Put"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/links/{link_id}/iface": {"get": {"tags": ["Links"], "summary": "Get Iface", "description": "Return iface info for links to Cloud or NAT devices.\n\nRequired privilege: Link.Audit", "operationId": "get_iface_v3_projects__project_id__links__link_id__iface_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "link_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Link Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/UDPPortInfo"}, {"$ref": "#/components/schemas/EthernetPortInfo"}], "title": "Response Get Iface V3 Projects Project Id Links Link Id Iface Get"}}}}, "404": {"description": "Could not find project or link", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/drawings": {"get": {"tags": ["Drawings"], "summary": "Get Drawings", "description": "Return the list of all drawings for a given project.\n\nRequired privilege: Drawing.Audit", "operationId": "get_drawings_v3_projects__project_id__drawings_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Drawing"}, "title": "Response Get Drawings V3 Projects Project Id Drawings Get"}}}}, "404": {"description": "Project or drawing not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Drawings"], "summary": "Create Drawing", "description": "Create a new drawing.\n\nRequired privilege: Drawing.Allocate", "operationId": "create_drawing_v3_projects__project_id__drawings_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Drawing"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Drawing"}}}}, "404": {"description": "Project or drawing not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/drawings/{drawing_id}": {"get": {"tags": ["Drawings"], "summary": "Get Drawing", "description": "Return a drawing.\n\nRequired privilege: Drawing.Audit", "operationId": "get_drawing_v3_projects__project_id__drawings__drawing_id__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "drawing_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Drawing Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Drawing"}}}}, "404": {"description": "Project or drawing not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Drawings"], "summary": "Update Drawing", "description": "Update a drawing.\n\nRequired privilege: Drawing.Modify", "operationId": "update_drawing_v3_projects__project_id__drawings__drawing_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "drawing_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Drawing Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Drawing"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Drawing"}}}}, "404": {"description": "Project or drawing not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Drawings"], "summary": "Delete Drawing", "description": "Delete a drawing.\n\nRequired privilege: Drawing.Allocate", "operationId": "delete_drawing_v3_projects__project_id__drawings__drawing_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "drawing_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Drawing Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Project or drawing not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/symbols": {"get": {"tags": ["Symbols"], "summary": "Get Symbols", "description": "Return all symbols.\n\nRequired privilege: Symbol.Audit", "operationId": "get_symbols_v3_symbols_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"additionalProperties": true, "type": "object"}, "type": "array", "title": "Response Get Symbols V3 Symbols Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/symbols/{symbol_id}/raw": {"get": {"tags": ["Symbols"], "summary": "Get Symbol", "description": "Download a symbol file.\n\nRequired privilege: Symbol.Audit", "operationId": "get_symbol_v3_symbols__symbol_id__raw_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "symbol_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Symbol Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Could not find symbol", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Symbols"], "summary": "Upload Symbol", "description": "Upload a symbol file.\n\nRequired privilege: Symbol.Allocate", "operationId": "upload_symbol_v3_symbols__symbol_id__raw_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "symbol_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Symbol Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/symbols/{symbol_id}/dimensions": {"get": {"tags": ["Symbols"], "summary": "Get Symbol Dimensions", "description": "Get a symbol dimensions.\n\nRequired privilege: Symbol.Audit", "operationId": "get_symbol_dimensions_v3_symbols__symbol_id__dimensions_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "symbol_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Symbol Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Get Symbol Dimensions V3 Symbols Symbol Id Dimensions Get"}}}}, "404": {"description": "Could not find symbol", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/symbols/default_symbols": {"get": {"tags": ["Symbols"], "summary": "Get Default Symbols", "description": "Return all default symbols.\n\nRequired privilege: Symbol.Audit", "operationId": "get_default_symbols_v3_symbols_default_symbols_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"additionalProperties": true, "type": "object", "title": "Response Get Default Symbols V3 Symbols Default Symbols Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/symbols/{symbol_id}": {"delete": {"tags": ["Symbols"], "summary": "Delete Symbol", "description": "Delete a custom symbol file.\n\nRequired privilege: Symbol.Allocate", "operationId": "delete_symbol_v3_symbols__symbol_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "symbol_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Symbol Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/snapshots": {"post": {"tags": ["Snapshots"], "summary": "Create Snapshot", "description": "Create a new snapshot of a project.\n\nRequired privilege: Snapshot.Allocate", "operationId": "create_snapshot_v3_projects__project_id__snapshots_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SnapshotCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Snapshot"}}}}, "404": {"description": "Could not find project or snapshot", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["Snapshots"], "summary": "Get Snapshots", "description": "Return all snapshots belonging to a given project.\n\nRequired privilege: Snapshot.Audit", "operationId": "get_snapshots_v3_projects__project_id__snapshots_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Snapshot"}, "title": "Response Get Snapshots V3 Projects Project Id Snapshots Get"}}}}, "404": {"description": "Could not find project or snapshot", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/snapshots/{snapshot_id}": {"delete": {"tags": ["Snapshots"], "summary": "Delete Snapshot", "description": "Delete a snapshot.\n\nRequired privilege: Snapshot.Allocate", "operationId": "delete_snapshot_v3_projects__project_id__snapshots__snapshot_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "snapshot_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Snapshot Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Could not find project or snapshot", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/projects/{project_id}/snapshots/{snapshot_id}/restore": {"post": {"tags": ["Snapshots"], "summary": "Restore Snapshot", "description": "Restore a snapshot.\n\nRequired privilege: Snapshot.Restore", "operationId": "restore_snapshot_v3_projects__project_id__snapshots__snapshot_id__restore_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "snapshot_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Snapshot Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}}, "404": {"description": "Could not find project or snapshot", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/computes": {"post": {"tags": ["Computes"], "summary": "Create Compute", "description": "Create a new compute on the controller.\n\nRequired privilege: Compute.Allocate", "operationId": "create_compute_v3_computes_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "connect", "in": "query", "required": false, "schema": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "title": "Connect"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ComputeCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Compute"}}}}, "404": {"description": "Could not connect to compute", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "409": {"description": "Could not create compute", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "401": {"description": "Invalid authentication for compute", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["Computes"], "summary": "Get Computes", "description": "Return all computes known by the controller.\n\nRequired privilege: Compute.Audit", "operationId": "get_computes_v3_computes_get", "security": [{"OAuth2PasswordBearer": []}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Compute"}, "title": "Response Get Computes V3 Computes Get"}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/computes/{compute_id}/connect": {"post": {"tags": ["Computes"], "summary": "Connect Compute", "description": "Connect to compute on the controller.\n\nRequired privilege: Compute.Audit", "operationId": "connect_compute_v3_computes__compute_id__connect_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "compute_id", "in": "path", "required": true, "schema": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/computes/{compute_id}": {"get": {"tags": ["Computes"], "summary": "Get Compute", "description": "Return a compute from the controller.\n\nRequired privilege: Compute.Audit", "operationId": "get_compute_v3_computes__compute_id__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "compute_id", "in": "path", "required": true, "schema": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Compute"}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Computes"], "summary": "Update Compute", "description": "Update a compute on the controller.\n\nRequired privilege: Compute.Modify", "operationId": "update_compute_v3_computes__compute_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "compute_id", "in": "path", "required": true, "schema": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ComputeUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Compute"}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Computes"], "summary": "Delete Compute", "description": "Delete a compute from the controller.\n\nRequired privilege: Compute.Allocate", "operationId": "delete_compute_v3_computes__compute_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "compute_id", "in": "path", "required": true, "schema": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/computes/{compute_id}/docker/images": {"get": {"tags": ["Computes"], "summary": "Docker Get Images", "description": "Get Docker images from a compute.", "operationId": "docker_get_images_v3_computes__compute_id__docker_images_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "compute_id", "in": "path", "required": true, "schema": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/ComputeDockerImage"}, "title": "Response Docker Get Images V3 Computes Compute Id Docker Images Get"}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/computes/{compute_id}/docker/images/pull": {"post": {"tags": ["Computes"], "summary": "Docker Pull Image", "description": "Pull or update a Docker image on a compute.\n\nRequired privilege: Compute.Modify", "operationId": "docker_pull_image_v3_computes__compute_id__docker_images_pull_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "compute_id", "in": "path", "required": true, "schema": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Body_docker_pull_image_v3_computes__compute_id__docker_images_pull_post"}}}}, "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/computes/{compute_id}/virtualbox/vms": {"get": {"tags": ["Computes"], "summary": "Virtualbox Vms", "description": "Get VirtualBox VMs from a compute.", "operationId": "virtualbox_vms_v3_computes__compute_id__virtualbox_vms_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "compute_id", "in": "path", "required": true, "schema": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/ComputeVirtualBoxVM"}, "title": "Response Virtualbox Vms V3 Computes Compute Id Virtualbox Vms Get"}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/computes/{compute_id}/vmware/vms": {"get": {"tags": ["Computes"], "summary": "Vmware Vms", "description": "Get VMware VMs from a compute.", "operationId": "vmware_vms_v3_computes__compute_id__vmware_vms_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "compute_id", "in": "path", "required": true, "schema": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/ComputeVMwareVM"}, "title": "Response Vmware Vms V3 Computes Compute Id Vmware Vms Get"}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/computes/{compute_id}/dynamips/auto_idlepc": {"post": {"tags": ["Computes"], "summary": "Dynamips Autoidlepc", "description": "Find a suitable Idle-PC value for a given IOS image. This may take a few minutes.", "operationId": "dynamips_autoidlepc_v3_computes__compute_id__dynamips_auto_idlepc_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "compute_id", "in": "path", "required": true, "schema": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AutoIdlePC"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/computes/{compute_id}/{emulator}/{endpoint_path}": {"get": {"tags": ["Computes"], "summary": "Forward Get", "description": "Forward a GET request to a compute.\nRead the full compute API documentation for available routes.", "operationId": "forward_get_v3_computes__compute_id___emulator___endpoint_path__get", "deprecated": true, "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "compute_id", "in": "path", "required": true, "schema": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}, {"name": "emulator", "in": "path", "required": true, "schema": {"type": "string", "title": "Emulator"}}, {"name": "endpoint_path", "in": "path", "required": true, "schema": {"type": "string", "title": "Endpoint Path"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Forward Get V3 Computes Compute Id Emulator Endpoint Path Get"}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["Computes"], "summary": "Forward Post", "description": "Forward a POST request to a compute.\nRead the full compute API documentation for available routes.", "operationId": "forward_post_v3_computes__compute_id___emulator___endpoint_path__post", "deprecated": true, "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "compute_id", "in": "path", "required": true, "schema": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}, {"name": "emulator", "in": "path", "required": true, "schema": {"type": "string", "title": "Emulator"}}, {"name": "endpoint_path", "in": "path", "required": true, "schema": {"type": "string", "title": "Endpoint Path"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Compute Data"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Forward Post V3 Computes Compute Id Emulator Endpoint Path Post"}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Computes"], "summary": "Forward Put", "description": "Forward a PUT request to a compute.\nRead the full compute API documentation for available routes.", "operationId": "forward_put_v3_computes__compute_id___emulator___endpoint_path__put", "deprecated": true, "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "compute_id", "in": "path", "required": true, "schema": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}, {"name": "emulator", "in": "path", "required": true, "schema": {"type": "string", "title": "Emulator"}}, {"name": "endpoint_path", "in": "path", "required": true, "schema": {"type": "string", "title": "Endpoint Path"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Compute Data"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Forward Put V3 Computes Compute Id Emulator Endpoint Path Put"}}}}, "404": {"description": "Compute not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/appliances": {"get": {"tags": ["Appliances"], "summary": "Get Appliances", "description": "Return all appliances known by the controller.\n\nRequired privilege: Appliance.Audit", "operationId": "get_appliances_v3_appliances_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "update", "in": "query", "required": false, "schema": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "title": "Update"}}, {"name": "symbol_theme", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Symbol Theme"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"oneOf": [{"$ref": "#/components/schemas/ApplianceV1_6"}, {"$ref": "#/components/schemas/ApplianceV8"}], "discriminator": {"propertyName": "registry_version", "mapping": {"1": "#/components/schemas/ApplianceV1_6", "2": "#/components/schemas/ApplianceV1_6", "3": "#/components/schemas/ApplianceV1_6", "4": "#/components/schemas/ApplianceV1_6", "5": "#/components/schemas/ApplianceV1_6", "6": "#/components/schemas/ApplianceV1_6", "8": "#/components/schemas/ApplianceV8"}}}, "title": "Response Get Appliances V3 Appliances Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/appliances/{appliance_id}": {"get": {"tags": ["Appliances"], "summary": "Get Appliance", "description": "Get an appliance file.\n\nRequired privilege: Appliance.Audit", "operationId": "get_appliance_v3_appliances__appliance_id__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "appliance_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Appliance Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/ApplianceV1_6"}, {"$ref": "#/components/schemas/ApplianceV8"}], "discriminator": {"propertyName": "registry_version", "mapping": {"1": "#/components/schemas/ApplianceV1_6", "2": "#/components/schemas/ApplianceV1_6", "3": "#/components/schemas/ApplianceV1_6", "4": "#/components/schemas/ApplianceV1_6", "5": "#/components/schemas/ApplianceV1_6", "6": "#/components/schemas/ApplianceV1_6", "8": "#/components/schemas/ApplianceV8"}}, "title": "Response Get Appliance V3 Appliances Appliance Id Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/appliances/{appliance_id}/version": {"post": {"tags": ["Appliances"], "summary": "Add Appliance Version", "description": "Add a version to an appliance.\n\nRequired privilege: Appliance.Allocate", "operationId": "add_appliance_version_v3_appliances__appliance_id__version_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "appliance_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Appliance Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/ApplianceVersion"}, {"$ref": "#/components/schemas/ApplianceVersionV8"}], "title": "Appliance Version"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Add Appliance Version V3 Appliances Appliance Id Version Post"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/appliances/{appliance_id}/install": {"post": {"tags": ["Appliances"], "summary": "Install Appliance", "description": "Install an appliance.\n\nRequired privilege: Appliance.Allocate", "operationId": "install_appliance_v3_appliances__appliance_id__install_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "appliance_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Appliance Id"}}, {"name": "version", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Version"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/netmiko/device_types": {"get": {"tags": ["Netmiko"], "summary": "Get Netmiko Device Types", "description": "Return the device types supported by the Netmiko library installed on this server.\n\nRequired privilege: None (authenticated users only)", "operationId": "get_netmiko_device_types_v3_netmiko_device_types_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NetmikoDeviceTypeList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/pools": {"get": {"tags": ["Resource pools"], "summary": "Get Resource Pools", "description": "Get all resource pools.\n\nRequired privilege: Pool.Audit", "operationId": "get_resource_pools_v3_pools_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"$ref": "#/components/schemas/ResourcePool"}, "type": "array", "title": "Response Get Resource Pools V3 Pools Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "post": {"tags": ["Resource pools"], "summary": "Create Resource Pool", "description": "Create a new resource pool\n\nRequired privilege: Pool.Allocate", "operationId": "create_resource_pool_v3_pools_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResourcePoolCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResourcePool"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/pools/{resource_pool_id}": {"get": {"tags": ["Resource pools"], "summary": "Get Resource Pool", "description": "Get a resource pool.\n\nRequired privilege: Pool.Audit", "operationId": "get_resource_pool_v3_pools__resource_pool_id__get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "resource_pool_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Resource Pool Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResourcePool"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["Resource pools"], "summary": "Update Resource Pool", "description": "Update a resource pool.\n\nRequired privilege: Pool.Modify", "operationId": "update_resource_pool_v3_pools__resource_pool_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "resource_pool_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Resource Pool Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResourcePoolUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResourcePool"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Resource pools"], "summary": "Delete Resource Pool", "description": "Delete a resource pool.\n\nRequired privilege: Pool.Allocate", "operationId": "delete_resource_pool_v3_pools__resource_pool_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "resource_pool_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Resource Pool Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/pools/{resource_pool_id}/resources": {"get": {"tags": ["Resource pools"], "summary": "Get Pool Resources", "description": "Get all resource in a pool.\n\nRequired privilege: Pool.Audit", "operationId": "get_pool_resources_v3_pools__resource_pool_id__resources_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "resource_pool_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Resource Pool Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Resource"}, "title": "Response Get Pool Resources V3 Pools Resource Pool Id Resources Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/pools/{resource_pool_id}/resources/{resource_id}": {"put": {"tags": ["Resource pools"], "summary": "Add Resource To Pool", "description": "Add resource to a resource pool.\n\nRequired privilege: Pool.Modify", "operationId": "add_resource_to_pool_v3_pools__resource_pool_id__resources__resource_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "resource_pool_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Resource Pool Id"}}, {"name": "resource_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Resource Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["Resource pools"], "summary": "Remove Resource From Pool", "description": "Remove resource from a resource pool.\n\nRequired privilege: Pool.Modify", "operationId": "remove_resource_from_pool_v3_pools__resource_pool_id__resources__resource_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "resource_pool_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Resource Pool Id"}}, {"name": "resource_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Resource Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/gns3vm/engines": {"get": {"tags": ["GNS3 VM"], "summary": "Get Engines", "description": "Return the list of supported engines for the GNS3VM.", "operationId": "get_engines_v3_gns3vm_engines_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"additionalProperties": true, "type": "object"}, "type": "array", "title": "Response Get Engines V3 Gns3Vm Engines Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "deprecated": true, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/gns3vm/engines/{engine}/vms": {"get": {"tags": ["GNS3 VM"], "summary": "Get Vms", "description": "Return all the available VMs for a specific virtualization engine.", "operationId": "get_vms_v3_gns3vm_engines__engine__vms_get", "deprecated": true, "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "engine", "in": "path", "required": true, "schema": {"type": "string", "title": "Engine"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"type": "object", "additionalProperties": true}, "title": "Response Get Vms V3 Gns3Vm Engines Engine Vms Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/gns3vm": {"get": {"tags": ["GNS3 VM"], "summary": "Get Gns3Vm Settings", "description": "Return the GNS3 VM settings.", "operationId": "get_gns3vm_settings_v3_gns3vm_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GNS3VM"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "deprecated": true, "security": [{"OAuth2PasswordBearer": []}]}, "put": {"tags": ["GNS3 VM"], "summary": "Update Gns3Vm Settings", "description": "Update the GNS3 VM settings.", "operationId": "update_gns3vm_settings_v3_gns3vm_put", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GNS3VM"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GNS3VM"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "deprecated": true, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/access/users/{user_id}/llm-model-configs": {"get": {"tags": ["LLM Model Configurations"], "summary": "Get User Llm Model Configs", "description": "Get user's effective LLM model configurations (own + inherited from groups).\n\nRequired privilege: LLMConfig.Audit", "operationId": "get_user_llm_model_configs_v3_access_users__user_id__llm_model_configs_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigInheritedResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["LLM Model Configurations"], "summary": "Create User Llm Model Config", "description": "Create a new LLM model configuration for a user.\n\nRequired privilege: LLMConfig.Modify\n\nIMPORTANT: context_limit is REQUIRED. Unit is K tokens (e.g., 128 = 128K = 128,000 tokens).\nPlease check your model provider's documentation for the current context window size.", "operationId": "create_user_llm_model_config_v3_access_users__user_id__llm_model_configs_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/users/{user_id}/llm-model-configs/own": {"get": {"tags": ["LLM Model Configurations"], "summary": "Get User Own Llm Model Configs", "description": "Get user's own LLM model configurations (excluding inherited ones).\n\nRequired privilege: LLMConfig.Audit", "operationId": "get_user_own_llm_model_configs_v3_access_users__user_id__llm_model_configs_own_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/LLMModelConfigResponse"}, "title": "Response Get User Own Llm Model Configs V3 Access Users User Id Llm Model Configs Own Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/users/{user_id}/llm-model-configs/default": {"get": {"tags": ["LLM Model Configurations"], "summary": "Get User Default Llm Model Config", "description": "Get user's default LLM model configuration.\n\nRequired privilege: LLMConfig.Audit", "operationId": "get_user_default_llm_model_config_v3_access_users__user_id__llm_model_configs_default_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/users/{user_id}/llm-model-configs/{config_id}": {"put": {"tags": ["LLM Model Configurations"], "summary": "Update User Llm Model Config", "description": "Update a user's LLM model configuration.\nSupports optimistic locking via expected_version field.\n\nRequired privilege: LLMConfig.Modify", "operationId": "update_user_llm_model_config_v3_access_users__user_id__llm_model_configs__config_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}, {"name": "config_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Config Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["LLM Model Configurations"], "summary": "Delete User Llm Model Config", "description": "Delete a user's LLM model configuration.\n\nRequired privilege: LLMConfig.Modify", "operationId": "delete_user_llm_model_config_v3_access_users__user_id__llm_model_configs__config_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}, {"name": "config_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Config Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/users/{user_id}/llm-model-configs/default/{config_id}": {"put": {"tags": ["LLM Model Configurations"], "summary": "Set User Default Llm Model Config", "description": "Set a user's default LLM model configuration.\n\nRequired privilege: LLMConfig.Modify", "operationId": "set_user_default_llm_model_config_v3_access_users__user_id__llm_model_configs_default__config_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}, {"name": "config_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Config Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/groups/{group_id}/llm-model-configs": {"get": {"tags": ["LLM Model Configurations"], "summary": "Get Group Llm Model Configs", "description": "Get all LLM model configurations for a user group.\n\nRequired privilege: Group.Audit", "operationId": "get_group_llm_model_configs_v3_access_groups__group_id__llm_model_configs_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigListResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["LLM Model Configurations"], "summary": "Create Group Llm Model Config", "description": "Create a new LLM model configuration for a user group.\n\nRequired privilege: Group.Modify\n\nIMPORTANT: context_limit is REQUIRED. Unit is K tokens (e.g., 128 = 128K = 128,000 tokens).\nPlease check your model provider's documentation for the current context window size.", "operationId": "create_group_llm_model_config_v3_access_groups__group_id__llm_model_configs_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Group Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/groups/{group_id}/llm-model-configs/default": {"get": {"tags": ["LLM Model Configurations"], "summary": "Get Group Default Llm Model Config", "description": "Get group's default LLM model configuration.\n\nRequired privilege: Group.Audit", "operationId": "get_group_default_llm_model_config_v3_access_groups__group_id__llm_model_configs_default_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Group Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/groups/{group_id}/llm-model-configs/{config_id}": {"put": {"tags": ["LLM Model Configurations"], "summary": "Update Group Llm Model Config", "description": "Update a group's LLM model configuration.\nSupports optimistic locking via expected_version field.\n\nRequired privilege: Group.Modify", "operationId": "update_group_llm_model_config_v3_access_groups__group_id__llm_model_configs__config_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Group Id"}}, {"name": "config_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Config Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["LLM Model Configurations"], "summary": "Delete Group Llm Model Config", "description": "Delete a group's LLM model configuration.\n\nRequired privilege: Group.Modify", "operationId": "delete_group_llm_model_config_v3_access_groups__group_id__llm_model_configs__config_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Group Id"}}, {"name": "config_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Config Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/groups/{group_id}/llm-model-configs/default/{config_id}": {"put": {"tags": ["LLM Model Configurations"], "summary": "Set Group Default Llm Model Config", "description": "Set a group's default LLM model configuration.\n\nRequired privilege: Group.Modify", "operationId": "set_group_default_llm_model_config_v3_access_groups__group_id__llm_model_configs_default__config_id__put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Group Id"}}, {"name": "config_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Config Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LLMModelConfigResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/copilot/reload/skills": {"post": {"tags": ["GNS3 Copilot"], "summary": "Reload Skills", "description": "Hot reload skills and prompts from the external GNS3-Skills repository.\n\nReloads injection skills, system prompts, and forbidden commands\nfrom the skills repository without restarting the server.\n\nRequires superadmin privileges.", "operationId": "reload_skills_v3_copilot_reload_skills_post", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"additionalProperties": true, "type": "object", "title": "Response Reload Skills V3 Copilot Reload Skills Post"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/copilot/projects/{project_id}/chat/stream": {"post": {"tags": ["GNS3 Copilot"], "summary": "Stream chat responses from GNS3 Copilot", "description": "Send a message to GNS3 Copilot and stream the response via Server-Sent Events (SSE).", "operationId": "stream_chat_v3_copilot_projects__project_id__chat_stream_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChatRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Resource not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/copilot/projects/{project_id}/chat/sessions": {"get": {"tags": ["GNS3 Copilot"], "summary": "List chat sessions", "description": "List all chat sessions for a project, optionally filtered by copilot_mode.", "operationId": "list_sessions_v3_copilot_projects__project_id__chat_sessions_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "copilot_mode", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Copilot Mode"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/ChatSession"}, "title": "Response List Sessions V3 Copilot Projects Project Id Chat Sessions Get"}}}}, "404": {"description": "Resource not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/copilot/projects/{project_id}/chat/sessions/{session_id}/history": {"get": {"tags": ["GNS3 Copilot"], "summary": "Get conversation history", "description": "Retrieve the conversation history for a specific session/thread.", "operationId": "get_history_v3_copilot_projects__project_id__chat_sessions__session_id__history_get", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "default": 100, "title": "Limit"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ConversationHistory"}}}}, "404": {"description": "Resource not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/copilot/projects/{project_id}/chat/sessions/{session_id}": {"delete": {"tags": ["GNS3 Copilot"], "summary": "Delete a chat session", "description": "Delete a specific chat session and its checkpoints.", "operationId": "delete_session_v3_copilot_projects__project_id__chat_sessions__session_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"204": {"description": "Successful Response"}, "404": {"description": "Resource not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["GNS3 Copilot"], "summary": "Rename a chat session", "description": "Rename a specific chat session.", "operationId": "rename_session_v3_copilot_projects__project_id__chat_sessions__session_id__patch", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RenameSession"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChatSession"}}}}, "404": {"description": "Resource not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/copilot/projects/{project_id}/chat/sessions/{session_id}/abort": {"post": {"tags": ["GNS3 Copilot"], "summary": "Abort a streaming session", "description": "Abort an ongoing streaming session for a specific session.", "operationId": "abort_session_v3_copilot_projects__project_id__chat_sessions__session_id__abort_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Resource not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/copilot/projects/{project_id}/chat/sessions/{session_id}/pin": {"put": {"tags": ["GNS3 Copilot"], "summary": "Pin a chat session", "description": "Pin a chat session to the top of the list.", "operationId": "pin_session_v3_copilot_projects__project_id__chat_sessions__session_id__pin_put", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChatSession"}}}}, "404": {"description": "Resource not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["GNS3 Copilot"], "summary": "Unpin a chat session", "description": "Unpin a chat session from the top of the list.", "operationId": "unpin_session_v3_copilot_projects__project_id__chat_sessions__session_id__pin_delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChatSession"}}}}, "404": {"description": "Resource not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/copilot/projects/{project_id}/chat/inject": {"post": {"tags": ["GNS3 Copilot"], "summary": "Inject a network fault for troubleshooting practice", "description": "Inject a realistic network fault into the GNS3 lab for troubleshooting training.", "operationId": "inject_issue_v3_copilot_projects__project_id__chat_inject_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Project Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChatRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "404": {"description": "Resource not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/api-keys": {"get": {"tags": ["API Keys", "API Keys"], "summary": "List Api Keys", "description": "List all API keys for the current user.", "operationId": "list_api_keys_v3_access_api_keys_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"items": {"additionalProperties": true, "type": "object"}, "type": "array", "title": "Response List Api Keys V3 Access Api Keys Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "post": {"tags": ["API Keys", "API Keys"], "summary": "Create Api Key", "description": "Create a new API key. The full key is returned only once.", "operationId": "create_api_key_v3_access_api_keys_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiKeyCreate"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"additionalProperties": true, "type": "object", "title": "Response Create Api Key V3 Access Api Keys Post"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/access/api-keys/{api_key_id}/revoke": {"post": {"tags": ["API Keys", "API Keys"], "summary": "Revoke Api Key", "description": "Revoke an API key. It will immediately stop working, but can be restored.", "operationId": "revoke_api_key_v3_access_api_keys__api_key_id__revoke_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "api_key_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Api Key Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Revoke Api Key V3 Access Api Keys Api Key Id Revoke Post"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/api-keys/{api_key_id}/restore": {"post": {"tags": ["API Keys", "API Keys"], "summary": "Restore Api Key", "description": "Restore a previously revoked API key.", "operationId": "restore_api_key_v3_access_api_keys__api_key_id__restore_post", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "api_key_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Api Key Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Restore Api Key V3 Access Api Keys Api Key Id Restore Post"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/access/api-keys/{api_key_id}": {"delete": {"tags": ["API Keys", "API Keys"], "summary": "Delete Api Key", "description": "Permanently delete an API key. Cannot be undone.", "operationId": "delete_api_key_v3_access_api_keys__api_key_id__delete", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "api_key_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Api Key Id"}}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/mcp/": {"get": {"tags": ["MCP", "MCP"], "summary": "Mcp Root", "description": "MCP service metadata.", "operationId": "mcp_root_v3_mcp__get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}}, "components": {"schemas": {"ACE": {"properties": {"ace_type": {"$ref": "#/components/schemas/ACEType", "description": "Type of the ACE"}, "path": {"type": "string", "title": "Path"}, "propagate": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Propagate", "default": true}, "allowed": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Allowed", "default": true}, "user_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "User Id"}, "group_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Group Id"}, "role_id": {"type": "string", "format": "uuid", "title": "Role Id"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "updated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Updated At"}, "ace_id": {"type": "string", "format": "uuid", "title": "Ace Id"}}, "type": "object", "required": ["ace_type", "path", "role_id", "ace_id"], "title": "ACE"}, "ACECreate": {"properties": {"ace_type": {"$ref": "#/components/schemas/ACEType", "description": "Type of the ACE"}, "path": {"type": "string", "title": "Path"}, "propagate": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Propagate", "default": true}, "allowed": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Allowed", "default": true}, "user_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "User Id"}, "group_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Group Id"}, "role_id": {"type": "string", "format": "uuid", "title": "Role Id"}}, "type": "object", "required": ["ace_type", "path", "role_id"], "title": "ACECreate", "description": "Properties to create an ACE."}, "ACEType": {"type": "string", "enum": ["user", "group"], "title": "ACEType"}, "ACEUpdate": {"properties": {"ace_type": {"$ref": "#/components/schemas/ACEType", "description": "Type of the ACE"}, "path": {"type": "string", "title": "Path"}, "propagate": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Propagate", "default": true}, "allowed": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Allowed", "default": true}, "user_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "User Id"}, "group_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Group Id"}, "role_id": {"type": "string", "format": "uuid", "title": "Role Id"}}, "type": "object", "required": ["ace_type", "path", "role_id"], "title": "ACEUpdate", "description": "Properties to update an ACE."}, "ApiKeyCreate": {"properties": {"name": {"type": "string", "title": "Name"}}, "type": "object", "required": ["name"], "title": "ApiKeyCreate", "description": "Schema for creating a new API key."}, "ApplianceImage": {"properties": {"filename": {"type": "string", "title": "Filename"}, "version": {"type": "string", "title": "Version of the file"}, "md5sum": {"anyOf": [{"type": "string", "pattern": "^[a-f0-9]{32}$"}, {"type": "null"}], "title": "md5sum of the file"}, "filesize": {"type": "integer", "title": "File size in bytes"}, "download_url": {"anyOf": [{"type": "string", "minLength": 1, "format": "uri"}, {"type": "string", "maxLength": 0}, {"type": "null"}], "title": "Download url where you can download the appliance from a browser"}, "direct_download_url": {"anyOf": [{"type": "string", "minLength": 1, "format": "uri"}, {"type": "string", "maxLength": 0}, {"type": "null"}], "title": "Optional. Non authenticated url to the image file where you can download the image."}, "compression": {"anyOf": [{"$ref": "#/components/schemas/Compression"}, {"type": "null"}], "title": "Optional, compression type of direct download url image."}, "checksum": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "checksum of the image file"}, "checksum_type": {"anyOf": [{"$ref": "#/components/schemas/ChecksumType"}, {"type": "null"}], "title": "checksum type of the image file"}, "compression_target": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Optional, file name of the image file inside the compressed file."}}, "type": "object", "required": ["filename", "version", "filesize"], "title": "ApplianceImage", "description": "Appliance image definition - compatible with both versions"}, "ApplianceMetadata": {"properties": {"appliance_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Appliance Id", "description": "ID of the appliance the template was installed from"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description"}, "vendor_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Vendor Name"}, "vendor_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Vendor Url"}, "vendor_logo_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Vendor Logo Url"}, "documentation_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Documentation Url"}, "product_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Product Name"}, "product_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Product Url"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}, "availability": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Availability"}, "maintainer": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Maintainer"}, "maintainer_email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Maintainer Email"}, "installation_instructions": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Installation Instructions"}, "default_username": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Username"}, "default_password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Password"}}, "additionalProperties": true, "type": "object", "title": "ApplianceMetadata", "description": "Metadata kept on a template installed from an appliance: vendor\ninformation, default credentials and other fields that describe\nthe appliance but are not node properties."}, "ApplianceV1_6": {"properties": {"registry_version": {"type": "integer", "enum": [1, 2, 3, 4, 5, 6], "title": "Version of the registry compatible with this appliance"}, "appliance_id": {"type": "string", "format": "uuid", "title": "Appliance ID"}, "name": {"type": "string", "title": "Appliance name"}, "builtin": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Whether the appliance is builtin or not"}, "category": {"$ref": "#/components/schemas/gns3server__schemas__controller__appliances__Category", "title": "Category of the appliance"}, "description": {"type": "string", "title": "Description of the appliance. Could be a marketing description"}, "vendor_name": {"type": "string", "title": "Name of the vendor"}, "vendor_url": {"anyOf": [{"type": "string", "minLength": 1, "format": "uri"}, {"type": "string", "maxLength": 0}, {"type": "null"}], "title": "Website of the vendor"}, "documentation_url": {"anyOf": [{"type": "string", "minLength": 1, "format": "uri"}, {"type": "string", "maxLength": 0}, {"type": "null"}], "title": "An optional documentation for using the appliance on vendor website"}, "product_name": {"type": "string", "title": "Product name"}, "product_url": {"anyOf": [{"type": "string", "minLength": 1, "format": "uri"}, {"type": "string", "maxLength": 0}, {"type": "null"}], "title": "An optional product url on vendor website"}, "status": {"$ref": "#/components/schemas/Status", "title": "Document if the appliance is working or not"}, "availability": {"anyOf": [{"$ref": "#/components/schemas/Availability"}, {"type": "null"}], "title": "About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly"}, "maintainer": {"type": "string", "title": "Maintainer name"}, "maintainer_email": {"anyOf": [{"type": "string", "format": "email"}, {"type": "string", "maxLength": 0}, {"type": "null"}], "title": "Maintainer email"}, "usage": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "How to use the appliance"}, "symbol": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "An optional symbol for the appliance"}, "netmiko_device_type": {"anyOf": [{"type": "string", "pattern": "^[a-z0-9_]+$|^$"}, {"type": "null"}], "title": "Device type for Netmiko-based automation tools"}, "first_port_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Optional name of the first networking port example: eth0"}, "port_name_format": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Optional formating of the networking port example: eth{0}"}, "port_segment_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2"}, "custom_adapters": {"anyOf": [{"items": {"$ref": "#/components/schemas/CustomAdapterItem"}, "type": "array"}, {"type": "null"}], "title": "Optional per-adapter overrides (port name, adapter type, MAC address)"}, "linked_clone": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "False if you don't want to use a single image for all nodes"}, "docker": {"anyOf": [{"$ref": "#/components/schemas/Docker"}, {"type": "null"}], "title": "Docker specific options"}, "iou": {"anyOf": [{"$ref": "#/components/schemas/Iou"}, {"type": "null"}], "title": "IOU specific options"}, "dynamips": {"anyOf": [{"$ref": "#/components/schemas/Dynamips"}, {"type": "null"}], "title": "Dynamips specific options"}, "qemu": {"anyOf": [{"$ref": "#/components/schemas/Qemu"}, {"type": "null"}], "title": "Qemu specific options"}, "tags": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "User-defined metadata tags for the appliance"}, "images": {"anyOf": [{"items": {"$ref": "#/components/schemas/ApplianceImage"}, "type": "array"}, {"type": "null"}], "title": "Images for this appliance"}, "versions": {"anyOf": [{"items": {"$ref": "#/components/schemas/ApplianceVersion"}, "type": "array"}, {"type": "null"}], "title": "Versions of the appliance"}}, "type": "object", "required": ["registry_version", "appliance_id", "name", "category", "description", "vendor_name", "product_name", "status", "maintainer"], "title": "ApplianceV1_6", "description": "GNS3 Appliance model for registry versions 1-6"}, "ApplianceV8": {"properties": {"registry_version": {"type": "integer", "const": 8, "title": "Version of the registry compatible with this appliance (version >=8 introduced breaking changes)"}, "appliance_id": {"type": "string", "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", "title": "Appliance ID"}, "name": {"type": "string", "title": "Appliance name"}, "builtin": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Whether the appliance is builtin or not"}, "category": {"$ref": "#/components/schemas/gns3server__schemas__controller__appliances__Category", "title": "Category of the appliance"}, "description": {"type": "string", "title": "Description of the appliance. Could be a marketing description"}, "vendor_name": {"type": "string", "title": "Name of the vendor"}, "vendor_url": {"type": "string", "minLength": 1, "format": "uri", "title": "Website of the vendor"}, "vendor_logo_url": {"anyOf": [{"type": "string", "minLength": 1, "format": "uri"}, {"type": "null"}], "title": "Link to the vendor logo (used by the GNS3 marketplace)"}, "documentation_url": {"anyOf": [{"type": "string", "minLength": 1, "format": "uri"}, {"type": "null"}], "title": "An optional documentation for using the appliance on vendor website"}, "product_name": {"type": "string", "title": "Product name"}, "product_url": {"anyOf": [{"type": "string", "minLength": 1, "format": "uri"}, {"type": "null"}], "title": "An optional product url on vendor website"}, "status": {"$ref": "#/components/schemas/Status", "title": "Document if the appliance is working or not"}, "availability": {"anyOf": [{"$ref": "#/components/schemas/Availability"}, {"type": "null"}], "title": "About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly"}, "maintainer": {"type": "string", "title": "Maintainer name"}, "maintainer_email": {"type": "string", "format": "email", "title": "Maintainer email"}, "installation_instructions": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Optional installation instructions"}, "usage": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "How to use the appliance"}, "default_username": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default username for the appliance"}, "default_password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default password for the appliance"}, "symbol": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "An optional symbol for the appliance"}, "netmiko_device_type": {"anyOf": [{"type": "string", "pattern": "^[a-z0-9_]+$|^$"}, {"type": "null"}], "title": "Device type for Netmiko-based automation tools"}, "tags": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "User-defined metadata tags for the appliance"}, "settings": {"items": {"$ref": "#/components/schemas/TemplateSetting"}, "type": "array", "title": "Settings for running the appliance"}, "images": {"anyOf": [{"items": {"$ref": "#/components/schemas/ApplianceImage"}, "type": "array"}, {"type": "null"}], "title": "Images for this appliance"}, "versions": {"anyOf": [{"items": {"$ref": "#/components/schemas/ApplianceVersionV8"}, "type": "array"}, {"type": "null"}], "title": "Versions of the appliance"}}, "type": "object", "required": ["registry_version", "appliance_id", "name", "category", "description", "vendor_name", "vendor_url", "product_name", "status", "maintainer", "maintainer_email", "settings"], "title": "ApplianceV8", "description": "GNS3 Appliance model for registry version 8"}, "ApplianceVersion": {"properties": {"name": {"type": "string", "title": "Name of the version"}, "idlepc": {"anyOf": [{"type": "string", "pattern": "^0x[0-9a-f]{8}"}, {"type": "null"}], "title": "Idlepc"}, "images": {"anyOf": [{"$ref": "#/components/schemas/ApplianceVersionImages"}, {"type": "null"}], "title": "Images used for this version"}}, "type": "object", "required": ["name"], "title": "ApplianceVersion", "description": "Appliance version definition for v1-6"}, "ApplianceVersionImages": {"properties": {"kernel_image": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Kernel image"}, "initrd": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Initrd disk image"}, "image": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "OS image"}, "bios_image": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Bios image"}, "hda_disk_image": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Hda disk image"}, "hdb_disk_image": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Hdc disk image"}, "hdc_disk_image": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Hdd disk image"}, "hdd_disk_image": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Hdd diskimage"}, "cdrom_image": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "cdrom image"}}, "type": "object", "title": "ApplianceVersionImages", "description": "Appliance version images configuration for v1-6"}, "ApplianceVersionV8": {"properties": {"name": {"type": "string", "title": "Name of the version"}, "settings": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Template settings to use to run the version"}, "idlepc": {"anyOf": [{"type": "string", "pattern": "^0x[0-9a-f]{8}"}, {"type": "null"}], "title": "Idlepc"}, "category": {"anyOf": [{"$ref": "#/components/schemas/gns3server__schemas__controller__appliances__Category"}, {"type": "null"}], "title": "Category of the version"}, "installation_instructions": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Optional installation instructions for the version"}, "usage": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Optional instructions about using the version"}, "default_username": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default username for the version"}, "default_password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default password for the version"}, "symbol": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "An optional symbol for the version"}, "images": {"anyOf": [{"$ref": "#/components/schemas/ApplianceVersionImages"}, {"type": "null"}], "title": "Images used for this version"}}, "type": "object", "required": ["name"], "title": "ApplianceVersionV8", "description": "Appliance version definition (v8)"}, "AutoIdlePC": {"properties": {"platform": {"type": "string", "title": "Platform", "description": "Cisco platform"}, "image": {"type": "string", "title": "Image", "description": "Image path"}, "ram": {"type": "integer", "title": "Ram", "description": "Amount of RAM in MB"}}, "type": "object", "required": ["platform", "image", "ram"], "title": "AutoIdlePC", "description": "Data for auto Idle-PC request.", "example": {"image": "/path/to/c7200_image.bin", "platform": "c7200", "ram": 256}}, "Availability": {"type": "string", "enum": ["free", "with-registration", "free-to-try", "service-contract"], "title": "Availability", "description": "Image availability enum"}, "Body_docker_pull_image_v3_computes__compute_id__docker_images_pull_post": {"properties": {"image": {"type": "string", "minLength": 1, "pattern": "^\\S+$", "title": "Image"}}, "type": "object", "required": ["image"], "title": "Body_docker_pull_image_v3_computes__compute_id__docker_images_pull_post"}, "Body_load_project_v3_projects_load_post": {"properties": {"path": {"type": "string", "title": "Path"}}, "type": "object", "required": ["path"], "title": "Body_load_project_v3_projects_load_post"}, "Body_login_v3_access_users_login_post": {"properties": {"grant_type": {"anyOf": [{"type": "string", "pattern": "^password$"}, {"type": "null"}], "title": "Grant Type"}, "username": {"type": "string", "title": "Username"}, "password": {"type": "string", "format": "password", "title": "Password"}, "scope": {"type": "string", "title": "Scope", "default": ""}, "client_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Client Id"}, "client_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "format": "password", "title": "Client Secret"}}, "type": "object", "required": ["username", "password"], "title": "Body_login_v3_access_users_login_post"}, "Capabilities": {"properties": {"version": {"type": "string", "title": "Version", "description": "Compute version number"}, "node_types": {"items": {"$ref": "#/components/schemas/NodeType"}, "type": "array", "title": "Node Types", "description": "Node types supported by the compute"}, "platform": {"type": "string", "title": "Platform", "description": "Platform where the compute is running (Linux, Windows or macOS)"}, "cpus": {"type": "integer", "title": "Cpus", "description": "Number of CPUs on this compute"}, "memory": {"type": "integer", "title": "Memory", "description": "Amount of memory on this compute"}, "disk_size": {"type": "integer", "title": "Disk Size", "description": "Disk size on this compute"}}, "type": "object", "required": ["version", "node_types", "platform", "cpus", "memory", "disk_size"], "title": "Capabilities", "description": "Capabilities supported by a compute."}, "ChatRequest": {"properties": {"message": {"type": "string", "title": "Message", "description": "User message content"}, "session_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Session Id", "description": "Session ID (auto-generated if not provided)"}, "stream": {"type": "boolean", "title": "Stream", "description": "Enable streaming response", "default": true}, "temperature": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Temperature", "description": "LLM temperature parameter (NOTE: currently not used. Temperature is loaded from user's LLM config in database. Reserved for future runtime override support.)"}, "mode": {"type": "string", "const": "text", "title": "Mode", "description": "Interaction mode", "default": "text"}}, "type": "object", "required": ["message"], "title": "ChatRequest", "description": "Chat request model."}, "ChatSession": {"properties": {"id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Id", "description": "Database ID"}, "thread_id": {"type": "string", "title": "Thread Id", "description": "Thread/session ID"}, "user_id": {"type": "string", "title": "User Id", "description": "User ID"}, "project_id": {"type": "string", "title": "Project Id", "description": "Associated GNS3 project ID"}, "title": {"type": "string", "title": "Title", "description": "Session title"}, "message_count": {"type": "integer", "title": "Message Count", "description": "Number of messages", "default": 0}, "llm_calls_count": {"type": "integer", "title": "Llm Calls Count", "description": "Number of LLM calls", "default": 0}, "input_tokens": {"type": "integer", "title": "Input Tokens", "description": "Input tokens used", "default": 0}, "output_tokens": {"type": "integer", "title": "Output Tokens", "description": "Output tokens generated", "default": 0}, "total_tokens": {"type": "integer", "title": "Total Tokens", "description": "Total tokens used", "default": 0}, "last_message_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Last Message At", "description": "Last message timestamp (ISO 8601)"}, "created_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Created At", "description": "Creation timestamp (ISO 8601)"}, "updated_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Updated At", "description": "Last update timestamp (ISO 8601)"}, "metadata": {"additionalProperties": true, "type": "object", "title": "Metadata", "description": "Session metadata"}, "stats": {"additionalProperties": true, "type": "object", "title": "Stats", "description": "Session statistics"}, "pinned": {"type": "boolean", "title": "Pinned", "description": "Whether the session is pinned to the top", "default": false}}, "type": "object", "required": ["thread_id", "user_id", "project_id", "title"], "title": "ChatSession", "description": "Chat session model."}, "ChecksumType": {"type": "string", "enum": ["md5"], "title": "ChecksumType", "description": "Checksum type enum"}, "Compression": {"type": "string", "enum": ["bzip2", "gzip", "lzma", "xz", "rar", "zip", "7z"], "title": "Compression", "description": "Compression type enum"}, "Compute": {"properties": {"protocol": {"$ref": "#/components/schemas/Protocol"}, "host": {"type": "string", "title": "Host"}, "port": {"type": "integer", "maximum": 65535.0, "exclusiveMinimum": 0.0, "title": "Port"}, "user": {"type": "string", "title": "User"}, "password": {"anyOf": [{"type": "string", "format": "password", "writeOnly": true}, {"type": "null"}], "title": "Password"}, "name": {"type": "string", "title": "Name"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "updated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Updated At"}, "compute_id": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}, "connected": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Connected", "description": "Whether the controller is connected to the compute or not"}, "cpu_usage_percent": {"anyOf": [{"type": "number", "maximum": 100.0, "minimum": 0.0}, {"type": "null"}], "title": "Cpu Usage Percent", "description": "CPU usage of the compute"}, "memory_usage_percent": {"anyOf": [{"type": "number", "maximum": 100.0, "minimum": 0.0}, {"type": "null"}], "title": "Memory Usage Percent", "description": "Memory usage of the compute"}, "disk_usage_percent": {"anyOf": [{"type": "number", "maximum": 100.0, "minimum": 0.0}, {"type": "null"}], "title": "Disk Usage Percent", "description": "Disk usage of the compute"}, "last_error": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Last Error", "description": "Last error found on the compute"}, "capabilities": {"anyOf": [{"$ref": "#/components/schemas/Capabilities"}, {"type": "null"}]}}, "type": "object", "required": ["protocol", "host", "port", "name", "compute_id"], "title": "Compute", "description": "Data returned for a compute."}, "ComputeCreate": {"properties": {"protocol": {"$ref": "#/components/schemas/Protocol"}, "host": {"type": "string", "title": "Host"}, "port": {"type": "integer", "maximum": 65535.0, "exclusiveMinimum": 0.0, "title": "Port"}, "user": {"type": "string", "title": "User"}, "password": {"anyOf": [{"type": "string", "format": "password", "writeOnly": true}, {"type": "null"}], "title": "Password"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "compute_id": {"anyOf": [{"type": "string"}, {"type": "string", "format": "uuid"}], "title": "Compute Id"}}, "type": "object", "required": ["protocol", "host", "port"], "title": "ComputeCreate", "description": "Data to create a compute.", "example": {"host": "127.0.0.1", "name": "My compute", "password": "password", "port": 3080, "user": "user"}}, "ComputeDockerImage": {"properties": {"image": {"type": "string", "title": "Image", "description": "Docker image name"}}, "type": "object", "required": ["image"], "title": "ComputeDockerImage", "description": "Docker image from compute."}, "ComputeUpdate": {"properties": {"protocol": {"anyOf": [{"$ref": "#/components/schemas/Protocol"}, {"type": "null"}]}, "host": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Host"}, "port": {"anyOf": [{"type": "integer", "maximum": 65535.0, "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Port"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "User"}, "password": {"anyOf": [{"type": "string", "format": "password", "writeOnly": true}, {"type": "null"}], "title": "Password"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}}, "type": "object", "title": "ComputeUpdate", "description": "Data to update a compute.", "example": {"host": "10.0.0.1", "port": 8080}}, "ComputeVMwareVM": {"properties": {"vmname": {"type": "string", "title": "Vmname", "description": "VMware VM name"}, "vmx_path": {"type": "string", "title": "Vmx Path", "description": "Path to the vmx file"}}, "type": "object", "required": ["vmname", "vmx_path"], "title": "ComputeVMwareVM", "description": "VMware VM from compute."}, "ComputeVirtualBoxVM": {"properties": {"vmname": {"type": "string", "title": "Vmname", "description": "VirtualBox VM name"}, "ram": {"type": "integer", "title": "Ram", "description": "VirtualBox VM memory"}}, "type": "object", "required": ["vmname", "ram"], "title": "ComputeVirtualBoxVM", "description": "VirtualBox VM from compute."}, "ConsoleType": {"type": "string", "enum": ["vnc", "telnet", "ssh", "http", "https", "spice", "spice+agent", "none", "docker_exec"], "title": "ConsoleType", "description": "Supported console types."}, "ConversationHistory": {"properties": {"thread_id": {"type": "string", "title": "Thread Id", "description": "Thread/session ID"}, "title": {"type": "string", "title": "Title", "description": "Conversation title"}, "messages": {"items": {"$ref": "#/components/schemas/OpenAIMessage"}, "type": "array", "title": "Messages", "description": "Conversation messages"}, "created_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Created At", "description": "Creation timestamp (ISO 8601)"}, "updated_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Updated At", "description": "Last update timestamp (ISO 8601)"}, "llm_calls": {"type": "integer", "title": "Llm Calls", "description": "Total LLM calls in this conversation", "default": 0}}, "type": "object", "required": ["thread_id", "title"], "title": "ConversationHistory", "description": "Conversation history model."}, "Credentials": {"properties": {"username": {"type": "string", "title": "Username"}, "password": {"type": "string", "title": "Password"}}, "type": "object", "required": ["username", "password"], "title": "Credentials"}, "CustomAdapter": {"properties": {"adapter_number": {"type": "integer", "title": "Adapter Number"}, "port_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Port Name"}, "adapter_type": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Adapter Type"}, "mac_address": {"anyOf": [{"type": "string", "pattern": "^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$"}, {"type": "null"}], "title": "Mac Address"}}, "type": "object", "required": ["adapter_number"], "title": "CustomAdapter", "description": "Custom adapter data."}, "CustomAdapterItem": {"properties": {"adapter_number": {"type": "integer", "title": "Adapter number"}, "port_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Custom port name"}, "adapter_type": {"anyOf": [{"$ref": "#/components/schemas/QemuAdapterType"}, {"type": "null"}], "title": "Custom adapter type"}, "mac_address": {"anyOf": [{"type": "string", "pattern": "^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$"}, {"type": "null"}], "title": "Custom MAC address"}}, "type": "object", "required": ["adapter_number"], "title": "CustomAdapterItem", "description": "Custom adapter configuration (v8)"}, "Docker": {"properties": {"adapters": {"type": "integer", "title": "Number of Ethernet adapters"}, "image": {"type": "string", "title": "Docker image in the Docker Hub"}, "start_command": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Command executed when the container start. Empty will use the default"}, "environment": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "One KEY=VAR environment by line"}, "console_type": {"anyOf": [{"$ref": "#/components/schemas/DockerConsoleType"}, {"type": "null"}], "title": "Type of console connection for the administration of the appliance"}, "console_http_port": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Console Http Port", "description": "Internal port in the container of the HTTP server"}, "console_http_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Console Http Path", "description": "Path of the web interface"}, "extra_hosts": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Extra Hosts", "description": "Hosts which will be written to /etc/hosts into container"}, "extra_volumes": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Extra Volumes", "description": "Additional directories to make persistent that are not included in the images VOLUME directive"}, "extra_configs": {"anyOf": [{"items": {"$ref": "#/components/schemas/ExtraConfig"}, "type": "array"}, {"type": "null"}], "title": "Extra Configs", "description": "Configuration files injected into the container (bind-mounted read-only)"}}, "type": "object", "required": ["adapters", "image"], "title": "Docker", "description": "Docker configuration for v1-6"}, "DockerConsoleType": {"type": "string", "enum": ["telnet", "ssh", "vnc", "http", "https", "none", "docker_exec"], "title": "DockerConsoleType", "description": "Docker console type enum"}, "DockerPropertiesV8": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name of the template"}, "category": {"anyOf": [{"$ref": "#/components/schemas/gns3server__schemas__controller__appliances__Category"}, {"type": "null"}], "title": "Category of the template"}, "default_name_format": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default name format"}, "usage": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "How to use the template"}, "symbol": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Symbol of the template"}, "image": {"type": "string", "title": "Docker image"}, "adapters": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Number of ethernet adapters"}, "start_command": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Command executed when the container start. Empty will use the default"}, "environment": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "One KEY=VAR environment by line"}, "console_type": {"anyOf": [{"$ref": "#/components/schemas/DockerConsoleType"}, {"type": "null"}], "title": "Type of console"}, "console_http_port": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Internal port in the container of the HTTP server"}, "console_http_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Path of the web interface"}, "console_resolution": {"anyOf": [{"type": "string", "pattern": "^[0-9]+x[0-9]+$"}, {"type": "null"}], "title": "Console resolution for VNC, for example 1024x768"}, "extra_hosts": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Docker extra hosts (added to /etc/hosts)"}, "extra_volumes": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Additional directories to make persistent"}, "custom_adapters": {"anyOf": [{"items": {"$ref": "#/components/schemas/CustomAdapterItem"}, "type": "array"}, {"type": "null"}], "title": "Custom adapters"}, "extra_configs": {"anyOf": [{"items": {"$ref": "#/components/schemas/ExtraConfig"}, "type": "array"}, {"type": "null"}], "title": "Configuration files injected into the container (bind-mounted read-only)"}}, "type": "object", "required": ["image"], "title": "DockerPropertiesV8", "description": "Docker template properties (v8)"}, "Drawing": {"properties": {"drawing_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Drawing Id"}, "project_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Project Id"}, "x": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "X"}, "y": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Y"}, "z": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Z"}, "locked": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Locked"}, "rotation": {"anyOf": [{"type": "integer", "maximum": 360.0, "minimum": -359.0}, {"type": "null"}], "title": "Rotation"}, "svg": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Svg"}}, "type": "object", "title": "Drawing", "description": "Drawing data."}, "Dynamips": {"properties": {"chassis": {"anyOf": [{"$ref": "#/components/schemas/DynamipsChassis"}, {"type": "null"}], "title": "Chassis type"}, "platform": {"$ref": "#/components/schemas/DynamipsPlatform", "title": "Platform type"}, "ram": {"type": "integer", "minimum": 1.0, "title": "Amount of ram"}, "nvram": {"type": "integer", "minimum": 1.0, "title": "Amount of nvram"}, "startup_config": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Config loaded at startup"}, "wic0": {"anyOf": [{"$ref": "#/components/schemas/DynamipsWic"}, {"type": "null"}]}, "wic1": {"anyOf": [{"$ref": "#/components/schemas/DynamipsWic"}, {"type": "null"}]}, "wic2": {"anyOf": [{"$ref": "#/components/schemas/DynamipsWic"}, {"type": "null"}]}, "slot0": {"anyOf": [{"$ref": "#/components/schemas/DynamipsSlot"}, {"type": "null"}]}, "slot1": {"anyOf": [{"$ref": "#/components/schemas/DynamipsSlot"}, {"type": "null"}]}, "slot2": {"anyOf": [{"$ref": "#/components/schemas/DynamipsSlot"}, {"type": "null"}]}, "slot3": {"anyOf": [{"$ref": "#/components/schemas/DynamipsSlot"}, {"type": "null"}]}, "slot4": {"anyOf": [{"$ref": "#/components/schemas/DynamipsSlot"}, {"type": "null"}]}, "slot5": {"anyOf": [{"$ref": "#/components/schemas/DynamipsSlot"}, {"type": "null"}]}, "slot6": {"anyOf": [{"$ref": "#/components/schemas/DynamipsSlot"}, {"type": "null"}]}, "midplane": {"anyOf": [{"$ref": "#/components/schemas/DynamipsMidplane"}, {"type": "null"}]}, "npe": {"anyOf": [{"$ref": "#/components/schemas/DynamipsNpe"}, {"type": "null"}]}}, "type": "object", "required": ["platform", "ram", "nvram"], "title": "Dynamips", "description": "Dynamips configuration for v1-6"}, "DynamipsChassis": {"type": "string", "enum": ["1720", "1721", "1750", "1751", "1760", "2610", "2620", "2610XM", "2620XM", "2650XM", "2621", "2611XM", "2621XM", "2651XM", "3620", "3640", "3660", ""], "title": "DynamipsChassis", "description": "Dynamips chassis enum"}, "DynamipsMidplane": {"type": "string", "enum": ["std", "vxr"], "title": "DynamipsMidplane", "description": "Dynamips midplane enum"}, "DynamipsNpe": {"type": "string", "enum": ["npe-100", "npe-150", "npe-175", "npe-200", "npe-225", "npe-300", "npe-400", "npe-g2"], "title": "DynamipsNpe", "description": "Dynamips NPE enum"}, "DynamipsPlatform": {"type": "string", "enum": ["c1700", "c2600", "c2691", "c3725", "c3745", "c3600", "c7200"], "title": "DynamipsPlatform", "description": "Dynamips platform enum"}, "DynamipsPropertiesV8": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name of the template"}, "category": {"anyOf": [{"$ref": "#/components/schemas/gns3server__schemas__controller__appliances__Category"}, {"type": "null"}], "title": "Category of the template"}, "default_name_format": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default name format"}, "usage": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "How to use the template"}, "symbol": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Symbol of the template"}, "chassis": {"anyOf": [{"$ref": "#/components/schemas/DynamipsChassis"}, {"type": "null"}], "title": "Chassis type"}, "platform": {"anyOf": [{"$ref": "#/components/schemas/DynamipsPlatform"}, {"type": "null"}], "title": "Platform type"}, "ram": {"anyOf": [{"type": "integer", "minimum": 1.0}, {"type": "null"}], "title": "Amount of ram"}, "nvram": {"anyOf": [{"type": "integer", "minimum": 1.0}, {"type": "null"}], "title": "Amount of nvram"}, "idlepc": {"anyOf": [{"type": "string", "pattern": "^0x[0-9a-f]{8}"}, {"type": "null"}], "title": "Idlepc"}, "startup_config": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Config loaded at startup"}, "wic0": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wic0"}, "wic1": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wic1"}, "wic2": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wic2"}, "slot0": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Slot0"}, "slot1": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Slot1"}, "slot2": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Slot2"}, "slot3": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Slot3"}, "slot4": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Slot4"}, "slot5": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Slot5"}, "slot6": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Slot6"}, "midplane": {"anyOf": [{"$ref": "#/components/schemas/DynamipsMidplane"}, {"type": "null"}]}, "npe": {"anyOf": [{"$ref": "#/components/schemas/DynamipsNpe"}, {"type": "null"}]}}, "type": "object", "title": "DynamipsPropertiesV8", "description": "Dynamips template properties (v8)"}, "DynamipsSlot": {"type": "string", "enum": ["C7200-IO-2FE", "C7200-IO-FE", "C7200-IO-GE-E", "NM-16ESW", "NM-1E", "NM-1FE-TX", "NM-4E", "NM-4T", "PA-2FE-TX", "PA-4E", "PA-4T+", "PA-8E", "PA-8T", "PA-A1", "PA-FE-TX", "PA-GE", "PA-POS-OC3", "C2600-MB-2FE", "C2600-MB-1E", "C1700-MB-1FE", "C2600-MB-2E", "C2600-MB-1FE", "C1700-MB-WIC1", "GT96100-FE", "Leopard-2FE", ""], "title": "DynamipsSlot", "description": "Dynamips slot enum"}, "DynamipsWic": {"type": "string", "enum": ["WIC-1ENET", "WIC-1T", "WIC-2T", ""], "title": "DynamipsWic", "description": "Dynamips WIC enum"}, "Engine": {"type": "string", "enum": ["vmware", "virtualbox", "hyper-v", "none"], "title": "Engine", "description": "\"The engine to use for the GNS3 VM."}, "ErrorMessage": {"properties": {"message": {"type": "string", "title": "Message"}}, "type": "object", "required": ["message"], "title": "ErrorMessage", "description": "Error message."}, "EthernetPortInfo": {"properties": {"node_id": {"type": "string", "format": "uuid", "title": "Node Id"}, "interface": {"type": "string", "title": "Interface"}, "type": {"type": "string", "title": "Type"}}, "type": "object", "required": ["node_id", "interface", "type"], "title": "EthernetPortInfo", "description": "Ethernet port information."}, "ExtraConfig": {"properties": {"target": {"type": "string", "title": "Target", "description": "Absolute path inside the container where the file is mounted"}, "content": {"type": "string", "title": "Content", "description": "File content written by GNS3 and bind-mounted read-only into the container", "default": ""}}, "type": "object", "required": ["target"], "title": "ExtraConfig", "description": "A configuration file injected into a Docker container.\n\nGNS3 writes ``content`` to a host file and bind-mounts it read-only at\n``target`` inside the container. Used to seed NOS startup configs (e.g.\nXRd first-boot config, FRR frr.conf) without rebuilding the image."}, "GNS3VM": {"properties": {"enable": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Enable", "description": "Enable/disable the GNS3 VM"}, "vmname": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Vmname", "description": "GNS3 VM name"}, "when_exit": {"anyOf": [{"$ref": "#/components/schemas/WhenExit"}, {"type": "null"}], "description": "Action when the GNS3 VM exits"}, "headless": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Headless", "description": "Start the GNS3 VM GUI or not"}, "engine": {"anyOf": [{"$ref": "#/components/schemas/Engine"}, {"type": "null"}], "description": "The engine to use for the GNS3 VM"}, "allocate_vcpus_ram": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Allocate Vcpus Ram", "description": "Allocate vCPUS and RAM settings"}, "vcpus": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Vcpus", "description": "Number of CPUs to allocate for the GNS3 VM"}, "ram": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Ram", "description": "Amount of memory to allocate for the GNS3 VM"}, "port": {"anyOf": [{"type": "integer", "maximum": 65535.0, "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Port"}}, "type": "object", "title": "GNS3VM", "description": "GNS3 VM data."}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "IOULicense": {"properties": {"iourc_content": {"type": "string", "title": "Iourc Content", "description": "Content of iourc file"}, "license_check": {"type": "boolean", "title": "License Check", "description": "Whether the license must be checked or not"}}, "type": "object", "required": ["iourc_content", "license_check"], "title": "IOULicense"}, "Image": {"properties": {"filename": {"type": "string", "title": "Filename", "description": "Image filename"}, "path": {"type": "string", "title": "Path", "description": "Image path"}, "image_type": {"$ref": "#/components/schemas/ImageType", "description": "Image type"}, "image_size": {"type": "integer", "title": "Image Size", "description": "Image size in bytes"}, "checksum": {"type": "string", "title": "Checksum", "description": "Checksum value"}, "checksum_algorithm": {"type": "string", "title": "Checksum Algorithm", "description": "Checksum algorithm"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "updated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Updated At"}}, "type": "object", "required": ["filename", "path", "image_type", "image_size", "checksum", "checksum_algorithm"], "title": "Image"}, "ImageType": {"type": "string", "enum": ["qemu", "ios", "iou"], "title": "ImageType"}, "Iou": {"properties": {"ethernet_adapters": {"type": "integer", "title": "Number of Ethernet adapters"}, "serial_adapters": {"type": "integer", "title": "Number of serial adapters"}, "nvram": {"type": "integer", "title": "Host NVRAM"}, "ram": {"type": "integer", "title": "Host RAM"}, "startup_config": {"type": "string", "title": "Config loaded at startup"}}, "type": "object", "required": ["ethernet_adapters", "serial_adapters", "nvram", "ram", "startup_config"], "title": "Iou", "description": "IOU configuration for v1-6"}, "IouPropertiesV8": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name of the template"}, "category": {"anyOf": [{"$ref": "#/components/schemas/gns3server__schemas__controller__appliances__Category"}, {"type": "null"}], "title": "Category of the template"}, "default_name_format": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default name format"}, "usage": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "How to use the template"}, "symbol": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Symbol of the template"}, "ethernet_adapters": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Number of ethernet adapters"}, "serial_adapters": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Number of serial adapters"}, "ram": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Host RAM"}, "nvram": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Host NVRAM"}, "startup_config": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Config loaded at startup"}}, "type": "object", "title": "IouPropertiesV8", "description": "IOU template properties (v8)"}, "Kvm": {"type": "string", "enum": ["require", "allow", "disable"], "title": "Kvm", "description": "KVM requirements enum"}, "LLMModelConfigCreate": {"properties": {"name": {"type": "string", "maxLength": 100, "minLength": 1, "title": "Name", "description": "Configuration name"}, "model_type": {"type": "string", "enum": ["text", "vision", "stt", "tts", "multimodal", "embedding", "reranking", "other"], "title": "Model Type", "description": "Model type"}, "is_default": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Default", "description": "Set as default configuration", "default": false}, "provider": {"type": "string", "title": "Provider", "description": "LLM provider"}, "base_url": {"type": "string", "title": "Base Url", "description": "API base URL"}, "model": {"type": "string", "title": "Model", "description": "Model name"}, "temperature": {"type": "number", "maximum": 2.0, "minimum": 0.0, "title": "Temperature", "default": 0.7}, "api_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Api Key"}, "max_tokens": {"anyOf": [{"type": "integer", "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Max Tokens"}, "context_limit": {"type": "integer", "exclusiveMinimum": 0.0, "title": "Context Limit", "description": "Model context window limit in K tokens (e.g., 128 = 128K tokens)"}, "context_strategy": {"type": "string", "enum": ["conservative", "balanced", "aggressive"], "title": "Context Strategy", "description": "Context trimming strategy", "default": "balanced"}, "copilot_mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Copilot Mode", "description": "GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'"}}, "additionalProperties": true, "type": "object", "required": ["name", "model_type", "provider", "base_url", "model", "context_limit"], "title": "LLMModelConfigCreate", "description": "Request to create a new LLM model configuration."}, "LLMModelConfigDataWithoutSecret": {"properties": {"provider": {"type": "string", "title": "Provider", "description": "LLM provider (e.g., 'openai', 'anthropic', 'ollama')"}, "base_url": {"type": "string", "title": "Base Url", "description": "API base URL"}, "model": {"type": "string", "title": "Model", "description": "Model name"}, "temperature": {"type": "number", "maximum": 2.0, "minimum": 0.0, "title": "Temperature", "description": "Temperature parameter", "default": 0.7}, "api_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Api Key", "description": "API key (always hidden in API responses)"}, "max_tokens": {"anyOf": [{"type": "integer", "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Max Tokens", "description": "Max tokens for generation"}, "context_limit": {"type": "integer", "exclusiveMinimum": 0.0, "title": "Context Limit", "description": "Model context window limit in K tokens (e.g., 128 = 128K tokens)"}, "context_strategy": {"type": "string", "enum": ["conservative", "balanced", "aggressive"], "title": "Context Strategy", "description": "Context trimming strategy: conservative (60%), balanced (75%), aggressive (85%)", "default": "balanced"}, "copilot_mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Copilot Mode", "description": "GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'"}}, "additionalProperties": true, "type": "object", "required": ["provider", "base_url", "model", "context_limit"], "title": "LLMModelConfigDataWithoutSecret", "description": "LLM model configuration data WITHOUT sensitive information."}, "LLMModelConfigInheritedResponse": {"properties": {"configs": {"items": {"$ref": "#/components/schemas/LLMModelConfigWithSource"}, "type": "array", "title": "Configs"}, "default_config": {"anyOf": [{"$ref": "#/components/schemas/LLMModelConfigWithSource"}, {"type": "null"}]}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["configs", "total"], "title": "LLMModelConfigInheritedResponse", "description": "Response containing user's effective configs (own + inherited from groups)."}, "LLMModelConfigListResponse": {"properties": {"configs": {"items": {"$ref": "#/components/schemas/LLMModelConfigResponse"}, "type": "array", "title": "Configs"}, "default_config": {"anyOf": [{"$ref": "#/components/schemas/LLMModelConfigResponse"}, {"type": "null"}]}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["configs", "total"], "title": "LLMModelConfigListResponse", "description": "Response containing a list of model configurations with default."}, "LLMModelConfigResponse": {"properties": {"created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "updated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Updated At"}, "config_id": {"type": "string", "format": "uuid", "title": "Config Id"}, "name": {"type": "string", "title": "Name"}, "model_type": {"type": "string", "enum": ["text", "vision", "stt", "tts", "multimodal", "embedding", "reranking", "other"], "title": "Model Type"}, "config": {"$ref": "#/components/schemas/LLMModelConfigDataWithoutSecret"}, "user_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "User Id"}, "group_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Group Id"}, "is_default": {"type": "boolean", "title": "Is Default"}, "version": {"type": "integer", "title": "Version", "description": "Optimistic locking version"}}, "type": "object", "required": ["config_id", "name", "model_type", "config", "is_default", "version"], "title": "LLMModelConfigResponse", "description": "LLM model configuration response (without API key for security)."}, "LLMModelConfigUpdate": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 100, "minLength": 1}, {"type": "null"}], "title": "Name"}, "model_type": {"anyOf": [{"type": "string", "enum": ["text", "vision", "stt", "tts", "multimodal", "embedding", "reranking", "other"]}, {"type": "null"}], "title": "Model Type"}, "is_default": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Is Default"}, "expected_version": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Expected Version", "description": "Expected version for optimistic locking"}, "provider": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Provider"}, "base_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Base Url"}, "model": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Model"}, "temperature": {"anyOf": [{"type": "number", "maximum": 2.0, "minimum": 0.0}, {"type": "null"}], "title": "Temperature"}, "api_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Api Key"}, "max_tokens": {"anyOf": [{"type": "integer"}, {"type": "string"}, {"type": "null"}], "title": "Max Tokens", "description": "Max tokens for generation (can be null)"}, "context_limit": {"anyOf": [{"type": "integer", "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Context Limit", "description": "Model context window limit in K tokens (e.g., 128 = 128K tokens)"}, "context_strategy": {"anyOf": [{"type": "string", "enum": ["conservative", "balanced", "aggressive"]}, {"type": "null"}], "title": "Context Strategy", "description": "Context trimming strategy"}, "copilot_mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Copilot Mode", "description": "GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'"}}, "additionalProperties": true, "type": "object", "title": "LLMModelConfigUpdate", "description": "Request to update an existing LLM model configuration."}, "LLMModelConfigWithSource": {"properties": {"created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "updated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Updated At"}, "config_id": {"type": "string", "format": "uuid", "title": "Config Id"}, "name": {"type": "string", "title": "Name"}, "model_type": {"type": "string", "enum": ["text", "vision", "stt", "tts", "multimodal", "embedding", "reranking", "other"], "title": "Model Type"}, "config": {"$ref": "#/components/schemas/LLMModelConfigDataWithoutSecret"}, "user_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "User Id"}, "group_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Group Id"}, "is_default": {"type": "boolean", "title": "Is Default"}, "version": {"type": "integer", "title": "Version"}, "source": {"type": "string", "title": "Source", "description": "Source: 'user' or 'group'"}, "group_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Name", "description": "Group name if source is 'group'"}}, "additionalProperties": true, "type": "object", "required": ["config_id", "name", "model_type", "config", "is_default", "version", "source"], "title": "LLMModelConfigWithSource", "description": "Model configuration with source information (for inheritance, without API key for security)."}, "Label": {"properties": {"text": {"type": "string", "title": "Text"}, "style": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Style", "description": "SVG style attribute. Apply default style if null"}, "x": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "X", "description": "Relative X position of the label. Center it if null"}, "y": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Y", "description": "Relative Y position of the label"}, "rotation": {"anyOf": [{"type": "integer", "maximum": 360.0, "minimum": -359.0}, {"type": "null"}], "title": "Rotation", "description": "Rotation of the label"}}, "type": "object", "required": ["text"], "title": "Label", "description": "Label data."}, "Link": {"properties": {"nodes": {"anyOf": [{"items": {"$ref": "#/components/schemas/LinkNode"}, "type": "array", "maxItems": 2, "minItems": 0}, {"type": "null"}], "title": "Nodes"}, "suspend": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Suspend"}, "link_style": {"anyOf": [{"$ref": "#/components/schemas/LinkStyle"}, {"type": "null"}]}, "filters": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Filters"}, "markers": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Markers", "description": "Traffic-insight markers on this link: name \u2192 {bpf, tag, enabled}"}, "show_filters_icon": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Filters Icon", "description": "Show filters icon in Web UI", "default": true}, "link_id": {"type": "string", "format": "uuid", "title": "Link Id"}, "project_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Project Id"}, "link_type": {"anyOf": [{"$ref": "#/components/schemas/gns3server__schemas__controller__links__LinkType"}, {"type": "null"}]}, "capturing": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Capturing", "description": "Read only property. True if a capture running on the link"}, "capture_file_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Capture File Name", "description": "Read only property. The name of the capture file if a capture is running"}, "capture_file_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Capture File Path", "description": "Read only property. The full path of the capture file if a capture is running"}, "capture_compute_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Capture Compute Id", "description": "Read only property. The compute identifier where a capture is running"}, "wireshark": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Wireshark", "description": "Read only property. True if a Web Wireshark session is active on the link", "default": false}}, "type": "object", "required": ["link_id"], "title": "Link"}, "LinkCapture": {"properties": {"data_link_type": {"type": "string", "title": "Data Link Type", "default": "DLT_EN10MB"}, "capture_file_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Capture File Name"}, "wireshark": {"type": "boolean", "title": "Wireshark", "default": false}}, "type": "object", "title": "LinkCapture", "description": "Link capture data."}, "LinkCreate": {"properties": {"nodes": {"items": {"$ref": "#/components/schemas/LinkNode"}, "type": "array", "maxItems": 2, "minItems": 2, "title": "Nodes"}, "suspend": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Suspend"}, "link_style": {"anyOf": [{"$ref": "#/components/schemas/LinkStyle"}, {"type": "null"}]}, "filters": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Filters"}, "markers": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Markers", "description": "Traffic-insight markers on this link: name \u2192 {bpf, tag, enabled}"}, "show_filters_icon": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Filters Icon", "description": "Show filters icon in Web UI", "default": true}, "link_id": {"type": "string", "format": "uuid", "title": "Link Id"}}, "type": "object", "required": ["nodes"], "title": "LinkCreate"}, "LinkNode": {"properties": {"node_id": {"type": "string", "format": "uuid", "title": "Node Id"}, "adapter_number": {"type": "integer", "title": "Adapter Number"}, "port_number": {"type": "integer", "title": "Port Number"}, "label": {"anyOf": [{"$ref": "#/components/schemas/Label"}, {"type": "null"}]}}, "type": "object", "required": ["node_id", "adapter_number", "port_number"], "title": "LinkNode", "description": "Link node data."}, "LinkStyle": {"properties": {"color": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Color"}, "width": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Width"}, "type": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Type"}, "link_type": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Link Type"}, "bezier_curviness": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Bezier Curviness"}, "flowchart_roundness": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Flowchart Roundness"}, "control_offset": {"anyOf": [{"prefixItems": [{"type": "number"}, {"type": "number"}], "type": "array", "maxItems": 2, "minItems": 2}, {"type": "null"}], "title": "Control Offset"}}, "type": "object", "title": "LinkStyle"}, "LinkUpdate": {"properties": {"nodes": {"anyOf": [{"items": {"$ref": "#/components/schemas/LinkNode"}, "type": "array", "maxItems": 2, "minItems": 0}, {"type": "null"}], "title": "Nodes"}, "suspend": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Suspend"}, "link_style": {"anyOf": [{"$ref": "#/components/schemas/LinkStyle"}, {"type": "null"}]}, "filters": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Filters"}, "markers": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Markers", "description": "Traffic-insight markers on this link: name \u2192 {bpf, tag, enabled}"}, "show_filters_icon": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Filters Icon", "description": "Show filters icon in Web UI", "default": true}}, "type": "object", "title": "LinkUpdate"}, "LoggedInUserUpdate": {"properties": {"password": {"anyOf": [{"type": "string", "maxLength": 100, "minLength": 8, "format": "password", "writeOnly": true}, {"type": "null"}], "title": "Password"}, "email": {"anyOf": [{"type": "string", "format": "email"}, {"type": "null"}], "title": "Email"}, "full_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Full Name"}}, "type": "object", "title": "LoggedInUserUpdate", "description": "Properties to update a logged-in user."}, "MarkerCreate": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 32, "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*$"}, {"type": "null"}], "title": "Name", "description": "Unique marker name on the link. Auto-generated when absent."}, "bpf": {"type": "string", "title": "Bpf"}, "tag": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Tag"}, "link_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Link Id"}, "color": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Color", "description": "User-chosen hex color for this marker in the Web UI, e.g. '#ff5722'"}, "highlight_duration": {"anyOf": [{"type": "integer", "minimum": 1.0}, {"type": "null"}], "title": "Highlight Duration", "description": "How long (milliseconds) the Web UI keeps this marker highlighted after a match. Omitted = use the UI default. Pure render hint \u2014 stored on the link, never sent to uBridge."}, "enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Enabled", "description": "Whether the marker is active. Defaults to true on creation."}, "direction": {"anyOf": [{"type": "string", "pattern": "^(tx|rx|both)$"}, {"type": "null"}], "title": "Direction", "description": "Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only, 'both' or null = both directions."}, "capture_node_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Capture Node Id", "description": "Which endpoint's uBridge hosts this marker (the 'observer'). tx/rx in `direction` are interpreted from this node's perspective. Must be one of the link's two endpoints and a marker-capable type. Omitted = server auto-picks (first started marker-capable endpoint)."}, "data_link_type": {"type": "string", "title": "Data Link Type", "description": "pcap link-layer type the marker's BPF compiles against and its capture file is written with (a uBridge `linktype` token). Defaults to DLT_EN10MB (Ethernet), which is omitted from the uBridge command. Only meaningful for serial links: set it to the matching serial DLT from the port's data_link_types \u2014 DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / DLT_ATM_RFC1483 \u2014 so the BPF offsets and pcap decode match the encapsulation configured in IOS. Create-only (changing it would invalidate the pcap).", "default": "DLT_EN10MB"}}, "type": "object", "required": ["bpf"], "title": "MarkerCreate", "description": "Body for attaching a traffic-insight marker to a link.\n\n``name`` is optional at the controller REST layer (auto-generated when\nabsent) but always set when the controller forwards to the compute."}, "MarkerDefinitionCreate": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 32, "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*$"}, {"type": "null"}], "title": "Name", "description": "Unique definition name. Auto-generated when absent."}, "bpf": {"type": "string", "title": "Bpf"}, "tag": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Tag"}, "color": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Color", "description": "User-chosen hex color for the marker in the Web UI, e.g. '#ff5722'"}, "highlight_duration": {"anyOf": [{"type": "integer", "minimum": 1.0}, {"type": "null"}], "title": "Highlight Duration", "description": "How long (milliseconds) the Web UI keeps this marker highlighted after a match. Omitted = use the UI default. Pure render hint \u2014 stored with the definition, never sent to uBridge."}, "direction": {"anyOf": [{"type": "string", "pattern": "^(tx|rx|both)$"}, {"type": "null"}], "title": "Direction", "description": "Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only, 'both' or null = both directions."}, "data_link_type": {"type": "string", "title": "Data Link Type", "description": "pcap link-layer type for inherited markers on serial links (uBridge `linktype`). Defaults to DLT_EN10MB (Ethernet): the definition then applies only to Ethernet links and serial links are skipped. Set a serial DLT \u2014 DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / DLT_ATM_RFC1483 \u2014 to also cover serial links with that encapsulation; Ethernet links stay EN10MB regardless. Changing it re-fans-out.", "default": "DLT_EN10MB"}}, "type": "object", "required": ["bpf"], "title": "MarkerDefinitionCreate", "description": "Body for creating / updating a project-level marker definition.\n\nThe definition is a template \u2014 when applied to a link the marker name is\nprefixed with ``global-`` (e.g. ``arp`` \u2192 ``global-arp``) so it can never\ncollide with a per-link private marker."}, "MarkerUpdate": {"properties": {"bpf": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Bpf"}, "tag": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Tag"}, "direction": {"anyOf": [{"type": "string", "pattern": "^(tx|rx|both)$"}, {"type": "null"}], "title": "Direction", "description": "Direction filter; 'both' or an explicit null clears it to both. Omit to keep."}, "color": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Color", "description": "Hex color render hint, e.g. '#ff5722'"}, "highlight_duration": {"anyOf": [{"type": "integer", "minimum": 1.0}, {"type": "null"}], "title": "Highlight Duration", "description": "UI highlight duration in ms; null = UI default"}, "enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Enabled", "description": "Toggle the marker on/off (instant)."}}, "type": "object", "title": "MarkerUpdate", "description": "Body for updating a marker \u2014 partial update, every field optional.\n\n``bpf`` is optional here (it is required on create). ``capture_node_id`` and\n``name`` are create-only / path-driven and intentionally absent; an explicit\n``direction: null`` clears the direction back to both (omitting keeps it)."}, "NetmikoDeviceType": {"properties": {"name": {"type": "string", "title": "Name", "description": "Device type name to store in the netmiko_device_type field"}, "telnet": {"type": "boolean", "title": "Telnet", "description": "Whether the device type connects over Telnet", "default": false}, "custom": {"type": "boolean", "title": "Custom", "description": "Whether the device type is a GNS3-copilot custom driver (gns3_ prefix)", "default": false}}, "type": "object", "required": ["name"], "title": "NetmikoDeviceType", "description": "A Netmiko device type supported by the installed Netmiko library."}, "NetmikoDeviceTypeList": {"properties": {"netmiko_version": {"type": "string", "title": "Netmiko Version", "description": "Version of the installed Netmiko library"}, "device_types": {"items": {"$ref": "#/components/schemas/NetmikoDeviceType"}, "type": "array", "title": "Device Types", "description": "Supported device types, sorted by name"}}, "type": "object", "required": ["netmiko_version", "device_types"], "title": "NetmikoDeviceTypeList", "description": "List of Netmiko device types supported by the installed Netmiko library."}, "Node": {"properties": {"compute_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "string"}], "title": "Compute Id"}, "name": {"type": "string", "title": "Name"}, "node_type": {"$ref": "#/components/schemas/NodeType"}, "node_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Node Id"}, "console": {"anyOf": [{"type": "integer", "maximum": 65535.0, "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Console", "description": "Console TCP port"}, "console_type": {"anyOf": [{"$ref": "#/components/schemas/ConsoleType"}, {"type": "null"}]}, "console_auto_start": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Console Auto Start", "description": "Automatically start the console when the node has started", "default": false}, "netmiko_device_type": {"anyOf": [{"type": "string", "pattern": "^[a-z0-9_]+$|^$"}, {"type": "null"}], "title": "Netmiko Device Type", "description": "Device type for Netmiko-based automation tools, overrides the template value"}, "default_username": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Username", "description": "Default username to log into the node, seeded from the template appliance metadata"}, "default_password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Password", "description": "Default password to log into the node, seeded from the template appliance metadata"}, "aux": {"anyOf": [{"type": "integer", "maximum": 65535.0, "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Aux", "description": "Auxiliary console TCP port"}, "aux_type": {"anyOf": [{"$ref": "#/components/schemas/ConsoleType"}, {"type": "null"}]}, "properties": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Properties", "description": "Properties specific to an emulator"}, "label": {"anyOf": [{"$ref": "#/components/schemas/Label"}, {"type": "null"}]}, "symbol": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Symbol"}, "x": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "X", "default": 0}, "y": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Y", "default": 0}, "z": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Z", "default": 1}, "locked": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Locked", "description": "Whether the element locked or not", "default": false}, "port_name_format": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Port Name Format", "description": "Formatting for port name {0} will be replace by port number"}, "port_segment_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Port Segment Size", "description": "Size of the port segment"}, "first_port_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "First Port Name", "description": "Name of the first port"}, "custom_adapters": {"anyOf": [{"items": {"$ref": "#/components/schemas/CustomAdapter"}, "type": "array"}, {"type": "null"}], "title": "Custom Adapters"}, "tags": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Tags", "description": "User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')"}, "template_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Template Id", "description": "Template UUID from which the node has been created. Read only"}, "project_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Project Id"}, "node_directory": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Directory", "description": "Working directory of the node. Read only"}, "status": {"anyOf": [{"$ref": "#/components/schemas/NodeStatus"}, {"type": "null"}], "description": "Node status. Read only"}, "command_line": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Command Line", "description": "Command line use to start the node. Read only"}, "width": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Width", "description": "Width of the node. Read only"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Height", "description": "Height of the node. Read only"}, "ports": {"anyOf": [{"items": {"$ref": "#/components/schemas/NodePort"}, "type": "array"}, {"type": "null"}], "title": "Ports", "description": "List of node ports. Read only"}, "console_host": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Console Host", "description": "Console host. Warning if the host is 0.0.0.0 or :: (listen on all interfaces) you need to use the same address you use to connect to the controller"}}, "type": "object", "required": ["compute_id", "name", "node_type"], "title": "Node"}, "NodeCreate": {"properties": {"compute_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "string"}], "title": "Compute Id"}, "name": {"type": "string", "title": "Name"}, "node_type": {"$ref": "#/components/schemas/NodeType"}, "node_id": {"type": "string", "format": "uuid", "title": "Node Id"}, "console": {"anyOf": [{"type": "integer", "maximum": 65535.0, "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Console", "description": "Console TCP port"}, "console_type": {"anyOf": [{"$ref": "#/components/schemas/ConsoleType"}, {"type": "null"}]}, "console_auto_start": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Console Auto Start", "description": "Automatically start the console when the node has started", "default": false}, "netmiko_device_type": {"anyOf": [{"type": "string", "pattern": "^[a-z0-9_]+$|^$"}, {"type": "null"}], "title": "Netmiko Device Type", "description": "Device type for Netmiko-based automation tools, overrides the template value"}, "default_username": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Username", "description": "Default username to log into the node, seeded from the template appliance metadata"}, "default_password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Password", "description": "Default password to log into the node, seeded from the template appliance metadata"}, "aux": {"anyOf": [{"type": "integer", "maximum": 65535.0, "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Aux", "description": "Auxiliary console TCP port"}, "aux_type": {"anyOf": [{"$ref": "#/components/schemas/ConsoleType"}, {"type": "null"}]}, "properties": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Properties", "description": "Properties specific to an emulator"}, "label": {"anyOf": [{"$ref": "#/components/schemas/Label"}, {"type": "null"}]}, "symbol": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Symbol"}, "x": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "X", "default": 0}, "y": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Y", "default": 0}, "z": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Z", "default": 1}, "locked": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Locked", "description": "Whether the element locked or not", "default": false}, "port_name_format": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Port Name Format", "description": "Formatting for port name {0} will be replace by port number"}, "port_segment_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Port Segment Size", "description": "Size of the port segment"}, "first_port_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "First Port Name", "description": "Name of the first port"}, "custom_adapters": {"anyOf": [{"items": {"$ref": "#/components/schemas/CustomAdapter"}, "type": "array"}, {"type": "null"}], "title": "Custom Adapters"}, "tags": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Tags", "description": "User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')"}}, "type": "object", "required": ["compute_id", "name", "node_type"], "title": "NodeCreate"}, "NodeDuplicate": {"properties": {"x": {"type": "integer", "title": "X"}, "y": {"type": "integer", "title": "Y"}, "z": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Z", "default": 0}}, "type": "object", "required": ["x", "y"], "title": "NodeDuplicate", "description": "Data to duplicate a node."}, "NodeFile": {"properties": {"path": {"type": "string", "title": "Path", "description": "File name"}, "size": {"type": "integer", "title": "Size", "description": "File size in bytes"}, "created_at": {"type": "string", "title": "Created At", "description": "File creation time (ISO 8601)"}, "modified_at": {"type": "string", "title": "Modified At", "description": "File modification time (ISO 8601)"}, "file_type": {"type": "string", "title": "File Type", "description": "File type determined by the file command"}}, "type": "object", "required": ["path", "size", "created_at", "modified_at", "file_type"], "title": "NodeFile", "description": "Detailed file information for node files."}, "NodePort": {"properties": {"name": {"type": "string", "title": "Name", "description": "Port name"}, "short_name": {"type": "string", "title": "Short Name", "description": "Port name"}, "adapter_number": {"type": "integer", "title": "Adapter Number", "description": "Adapter slot"}, "adapter_type": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Adapter Type", "description": "Adapter type"}, "port_number": {"type": "integer", "title": "Port Number", "description": "Port slot"}, "link_type": {"$ref": "#/components/schemas/gns3server__schemas__controller__nodes__LinkType", "description": "Type of link"}, "data_link_types": {"additionalProperties": true, "type": "object", "title": "Data Link Types", "description": "Available PCAP types for capture"}, "mac_address": {"anyOf": [{"type": "string", "pattern": "^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$"}, {"type": "null"}], "title": "Mac Address"}}, "type": "object", "required": ["name", "short_name", "adapter_number", "port_number", "link_type", "data_link_types"], "title": "NodePort", "description": "Node port data."}, "NodeStatus": {"type": "string", "enum": ["stopped", "started", "suspended"], "title": "NodeStatus", "description": "Supported node statuses."}, "NodeType": {"type": "string", "enum": ["cloud", "nat", "ethernet_hub", "ethernet_switch", "frame_relay_switch", "atm_switch", "docker", "dynamips", "vpcs", "virtualbox", "vmware", "iou", "qemu"], "title": "NodeType", "description": "Supported node types."}, "NodeUpdate": {"properties": {"compute_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "string"}, {"type": "null"}], "title": "Compute Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "node_type": {"anyOf": [{"$ref": "#/components/schemas/NodeType"}, {"type": "null"}]}, "node_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Node Id"}, "console": {"anyOf": [{"type": "integer", "maximum": 65535.0, "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Console", "description": "Console TCP port"}, "console_type": {"anyOf": [{"$ref": "#/components/schemas/ConsoleType"}, {"type": "null"}]}, "console_auto_start": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Console Auto Start", "description": "Automatically start the console when the node has started", "default": false}, "netmiko_device_type": {"anyOf": [{"type": "string", "pattern": "^[a-z0-9_]+$|^$"}, {"type": "null"}], "title": "Netmiko Device Type", "description": "Device type for Netmiko-based automation tools, overrides the template value"}, "default_username": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Username", "description": "Default username to log into the node, seeded from the template appliance metadata"}, "default_password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Password", "description": "Default password to log into the node, seeded from the template appliance metadata"}, "aux": {"anyOf": [{"type": "integer", "maximum": 65535.0, "exclusiveMinimum": 0.0}, {"type": "null"}], "title": "Aux", "description": "Auxiliary console TCP port"}, "aux_type": {"anyOf": [{"$ref": "#/components/schemas/ConsoleType"}, {"type": "null"}]}, "properties": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Properties", "description": "Properties specific to an emulator"}, "label": {"anyOf": [{"$ref": "#/components/schemas/Label"}, {"type": "null"}]}, "symbol": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Symbol"}, "x": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "X", "default": 0}, "y": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Y", "default": 0}, "z": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Z", "default": 1}, "locked": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Locked", "description": "Whether the element locked or not", "default": false}, "port_name_format": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Port Name Format", "description": "Formatting for port name {0} will be replace by port number"}, "port_segment_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Port Segment Size", "description": "Size of the port segment"}, "first_port_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "First Port Name", "description": "Name of the first port"}, "custom_adapters": {"anyOf": [{"items": {"$ref": "#/components/schemas/CustomAdapter"}, "type": "array"}, {"type": "null"}], "title": "Custom Adapters"}, "tags": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Tags", "description": "User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')"}}, "type": "object", "title": "NodeUpdate", "description": "Data to update a node."}, "OpenAIMessage": {"properties": {"id": {"type": "string", "title": "Id", "description": "Message ID"}, "role": {"type": "string", "enum": ["user", "assistant", "system", "tool"], "title": "Role", "description": "Message role"}, "content": {"type": "string", "title": "Content", "description": "Message content"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name", "description": "Tool message name"}, "tool_call_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Tool Call Id", "description": "Associated tool call ID (for tool messages)"}, "tool_calls": {"anyOf": [{"items": {"$ref": "#/components/schemas/OpenAIToolCall"}, "type": "array"}, {"type": "null"}], "title": "Tool Calls", "description": "Tool calls (for assistant messages)"}, "metadata": {"additionalProperties": true, "type": "object", "title": "Metadata", "description": "Message metadata (includes created_at)"}}, "type": "object", "required": ["id", "role", "content"], "title": "OpenAIMessage", "description": "Message model for conversation history."}, "OpenAIToolCall": {"properties": {"id": {"type": "string", "title": "Id", "description": "Tool call ID"}, "type": {"type": "string", "const": "function", "title": "Type", "description": "Tool call type", "default": "function"}, "function": {"additionalProperties": true, "type": "object", "title": "Function", "description": "Function name and arguments"}}, "type": "object", "required": ["id", "function"], "title": "OpenAIToolCall", "description": "Tool call information (OpenAI compatible format)."}, "Privilege": {"properties": {"name": {"type": "string", "title": "Name"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "updated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Updated At"}, "privilege_id": {"type": "string", "format": "uuid", "title": "Privilege Id"}}, "type": "object", "required": ["name", "privilege_id"], "title": "Privilege"}, "Project": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "project_id": {"type": "string", "format": "uuid", "title": "Project Id"}, "path": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Path", "description": "Project directory"}, "auto_close": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Auto Close", "description": "Close project when last client leaves"}, "auto_open": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Auto Open", "description": "Project opens when GNS3 starts"}, "auto_start": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Auto Start", "description": "Project starts when opened"}, "scene_height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Scene Height", "description": "Height of the drawing area"}, "scene_width": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Scene Width", "description": "Width of the drawing area"}, "zoom": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Zoom", "description": "Zoom of the drawing area"}, "show_layers": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Layers", "description": "Show layers on the drawing area"}, "snap_to_grid": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Snap To Grid", "description": "Snap to grid on the drawing area"}, "show_grid": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Grid", "description": "Show the grid on the drawing area"}, "grid_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Grid Size", "description": "Grid size for the drawing area for nodes"}, "drawing_grid_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Drawing Grid Size", "description": "Grid size for the drawing area for drawings"}, "show_interface_labels": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Interface Labels", "description": "Show interface labels on the drawing area"}, "supplier": {"anyOf": [{"$ref": "#/components/schemas/Supplier"}, {"type": "null"}], "description": "Supplier of the project"}, "variables": {"anyOf": [{"items": {"$ref": "#/components/schemas/Variable"}, "type": "array"}, {"type": "null"}], "title": "Variables", "description": "Variables required to run the project"}, "status": {"anyOf": [{"$ref": "#/components/schemas/ProjectStatus"}, {"type": "null"}]}, "filename": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Filename"}, "created_by": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Created By", "description": "Username of the user who created the project"}}, "type": "object", "required": ["project_id"], "title": "Project"}, "ProjectCompression": {"type": "string", "enum": ["none", "zip", "bzip2", "lzma", "zstd"], "title": "ProjectCompression", "description": "Supported project compression."}, "ProjectCreate": {"properties": {"name": {"type": "string", "title": "Name"}, "project_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Project Id"}, "path": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Path", "description": "Project directory"}, "auto_close": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Auto Close", "description": "Close project when last client leaves"}, "auto_open": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Auto Open", "description": "Project opens when GNS3 starts"}, "auto_start": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Auto Start", "description": "Project starts when opened"}, "scene_height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Scene Height", "description": "Height of the drawing area"}, "scene_width": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Scene Width", "description": "Width of the drawing area"}, "zoom": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Zoom", "description": "Zoom of the drawing area"}, "show_layers": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Layers", "description": "Show layers on the drawing area"}, "snap_to_grid": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Snap To Grid", "description": "Snap to grid on the drawing area"}, "show_grid": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Grid", "description": "Show the grid on the drawing area"}, "grid_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Grid Size", "description": "Grid size for the drawing area for nodes"}, "drawing_grid_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Drawing Grid Size", "description": "Grid size for the drawing area for drawings"}, "show_interface_labels": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Interface Labels", "description": "Show interface labels on the drawing area"}, "supplier": {"anyOf": [{"$ref": "#/components/schemas/Supplier"}, {"type": "null"}], "description": "Supplier of the project"}, "variables": {"anyOf": [{"items": {"$ref": "#/components/schemas/Variable"}, "type": "array"}, {"type": "null"}], "title": "Variables", "description": "Variables required to run the project"}}, "type": "object", "required": ["name"], "title": "ProjectCreate", "description": "Properties for project creation."}, "ProjectDuplicate": {"properties": {"name": {"type": "string", "title": "Name"}, "project_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Project Id"}, "path": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Path", "description": "Project directory"}, "auto_close": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Auto Close", "description": "Close project when last client leaves"}, "auto_open": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Auto Open", "description": "Project opens when GNS3 starts"}, "auto_start": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Auto Start", "description": "Project starts when opened"}, "scene_height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Scene Height", "description": "Height of the drawing area"}, "scene_width": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Scene Width", "description": "Width of the drawing area"}, "zoom": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Zoom", "description": "Zoom of the drawing area"}, "show_layers": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Layers", "description": "Show layers on the drawing area"}, "snap_to_grid": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Snap To Grid", "description": "Snap to grid on the drawing area"}, "show_grid": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Grid", "description": "Show the grid on the drawing area"}, "grid_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Grid Size", "description": "Grid size for the drawing area for nodes"}, "drawing_grid_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Drawing Grid Size", "description": "Grid size for the drawing area for drawings"}, "show_interface_labels": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Interface Labels", "description": "Show interface labels on the drawing area"}, "supplier": {"anyOf": [{"$ref": "#/components/schemas/Supplier"}, {"type": "null"}], "description": "Supplier of the project"}, "variables": {"anyOf": [{"items": {"$ref": "#/components/schemas/Variable"}, "type": "array"}, {"type": "null"}], "title": "Variables", "description": "Variables required to run the project"}, "reset_mac_addresses": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Reset Mac Addresses", "description": "Reset MAC addresses for this project", "default": false}}, "type": "object", "required": ["name"], "title": "ProjectDuplicate", "description": "Properties for project duplication."}, "ProjectStatus": {"type": "string", "enum": ["opened", "closed"], "title": "ProjectStatus", "description": "Supported project statuses."}, "ProjectUpdate": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "project_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Project Id"}, "path": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Path", "description": "Project directory"}, "auto_close": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Auto Close", "description": "Close project when last client leaves"}, "auto_open": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Auto Open", "description": "Project opens when GNS3 starts"}, "auto_start": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Auto Start", "description": "Project starts when opened"}, "scene_height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Scene Height", "description": "Height of the drawing area"}, "scene_width": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Scene Width", "description": "Width of the drawing area"}, "zoom": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Zoom", "description": "Zoom of the drawing area"}, "show_layers": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Layers", "description": "Show layers on the drawing area"}, "snap_to_grid": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Snap To Grid", "description": "Snap to grid on the drawing area"}, "show_grid": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Grid", "description": "Show the grid on the drawing area"}, "grid_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Grid Size", "description": "Grid size for the drawing area for nodes"}, "drawing_grid_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Drawing Grid Size", "description": "Grid size for the drawing area for drawings"}, "show_interface_labels": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Show Interface Labels", "description": "Show interface labels on the drawing area"}, "supplier": {"anyOf": [{"$ref": "#/components/schemas/Supplier"}, {"type": "null"}], "description": "Supplier of the project"}, "variables": {"anyOf": [{"items": {"$ref": "#/components/schemas/Variable"}, "type": "array"}, {"type": "null"}], "title": "Variables", "description": "Variables required to run the project"}}, "type": "object", "title": "ProjectUpdate", "description": "Properties for project update."}, "Protocol": {"type": "string", "enum": ["http", "https"], "title": "Protocol", "description": "Protocol supported to communicate with a compute."}, "Qemu": {"properties": {"adapter_type": {"$ref": "#/components/schemas/QemuAdapterType", "title": "Type of network adapter"}, "adapters": {"type": "integer", "title": "Number of adapters"}, "ram": {"type": "integer", "title": "RAM allocated to the appliance (MB)"}, "cpus": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Number of Virtual CPU"}, "hda_disk_interface": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskInterface"}, {"type": "null"}], "title": "Disk interface for the installed hda_disk_image"}, "hdb_disk_interface": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskInterface"}, {"type": "null"}], "title": "Disk interface for the installed hdb_disk_image"}, "hdc_disk_interface": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskInterface"}, {"type": "null"}], "title": "Disk interface for the installed hdc_disk_image"}, "hdd_disk_interface": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskInterface"}, {"type": "null"}], "title": "Disk interface for the installed hdd_disk_image"}, "arch": {"$ref": "#/components/schemas/QemuPlatform", "title": "Architecture emulated"}, "console_type": {"$ref": "#/components/schemas/QemuConsoleType", "title": "Type of console connection for the administration of the appliance"}, "boot_priority": {"anyOf": [{"$ref": "#/components/schemas/QemuBootPriority"}, {"type": "null"}], "title": "Disk boot priority"}, "kernel_command_line": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Command line parameters sent to the kernel"}, "kvm": {"$ref": "#/components/schemas/Kvm", "title": "KVM requirements"}, "options": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Optional additional qemu command line options"}, "cpu_throttling": {"anyOf": [{"type": "number", "maximum": 100.0, "minimum": 0.0}, {"type": "null"}], "title": "Throttle the CPU"}, "on_close": {"anyOf": [{"$ref": "#/components/schemas/QemuOnClose"}, {"type": "null"}], "title": "Action to execute on the VM is closed"}, "process_priority": {"anyOf": [{"$ref": "#/components/schemas/QemuProcessPriority"}, {"type": "null"}], "title": "Process priority for QEMU"}}, "type": "object", "required": ["adapter_type", "adapters", "ram", "arch", "console_type", "kvm"], "title": "Qemu", "description": "QEMU configuration for v1-6"}, "QemuAdapterType": {"type": "string", "enum": ["e1000", "i82550", "i82551", "i82557a", "i82557b", "i82557c", "i82558a", "i82558b", "i82559a", "i82559b", "i82559c", "i82559er", "i82562", "i82801", "igb", "ne2k_pci", "pcnet", "rtl8139", "virtio", "virtio-net-pci", "vmxnet3"], "title": "QemuAdapterType", "description": "Qemu adapter type enum"}, "QemuBootPriority": {"type": "string", "enum": ["c", "d", "n", "cn", "cd", "dn", "dc", "nc", "nd"], "title": "QemuBootPriority", "description": "Boot priority enum"}, "QemuConsoleType": {"type": "string", "enum": ["telnet", "ssh", "vnc", "spice", "spice+agent", "none"], "title": "QemuConsoleType", "description": "Qemu console type enum"}, "QemuDiskImageAdapterType": {"type": "string", "enum": ["ide", "lsilogic", "buslogic", "legacyESX"], "title": "QemuDiskImageAdapterType", "description": "Supported Qemu disk image on/off options."}, "QemuDiskImageCreate": {"properties": {"format": {"$ref": "#/components/schemas/QemuDiskImageFormat", "description": "Image format type"}, "size": {"type": "integer", "title": "Size", "description": "Image size in Megabytes"}, "preallocation": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImagePreallocation"}, {"type": "null"}]}, "cluster_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cluster Size"}, "refcount_bits": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Refcount Bits"}, "lazy_refcounts": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImageOnOff"}, {"type": "null"}]}, "subformat": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImageSubformat"}, {"type": "null"}]}, "static": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImageOnOff"}, {"type": "null"}]}, "zeroed_grain": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImageOnOff"}, {"type": "null"}]}, "adapter_type": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImageAdapterType"}, {"type": "null"}]}}, "type": "object", "required": ["format", "size"], "title": "QemuDiskImageCreate"}, "QemuDiskImageFormat": {"type": "string", "enum": ["qcow2", "qcow", "vpc", "vdi", "vdmk", "raw"], "title": "QemuDiskImageFormat", "description": "Supported Qemu disk image formats."}, "QemuDiskImageOnOff": {"type": "string", "enum": ["on", "off"], "title": "QemuDiskImageOnOff", "description": "Supported Qemu image on/off options."}, "QemuDiskImagePreallocation": {"type": "string", "enum": ["off", "metadata", "falloc", "full"], "title": "QemuDiskImagePreallocation", "description": "Supported Qemu disk image pre-allocation options."}, "QemuDiskImageSubformat": {"type": "string", "enum": ["dynamic", "fixed", "streamOptimized", "twoGbMaxExtentSparse", "twoGbMaxExtentFlat", "monolithicSparse", "monolithicFlat"], "title": "QemuDiskImageSubformat", "description": "Supported Qemu disk image sub-format options."}, "QemuDiskImageUpdate": {"properties": {"format": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImageFormat"}, {"type": "null"}], "description": "Image format type"}, "size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Size", "description": "Image size in Megabytes"}, "preallocation": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImagePreallocation"}, {"type": "null"}]}, "cluster_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cluster Size"}, "refcount_bits": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Refcount Bits"}, "lazy_refcounts": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImageOnOff"}, {"type": "null"}]}, "subformat": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImageSubformat"}, {"type": "null"}]}, "static": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImageOnOff"}, {"type": "null"}]}, "zeroed_grain": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImageOnOff"}, {"type": "null"}]}, "adapter_type": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskImageAdapterType"}, {"type": "null"}]}, "extend": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Extend", "description": "Number of Megabytes to extend the image"}}, "type": "object", "title": "QemuDiskImageUpdate"}, "QemuDiskInterface": {"type": "string", "enum": ["ide", "sata", "nvme", "scsi", "sd", "mtd", "floppy", "pflash", "virtio", "none"], "title": "QemuDiskInterface", "description": "Disk interface enum"}, "QemuOnClose": {"type": "string", "enum": ["power_off", "shutdown_signal", "save_vm_state"], "title": "QemuOnClose", "description": "Qemu on_close action enum"}, "QemuPlatform": {"type": "string", "enum": ["aarch64", "alpha", "arm", "cris", "i386", "lm32", "m68k", "microblaze", "microblazeel", "mips", "mips64", "mips64el", "mipsel", "moxie", "or32", "ppc", "ppc64", "ppcemb", "s390x", "sh4", "sh4eb", "sparc", "sparc64", "tricore", "unicore32", "x86_64", "xtensa", "xtensaeb"], "title": "QemuPlatform", "description": "Qemu platform enum"}, "QemuProcessPriority": {"type": "string", "enum": ["realtime", "very high", "high", "normal", "low", "very low"], "title": "QemuProcessPriority", "description": "Qemu process priority enum"}, "QemuPropertiesV8": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name of the template"}, "category": {"anyOf": [{"$ref": "#/components/schemas/gns3server__schemas__controller__appliances__Category"}, {"type": "null"}], "title": "Category of the template"}, "default_name_format": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default name format"}, "usage": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "How to use the template"}, "symbol": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Symbol of the template"}, "adapter_type": {"anyOf": [{"$ref": "#/components/schemas/QemuAdapterType"}, {"type": "null"}], "title": "Type of network adapter"}, "adapters": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Number of adapters"}, "custom_adapters": {"anyOf": [{"items": {"$ref": "#/components/schemas/CustomAdapterItem"}, "type": "array"}, {"type": "null"}], "title": "Custom adapters"}, "first_port_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Optional name of the first networking port example: eth0"}, "port_name_format": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Optional formating of the networking port example: eth{0}"}, "port_segment_size": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2"}, "linked_clone": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "False if you don't want to use a single image for all nodes"}, "ram": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Ram allocated to the appliance (MB)"}, "cpus": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Number of Virtual CPU"}, "hda_disk_interface": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskInterface"}, {"type": "null"}], "title": "Disk interface for the installed hda_disk_image"}, "hdb_disk_interface": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskInterface"}, {"type": "null"}], "title": "Disk interface for the installed hdb_disk_image"}, "hdc_disk_interface": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskInterface"}, {"type": "null"}], "title": "Disk interface for the installed hdc_disk_image"}, "hdd_disk_interface": {"anyOf": [{"$ref": "#/components/schemas/QemuDiskInterface"}, {"type": "null"}], "title": "Disk interface for the installed hdd_disk_image"}, "platform": {"anyOf": [{"$ref": "#/components/schemas/QemuPlatform"}, {"type": "null"}], "title": "Platform to emulate"}, "console_type": {"anyOf": [{"$ref": "#/components/schemas/QemuConsoleType"}, {"type": "null"}], "title": "Type of console connection for the administration of the appliance"}, "boot_priority": {"anyOf": [{"$ref": "#/components/schemas/QemuBootPriority"}, {"type": "null"}], "title": "Optional define the disk boot priory. Refer to -boot option in qemu manual for more details."}, "kernel_command_line": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Command line parameters send to the kernel"}, "kvm": {"anyOf": [{"$ref": "#/components/schemas/Kvm"}, {"type": "null"}], "title": "KVM requirements"}, "options": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Optional additional qemu command line options"}, "cpu_throttling": {"anyOf": [{"type": "integer", "maximum": 800.0, "minimum": 0.0}, {"type": "null"}], "title": "Throttle the CPU"}, "tpm": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Enable the Trusted Platform Module (TPM)"}, "uefi": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Enable the UEFI boot mode"}, "on_close": {"anyOf": [{"$ref": "#/components/schemas/QemuOnClose"}, {"type": "null"}], "title": "Action to execute on the VM is closed"}, "process_priority": {"anyOf": [{"$ref": "#/components/schemas/QemuProcessPriority"}, {"type": "null"}], "title": "Process priority for QEMU"}}, "type": "object", "title": "QemuPropertiesV8", "description": "Qemu template properties (v8)"}, "RefreshTokenRequest": {"properties": {"refresh_token": {"type": "string", "title": "Refresh Token"}}, "type": "object", "required": ["refresh_token"], "title": "RefreshTokenRequest", "description": "Schema for requesting a token refresh."}, "RenameSession": {"properties": {"title": {"type": "string", "maxLength": 255, "minLength": 1, "title": "Title", "description": "New session title"}}, "type": "object", "required": ["title"], "title": "RenameSession", "description": "Rename session request model."}, "Resource": {"properties": {"resource_id": {"type": "string", "format": "uuid", "title": "Resource Id"}, "resource_type": {"$ref": "#/components/schemas/ResourceType", "description": "Type of the resource"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "updated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Updated At"}}, "type": "object", "required": ["resource_id", "resource_type"], "title": "Resource"}, "ResourcePool": {"properties": {"name": {"type": "string", "title": "Name"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "updated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Updated At"}, "resource_pool_id": {"type": "string", "format": "uuid", "title": "Resource Pool Id"}}, "type": "object", "required": ["name", "resource_pool_id"], "title": "ResourcePool"}, "ResourcePoolCreate": {"properties": {"name": {"type": "string", "title": "Name"}}, "type": "object", "required": ["name"], "title": "ResourcePoolCreate", "description": "Properties to create a resource pool."}, "ResourcePoolUpdate": {"properties": {"name": {"type": "string", "title": "Name"}}, "type": "object", "required": ["name"], "title": "ResourcePoolUpdate", "description": "Properties to update a resource pool."}, "ResourceType": {"type": "string", "enum": ["project"], "title": "ResourceType"}, "Role": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "updated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Updated At"}, "role_id": {"type": "string", "format": "uuid", "title": "Role Id"}, "is_builtin": {"type": "boolean", "title": "Is Builtin"}, "privileges": {"items": {"$ref": "#/components/schemas/Privilege"}, "type": "array", "title": "Privileges"}}, "type": "object", "required": ["role_id", "is_builtin", "privileges"], "title": "Role"}, "RoleCreate": {"properties": {"name": {"type": "string", "title": "Name"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description"}}, "type": "object", "required": ["name"], "title": "RoleCreate", "description": "Properties to create a role."}, "RoleUpdate": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description"}}, "type": "object", "title": "RoleUpdate", "description": "Properties to update a role."}, "Snapshot": {"properties": {"name": {"type": "string", "title": "Name", "description": "Name of the snapshot"}, "description": {"type": "string", "title": "Description", "description": "Description of the snapshot"}, "snapshot_id": {"type": "string", "format": "uuid", "title": "Snapshot Id"}, "project_id": {"type": "string", "format": "uuid", "title": "Project Id"}, "filename": {"type": "string", "title": "Filename", "description": "Filename of the snapshot"}, "created_at": {"type": "integer", "title": "Created At", "description": "Date of the snapshot (UTC timestamp)"}}, "type": "object", "required": ["name", "description", "snapshot_id", "project_id", "filename", "created_at"], "title": "Snapshot"}, "SnapshotCreate": {"properties": {"name": {"type": "string", "title": "Name", "description": "Name of the snapshot"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description", "description": "Description of the snapshot"}}, "type": "object", "required": ["name"], "title": "SnapshotCreate", "description": "Properties for snapshot creation."}, "Status": {"type": "string", "enum": ["stable", "experimental", "broken"], "title": "Status", "description": "Appliance status enum"}, "Supplier": {"properties": {"logo": {"type": "string", "title": "Logo", "description": "Path to the project supplier logo"}, "url": {"anyOf": [{"type": "string", "maxLength": 2083, "minLength": 1, "format": "uri"}, {"type": "null"}], "title": "Url", "description": "URL to the project supplier site"}}, "type": "object", "required": ["logo"], "title": "Supplier"}, "Template": {"properties": {"template_id": {"type": "string", "format": "uuid", "title": "Template Id"}, "name": {"type": "string", "title": "Name"}, "version": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Version"}, "category": {"$ref": "#/components/schemas/gns3server__schemas__controller__templates__Category"}, "default_name_format": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Name Format"}, "symbol": {"type": "string", "title": "Symbol"}, "template_type": {"$ref": "#/components/schemas/NodeType"}, "compute_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Compute Id"}, "usage": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Usage", "default": ""}, "netmiko_device_type": {"anyOf": [{"type": "string", "pattern": "^[a-z0-9_]+$|^$"}, {"type": "null"}], "title": "Netmiko Device Type", "description": "Device type for Netmiko-based automation tools (e.g. 'cisco_xr' or 'nokia_srl')"}, "tags": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Tags", "description": "User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')"}, "appliance_metadata": {"anyOf": [{"$ref": "#/components/schemas/ApplianceMetadata"}, {"type": "null"}], "description": "Metadata inherited from the appliance the template was installed from"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "updated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Updated At"}, "builtin": {"type": "boolean", "title": "Builtin"}}, "additionalProperties": true, "type": "object", "required": ["template_id", "name", "category", "symbol", "template_type", "builtin"], "title": "Template"}, "TemplateCreate": {"properties": {"template_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Template Id"}, "name": {"type": "string", "title": "Name"}, "version": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Version"}, "category": {"anyOf": [{"$ref": "#/components/schemas/gns3server__schemas__controller__templates__Category"}, {"type": "null"}]}, "default_name_format": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Name Format"}, "symbol": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Symbol"}, "template_type": {"$ref": "#/components/schemas/NodeType"}, "compute_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Compute Id"}, "usage": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Usage", "default": ""}, "netmiko_device_type": {"anyOf": [{"type": "string", "pattern": "^[a-z0-9_]+$|^$"}, {"type": "null"}], "title": "Netmiko Device Type", "description": "Device type for Netmiko-based automation tools (e.g. 'cisco_xr' or 'nokia_srl')"}, "tags": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Tags", "description": "User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')"}, "appliance_metadata": {"anyOf": [{"$ref": "#/components/schemas/ApplianceMetadata"}, {"type": "null"}], "description": "Metadata inherited from the appliance the template was installed from"}}, "additionalProperties": true, "type": "object", "required": ["name", "template_type"], "title": "TemplateCreate", "description": "Properties to create a template."}, "TemplateSetting": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name of the settings set"}, "default": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Whether these are the default settings"}, "inherit_default_properties": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Whether the default properties should be used", "default": true}, "template_type": {"$ref": "#/components/schemas/TemplateType", "title": "Type of emulator properties"}, "template_properties": {"anyOf": [{"$ref": "#/components/schemas/QemuPropertiesV8"}, {"$ref": "#/components/schemas/DynamipsPropertiesV8"}, {"$ref": "#/components/schemas/IouPropertiesV8"}, {"$ref": "#/components/schemas/DockerPropertiesV8"}], "title": "Properties for the template"}}, "type": "object", "required": ["template_type", "template_properties"], "title": "TemplateSetting", "description": "Emulator settings configuration (v8)"}, "TemplateType": {"type": "string", "enum": ["docker", "iou", "dynamips", "qemu"], "title": "TemplateType", "description": "Template type enum"}, "TemplateUpdate": {"properties": {"template_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}], "title": "Template Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "version": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Version"}, "category": {"anyOf": [{"$ref": "#/components/schemas/gns3server__schemas__controller__templates__Category"}, {"type": "null"}]}, "default_name_format": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Name Format"}, "symbol": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Symbol"}, "template_type": {"anyOf": [{"$ref": "#/components/schemas/NodeType"}, {"type": "null"}]}, "compute_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Compute Id"}, "usage": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Usage", "default": ""}, "netmiko_device_type": {"anyOf": [{"type": "string", "pattern": "^[a-z0-9_]+$|^$"}, {"type": "null"}], "title": "Netmiko Device Type", "description": "Device type for Netmiko-based automation tools (e.g. 'cisco_xr' or 'nokia_srl')"}, "tags": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Tags", "description": "User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')"}, "appliance_metadata": {"anyOf": [{"$ref": "#/components/schemas/ApplianceMetadata"}, {"type": "null"}], "description": "Metadata inherited from the appliance the template was installed from"}}, "additionalProperties": true, "type": "object", "title": "TemplateUpdate"}, "TemplateUsage": {"properties": {"x": {"type": "integer", "title": "X"}, "y": {"type": "integer", "title": "Y"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name", "description": "Use this name to create a new node"}, "compute_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Compute Id", "description": "Used if the template doesn't have a default compute"}}, "type": "object", "required": ["x", "y"], "title": "TemplateUsage"}, "Token": {"properties": {"access_token": {"type": "string", "title": "Access Token"}, "token_type": {"type": "string", "title": "Token Type"}, "refresh_token": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Refresh Token"}}, "type": "object", "required": ["access_token", "token_type"], "title": "Token"}, "UDPPortInfo": {"properties": {"node_id": {"type": "string", "format": "uuid", "title": "Node Id"}, "lport": {"type": "integer", "title": "Lport"}, "rhost": {"type": "string", "title": "Rhost"}, "rport": {"type": "integer", "title": "Rport"}, "type": {"type": "string", "title": "Type"}}, "type": "object", "required": ["node_id", "lport", "rhost", "rport", "type"], "title": "UDPPortInfo", "description": "UDP port information."}, "User": {"properties": {"username": {"anyOf": [{"type": "string", "minLength": 3, "pattern": "[a-zA-Z0-9_-]+$"}, {"type": "null"}], "title": "Username"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "email": {"anyOf": [{"type": "string", "format": "email"}, {"type": "null"}], "title": "Email"}, "full_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Full Name"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "updated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Updated At"}, "user_id": {"type": "string", "format": "uuid", "title": "User Id"}, "last_login": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Last Login"}, "is_superadmin": {"type": "boolean", "title": "Is Superadmin", "default": false}}, "type": "object", "required": ["user_id"], "title": "User"}, "UserCreate": {"properties": {"username": {"type": "string", "minLength": 3, "pattern": "[a-zA-Z0-9_-]+$", "title": "Username"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "email": {"anyOf": [{"type": "string", "format": "email"}, {"type": "null"}], "title": "Email"}, "full_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Full Name"}, "password": {"type": "string", "maxLength": 100, "minLength": 8, "format": "password", "title": "Password", "writeOnly": true}}, "type": "object", "required": ["username", "password"], "title": "UserCreate", "description": "Properties to create a user."}, "UserGroup": {"properties": {"name": {"anyOf": [{"type": "string", "minLength": 3, "pattern": "[a-zA-Z0-9_-]+$"}, {"type": "null"}], "title": "Name"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "updated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Updated At"}, "user_group_id": {"type": "string", "format": "uuid", "title": "User Group Id"}, "is_builtin": {"type": "boolean", "title": "Is Builtin"}}, "type": "object", "required": ["user_group_id", "is_builtin"], "title": "UserGroup"}, "UserGroupCreate": {"properties": {"name": {"anyOf": [{"type": "string", "minLength": 3, "pattern": "[a-zA-Z0-9_-]+$"}, {"type": "null"}], "title": "Name"}}, "type": "object", "required": ["name"], "title": "UserGroupCreate", "description": "Properties to create a user group."}, "UserGroupUpdate": {"properties": {"name": {"anyOf": [{"type": "string", "minLength": 3, "pattern": "[a-zA-Z0-9_-]+$"}, {"type": "null"}], "title": "Name"}}, "type": "object", "title": "UserGroupUpdate", "description": "Properties to update a user group."}, "UserUpdate": {"properties": {"username": {"anyOf": [{"type": "string", "minLength": 3, "pattern": "[a-zA-Z0-9_-]+$"}, {"type": "null"}], "title": "Username"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "email": {"anyOf": [{"type": "string", "format": "email"}, {"type": "null"}], "title": "Email"}, "full_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Full Name"}, "password": {"anyOf": [{"type": "string", "maxLength": 100, "minLength": 8, "format": "password", "writeOnly": true}, {"type": "null"}], "title": "Password"}}, "type": "object", "title": "UserUpdate", "description": "Properties to update a user."}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}, "input": {"title": "Input"}, "ctx": {"type": "object", "title": "Context"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}, "Variable": {"properties": {"name": {"type": "string", "title": "Name", "description": "Variable name"}, "value": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Value", "description": "Variable value"}}, "type": "object", "required": ["name"], "title": "Variable"}, "Version": {"properties": {"controller_host": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Controller Host", "description": "Controller hostname or IP address"}, "version": {"type": "string", "title": "Version", "description": "Version number"}, "local": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Local", "description": "Whether this is a local server or not"}}, "type": "object", "required": ["version"], "title": "Version"}, "WhenExit": {"type": "string", "enum": ["stop", "suspend", "keep"], "title": "WhenExit", "description": "What to do with the VM when GNS3 VM exits."}, "gns3server__schemas__controller__appliances__Category": {"type": "string", "enum": ["router", "multilayer_switch", "switch", "firewall", "guest"], "title": "Category", "description": "Appliance category enum"}, "gns3server__schemas__controller__links__LinkType": {"type": "string", "enum": ["ethernet", "serial"], "title": "LinkType", "description": "Link type."}, "gns3server__schemas__controller__nodes__LinkType": {"type": "string", "enum": ["ethernet", "serial"], "title": "LinkType", "description": "Supported link types."}, "gns3server__schemas__controller__templates__Category": {"type": "string", "enum": ["router", "switch", "guest", "firewall"], "title": "Category", "description": "Supported categories"}}, "securitySchemes": {"OAuth2PasswordBearer": {"type": "oauth2", "flows": {"password": {"scopes": {}, "tokenUrl": "/v3/access/users/login"}}}}}} \ No newline at end of file diff --git a/docs/redoc.html b/docs/redoc.html index 7290dcf6b..bb28aa163 100644 --- a/docs/redoc.html +++ b/docs/redoc.html @@ -1,35 +1,31 @@ - - + GNS3 controller API - ReDoc + + + + + + - GNS3 controller API - Swagger UI + + -
-
- - - + + + \ No newline at end of file diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py index 5facc2499..78d9caf67 100644 --- a/gns3server/api/routes/controller/__init__.py +++ b/gns3server/api/routes/controller/__init__.py @@ -61,6 +61,7 @@ from . import acl from . import pools from . import privileges from . import api_keys +from . import netmiko from .dependencies.authentication import get_current_active_user @@ -159,6 +160,12 @@ router.include_router( tags=["Appliances"] ) +router.include_router( + netmiko.router, + prefix="/netmiko", + tags=["Netmiko"] +) + router.include_router( pools.router, prefix="/pools", diff --git a/gns3server/api/routes/controller/netmiko.py b/gns3server/api/routes/controller/netmiko.py new file mode 100644 index 000000000..1fd2b907d --- /dev/null +++ b/gns3server/api/routes/controller/netmiko.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python +# +# Copyright (C) 2020 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 . + +""" +API routes for Netmiko metadata. +""" + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status + +from gns3server import schemas + +from .dependencies.authentication import get_current_active_user + +import logging + +log = logging.getLogger(__name__) + + +router = APIRouter() + +# Computed once per process: the list only changes if the installed +# Netmiko library changes, which requires a server restart anyway. +_device_types_cache: Optional[schemas.NetmikoDeviceTypeList] = None + + +def _load_netmiko_device_types() -> schemas.NetmikoDeviceTypeList: + """ + Build the list of device types supported by the installed Netmiko library. + + Imports Netmiko and the GNS3-copilot custom drivers (which register + additional 'gns3_*' device types into Netmiko's CLASS_MAPPER on import), + then filters out the '_ssh' aliases and the 'autodetect' + pseudo device type. + + Raises: + ImportError: If Netmiko is not installed (ai-features extra). + """ + + import importlib + + import netmiko + # "from netmiko import ssh_dispatcher" is shadowed by a function of the same + # name in netmiko's __init__, so import the module through importlib + sd = importlib.import_module("netmiko.ssh_dispatcher") + + # Importing the package auto-registers all custom drivers (in case nothing + # imported them yet); failures are logged by the package itself, do not + # fail the whole endpoint. + try: + from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401 + except Exception as e: + log.warning(f"Could not register GNS3-copilot custom Netmiko drivers: {e}") + + # Custom drivers all use the 'gns3_' prefix by convention, which is more + # reliable than diffing CLASS_MAPPER around the import: the drivers may + # already be registered when the copilot package got imported at startup. + device_types = [ + schemas.NetmikoDeviceType(name=name, telnet="_telnet" in name, custom=name.startswith("gns3_")) + for name in sorted(sd.CLASS_MAPPER.keys()) + if not name.endswith("_ssh") and name != "autodetect" + ] + return schemas.NetmikoDeviceTypeList(netmiko_version=netmiko.__version__, device_types=device_types) + + +@router.get( + "/device_types", + response_model=schemas.NetmikoDeviceTypeList, + dependencies=[Depends(get_current_active_user)] +) +def get_netmiko_device_types() -> schemas.NetmikoDeviceTypeList: + """ + Return the device types supported by the Netmiko library installed on this server. + + Required privilege: None (authenticated users only) + """ + + global _device_types_cache + if _device_types_cache is None: + try: + _device_types_cache = _load_netmiko_device_types() + except ImportError: + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="Netmiko is not available. Install AI dependencies with: pip install gns3-server[ai-features]" + ) + return _device_types_cache diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index 7d4ba6c22..0d4ed490f 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -61,6 +61,7 @@ from .controller.tokens import Token, ApiKeyCreate, RefreshTokenRequest from .controller.snapshots import SnapshotCreate, Snapshot from .controller.iou_license import IOULicense from .controller.capabilities import Capabilities +from .controller.netmiko import NetmikoDeviceType, NetmikoDeviceTypeList # Controller template schemas from .controller.templates.vpcs_templates import VPCSTemplate, VPCSTemplateUpdate diff --git a/gns3server/schemas/controller/netmiko.py b/gns3server/schemas/controller/netmiko.py new file mode 100644 index 000000000..00eb6e5a4 --- /dev/null +++ b/gns3server/schemas/controller/netmiko.py @@ -0,0 +1,38 @@ +# +# Copyright (C) 2020 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 . + + +from pydantic import BaseModel, Field +from typing import List + + +class NetmikoDeviceType(BaseModel): + """ + A Netmiko device type supported by the installed Netmiko library. + """ + + name: str = Field(..., description="Device type name to store in the netmiko_device_type field") + telnet: bool = Field(False, description="Whether the device type connects over Telnet") + custom: bool = Field(False, description="Whether the device type is a GNS3-copilot custom driver (gns3_ prefix)") + + +class NetmikoDeviceTypeList(BaseModel): + """ + List of Netmiko device types supported by the installed Netmiko library. + """ + + netmiko_version: str = Field(..., description="Version of the installed Netmiko library") + device_types: List[NetmikoDeviceType] = Field(..., description="Supported device types, sorted by name") diff --git a/tests/api/routes/controller/test_netmiko.py b/tests/api/routes/controller/test_netmiko.py new file mode 100644 index 000000000..84fe8934c --- /dev/null +++ b/tests/api/routes/controller/test_netmiko.py @@ -0,0 +1,77 @@ +# +# Copyright (C) 2020 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 + +pytestmark = pytest.mark.asyncio + + +class TestNetmikoRoutes: + + async def test_device_types(self, app: FastAPI, client: AsyncClient) -> None: + """ + Test listing the device types supported by the installed Netmiko library. + """ + + pytest.importorskip("netmiko", reason="netmiko is not installed") + import netmiko + + response = await client.get(app.url_path_for("get_netmiko_device_types")) + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert data["netmiko_version"] == netmiko.__version__ + + device_types = data["device_types"] + assert len(device_types) > 0 + + names = [entry["name"] for entry in device_types] + assert names == sorted(names) + + by_name = {entry["name"]: entry for entry in device_types} + assert by_name["cisco_ios"]["telnet"] is False + assert by_name["cisco_ios"]["custom"] is False + assert by_name["cisco_ios_telnet"]["telnet"] is True + + # '_ssh' aliases and the 'autodetect' pseudo device type are filtered out + assert not [name for name in names if name.endswith("_ssh")] + assert "autodetect" not in names + + # GNS3-copilot custom drivers are flagged as custom + assert by_name["gns3_vpcs_telnet"]["custom"] is True + assert by_name["gns3_vpcs_telnet"]["telnet"] is True + + async def test_device_types_unavailable(self, app: FastAPI, client: AsyncClient, monkeypatch) -> None: + """ + Test that a 501 is returned when Netmiko is not installed. + """ + + from gns3server.api.routes.controller import netmiko as netmiko_route + + def _raise_import_error(): + raise ImportError("No module named 'netmiko'") + + monkeypatch.setattr(netmiko_route, "_load_netmiko_device_types", _raise_import_error) + monkeypatch.setattr(netmiko_route, "_device_types_cache", None) + + response = await client.get(app.url_path_for("get_netmiko_device_types")) + assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED + # the HTTP exception handler formats errors with a "message" key + assert "ai-features" in response.json()["message"] From 38742ad4b6daaa7b7e3928cdd67b8ec653577afd Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 17 Aug 2026 21:24:54 +0800 Subject: [PATCH 11/28] tests: sign class-scoped client tokens with the default JWT secret Since aef337e86 the config loader generates a random JWT secret even without a main config file, so the class-scoped client/authorized_client fixtures signed their tokens with a key that the autouse run_around_tests fixture would immediately replace with the default one for every test function. Every controller API test using those fixtures was failing with 401 (BadSignature). Sign both fixtures explicitly with DEFAULT_JWT_SECRET_KEY to match the secret enforced at request time. --- tests/conftest.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7271179a0..f67b04839 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -155,7 +155,10 @@ def unauthorized_client(base_client: AsyncClient, test_user: User) -> AsyncClien @pytest_asyncio.fixture(loop_scope="class", scope="class") def authorized_client(base_client: AsyncClient, test_user: User) -> AsyncClient: - access_token = auth_service.create_access_token(test_user.username) + # Sign with the default secret key: the class-scoped token must stay valid + # across every test, but "run_around_tests" resets the config and forces + # jwt_secret_key back to the default for each test function. + access_token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY) base_client.headers = { **base_client.headers, "Authorization": f"Bearer {access_token}", @@ -168,7 +171,9 @@ async def client(base_client: AsyncClient) -> AsyncClient: # The super admin is automatically created when the users table is created # this account that can access all endpoints without restrictions. - access_token = auth_service.create_access_token("admin") + # Sign with the default secret key so the token matches the one enforced + # by "run_around_tests" when the config is reset for each test function. + access_token = auth_service.create_access_token("admin", secret_key=DEFAULT_JWT_SECRET_KEY) base_client.headers = { **base_client.headers, "Authorization": f"Bearer {access_token}", From 19f20e8d75e66ab6ca5132e7709beba119b37ef9 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 17 Aug 2026 21:44:50 +0800 Subject: [PATCH 12/28] copilot: log into devices using the node default credentials The vendored gns3fy Node model dropped default_username/default_password from the API response, and the nornir groups hardcoded empty credentials, so drivers that require authentication (gns3_ruijie_telnet, stock netmiko SSH/telnet) could not log in. Carry the per-node credentials through nodes_inventory() into the nornir hosts data at host level, where they override the group's empty fallback. Missing or cleared ("") values keep inheriting from the group, so no-auth drivers are unaffected. --- .../gns3_copilot/gns3_client/custom_gns3fy.py | 4 + .../utils/get_gns3_device_port.py | 12 +- tests/agent/test_custom_gns3fy.py | 103 ++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py index 83a5cc191..170e8757b 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py +++ b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py @@ -1372,6 +1372,8 @@ class Node: properties: Any | None = None tags: list[str] | None = None netmiko_device_type: str | None = None + default_username: str | None = None + default_password: str | None = None template: str | None = None links: list[Link] = field(default_factory=list, repr=False) @@ -2489,6 +2491,8 @@ class Project: "y": _n.y, "tags": _n.tags if _n.tags else [], "netmiko_device_type": _n.netmiko_device_type, + "default_username": _n.default_username, + "default_password": _n.default_password, } } ) diff --git a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py index fe7401770..3ef331157 100644 --- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -160,7 +160,7 @@ def get_device_ports_from_topology( # This is the Nornir best practice - each host has its own # connection configuration (device_type), while sharing common # settings (hostname, timeout) via group inheritance. - hosts_data[device_name] = { + host_entry = { "port": node_info["console_port"], "platform": platform, "groups": ["network_devices"], # For inheriting hostname, timeout, etc. @@ -171,6 +171,16 @@ def get_device_ports_from_topology( }, } + # Per-node default credentials (seeded from the template appliance + # metadata) override the group's empty fallback. Only inject when + # set, so credential-less devices keep inheriting the group values. + if node_info.get("default_username"): + host_entry["username"] = node_info["default_username"] + if node_info.get("default_password"): + host_entry["password"] = node_info["default_password"] + + hosts_data[device_name] = host_entry + logger.info("Returning %d device port mappings", len(hosts_data)) return hosts_data diff --git a/tests/agent/test_custom_gns3fy.py b/tests/agent/test_custom_gns3fy.py index 29aec0592..783ec59ed 100644 --- a/tests/agent/test_custom_gns3fy.py +++ b/tests/agent/test_custom_gns3fy.py @@ -150,3 +150,106 @@ def test_device_ports_error_without_any_device_type(monkeypatch): assert "error" in hosts["R2"] assert "netmiko_device_type" in hosts["R2"]["error"] + + +def test_node_accepts_default_credentials(): + """ + The vendored Node model must keep the default credentials so the + device-port tools can log into devices that require authentication. + """ + pytest.importorskip("jwt", reason="ai-features extras not installed") + from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node + + node = Node( + name="R1", + project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7", + node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e", + node_type="docker", + console_type="telnet", + status="started", + default_username="admin", + default_password="admin123", + ) + assert node.default_username == "admin" + assert node.default_password == "admin123" + + +def test_nodes_inventory_emits_default_credentials(): + """ + The inventory dict consumed by get_device_ports_from_topology must + carry the per-node default credentials. + """ + pytest.importorskip("jwt", reason="ai-features extras not installed") + from types import SimpleNamespace + from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node, Project + + project = Project( + project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7", + connector=SimpleNamespace(base_url="http://127.0.0.1:3080"), + ) + project.nodes = [ + Node( + name="R1", + project_id=project.project_id, + node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e", + node_type="dynamips", + console=5000, + default_username="admin", + default_password="admin123", + ), + ] + + inventory = project.nodes_inventory() + assert inventory["R1"]["default_username"] == "admin" + assert inventory["R1"]["default_password"] == "admin123" + + +def test_device_ports_inject_default_credentials(monkeypatch): + """ + Per-node default credentials become host-level nornir values (which + override the group's empty fallback); missing or cleared ("") values + keep inheriting from the group. + """ + pytest.importorskip("jwt", reason="ai-features extras not installed") + from gns3server.agent.gns3_copilot.utils import get_gns3_device_port + from gns3server.agent.gns3_copilot import gns3_client + + class _FakeTopology: + def _run(self, project_id=None, jwt_token=None, url=None): + return { + "nodes": { + "R1": { + "console_port": 5000, + "tags": [], + "netmiko_device_type": "cisco_ios_telnet", + "default_username": "admin", + "default_password": "admin123", + }, + # credentials cleared via PUT arrive as empty strings + "R2": { + "console_port": 5001, + "tags": [], + "netmiko_device_type": "cisco_ios_telnet", + "default_username": "", + "default_password": "", + }, + # never seeded + "R3": { + "console_port": 5002, + "tags": [], + "netmiko_device_type": "cisco_ios_telnet", + }, + } + } + + monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology) + hosts = get_gns3_device_port.get_device_ports_from_topology(["R1", "R2", "R3"]) + + # set credentials land at host level + assert hosts["R1"]["username"] == "admin" + assert hosts["R1"]["password"] == "admin123" + # cleared ("") and absent credentials do not override the group fallback + assert "username" not in hosts["R2"] + assert "password" not in hosts["R2"] + assert "username" not in hosts["R3"] + assert "password" not in hosts["R3"] From 2b811605708864e32a9b660b50307c71a1e27303 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 18 Aug 2026 01:17:37 +0800 Subject: [PATCH 13/28] docs: record the XRd console --More-- pager bug in project memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (confirmed live): the XRd console reaches the container CLI via docker exec, and the XR pager reads the exec PTY's 24-row window (TIOCGWINSZ) instead of the CLI-level terminal length — so 'terminal length 0' shows in 'show terminal' yet long-output commands page after exactly 24 lines and park at --More--, making netmiko time out. '| no-more' does not exist on IOS-XR. Also documents the agreed copilot-side fix design (tail-anchored --More-- detection with quiet double-confirmation, reconnect before retry, session_log), for the upcoming feat/copilot-xrd-more-handling branch. --- .claude/memory/MEMORY.md | 3 + .claude/memory/xrd-console-more-pager-bug.md | 85 ++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 .claude/memory/xrd-console-more-pager-bug.md diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index dbc0f3b8d..a9b9e74f2 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -29,6 +29,9 @@ ### Docker Container Stop Delay - **[Docker Container Stop Delay](./docker-container-stop-delay.md)** - Some containers take ~5s to stop because they don't handle SIGTERM (AlpiNet, OstinatoWireshark) +### Device Console / Copilot Known Bugs +- **[XRd Console --More-- Pager Bug](./xrd-console-more-pager-bug.md)** - XRd console (docker exec PTY, 24 rows) pager bypasses `terminal length 0`, long-output commands consistently hit netmiko ReadTimeout; `| no-more` does not exist on XR; agreed fix design: copilot tail-anchored `--More--` auto-answer + reconnect before retry + session_log (designed, not yet implemented) + ### MCP Service - **[MCP Service Design](./mcp-service-design.md)** - MCP (Model Context Protocol) service architecture using FastMCP with SSE transport, JWT auth, 29 tools across 5 domains - **[MCP Tool Description Location](./mcp-tool-description-guide.md)** - Where to define MCP tool descriptions: in `@mcp.tool()` functions in `__init__.py`, not in `*_TOOLS` arrays diff --git a/.claude/memory/xrd-console-more-pager-bug.md b/.claude/memory/xrd-console-more-pager-bug.md new file mode 100644 index 000000000..1a7e2e300 --- /dev/null +++ b/.claude/memory/xrd-console-more-pager-bug.md @@ -0,0 +1,85 @@ +# XRd Console --More-- Pager Bug (PTY 24-row window ignores terminal length 0) + +## Background + +Copilot commands with long output consistently fail against XRd (IOS XRv 9000 container) nodes: `device_show_run` running `show ipv4 interface brief` always reports `netmiko_multiline (failed)` (even as a single command); short-output commands like `show ipv4 interface ` and `show running-config interface ` work fine. + +Failure sequence (from the 2026-08-18 logs): + +1. First failure: `ReadTimeout: Pattern not detected: 'RP/0/RP0/CPU0:ios\#'` — the command echo already matched, but the prompt never appeared within 60s +2. The copilot's single-retry reuses the same netmiko session (nornir caches it on the host object) with half-consumed output in the buffer → the second failure dies earlier in `command_echo_read` (`Pattern not detected: 'show\ ipv4\ interface\ brief'`) — a follow-on effect of the dirty session, not an independent fault + +## Root Cause (confirmed by live testing) + +1. The GNS3 XRd console reaches the container CLI via **docker exec**; Docker allocates the exec PTY with a default **24-row** window +2. The XR pager (at least for table-engine/TABLAST-style commands) reads the **PTY window size (TIOCGWINSZ)**, not the CLI-level `terminal length` setting +3. Evidence: `show terminal` reports `Length: 0 lines, Width: 511 columns` (so `terminal length 0` IS in effect), yet `show ipv4 interface brief` pages after **exactly 24 lines** (timestamp + blank line + header + 21 interface rows) with `--More--` +4. The device parks at `--More--` waiting for a keypress → the channel goes silent → netmiko ReadTimeout. Netmiko never presses space; it assumes paging is disabled + +### Dead ends (do not retry) + +- `show ipv4 interface brief | no-more` → `% Invalid input detected`. `| no-more` is a **Junos** pipe modifier; IOS-XR does not have it (the CRS 4.1 doc covers filtering *at* the --More-- prompt, not this) +- `terminal length 0`: takes effect at the CLI layer, but this pager ignores it +- The XR CLI has no command to change PTY rows, and from inside the container you cannot reach another session's PTY + +### Container-layer levers (deferred, high risk) + +Changing the console plumbing in `gns3server/compute/docker/`: run the exec with `tty=false` (over pipes the CLI would likely fall back to the configured length = 0, i.e. no paging), or resize the exec PTY via the Docker API after creation. Both are XRd special-cases on a shared path used by every docker node's console — not accepted. + +## Decision/Implementation (design agreed, NOT yet implemented) + +Planned branch `feat/copilot-xrd-more-handling` (based on `feat/copilot-node-default-credentials`), three parts: + +### 1. Display tool: for `cisco_xr*` platforms, replace netmiko_multiline with a channel-level loop that answers `--More--` + +```python +conn.write_channel(cmd + "\n") +buf = "" +while not past_deadline: + buf += conn.read_channel() + if prompt_re.search(buf): # normal path: prompt only + break + if re.search(r"--More--\s*$", buf): # tail-anchored check only + if still_quiet_after_one_poll: # ~150ms quiet double-check + conn.write_channel(" ") # page forward (space, not q — q truncates) + sleep(0.2) +``` + +**Key design constraint (user-flagged)**: never put `--More--` into netmiko's `expect_string` — expect does a `re.search` over accumulated output, so banner/description text containing "More" would false-trigger. The fundamental distinction used: **the real pager's `--More--` is the last byte of the stream (no newline, nothing follows until a keypress); a literal `--More--` in content is always followed by more arriving bytes**. Three safeguards: normal path never looks for More + tail anchoring + quiet double-confirmation. + +### 2. Reconnect before retrying + +The retry branch of `_run_all_device_configs_with_single_retry` must call `task.host.close_connection("netmiko")` first — otherwise the retry is doomed by the dirty session (the second error in the logs proves it). The config tool has the same retry structure and needs the same fix. + +### 3. Persist session_log + +Add `session_log_file` to `connection_options.netmiko.extras` in hosts_data (a native netmiko ConnectHandler parameter, passed through by nornir_netmiko). The copilot currently has no session_log anywhere, which made this bug a guessing game. + +## Rationale + +- Device side is unsolvable (CLI layer) or high-risk (docker plumbing layer); answering `--More--` at the copilot layer is a generic fix with zero plumbing risk: whatever the PTY row count, whatever odd console produces a pager, sending space on More heals it +- Tail-anchored detection drives the false-positive probability down to "content happens to be TCP-segment-split right after `--More--` AND stays silent" — which the quiet double-check then covers + +## Related Files + +- `gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py:284-341` — `_run_all_device_configs_with_single_retry` (the dirty-session retry and the loop replacement point) +- `gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py` — same retry structure to fix +- `gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py` — hosts_data construction (session_log extras insertion point) +- `venv/.../netmiko/cisco/cisco_xr.py:14-22` — `session_preparation` (sends `terminal width 511` + `disable_paging`, both ineffective against this bug; note the comment at line 16: "IOS-XR has an issue where it echoes the command even though it hasn't returned the prompt") + +## Examples + +Live console transcript (2026-08-17): + +``` +RP/0/RP0/CPU0:ios#show terminal +Line "vty2", Location "", Type "VTY" +Length: 0 lines, Width: 511 columns <- CLI-layer setting in effect + +RP/0/RP0/CPU0:ios#show ipv4 interface brief | no-more +% Invalid input detected at '^' marker. <- pipe does not exist on XR + +RP/0/RP0/CPU0:ios#show ipv4 interface brief +(exactly 24 lines: timestamp + blank + header + 21 interface rows) + --More-- <- the 24-row PTY is paging +``` From fd7594f62e51cc70b366b2a1a75b8340b5fcb238 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 18 Aug 2026 01:36:52 +0800 Subject: [PATCH 14/28] docker: give the docker_exec console a tall default PTY geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docker_exec console resized its exec PTY to 80x24 until a client sent NAWS. CLIs that page on the PTY window size instead of the terminal length (the IOS-XR pager) therefore parked long output at --More-- for clients that never negotiate NAWS — netmiko, bare telnet — making copilot device commands time out on XRd. Default the exec to 511x10000 instead (511 matches netmiko's own 'terminal width 511' convention): no paging and no hard wrapping for non-NAWS clients, while real NAWS clients keep resizing to their actual geometry as before. Also updates the project memory record with the confirmed root cause and the fix. --- .claude/memory/MEMORY.md | 2 +- .claude/memory/xrd-console-more-pager-bug.md | 77 ++++++++----------- gns3server/compute/docker/vendor_docker_vm.py | 9 ++- tests/compute/docker/test_vendor_docker_vm.py | 11 +++ 4 files changed, 52 insertions(+), 47 deletions(-) diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index a9b9e74f2..2b757063e 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -30,7 +30,7 @@ - **[Docker Container Stop Delay](./docker-container-stop-delay.md)** - Some containers take ~5s to stop because they don't handle SIGTERM (AlpiNet, OstinatoWireshark) ### Device Console / Copilot Known Bugs -- **[XRd Console --More-- Pager Bug](./xrd-console-more-pager-bug.md)** - XRd console (docker exec PTY, 24 rows) pager bypasses `terminal length 0`, long-output commands consistently hit netmiko ReadTimeout; `| no-more` does not exist on XR; agreed fix design: copilot tail-anchored `--More--` auto-answer + reconnect before retry + session_log (designed, not yet implemented) +- **[XRd Console --More-- Pager Bug](./xrd-console-more-pager-bug.md)** - FIXED: root cause was our own 80x24 initial PTY geometry for the docker_exec console (the XR pager reads PTY rows, not `terminal length`); initial geometry is now 511x10000. Copilot reconnect-before-retry + session_log still open; `--More--` auto-answer kept as fallback design ### MCP Service - **[MCP Service Design](./mcp-service-design.md)** - MCP (Model Context Protocol) service architecture using FastMCP with SSE transport, JWT auth, 29 tools across 5 domains diff --git a/.claude/memory/xrd-console-more-pager-bug.md b/.claude/memory/xrd-console-more-pager-bug.md index 1a7e2e300..d796d3a4a 100644 --- a/.claude/memory/xrd-console-more-pager-bug.md +++ b/.claude/memory/xrd-console-more-pager-bug.md @@ -1,71 +1,62 @@ -# XRd Console --More-- Pager Bug (PTY 24-row window ignores terminal length 0) +# XRd Console --More-- Pager Bug (PTY window size paging vs terminal length 0) ## Background -Copilot commands with long output consistently fail against XRd (IOS XRv 9000 container) nodes: `device_show_run` running `show ipv4 interface brief` always reports `netmiko_multiline (failed)` (even as a single command); short-output commands like `show ipv4 interface ` and `show running-config interface ` work fine. +Copilot commands with long output consistently failed against XRd (IOS XRv 9000 container) nodes: `device_show_run` running `show ipv4 interface brief` always reported `netmiko_multiline (failed)` (even as a single command); short-output commands like `show ipv4 interface ` worked fine. Failure sequence (from the 2026-08-18 logs): -1. First failure: `ReadTimeout: Pattern not detected: 'RP/0/RP0/CPU0:ios\#'` — the command echo already matched, but the prompt never appeared within 60s -2. The copilot's single-retry reuses the same netmiko session (nornir caches it on the host object) with half-consumed output in the buffer → the second failure dies earlier in `command_echo_read` (`Pattern not detected: 'show\ ipv4\ interface\ brief'`) — a follow-on effect of the dirty session, not an independent fault +1. First failure: `ReadTimeout: Pattern not detected: 'RP/0/RP0/CPU0:ios\#'` — the command echo matched, but the prompt never appeared within 60s +2. The copilot's single-retry reused the same netmiko session (nornir caches it on the host object) with half-consumed output in the buffer → the second failure died earlier in `command_echo_read` — a follow-on effect of the dirty session, not an independent fault -## Root Cause (confirmed by live testing) +## Root Cause (fully traced) -1. The GNS3 XRd console reaches the container CLI via **docker exec**; Docker allocates the exec PTY with a default **24-row** window -2. The XR pager (at least for table-engine/TABLAST-style commands) reads the **PTY window size (TIOCGWINSZ)**, not the CLI-level `terminal length` setting -3. Evidence: `show terminal` reports `Length: 0 lines, Width: 511 columns` (so `terminal length 0` IS in effect), yet `show ipv4 interface brief` pages after **exactly 24 lines** (timestamp + blank line + header + 21 interface rows) with `--More--` -4. The device parks at `--More--` waiting for a keypress → the channel goes silent → netmiko ReadTimeout. Netmiko never presses space; it assumes paging is disabled +The XRd node uses the `docker_exec` console type (`gns3server/compute/docker/vendor_docker_vm.py`, `_LazyExecTelnetServer`): a telnet TCP server whose backend is a `docker exec` PTY. Three facts combine: + +1. The XR pager (at least for table-engine/TABLAST-style commands) pages on the **PTY window size (TIOCGWINSZ)**, not the CLI-level `terminal length` — `show terminal` happily reports `Length: 0 lines` while output still pages +2. `_LazyExecTelnetServer.client_connected_hook` explicitly resized the exec PTY to **80×24** "before NAWS" (`await self._on_naws(80, 24)`) — the 24 rows were **our own initial geometry, not a Docker default**. Live test: `show ipv4 interface brief` paged after exactly 24 lines +3. netmiko's telnetlib never negotiates **NAWS**, so the initial geometry is never corrected for copilot/bare-telnet clients. Real NAWS clients resize to their own geometry via the existing `_on_naws` → `POST /exec/{id}/resize` wiring ### Dead ends (do not retry) -- `show ipv4 interface brief | no-more` → `% Invalid input detected`. `| no-more` is a **Junos** pipe modifier; IOS-XR does not have it (the CRS 4.1 doc covers filtering *at* the --More-- prompt, not this) +- `show ipv4 interface brief | no-more` → `% Invalid input detected`. `| no-more` is a **Junos** pipe modifier; IOS-XR does not have it - `terminal length 0`: takes effect at the CLI layer, but this pager ignores it -- The XR CLI has no command to change PTY rows, and from inside the container you cannot reach another session's PTY +- The XR CLI has no command to change PTY rows -### Container-layer levers (deferred, high risk) +### Related fact: paramiko vs docker_exec -Changing the console plumbing in `gns3server/compute/docker/`: run the exec with `tty=false` (over pipes the CLI would likely fall back to the configured length = 0, i.e. no paging), or resize the exec PTY via the Docker API after creation. Both are XRd special-cases on a shared path used by every docker node's console — not accepted. +paramiko (SSH) cannot connect to a `docker_exec` console — the client-side endpoint is a plain telnet server (`AsyncioTelnetServer`); only `console_type: ssh` (standard attach path, `AsyncioSSHServer`) speaks SSH. The copilot correctly uses netmiko `*_telnet` drivers (netmiko's vendored `_telnetlib`, stdlib-free on Python 3.13). -## Decision/Implementation (design agreed, NOT yet implemented) +## Decision/Implementation -Planned branch `feat/copilot-xrd-more-handling` (based on `feat/copilot-node-default-credentials`), three parts: +### Fix (implemented 2026-08-18, branch `feat/docker-exec-default-pty-geometry`) -### 1. Display tool: for `cisco_xr*` platforms, replace netmiko_multiline with a channel-level loop that answers `--More--` +Change the initial exec geometry in `vendor_docker_vm.py` `client_connected_hook` from 80×24 to **511×10000** (`await self._on_naws(511, 10000)`): -```python -conn.write_channel(cmd + "\n") -buf = "" -while not past_deadline: - buf += conn.read_channel() - if prompt_re.search(buf): # normal path: prompt only - break - if re.search(r"--More--\s*$", buf): # tail-anchored check only - if still_quiet_after_one_poll: # ~150ms quiet double-check - conn.write_channel(" ") # page forward (space, not q — q truncates) - sleep(0.2) -``` +- Tall/wide default so CLIs that page on PTY rows never hit `--More--` for clients that never send NAWS (netmiko, bare telnet) +- Width 511 matches netmiko's `terminal width 511` convention +- Real NAWS clients still resize to their actual geometry right after connecting (existing `_on_naws` path unchanged) +- Test: `test_first_connect_sets_tall_default_pty_geometry` in `tests/compute/docker/test_vendor_docker_vm.py` -**Key design constraint (user-flagged)**: never put `--More--` into netmiko's `expect_string` — expect does a `re.search` over accumulated output, so banner/description text containing "More" would false-trigger. The fundamental distinction used: **the real pager's `--More--` is the last byte of the stream (no newline, nothing follows until a keypress); a literal `--More--` in content is always followed by more arriving bytes**. Three safeguards: normal path never looks for More + tail anchoring + quiet double-confirmation. +### Fallback design (NOT implemented — keep if the pager ever resurfaces on another console type) -### 2. Reconnect before retrying +Channel-level loop answering `--More--` in the copilot display tool for `cisco_xr*`: prompt regex breaks; **tail-anchored** `re.search(r"--More--\s*$", buf)` with a ~150ms quiet double-confirmation before `write_channel(" ")`. Never put `--More--` into netmiko's `expect_string` — expect `re.search`es accumulated output, so content containing "More" would false-trigger. -The retry branch of `_run_all_device_configs_with_single_retry` must call `task.host.close_connection("netmiko")` first — otherwise the retry is doomed by the dirty session (the second error in the logs proves it). The config tool has the same retry structure and needs the same fix. +### Still-open copilot improvements (agreed, not yet implemented) -### 3. Persist session_log - -Add `session_log_file` to `connection_options.netmiko.extras` in hosts_data (a native netmiko ConnectHandler parameter, passed through by nornir_netmiko). The copilot currently has no session_log anywhere, which made this bug a guessing game. +1. Reconnect before retry: `_run_all_device_configs_with_single_retry` (display tool) and the config tool's retry should `task.host.close_connection("netmiko")` first — retrying on a dirty session is doomed +2. `session_log_file` in hosts_data netmiko extras — the copilot has no session_log anywhere, which made this bug a guessing game ## Rationale -- Device side is unsolvable (CLI layer) or high-risk (docker plumbing layer); answering `--More--` at the copilot layer is a generic fix with zero plumbing risk: whatever the PTY row count, whatever odd console produces a pager, sending space on More heals it -- Tail-anchored detection drives the false-positive probability down to "content happens to be TCP-segment-split right after `--More--` AND stays silent" — which the quiet double-check then covers +Resizing the PTY at exec creation attacks the root (geometry is fixed before the CLI outputs anything); it covers every consumer of the docker_exec console (copilot, MCP, bare telnet) with a one-line change, while the `--More--` auto-answer design remains as a generic fallback for consoles without a resize path. ## Related Files -- `gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py:284-341` — `_run_all_device_configs_with_single_retry` (the dirty-session retry and the loop replacement point) -- `gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py` — same retry structure to fix -- `gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py` — hosts_data construction (session_log extras insertion point) -- `venv/.../netmiko/cisco/cisco_xr.py:14-22` — `session_preparation` (sends `terminal width 511` + `disable_paging`, both ineffective against this bug; note the comment at line 16: "IOS-XR has an issue where it echoes the command even though it hasn't returned the prompt") +- `gns3server/compute/docker/vendor_docker_vm.py` — `_LazyExecTelnetServer`: `_on_naws` (exec resize), `_create_exec` (Tty=True, TERM=xterm), `client_connected_hook` (initial geometry — the fix) +- `gns3server/compute/docker/docker_vm.py:1074-1087` — standard console path: NAWS → `containers/{cid}/resize` +- `gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py:284-341` — dirty-session retry (open item) +- `gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py` — hosts_data (session_log extras insertion point) ## Examples @@ -73,13 +64,9 @@ Live console transcript (2026-08-17): ``` RP/0/RP0/CPU0:ios#show terminal -Line "vty2", Location "", Type "VTY" Length: 0 lines, Width: 511 columns <- CLI-layer setting in effect -RP/0/RP0/CPU0:ios#show ipv4 interface brief | no-more -% Invalid input detected at '^' marker. <- pipe does not exist on XR - RP/0/RP0/CPU0:ios#show ipv4 interface brief (exactly 24 lines: timestamp + blank + header + 21 interface rows) - --More-- <- the 24-row PTY is paging + --More-- <- the 80x24 initial exec geometry was paging ``` diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 800d46603..dbbdf340c 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -504,7 +504,14 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): log.warning(f"{self._log_name}: failed to create exec: {exc}", exc_info=True) raise try: - await self._on_naws(80, 24) # initial size before NAWS + # Tall/wide default geometry before any NAWS arrives: a + # 24-row PTY makes CLIs that page on the PTY window size + # (e.g. the IOS-XR pager) park at --More-- for clients + # that never negotiate NAWS (netmiko, bare telnet). + # Width 511 matches netmiko's 'terminal width 511'. + # Real NAWS clients resize to their own geometry right + # after connecting. + await self._on_naws(511, 10000) except Exception: pass else: diff --git a/tests/compute/docker/test_vendor_docker_vm.py b/tests/compute/docker/test_vendor_docker_vm.py index 3619d0b10..691adf1dd 100644 --- a/tests/compute/docker/test_vendor_docker_vm.py +++ b/tests/compute/docker/test_vendor_docker_vm.py @@ -531,6 +531,17 @@ async def test_first_connect_creates_exec(compute_project, manager): srv._create_exec.assert_called_once() +@pytest.mark.asyncio +async def test_first_connect_sets_tall_default_pty_geometry(compute_project, manager): + """The exec PTY must start tall/wide: a 24-row initial geometry makes CLIs + that page on the PTY window size (IOS-XR pager) park at --More-- for + clients that never send NAWS (netmiko, bare telnet).""" + + srv = _make_lazy_server(compute_project, manager) + await srv.client_connected_hook() + srv._on_naws.assert_called_once_with(511, 10000) + + @pytest.mark.asyncio async def test_reconnect_live_exec_not_recreated(compute_project, manager): """Reconnecting while the exec is alive must NOT recreate it.""" From 704b5d80c25403cd792ef3a5b12e08b54cd52ab6 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 18 Aug 2026 23:55:57 +0800 Subject: [PATCH 15/28] auth: split JWT validation errors by failure cause get_token_data used to raise the same "Could not validate credentials" for every JWT-level failure (bad signature, expired, malformed), which made console WebSocket auth failures impossible to tell apart. Return a distinct detail per cause and log the underlying exception plus the unverified header alg value on rejection. --- gns3server/services/authentication.py | 53 ++++++++++++++++++--------- tests/api/routes/test_routes.py | 2 +- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/gns3server/services/authentication.py b/gns3server/services/authentication.py index 574c96c69..6103ee6f9 100644 --- a/gns3server/services/authentication.py +++ b/gns3server/services/authentication.py @@ -16,7 +16,9 @@ from joserfc import jwt from joserfc.jwk import OctKey -from joserfc.errors import JoseError +from joserfc.errors import JoseError, BadSignatureError +import base64 +import json import time from datetime import datetime, timedelta, timezone import bcrypt @@ -34,6 +36,17 @@ log = logging.getLogger(__name__) DEFAULT_JWT_SECRET_KEY = "efd08eccec3bd0a1be2e086670e5efa90969c68d07e072d7354a76cea5e33d4e" +def _extract_alg(token: str) -> str: + """Best-effort extraction of the unverified JWT header "alg" value — for logging only.""" + + try: + header_segment = token.split(".", 1)[0] + header = json.loads(base64.urlsafe_b64decode(header_segment + "=" * (-len(header_segment) % 4))) + return str(header.get("alg", "")) + except Exception: + return "" + + class AuthService: def hash_password(self, password: str) -> str: @@ -75,32 +88,38 @@ class AuthService: def get_token_data(self, token: str, secret_key: str = None) -> TokenData: - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) + def auth_error(detail: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + headers={"WWW-Authenticate": "Bearer"}, + ) + + if secret_key is None: + secret_key = Config.instance().settings.Controller.jwt_secret_key + if secret_key is None: + secret_key = DEFAULT_JWT_SECRET_KEY + log.error("A JWT secret key must be configured to secure the server, using an unsecured default key!") + algorithm = Config.instance().settings.Controller.jwt_algorithm + key = OctKey.import_key(secret_key) try: - if secret_key is None: - secret_key = Config.instance().settings.Controller.jwt_secret_key - if secret_key is None: - secret_key = DEFAULT_JWT_SECRET_KEY - log.error("A JWT secret key must be configured to secure the server, using an unsecured default key!") - algorithm = Config.instance().settings.Controller.jwt_algorithm - key = OctKey.import_key(secret_key) payload = jwt.decode(token, key, algorithms=[algorithm]) username: str = payload.claims.get("sub") if username is None: - raise credentials_exception + raise auth_error("Invalid token: missing subject claim") # Validate the exp claim — joserfc does not validate time-based claims by default token_exp: int = payload.claims.get("exp", 0) if token_exp and time.time() > token_exp: - raise credentials_exception + raise auth_error("Token has expired") token_version: int = payload.claims.get("ver", 0) token_use: str = payload.claims.get("type", "access") token_data = TokenData(username=username, token_version=token_version, token_use=token_use) - except (JoseError, ValidationError, ValueError): - raise credentials_exception + except BadSignatureError as e: + log.error("JWT rejected: bad signature (header alg: '%s', error: %s)", _extract_alg(token), e) + raise auth_error("Invalid token signature") + except (JoseError, ValidationError, ValueError) as e: + log.error("JWT rejected: %s: %s (header alg: '%s')", type(e).__name__, e, _extract_alg(token)) + raise auth_error(f"Invalid token ({type(e).__name__})") return token_data def get_username_from_token(self, token: str, secret_key: str = None) -> Optional[str]: diff --git a/tests/api/routes/test_routes.py b/tests/api/routes/test_routes.py index 5c8a59166..0854581f7 100644 --- a/tests/api/routes/test_routes.py +++ b/tests/api/routes/test_routes.py @@ -106,7 +106,7 @@ class TestRoutes: async with aconnect_ws(path, client, params=params) as ws: json_notification = await ws.receive_json() assert json_notification['event'] == { - 'message': 'Could not authenticate while connecting to controller WebSocket: Could not validate credentials' + 'message': 'Could not authenticate while connecting to controller WebSocket: Invalid token (DecodeError)' } From 200ccf0dfe16f6a6be11a8a6d9c8e2b0b348088c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 19 Aug 2026 00:08:08 +0800 Subject: [PATCH 16/28] mcp: fingerprint short-lived console tokens for copy-corruption checks node_console_info now returns token_sha256_prefix (sha256, first 8 hex chars) and token_ttl_seconds alongside the console WebSocket URL, and controller WebSocket auth rejections include the sha256 prefix of the token as received. Comparing the two immediately distinguishes a token corrupted in transfer from server-side rejection causes (expired, revoked, bad signature). --- .../routes/controller/dependencies/authentication.py | 10 +++++++++- gns3server/api/routes/mcp/nodes.py | 7 +++++++ tests/api/routes/test_routes.py | 3 ++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/gns3server/api/routes/controller/dependencies/authentication.py b/gns3server/api/routes/controller/dependencies/authentication.py index 60c880894..1712155cd 100644 --- a/gns3server/api/routes/controller/dependencies/authentication.py +++ b/gns3server/api/routes/controller/dependencies/authentication.py @@ -15,6 +15,7 @@ # along with this program. If not, see . import asyncio +import hashlib import logging import bcrypt @@ -176,7 +177,14 @@ async def get_current_active_user_from_websocket( return user except HTTPException as e: - err_msg = f"Could not authenticate while connecting to controller WebSocket: {e.detail}" + # Fingerprint the received token so clients can compare it against the fingerprint + # returned when the token was issued (e.g. token_sha256_prefix from the + # node_console_info MCP tool) and detect copy corruption on their side. + token_sha256_prefix = hashlib.sha256(token.encode()).hexdigest()[:8] + err_msg = ( + f"Could not authenticate while connecting to controller WebSocket: {e.detail} " + f"(received token sha256 prefix: {token_sha256_prefix})" + ) websocket_error = {"action": "log.error", "event": {"message": err_msg}} await websocket.send_json(websocket_error) log.error(err_msg) diff --git a/gns3server/api/routes/mcp/nodes.py b/gns3server/api/routes/mcp/nodes.py index 0a7c1320c..3d3a7133a 100644 --- a/gns3server/api/routes/mcp/nodes.py +++ b/gns3server/api/routes/mcp/nodes.py @@ -25,6 +25,7 @@ via Gns3Connector (from custom_gns3fy). from typing import Any from concurrent.futures import ThreadPoolExecutor, as_completed +import hashlib import logging from gns3server.services import auth_service @@ -312,6 +313,12 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An "ws_url": ws_url, "command": f"websocat -t --no-close {ws_url}", } + if ws_token: + # Fingerprint of the minted token: compare it against what actually reached the + # server (logged on WebSocket auth rejection) to detect copy corruption, and + # re-request the URL once token_ttl_seconds has elapsed. + result["token_sha256_prefix"] = hashlib.sha256(ws_token.encode()).hexdigest()[:8] + result["token_ttl_seconds"] = 600 if console_type in ("vnc",): result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}" return result diff --git a/tests/api/routes/test_routes.py b/tests/api/routes/test_routes.py index 0854581f7..bc357d668 100644 --- a/tests/api/routes/test_routes.py +++ b/tests/api/routes/test_routes.py @@ -106,7 +106,8 @@ class TestRoutes: async with aconnect_ws(path, client, params=params) as ws: json_notification = await ws.receive_json() assert json_notification['event'] == { - 'message': 'Could not authenticate while connecting to controller WebSocket: Invalid token (DecodeError)' + 'message': 'Could not authenticate while connecting to controller WebSocket: ' + 'Invalid token (DecodeError) (received token sha256 prefix: 4d4f92fb)' } From c3145a9f65d5aa84dd497959585d182dd53ce275 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 19 Aug 2026 14:54:34 +0800 Subject: [PATCH 17/28] mcp: don't run API keys through JWT validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _resolve_token tried the JWT path before checking for the gns3_ prefix, so every API-key connection logged a spurious "JWT rejected" ERROR from get_token_data. Check the prefix first, and downgrade the JWT-rejected log to WARNING — a rejected token is a client problem, not a server one. --- gns3server/api/routes/mcp/__init__.py | 18 ++++++++++-------- gns3server/services/authentication.py | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 021f66b99..47438b9c8 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -213,14 +213,16 @@ async def _resolve_token(token: str) -> str | None: Returns None if the token is invalid. """ - # Try JWT first - try: - token_data = auth_service.get_token_data(token) - _jwt_username_var.set(token_data.username) - _jwt_token_version_var.set(token_data.token_version) - return token - except Exception: - pass + # API keys (gns3_...) are never valid JWTs — skip the JWT attempt for them + # so it doesn't log a spurious "JWT rejected" line on every API-key connection. + if not token.startswith("gns3_"): + try: + token_data = auth_service.get_token_data(token) + _jwt_username_var.set(token_data.username) + _jwt_token_version_var.set(token_data.token_version) + return token + except Exception: + pass # Try API key — format: gns3__ → O(1) lookup if token.startswith("gns3_") and _app is not None: diff --git a/gns3server/services/authentication.py b/gns3server/services/authentication.py index 6103ee6f9..90f943b99 100644 --- a/gns3server/services/authentication.py +++ b/gns3server/services/authentication.py @@ -115,10 +115,10 @@ class AuthService: token_use: str = payload.claims.get("type", "access") token_data = TokenData(username=username, token_version=token_version, token_use=token_use) except BadSignatureError as e: - log.error("JWT rejected: bad signature (header alg: '%s', error: %s)", _extract_alg(token), e) + log.warning("JWT rejected: bad signature (header alg: '%s', error: %s)", _extract_alg(token), e) raise auth_error("Invalid token signature") except (JoseError, ValidationError, ValueError) as e: - log.error("JWT rejected: %s: %s (header alg: '%s')", type(e).__name__, e, _extract_alg(token)) + log.warning("JWT rejected: %s: %s (header alg: '%s')", type(e).__name__, e, _extract_alg(token)) raise auth_error(f"Invalid token ({type(e).__name__})") return token_data From 210103058f10a461b810c2a316bf49797c9f5697 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 19 Aug 2026 22:44:09 +0800 Subject: [PATCH 18/28] docker: don't recreate containers on empty-string property PUTs Web clients serialize empty form fields as "" while unset values are stored as None on the node. The bare != diff in the update handler then sees a phantom change on every full PUT and recreates the container for nothing -- even when the user only changed a controller-only field such as netmiko_device_type. Normalize at the schema boundary ("" -> None for start_command, environment and extra_hosts; "" -> "/" for console_http_path), make the setters apply the same canonicalization, and create nodes through the setters instead of bypassing them in __init__ so both paths store identical values. --- gns3server/compute/docker/docker_vm.py | 20 ++++++---- gns3server/schemas/compute/docker_nodes.py | 17 +++++++- tests/api/routes/compute/test_docker_nodes.py | 39 +++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index fa45c43e6..1fade0fd1 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -129,8 +129,10 @@ class DockerVM(BaseNode): if ":" not in image: image = f"{image}:latest" self._image = image - self._start_command = start_command - self._environment = environment + # assign through the property setters so creation and updates apply + # the same value normalization (e.g. "" -> None) + self.start_command = start_command + self.environment = environment self._cid = None self._ethernet_adapters = [] self._temporary_directory = None @@ -138,10 +140,10 @@ class DockerVM(BaseNode): self._vnc_process = None self._vncconfig_process = None self._console_resolution = console_resolution - self._console_http_path = console_http_path + self.console_http_path = console_http_path self._console_http_port = console_http_port self._console_websocket = None - self._extra_hosts = extra_hosts + self.extra_hosts = extra_hosts self._extra_volumes = extra_volumes or [] self._extra_configs = extra_configs or [] self._memory = memory @@ -288,7 +290,9 @@ class DockerVM(BaseNode): @console_http_path.setter def console_http_path(self, path): - self._console_http_path = path + # the canonical "no path" value is "/" so that "", None and "/" + # all compare equal in the update diff + self._console_http_path = path or "/" @property def console_http_port(self): @@ -304,7 +308,8 @@ class DockerVM(BaseNode): @environment.setter def environment(self, command): - self._environment = command + # "" and None are the same "no environment variables" value + self._environment = command or None @property def extra_hosts(self): @@ -312,7 +317,8 @@ class DockerVM(BaseNode): @extra_hosts.setter def extra_hosts(self, extra_hosts): - self._extra_hosts = extra_hosts + # "" and None are the same "no extra hosts" value + self._extra_hosts = extra_hosts or None @property def extra_volumes(self): diff --git a/gns3server/schemas/compute/docker_nodes.py b/gns3server/schemas/compute/docker_nodes.py index 1461c18c1..7b39921f6 100644 --- a/gns3server/schemas/compute/docker_nodes.py +++ b/gns3server/schemas/compute/docker_nodes.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from typing import Optional, List from uuid import UUID @@ -26,6 +26,21 @@ class DockerBase(BaseModel): Common Docker node properties. """ + @field_validator("start_command", "environment", "extra_hosts", mode="before") + @classmethod + def _empty_string_to_none(cls, value): + # Web clients serialize empty form fields as "" while unset values are + # stored as None on the node: normalize before the update diff runs, + # otherwise every full PUT would see a phantom change and recreate + # the container for nothing. + return value or None + + @field_validator("console_http_path", mode="before") + @classmethod + def _empty_string_to_root_path(cls, value): + # the canonical "no path" value is "/" (the creation default) + return value or "/" + name: str image: str = Field(..., description="Docker image name") node_id: Optional[UUID] = None diff --git a/tests/api/routes/compute/test_docker_nodes.py b/tests/api/routes/compute/test_docker_nodes.py index 13e4609b7..278596dba 100644 --- a/tests/api/routes/compute/test_docker_nodes.py +++ b/tests/api/routes/compute/test_docker_nodes.py @@ -306,6 +306,45 @@ class TestDockerNodesRoutes: assert response.json()["environment"] == "GNS3=1\nGNS4=0" assert response.json()["extra_hosts"] == "test:127.0.0.1" + async def test_docker_update_empty_strings_do_not_recreate_container( + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project + ) -> None: + """ + Web clients serialize empty form fields as "" while unset values are + stored as None on the node: a full PUT must not see a phantom change + and recreate the container for nothing. + """ + + params = {"name": "DOCKER-EMPTY", "image": "nginx", "environment": ""} + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "nginx"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": "8bd8153ea8f5"}): + response = await compute_client.post( + app.url_path_for("compute:create_docker_node", project_id=compute_project.id), json=params + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["environment"] is None # "" normalized at creation + assert response.json()["console_http_path"] == "/" + node_id = response.json()["node_id"] + + with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.update") as mock: + response = await compute_client.put( + app.url_path_for("compute:update_docker_node", project_id=compute_project.id, node_id=node_id), + json={ + "name": "DOCKER-EMPTY", + "start_command": "", + "environment": "", + "extra_hosts": "", + "console_http_path": "", + }, + ) + assert response.status_code == 200 + assert not mock.called # no real change: the container must not be recreated + assert response.json()["start_command"] is None + assert response.json()["console_http_path"] == "/" + async def test_docker_start_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: From 72dc10a6693dab11e266e1304ea6a31534072182 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 19 Aug 2026 23:27:40 +0800 Subject: [PATCH 19/28] controller: allow markers and packet filters on Ethernet switch links The brctl Ethernet switch runs a per-port uBridge relay, so it can host the mark filter and packet filters like any uBridge-backed node. Add ethernet_switch to _MARKER_CAPABLE_TYPES and _get_filter_node, narrow the UDPLink.update() NIO-PUT skip down to the Dynamips-hosted ethernet_hub, and expose the matching compute endpoints: PUT nio (filter/marker reapply) plus the per-marker toggle/pause/resume/delete/rebuild routes. The ethernet_hub keeps its exclusion: its routes still wire into the Dynamips hub, which has no uBridge of its own. --- .../builtin-ethernet-switch-ubridge.md | 6 +- docs/features/marker-traffic-insight.md | 10 +- .../routes/compute/ethernet_switch_nodes.py | 99 +++++++++++++++++++ gns3server/controller/link.py | 2 + gns3server/controller/udp_link.py | 14 ++- .../compute/test_ethernet_switch_nodes.py | 87 ++++++++++++++++ tests/controller/test_link.py | 4 +- tests/controller/test_marker.py | 28 +++++- tests/controller/test_udp_link.py | 49 +++++++++ 9 files changed, 282 insertions(+), 17 deletions(-) diff --git a/docs/features/builtin-ethernet-switch-ubridge.md b/docs/features/builtin-ethernet-switch-ubridge.md index 14e8e46b2..45e5430c0 100644 --- a/docs/features/builtin-ethernet-switch-ubridge.md +++ b/docs/features/builtin-ethernet-switch-ubridge.md @@ -85,7 +85,11 @@ bridge start {node_id}-{port} ``` Captures and marker signals are applied via the existing `_ubridge_apply_filters` -and `_ubridge_apply_markers` helpers from `BaseNode`. +and `_ubridge_apply_markers` helpers from `BaseNode`. The controller therefore allows +packet filters and traffic-insight markers on switch links (including switch-to-switch): +`ethernet_switch` is a marker/filter-capable node type, and the compute API exposes the +matching NIO-update and per-marker endpoints. The `ethernet_hub` — still Dynamips-hosted, +no uBridge — remains excluded. ### `remove_nio(port_number)` diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index b275c02b7..ab71cbf65 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -97,8 +97,8 @@ IOU runs a single `IOL-BRIDGE` per node shared by every interface, so `bridge`+` identical across that node's links. uBridge keeps a separate filter list **per port (bay/unit)** within the bridge, so each interface gets its own `global-{name}` filter, its own pcap file, and its own `link=`. The shared bridge name is irrelevant to attribution. Other -capable node types (`qemu`, `docker`, `vpcs`, `cloud`) already use one bridge per link; `link` -applies uniformly to all of them. +capable node types (`qemu`, `docker`, `vpcs`, `cloud`, `ethernet_switch`) already use one +bridge per link (the switch's per-port relay); `link` applies uniformly to all of them. ## Direction @@ -137,7 +137,7 @@ pass `capture_node_id` on marker **create**: ``` The value must be one of the link's two endpoints and a marker-capable type (`vpcs`, `qemu`, -`docker`, `iou`, `dynamips`, `cloud`); any other id is rejected with `409`. Omit it to keep +`docker`, `iou`, `dynamips`, `cloud`, `ethernet_switch`); any other id is rejected with `409`. Omit it to keep the auto-pick. The chosen id is echoed back as `capture_node_id` in the marker entry and in each `MARK` signal's `node=`, so the Web UI always knows the observer regardless of who picked it. @@ -357,7 +357,9 @@ direction relative to the capture node; see [Direction](#direction). runs one `tcpdump -d` rather than *N*. (uBridge still runs `pcap_compile` itself at install time, so an invalid expression can never slip through.) - **Supported node types.** A marker needs a uBridge bridge: `vpcs`, `qemu`, `docker`, - `iou`, `dynamips`, `cloud` (one capable endpoint suffices). Types without a uBridge are + `iou`, `dynamips`, `cloud`, `ethernet_switch` (one capable endpoint suffices). The + `ethernet_switch` hosts markers on its per-port uBridge relays (brctl backend); the + `ethernet_hub` is still Dynamips-hosted and has no uBridge. Types without a uBridge are silently skipped by the inheritance fan-out. IOU uses one shared `IOL-BRIDGE` per node but keeps filters, pcap files, and `link=` ids per port, so multi-interface nodes are handled (see [Per-link attribution](#per-link-attribution)). diff --git a/gns3server/api/routes/compute/ethernet_switch_nodes.py b/gns3server/api/routes/compute/ethernet_switch_nodes.py index eaff681cb..d6d253ca9 100644 --- a/gns3server/api/routes/compute/ethernet_switch_nodes.py +++ b/gns3server/api/routes/compute/ethernet_switch_nodes.py @@ -192,6 +192,33 @@ async def create_ethernet_switch_nio( return nio.asdict() +@router.put( + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", + status_code=status.HTTP_201_CREATED, + response_model=schemas.UDPNIO, +) +async def update_ethernet_switch_nio( + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: schemas.UDPNIO, + node: EthernetSwitch = Depends(dep_node) +) -> schemas.UDPNIO: + """ + Update a NIO (Network Input/Output) on the node: re-apply the packet + filters and traffic-insight markers carried by the NIO onto the port's + uBridge relay. The adapter number on the switch is always 0. + """ + + nio = node.get_nio(port_number) + nio.filters.clear() + if nio_data.filters: + nio.filters = nio_data.filters + nio.markers = nio_data.markers or {} + await node.update_nio(port_number, nio) + return nio.asdict() + + @router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT) async def delete_ethernet_switch_nio( *, @@ -257,3 +284,75 @@ async def stream_pcap_file( nio = node.get_nio(port_number) stream = Builtin.instance().stream_pcap_file(nio, node.project.id) return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") + + +@router.put("/{node_id}/markers/{marker_name}") +async def toggle_ethernet_switch_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: EthernetSwitch = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + if not any(n == marker_name for (n, lid) in node._marker_filter_bridges): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Marker '{marker_name}' is not installed on this node", + ) + await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled) + return {"marker_name": marker_name, "enabled": toggle_data.enabled} + + +@router.post("/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT) +async def pause_ethernet_switch_markers(node: EthernetSwitch = Depends(dep_node)) -> None: + + await node._ubridge_marker_pause() + + +@router.post("/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT) +async def resume_ethernet_switch_markers(node: EthernetSwitch = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() + + +@router.delete( + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_ethernet_switch_marker_capture( + *, + marker_name: str, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + link_id: str = "", + node: EthernetSwitch = Depends(dep_node) +) -> None: + """ + Delete a marker's capture pcap (called by the controller when the marker is + removed) so the file is cleaned up even with the switch stopped. Also drops + the marker from the port NIO's cached spec so a switch restart won't + reinstall it (and recreate an empty pcap). The adapter number is always 0. + """ + + nio = node.get_nio(port_number) + await node.delete_marker_capture(marker_name, link_id, nio) + + +@router.put("/{node_id}/markers/{marker_name}/rebuild") +async def rebuild_ethernet_switch_marker( + marker_name: str, + rebuild_data: schemas.MarkerRebuild, + node: EthernetSwitch = Depends(dep_node) +) -> dict: + """ + Re-install a single marker filter with new BPF/tag/direction (delete + add, + no bridge reset) so sibling markers' pcaps stay open. + """ + + await node.rebuild_marker_filter( + marker_name, rebuild_data.link_id, rebuild_data.bpf, + rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, + ) + return {"marker_name": marker_name} diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index d27fe80e8..5f68e484b 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -624,6 +624,8 @@ class Link: "nat", "virtualbox", "docker", + # the brctl Ethernet switch applies filters on its per-port uBridge relays + "ethernet_switch", ): return node["node"] return None diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 1184dad74..bc6cf2d52 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -24,12 +24,13 @@ from .link import Link, _UNSET from .node_types import BUILTIN_NODE_TYPES from gns3server.utils.packet_filter_validation import validate_bpf_syntax, FilterValidationError -# Node types without a uBridge bridge — a marker filter has nothing to attach to. # Node types that can host a marker (have a uBridge bridge to attach the # `mark` filter to). Mirrors _get_filter_node in link.py, minus "nat" -# (which has no uBridge). +# (which has no uBridge) and "ethernet_hub" (still Dynamips-hosted, no +# uBridge of its own). "ethernet_switch" hosts markers on the per-port +# uBridge relays of its brctl kernel-bridge backend. _MARKER_CAPABLE_TYPES = frozenset({ - "vpcs", "qemu", "docker", "iou", "dynamips", "cloud", + "vpcs", "qemu", "docker", "iou", "dynamips", "cloud", "ethernet_switch", }) @@ -234,7 +235,10 @@ class UDPLink(Link): self._link_data[0]["filters"] = node1_filters self._link_data[0]["markers"] = node1_markers self._link_data[0]["suspend"] = self._suspended - if node1.node_type not in ("ethernet_switch", "ethernet_hub"): + # The Ethernet hub is still Dynamips-hosted (no uBridge of its own and + # no PUT NIO route) — keep skipping its side. Every other node type, + # including the brctl Ethernet switch, re-applies via the NIO update. + if node1.node_type != "ethernet_hub": await node1.put( f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=self._link_data[0], timeout=120 ) @@ -244,7 +248,7 @@ class UDPLink(Link): self._link_data[1]["filters"] = node2_filters self._link_data[1]["markers"] = node2_markers self._link_data[1]["suspend"] = self._suspended - if node2.node_type not in ("ethernet_switch", "ethernet_hub"): + if node2.node_type != "ethernet_hub": await node2.put( f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=221 ) diff --git a/tests/api/routes/compute/test_ethernet_switch_nodes.py b/tests/api/routes/compute/test_ethernet_switch_nodes.py index 16677686b..af2a12ef1 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -382,6 +382,7 @@ class TestEthernetSwitchNodesRoutes: response = await compute_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT + # the port's relay bridge and TAP are released from the kernel bridge br = node._bridge_name tap = f"{br}-0" relay = f"{node.id}-0" @@ -390,6 +391,92 @@ class TestEthernetSwitchNodesRoutes: call(f"bridge delete {relay}"), ]) + async def test_ethernet_switch_update_nio( + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + ethernet_switch: dict + ) -> None: + + url = app.url_path_for( + "compute:create_ethernet_switch_nio", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0" + ) + params = self._udp_params() + params["filters"] = {"delay": [10, 0]} + response = await compute_client.post(url, json=params) + assert response.status_code == status.HTTP_201_CREATED + + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + + params["filters"] = {"packet_loss": [10]} + params["markers"] = {} + url = app.url_path_for( + "compute:update_ethernet_switch_nio", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0" + ) + response = await compute_client.put(url, json=params) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["filters"] == {"packet_loss": [10]} + + # update_nio re-applies the filters on the port's uBridge relay + relay = node._ubridge_bridge_name(0) + node._ubridge_send.assert_any_call(f"bridge reset_packet_filters {relay}") + + async def test_ethernet_switch_toggle_marker( + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + ethernet_switch: dict + ) -> None: + + # a marker installed via the NIO registers in the node's filter-bridge map + url = app.url_path_for( + "compute:create_ethernet_switch_nio", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0" + ) + params = self._udp_params() + params["markers"] = {"icmp": {"bpf": "icmp", "link_id": "link-1", "enabled": True}} + response = await compute_client.post(url, json=params) + assert response.status_code == status.HTTP_201_CREATED + + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + + url = app.url_path_for( + "compute:toggle_ethernet_switch_marker", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + marker_name="icmp" + ) + response = await compute_client.put(url, json={"enabled": False}) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"marker_name": "icmp", "enabled": False} + relay = node._ubridge_bridge_name(0) + node._ubridge_send.assert_any_call(f"bridge enable_packet_filter {relay} icmp off") + + # toggling an unknown marker is a 404 + url = app.url_path_for( + "compute:toggle_ethernet_switch_marker", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + marker_name="nope" + ) + response = await compute_client.put(url, json={"enabled": True}) + assert response.status_code == status.HTTP_404_NOT_FOUND + async def test_ethernet_switch_start_capture( self, app: FastAPI, diff --git a/tests/controller/test_link.py b/tests/controller/test_link.py index 54d1db5ba..0aa29b219 100644 --- a/tests/controller/test_link.py +++ b/tests/controller/test_link.py @@ -378,9 +378,9 @@ async def test_available_filters(project, compute): link.create = AsyncioMagicMock() assert link.available_filters() == [] - # Ethernet switch is not supported should return 0 filters + # The brctl Ethernet switch hosts filters on its per-port uBridge relays await link.add_node(node1, 0, 4) - assert link.available_filters() == [] + assert len(link.available_filters()) > 0 node2 = Node(project, compute, "node2", node_type="vpcs") node2._ports = [EthernetPort("E0", 0, 0, 4)] diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index c5297aeff..7bacef1f2 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -56,20 +56,21 @@ def _valid_bpf(): return stack -async def _make_link(project, port_cls=EthernetPort): - """Build a created UDPLink between two VPCS nodes on a mocked compute. +async def _make_link(project, port_cls=EthernetPort, node_types=("vpcs", "vpcs")): + """Build a created UDPLink between two nodes on a mocked compute. ``port_cls`` defaults to EthernetPort; pass SerialPort for a serial link - (the link's link_type follows the port). + (the link's link_type follows the port). ``node_types`` overrides the + endpoint node types (e.g. a switch-to-switch link). """ compute = MagicMock() compute.id = "local" compute.host = "example.com" - node1 = Node(project, compute, "n1", node_type="vpcs") + node1 = Node(project, compute, "n1", node_type=node_types[0]) node1._ports = [port_cls("E0", 0, 0, 0)] - node2 = Node(project, compute, "n2", node_type="vpcs") + node2 = Node(project, compute, "n2", node_type=node_types[1]) node2._ports = [port_cls("E0", 0, 0, 1)] async def subnet(_other): @@ -112,6 +113,23 @@ async def test_start_marker_stores_entry(project): assert entry["highlight_duration"] == 800 assert entry["enabled"] is True assert entry["capture_node_id"] in {n["node"].id for n in link._nodes} + + +@pytest.mark.asyncio +async def test_start_marker_on_ethernet_switch_link(project): + """A switch-to-switch link can host a marker: the brctl Ethernet switch + runs a per-port uBridge relay the `mark` filter attaches to.""" + + with _valid_bpf(): + link = await _make_link(project, node_types=("ethernet_switch", "ethernet_switch")) + await link.start_marker("icmp", "icmp") + + entry = link.markers["icmp"] + switch_ids = {n["node"].id for n in link._nodes} + assert entry["capture_node_id"] in switch_ids + # the marker rides exactly one side's NIO and is pushed via update() + carrying = [d for d in link._link_data if "icmp" in d["markers"]] + assert len(carrying) == 1 assert "inherited_from" not in entry diff --git a/tests/controller/test_udp_link.py b/tests/controller/test_udp_link.py index b17120757..e5045cc36 100644 --- a/tests/controller/test_udp_link.py +++ b/tests/controller/test_udp_link.py @@ -451,6 +451,55 @@ async def test_update(project): }, timeout=120) +@pytest.mark.asyncio +async def test_update_ethernet_switch_nio(project): + """ + Link updates must reach an Ethernet switch endpoint: the brctl switch has + a PUT NIO route, so only the Dynamips-hosted hub side stays skipped. + """ + + compute1 = MagicMock() + + node_vpcs = Node(project, compute1, "node1", node_type="vpcs") + node_vpcs._ports = [EthernetPort("E0", 0, 0, 4)] + node_switch = Node(project, compute1, "node2", node_type="ethernet_switch") + node_switch._ports = [EthernetPort("E0", 0, 3, 1)] + + async def subnet_callback(compute2): + return ("192.168.1.1", "192.168.1.2") + + compute1.get_ip_on_same_subnet.side_effect = subnet_callback + + async def compute1_callback(path, data={}, **kwargs): + if "/ports/udp" in path: + response = MagicMock() + response.json = {"udp_port": 1024} + return response + + compute1.post.side_effect = compute1_callback + compute1.put = AsyncioMagicMock() + compute1.host = "example.com" + + link = UDPLink(project) + await link.add_node(node_vpcs, 0, 4) + await link.add_node(node_switch, 3, 1) + assert link.created + + await link.update_filters({"delay": [10, 0]}) + compute1.put.assert_any_call( + "/projects/{}/ethernet_switch/nodes/{}/adapters/3/ports/1/nio".format(project.id, node_switch.id), + data={ + "lport": 1024, + "rhost": "192.168.1.1", + "rport": 1024, + "type": "nio_udp", + "suspend": False, + "markers": {}, + "filters": {} + }, timeout=221 + ) + + @pytest.mark.asyncio async def test_update_suspend(project): compute1 = MagicMock() From 5188ae625a282ffe9b04c9e31421f7c1b1e54b7e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 19 Aug 2026 23:34:14 +0800 Subject: [PATCH 20/28] tests: pick a free console port instead of hardcoding 5011 A dev machine running a real gns3server (or qemu) can already listen on 5011; reserve_tcp_port then silently replaces it with the next free port and test_console fails on the exact-echo assertion. Pick a port that is actually free on the host first, like test_change_console_port does. --- tests/compute/test_base_node.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/compute/test_base_node.py b/tests/compute/test_base_node.py index fa8dce6a5..9aa97cf81 100644 --- a/tests/compute/test_base_node.py +++ b/tests/compute/test_base_node.py @@ -51,11 +51,16 @@ def test_temporary_directory(compute_project, manager): assert isinstance(node.temporary_directory, str) -def test_console(compute_project, manager): +def test_console(compute_project, manager, port_manager): node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) - node.console = 5011 - assert node.console == 5011 + # pick a port that is actually free on this host: a hardcoded one may be + # taken by a running gns3server/qemu on a dev machine, and the setter would + # silently replace it with the next free port + console_port = port_manager.get_free_tcp_port(node.project) + port_manager.release_tcp_port(console_port, node.project) + node.console = console_port + assert node.console == console_port node.console = None assert node.console is None From abd0b8e274bf5d2c3ff4cbfa17cdb9b75d361fa8 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 20 Aug 2026 13:30:13 +0800 Subject: [PATCH 21/28] console: forward client terminal size over the console WebSocket The docker_exec console defaults its exec PTY to 511x10000 (the no-NAWS default that keeps the IOS-XR pager quiet for netmiko). A CPR-answering client (xterm.js) on top of that tall canvas makes prompt_toolkit-based CLIs (SR Linux sr_cli) re-emit their accumulated output on every incremental render: ~145 KB instead of ~60 KB per command, visible in the WebUI as full-screen clear/redraw flicker. Let WebSocket console clients propagate their real terminal geometry: binary frames {"cols": N, "rows": M} alongside text frames carrying terminal data. The controller forwards binary frames (previously only text was forwarded), and the compute side turns them into a NAWS subnegotiation for telnet-based consoles (docker_exec included) or an asyncssh pty size change for SSH consoles. The docker_exec console restores the tall 511x10000 default when its last client disconnects, so a later non-NAWS client (netmiko, bare telnet) connecting to the still-live exec doesn't inherit a browser geometry and hit PTY-window paging again. --- gns3server/api/routes/controller/nodes.py | 12 +++- gns3server/compute/base_node.py | 56 +++++++++++++++++++ gns3server/compute/docker/vendor_docker_vm.py | 16 +++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index d0b27f81f..de29db5dc 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -694,13 +694,19 @@ async def ws_console( async def ws_receive(ws_console_compute): """ Receive WebSocket data from client and forward to compute console WebSocket. + Text frames carry terminal data; binary frames carry client control + messages (e.g. terminal size), forwarded as-is. """ try: while True: - data = await websocket.receive_text() - if data: - await ws_console_compute.send_str(data) + msg = await websocket.receive() + if msg["type"] == "websocket.disconnect": + break + if "text" in msg and msg["text"]: + await ws_console_compute.send_str(msg["text"]) + elif "bytes" in msg and msg["bytes"]: + await ws_console_compute.send_bytes(msg["bytes"]) except WebSocketDisconnect: await ws_console_compute.close() log.info( diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 8e3188c84..f22fa97c0 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -19,6 +19,9 @@ import os import stat import shutil import asyncio +import contextlib +import json +import struct import tempfile import psutil import platform @@ -561,6 +564,51 @@ class BaseNode: log.warning(f"Cannot connect to node {self.name} console server: {e}") return + def _parse_terminal_size_message(data: bytes): + """ + Binary control frames sent by WebSocket console clients to propagate + their terminal geometry: {"cols": int, "rows": int}. Terminal data + travels as text frames (xterm.js AttachAddon), so binary frames are + an unambiguous side channel. Returns (cols, rows) or None. + """ + + try: + message = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + return None + if not isinstance(message, dict): + return None + cols, rows = message.get("cols"), message.get("rows") + if ( + isinstance(cols, int) and not isinstance(cols, bool) + and isinstance(rows, int) and not isinstance(rows, bool) + and 2 <= cols <= 5000 + and 2 <= rows <= 100000 + ): + return cols, rows + return None + + async def resize_console(cols: int, rows: int) -> None: + """ + Propagate a client terminal resize to the node console stream: + SSH channels use a pty request update, telnet-based consoles + (including docker_exec) speak a NAWS subnegotiation to the console + telnet server, which resizes the underlying stream (e.g. the + docker exec pty). + """ + + if self._console_type == "ssh": + with contextlib.suppress(AttributeError): + ssh_process.change_terminal_size(cols, rows) + else: + telnet_writer.write( + bytes([255, 251, 31]) # IAC WILL NAWS + + bytes([255, 250, 31]) # IAC SB NAWS + + struct.pack("!HH", cols, rows).replace(b"\xff", b"\xff\xff") + + bytes([255, 240]) # IAC SE + ) + await telnet_writer.drain() + async def ws_forward(telnet_writer): try: @@ -571,6 +619,14 @@ class BaseNode: if "text" in msg and msg["text"]: data = msg["text"].encode() elif "bytes" in msg and msg["bytes"]: + size = _parse_terminal_size_message(msg["bytes"]) + if size is not None: + log.debug( + f"Console WebSocket client {websocket.client.host}:{websocket.client.port}" + f" resized terminal to {size[0]}x{size[1]}" + ) + await resize_console(*size) + continue data = msg["bytes"] else: continue diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index dbbdf340c..46c56ca6f 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -412,6 +412,17 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): return False return True + async def _disconnect_client(self, network_writer): + await super()._disconnect_client(network_writer) + # When the last client leaves, restore the tall no-NAWS default: a + # browser client resizes the exec to its own geometry (WS terminal + # size control frames -> NAWS), and the next non-NAWS client (netmiko, + # bare telnet) connecting to the still-live exec would otherwise + # inherit it and hit PTY-window paging (the IOS-XR --More-- trap). + if self._exec_id and not await self._get_connections_snapshot(): + with contextlib.suppress(Exception): + await self._on_naws(511, 10000) + async def _on_naws(self, columns, rows): if self._exec_id: try: @@ -509,8 +520,9 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): # (e.g. the IOS-XR pager) park at --More-- for clients # that never negotiate NAWS (netmiko, bare telnet). # Width 511 matches netmiko's 'terminal width 511'. - # Real NAWS clients resize to their own geometry right - # after connecting. + # WebUI clients resize to their real geometry right after + # connecting, via WS terminal-size control frames turned + # into NAWS by start_websocket_console. await self._on_naws(511, 10000) except Exception: pass From 5741e85b65d806649e2a67896248defa93d5a038 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 20 Aug 2026 13:36:55 +0800 Subject: [PATCH 22/28] docker: add GNS3_CONSOLE_RESIZE knob for paging CLIs The exec behind a docker_exec console is shared by every console client, so a browser's terminal-size resize (WS control frames -> NAWS) also changes the geometry concurrent netmiko sessions see. SR Linux doesn't care (no pager, no hard wrapping), but CLIs that page on the PTY window size (IOS-XR) would park at --More-- again the moment a browser is connected. Split the client-driven NAWS path (_on_naws) from the internal resize (_resize_exec): GNS3_CONSOLE_RESIZE=0 makes the console ignore client resizes entirely and keep the tall 511x10000 no-paging default, while the creation-time default and the restore-on-last-disconnect still go through the internal path. XRd appliance templates should set it. --- gns3server/compute/docker/vendor_docker_vm.py | 34 ++++++++++-- tests/compute/docker/test_vendor_docker_vm.py | 52 +++++++++++++++++-- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 46c56ca6f..ca4b10489 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -55,6 +55,11 @@ class VendorDockerVM(DockerVM): (adapter order) instead of default ``eth{N}``. * ``GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli`` — command run inside the container by the ``docker_exec`` console (defaults to ``/bin/sh``). + * ``GNS3_CONSOLE_RESIZE=0`` — ignore client-driven console resizes + (WS terminal-size frames / telnet NAWS). Set for CLIs that page on the + PTY window size (IOS-XR): the exec PTY must stay at the tall + no-NAWS default for every client, including concurrent netmiko + sessions on the shared exec. * ``GNS3_STOP_TIMEOUT=60`` — SIGTERM grace period in seconds when stopping the container (default 60; Docker SIGKILLs once it expires). """ @@ -77,6 +82,7 @@ class VendorDockerVM(DockerVM): self._gns3_init = True self._interface_names = [] self._console_cmd = None + self._console_resize = True self._stop_timeout = 60 if self._environment: for _line in self._environment.splitlines(): @@ -89,6 +95,8 @@ class VendorDockerVM(DockerVM): ] elif _line.startswith("GNS3_CONSOLE_CMD="): self._console_cmd = _line.split("=", 1)[1].strip() + elif _line.startswith("GNS3_CONSOLE_RESIZE="): + self._console_resize = _line.split("=", 1)[1].strip().lower() not in ("0", "false", "no") elif _line.startswith("GNS3_STOP_TIMEOUT="): try: timeout = int(_line.split("=", 1)[1].strip()) @@ -357,7 +365,13 @@ class VendorDockerVM(DockerVM): Command from GNS3_CONSOLE_CMD. """ - telnet = _LazyExecTelnetServer(self, self.manager, self._cid, self._console_cmd or "/bin/sh") + telnet = _LazyExecTelnetServer( + self, + self.manager, + self._cid, + self._console_cmd or "/bin/sh", + allow_resize=self._console_resize, + ) try: self._telnet_servers.append( await telnet.start(self._manager.port_manager.console_host, self.console) @@ -384,7 +398,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): CPR, producing a blank/degraded screen on reconnect. """ - def __init__(self, vm, manager, cid, command): + def __init__(self, vm, manager, cid, command, allow_resize=True): super().__init__( reader=None, writer=None, @@ -397,6 +411,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): self._manager = manager self._cid = cid self._command = command + self._allow_resize = allow_resize self._exec_id = None self._broadcast_task = None self._lock = asyncio.Lock() @@ -421,9 +436,9 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): # inherit it and hit PTY-window paging (the IOS-XR --More-- trap). if self._exec_id and not await self._get_connections_snapshot(): with contextlib.suppress(Exception): - await self._on_naws(511, 10000) + await self._resize_exec(511, 10000) - async def _on_naws(self, columns, rows): + async def _resize_exec(self, columns, rows): if self._exec_id: try: await self._manager.query( @@ -434,6 +449,15 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): except DockerError: pass + async def _on_naws(self, columns, rows): + # Client-driven resize (WS terminal-size control frames, telnet NAWS). + # Ignored for paging CLIs (GNS3_CONSOLE_RESIZE=0): with the exec shared + # by all clients, one browser resize would break concurrent netmiko + # sessions that rely on the tall no-paging geometry. + if not self._allow_resize: + return + await self._resize_exec(columns, rows) + async def run(self, network_reader, network_writer): """Catch and log any exception that kills the client session.""" try: @@ -523,7 +547,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): # WebUI clients resize to their real geometry right after # connecting, via WS terminal-size control frames turned # into NAWS by start_websocket_console. - await self._on_naws(511, 10000) + await self._resize_exec(511, 10000) except Exception: pass else: diff --git a/tests/compute/docker/test_vendor_docker_vm.py b/tests/compute/docker/test_vendor_docker_vm.py index 691adf1dd..753c3df91 100644 --- a/tests/compute/docker/test_vendor_docker_vm.py +++ b/tests/compute/docker/test_vendor_docker_vm.py @@ -463,12 +463,15 @@ def test_cleanup_console_resources_no_writer(compute_project, manager): # _LazyExecTelnetServer — upstream aliveness + reconnect/recreate logic # --------------------------------------------------------------------------- -def _make_lazy_server(compute_project, manager): +def _make_lazy_server(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli"): """Build a _LazyExecTelnetServer with _create_exec mocked out (no docker).""" - vm = _make_vm(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli") - srv = _LazyExecTelnetServer(vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli") + vm = _make_vm(compute_project, manager, environment=environment) + srv = _LazyExecTelnetServer( + vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli", + allow_resize=vm._console_resize, + ) srv._create_exec = AsyncioMagicMock() - srv._on_naws = AsyncioMagicMock() + srv._resize_exec = AsyncioMagicMock() return srv @@ -539,7 +542,46 @@ async def test_first_connect_sets_tall_default_pty_geometry(compute_project, man srv = _make_lazy_server(compute_project, manager) await srv.client_connected_hook() - srv._on_naws.assert_called_once_with(511, 10000) + srv._resize_exec.assert_called_once_with(511, 10000) + + +@pytest.mark.asyncio +async def test_client_naws_resizes_exec_by_default(compute_project, manager): + """Client-driven NAWS (WS terminal-size frames) reaches the exec resize.""" + + srv = _make_lazy_server(compute_project, manager) + await srv._on_naws(120, 40) + srv._resize_exec.assert_called_once_with(120, 40) + + +@pytest.mark.asyncio +async def test_client_naws_ignored_when_resize_disabled(compute_project, manager): + """GNS3_CONSOLE_RESIZE=0: client resizes must not change the shared exec + geometry (paging CLIs need the tall default for concurrent netmiko).""" + + srv = _make_lazy_server( + compute_project, manager, + environment="GNS3_CONSOLE_RESIZE=0", + ) + assert srv._allow_resize is False + await srv._on_naws(120, 40) + srv._resize_exec.assert_not_called() + # the tall default is still applied at exec creation (internal path) + await srv.client_connected_hook() + srv._resize_exec.assert_called_once_with(511, 10000) + + +@pytest.mark.asyncio +async def test_last_client_disconnect_restores_tall_default(compute_project, manager): + """When the last console client leaves, the exec goes back to the tall + no-NAWS default so a later non-NAWS client (netmiko) doesn't inherit a + browser geometry and hit PTY-window paging.""" + + srv = _make_lazy_server(compute_project, manager) + srv._exec_id = "abc" + writer = AsyncioMagicMock() + await srv._disconnect_client(writer) + srv._resize_exec.assert_called_once_with(511, 10000) @pytest.mark.asyncio From 65e8eb9e286cbe317780e415b406c88414545cce Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 20 Aug 2026 13:42:46 +0800 Subject: [PATCH 23/28] docker: don't lose a client size that races the exec creation A browser's terminal-size control frame (NAWS through the console telnet server) can arrive while client_connected_hook is still creating the exec; the resize is a no-op then, and the tall default applied after creation would overwrite it, leaving the session at 511x10000 until the user resizes. Record sizes received before the exec exists and prefer them over the tall default once creation finishes. The recorded size is cleared when the last client disconnects, together with the restore-to-default. --- gns3server/compute/docker/vendor_docker_vm.py | 36 ++++++++++++------- tests/compute/docker/test_vendor_docker_vm.py | 18 ++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index ca4b10489..13e6e0566 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -413,6 +413,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): self._command = command self._allow_resize = allow_resize self._exec_id = None + self._client_size = None # size received while no exec existed yet self._broadcast_task = None self._lock = asyncio.Lock() self._log_name = f"docker_exec console '{vm.name}'" @@ -436,18 +437,24 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): # inherit it and hit PTY-window paging (the IOS-XR --More-- trap). if self._exec_id and not await self._get_connections_snapshot(): with contextlib.suppress(Exception): + self._client_size = None await self._resize_exec(511, 10000) async def _resize_exec(self, columns, rows): - if self._exec_id: - try: - await self._manager.query( - "POST", - f"exec/{self._exec_id}/resize", - params={"h": str(rows), "w": str(columns)}, - ) - except DockerError: - pass + if not self._exec_id: + # No exec yet (first client still inside client_connected_hook): + # remember the size — the hook applies it right after creation + # instead of the tall default, so it doesn't get overwritten. + self._client_size = (columns, rows) + return + try: + await self._manager.query( + "POST", + f"exec/{self._exec_id}/resize", + params={"h": str(rows), "w": str(columns)}, + ) + except DockerError: + pass async def _on_naws(self, columns, rows): # Client-driven resize (WS terminal-size control frames, telnet NAWS). @@ -544,10 +551,13 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): # (e.g. the IOS-XR pager) park at --More-- for clients # that never negotiate NAWS (netmiko, bare telnet). # Width 511 matches netmiko's 'terminal width 511'. - # WebUI clients resize to their real geometry right after - # connecting, via WS terminal-size control frames turned - # into NAWS by start_websocket_console. - await self._resize_exec(511, 10000) + # A size already pushed by this client (WS terminal-size + # control frames -> NAWS, racing the exec creation) wins + # over the default. + if self._client_size: + await self._resize_exec(*self._client_size) + else: + await self._resize_exec(511, 10000) except Exception: pass else: diff --git a/tests/compute/docker/test_vendor_docker_vm.py b/tests/compute/docker/test_vendor_docker_vm.py index 753c3df91..e70bc6dd0 100644 --- a/tests/compute/docker/test_vendor_docker_vm.py +++ b/tests/compute/docker/test_vendor_docker_vm.py @@ -584,6 +584,24 @@ async def test_last_client_disconnect_restores_tall_default(compute_project, man srv._resize_exec.assert_called_once_with(511, 10000) +@pytest.mark.asyncio +async def test_size_arriving_before_exec_wins_over_default(compute_project, manager): + """A client size that races the exec creation (WS control frame / NAWS + arriving inside client_connected_hook) must not be overwritten by the + tall default once the exec exists.""" + + srv = _make_lazy_server(compute_project, manager) + assert srv._exec_id is None + # real _resize_exec (not the mock) records the size when no exec exists + srv._resize_exec = _LazyExecTelnetServer._resize_exec.__get__(srv) + await srv._on_naws(120, 40) + assert srv._client_size == (120, 40) + + srv._resize_exec = AsyncioMagicMock() + await srv.client_connected_hook() + srv._resize_exec.assert_called_once_with(120, 40) + + @pytest.mark.asyncio async def test_reconnect_live_exec_not_recreated(compute_project, manager): """Reconnecting while the exec is alive must NOT recreate it.""" From 8d8b2c9692b8534583e84cf653c055f8fdad5539 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 20 Aug 2026 14:08:49 +0800 Subject: [PATCH 24/28] docs: terminal geometry, WS size forwarding, GNS3_CONSOLE_RESIZE docker-exec-console.md gains a "Terminal geometry and size forwarding" section: the WS binary control-frame protocol ({"cols","rows"} -> NAWS / asyncssh resize), the tall 511x10000 default and when it is applied or restored, the exec-creation race handling, the GNS3_CONSOLE_RESIZE=0 knob for paging CLIs, and the SR Linux flicker root cause with the measured numbers (rows-driven ~2.4x output inflation on CPR-answering clients; width-independent; sr_cli's scroll-append re-rendering is inherent and identical outside GNS3). vendor-nos-xrd.md: the appliance recipe now includes GNS3_CONSOLE_RESIZE=0 with the rationale (shared exec + XR pager vs browser resizes). New troubleshooting entry for the flicker symptom. --- docs/features/docker-exec-console.md | 121 +++++++++++++++++++++++---- docs/features/vendor-nos-xrd.md | 11 +++ 2 files changed, 116 insertions(+), 16 deletions(-) diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md index a121ea23c..332e22af4 100644 --- a/docs/features/docker-exec-console.md +++ b/docs/features/docker-exec-console.md @@ -31,17 +31,20 @@ they let a vendor NOS run as a first-class GNS3 Docker router node. > schema changes, so existing Docker nodes (FRR, ipterm, …) are unaffected. > `console_type: "docker_exec"` is added to the `ConsoleType` enum. -## The three environment knobs +## Environment knobs -All three are read from the node's `environment` field. Entries prefixed with +All are read from the node's `environment` field. Entries prefixed with `GNS3_` are **not** forwarded into the container (existing GNS3 behaviour), so -they stay host-side configuration. +they stay host-side configuration. The console-relevant ones (the full vendor +set, incl. `GNS3_SHM_SIZE` / `GNS3_DEVICES` / `GNS3_MASK_UDEV` / +`GNS3_STOP_TIMEOUT`, is documented in [vendor-nos-xrd.md](./vendor-nos-xrd.md)): | Variable | Purpose | |----------|---------| | `GNS3_SKIP_INIT=1` | Do **not** prepend `/gns3/init.sh` to the entrypoint. Vendor NOS images must run their own entrypoint (e.g. SR Linux's `sr_linux`); GNS3's init script (busybox bootstrap, `ifup`, eth wait) interferes with them. | | `GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3` | Rename the injected interfaces in adapter order instead of the default `eth{N}`. SR Linux expects `mgmt0` + `e1-N`; without this it does not recognise its datapath. | | `GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli` | Command run by the `docker_exec` console inside the container. | +| `GNS3_CONSOLE_RESIZE=0` | Ignore client-driven console resizes (WS terminal-size control frames / telnet NAWS) and keep the tall no-paging PTY geometry. Set for CLIs that page on the PTY window size (IOS-XR) — see [Terminal geometry](#terminal-geometry-and-size-forwarding). | ## Architecture: `VendorDockerVM` subclass @@ -87,18 +90,21 @@ Setting `console_type: "docker_exec"` makes the node's primary console port run ```mermaid graph LR - A[Web UI xterm.js] -->|console WS| B[GNS3 Compute telnet server] - B -->|binary pty stream| C[Docker exec API] - C -->|Tty:true pty| D[sr_cli / vendor CLI] - A -.->|NAWS size| B - B -.->|POST exec/.../resize| C + A[Web UI xterm.js] -->|console WS: text frames| B[Controller forward] + B -->|WS: text + binary| C[GNS3 Compute telnet server] + C -->|binary pty stream| D[Docker exec API] + D -->|Tty:true pty| E[sr_cli / vendor CLI] + A -.->|binary control frame {"cols","rows"}| B + B -.-> C + C -.->|POST exec/.../resize| D ``` The console uses GNS3's **existing shared/broadcast telnet model**: a single exec instance (one CLI session) is broadcast to every console client, exactly like the primary console shares one PID 1. There is deliberately **no per-client session isolation** — this matches how every other GNS3 console -behaves. +behaves. The PTY geometry is likewise shared: the last client resize wins +(see [Terminal geometry](#terminal-geometry-and-size-forwarding)). ### Implementation @@ -159,8 +165,11 @@ reconnect logic is unit-tested. non-multiplexed bidirectional pty byte stream — no frame demux needed. 5. **NAWS → exec resize.** The telnet server runs with `naws=True`; the - `window_size_changed_callback` calls `POST exec/{eid}/resize?h=&w=` so the - TUI lays out for the xterm.js window size. + `window_size_changed_callback` (`_on_naws`, gated by + `GNS3_CONSOLE_RESIZE`) calls `POST exec/{eid}/resize?h=&w=` so the TUI + lays out for the client's window size. The internal `_resize_exec` path + (creation-time default, restore-on-idle) is not gated. See + [Terminal geometry](#terminal-geometry-and-size-forwarding). 6. **Binary passthrough + redraw.** `binary=True` so TUI escape sequences reach xterm.js intact; `echo=False` (the pty echoes). On every client (re)connect @@ -168,9 +177,66 @@ reconnect logic is unit-tested. screen for a previous client redraws for the new one (otherwise a reconnect shows a blank screen until the next output). +### Terminal geometry and size forwarding + +The exec PTY geometry is a shared resource with three consumers that want +different things: + +- **Browser clients (xterm.js)** need the PTY to match their real window, or + TUI CLIs misrender and over-render (below). +- **Non-NAWS clients** (netmiko, bare telnet — no terminal-size negotiation) + need the PTY *tall*: CLIs that page on the PTY window size (the IOS-XR + pager ignores `terminal length 0`) park at `--More--` on a 24-row PTY. +- **Concurrent sessions share one exec** — one browser resize changes what + every attached client sees. + +Resolution: + +1. **Tall default.** The exec is created at 511×10000 (width 511 matches + netmiko's `terminal width 511` convention). Non-NAWS clients get no paging + and no hard wrapping. +2. **WS terminal-size forwarding.** Console WebSocket clients may send + **binary control frames** — UTF-8 JSON `{"cols": N, "rows": N}` — + alongside text frames carrying terminal data (xterm.js's AttachAddon only + sends text, so binary is an unambiguous side channel; valid ranges are + cols 2–5000, rows 2–100000, anything else is silently ignored). The + controller forwards binary frames (previously only text was forwarded — + and a binary frame would have crashed the old `receive_text` loop), and + the compute side turns them into a telnet NAWS subnegotiation for + telnet-based consoles (docker_exec included) or an asyncssh + `change_terminal_size` for SSH consoles + (`base_node.py` `start_websocket_console`). +3. **Races.** A size frame that arrives before/during the exec creation is + remembered and applied right after creation — it is **not** overwritten by + the tall default. When the **last** client disconnects the exec goes back + to 511×10000, so a later non-NAWS client attaching to the still-live exec + doesn't inherit a browser geometry and hit PTY-window paging. +4. **`GNS3_CONSOLE_RESIZE=0`** makes the console ignore client resizes + entirely (the tall default is then permanent). Set it for paging CLIs + where a browser resize would break concurrent netmiko sessions on the + shared exec — XRd, which is line-oriented and doesn't need browser + resizing at all. + +**Why the browser must send its size — the SR Linux flicker.** `sr_cli` is a +prompt_toolkit TUI that anchors its layout with cursor-position requests +(CPR), which xterm.js answers. On a 10000-row PTY canvas the CPR-anchored +model conflicts with the winsize model, and every incremental render re-emits +the accumulated output: measured with a CPR-answering client, one `info` +command produces **~145 KB instead of ~60 KB** (~7× duplicated lines either +way — the CLI re-renders its output region as a scroll-append stream; that +part is inherent to `sr_cli` and identical outside GNS3, verified via manual +`docker exec`). The inflation is driven by **rows** (24/32 → normal, 10000 → +pathological, at any width) and is invisible without CPR answers — which is +why plain-telnet probes and real xterm.js sessions behaved so differently. +In the Web UI the excess renders as frequent full-screen clear/redraw — the +"flicker". With the browser's real size forwarded (rows ≈ 30), output volume +and rendering return to normal. + **File**: `gns3server/compute/base_node.py` — the console WebSocket guard now allows `docker_exec` (alongside `telnet`/`ssh`), since the WS bridge connects to -the console TCP port exactly as it does for telnet. +the console TCP port exactly as it does for telnet. The same WS handler also +intercepts binary control frames and propagates client terminal sizes (see +[Terminal geometry](#terminal-geometry-and-size-forwarding)). ### Why earlier approaches failed (context) @@ -418,17 +484,35 @@ present on the host. effect. If it does not, the image needs the bridge earlier (a vendor-specific entrypoint wrapper, not covered by this prototype). +**11. Web console flickers (full-screen clear/redraw) on every command** +- The PTY is stuck at the tall 511×10000 default while a CPR-answering client + is attached — see + [Terminal geometry](#terminal-geometry-and-size-forwarding). Check that the + Web UI actually sends the binary size control frames on connect/resize + (F12 → the console WS should show outgoing binary frames), and that the + server is new enough to forward them (the controller used to forward text + frames only). A client that never negotiates/forwards size (old Web UI, + bare telnet without NAWS) cannot trigger the fix — but also never answers + CPR, so it doesn't flicker either. +- The much milder per-keystroke/5 s cursor toggles (`\e[?25l…\e[?25h`) from + the TUI are normal and not this bug. + ## Limitations 1. **Shared session (broadcast).** All console clients share one CLI session and can see each other's input — identical to GNS3's existing primary - console model. There is no per-client independent session. + console model. There is no per-client independent session. The PTY + geometry is shared too (last resize wins): two browsers of different sizes + disagree harmlessly, but a browser on a *paging* CLI needs + `GNS3_CONSOLE_RESIZE=0` to stop resizing on behalf of concurrent netmiko + sessions (see [Terminal geometry](#terminal-geometry-and-size-forwarding)). 2. **`reset_console` not wired.** The console-reset action only handles `telnet`/`ssh`; it is a no-op for `docker_exec` (non-blocking; reconnect works fine). 3. **Prototype knobs.** `GNS3_SKIP_INIT` / `GNS3_INTERFACE_NAMES` / - `GNS3_CONSOLE_CMD` are environment-driven; they are not yet first-class node - schema fields and are not declared in the appliance (`gns3a`) schema. + `GNS3_CONSOLE_CMD` / `GNS3_CONSOLE_RESIZE` are environment-driven; they are + not yet first-class node schema fields and are not declared in the + appliance (`gns3a`) schema. 4. **Rootful-Docker assumption** (`UsernsMode: host`, set for all GNS3 Docker nodes) so the container-side chown acts on the host files' real uid/gid (see the volume-persistence section). @@ -448,7 +532,11 @@ present on the host. `_get_container_ifname`, `_cleanup_console_resources`). - `gns3server/compute/docker/__init__.py` — `Docker._select_node_class` / `create_node` factory. -- `gns3server/compute/base_node.py` — console WebSocket guard. +- `gns3server/compute/base_node.py` — console WebSocket guard; binary + terminal-size control frames → NAWS / asyncssh resize + (`start_websocket_console`). +- `gns3server/api/routes/controller/nodes.py` — console WS forwarding + (text and binary frames). - `gns3server/schemas/common.py` — `ConsoleType.docker_exec`. - containerlab `nodes/srl/srl.go` — reference for SR Linux launch command and interface naming. @@ -457,6 +545,7 @@ present on the host. | Version | Date | Changes | |---------|------|---------| +| 1.6 | 2026-08-20 | Terminal geometry and size forwarding: WS binary control frames `{"cols","rows"}` → NAWS / asyncssh resize (controller now forwards binary frames; compute intercepts them); tall 511×10000 default kept for non-NAWS clients, applied post-creation and restored on last disconnect (client size racing exec creation wins over the default); new `GNS3_CONSOLE_RESIZE=0` knob for paging CLIs (XRd) where a browser resize would break concurrent netmiko sessions on the shared exec; documented the SR Linux flicker root cause (tall rows × CPR-answering client → ~2.4× re-emitted output; rows-driven, width-independent). | | 1.5 | 2026-08-13 | Add appliance (`gns3a`) packaging section: 35-adapter full-chassis design, the three server-side schema fixes (DockerConsoleType, ApplianceV1_6.custom_adapters, extra_volumes passthrough), and the symbol-theme caveat (any `:/symbols/` symbol is rewritten to the category default at load). | | 1.4 | 2026-08-13 | Reconnect fix: drop the while-true wrapper (it restarted the CLI with no client to answer CPR → blank screen on reconnect); the exec is now recreated on connect when the upstream has died. `_LazyExecTelnetServer` extracted to module level and unit-tested. | | 1.3 | 2026-08-12 | Document runtime ownership safety (root processes, self-healing daemons, ACL evidence for SR Linux), the boot-ordering caveat (bridge after boot → verify save/stop/start closed loop), and troubleshooting #10. | diff --git a/docs/features/vendor-nos-xrd.md b/docs/features/vendor-nos-xrd.md index a512b9b35..6875cfb70 100644 --- a/docs/features/vendor-nos-xrd.md +++ b/docs/features/vendor-nos-xrd.md @@ -100,6 +100,7 @@ Note: "journal corrupted" messages with varying machine-IDs come from the ``` GNS3_SKIP_INIT=1 GNS3_CONSOLE_CMD=/pkg/bin/xr_cli.sh +GNS3_CONSOLE_RESIZE=0 GNS3_MASK_UDEV=1 GNS3_SHM_SIZE=1024 GNS3_DEVICES=/dev/fuse @@ -109,6 +110,15 @@ XR_MGMT_INTERFACES=linux:eth0,xr_name=Mg0/RP0/CPU0/0,chksum,snoop_v4,snoop_v6 XR_INTERFACES=linux:eth1,xr_name=Gi0/0/0/0;linux:eth2,xr_name=Gi0/0/0/1;... ``` +`GNS3_CONSOLE_RESIZE=0` (console geometry lock): the XR pager pages on the +PTY window size and ignores `terminal length 0`, so the exec PTY must stay at +the tall no-paging default for **every** client. The docker_exec console is a +single shared exec — one browser's terminal-size resize (WS control frames → +NAWS) would change the geometry concurrent netmiko/copilot sessions see and +bring `--More--` back. XRd's CLI is line-oriented, so browsers lose nothing +by not resizing. See +[docker-exec-console.md](./docker-exec-console.md#terminal-geometry-and-size-forwarding). + XRd-specific gotchas (image-side, not GNS3): - Management interface xr_name is **`Mg0/RP0/CPU0/0`** (short prefix, `CPU0` @@ -201,6 +211,7 @@ sequenceDiagram | Version | Date | Changes | |---------|------|---------| +| 1.5 | 2026-08-20 | Appliance env gains `GNS3_CONSOLE_RESIZE=0`: client-driven console resizes are ignored so the shared exec PTY stays at the tall no-paging geometry for concurrent netmiko/copilot sessions (browsers included). | | 1.4 | 2026-08-15 | Code-review hardening: stop-query HTTP timeout scales with `GNS3_STOP_TIMEOUT` (values >300 s no longer abort); overlapping mask/config bind targets deduplicated (Docker "Duplicate mount point"); `ExtraConfig.target` validated at save time and directory forms rejected; host-readiness check no longer aborts on one unreadable `/proc/sys` key; base env parser strips trailing commas; vendor env knobs re-parsed on create (PUT environment takes effect); graceful stop limited to explicit user stop (delete/update/close keep the immediate kill); extra_configs under a persisted volume warns. | | 1.3 | 2026-08-14 | Persistence corrected: XR's live data layer is `/xr-storage` (the image's symlink farm is materialized into real directories at bootstrap; `/xr-storage-shadow` is a pristine spare) — the appliance persists both. Vendor containers now stop gracefully (SIGTERM + `GNS3_STOP_TIMEOUT` grace, default 60 s) instead of being SIGKILLed on the spot. | | 1.2 | 2026-08-14 | Link UDP self-loop root-caused to a port-allocation race and fixed — see [link-udp-self-loop](../bugs/link-udp-self-loop.md) (now marked Fixed). | From 6aeb5dbda58db9e84e15e77c8296a69188bbe85c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 21 Aug 2026 00:45:16 +0800 Subject: [PATCH 25/28] feat(copilot): device skills per-topic split layout and topic retrieval SkillsLoader.load_device_skills() now supports two device layouts: the existing single file and a split directory (device//_base.yaml + one YAML per protocol topic). Topic files are merged into the device skill under 'topics', keyed by their 'topic' field; mismatched device_type, missing _base.yaml and duplicate topics are handled with explicit log-and-skip. get_skill() and DeviceSkillsTool gain a 'topic' parameter following the injection list -> index -> issue pattern. Topic bodies are never returned without an explicit topic request - index/summary/full all serve a topic index instead - so growing a device with new protocol topics no longer grows the token cost of device-level lookups. Also fix reload_skills() to actually drop injection skills that fail validate_skill_format() instead of logging 'skipping' and merging them anyway. --- .../implemented/skills-repository.md | 30 +- .../agent/gns3_copilot/skills/loader.py | 117 +++++- .../agent/gns3_copilot/skills/manager.py | 13 +- .../agent/gns3_copilot/skills/registry.py | 76 +++- tests/agent/test_skills_device_topics.py | 348 ++++++++++++++++++ 5 files changed, 546 insertions(+), 38 deletions(-) create mode 100644 tests/agent/test_skills_device_topics.py diff --git a/docs/gns3-copilot/implemented/skills-repository.md b/docs/gns3-copilot/implemented/skills-repository.md index aa9c8f2db..167941465 100644 --- a/docs/gns3-copilot/implemented/skills-repository.md +++ b/docs/gns3-copilot/implemented/skills-repository.md @@ -14,7 +14,7 @@ GNS3 Copilot loads all skills, prompts, and security configurations from an exte The repository provides: - **Injection skills** (39 categories): Network fault scenarios for troubleshooting practice -- **Device skills**: Device-specific command knowledge (VPCS, etc.) +- **Device skills**: Device-specific command knowledge (VPCS, etc.) — large devices split into per-protocol **topics** - **Feature skills**: Topology planning, network design - **System prompts**: Agent personality and behavior definitions - **Forbidden commands**: Security rules for command filtering @@ -24,7 +24,7 @@ The repository provides: ```mermaid graph TD subgraph "GNS3-Skills Repository" - YAML[injection/*.yaml
device/*.yaml
feature/*.yaml] + YAML[injection/*.yaml
device/*.yaml + device/*/*.yaml
feature/*.yaml] MD[prompts/*.md] CFG[config/forbidden_commands.txt] end @@ -56,7 +56,11 @@ GNS3-Skills/ │ ├── vlan_issues.yaml │ └── ... ├── device/ # Device-specific skills -│ └── vpcs.yaml +│ ├── vpcs.yaml # small devices: one single file +│ └── frr/ # large devices: split per protocol topic +│ ├── _base.yaml # device-level skill (console model, notes, aliases) +│ ├── ospf.yaml # topic file (merged under "topics" at load time) +│ └── bgp.yaml ├── feature/ # Feature skills │ └── topology_planner.yaml ├── prompts/ # System prompts (Markdown) @@ -68,6 +72,26 @@ GNS3-Skills/ └── forbidden_commands.txt ``` +## Device Topics + +A device with knowledge for many protocols would grow one YAML file indefinitely. Such devices use a split layout instead: `device//_base.yaml` holds the device-level skill, and one file per protocol topic (`ospf.yaml`, `bgp.yaml`, ...) holds its commands and troubleshooting entries. The loader merges them into a single `SKILLS_REGISTRY` entry: + +``` +SKILLS_REGISTRY["frr_vtysh"] = { ..._base.yaml..., "topics": { "ospf": {...}, "bgp": {...} } } +``` + +Topic files must declare `device_type` (matching their `_base.yaml`), `topic` and `name`; the CI validator in the skills repository enforces this. + +The `device_skills` tool exposes a three-step drill-down (mirroring `injection_skills`'s list → index → issue pattern): + +```json +{"action": "list"} +{"device_type": "frr_vtysh", "detail": "index"} +{"device_type": "frr_vtysh", "topic": "bgp"} +``` + +Topic bodies are only served on an explicit `topic` request — every other detail level returns a topic index — so adding topics to a device does not grow the token cost of device-level lookups. + ## Configuration Skills repository settings are configured in `gns3_server.conf` under the `[Server]` section: diff --git a/gns3server/agent/gns3_copilot/skills/loader.py b/gns3server/agent/gns3_copilot/skills/loader.py index 4d585cac5..fee183ef6 100644 --- a/gns3server/agent/gns3_copilot/skills/loader.py +++ b/gns3server/agent/gns3_copilot/skills/loader.py @@ -100,6 +100,12 @@ class SkillsLoader: """ Load all device skills from YAML files. + Supports two layouts: + - Single file: device/.yaml + - Split directory: device//_base.yaml + device//.yaml + (topic files are merged into the base skill under "topics", + keyed by their "topic" field) + Returns: Dictionary mapping skill keys to skill definitions """ @@ -107,33 +113,110 @@ class SkillsLoader: logger.error("PyYAML is not installed. Cannot load skills from YAML.") return {} - skills = {} + skills: Dict[str, Dict[str, Any]] = {} device_dir = self.skills_dir / "device" if not device_dir.exists(): logger.warning(f"Device skills directory not found: {device_dir}") return {} - for yaml_file in device_dir.glob("*.yaml"): - try: - skill_data = self._load_yaml(yaml_file) - if not skill_data: - logger.warning(f"Skipping empty YAML file: {yaml_file}") - continue - # Use device_type from YAML content as the key - # Fallback to filename stem if device_type not present - skill_key = skill_data.get("device_type") if isinstance(skill_data, dict) else None - if not skill_key: - skill_key = yaml_file.stem - logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key") - skills[skill_key] = skill_data - logger.debug(f"Loaded device skill: {skill_key} from {yaml_file}") - except Exception as e: - logger.error(f"Failed to load skill from {yaml_file}: {e}") + for entry in sorted(device_dir.iterdir()): + if entry.is_file() and entry.suffix == ".yaml": + self._load_single_device_skill(skills, entry) + elif entry.is_dir(): + self._load_split_device_skill(skills, entry) logger.debug(f"Loaded {len(skills)} device skills from device directory") return skills + def _load_single_device_skill(self, skills: Dict[str, Dict[str, Any]], yaml_file: Path) -> None: + """ + Load a single-file device skill into the skills dictionary. + """ + try: + skill_data = self._load_yaml(yaml_file) + if not skill_data: + logger.warning(f"Skipping empty YAML file: {yaml_file}") + return + # Use device_type from YAML content as the key + # Fallback to filename stem if device_type not present + skill_key = skill_data.get("device_type") + if not skill_key: + skill_key = yaml_file.stem + logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key") + skills[skill_key] = skill_data + logger.debug(f"Loaded device skill: {skill_key} from {yaml_file}") + except Exception as e: + logger.error(f"Failed to load skill from {yaml_file}: {e}") + + def _load_split_device_skill(self, skills: Dict[str, Dict[str, Any]], device_path: Path) -> None: + """ + Load a split device skill (directory with _base.yaml + topic files). + + The base file provides the device-level skill; every other YAML file + in the directory is a protocol topic merged under "topics". + """ + base_file = device_path / "_base.yaml" + if not base_file.exists(): + logger.error(f"No _base.yaml in device directory: {device_path}, skipping") + return + + try: + base_data = self._load_yaml(base_file) + except Exception as e: + logger.error(f"Failed to load skill from {base_file}: {e}") + return + if not base_data: + logger.warning(f"Skipping empty YAML file: {base_file}") + return + + skill_key = base_data.get("device_type") + if not skill_key: + skill_key = device_path.name + logger.warning(f"No device_type in {base_file}, using directory name '{skill_key}' as key") + + # Seed topics from the base file (if any), then merge topic files + base_topics = base_data.get("topics") + topics: Dict[str, Any] = dict(base_topics) if isinstance(base_topics, dict) else {} + + for yaml_file in sorted(device_path.glob("*.yaml")): + if yaml_file.name == "_base.yaml": + continue + try: + topic_data = self._load_yaml(yaml_file) + if not topic_data: + logger.warning(f"Skipping empty YAML file: {yaml_file}") + continue + + topic_device_type = topic_data.pop("device_type", None) + if topic_device_type is not None and topic_device_type != skill_key: + logger.error( + f"device_type mismatch in {yaml_file}: '{topic_device_type}' " + f"!= base '{skill_key}', skipping topic" + ) + continue + + topic_key = topic_data.pop("topic", None) + if not topic_key: + topic_key = yaml_file.stem + logger.warning(f"No 'topic' field in {yaml_file}, using filename '{topic_key}'") + if topic_key in topics: + logger.warning(f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting") + + # category/topics belong to the base skill only + topic_data.pop("category", None) + topic_data.pop("topics", None) + + topics[topic_key] = topic_data + logger.debug(f"Loaded device topic: {skill_key}/{topic_key} from {yaml_file}") + except Exception as e: + logger.error(f"Failed to load topic from {yaml_file}: {e}") + + if topics: + base_data["topics"] = topics + skills[skill_key] = base_data + logger.debug(f"Loaded device skill: {skill_key} from {device_path} ({len(topics)} topics)") + def load_feature_skills(self) -> Dict[str, Dict[str, Any]]: """ Load all feature skills from YAML files. diff --git a/gns3server/agent/gns3_copilot/skills/manager.py b/gns3server/agent/gns3_copilot/skills/manager.py index 6a663e0e8..e14b8a8dc 100644 --- a/gns3server/agent/gns3_copilot/skills/manager.py +++ b/gns3server/agent/gns3_copilot/skills/manager.py @@ -221,11 +221,18 @@ class SkillsManager: logger.warning("No injection skills loaded, keeping existing skills") return False - # Validate injection skills + # Validate injection skills (drop invalid ones before merging) + valid_injection_skills = {} for skill_key, skill_data in new_injection_skills.items(): - if not self.loader.validate_skill_format(skill_data): + if self.loader.validate_skill_format(skill_data): + valid_injection_skills[skill_key] = skill_data + else: logger.error(f"Invalid skill format for {skill_key}, skipping") - continue + + if not valid_injection_skills: + logger.warning("No valid injection skills loaded, keeping existing skills") + return False + new_injection_skills = valid_injection_skills # Load new device skills from YAML files new_device_skills = self.loader.load_device_skills() diff --git a/gns3server/agent/gns3_copilot/skills/registry.py b/gns3server/agent/gns3_copilot/skills/registry.py index 4d0cf1a53..a003413be 100644 --- a/gns3server/agent/gns3_copilot/skills/registry.py +++ b/gns3server/agent/gns3_copilot/skills/registry.py @@ -373,6 +373,7 @@ def get_skill( category: str | None = None, detail: str = "full", issue: str | None = None, + topic: str | None = None, ) -> dict[str, Any]: """ Get skill by device_type, with configurable detail level. @@ -382,6 +383,9 @@ def get_skill( category: Optional category filter detail: Detail level - "index" (names only), "summary" (+desc/sev/diff), "full" (all) issue: Optional specific issue key to retrieve + topic: Optional protocol topic to retrieve (split devices only). + Topic bodies are NEVER included without an explicit topic + request - all other detail levels return a topic index. Returns: Skill dictionary (detail varies by level), or error dict @@ -412,6 +416,28 @@ def get_skill( ], } + topics = skill.get("topics", {}) + + # Single topic lookup (topic bodies stay out of every other response) + if topic: + topic_data = topics.get(topic) + if not topic_data: + for key, data in topics.items(): + if key.lower() == topic.lower(): + topic_data = data + topic = key + break + if not topic_data: + return { + "error": f"Unknown topic '{topic}' in {device_type}", + "available_topics": list(topics.keys()), + } + return { + "device_type": device_type, + "skill_name": skill.get("name"), + "topic": {topic: topic_data}, + } + issues = skill.get("issues", {}) # Single issue lookup (most token-efficient) @@ -429,17 +455,20 @@ def get_skill( } if detail == "index": - # Minimal: only issue keys and names (90%+ token savings) - return { + # Minimal: only issue/topic keys and names (90%+ token savings) + result = { "device_type": device_type, "name": skill.get("name"), "description": skill.get("description"), "issues": {k: v["name"] for k, v in issues.items()}, } + if topics: + result["topics"] = {k: v.get("name", k) for k, v in topics.items()} + return result if detail == "summary": # Moderate: names + description + severity + difficulty - return { + result = { "device_type": device_type, "name": skill.get("name"), "description": skill.get("description"), @@ -453,14 +482,22 @@ def get_skill( for k, v in issues.items() }, } + if topics: + result["topics"] = { + k: {"name": v.get("name", k), "description": v.get("description", "")} + for k, v in topics.items() + } + return result - # Full detail (original behavior) - result = dict(skill) + # Full detail: topic bodies are replaced by the topic index + result = {k: v for k, v in skill.items() if k != "topics"} result["device_type"] = device_type + if topics: + result["topics"] = {k: v.get("name", k) for k, v in topics.items()} return result -def list_available_skills(category: str | None = None) -> list[dict[str, str]]: +def list_available_skills(category: str | None = None) -> list[dict[str, Any]]: """List all available device/feature skills, optionally filtered by category.""" skills = [] for did, skill in SKILLS_REGISTRY.items(): @@ -470,12 +507,14 @@ def list_available_skills(category: str | None = None) -> list[dict[str, str]]: "device_type": did, "name": skill.get("name", did), "category": skill.get("category"), + "topic_count": len(skill.get("topics", {})), }) else: skills.append({ "device_type": did, "name": skill.get("name", did), "category": skill.get("category"), + "topic_count": len(skill.get("topics", {})), }) return skills @@ -642,15 +681,21 @@ class DeviceSkillsTool(BaseTool): Provides access to device command knowledge (VPCS), topology planning, etc. For fault injection skills, use the injection_skills tool. - INPUT FORMAT (JSON string): - { - "action": "get", # "get" (default) or "list" - "device_type": "gns3_vpcs_telnet", # Required for action="get" - "detail": "full" # "full" (default) for complete skill information - } + TOKEN-EFFICIENT USAGE: + 1. List devices: {"action": "list"} + 2. List topics of a device: {"device_type": "frr_vtysh", "detail": "index"} + 3. Get ONE protocol topic (devices with topics): {"device_type": "frr_vtysh", "topic": "bgp"} + 4. Devices without topics: {"device_type": "gns3_vpcs_telnet"} - For action="list": - {"action": "list"} # Lists all available device/feature skills + Topic bodies are NEVER returned without an explicit "topic" - fetching a + device without one only returns its base skill plus the topic index, so + always request the specific protocol topic before configuring it. + + PARAMETERS: + - action: "list" or "get" (default "get") + - device_type: Required for action="get" (e.g., "frr_vtysh") + - topic: Protocol topic key from the topic index (e.g., "ospf", "bgp") + - detail: "index" | "summary" | "full" (default "full") """ def _run( @@ -693,8 +738,9 @@ class DeviceSkillsTool(BaseTool): category = params.get("category") detail = params.get("detail", "full") issue = params.get("issue") + topic = params.get("topic") - skill = get_skill(device_type, category, detail=detail, issue=issue) + skill = get_skill(device_type, category, detail=detail, issue=issue, topic=topic) return json.dumps(skill, ensure_ascii=False, indent=2) diff --git a/tests/agent/test_skills_device_topics.py b/tests/agent/test_skills_device_topics.py new file mode 100644 index 000000000..68d8371c5 --- /dev/null +++ b/tests/agent/test_skills_device_topics.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python +# +# 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 . + +""" +Tests for device skill topic splitting: directory layout loading +(_base.yaml + topic files) and topic-level retrieval via get_skill / +DeviceSkillsTool. +""" + +import pytest + +from gns3server.agent.gns3_copilot.skills.loader import SkillsLoader +from gns3server.agent.gns3_copilot.skills.registry import ( + DeviceSkillsTool, + get_skill, +) + + +@pytest.fixture +def skills_dir(tmp_path): + """ + Build a skills directory with both device layouts: + + - vpcs.yaml: single-file device (no topics) + - frr/: split device (_base.yaml + ospf/bgp topic files, + one mismatched topic file, one file without a topic field) + - orphan/: directory without _base.yaml (skipped) + """ + device_dir = tmp_path / "device" + device_dir.mkdir() + + (device_dir / "vpcs.yaml").write_text( + """ +name: "VPCS" +description: "VPCS test device" +device_type: "gns3_vpcs_telnet" +category: "device" +config_commands: + ip_config: + syntax: "ip
/ " + description: "Set PC address" +""", + encoding="utf-8", + ) + + frr_dir = device_dir / "frr" + frr_dir.mkdir() + (frr_dir / "_base.yaml").write_text( + """ +name: "FRR (Free Range Routing)" +description: "FRR test device" +device_type: "frr_vtysh" +category: "device" +config_commands: + write_memory: + syntax: "write memory" + description: "Save config" +""", + encoding="utf-8", + ) + (frr_dir / "ospf.yaml").write_text( + """ +device_type: "frr_vtysh" +topic: ospf +name: "OSPF (FRR 10.x)" +description: "OSPF topic" +config_commands: + ospfv2: + syntax: "router ospf" + description: "OSPFv2 process" +""", + encoding="utf-8", + ) + (frr_dir / "bgp.yaml").write_text( + """ +device_type: "frr_vtysh" +topic: bgp +name: "BGP (FRR 10.x)" +description: "BGP topic" +config_commands: + bgp_base: + syntax: "router bgp " + description: "BGP base" +""", + encoding="utf-8", + ) + # device_type mismatch with the base -> topic must be skipped + (frr_dir / "mpls.yaml").write_text( + """ +device_type: "other_device_type" +topic: mpls +name: "MPLS" +config_commands: + mpls_base: + syntax: "router mpls" + description: "..." +""", + encoding="utf-8", + ) + # no topic field -> falls back to the filename stem + (frr_dir / "static.yaml").write_text( + """ +device_type: "frr_vtysh" +name: "Static routing" +config_commands: + static_routes: + syntax: "ip route / " + description: "..." +""", + encoding="utf-8", + ) + + # directory without _base.yaml -> whole device skipped + orphan_dir = device_dir / "orphan" + orphan_dir.mkdir() + (orphan_dir / "some_topic.yaml").write_text( + """ +device_type: "orphan_device" +topic: anything +name: "Orphan" +""", + encoding="utf-8", + ) + + return tmp_path + + +class TestDeviceSkillsLoading: + """ + SkillsLoader.load_device_skills() with single-file and split layouts. + """ + + def test_both_layouts_are_loaded(self, skills_dir): + skills = SkillsLoader(str(skills_dir)).load_device_skills() + + assert "gns3_vpcs_telnet" in skills + assert "frr_vtysh" in skills + # the orphan directory (no _base.yaml) must not produce an entry + assert "orphan_device" not in skills + assert len(skills) == 2 + + def test_topics_are_merged_under_topics(self, skills_dir): + skills = SkillsLoader(str(skills_dir)).load_device_skills() + frr = skills["frr_vtysh"] + + # base-level content stays at the top level + assert frr["config_commands"]["write_memory"]["syntax"] == "write memory" + assert frr["category"] == "device" + + topics = frr["topics"] + assert topics["ospf"]["name"] == "OSPF (FRR 10.x)" + assert topics["bgp"]["config_commands"]["bgp_base"]["syntax"] == "router bgp " + # file without a topic field falls back to its filename stem + assert "static" in topics + # mismatched device_type is skipped + assert "mpls" not in topics + + def test_topic_metadata_is_stripped(self, skills_dir): + skills = SkillsLoader(str(skills_dir)).load_device_skills() + for topic_data in skills["frr_vtysh"]["topics"].values(): + assert "device_type" not in topic_data + assert "topic" not in topic_data + assert "category" not in topic_data + assert "topics" not in topic_data + + def test_single_file_device_has_no_topics_key(self, skills_dir): + skills = SkillsLoader(str(skills_dir)).load_device_skills() + assert "topics" not in skills["gns3_vpcs_telnet"] + + +@pytest.fixture +def device_registry(): + """ + Populate SKILLS_REGISTRY with a split device and a single-file device, + restoring the previous content afterwards. + """ + from gns3server.agent.gns3_copilot.skills import registry + + saved = dict(registry.SKILLS_REGISTRY) + registry.SKILLS_REGISTRY.clear() + registry.SKILLS_REGISTRY.update( + { + "frr_vtysh": { + "name": "FRR (Free Range Routing)", + "description": "FRR test device", + "category": "device", + "config_commands": {"write_memory": {"syntax": "write memory"}}, + "topics": { + "ospf": { + "name": "OSPF (FRR 10.x)", + "description": "OSPF topic", + "config_commands": {"ospfv2": {"syntax": "router ospf"}}, + }, + "bgp": { + "name": "BGP (FRR 10.x)", + "description": "BGP topic", + "config_commands": {"bgp_base": {"syntax": "router bgp "}}, + }, + }, + }, + "gns3_vpcs_telnet": { + "name": "VPCS", + "description": "VPCS test device", + "category": "device", + "config_commands": {"ip_config": {"syntax": "ip
/"}}, + }, + } + ) + yield registry + registry.SKILLS_REGISTRY.clear() + registry.SKILLS_REGISTRY.update(saved) + + +class TestGetSkillTopics: + """ + Topic-level retrieval and topic index behavior in get_skill(). + """ + + def test_topic_lookup_returns_topic_body(self, device_registry): + result = get_skill("frr_vtysh", topic="bgp") + assert result["device_type"] == "frr_vtysh" + assert result["skill_name"] == "FRR (Free Range Routing)" + assert result["topic"]["bgp"]["config_commands"]["bgp_base"]["syntax"] == "router bgp " + + def test_topic_lookup_is_case_insensitive(self, device_registry): + result = get_skill("frr_vtysh", topic="BGP") + assert "bgp" in result["topic"] + + def test_unknown_topic_lists_available_topics(self, device_registry): + result = get_skill("frr_vtysh", topic="mpls") + assert "error" in result + assert sorted(result["available_topics"]) == ["bgp", "ospf"] + + def test_full_without_topic_returns_index_not_bodies(self, device_registry): + result = get_skill("frr_vtysh", detail="full") + # base-level content is included... + assert result["config_commands"]["write_memory"]["syntax"] == "write memory" + # ...but topic bodies are never included without an explicit topic + assert result["topics"] == { + "ospf": "OSPF (FRR 10.x)", + "bgp": "BGP (FRR 10.x)", + } + assert "config_commands" not in result["topics"]["ospf"] + + def test_index_includes_topic_index(self, device_registry): + result = get_skill("frr_vtysh", detail="index") + assert result["topics"]["bgp"] == "BGP (FRR 10.x)" + + def test_summary_includes_topic_descriptions(self, device_registry): + result = get_skill("frr_vtysh", detail="summary") + assert result["topics"]["ospf"] == { + "name": "OSPF (FRR 10.x)", + "description": "OSPF topic", + } + + def test_single_file_device_still_works(self, device_registry): + result = get_skill("gns3_vpcs_telnet") + assert result["config_commands"]["ip_config"]["syntax"] == "ip
/" + assert "topics" not in result + + +class TestDeviceSkillsToolTopics: + """ + DeviceSkillsTool passes the topic parameter through to get_skill(). + """ + + def test_tool_topic_request(self, device_registry): + import json + + tool = DeviceSkillsTool() + result = json.loads(tool._run('{"device_type": "frr_vtysh", "topic": "ospf"}')) + assert result["topic"]["ospf"]["config_commands"]["ospfv2"]["syntax"] == "router ospf" + + def test_tool_list_shows_topic_counts(self, device_registry): + import json + + tool = DeviceSkillsTool() + result = json.loads(tool._run('{"action": "list"}')) + by_type = {s["device_type"]: s for s in result["skills"]} + assert by_type["frr_vtysh"]["topic_count"] == 2 + assert by_type["gns3_vpcs_telnet"]["topic_count"] == 0 + + +class TestReloadSkillsValidation: + """ + Invalid injection skills are dropped instead of merged into the registry. + """ + + def test_invalid_injection_skill_is_dropped(self, tmp_path, monkeypatch): + from gns3server.config import Config + from gns3server.agent.gns3_copilot.skills import registry + from gns3server.agent.gns3_copilot.skills.manager import SkillsManager + + # SkillsManager derives its local path from /skills + injection_dir = tmp_path / "skills" / "injection" + injection_dir.mkdir(parents=True) + (injection_dir / "valid.yaml").write_text( + """ +name: "OSPF Issues Injection" +description: "OSPF faults" +category: "injection" +issues: + ospf_area_mismatch: + name: "OSPF Area Mismatch" + description: "Areas differ" +""", + encoding="utf-8", + ) + # missing the required "issues" field -> invalid, must be dropped + (injection_dir / "broken.yaml").write_text( + """ +name: "Broken Injection" +description: "No issues field" +""", + encoding="utf-8", + ) + + monkeypatch.setattr(Config, "config_dir", property(lambda self: str(tmp_path))) + + saved_injection = dict(registry.INJECTION_SKILLS_REGISTRY) + saved_skills = dict(registry.SKILLS_REGISTRY) + registry.INJECTION_SKILLS_REGISTRY.clear() + registry.SKILLS_REGISTRY.clear() + try: + manager = SkillsManager(repo_url="https://example.invalid/gns3-skills.git") + manager._repo = None + assert manager.reload_skills() is True + assert "injection_valid" in registry.INJECTION_SKILLS_REGISTRY + assert "injection_broken" not in registry.INJECTION_SKILLS_REGISTRY + finally: + registry.INJECTION_SKILLS_REGISTRY.clear() + registry.INJECTION_SKILLS_REGISTRY.update(saved_injection) + registry.SKILLS_REGISTRY.clear() + registry.SKILLS_REGISTRY.update(saved_skills) From 89d7f866cb5ffa7b9ff8bbbf745adf517273ff7a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 22 Aug 2026 00:09:13 +0800 Subject: [PATCH 26/28] docker: replace vendor SKIP_INIT exec volume bridge with create-time direct binds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SKIP_INIT volume bridge replicated init.sh's seed + mount --bind script via docker exec *after* the container started. That copied the mechanism but not the invariant that makes init.sh safe — the entrypoint position, which guarantees the volume is in place before the application runs. The exec runs concurrently with the NOS boot, so whether the NOS loaded its persisted config or the overlay's factory copy was a timing race: - single node stop/start on an idle system won it (exec ~1s, SR Linux reads its startup config at ~2-4s) — the save/stop/start round-trip passed; - a server restart + project reload lost it (concurrent node starts queue on the Docker API, delaying the exec by seconds) — SR Linux booted factory while the persisted config.json sat intact on the host; - XRd was immune (systemd boots tens of seconds before XR touches /xr-storage), which is why the race was never observed on it. Replace the bridge entirely: - new DockerVM._prepare_volumes hook (no-op in the base class) runs in create() after the image is present, before the container is created; VendorDockerVM overrides it to seed each volume's host directory from the image (throwaway docker create container + docker cp -a, nothing executes). The .gns3_perms marker gates the seeding: a volume that ever started is never re-seeded, so saved configuration is never overwritten with factory content (also the upgrade path for existing nodes). - VendorDockerVM._mount_binds now binds the volumes directly at their real in-container paths (/etc/opt/srlinux) instead of /gns3volumes aliases, so the persisted config is visible to the NOS from the very first process. - _setup_skip_init_volumes and its start() call are gone; the container-side _fix_permissions targets the volume paths directly (the direct binds exist for the whole container lifetime, unlike the old bridge). The volume-list computation (validation + overlap de-duplication) moves into DockerVM._persistent_volume_list so create-time seeding and _mount_binds cannot drift apart. --- docs/features/docker-exec-console.md | 131 ++++++----- docs/features/vendor-nos-xrd.md | 10 +- gns3server/compute/docker/docker_vm.py | 70 ++++-- gns3server/compute/docker/vendor_docker_vm.py | 207 ++++++++++++------ tests/compute/docker/test_vendor_docker_vm.py | 181 +++++++++++---- 5 files changed, 402 insertions(+), 197 deletions(-) diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md index 332e22af4..41a3a641b 100644 --- a/docs/features/docker-exec-console.md +++ b/docs/features/docker-exec-console.md @@ -322,41 +322,46 @@ This is the one place where skipping init.sh changes behaviour beyond boot: **nothing writes through to the host** — the container writes to its overlay filesystem and the data is lost on stop. -The bridge (see init.sh lines 35–52) has two parts: +init.sh (as the entrypoint) is safe because it runs **before** the +application: for each volume it seeds the host directory with the image's +original files on first start, then `mount --bind /gns3volumes ` +bridges persistent storage into place. + +`VendorDockerVM` cannot use that position (the NOS must own its entrypoint), +so the same persistence is established entirely **outside the container and +before it exists**: ``` -host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always mounted) - │ init.sh: mount --bind - ▼ - /etc/opt/srlinux (where the NOS writes) +create() 之前: host dir seeded from the image (docker create + docker cp, first time only) +create() 时: host ──Docker bind mount──▶ /etc/opt/srlinux (direct, at the real path) +启动: NOS native entrypoint — the persisted config is visible from the first process ``` -`VendorDockerVM` replicates this for SKIP_INIT containers: +1. **`_prepare_volumes()`** — host-side, at `create()` time (after the image + is present, before the container is created). For each persistent volume + whose host directory lacks the `.gns3_perms` marker, a throwaway + `docker create` container (nothing executes) is used as a `docker cp -a` + source to seed the host directory with the image's original content. The + marker is written after the copy attempt — a volume that has it (every + node that ever started, on any GNS3 version) is **never re-seeded**, so + saved configuration is never overwritten with factory content. -1. **`_setup_skip_init_volumes()`** — runs once per start, right after the - container is up (`VendorDockerVM.start()`). For each persistent volume it - `docker exec`s a busybox script that: - - seeds the host directory with the container's original files on first - start (`cp -a` + `.gns3_perms` marker), exactly like init.sh; - - `mount --bind /gns3volumes ` to bridge persistent storage - back to the in-container path — on subsequent starts the persisted data - replaces the fresh overlay content; - - restores the permissions recorded in `.gns3_perms` at the previous stop - (best-effort). +2. **`_mount_binds()` override** — the volume binds target the **real + in-container paths** (`/etc/opt/srlinux`) instead of `/gns3volumes`. + With the content seeded first, the image's files are never shadowed by an + empty mount, and the NOS sees its persisted configuration from the very + first process — no post-start mount pass that could race the NOS reading + its startup config (see "History: the exec-bridge race" below). -2. **Container-side `_fix_permissions()` override targeting `/gns3volumes`** - — `DockerVM._fix_permissions` operates on the in-container paths - (`/etc/opt/srlinux`, …), which only resolve to persistent storage while - the `mount --bind` bridge is up; after a container restart the bridge is - gone and it would chown the overlay copy instead of the host files. It - also restarts an exited container just to chown. The override instead - runs the same busybox record/chmod/chown script **inside the container - (as root) on the `/gns3volumes` paths** — the Docker bind-mount - targets, which exist for the whole container lifetime and need no bridge. - A stopped/exited container is **not** restarted: the pass is skipped and - the next start fixes ownership. It runs at start (so the controller can - read project files while the node runs) and at stop (for files written - during runtime). +3. **Container-side `_fix_permissions()` override** — runs the same busybox + record/chmod/chown script **inside the container (as root) on the volume + paths**. Because the volumes are Docker bind mounts created with the + container, the in-container paths resolve to the host files for the whole + container lifetime. A stopped/exited container is **not** restarted (the + base class would, just to chown; vendor NOS images are heavy to boot): + the pass is skipped and the next start fixes ownership. It runs at start + (so the controller can read project files while the node runs) and at + stop (for files written during runtime). > The fix must run container-side: files written by the container are > host-side root-owned, and an unprivileged GNS3 process cannot chown them @@ -375,9 +380,10 @@ the base class just created. Without `GNS3_SKIP_INIT` the mount is kept | Phase | Normal Docker node | `VendorDockerVM` + `GNS3_SKIP_INIT` | |-------|--------------------|--------------------------------------| -| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | `docker exec` after start: seed + bind-mount + restore perms; then container-side chown on `/gns3volumes` | -| stop | container-side `_fix_permissions` on in-container paths (restarts an exited container) | container-side `_fix_permissions` on `/gns3volumes` paths (skips dead containers, no restart) | -| volume config | identical `_mount_binds` (host → `/gns3volumes`) | identical | +| create | — | `_prepare_volumes()` seeds host dirs from the image (first create only); volumes bound **directly** at their real paths | +| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | container-side `_fix_permissions` on the volume paths (skips dead containers, no restart) | +| stop | container-side `_fix_permissions` on in-container paths (restarts an exited container) | container-side `_fix_permissions` on the volume paths (skips dead containers, no restart) | +| volume config | `_mount_binds`: host → `/gns3volumes` | `_mount_binds` override: host → `` directly | ### Runtime ownership safety @@ -403,17 +409,28 @@ ever matters, drop the start-time pass and keep only the stop-time one (standard behaviour — the trade-off is mid-run `Permission denied` in the file browser, identical to regular Docker nodes). -### Boot-ordering caveat +### History: the exec-bridge race (fixed) -The volume bridge (`mount --bind`) is established **after** the vendor -entrypoint has started (there is no init.sh to do it before), so the NOS's -early boot reads the overlay copy of the volume paths — default image -content, not the persisted data. Whether the persisted config takes effect -depends on the NOS re-reading those files after the bridge is up (SR Linux's -daemons do re-read/write their managed files during boot, as observed). -Always verify the closed loop when adopting a new image: `save` a config → -stop the node → start it → confirm the config is actually applied, not just -present on the host. +The first SKIP_INIT implementation replicated init.sh's script **via +`docker exec` after the container started** instead of binding directly at +create time. That copied the mechanism but not the invariant that makes +init.sh safe — the entrypoint position, which guarantees the volume is in +place *before* the application runs. An exec-based bind runs **concurrently** +with the NOS boot, so whether the NOS reads its persisted config or the +overlay's factory copy was a timing race: + +- a single node stop/start on an idle system won it (the exec landed ~1 s + in, SR Linux reads its startup config at ~2–4 s) — which is why the + round-trip "save → stop → start → config still there" passed; +- a server restart + project reload lost it (all nodes start concurrently, + the Docker API queue delays the execs by several seconds) — SR Linux + booted factory while the persisted `config.json` sat intact on the host; +- XRd was immune either way (systemd boots for tens of seconds before any + XR process touches `/xr-storage`), which is why the race was never seen + on it. + +The direct-bind-at-create design removes the window entirely; there is no +ordering requirement left to verify when adopting a new NOS image. ## Troubleshooting @@ -472,17 +489,17 @@ present on the host. root-owned files at runtime. **9. Persistent volume empty on the host after `save` + stop** -- Ensure `GNS3_SKIP_INIT=1` is set (so the host-side bridge path is taken) and - the volume path is in `extra_volumes`; check the compute log for - `Volume '' bound to persistent storage`. +- Ensure `GNS3_SKIP_INIT=1` is set (so the direct-bind path is taken) and + the volume path is in `extra_volumes`; check that the host directory + carries the `.gns3_perms` marker (written at create-time seeding) and the + compute log for `Seeded persistent volume`. **10. Persisted config present on the host but not applied after restart** -- The volume bridge is established after the NOS has booted (see - *Boot-ordering caveat*); the NOS may have already loaded the overlay's - default config into memory. Verify with a visible change (hostname, - interface description): `save` → stop → start → check the change took - effect. If it does not, the image needs the bridge earlier (a - vendor-specific entrypoint wrapper, not covered by this prototype). +- On builds since the direct-bind rework this should not happen: the volume + is in place before the first process. If you see it, confirm the server + build includes the rework (older builds established the bind via a + post-start `docker exec` that could lose the race against the NOS reading + its startup config — see *History: the exec-bridge race*). **11. Web console flickers (full-screen clear/redraw) on every command** - The PTY is stuck at the tall 511×10000 default while a CPR-answering client @@ -516,17 +533,16 @@ present on the host. 4. **Rootful-Docker assumption** (`UsernsMode: host`, set for all GNS3 Docker nodes) so the container-side chown acts on the host files' real uid/gid (see the volume-persistence section). -5. **Post-boot volume bridge.** The bind-mount bridge is established after the - vendor entrypoint has started (init.sh would do it before). A NOS that - strictly requires its persisted files at its very first read may need a - different boot arrangement (see *Boot-ordering caveat*). +5. **Docker CLI dependency.** Volume seeding shells out to the `docker` + binary (`docker create` + `docker cp` + `docker rm`) at create time — + the same dependency the permission passes already have. ## References - `gns3server/compute/docker/vendor_docker_vm.py` — `VendorDockerVM`: `_start_docker_exec_console`, `_LazyExecTelnetServer`, - `_setup_skip_init_volumes`, container-side `_fix_permissions` on - `/gns3volumes`, `start()`. + `_prepare_volumes` (host-side seeding), direct volume binds in + `_mount_binds`, container-side `_fix_permissions`, `start()`. - `gns3server/compute/docker/docker_vm.py` — `DockerVM` extension hooks (`_prepare_init_and_interface_env`, `_start_console_server`, `_get_container_ifname`, `_cleanup_console_resources`). @@ -545,6 +561,7 @@ present on the host. | Version | Date | Changes | |---------|------|---------| +| 1.7 | 2026-08-22 | SKIP_INIT volume persistence rebuilt: host-side seeding at create time (`docker create` + `docker cp -a`, marker-gated so saved config is never overwritten) and direct bind mounts at the real in-container paths replace the post-start `docker exec` bridge. Root cause: the exec bridge raced the NOS reading its startup config — SR Linux read `config.json` at ~2–4 s and booted factory whenever concurrent node starts (server restart + project reload) delayed the exec past that point, while single-node stop/start and XRd (systemd touches `/xr-storage` tens of seconds in) never lost the race. New `_prepare_volumes` hook on `DockerVM`; `_fix_permissions` now targets the volume paths directly. | | 1.6 | 2026-08-20 | Terminal geometry and size forwarding: WS binary control frames `{"cols","rows"}` → NAWS / asyncssh resize (controller now forwards binary frames; compute intercepts them); tall 511×10000 default kept for non-NAWS clients, applied post-creation and restored on last disconnect (client size racing exec creation wins over the default); new `GNS3_CONSOLE_RESIZE=0` knob for paging CLIs (XRd) where a browser resize would break concurrent netmiko sessions on the shared exec; documented the SR Linux flicker root cause (tall rows × CPR-answering client → ~2.4× re-emitted output; rows-driven, width-independent). | | 1.5 | 2026-08-13 | Add appliance (`gns3a`) packaging section: 35-adapter full-chassis design, the three server-side schema fixes (DockerConsoleType, ApplianceV1_6.custom_adapters, extra_volumes passthrough), and the symbol-theme caveat (any `:/symbols/` symbol is rewritten to the category default at load). | | 1.4 | 2026-08-13 | Reconnect fix: drop the while-true wrapper (it restarted the CLI with no client to answer CPR → blank screen on reconnect); the exec is now recreated on connect when the upstream has died. `_LazyExecTelnetServer` extracted to module level and unit-tested. | diff --git a/docs/features/vendor-nos-xrd.md b/docs/features/vendor-nos-xrd.md index 6875cfb70..13ed3960f 100644 --- a/docs/features/vendor-nos-xrd.md +++ b/docs/features/vendor-nos-xrd.md @@ -41,7 +41,7 @@ graph TB MASK["GNS3_MASK_UDEV → /dev/null binds"] HOSTCFG["ShmSize / Devices"] CFGINJ["extra_configs → RO single-file bind"] - VBRIDGE["VendorDockerVM volume bridge"] + VBRIDGE["VendorDockerVM volume seeding + direct binds"] HOSTCHK["host-readiness check (read-only)"] end subgraph Container["XRd container"] @@ -158,11 +158,12 @@ sequenceDiagram participant X as XRd container U->>S: create node from template S->>S: parse GNS3_* env host-side - S->>D: container create (ShmSize, Devices, /dev/null binds, firstboot.cfg RO bind) + S->>D: seed volume host dirs from image (docker create + cp, first time only) + S->>D: container create (ShmSize, Devices, /dev/null binds, firstboot.cfg RO bind, volumes bound directly at /xr-storage*) U->>S: start S->>X: container start (native entrypoint /usr/sbin/init) Note over X: systemd boots; udevd + udevadm masked → host untouched - S->>X: docker exec volume bridge (container's own chown) + S->>X: docker exec permission fix (container's own chown) U->>S: open console S->>X: docker exec pty: /pkg/bin/xr_cli.sh X-->>U: IOS XR CLI (first boot: apply /firstboot.cfg, save to /xr-storage-shadow) @@ -198,7 +199,7 @@ sequenceDiagram - `gns3server/compute/docker/docker_vm.py` — HostConfig env injection, `_UDEV_UNITS`/`_UDEVADM_PATHS`, `extra_configs` binds, `_format_devices()` - `gns3server/compute/docker/vendor_docker_vm.py` — vendor path, volume - bridge, container-chown + seeding + direct binds, container-chown - `gns3server/compute/docker/__init__.py` — `_check_host_readiness()` - `gns3server/schemas/common.py` — `ExtraConfig` - `gns3server/db/models/templates.py` + `db_migrations/` — persistence @@ -211,6 +212,7 @@ sequenceDiagram | Version | Date | Changes | |---------|------|---------| +| 1.6 | 2026-08-21 | SKIP_INIT volume persistence rebuilt: host-side seeding at create time (`docker create` + `docker cp`) and direct bind mounts at the real in-container paths replace the post-start `docker exec` bridge, which raced the NOS reading its startup config (visible on SR Linux: factory boot after a server restart + project reload; XRd was immune only because systemd touches `/xr-storage` tens of seconds in). No behaviour change for XRd beyond the race removal. | | 1.5 | 2026-08-20 | Appliance env gains `GNS3_CONSOLE_RESIZE=0`: client-driven console resizes are ignored so the shared exec PTY stays at the tall no-paging geometry for concurrent netmiko/copilot sessions (browsers included). | | 1.4 | 2026-08-15 | Code-review hardening: stop-query HTTP timeout scales with `GNS3_STOP_TIMEOUT` (values >300 s no longer abort); overlapping mask/config bind targets deduplicated (Docker "Duplicate mount point"); `ExtraConfig.target` validated at save time and directory forms rejected; host-readiness check no longer aborts on one unreadable `/proc/sys` key; base env parser strips trailing commas; vendor env knobs re-parsed on create (PUT environment takes effect); graceful stop limited to explicit user stop (delete/update/close keep the immediate kill); extra_configs under a persisted volume warns. | | 1.3 | 2026-08-14 | Persistence corrected: XR's live data layer is `/xr-storage` (the image's symlink farm is materialized into real directories at bootstrap; `/xr-storage-shadow` is a pristine spare) — the appliance persists both. Vendor containers now stop gracefully (SIGTERM + `GNS3_STOP_TIMEOUT` grace, default 60 s) instead of being SIGKILLed on the spot. | diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 1fade0fd1..6a04f64d5 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -379,6 +379,51 @@ class DockerVM(BaseNode): result = await self.manager.query("GET", f"images/{self._image}/json") return result + def _persistent_volume_list(self, image_info, include_network_config=True): + """ + The in-container paths that get a persistent volume mount: GNS3's + /etc/network, every VOLUME declared by the image and the node's + extra_volumes. Overlapping paths are de-duplicated so that a path + covered by a more general volume is not mounted twice. + + :param include_network_config: include GNS3's hardcoded /etc/network + volume (consumed by init.sh; subclasses that skip init.sh pass + False so the list matches the mounts they actually create). + """ + + for volume in self._extra_volumes: + if not volume.strip() or volume[0] != "/" or volume.find("..") >= 0: + raise DockerError( + f"Persistent volume '{volume}' has invalid format. It must start with a '/' and not contain '..'." + ) + volumes = [] + if include_network_config: + volumes.append("/etc/network") + volumes.extend((image_info.get("Config", {}).get("Volumes") or {}).keys()) + volumes.extend(self._extra_volumes) + + deduped = [] + # define lambdas for validation checks + nf = lambda x: re.sub(r"//+", "/", (x if x.endswith("/") else x + "/")) + generalises = lambda v1, v2: nf(v2).startswith(nf(v1)) + for volume in volumes: + # remove any mount that is equal or more specific, then append this one + deduped = list(filter(lambda v: not generalises(volume, v), deduped)) + # if there is nothing more general, append this mount + if not [v for v in deduped if generalises(v, volume)]: + deduped.append(volume) + return deduped + + async def _prepare_volumes(self, image_info): + """ + Hook: prepare persistent volumes before the container (and its + mounts) are created. The default implementation does nothing — + init.sh performs the first-copy seeding inside the container at + boot. Subclasses that skip init.sh override this to seed the host + directories from the image instead, so their mounts can be bound + directly at the real in-container paths from the very first process. + """ + def _mount_binds(self, image_info): """ :returns: Return the path that we need to map to local folders @@ -402,26 +447,7 @@ class DockerVM(BaseNode): self._create_network_config() except OSError as e: raise DockerError(f"Could not create network config in the container: {e}") - volumes = ["/etc/network"] - - volumes.extend((image_info.get("Config", {}).get("Volumes") or {}).keys()) - for volume in self._extra_volumes: - if not volume.strip() or volume[0] != "/" or volume.find("..") >= 0: - raise DockerError( - f"Persistent volume '{volume}' has invalid format. It must start with a '/' and not contain '..'." - ) - volumes.extend(self._extra_volumes) - - self._volumes = [] - # define lambdas for validation checks - nf = lambda x: re.sub(r"//+", "/", (x if x.endswith("/") else x + "/")) - generalises = lambda v1, v2: nf(v2).startswith(nf(v1)) - for volume in volumes: - # remove any mount that is equal or more specific, then append this one - self._volumes = list(filter(lambda v: not generalises(volume, v), self._volumes)) - # if there is nothing more general, append this mount - if not [v for v in self._volumes if generalises(v, volume)]: - self._volumes.append(volume) + self._volumes = self._persistent_volume_list(image_info) for volume in self._volumes: source = os.path.join(self.working_dir, os.path.relpath(volume, "/")) @@ -544,6 +570,10 @@ class DockerVM(BaseNode): f"(max available is {available_cpus} CPUs)" ) + # Prepare persistent volume content before the container and its + # mounts are created (no-op for the init.sh path). + await self._prepare_volumes(image_infos) + params = { "Hostname": self._name, "Image": self._image, diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 13e6e0566..6089663f2 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -48,9 +48,11 @@ class VendorDockerVM(DockerVM): (host-side only — GNS3_ entries are never forwarded into the container): * ``GNS3_SKIP_INIT=1`` — do not prepend /gns3/init.sh; the container runs - its own entrypoint (e.g. SR Linux's ``sr_linux``). Init.sh's volume - persistence (bind-mount /gns3volumes → target) is replicated via - ``docker exec`` after the container starts. + its own entrypoint (e.g. SR Linux's ``sr_linux``). Persistent volumes + are seeded host-side and bound directly at their real in-container + paths at create time (see ``_prepare_volumes`` / ``_mount_binds``), so + the NOS sees its saved configuration from the very first process — + no post-start mount pass that could race the NOS reading its config. * ``GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2`` — rename injected interfaces (adapter order) instead of default ``eth{N}``. * ``GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli`` — command run inside the @@ -127,6 +129,17 @@ class VendorDockerVM(DockerVM): Removes the bind, drops the volume from self._volumes (so GNS3_VOLUMES and the vendor passes stay consistent) and deletes the host-side skeleton directory the base class just created. + + Additionally, the persistent volumes are bound directly at their + real in-container paths instead of /gns3volumes. With + init.sh skipped there is no in-container mount pass, so a volume + bound at /gns3volumes would only be moved into place by a post-start + ``docker exec`` — racing the NOS reading its startup configuration + (an SR Linux node booted factory whenever the exec lost that race, + e.g. on the concurrent node starts of a project reload). Binding at + the real path is safe because the content is seeded host-side before + the container is created (see _prepare_volumes): the image's files + are never shadowed by an empty mount. """ binds = super()._mount_binds(image_info) if self._gns3_init: @@ -136,7 +149,115 @@ class VendorDockerVM(DockerVM): shutil.rmtree(os.path.join(self.working_dir, "etc", "network"), ignore_errors=True) with contextlib.suppress(OSError): os.rmdir(os.path.join(self.working_dir, "etc")) - return binds + + # Re-target the volume binds from /gns3volumes to . + retargeted = [] + for bind in binds: + target = bind.get("Target", "") + if target.startswith("/gns3volumes"): + volume = target[len("/gns3volumes"):] + if volume in self._volumes: + bind = {**bind, "Target": volume} + retargeted.append(bind) + return retargeted + + async def _prepare_volumes(self, image_info): + """ + Override: for SKIP_INIT containers, seed every persistent volume's + host directory with the image's original content *before* the + container is created. This is the host-side replacement of init.sh's + first-copy: because the volume is then bound directly at its real + in-container path (see _mount_binds), the seed must exist first or + the NOS would boot with an empty config directory. + + ``.gns3_perms`` doubles as the seeded marker: a volume that has it + (every node that ever started, on any GNS3 version) is never + re-seeded — a re-seed would overwrite the node's saved + configuration with the factory image content. + """ + if self._gns3_init: + return + volumes = self._persistent_volume_list(image_info, include_network_config=False) + to_seed = [] + for volume in volumes: + host_dir = os.path.join(self.working_dir, os.path.relpath(volume, "/")) + os.makedirs(host_dir, exist_ok=True) + if not os.path.exists(os.path.join(host_dir, ".gns3_perms")): + to_seed.append((volume, host_dir)) + if not to_seed: + return + seed_cid = await self._create_seed_container() + try: + for volume, host_dir in to_seed: + await self._seed_volume_from_container(seed_cid, volume, host_dir) + # Write the marker only after the copy attempt, mirroring + # init.sh: a volume without it is (re)seeded on the next + # create(), so a partial seed self-heals. + open(os.path.join(host_dir, ".gns3_perms"), "a").close() + finally: + await self._remove_seed_container(seed_cid) + + async def _create_seed_container(self): + """ + A throwaway ``docker create`` container (nothing executes) used as + the copy source for seeding persistent volumes with the image's + original content. + """ + + try: + process = await asyncio.subprocess.create_subprocess_exec( + "docker", "create", self._image, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + except OSError as e: + raise DockerError(f"Could not seed persistent volumes for '{self._name}': {e}") + stdout, stderr = await process.communicate() + if process.returncode != 0: + raise DockerError( + f"Could not create a seeding container for image '{self._image}': " + f"{stderr.decode(errors='replace').strip()}" + ) + return stdout.decode().strip() + + async def _seed_volume_from_container(self, seed_cid, volume, host_dir): + """ + Copy one volume's original content from the seeding container to its + host directory with ``docker cp -a`` (preserves modes/ownership; no + dependency on tools inside the image). + """ + + try: + process = await asyncio.subprocess.create_subprocess_exec( + "docker", "cp", "-a", f"{seed_cid}:{volume}/.", host_dir + "/", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + except OSError as e: + raise DockerError(f"Could not seed persistent volume '{volume}' for '{self._name}': {e}") + _, stderr = await process.communicate() + if process.returncode != 0: + # A path the image does not contain (e.g. XRd's /xr-storage-shadow) + # is not an error: the volume starts empty. Same tolerance as + # init.sh's first copy (cp -a ... 2>/dev/null). + log.info( + "Persistent volume '%s' on '%s' not seedable from image '%s' (%s); starting empty", + volume, self._name, self._image, stderr.decode(errors="replace").strip(), + ) + return + log.info("Seeded persistent volume '%s' for '%s' from image '%s'", volume, self._name, self._image) + + async def _remove_seed_container(self, seed_cid): + """ + Best-effort removal of the seeding container. + """ + + try: + process = await asyncio.subprocess.create_subprocess_exec( + "docker", "rm", "-f", seed_cid, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + except OSError: + return + await process.communicate() def _prepare_init_and_interface_env(self, params): """ @@ -209,23 +330,22 @@ class VendorDockerVM(DockerVM): async def start(self): await super().start() if self.status == "started" and not self._gns3_init: - await self._setup_skip_init_volumes() - # Fix host-side ownership of the seeded volume right away so the - # controller can read project files while the node runs. Reset the - # "fixed" flag afterwards: files written by the container during - # runtime still need the stop-time pass. + # Persistent volumes are seeded and bound directly at create time + # (see _prepare_volumes / _mount_binds), so there is no post-start + # bridge to run. Fix host-side ownership right away so the + # controller can read project files while the node runs, and reset + # the "fixed" flag: files written by the container during runtime + # still need the stop-time pass. await self._fix_permissions() self._permissions_fixed = False async def _fix_permissions(self): """ Container-side override of DockerVM._fix_permissions for vendor NOS - containers. It targets the Docker bind-mount paths - (`/gns3volumes`) directly instead of the in-container paths: - the in-container paths only resolve to persistent storage while the - `mount --bind` bridge from _setup_skip_init_volumes is up, and after a - container restart the bridge is gone — the base implementation would - then chown the overlay copy instead of the host files. + containers. The persistent volumes are Docker bind mounts created + with the container (see _mount_binds), so the in-container paths + resolve to the host-side files for the container's whole lifetime — + no /gns3volumes aliasing is needed. The busybox script runs inside the container as root (a host-side GNS3 process may be unprivileged and cannot chown root-owned files). @@ -248,7 +368,7 @@ class VendorDockerVM(DockerVM): uid, gid = os.getuid(), os.getgid() for volume in self._volumes: - target = f"/gns3volumes{volume}" + target = volume log.debug("Docker container '%s' fix ownership on %s", self._name, target) try: # chown prefers the container's own coreutils over /gns3/bin/busybox: @@ -284,61 +404,6 @@ class VendorDockerVM(DockerVM): else: self._permissions_fixed = True - async def _setup_skip_init_volumes(self): - """ - Replicate the volume-persistence portion of init.sh (lines 35–52) for - containers that skip init.sh (GNS3_SKIP_INIT=1). - - On first start the container's original files are seeded into the - persistent host directory; on subsequent starts the persisted data - is bind-mounted over the in-container path so writes land on the host. - Permission-changes recorded by _fix_permissions at the previous - stop are restored (best-effort). - """ - for volume in self._volumes: - vol_target = f"/gns3volumes{volume}" - # fmt: off - script = ( - f'mkdir -p "{volume}" && ' - f'if [ ! -f "{vol_target}/.gns3_perms" ]; then ' - f' /gns3/bin/busybox cp -a "{volume}/." "{vol_target}/" 2>/dev/null; ' - f' /gns3/bin/busybox touch "{vol_target}/.gns3_perms"; ' - f'fi && ' - f'/gns3/bin/busybox mount --bind "{vol_target}" "{volume}" && ' - f'while IFS=: read -r PERMS OWNER GROUP FILE; do ' - f' [ -L "$FILE" ] || /gns3/bin/busybox chmod "$PERMS" "$FILE" 2>/dev/null; ' - # chown: prefer the container's coreutils, fall back to busybox - # (see _fix_permissions -- static busybox chown aborts on - # mismatched-glibc NOS images like XRd). - f' ( command -v chown >/dev/null 2>&1 && chown -h "$OWNER:$GROUP" "$FILE" || /gns3/bin/busybox chown -h "$OWNER:$GROUP" "$FILE" ) 2>/dev/null; ' - f'done < "{volume}/.gns3_perms"' - ) - # fmt: on - try: - process = await asyncio.subprocess.create_subprocess_exec( - "docker", - "exec", - self._cid, - "sh", - "-c", - script, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await process.communicate() - if process.returncode != 0: - err = stderr.decode(errors="replace").strip() - log.warning( - "Volume setup for '%s' on container '%s' returned %d: %s", - volume, self._name, process.returncode, err, - ) - else: - log.info("Volume '%s' bound to persistent storage for '%s'", volume, self._name) - except OSError as e: - log.warning( - "Could not setup volume '%s' for container '%s': %s", volume, self._name, e - ) - async def _start_console_server(self): """ Override: add the ``docker_exec`` console type alongside the diff --git a/tests/compute/docker/test_vendor_docker_vm.py b/tests/compute/docker/test_vendor_docker_vm.py index e70bc6dd0..9f578c797 100644 --- a/tests/compute/docker/test_vendor_docker_vm.py +++ b/tests/compute/docker/test_vendor_docker_vm.py @@ -24,8 +24,10 @@ These tests cover: * init.sh prepend being skipped with GNS3_SKIP_INIT; * GNS3_INTERFACE_NAMES renaming injected interfaces (move_to_ns target); * the hardcoded /etc/network mount being dropped for SKIP_INIT containers; + * persistent volumes being seeded host-side and bound directly at their + real in-container paths (no post-start bridge racing the NOS boot); * the docker_exec console dispatch in start(); - * the SKIP_INIT volume bridge and container-side _fix_permissions passes. + * the container-side _fix_permissions passes. """ import uuid @@ -235,28 +237,36 @@ async def test_create_interface_names_sets_max_ethernet(compute_project, manager async def test_create_drops_etc_network_for_skip_init(compute_project, manager): response = _create_response(None, volumes={"/opt/srlinux/appmgr": None}) + seed_proc = MagicMock() + seed_proc.communicate = AsyncioMagicMock(return_value=(b"seedcid", b"")) + seed_proc.returncode = 0 with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "srlinux"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: - vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project, - manager, "srlinux:latest", - console_type="docker_exec", - environment="GNS3_SKIP_INIT=1", - extra_volumes=["/etc/opt/srlinux"]) - await vm.create() - sent = mock.call_args.kwargs["data"] - targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]] - # /etc/network must NOT be mounted - assert "/gns3volumes/etc/network" not in targets - # but the declared volumes ARE mounted - assert "/gns3volumes/opt/srlinux/appmgr" in targets - assert "/gns3volumes/etc/opt/srlinux" in targets - # GNS3_VOLUMES env must also exclude /etc/network - vol_env = [v for v in sent["Env"] if v.startswith("GNS3_VOLUMES=")][0] - assert "/etc/network" not in vol_env - # host skeleton dir removed - assert not os.path.exists(os.path.join(vm.working_dir, "etc", "network")) + with patch("asyncio.subprocess.create_subprocess_exec", + return_value=seed_proc): + vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project, + manager, "srlinux:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1", + extra_volumes=["/etc/opt/srlinux"]) + await vm.create() + sent = mock.call_args.kwargs["data"] + targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]] + # /etc/network must NOT be mounted + assert "/gns3volumes/etc/network" not in targets + assert "/etc/network" not in targets + # the declared volumes are bound DIRECTLY at their real paths — + # no /gns3volumes aliasing and no post-start bridge + assert "/opt/srlinux/appmgr" in targets + assert "/etc/opt/srlinux" in targets + assert not any(t.startswith("/gns3volumes/") for t in targets) + # GNS3_VOLUMES env must also exclude /etc/network + vol_env = [v for v in sent["Env"] if v.startswith("GNS3_VOLUMES=")][0] + assert "/etc/network" not in vol_env + # host skeleton dir removed + assert not os.path.exists(os.path.join(vm.working_dir, "etc", "network")) @pytest.mark.asyncio @@ -321,7 +331,6 @@ async def test_start_docker_exec_dispatches_console(compute_project, manager): vm._get_namespace = AsyncioMagicMock(return_value=42) vm._add_ubridge_connection = AsyncioMagicMock() vm._start_docker_exec_console = AsyncioMagicMock() - vm._setup_skip_init_volumes = AsyncioMagicMock() vm._fix_permissions = AsyncioMagicMock() with patch("gns3server.compute.docker.Docker.install_busybox"): @@ -330,8 +339,8 @@ async def test_start_docker_exec_dispatches_console(compute_project, manager): vm._start_docker_exec_console.assert_called_once() assert vm.status == "started" - # SKIP_INIT path runs the volume bridge + permission fix - vm._setup_skip_init_volumes.assert_called_once() + # SKIP_INIT path still runs the permission fix (volumes are already + # seeded and bound at create time — no post-start bridge anymore) vm._fix_permissions.assert_called_once() @@ -346,19 +355,18 @@ async def test_start_without_skip_init_skips_vendor_passes(compute_project, mana vm._get_namespace = AsyncioMagicMock(return_value=42) vm._add_ubridge_connection = AsyncioMagicMock() vm._start_docker_exec_console = AsyncioMagicMock() - vm._setup_skip_init_volumes = AsyncioMagicMock() vm._fix_permissions = AsyncioMagicMock() with patch("gns3server.compute.docker.Docker.install_busybox"): with asyncio_patch("gns3server.compute.docker.Docker.query"): await vm.start() - # init.sh runs (no SKIP_INIT) → no vendor bridge/fix passes - vm._setup_skip_init_volumes.assert_not_called() + # init.sh runs (no SKIP_INIT) → no vendor permission pass + vm._fix_permissions.assert_not_called() # --------------------------------------------------------------------------- -# _fix_permissions — container-side, skips dead containers, targets /gns3volumes +# _fix_permissions — container-side, skips dead containers, targets volume paths # --------------------------------------------------------------------------- @pytest.mark.asyncio @@ -387,7 +395,7 @@ async def test_fix_permissions_skips_missing_container(compute_project, manager) @pytest.mark.asyncio -async def test_fix_permissions_targets_gns3volumes(compute_project, manager): +async def test_fix_permissions_targets_volume_paths(compute_project, manager): vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1") vm._volumes = ["/etc/opt/srlinux", "/var/log/srlinux"] @@ -404,37 +412,120 @@ async def test_fix_permissions_targets_gns3volumes(compute_project, manager): await vm._fix_permissions() # one exec per volume assert mock_exec.call_count == 2 - # each script must target /gns3volumes, not the raw path + # each script must target the real in-container path (the direct + # bind mount), never the old /gns3volumes alias for call_obj in mock_exec.call_args_list: script = call_obj.args[-1] # last positional arg is the sh -c script - assert "/gns3volumes" in script - # must NOT chown the in-container path directly - assert 'chown' in script and '"/gns3volumes' in script + assert "/gns3volumes" not in script + assert '"/etc/opt/srlinux"' in script or '"/var/log/srlinux"' in script + assert 'chown' in script # --------------------------------------------------------------------------- -# _setup_skip_init_volumes — bridge via docker exec +# _prepare_volumes — host-side seeding (docker create + cp + rm) # --------------------------------------------------------------------------- +def _seed_proc(stdout=b"seedcid\n", returncode=0): + proc = MagicMock() + proc.communicate = AsyncioMagicMock(return_value=(stdout, b"")) + proc.returncode = returncode + return proc + + @pytest.mark.asyncio -async def test_setup_skip_init_volumes_runs_exec(compute_project, manager): +async def test_prepare_volumes_seeds_unmarked_volume(compute_project, manager): vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", extra_volumes=["/etc/opt/srlinux"]) - vm._volumes = ["/etc/opt/srlinux"] - - proc = MagicMock() - proc.communicate = AsyncioMagicMock(return_value=(b"", b"")) - proc.returncode = 0 + image_info = {"Config": {"Volumes": {}}} with patch("asyncio.subprocess.create_subprocess_exec", - return_value=proc) as mock_exec: - await vm._setup_skip_init_volumes() - assert mock_exec.call_count == 1 - script = mock_exec.call_args.args[-1] - # must do the bind mount - assert "mount --bind" in script - assert "/gns3volumes/etc/opt/srlinux" in script + return_value=_seed_proc()) as mock_exec: + await vm._prepare_volumes(image_info) + # docker create + docker cp + docker rm + assert mock_exec.call_count == 3 + argvs = [c.args for c in mock_exec.call_args_list] + assert argvs[0][1:3] == ("create", "srlinux:latest") + assert argvs[1][1:4] == ("cp", "-a", "seedcid:/etc/opt/srlinux/.") + assert argvs[2][1:3] == ("rm", "-f") + host_dir = os.path.join(vm.working_dir, "etc", "opt", "srlinux") + assert os.path.exists(os.path.join(host_dir, ".gns3_perms")) + + # a second create() must not re-seed (marker present): no docker CLI call + await vm._prepare_volumes(image_info) + assert mock_exec.call_count == 3 + + +@pytest.mark.asyncio +async def test_prepare_volumes_never_overwrites_marked_volume(compute_project, manager): + """Regression guard: a volume that ever started (marker present) holds the + node's saved configuration — re-seeding would reset it to factory.""" + + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", + extra_volumes=["/etc/opt/srlinux"]) + host_dir = os.path.join(vm.working_dir, "etc", "opt", "srlinux") + os.makedirs(host_dir, exist_ok=True) + marker = os.path.join(host_dir, ".gns3_perms") + open(marker, "w").close() + saved = os.path.join(host_dir, "config.json") + with open(saved, "w") as f: + f.write('{"user": "config"}') + + with patch("asyncio.subprocess.create_subprocess_exec", + return_value=_seed_proc()) as mock_exec: + await vm._prepare_volumes({"Config": {"Volumes": {}}}) + mock_exec.assert_not_called() + with open(saved) as f: + assert f.read() == '{"user": "config"}' + + +@pytest.mark.asyncio +async def test_prepare_volumes_tolerates_missing_image_path(compute_project, manager): + """A volume path the image does not contain (e.g. XRd's /xr-storage-shadow) + starts empty — cp fails, the marker is still written, no raise.""" + + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", + extra_volumes=["/xr-storage-shadow"]) + calls = {"n": 0} + + def proc_factory(*args, **kwargs): + # first call (docker create) succeeds, second (docker cp) fails, + # third (docker rm) succeeds + codes = [0, 1, 0] + proc = _seed_proc(returncode=codes[calls["n"]]) + calls["n"] += 1 + return proc + + with patch("asyncio.subprocess.create_subprocess_exec", + side_effect=proc_factory): + await vm._prepare_volumes({"Config": {"Volumes": {}}}) + assert calls["n"] == 3 # rm still ran (finally path) + host_dir = os.path.join(vm.working_dir, "xr-storage-shadow") + assert os.path.exists(os.path.join(host_dir, ".gns3_perms")) + + +@pytest.mark.asyncio +async def test_prepare_volumes_skips_without_skip_init(compute_project, manager): + + vm = _make_vm(compute_project, manager) # no SKIP_INIT + with patch("asyncio.subprocess.create_subprocess_exec", + return_value=_seed_proc()) as mock_exec: + await vm._prepare_volumes({"Config": {"Volumes": {"/etc/opt/srlinux": None}}}) + mock_exec.assert_not_called() + + +@pytest.mark.asyncio +async def test_prepare_volumes_raises_when_seed_container_fails(compute_project, manager): + """If `docker create` itself fails, creation must abort loudly instead of + binding an empty directory over the NOS's config path.""" + + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", + extra_volumes=["/etc/opt/srlinux"]) + proc = _seed_proc(stdout=b"", returncode=1) + + with patch("asyncio.subprocess.create_subprocess_exec", return_value=proc): + with pytest.raises(DockerError): + await vm._prepare_volumes({"Config": {"Volumes": {}}}) # --------------------------------------------------------------------------- From c709d74826d071a57f87a6ebf0167d4a1988f731 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 22 Aug 2026 00:36:24 +0800 Subject: [PATCH 27/28] fix: compute notification stream silently died on uncaught exceptions Two exception paths could permanently kill the compute notification chain (no more compute.updated events, no reconnection until a server restart): - connect() only caught ComputeError, but _run_http_query translates HTTP status errors (401/403/404/...) into sibling ControllerError subclasses (and a raw fastapi HTTPException for unexpected statuses). Those escaped the fire-and-forget connect() task started at controller startup and died silently. Now they notify clients, schedule an exponential-backoff retry, and still re-raise for explicit callers. The dead web.HTTP* except branches (never reached since _run_http_query converts HTTP errors itself) are removed. - _connect_notification() only caught aiohttp.ClientError. A malformed frame (e.g. missing 'action') or any error raised while dispatching a compute event (e.g. a pydantic ValidationError in node.parse_node_response) escaped the task, skipped the reconnect scheduling placed after the try block, and killed the stream forever. Now any exception is logged with its traceback (the gather() future holding it was never retrieved, so nothing was ever printed) and the reconnect scheduling + final compute.updated emit live in the finally block so every exit path recovers. Also moves the usage-stats reset before the disconnect log line so the emitted compute.updated snapshot is consistent. --- gns3server/controller/compute.py | 85 +++++++++++++----------- tests/controller/test_compute.py | 108 ++++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 37 deletions(-) diff --git a/gns3server/controller/compute.py b/gns3server/controller/compute.py index dbd54c151..a418f92a6 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -24,7 +24,6 @@ import sys import io from fastapi import HTTPException -from aiohttp import web if sys.version_info >= (3, 11): from asyncio import timeout as asynctimeout @@ -373,6 +372,27 @@ class Compute: except ControllerError: pass + async def _report_connection_failure(self, error): + """ + Update the connection state after a failure, notify clients and + schedule a reconnection attempt with exponential backoff. + """ + + self._connected = False + self._last_error = str(error) + self._controller.notification.controller_emit("compute.updated", self.asdict()) + # Try to reconnect if server unavailable only if not during tests (otherwise we create a ressource usage bomb) + if hasattr(sys, "_called_from_test") and sys._called_from_test: + return + self._connection_failure += 1 + # After 10 failures we close the project using the compute to avoid sync issues + if self._connection_failure == 10: + log.error(f"Could not connect to compute '{self._id}' after multiple attempts: {error}") + await self._controller.close_compute_projects(self) + # Exponential backoff: 5s, 10s, 20s, 40s, 80s, then cap at 300s + delay = min(5 * (2 ** (self._connection_failure - 1)), 300) + asyncio.get_event_loop().call_later(delay, lambda: asyncio.ensure_future(self._try_reconnect())) + @locking async def connect(self, report_failed_connection=False): """ @@ -385,32 +405,20 @@ class Compute: response = await self._run_http_query("GET", "/capabilities") except ComputeError as e: # Update connection status and notify UI - self._connected = False - self._last_error = str(e) - self._controller.notification.controller_emit("compute.updated", self.asdict()) - + await self._report_connection_failure(e) if report_failed_connection: raise log.warning(f"Cannot connect to compute '{self._id}': {e}") - # Try to reconnect if server unavailable only if not during tests (otherwise we create a ressource usage bomb) - if not hasattr(sys, "_called_from_test") or not sys._called_from_test: - self._connection_failure += 1 - # After 10 failures we close the project using the compute to avoid sync issues - if self._connection_failure == 10: - log.error(f"Could not connect to compute '{self._id}' after multiple attempts: {e}") - await self._controller.close_compute_projects(self) - # Exponential backoff: 5s, 10s, 20s, 40s, 80s, then cap at 300s - delay = min(5 * (2 ** (self._connection_failure - 1)), 300) - asyncio.get_event_loop().call_later(delay, lambda: asyncio.ensure_future(self._try_reconnect())) return - except web.HTTPNotFound: - raise ControllerNotFoundError(f"The server {self._id} is not a GNS3 server or it's a 1.X server") - except web.HTTPUnauthorized: - raise ControllerUnauthorizedError(f"Invalid auth for server {self._id}") - except web.HTTPServiceUnavailable: - raise ControllerNotFoundError(f"The server {self._id} is unavailable") - except ValueError: - raise ComputeError(f"Invalid server url for server {self._id}") + except (ControllerError, HTTPException) as e: + # _run_http_query translates HTTP status errors into ControllerError + # subclasses (or a raw HTTPException for unexpected status codes). + # They used to escape this method and silently kill the fire-and-forget + # connect() task started at controller startup: no notification, no retry. + # Schedule the retry, then re-raise so explicit callers still get the error. + await self._report_connection_failure(e) + log.warning(f"Cannot connect to compute '{self._id}': {e}") + raise if "version" not in response.json: msg = f"The server {self._id} is not a GNS3 server" @@ -488,22 +496,27 @@ class Compute: elif response.type == aiohttp.WSMsgType.CLOSED: pass break - except aiohttp.ClientError as e: - log.error(f"Client response error received on compute '{self._id}' WebSocket '{ws_url}': {e}") + except asyncio.CancelledError: + raise + except Exception as e: + # A malformed frame or an error raised while dispatching a compute event + # used to escape this task (only aiohttp.ClientError was caught) and + # permanently killed the notification stream: no more compute.updated + # events and no reconnection until the server was restarted. Log the + # error with its traceback and reconnect below. + log.error(f"Error on compute '{self._id}' notification stream '{ws_url}': {e!r}", exc_info=True) finally: self._connected = False + self._cpu_usage_percent = None + self._memory_usage_percent = None + self._disk_usage_percent = None log.info(f"Connection closed to compute '{self._id}' WebSocket '{ws_url}'") - - # Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a resources usage bomb) - from gns3server.api.server import app - if not app.state.exiting and not hasattr(sys, "_called_from_test"): - log.info(f"Reconnecting to compute '{self._id}' WebSocket '{ws_url}'") - asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self.connect())) - - self._cpu_usage_percent = None - self._memory_usage_percent = None - self._disk_usage_percent = None - self._controller.notification.controller_emit("compute.updated", self.asdict()) + self._controller.notification.controller_emit("compute.updated", self.asdict()) + # Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a resources usage bomb) + from gns3server.api.server import app + if not app.state.exiting and not hasattr(sys, "_called_from_test"): + log.info(f"Reconnecting to compute '{self._id}' WebSocket '{ws_url}'") + asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self.connect())) def _getUrl(self, path): host = self._host diff --git a/tests/controller/test_compute.py b/tests/controller/test_compute.py index 25fc991ca..58ba606c4 100644 --- a/tests/controller/test_compute.py +++ b/tests/controller/test_compute.py @@ -15,13 +15,22 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import sys import json +import asyncio +import aiohttp import pytest +from types import SimpleNamespace from unittest.mock import patch, MagicMock from gns3server.controller.project import Project from gns3server.controller.compute import Compute -from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError, ComputeConflictError +from gns3server.controller.controller_error import ( + ControllerError, + ControllerNotFoundError, + ControllerUnauthorizedError, + ComputeConflictError, +) from pydantic import SecretStr from tests.utils import asyncio_patch, AsyncioMagicMock @@ -524,3 +533,100 @@ async def test_get_ip_on_same_subnet(controller): }, ] assert await compute1.get_ip_on_same_subnet(compute2) == ('192.168.2.1', '192.168.1.2') + + +class FakeWebSocket: + """ + Minimal aiohttp WebSocketResponse stand-in for notification stream tests. + """ + + def __init__(self, frames): + self._frames = list(frames) + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + return False + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._frames: + raise StopAsyncIteration + return self._frames.pop(0) + + +def _text_frame(payload): + return SimpleNamespace(type=aiohttp.WSMsgType.TEXT, data=json.dumps(payload)) + + +@pytest.mark.asyncio +async def test_connect_notification_poison_frame_autoreconnects(compute, monkeypatch): + """ + A malformed frame must not permanently kill the notification stream: the + error is logged, clients are notified and a reconnection is scheduled. + """ + + emit_mock = MagicMock() + monkeypatch.setattr(compute._controller.notification, "controller_emit", emit_mock) + frames = [ + _text_frame({"action": "ping", "event": {"cpu_usage_percent": 10.0, "memory_usage_percent": 20.0, "disk_usage_percent": 30.0}}), + _text_frame({"event": {"poison": True}}), # missing "action": raises KeyError in the receive loop + ] + session = MagicMock() + session.closed = False + session.ws_connect = MagicMock(return_value=FakeWebSocket(frames)) + compute._http_session = session + + # allow the reconnection to be scheduled during the test + monkeypatch.delattr(sys, "_called_from_test", raising=False) + from gns3server.api.server import app as gns3_app + monkeypatch.setattr(gns3_app.state, "exiting", False) + + async def fake_connect(): + compute._reconnect_attempted = True + monkeypatch.setattr(compute, "connect", fake_connect) + + # must not raise despite the poison frame + await compute._connect_notification() + + actions = [c.args[0] for c in emit_mock.call_args_list] + assert actions.count("compute.updated") >= 2 # one for the ping, one for the disconnect + assert compute._connected is False + + # the reconnection scheduled by the finally block fires after 1 second + await asyncio.sleep(1.2) + assert compute._reconnect_attempted is True + + +@pytest.mark.asyncio +async def test_connect_http_error_notifies_schedules_retry_and_raises(compute, monkeypatch): + """ + HTTP-level failures (401/403/404...) reach connect() as ControllerError + subclasses. They must notify clients, schedule a retry and still raise for + explicit callers. They used to silently kill the fire-and-forget connect() + task started at controller startup: no notification, no retry. + """ + + compute._connected = False + emit_mock = MagicMock() + monkeypatch.setattr(compute._controller.notification, "controller_emit", emit_mock) + + async def raise_unauthorized(*args, **kwargs): + raise ControllerUnauthorizedError("Invalid authentication for compute 'my_compute_id'") + + monkeypatch.setattr(compute, "_run_http_query", raise_unauthorized) + monkeypatch.delattr(sys, "_called_from_test", raising=False) + scheduled_delays = [] + monkeypatch.setattr(asyncio.get_event_loop(), "call_later", lambda delay, callback: scheduled_delays.append(delay)) + + with pytest.raises(ControllerUnauthorizedError): + await compute.connect() + + assert compute._last_error == "Invalid authentication for compute 'my_compute_id'" + assert compute.connected is False + actions = [c.args[0] for c in emit_mock.call_args_list] + assert "compute.updated" in actions + assert scheduled_delays == [5] # first exponential backoff delay From e98c51889e86f255361df83b7d3233d122dc82b9 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 22 Aug 2026 00:50:49 +0800 Subject: [PATCH 28/28] fix: notification ping starved under sustained event load NotificationQueue.get only generated a synthetic ping when the queue was idle for the full timeout. Under sustained event load (e.g. a project with markers matching at 15-260 events/s) the queue never idled, so compute notification streams never carried a ping and the controller stopped emitting compute.updated: clients lost compute statistics until the event flow paused or the server restarted. A ping is now guaranteed at least every timeout seconds regardless of event flow: when the ping deadline is reached the next get() returns a ping ahead of queued events (pings only carry statistics, so skipping ahead of real events is harmless). Both the compute stream (compute CPU/memory/disk stats -> compute.updated) and the controller stream (idle keepalive) benefit. --- gns3server/utils/notification_queue.py | 32 +++++++-- tests/utils/test_notification_queue.py | 89 ++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 tests/utils/test_notification_queue.py diff --git a/gns3server/utils/notification_queue.py b/gns3server/utils/notification_queue.py index e198ffc9a..b7cf93759 100644 --- a/gns3server/utils/notification_queue.py +++ b/gns3server/utils/notification_queue.py @@ -17,6 +17,7 @@ import asyncio import json +import time import psutil from gns3server.utils.cpu_percent import CpuPercent @@ -35,22 +36,39 @@ class NotificationQueue(asyncio.Queue): def __init__(self): super().__init__() self._first = True + self._last_ping = None async def get(self, timeout): """ - When timeout is expire we send a ping notification with server information + Return a notification, or a ping notification with server information + at least every `timeout` seconds. The ping used to be generated only + when the queue was idle for the full timeout, which starved it under + sustained event load (e.g. high marker.match rates): clients stopped + receiving compute statistics until the event flow paused. """ # At first get we return a ping so the client immediately receives data if self._first: self._first = False - return ("ping", self._getPing(), {}) + return self._ping() - try: - (action, msg, kwargs) = await asyncio.wait_for(super().get(), timeout) - except asyncio.TimeoutError: - return ("ping", self._getPing(), {}) - return (action, msg, kwargs) + while True: + now = time.monotonic() + if self._last_ping is None or now - self._last_ping >= timeout: + return self._ping() + try: + (action, msg, kwargs) = await asyncio.wait_for(super().get(), timeout - (now - self._last_ping)) + return (action, msg, kwargs) + except asyncio.TimeoutError: + continue # the ping deadline has been reached + + def _ping(self): + """ + Build a ping notification and stamp the ping deadline. + """ + + self._last_ping = time.monotonic() + return ("ping", self._getPing(), {}) def _getPing(self): """ diff --git a/tests/utils/test_notification_queue.py b/tests/utils/test_notification_queue.py new file mode 100644 index 000000000..769a93e98 --- /dev/null +++ b/tests/utils/test_notification_queue.py @@ -0,0 +1,89 @@ +# +# 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 time +import asyncio + +import pytest + +from gns3server.utils.notification_queue import NotificationQueue + + +async def _feed(queue, until, interval=0.02): + """ + Continuously put dummy events on the queue to simulate sustained load + (e.g. high marker.match rates). + """ + + seq = 0 + while time.monotonic() < until: + queue.put_nowait(("dummy", {"seq": seq}, {})) + seq += 1 + await asyncio.sleep(interval) + + +@pytest.mark.asyncio +async def test_first_get_returns_ping(): + + queue = NotificationQueue() + action, event, _ = await asyncio.wait_for(queue.get(1), 1) + assert action == "ping" + assert "cpu_usage_percent" in event + + +@pytest.mark.asyncio +async def test_idle_queue_pings_after_timeout(): + + queue = NotificationQueue() + await queue.get(0.3) # consume the first immediate ping + + start = time.monotonic() + action, _, _ = await asyncio.wait_for(queue.get(0.3), 1) + assert action == "ping" + assert time.monotonic() - start >= 0.25 # had to wait for the idle timeout + + +@pytest.mark.asyncio +async def test_ping_not_starved_under_sustained_load(): + """ + Regression test: a continuously-fed queue must still emit a ping at least + every `timeout` seconds. The old idle-timeout-only ping never fired under + sustained event load, so clients stopped receiving compute statistics + (no more compute.updated events) until the event flow paused. + """ + + queue = NotificationQueue() + action, _, _ = await queue.get(0.5) # consume the first immediate ping + assert action == "ping" + + until = time.monotonic() + 2.0 + producer = asyncio.create_task(_feed(queue, until)) + try: + pings = 0 + events = 0 + deadline = time.monotonic() + 2.5 + while time.monotonic() < deadline: + action, _, _ = await asyncio.wait_for(queue.get(0.5), 1) + if action == "ping": + pings += 1 + else: + events += 1 + # real events still flow... + assert events > 0 + # ...and pings interleave roughly every 0.5s instead of starving + assert pings >= 2 + finally: + producer.cancel()