Merge origin/3.1 into image-manager-out-of-sync

Preserve image reconciliation, locking, and regression coverage while integrating upstream release, dependency, typing, and formatting updates. Align feature code with the new Ruff checks.
This commit is contained in:
Cristi committed 2026-09-28 23:16:24 +03:00
commit 42d30651da
497 files changed
+22962 -24152

No files matched your search

+19 -19
View File
@@ -202,12 +202,12 @@ class Controller:
if computes:
for c in computes:
try:
#FIXME: Task exception was never retrieved
# FIXME: Task exception was never retrieved
await self.add_compute(
compute_id=str(c.compute_id),
connect=False,
**c.dict(exclude_unset=True, exclude={"compute_id", "created_at", "updated_at"}),
ssl_context=self._ssl_context
ssl_context=self._ssl_context,
)
except (ControllerError, KeyError):
pass # Skip not available servers at loading
@@ -222,11 +222,9 @@ class Controller:
# start to auto open projects (if configured) 5 seconds after the controller has started
self._project_auto_open_task_handle = asyncio.get_event_loop().call_later(
5,
lambda: asyncio.create_task(self._project_auto_open())
5, lambda: asyncio.create_task(self._project_auto_open())
)
def _create_ssl_context(self, server_config):
import ssl
@@ -303,11 +301,10 @@ class Controller:
controller_vars = {
"appliances_etag": self._appliance_manager.appliances_etag,
"iou_license_check": self._iou_license_settings["license_check"],
"version": __version__
"version": __version__,
}
if self._iou_license_settings["iourc_content"]:
server_config = Config.instance().settings.Server
os.makedirs(server_config.secrets_dir, exist_ok=True)
iourc_path = os.path.join(server_config.secrets_dir, "iou_license")
@@ -321,7 +318,7 @@ class Controller:
try:
os.makedirs(os.path.dirname(self._vars_file), exist_ok=True)
with open(self._vars_file, 'w+') as f:
with open(self._vars_file, "w+") as f:
json.dump(controller_vars, f, indent=4, sort_keys=True)
except OSError as e:
log.error(f"Cannot write controller vars file '{self._vars_file}': {e}")
@@ -357,15 +354,20 @@ class Controller:
# IOU license check is disabled by default
self._iou_license_settings["license_check"] = controller_vars.get("iou_license_check", False)
log.info("IOU license check is {} on the controller".format("enabled" if self._iou_license_settings["license_check"] else "disabled"))
log.info(
"IOU license check is {} on the controller".format(
"enabled" if self._iou_license_settings["license_check"] else "disabled"
)
)
# install the built-in appliances if needed
if Config.instance().settings.Server.install_builtin_appliances:
previous_version = controller_vars.get("version")
log.info("Comparing controller version {} with config version {}".format(__version__, previous_version))
builtin_appliances_path = self._appliance_manager.builtin_appliances_path()
if not previous_version or \
parse_version(__version__.split("+")[0]) > parse_version(previous_version.split("+")[0]):
if not previous_version or parse_version(__version__.split("+")[0]) > parse_version(
previous_version.split("+")[0]
):
await self._appliance_manager.install_builtin_appliances()
elif not os.listdir(builtin_appliances_path):
await self._appliance_manager.install_builtin_appliances()
@@ -467,7 +469,9 @@ class Controller:
self._projects_scan_handle.cancel()
loop = self._projects_monitor_loop or asyncio.get_running_loop()
self._projects_scan_handle = loop.call_later(delay, lambda: asyncio.create_task(self._scan_projects_directory()))
self._projects_scan_handle = loop.call_later(
delay, lambda: asyncio.create_task(self._scan_projects_directory())
)
async def _scan_projects_directory(self):
"""
@@ -488,7 +492,6 @@ class Controller:
except Exception as e:
log.warning(f"Projects directory rescan failed: {e}")
@staticmethod
async def install_resource_files(dst_path, resource_name, upgrade_resources=True):
"""
@@ -496,6 +499,7 @@ class Controller:
"""
installed_resources = []
async def should_copy(src, dst, upgrade_resources):
if not os.path.exists(dst):
return True
@@ -512,7 +516,7 @@ class Controller:
if not os.path.exists(os.path.join(dst_path, filename)):
shutil.copy(os.path.join(resource_path, filename), os.path.join(dst_path, filename))
else:
for entry in importlib_resources.files('gns3server').joinpath(resource_name).iterdir():
for entry in importlib_resources.files("gns3server").joinpath(resource_name).iterdir():
full_path = os.path.join(dst_path, entry.name)
if entry.is_file() and await should_copy(str(entry), full_path, upgrade_resources):
log.debug(f'Installing {resource_name} resource file "{entry.name}" to "{full_path}"')
@@ -604,7 +608,6 @@ class Controller:
"""
if compute_id not in self._computes:
# We disallow to create from the outside the local and VM server
if (compute_id == "local" or compute_id == "vm") and not force:
return None
@@ -799,10 +802,7 @@ class Controller:
project = self._projects[topo_data["project_id"]]
else:
project = await self.add_project(
path=os.path.dirname(path),
status="closed",
filename=os.path.basename(path),
**topo_data
path=os.path.dirname(path), status="closed", filename=os.path.basename(path), **topo_data
)
if load or project.auto_open:
await project.open()
-1
View File
@@ -22,7 +22,6 @@ log = logging.getLogger(__name__)
class Appliance:
def __init__(self, path, data, builtin=True):
self._data = data.copy()
+73 -63
View File
@@ -117,6 +117,7 @@ class ApplianceManager:
dst_path = self.builtin_appliances_path()
log.info(f"Installing built-in appliances in '{dst_path}'")
from . import Controller
try:
await Controller.instance().install_resource_files(dst_path, "appliances")
except OSError as e:
@@ -136,12 +137,7 @@ class ApplianceManager:
return appliances
async def _download_image(
self,
image_dir: str,
image_name: str,
image_type: str,
image_url: str,
images_repo: ImagesRepository
self, image_dir: str, image_name: str, image_type: str, image_url: str, images_repo: ImagesRepository
) -> None:
"""
Download an image.
@@ -153,7 +149,9 @@ class ApplianceManager:
async with HTTPClient.get(image_url) as response:
if response.status != 200:
raise ControllerError(f"Could not download '{image_name}' due to HTTP error code {response.status}")
await write_image(image_name, image_path, response.content.iter_any(), images_repo, allow_raw_image=True)
await write_image(
image_name, image_path, response.content.iter_any(), images_repo, allow_raw_image=True
)
except (OSError, InvalidImageError) as e:
raise ControllerError(f"Could not save {image_type} image '{image_path}': {e}")
except ClientError as e:
@@ -162,11 +160,7 @@ class ApplianceManager:
raise ControllerError(f"Timeout while downloading '{image_name}' from '{image_url}'")
async def _find_appliance_version_images(
self,
appliance: Appliance,
version: dict,
images_repo: ImagesRepository,
image_dir: str
self, appliance: Appliance, version: dict, images_repo: ImagesRepository, image_dir: str
) -> None:
"""
Find all the images belonging to a specific appliance version.
@@ -188,7 +182,9 @@ class ApplianceManager:
async with image_lock(image_path):
image_info = await read_image_info(image_path, allow_raw_image=True)
if image_info["checksum"] != image_checksum:
raise ControllerError(f"Image '{image_path}' does not match the appliance checksum")
raise ControllerError(
f"Image '{image_path}' does not match the appliance checksum"
)
try:
await images_repo.save_verified_image(image_info)
except SQLAlchemyError as e:
@@ -198,11 +194,8 @@ class ApplianceManager:
direct_download_url = image.get("direct_download_url")
if direct_download_url:
await self._download_image(
image_dir,
appliance_file,
appliance.type,
direct_download_url,
images_repo)
image_dir, appliance_file, appliance.type, direct_download_url, images_repo
)
else:
raise ControllerError(f"Could not find '{appliance_file}'")
@@ -216,8 +209,8 @@ class ApplianceManager:
except ValidationError as e:
raise ControllerError(message=f"Could not validate template data: {e}")
template = await TemplatesService(templates_repo).create_template(template_create)
#template_id = template.get("template_id")
#await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*")
# template_id = template.get("template_id")
# await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*")
log.info(f"Template '{template.get('name')}' has been created")
return template
@@ -239,14 +232,14 @@ class ApplianceManager:
return template_data
async def install_appliances_from_image(
self,
image_path: str,
image_checksum: str,
images_repo: ImagesRepository,
templates_repo: TemplatesRepository,
rbac_repo: RbacRepository,
current_user: schemas.User,
image_dir: str
self,
image_path: str,
image_checksum: str,
images_repo: ImagesRepository,
templates_repo: TemplatesRepository,
rbac_repo: RbacRepository,
current_user: schemas.User,
image_dir: str,
) -> List[dict]:
"""
Install appliances using an image checksum.
@@ -264,11 +257,13 @@ class ApplianceManager:
ApplianceModel.model_validate(appliance.asdict())
except ValidationError as e:
log.warning(f"Could not validate appliance '{appliance.id}': {e}")
results.append({
"status": "skipped",
"name": appliance.name,
"reason": f"could not validate appliance '{appliance.id}': {e}",
})
results.append(
{
"status": "skipped",
"name": appliance.name,
"reason": f"could not validate appliance '{appliance.id}': {e}",
}
)
continue
if appliance.versions:
for version in appliance.versions:
@@ -284,37 +279,45 @@ class ApplianceManager:
# duplicates when the appliance version differs, but two
# templates sharing a name is never what the user asked for here
log.warning(f"Template '{name}' already exists, skipping automatic template creation")
results.append({
"status": "skipped",
"name": name,
"reason": f"a template named '{name}' already exists",
})
results.append(
{
"status": "skipped",
"name": name,
"reason": f"a template named '{name}' already exists",
}
)
continue
template = await self._create_template(template_data, templates_repo, rbac_repo, current_user)
results.append({
"status": "created",
"template_id": str(template.get("template_id")),
"name": template.get("name"),
"version": template.get("version"),
"template_type": template.get("template_type"),
})
template = await self._create_template(
template_data, templates_repo, rbac_repo, current_user
)
results.append(
{
"status": "created",
"template_id": str(template.get("template_id")),
"name": template.get("name"),
"version": template.get("version"),
"template_type": template.get("template_type"),
}
)
except (ControllerError, InvalidImageError) as e:
log.warning(f"Could not automatically create template using image '{image_path}': {e}")
results.append({
"status": "skipped",
"name": appliance.name,
"reason": str(e),
})
results.append(
{
"status": "skipped",
"name": appliance.name,
"reason": str(e),
}
)
return results
async def install_appliance(
self,
appliance_id: UUID,
version: str,
images_repo: ImagesRepository,
templates_repo: TemplatesRepository,
rbac_repo: RbacRepository,
current_user: schemas.User
self,
appliance_id: UUID,
version: str,
images_repo: ImagesRepository,
templates_repo: TemplatesRepository,
rbac_repo: RbacRepository,
current_user: schemas.User,
) -> None:
"""
Install a new appliance
@@ -337,11 +340,15 @@ class ApplianceManager:
for appliance_version_info in appliance.versions:
if appliance_version_info.get("name") == version:
try:
template_type = ApplianceToTemplate().get_template_type(appliance.asdict(), appliance_version_info)
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)
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)
@@ -352,8 +359,9 @@ class ApplianceManager:
else:
if appliance.versions:
# TODO: install appliance versions based on available images
raise ControllerBadRequestError(message=f"Selecting a version is required to install "
f"appliance '{appliance_id}'")
raise ControllerBadRequestError(
message=f"Selecting a version is required to install appliance '{appliance_id}'"
)
template_data = await self._appliance_to_template(appliance)
return await self._create_template(template_data, templates_repo, rbac_repo, current_user)
@@ -385,7 +393,9 @@ class ApplianceManager:
json_data = appliance.asdict() # Check if loaded without error
if appliance.status != "broken":
# Validate using discriminated union - automatically routes to correct version
log.debug(f"Validating appliance '{appliance.id}' with registry version {appliance.registry_version}")
log.debug(
f"Validating appliance '{appliance.id}' with registry version {appliance.registry_version}"
)
ApplianceModel.model_validate(json_data)
self._appliances[appliance.id] = appliance
if not appliance.symbol or appliance.symbol.startswith(":/symbols/"):
@@ -315,8 +315,7 @@ class ApplianceToTemplate:
return settings_list[0]
raise ControllerError(
f"Appliance '{appliance_config['name']}' has multiple settings "
f"but none is marked as default"
f"Appliance '{appliance_config['name']}' has multiple settings but none is marked as default"
)
def _merge_v8_properties(self, settings, appliance_config):
+20 -15
View File
@@ -40,7 +40,7 @@ from ..controller.controller_error import (
ControllerTimeoutError,
ControllerUnauthorizedError,
ComputeError,
ComputeConflictError
ComputeConflictError,
)
from ..version import __version__, __version_info__
@@ -216,6 +216,13 @@ class Compute:
"""
return self._host
@host.setter
def host(self, host):
self._host = host
self._host_ip_cache = None # invalidate; re-resolve on next access
if self._console_host is None:
self._console_host = host
@property
def host_ip(self):
"""
@@ -228,13 +235,6 @@ class Compute:
self._host_ip_cache = "0.0.0.0"
return self._host_ip_cache
@host.setter
def host(self, host):
self._host = host
self._host_ip_cache = None # invalidate; re-resolve on next access
if self._console_host is None:
self._console_host = host
@property
def console_host(self):
return self._console_host
@@ -483,10 +483,7 @@ class Compute:
self._controller.notification.controller_emit("compute.updated", self.asdict())
else:
await self._controller.notification.dispatch(
action,
event,
project_id=project_id,
compute_id=self.id
action, event, project_id=project_id, compute_id=self.id
)
else:
if response.type == aiohttp.WSMsgType.CLOSE:
@@ -514,6 +511,7 @@ class Compute:
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()))
@@ -534,7 +532,7 @@ class Compute:
return f"{self._protocol}://{host}:{self._port}/v3/compute{path}"
def get_url(self, path):
""" Returns URL for specific path at Compute"""
"""Returns URL for specific path at Compute"""
return self._getUrl(path)
async def _run_http_query(self, method, path, data=None, timeout=120, raw=False, stream=False, params=None):
@@ -567,7 +565,14 @@ class Compute:
try:
log.debug(f"Attempting request to compute: {method} {url} {headers}")
response = await self._session().request(
method, url, headers=headers, data=data, auth=self._auth, params=params, chunked=chunked, timeout=timeout
method,
url,
headers=headers,
data=data,
auth=self._auth,
params=params,
chunked=chunked,
timeout=timeout,
)
except asyncio.TimeoutError:
raise ComputeError(f"Timeout error for {method} call to {url} after {timeout}s")
@@ -627,7 +632,7 @@ class Compute:
raise HTTPException(
status_code=response.status,
detail=f"HTTP error {response.status} received from compute "
f"'{self.name}' for request {method} {path}: {msg}"
f"'{self.name}' for request {method} {path}: {msg}",
)
if body and len(body):
+2 -10
View File
@@ -81,7 +81,7 @@ async def export_project(
keep_compute_ids,
allow_all_nodes,
temporary_dir,
reset_mac_addresses
reset_mac_addresses,
)
# Export the local files
@@ -198,14 +198,7 @@ def _is_exportable(path, include_snapshots=False):
async def _patch_project_file(
project,
path,
zstream,
include_images,
keep_compute_ids,
allow_all_nodes,
temporary_dir,
reset_mac_addresses
project, path, zstream, include_images, keep_compute_ids, allow_all_nodes, temporary_dir, reset_mac_addresses
):
"""
Patch a project file (.gns3) to export a project.
@@ -242,7 +235,6 @@ async def _patch_project_file(
if "properties" in node:
for prop, value in node["properties"].items():
# reset the MAC address
if reset_mac_addresses and prop in ("mac_addr", "mac_address"):
node["properties"][prop] = None
@@ -28,7 +28,6 @@ log = logging.getLogger(__name__)
class HyperVGNS3VM(BaseGNS3VM):
_HYPERV_VM_STATE_ENABLED = 2
_HYPERV_VM_STATE_DISABLED = 3
_HYPERV_VM_STATE_SHUTDOWN = 4
@@ -135,8 +135,8 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
result = await self._execute("showvminfo", [self._vmname, "--machinereadable"])
for info in result.splitlines():
if '=' in info:
name, value = info.split('=', 1)
if "=" in info:
name, value = info.split("=", 1)
if name == "{}{}".format(backend_type, interface_number):
return value.strip('"')
return None
@@ -188,7 +188,9 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
lower_ip = str(interface.ip + 2)
upper_ip = str(subnet.network_address + subnet.num_addresses - 2)
except ValueError:
raise GNS3VMError("Invalid IP address and netmask for vboxnet {}: {}/{}".format(vboxnet, ip_address, netmask))
raise GNS3VMError(
"Invalid IP address and netmask for vboxnet {}: {}/{}".format(vboxnet, ip_address, netmask)
)
dhcp_server_args = [
"add",
@@ -197,7 +199,7 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
"--netmask={}".format(netmask),
"--lower-ip={}".format(lower_ip),
"--upper-ip={}".format(upper_ip),
"--enable"
"--enable",
]
await self._execute("dhcpserver", dhcp_server_args)
@@ -249,7 +251,6 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
return True
return False
async def list(self):
"""
List all VirtualBox VMs
@@ -271,16 +272,24 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
# get a NAT interface number
nat_interface_number = await self._look_for_interface("nat")
if nat_interface_number < 0 and await self._look_for_interface("natnetwork") < 0:
raise GNS3VMError(f'VM "{self.vmname}" must have a NAT interface or NAT Network configured in order to start')
raise GNS3VMError(
f'VM "{self.vmname}" must have a NAT interface or NAT Network configured in order to start'
)
if sys.platform.startswith("darwin") and parse_version(self._system_properties["API version"]) >= parse_version("7_0"):
if sys.platform.startswith("darwin") and parse_version(self._system_properties["API version"]) >= parse_version(
"7_0"
):
# VirtualBox 7.0+ on macOS requires a host-only network interface
backend_type = "hostonly-network"
backend_description = "host-only network"
vboxnet_type = "hostonlynets"
interface_number = await self._look_for_interface("hostonlynetwork")
if interface_number < 0:
raise GNS3VMError('VM "{}" must have a network adapter attached to a host-only network in order to start'.format(self.vmname))
raise GNS3VMError(
'VM "{}" must have a network adapter attached to a host-only network in order to start'.format(
self.vmname
)
)
else:
backend_type = "hostonlyadapter"
backend_description = "host-only adapter"
@@ -288,11 +297,19 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
interface_number = await self._look_for_interface("hostonly")
if interface_number < 0:
raise GNS3VMError('VM "{}" must have a network adapter attached to a {} in order to start'.format(self.vmname, backend_description))
raise GNS3VMError(
'VM "{}" must have a network adapter attached to a {} in order to start'.format(
self.vmname, backend_description
)
)
vboxnet = await self._look_for_vboxnet(backend_type, interface_number)
if vboxnet is None:
raise GNS3VMError('A VirtualBox host-only network could not be found on network adapter {} for "{}"'.format(interface_number, self._vmname))
raise GNS3VMError(
'A VirtualBox host-only network could not be found on network adapter {} for "{}"'.format(
interface_number, self._vmname
)
)
if not (await self._check_vboxnet_exists(vboxnet, vboxnet_type)):
if sys.platform.startswith("win") and vboxnet == "vboxnet0":
@@ -300,24 +317,30 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
# on Windows. Try to patch this with the first available vboxnet we find.
first_available_vboxnet = await self._find_first_available_vboxnet()
if first_available_vboxnet is None:
raise GNS3VMError('Please add a VirtualBox host-only network with DHCP enabled and attached it to network adapter {} for "{}"'.format(interface_number, self._vmname))
raise GNS3VMError(
'Please add a VirtualBox host-only network with DHCP enabled and attached it to network adapter {} for "{}"'.format(
interface_number, self._vmname
)
)
await self.set_hostonly_network(interface_number, first_available_vboxnet)
vboxnet = first_available_vboxnet
else:
try:
await self._execute("hostonlyif", ["create"])
except GNS3VMError:
raise GNS3VMError('VirtualBox host-only network "{}" does not exist and could not be automatically created, please make the sure the network adapter {} configuration is valid for "{}"'.format(
vboxnet,
interface_number,
self._vmname
))
raise GNS3VMError(
'VirtualBox host-only network "{}" does not exist and could not be automatically created, please make the sure the network adapter {} configuration is valid for "{}"'.format(
vboxnet, interface_number, self._vmname
)
)
if backend_type == "hostonlyadapter" and not (await self._check_dhcp_server(vboxnet)):
try:
await self._add_dhcp_server(vboxnet)
except GNS3VMError as e:
raise GNS3VMError("Could not add DHCP server for vboxnet {}: {}, please configure manually".format(vboxnet, e))
raise GNS3VMError(
"Could not add DHCP server for vboxnet {}: {}, please configure manually".format(vboxnet, e)
)
vm_state = await self._get_state()
log.info(f'"{self._vmname}" state is {vm_state}')
@@ -363,12 +386,24 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
if await self._check_vbox_port_forwarding():
# delete the GNS3VM NAT port forwarding rule if it exists
log.info("Removing GNS3VM NAT port forwarding rule from interface {}".format(nat_interface_number))
await self._execute("controlvm", [self._vmname, "natpf{}".format(nat_interface_number), "delete", "GNS3VM"])
await self._execute(
"controlvm", [self._vmname, "natpf{}".format(nat_interface_number), "delete", "GNS3VM"]
)
# add a GNS3VM NAT port forwarding rule to redirect 127.0.0.1 with random port to the port in the VM
log.info("Adding GNS3VM NAT port forwarding rule with port {} to interface {}".format(api_port, nat_interface_number))
await self._execute("controlvm", [self._vmname, "natpf{}".format(nat_interface_number),
"GNS3VM,tcp,{},{},,{}".format(ip_address, api_port, self.port)])
log.info(
"Adding GNS3VM NAT port forwarding rule with port {} to interface {}".format(
api_port, nat_interface_number
)
)
await self._execute(
"controlvm",
[
self._vmname,
"natpf{}".format(nat_interface_number),
"GNS3VM,tcp,{},{},,{}".format(ip_address, api_port, self.port),
],
)
self.ip_address = await self._get_ip_from_server(interface_number, api_port)
@@ -384,8 +419,8 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
while remaining_try > 0:
result = await self._execute("guestproperty", ["get", self._vmname, "/VirtualBox/GuestInfo/Net/0/V4/IP"])
for info in result.splitlines():
if ':' in info:
name, value = info.split(':', 1)
if ":" in info:
name, value = info.split(":", 1)
if name == "Value":
return value.strip()
remaining_try -= 1
+18 -15
View File
@@ -41,16 +41,16 @@ Handle the import of project from a .gns3project
async def import_project(
controller,
project_id,
stream,
location=None,
name=None,
reset_mac_addresses=False,
keep_compute_ids=False,
auto_start=False,
auto_open=False,
auto_close=True,
controller,
project_id,
stream,
location=None,
name=None,
reset_mac_addresses=False,
keep_compute_ids=False,
auto_start=False,
auto_open=False,
auto_close=True,
):
"""
Import a project contain in a zip file
@@ -135,7 +135,9 @@ async def import_project(
else:
# Round-robin through available compute resources.
# Only use computes that are connected to avoid import failures
available_computes = {compute_id: compute for compute_id, compute in controller.computes.items() if compute.connected}
available_computes = {
compute_id: compute for compute_id, compute in controller.computes.items() if compute.connected
}
if available_computes:
compute_nodes = itertools.cycle(available_computes)
for node in topology["topology"]["nodes"]:
@@ -258,6 +260,7 @@ def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=Fals
for drawing in topology["topology"]["drawings"]:
drawing["drawing_id"] = str(uuid.uuid4())
def _move_node_file(path, old_id, new_id):
"""
Move a file from a node when changing its id
@@ -284,7 +287,7 @@ async def _move_files_to_compute(compute, project_id, directory, files_path):
location = os.path.join(directory, files_path)
if os.path.exists(location):
for (dirpath, dirnames, filenames) in os.walk(location, followlinks=False):
for dirpath, dirnames, filenames in os.walk(location, followlinks=False):
for filename in filenames:
path = os.path.join(dirpath, filename)
if os.path.islink(path):
@@ -314,7 +317,7 @@ async def _import_images(controller, images_path):
image_dir = controller.images_path()
root = images_path
for (dirpath, dirnames, filenames) in os.walk(root, followlinks=False):
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
for filename in filenames:
path = os.path.join(dirpath, filename)
dst = os.path.join(image_dir, os.path.relpath(path, root))
@@ -326,11 +329,12 @@ async def _import_images(controller, images_path):
# read the first 7 bytes of the file.
elf_header_start = f.read(7)
# IOU images must start with the ELF magic number, be 32-bit or 64-bit, little endian and have an ELF version of 1
if elf_header_start == b'\x7fELF\x01\x01\x01' or elf_header_start == b'\x7fELF\x02\x01\x01':
if elf_header_start == b"\x7fELF\x01\x01\x01" or elf_header_start == b"\x7fELF\x02\x01\x01":
os.chmod(dst, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
except OSError as e:
continue
async def update_snapshots(snapshots_dir, project_path, project_name, project_id, reset_mac_addresses=True):
"""
Load the snapshots and update their project name and project ID to be the same as the main project.
@@ -342,7 +346,6 @@ async def update_snapshots(snapshots_dir, project_path, project_name, project_id
continue
snapshot_path = os.path.join(snapshots_dir, snapshot)
with tempfile.TemporaryDirectory(dir=snapshots_dir) as tmpdir:
# extract everything to a temporary directory
try:
with open(snapshot_path, "rb") as f:
+17 -20
View File
@@ -23,7 +23,11 @@ import html
from .controller_error import ControllerError, ControllerNotFoundError
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
from gns3server.config import Config
from gns3server.utils.packet_filter_validation import validate_all_filters, filter_inactive_filters, FilterValidationError
from gns3server.utils.packet_filter_validation import (
validate_all_filters,
filter_inactive_filters,
FilterValidationError,
)
import logging
@@ -164,7 +168,7 @@ class Link:
"""
Get whether to show filters icon in Web UI
"""
return getattr(self, '_show_filters_icon', True)
return getattr(self, "_show_filters_icon", True)
@property
def project(self):
@@ -438,14 +442,13 @@ class Link:
jwt_token=jwt_token,
memory=webwireshark_config.memory,
cpus=webwireshark_config.cpus,
pids_limit=webwireshark_config.pids_limit
pids_limit=webwireshark_config.pids_limit,
)
# Send notification
self._project.emit_notification("link.web_wireshark_started", {
"link_id": self.id,
"ws_url": result.get("ws_url", result.get("url"))
})
self._project.emit_notification(
"link.web_wireshark_started", {"link_id": self.id, "ws_url": result.get("ws_url", result.get("url"))}
)
log.info(f"Web Wireshark started for link {self.id}: {result.get('ws_url')}")
self._wireshark = True
@@ -466,10 +469,7 @@ class Link:
try:
log.info(f"Stopping Web Wireshark for link {self.id}")
await manager.stop_wireshark_session(
project_id=self._project.id,
link_id=self.id
)
await manager.stop_wireshark_session(project_id=self._project.id, link_id=self.id)
log.info(f"Web Wireshark stopped for link {self.id}")
self._wireshark = False
@@ -494,15 +494,12 @@ class Link:
log.info(f"Restarting Web Wireshark for link {self.id}")
result = await manager.restart_wireshark_session(
project_id=self._project.id,
link_id=self.id,
jwt_token=jwt_token
project_id=self._project.id, link_id=self.id, jwt_token=jwt_token
)
self._project.emit_notification("link.web_wireshark_started", {
"link_id": self.id,
"ws_url": result.get("ws_url", result.get("url"))
})
self._project.emit_notification(
"link.web_wireshark_started", {"link_id": self.id, "ws_url": result.get("ws_url", result.get("url"))}
)
log.info(f"Web Wireshark restarted for link {self.id}: {result.get('ws_url')}")
@@ -660,7 +657,7 @@ class Link:
"markers": self._persist_markers(),
"link_style": self._link_style,
"suspend": self._suspended,
"show_filters_icon": getattr(self, '_show_filters_icon', True),
"show_filters_icon": getattr(self, "_show_filters_icon", True),
}
result = {
"nodes": res,
@@ -676,6 +673,6 @@ class Link:
"suspend": self._suspended,
"link_style": self._link_style,
"wireshark": self._wireshark,
"show_filters_icon": getattr(self, '_show_filters_icon', True),
"show_filters_icon": getattr(self, "_show_filters_icon", True),
}
return result
+63 -68
View File
@@ -124,10 +124,10 @@ class _SharkdRpcError(Exception):
# magic → (byte order, timestamp unit). Both pcap families uBridge can write
# (libpcap default µs; ns variant accepted defensively) and both endiannesses.
_PCAP_MAGICS = {
0xA1B2C3D4: ("<", 1), # little-endian, microseconds
0xD4C3B2A1: (">", 1), # big-endian, microseconds
0xA1B23C4D: ("<", 1000), # little-endian, nanoseconds
0x4D3CB2A1: (">", 1000), # big-endian, nanoseconds
0xA1B2C3D4: ("<", 1), # little-endian, microseconds
0xD4C3B2A1: (">", 1), # big-endian, microseconds
0xA1B23C4D: ("<", 1000), # little-endian, nanoseconds
0x4D3CB2A1: (">", 1000), # big-endian, nanoseconds
}
@@ -174,9 +174,7 @@ def scan_pcap_frames(path):
pos = 24
while pos + 16 <= len(data):
ts_sec, ts_frac, incl_len, _orig_len = struct.unpack(
byteorder + "IIII", data[pos:pos + 16]
)
ts_sec, ts_frac, incl_len, _orig_len = struct.unpack(byteorder + "IIII", data[pos : pos + 16])
if incl_len > 0xFFFF or pos + 16 + incl_len > len(data):
break # truncated tail (snapshot mid-write / torn final record)
# Normalize ns pcaps to µs by truncation — uBridge writes µs anyway.
@@ -197,7 +195,7 @@ def read_frame_bytes(path, frame_number):
return None
# Offset arithmetic mirrors the header scan: global header + every full
# record before the target + the target's own record header.
offset = 24 + sum(16 + incl for _s, _u, incl in frames[:frame_number - 1]) + 16
offset = 24 + sum(16 + incl for _s, _u, incl in frames[: frame_number - 1]) + 16
incl_len = frames[frame_number - 1][2]
with open(path, "rb") as f:
f.seek(offset)
@@ -213,10 +211,7 @@ def read_frame_bytes(path, frame_number):
# (/etc/wireshark &c.), so the column indexes in _columns_for are a contract
# we own rather than an environment default. Exactly the four columns the
# frame entries consume — nothing else rides along in every page.
_PINNED_COLUMNS = (
'gui.column.format: "Source", "%s", "Destination", "%d", '
'"Protocol", "%p", "Info", "%i"\n'
)
_PINNED_COLUMNS = 'gui.column.format: "Source", "%s", "Destination", "%d", "Protocol", "%p", "Info", "%i"\n'
async def _prepare_scratch(pcap):
@@ -262,13 +257,13 @@ def _engine_env(scratch_dir):
# semantics live in VALUES (field names, labels, filter expressions), which
# are never touched.
_KEY_RENAME = {
"t": "element", # node type ("proto", …)
"l": "label", # display text
"fn": "name", # field name (e.g. "ip.ttl")
"f": "filter_expr", # ready-made display filter with the value baked in
"s": "expert", # expert severity name ("Chat", "Warn", …)
"g": "generated", # generated-by-wireshark flag
"n": "children", # nested fields
"t": "element", # node type ("proto", …)
"l": "label", # display text
"fn": "name", # field name (e.g. "ip.ttl")
"f": "filter_expr", # ready-made display filter with the value baked in
"s": "expert", # expert severity name ("Chat", "Warn", …)
"g": "generated", # generated-by-wireshark flag
"n": "children", # nested fields
}
# "h" → pos + size (byte range for hex highlighting) — handled specially.
# "e" is sharkd's internal header-field registry id — unstable across
@@ -308,6 +303,7 @@ def _count_tree_nodes(value):
# sharkd sessions (one resident process per source pcap)
# ---------------------------------------------------------------------------
class _SharkdSession:
"""A resident `sharkd -` process with one pcap loaded, addressed through
line-oriented JSON-RPC. Serialized by an asyncio lock (sharkd serves one
@@ -425,8 +421,7 @@ class _SharkdManager:
async def _acquire(self, pcap):
if shutil.which("sharkd") is None:
raise SharkdMissingError(
"sharkd is not available on this server — marker replay requires sharkd "
"(part of the Wireshark package)"
"sharkd is not available on this server — marker replay requires sharkd (part of the Wireshark package)"
)
try:
stat = os.stat(pcap)
@@ -492,9 +487,12 @@ class _SharkdManager:
scratch_dir, scratch = await _prepare_scratch(pcap)
try:
proc = await asyncio.create_subprocess_exec(
"sharkd", "-",
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL, env=_engine_env(scratch_dir),
"sharkd",
"-",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
env=_engine_env(scratch_dir),
limit=_STREAM_LIMIT_BYTES,
)
except OSError as e:
@@ -541,6 +539,7 @@ async def close_sessions():
# Columns + display filter (sharkd `frames` RPC)
# ---------------------------------------------------------------------------
async def _columns_for(pcap, filter_expr):
"""
One resident `frames` pass over the source pcap. Returns
@@ -596,6 +595,7 @@ async def _columns_for(pcap, filter_expr):
# Tag gate + timeline assembly
# ---------------------------------------------------------------------------
def _tag_markers(project, tag):
"""
Every marker entry in the project carrying ``tag`` (flat
@@ -606,13 +606,15 @@ def _tag_markers(project, tag):
for key, info in project.markers.items():
if info.get("tag") == tag:
link_id, _, name = key.partition("/")
entries.append({
"node_id": info["node_id"],
"link_id": link_id,
"marker": name,
"enabled": info.get("enabled", True),
"data_link_type": info.get("data_link_type", "DLT_EN10MB"),
})
entries.append(
{
"node_id": info["node_id"],
"link_id": link_id,
"marker": name,
"enabled": info.get("enabled", True),
"data_link_type": info.get("data_link_type", "DLT_EN10MB"),
}
)
return entries
@@ -659,13 +661,12 @@ async def _merged_frames(project, entries, filter_expr=None, link_id=None):
merged = []
sources = []
for entry in entries:
pcap = os.path.join(
markers_dir, f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}.pcap"
)
pcap = os.path.join(markers_dir, f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}.pcap")
frames = scan_pcap_frames(pcap) if os.path.exists(pcap) else []
# Inventory first: every source, engine-free totals.
sources.append({**{k: entry[k] for k in ("node_id", "link_id", "marker", "data_link_type")},
"count": len(frames)})
sources.append(
{**{k: entry[k] for k in ("node_id", "link_id", "marker", "data_link_type")}, "count": len(frames)}
)
if link_id and entry["link_id"] != link_id:
continue # link narrows the stream before any engine work
if frames:
@@ -677,22 +678,24 @@ async def _merged_frames(project, entries, filter_expr=None, link_id=None):
if filter_expr is not None and frame_number not in columns:
continue
cols = columns.get(frame_number, {})
merged.append({
"ts": _format_ts(sec, usec),
"ts_us": sec * 1_000_000 + usec,
"_source": source_key,
"len": incl_len,
"node_id": entry["node_id"],
"link_id": entry["link_id"],
"marker": entry["marker"],
"frame_number": frame_number,
"src": cols.get("src"),
"dst": cols.get("dst"),
"proto": cols.get("proto"),
"info": cols.get("info"),
"bg": cols.get("bg"),
"fg": cols.get("fg"),
})
merged.append(
{
"ts": _format_ts(sec, usec),
"ts_us": sec * 1_000_000 + usec,
"_source": source_key,
"len": incl_len,
"node_id": entry["node_id"],
"link_id": entry["link_id"],
"marker": entry["marker"],
"frame_number": frame_number,
"src": cols.get("src"),
"dst": cols.get("dst"),
"proto": cols.get("proto"),
"info": cols.get("info"),
"bg": cols.get("bg"),
"fg": cols.get("fg"),
}
)
merged.sort(key=lambda f: (f["ts_us"], f["_source"], f["frame_number"]))
for frame in merged:
del frame["ts_us"]
@@ -702,9 +705,7 @@ async def _merged_frames(project, entries, filter_expr=None, link_id=None):
def _validate_filter(filter_expr):
if filter_expr is not None and len(filter_expr) > FILTER_MAX_LENGTH:
raise ControllerBadRequestError(
f"Display filter too long (max {FILTER_MAX_LENGTH} characters)"
)
raise ControllerBadRequestError(f"Display filter too long (max {FILTER_MAX_LENGTH} characters)")
async def build_timeline(project, tag, filter_expr=None, link_id=None):
@@ -746,13 +747,14 @@ async def query_frames(project, tag, ts, window_ms=100, limit=1000, filter_expr=
start_us = _parse_ts(ts)
end_us = start_us + max(window_ms, 0) * 1000
hits = [f for f in frames if start_us <= _parse_ts(f["ts"]) <= end_us]
return {"frames": hits[:max(limit, 0)]}
return {"frames": hits[: max(limit, 0)]}
# ---------------------------------------------------------------------------
# Frame detail (lazy — one frame per call, via the resident session)
# ---------------------------------------------------------------------------
async def decode_frame(project, tag, ts, node_id, link_id, marker, frame_number=None):
"""
Decode exactly one frame: locate its pcap by source identity, verify the
@@ -770,29 +772,23 @@ async def decode_frame(project, tag, ts, node_id, link_id, marker, frame_number=
entries = gate_tag(project, tag)
entry = next(
(e for e in entries
if e["node_id"] == node_id and e["link_id"] == link_id and e["marker"] == marker),
(e for e in entries if e["node_id"] == node_id and e["link_id"] == link_id and e["marker"] == marker),
None,
)
if entry is None:
raise ControllerNotFoundError(
f"No marker '{marker}' with tag {tag} on link {link_id} captured by {node_id}"
)
raise ControllerNotFoundError(f"No marker '{marker}' with tag {tag} on link {link_id} captured by {node_id}")
pcap = os.path.join(project.markers_directory, f"{node_id}_{link_id}_{marker}.pcap")
if not os.path.exists(pcap):
raise ControllerNotFoundError(f"No capture file for marker '{marker}' (nothing ever matched)")
frames = scan_pcap_frames(pcap)
rebuilt_message = (
f"No frame at ts {ts} in marker '{marker}' (the capture may have been rebuilt)"
)
rebuilt_message = f"No frame at ts {ts} in marker '{marker}' (the capture may have been rebuilt)"
if frame_number is None:
# The ts must be the exact string the timeline returned; find the frame
# it identifies rather than trusting any position hint from the client.
frame_number = next(
(i for i, (sec, usec, _len) in enumerate(frames, start=1)
if _format_ts(sec, usec) == ts),
(i for i, (sec, usec, _len) in enumerate(frames, start=1) if _format_ts(sec, usec) == ts),
None,
)
if frame_number is None:
@@ -820,8 +816,7 @@ async def decode_frame(project, tag, ts, node_id, link_id, marker, frame_number=
tree = _rename_value(result.get("tree", []))
return {
"ts": ts,
"source": {"node_id": node_id, "link_id": link_id, "marker": marker,
"frame_number": frame_number},
"source": {"node_id": node_id, "link_id": link_id, "marker": marker, "frame_number": frame_number},
"field_count": _count_tree_nodes(tree),
"hex": raw_hex,
"tree": tree,
+65 -63
View File
@@ -21,12 +21,7 @@ import copy
import uuid
import os
from .controller_error import (
ControllerError,
ControllerTimeoutError,
ComputeError,
ComputeConflictError
)
from .controller_error import ControllerError, ControllerTimeoutError, ComputeError, ComputeConflictError
from .node_types import BUILTIN_NODE_TYPES
from .ports.port_factory import PortFactory, StandardPortFactory, DynamipsPortFactory
from ..utils.images import images_directories
@@ -540,7 +535,10 @@ class Node:
raise
log.warning(
"Could not provide missing image '%s' for node '%s' [%s]: %s",
last_missing_image, self._name, self._id, upload_error
last_missing_image,
self._name,
self._id,
upload_error,
)
if not res:
if allow_missing_image:
@@ -554,7 +552,9 @@ class Node:
self._missing_images = missing_images
log.warning(
"Node '%s' [%s] is kept in degraded state, missing image(s): %s",
self._name, self._id, ", ".join(m["image"] for m in self._missing_images)
self._name,
self._id,
", ".join(m["image"] for m in self._missing_images),
)
return False
raise e
@@ -571,7 +571,9 @@ class Node:
self._missing_images = self._compute_missing_images(last_missing_image)
log.warning(
"Node '%s' [%s] could not be created, missing image(s): %s",
self._name, self._id, ", ".join(m["image"] for m in self._missing_images)
self._name,
self._id,
", ".join(m["image"] for m in self._missing_images),
)
elif last_missing_error is not None:
# Uploading appeared to succeed, but the compute rejected every
@@ -655,18 +657,13 @@ class Node:
if prop is None:
fallback_basename = os.path.basename(fallback_image)
prop = next(
(
p
for p in mapping
if properties.get(p) and os.path.basename(properties[p]) == fallback_basename
),
(p for p in mapping if properties.get(p) and os.path.basename(properties[p]) == fallback_basename),
None,
)
if prop is None and mapping:
prop = next(iter(mapping))
already_reported = any(
m["image"] == fallback_image or (prop is not None and m["property"] == prop)
for m in missing
m["image"] == fallback_image or (prop is not None and m["property"] == prop) for m in missing
)
if not already_reported:
image_type = mapping.get(prop, self._node_type)
@@ -722,11 +719,7 @@ class Node:
if prop == "properties":
compute_properties = copy.deepcopy(kwargs[prop])
else:
if (
prop == "name"
and self.status == "started"
and self._node_type not in BUILTIN_NODE_TYPES
):
if prop == "name" and self.status == "started" and self._node_type not in BUILTIN_NODE_TYPES:
raise ControllerError("Sorry, it is not possible to rename a node that is already powered on")
setattr(self, prop, kwargs[prop])
@@ -808,7 +801,7 @@ class Node:
"startup_config_content",
"private_config_content",
"startup_script",
"custom_adapters"
"custom_adapters",
]:
if key in self._properties:
del self._properties[key]
@@ -866,10 +859,17 @@ class Node:
# Image path fields for various node types
image_path_fields = {
# Common fields (IOU, Docker, etc.)
"path", "image",
"path",
"image",
# QEMU-specific fields
"hda_disk_image", "hdb_disk_image", "hdc_disk_image", "hdd_disk_image",
"cdrom_image", "bios_image", "initrd", "kernel_image",
"hda_disk_image",
"hdb_disk_image",
"hdc_disk_image",
"hdd_disk_image",
"cdrom_image",
"bios_image",
"initrd",
"kernel_image",
# VMware-specific fields
"vmx_path",
}
@@ -931,7 +931,9 @@ class Node:
if self.node_type == "iou":
license_check = self._project.controller.iou_license.get("license_check", True)
iourc_content = self._project.controller.iou_license.get("iourc_content", None)
await self.post("/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content})
await self.post(
"/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content}
)
else:
await self.post("/start", data=data, timeout=240)
except asyncio.TimeoutError:
@@ -1046,9 +1048,7 @@ class Node:
self.project.emit_notification("log.info", {"message": f"Uploading missing image {img}"})
try:
with open(image, "rb") as f:
await self._compute.post(
f"/{self._node_type}/images/{os.path.basename(img)}", data=f, timeout=None
)
await self._compute.post(f"/{self._node_type}/images/{os.path.basename(img)}", data=f, timeout=None)
except OSError as e:
raise ControllerError(f"Can't upload {image}: {str(e)}")
self.project.emit_notification("log.info", {"message": f"Upload finished for {img}"})
@@ -1094,23 +1094,23 @@ class Node:
# compute to pull it from the Docker repository as a fallback
self.project.emit_notification(
"log.info",
{"message": f"Docker image '{image}' is not on the controller host, "
f"asking compute '{self._compute.name}' to pull it"}
{
"message": f"Docker image '{image}' is not on the controller host, "
f"asking compute '{self._compute.name}' to pull it"
},
)
await self._compute.post("/docker/images/pull", data={"image": image}, timeout=None)
return True
self.project.emit_notification(
"log.info",
{"message": f"Syncing Docker image '{image}' to compute '{self._compute.name}'"}
"log.info", {"message": f"Syncing Docker image '{image}' to compute '{self._compute.name}'"}
)
try:
await self._compute.post("/docker/images/load", data=response.content, timeout=None)
finally:
response.close()
self.project.emit_notification(
"log.info",
{"message": f"Docker image '{image}' has been synced to compute '{self._compute.name}'"}
"log.info", {"message": f"Docker image '{image}' has been synced to compute '{self._compute.name}'"}
)
return True
@@ -1201,7 +1201,9 @@ class Node:
port_name = custom_adapter_settings.get("port_name", port_name)
mac_address = custom_adapter_settings.get("mac_address")
if not mac_address and "mac_address" in self._properties:
mac_address = int_to_macaddress(macaddress_to_int(self._properties["mac_address"]) + adapter_number)
mac_address = int_to_macaddress(
macaddress_to_int(self._properties["mac_address"]) + adapter_number
)
port = PortFactory(port_name, 0, adapter_number, 0, "ethernet", short_name=port_name)
port.mac_address = mac_address
@@ -1245,33 +1247,33 @@ class Node:
"""
topology = {
"compute_id": str(self._compute.id),
"node_id": self._id,
"node_type": self._node_type,
"template_id": self._template_id,
"name": self._name,
"console": self._console,
"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,
"label": self._label,
"x": self._x,
"y": self._y,
"z": self._z,
"locked": self._locked,
"width": self._width,
"height": self._height,
"symbol": self._symbol,
"port_name_format": self._port_name_format,
"port_segment_size": self._port_segment_size,
"first_port_name": self._first_port_name,
"custom_adapters": self._custom_adapters,
"tags": self._tags,
"compute_id": str(self._compute.id),
"node_id": self._id,
"node_type": self._node_type,
"template_id": self._template_id,
"name": self._name,
"console": self._console,
"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,
"label": self._label,
"x": self._x,
"y": self._y,
"z": self._z,
"locked": self._locked,
"width": self._width,
"height": self._height,
"symbol": self._symbol,
"port_name_format": self._port_name_format,
"port_segment_size": self._port_segment_size,
"first_port_name": self._first_port_name,
"custom_adapters": self._custom_adapters,
"tags": self._tags,
}
if topology_dump:
+10 -8
View File
@@ -21,11 +21,13 @@ Shared node type constants used across the controller.
# Node types that are always running (builtin/virtual switch nodes).
# These nodes do not require explicit start/stop and have limited feature support.
BUILTIN_NODE_TYPES = frozenset({
"cloud",
"nat",
"ethernet_switch",
"ethernet_hub",
"frame_relay_switch",
"atm_switch",
})
BUILTIN_NODE_TYPES = frozenset(
{
"cloud",
"nat",
"ethernet_switch",
"ethernet_hub",
"frame_relay_switch",
"atm_switch",
}
)
@@ -18,12 +18,10 @@
Ethernet port for Ethernet link end points.
"""
from .port import Port
class EthernetPort(Port):
"""
Ethernet port.
"""
+1 -1
View File
@@ -83,7 +83,7 @@ class Port:
# If port name format has changed we use the port name as the short name (1.X behavior)
if self._short_name:
return self._short_name
elif self._name and '/' in self._name:
elif self._name and "/" in self._name:
return self._name.replace(self.long_name_type(), self.short_name_type())
elif self._name and self._name.startswith(f"{self.long_name_type()}{self._interface_number}"):
return self.short_name_type() + f"{self._interface_number}"
@@ -66,7 +66,6 @@ class StandardPortFactory:
ethernet_adapters = properties.get("adapters", 1)
for adapter_number in range(adapter_number, ethernet_adapters + adapter_number):
custom_adapter_settings = {}
if custom_adapters:
for custom_adapter in custom_adapters:
+66 -74
View File
@@ -657,9 +657,7 @@ class Project:
return node
@open_required
async def add_node(
self, compute, name, node_id, dump=True, node_type=None, allow_missing_image=False, **kwargs
):
async def add_node(self, compute, name, node_id, dump=True, node_type=None, allow_missing_image=False, **kwargs):
"""
Create a node or return an existing node
@@ -846,10 +844,7 @@ class Project:
try:
await link.update_filters(link_data["filters"])
except ControllerError as e:
log.warning(
"Dropping invalid filters on link %s: %s",
link_data.get("link_id"), e
)
log.warning("Dropping invalid filters on link %s: %s", link_data.get("link_id"), e)
# Restore traffic-insight markers directly into link state (mirrors how
# filters are restored via update_filters). The capture_node_id persisted
# last time is reused for NIO routing; no side resolution is possible here
@@ -865,7 +860,9 @@ class Project:
if not result.get("valid"):
log.warning(
"Dropping marker %s on link %s: invalid BPF (%s)",
name, link_data.get("link_id"), result.get("error")
name,
link_data.get("link_id"),
result.get("error"),
)
continue
link._markers[name] = {
@@ -935,7 +932,9 @@ class Project:
if not result.get("valid"):
log.warning(
"Dropping marker %s on link %s: invalid BPF (%s)",
name, link_data.get("link_id"), result.get("error")
name,
link_data.get("link_id"),
result.get("error"),
)
continue
link._markers[name] = {
@@ -996,7 +995,9 @@ class Project:
link._deferred = True
log.info(
"Project '%s' [%s]: deferring link %s until missing image(s) are resolved",
self._name, self._id, link.id,
self._name,
self._id,
link.id,
)
return None
# Apply project-level marker definitions onto the link's memory
@@ -1151,7 +1152,8 @@ class Project:
self._marker_definitions[name]["paused"] = True
marker_name = f"global-{name}"
affected = [
link for link in self._links.values()
link
for link in self._links.values()
if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name
]
await self._marker_apply_concurrently(
@@ -1170,7 +1172,8 @@ class Project:
self._marker_definitions[name]["paused"] = False
marker_name = f"global-{name}"
affected = [
link for link in self._links.values()
link
for link in self._links.values()
if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name
]
await self._marker_apply_concurrently(
@@ -1199,9 +1202,7 @@ class Project:
"""
result = validate_bpf_syntax(bpf)
if not result.get("valid"):
raise ControllerError(
f"Marker definition '{name}': invalid BPF — {result.get('error', 'unknown error')}"
)
raise ControllerError(f"Marker definition '{name}': invalid BPF — {result.get('error', 'unknown error')}")
def _validate_marker_definition_direction(self, name, direction):
"""
@@ -1223,7 +1224,9 @@ class Project:
"For a capture-node-relative direction on a single link, use a per-link marker."
)
async def create_marker_definition(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None, data_link_type="DLT_EN10MB"):
async def create_marker_definition(
self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None, data_link_type="DLT_EN10MB"
):
"""
Create a project-level marker definition and fan out to every existing
link that has a capable node. Links without a capable node are silently
@@ -1231,26 +1234,32 @@ class Project:
"""
if name in self._marker_definitions:
raise ControllerError(
f"Marker definition '{name}' already exists in this project"
)
raise ControllerError(f"Marker definition '{name}' already exists in this project")
self._validate_marker_definition_bpf(name, bpf)
self._validate_marker_definition_direction(name, direction)
self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration, "data_link_type": data_link_type, "paused": False}
self._marker_definitions[name] = {
"bpf": bpf,
"tag": tag,
"direction": direction,
"color": color,
"highlight_duration": highlight_duration,
"data_link_type": data_link_type,
"paused": False,
}
await self._apply_def_to_all_links(name)
self.dump()
self.emit_notification("project.updated", self.asdict())
async def update_marker_definition(self, name, bpf=None, tag=None, direction=_UNSET, color=None, highlight_duration=None, data_link_type=_UNSET):
async def update_marker_definition(
self, name, bpf=None, tag=None, direction=_UNSET, color=None, highlight_duration=None, data_link_type=_UNSET
):
"""
Update a marker definition and sync every inherited copy on every link.
"""
if name not in self._marker_definitions:
raise ControllerNotFoundError(
f"Marker definition '{name}' not found in this project"
)
raise ControllerNotFoundError(f"Marker definition '{name}' not found in this project")
d = self._marker_definitions[name]
if bpf is not None:
@@ -1270,9 +1279,9 @@ class Project:
# Links that currently carry an inherited copy of this definition.
affected = [
link for link in self._links.values()
if f"global-{name}" in link.markers
and link.markers[f"global-{name}"].get("inherited_from") == name
link
for link in self._links.values()
if f"global-{name}" in link.markers and link.markers[f"global-{name}"].get("inherited_from") == name
]
if data_link_type is not _UNSET:
@@ -1291,9 +1300,15 @@ class Project:
for link in affected:
try:
await link.update_marker(
f"global-{name}", bpf=d["bpf"], tag=d.get("tag"), direction=d.get("direction"),
color=d.get("color"), highlight_duration=d.get("highlight_duration"), inherited=True,
dump=False, memory_only=True
f"global-{name}",
bpf=d["bpf"],
tag=d.get("tag"),
direction=d.get("direction"),
color=d.get("color"),
highlight_duration=d.get("highlight_duration"),
inherited=True,
dump=False,
memory_only=True,
)
except ControllerError as e:
log.warning("Failed to sync marker global-%s on link %s: %s", name, link.id, e)
@@ -1307,16 +1322,14 @@ class Project:
"""
if name not in self._marker_definitions:
raise ControllerNotFoundError(
f"Marker definition '{name}' not found in this project"
)
raise ControllerNotFoundError(f"Marker definition '{name}' not found in this project")
del self._marker_definitions[name]
affected = [
link for link in self._links.values()
if f"global-{name}" in link.markers
and link.markers[f"global-{name}"].get("inherited_from") == name
link
for link in self._links.values()
if f"global-{name}" in link.markers and link.markers[f"global-{name}"].get("inherited_from") == name
]
for link in affected:
try:
@@ -1382,9 +1395,7 @@ class Project:
)
if per_compute:
await asyncio.gather(
*[_dispatch(c, n) for c, n in per_compute.items()]
)
await asyncio.gather(*[_dispatch(c, n) for c, n in per_compute.items()])
async def apply_defs_to_new_link(self, link):
"""
@@ -1403,10 +1414,7 @@ class Project:
# after; per-def dumps here would be N full topology writes.
await link.inherit_marker(def_name, d, dump=False)
except ControllerError as e:
log.warning(
"Marker definition '%s' could not be applied to new link %s: %s",
def_name, link.id, e
)
log.warning("Marker definition '%s' could not be applied to new link %s: %s", def_name, link.id, e)
async def _marker_apply_concurrently(self, links, operation, fail_msg):
"""
@@ -1428,10 +1436,7 @@ class Project:
if not links:
return
_t0 = time.time()
log.info(
"Project '%s' [%s]: fanning out marker operation to %d links...",
self._name, self._id, len(links)
)
log.info("Project '%s' [%s]: fanning out marker operation to %d links...", self._name, self._id, len(links))
sem = asyncio.Semaphore(32)
async def guarded(link):
@@ -1442,10 +1447,7 @@ class Project:
log.warning(fail_msg(link, e))
await asyncio.gather(*(guarded(link) for link in links))
log.info(
"Project '%s' [%s]: marker fan-out done in %.2fs",
self._name, self._id, time.time() - _t0
)
log.info("Project '%s' [%s]: marker fan-out done in %.2fs", self._name, self._id, time.time() - _t0)
@property
def snapshots(self):
@@ -1512,7 +1514,7 @@ class Project:
for snapshot in self._snapshots.values():
self._snapshot_conf.append(snapshot.asdict())
try:
with open(self._snapshot_conf_path, 'w+') as f:
with open(self._snapshot_conf_path, "w+") as f:
json.dump(self._snapshot_conf, f, indent=4)
except OSError as e:
log.error("Cannot write snapshot config '{}': {}".format(self._snapshot_conf_path, e))
@@ -1874,8 +1876,7 @@ class Project:
result = validate_bpf_syntax(bpf)
if not result.get("valid"):
log.warning(
"Dropping marker definition '%s' on load: invalid BPF (%s)",
def_name, result.get("error")
"Dropping marker definition '%s' on load: invalid BPF (%s)", def_name, result.get("error")
)
continue
clean_defs[def_name] = d
@@ -1986,9 +1987,7 @@ class Project:
)
if per_compute:
await asyncio.gather(
*[_dispatch_batch(c, n) for c, n in per_compute.items()]
)
await asyncio.gather(*[_dispatch_batch(c, n) for c, n in per_compute.items()])
# Finalise every link: wire node/port back-references, mark created,
# notify clients, and apply project-level marker definitions.
@@ -2096,12 +2095,7 @@ class Project:
# Do not compress the exported project when duplicating
with aiozipstream.ZipFile(compression=zipfile.ZIP_STORED) as zstream:
await export_project(
zstream,
self,
tmpdir,
keep_compute_ids=True,
include_snapshots=True,
allow_all_nodes=True
zstream, self, tmpdir, keep_compute_ids=True, include_snapshots=True, allow_all_nodes=True
)
# export the project to a temporary location
@@ -2120,7 +2114,7 @@ class Project:
f,
name=name,
reset_mac_addresses=reset_mac_addresses,
keep_compute_ids=True
keep_compute_ids=True,
)
log.info(f"Project '{project.name}' duplicated in {time.time() - begin:.4f} seconds")
@@ -2161,8 +2155,12 @@ class Project:
else:
new_project_path = p_work.joinpath(new_project_id)
# copy dir
await wait_run_in_executor(shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True)
log.info("Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0))
await wait_run_in_executor(
shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True
)
log.info(
"Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0)
)
# Read the topology file using the actual filename (self._filename), not self.name
# This handles the case where a project has been renamed but we need to read the actual file
@@ -2181,7 +2179,7 @@ class Project:
regenerate_topology_ids(topology, new_project_path, reset_mac_addresses)
# dump the updated .gns3 project file
dot_gns3_path = new_project_path.joinpath('{}.gns3'.format(project_name))
dot_gns3_path = new_project_path.joinpath("{}.gns3".format(project_name))
topology["project_id"] = new_project_id
with open(dot_gns3_path, "w+") as f:
json.dump(topology, f, indent=4, sort_keys=True)
@@ -2359,13 +2357,7 @@ class Project:
data["z"] = z
data["locked"] = False # duplicated node must not be locked
new_node_uuid = str(uuid.uuid4())
new_node = await self.add_node(
node.compute,
node.name,
new_node_uuid,
node_type=node_type,
**data
)
new_node = await self.add_node(node.compute, node.name, new_node_uuid, node_type=node_type, **data)
try:
await node.post("/duplicate", timeout=None, data={"destination_node_id": new_node_uuid})
except ControllerNotFoundError:
+11 -4
View File
@@ -42,6 +42,7 @@ FILENAME_DATETIME_FORMAT = "%d%m%y_%H%M%S"
# Used to create a description of the snapshot with a human-readable date and time
DESCRIPTION_DATETIME_FORMAT = "%Y-%m-%d at %H:%M:%S"
class Snapshot:
"""
A snapshot object
@@ -69,10 +70,16 @@ class Snapshot:
else:
self._name = filename.rsplit("_", 2)[0]
datestring = filename.replace(self._name + "_", "").split(".")[0]
self._created_at = int(datetime.strptime(datestring, FILENAME_DATETIME_FORMAT).replace(tzinfo=timezone.utc).timestamp())
self._created_at = int(
datetime.strptime(datestring, FILENAME_DATETIME_FORMAT).replace(tzinfo=timezone.utc).timestamp()
)
if not description:
date = datetime.fromtimestamp(self._created_at, tz=timezone.utc).replace(tzinfo=None).strftime(DESCRIPTION_DATETIME_FORMAT)
date = (
datetime.fromtimestamp(self._created_at, tz=timezone.utc)
.replace(tzinfo=None)
.strftime(DESCRIPTION_DATETIME_FORMAT)
)
description = "Snapshot '{}' taken on {}".format(self._name, date)
self._description = description
@@ -149,7 +156,7 @@ class Snapshot:
location=self._project.path,
auto_start=self._project.auto_start,
auto_open=self._project.auto_open,
auto_close=self._project.auto_close
auto_close=self._project.auto_close,
)
log.info("Snapshot '{}' restored in {:.4f} seconds".format(self.name, time.time() - begin))
except (OSError, PermissionError) as e:
@@ -165,5 +172,5 @@ class Snapshot:
"created_at": self._created_at,
"description": self._description,
"filename": self._filename,
"project_id": self._project.id
"project_id": self._project.id,
}
+7 -2
View File
@@ -35,7 +35,12 @@ from .drawing import Drawing
from .node import Node
from .link import Link
from gns3server.utils.hostname import is_ios_hostname_valid, is_rfc1123_hostname_valid, to_rfc1123_hostname, to_ios_hostname
from gns3server.utils.hostname import (
is_ios_hostname_valid,
is_rfc1123_hostname_valid,
to_rfc1123_hostname,
to_ios_hostname,
)
from gns3server.schemas.controller.topology import Topology
from gns3server.schemas.compute.dynamips_nodes import DynamipsCreate
@@ -227,7 +232,7 @@ def _convert_2_2_0(topo, topo_path):
new_name = to_rfc1123_hostname(node["name"])
log.info(f"Convert node name {node['name']} to {new_name} (RFC1123)")
node["name"] = new_name
if node["node_type"] in ("dynamips", "iou") and not is_ios_hostname_valid(node["name"] ):
if node["node_type"] in ("dynamips", "iou") and not is_ios_hostname_valid(node["name"]):
new_name = to_ios_hostname(node["name"])
log.info(f"Convert node name {node['name']} to {new_name} (IOS)")
node["name"] = new_name
+59 -36
View File
@@ -29,9 +29,17 @@ from gns3server.utils.packet_filter_validation import validate_bpf_syntax, Filte
# (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", "ethernet_switch",
})
_MARKER_CAPABLE_TYPES = frozenset(
{
"vpcs",
"qemu",
"docker",
"iou",
"dynamips",
"cloud",
"ethernet_switch",
}
)
log = logging.getLogger(__name__)
@@ -58,7 +66,7 @@ class UDPLink(Link):
def deferred(self):
"""Whether NIO creation is waiting for missing node images."""
return self._deferred
def _get_node_filters(self, node1, node2):
"""
Determine which node gets the active filters applied.
@@ -81,10 +89,14 @@ class UDPLink(Link):
without an NIO rebuild.
"""
return {
name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id,
"direction": m.get("direction"),
"data_link_type": m.get("data_link_type", "DLT_EN10MB"),
"enabled": m.get("enabled", True)}
name: {
"bpf": m["bpf"],
"tag": m.get("tag"),
"link_id": self._id,
"direction": m.get("direction"),
"data_link_type": m.get("data_link_type", "DLT_EN10MB"),
"enabled": m.get("enabled", True),
}
for name, m in self._markers.items()
if m.get("capture_node_id") == node.id
}
@@ -183,33 +195,24 @@ class UDPLink(Link):
tuples returned by :meth:`_prepare`.
"""
(node1, adapter_number1, port_number1, nio_data1), \
(node2, adapter_number2, port_number2, nio_data2) = entries
(node1, adapter_number1, port_number1, nio_data1), (node2, adapter_number2, port_number2, nio_data2) = entries
# The two ends are independent once the ports and peer addresses are
# known — each node talks to its own compute/uBridge with no shared
# lock between them — so the two POSTs overlap. If either fails, roll
# back whichever side succeeded before re-raising the first error.
results = await asyncio.gather(
node1.post(
f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=nio_data1, timeout=120
),
node2.post(
f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=nio_data2, timeout=120
),
node1.post(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=nio_data1, timeout=120),
node2.post(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=nio_data2, timeout=120),
return_exceptions=True,
)
errors = [result for result in results if isinstance(result, Exception)]
if errors:
cleanup = []
if not isinstance(results[0], Exception):
cleanup.append(
node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120)
)
cleanup.append(node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120))
if not isinstance(results[1], Exception):
cleanup.append(
node2.delete(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", timeout=120)
)
cleanup.append(node2.delete(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", timeout=120))
if cleanup:
await asyncio.gather(*cleanup, return_exceptions=True)
raise errors[0]
@@ -320,7 +323,9 @@ class UDPLink(Link):
),
data=data,
)
await super().start_capture(data_link_type=data_link_type, capture_file_name=capture_file_name, wireshark=wireshark, jwt_token=jwt_token)
await super().start_capture(
data_link_type=data_link_type, capture_file_name=capture_file_name, wireshark=wireshark, jwt_token=jwt_token
)
async def stop_capture(self):
"""
@@ -378,10 +383,7 @@ class UDPLink(Link):
# Prefer started.
for node in self._nodes:
if (
node["node"].node_type in _MARKER_CAPABLE_TYPES
and node["node"].status == "started"
):
if node["node"].node_type in _MARKER_CAPABLE_TYPES and node["node"].status == "started":
return node
# Accept stopped but capable (marker rides NIO, applied at start).
@@ -389,10 +391,7 @@ class UDPLink(Link):
if node["node"].node_type in _MARKER_CAPABLE_TYPES:
return node
raise ControllerError(
"Cannot add marker because no device on this link supports "
"traffic insight"
)
raise ControllerError("Cannot add marker because no device on this link supports traffic insight")
def _node_by_id(self, node_id):
"""
@@ -415,9 +414,7 @@ class UDPLink(Link):
f"marker — no uBridge bridge to attach the filter to"
)
return node
raise ControllerNotFoundError(
f"Node {node_id} is not an endpoint of link {self._id}"
)
raise ControllerNotFoundError(f"Node {node_id} is not an endpoint of link {self._id}")
async def node_updated(self, node):
"""
@@ -431,7 +428,21 @@ class UDPLink(Link):
# explicitly deletes a marker via the REST API, and a marker is torn
# down automatically only when its link is deleted.
async def start_marker(self, name, bpf, tag=None, direction=None, data_link_type="DLT_EN10MB", capture_node_id=None, color=None, highlight_duration=None, enabled=True, inherited_from=None, dump=True, memory_only=False):
async def start_marker(
self,
name,
bpf,
tag=None,
direction=None,
data_link_type="DLT_EN10MB",
capture_node_id=None,
color=None,
highlight_duration=None,
enabled=True,
inherited_from=None,
dump=True,
memory_only=False,
):
"""
Attach a traffic-insight marker to this link.
@@ -571,7 +582,19 @@ class UDPLink(Link):
if dump:
self._project.dump()
async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET, color=None, highlight_duration=None, inherited=False, dump=True, memory_only=False):
async def update_marker(
self,
name,
bpf=None,
tag=None,
enabled=None,
direction=_UNSET,
color=None,
highlight_duration=None,
inherited=False,
dump=True,
memory_only=False,
):
"""
Update an existing marker's fields and push to uBridge fine-grained — no
full NIO reapply, so sibling markers' pcaps stay open. bpf/tag/direction