#!/usr/bin/env python
#
# Copyright (C) 2016 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 asyncio
import html
import copy
import uuid
import os
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
from ..utils.application_id import is_iol_runner_environment
from ..utils import macaddress_to_int, int_to_macaddress
from ..config import Config
import logging
log = logging.getLogger(__name__)
# Image properties of a node, mapped by node type. The value is the image
# "type" used by the image manager (see utils.images). Docker images are not
# registered in the image database, they are matched by tag instead.
IMAGE_PROPERTIES_BY_NODE_TYPE = {
"qemu": {
"hda_disk_image": "qemu",
"hdb_disk_image": "qemu",
"hdc_disk_image": "qemu",
"hdd_disk_image": "qemu",
"cdrom_image": "qemu",
"initrd": "qemu",
"kernel_image": "qemu",
"bios_image": "qemu",
},
"dynamips": {"image": "ios"},
"iou": {"path": "iou"},
"docker": {"image": "docker"},
}
def _extract_iol_startup_config_knob(environment):
"""
Split the GNS3_IOL_STARTUP_CONFIG knob out of a Docker environment
(iol-runner images): the value is a config file name, resolved by the
controller in its configs directory.
:returns: (filename, environment without the knob line); filename is None
when the knob is absent or empty.
"""
if not environment or "GNS3_IOL_STARTUP_CONFIG=" not in environment:
return None, environment
filename = None
kept = []
for line in environment.splitlines():
stripped = line.strip().rstrip(",")
if stripped.startswith("GNS3_IOL_STARTUP_CONFIG="):
if filename is None:
filename = stripped.split("=", 1)[1].strip()
else:
kept.append(line)
return filename or None, "\n".join(kept)
class Node:
# These properties are used only on controller and are not forwarded to the compute
CONTROLLER_ONLY_PROPERTIES = [
"x",
"y",
"z",
"locked",
"width",
"height",
"symbol",
"label",
"console_host",
"port_name_format",
"first_port_name",
"port_segment_size",
"ports",
"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):
"""
:param project: Project of the node
:param compute: Compute where the server will run
:param name: Node name
:param node_id: UUID of the node (integer)
:param node_type: Type of emulator
:param template_id: Template ID used to create this node
:param kwargs: Node properties
"""
assert node_type
if node_id is None:
self._id = str(uuid.uuid4())
else:
self._id = node_id
self._project = project
self._compute = compute
self._node_type = node_type
self._label = None
self._links = set()
self._name = None
self.name = name
self._console = None
self._console_type = None
self._aux = None
self._aux_type = None
self._properties = None
self._command_line = None
self._node_directory = None
self._status = "stopped"
self._template_id = template_id
self._x = 0
self._y = 0
self._z = 1 # default z value is 1
self._locked = False
self._tags = []
self._ports = None
self._symbol = None
self._custom_adapters = []
if node_type == "iou":
self._port_name_format = "Ethernet{segment0}/{port0}"
self._port_by_adapter = 4
self.port_segment_size = 4
else:
self._port_name_format = "Ethernet{0}"
self._port_by_adapter = 1
self._port_segment_size = 0
self._first_port_name = None
self._console_auto_start = False
self._netmiko_device_type = None
self._default_username = None
self._default_password = None
# Set when the node cannot be created on its compute because one or
# more of its images are missing. The node is kept on the controller
# (topology and links are preserved) until the user provides a
# compatible replacement image.
self._missing_images = []
# This properties will be recomputed
ignore_properties = ("width", "height", "hover_symbol")
self.properties = kwargs.pop("properties", {})
# Update node properties with additional elements
for prop in kwargs:
if prop and prop not in ignore_properties:
if hasattr(self, prop):
try:
setattr(self, prop, kwargs[prop])
except AttributeError as e:
log.critical(f"Cannot set attribute '{prop}'")
raise e
else:
if prop not in self.CONTROLLER_ONLY_PROPERTIES and kwargs[prop] is not None and kwargs[prop] != "":
self.properties[prop] = kwargs[prop]
if self._symbol is None:
# compatibility with old node templates
if "default_symbol" in self.properties:
default_symbol = self.properties.pop("default_symbol")
if default_symbol.endswith("normal.svg"):
self.symbol = default_symbol[:-11] + ".svg"
else:
self.symbol = default_symbol
else:
self.symbol = ":/symbols/computer.svg"
def is_always_running(self):
"""
:returns: Boolean True if the node is always running
like ethernet switch
"""
return self.node_type not in ("qemu", "docker", "dynamips", "vpcs", "vmware", "virtualbox", "iou")
@property
def id(self):
return self._id
@property
def status(self):
return self._status
@property
def missing_image(self):
"""
:returns: True if at least one image required by this node is missing
and the node could therefore not be created on its compute.
"""
return len(self._missing_images) > 0
@property
def missing_images(self):
return self._missing_images
@property
def template_id(self):
return self._template_id
@property
def name(self):
return self._name
@name.setter
def name(self, new_name):
self._name = self._project.update_node_name(self, new_name)
# The text in label need to be always the node name
if self.label and self._label["text"] != self._name:
self._label["text"] = self._name
self._label["x"] = None # Center text
@property
def node_type(self):
return self._node_type
@property
def console(self):
return self._console
@console.setter
def console(self, val):
self._console = val
@property
def aux(self):
return self._aux
@aux.setter
def aux(self, val):
self._aux = val
@property
def console_type(self):
return self._console_type
@console_type.setter
def console_type(self, val):
self._console_type = val
@property
def aux_type(self):
return self._aux_type
@aux_type.setter
def aux_type(self, val):
self._aux_type = val
@property
def console_auto_start(self):
return self._console_auto_start
@console_auto_start.setter
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 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
@properties.setter
def properties(self, val):
self._properties = val
@property
def tags(self):
return self._tags
@tags.setter
def tags(self, val):
self._tags = val
def _base_config_file_content(self, path):
if not os.path.isabs(path):
path = os.path.join(self.project.controller.configs_path(), path)
try:
with open(path, encoding="utf-8", errors="ignore") as f:
return f.read()
except OSError:
return None
@property
def project(self):
return self._project
@property
def compute(self):
return self._compute
@property
def host(self):
"""
:returns: Domain or ip for console connection
"""
return self._compute.host
@property
def ports(self):
if self._ports is None:
self._list_ports()
return self._ports
@property
def x(self):
return self._x
@x.setter
def x(self, val):
self._x = val
@property
def y(self):
return self._y
@y.setter
def y(self, val):
self._y = val
@property
def z(self):
return self._z
@z.setter
def z(self, val):
self._z = val
@property
def locked(self):
return self._locked
@locked.setter
def locked(self, val):
self._locked = val
@property
def width(self):
return self._width
@property
def height(self):
return self._height
@property
def symbol(self):
return self._symbol
@symbol.setter
def symbol(self, val):
if val is None:
val = ":/symbols/computer.svg"
try:
if not val.startswith(":") and os.path.isabs(val):
default_symbol_directory = Config.instance().settings.Server.symbols_path
if os.path.commonprefix([default_symbol_directory, val]) != default_symbol_directory:
val = os.path.basename(val)
except OSError:
pass
self._symbol = val
try:
self._width, self._height, filetype = self._project.controller.symbols.get_size(val)
except (ValueError, OSError) as e:
log.error(f"Could not set symbol: {e}")
# If symbol is invalid we replace it by the default
self.symbol = ":/symbols/computer.svg"
if self._label is None:
# Apply to label user style or default
try:
style = None # FIXME: allow configuration of default label font & color on controller
# style = qt_font_to_style(self._project.controller.settings["GraphicsView"]["default_label_font"],
# self._project.controller.settings["GraphicsView"]["default_label_color"])
except KeyError:
style = "font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;"
self._label = {
"y": round(self._height / 2 + 10) * -1,
"text": html.escape(self._name),
"style": style, # None: means the client will apply its default style
"x": None, # None: means the client should center it
"rotation": 0,
}
@property
def label(self):
return self._label
@label.setter
def label(self, val):
# The text in label need to be always the node name
val["text"] = self._name
self._label = val
@property
def port_name_format(self):
return self._port_name_format
@port_name_format.setter
def port_name_format(self, val):
self._port_name_format = val
@property
def port_segment_size(self):
return self._port_segment_size
@port_segment_size.setter
def port_segment_size(self, val):
self._port_segment_size = val
@property
def first_port_name(self):
return self._first_port_name
@first_port_name.setter
def first_port_name(self, val):
self._first_port_name = val
@property
def custom_adapters(self):
return self._custom_adapters
@custom_adapters.setter
def custom_adapters(self, val):
self._custom_adapters = val
def add_link(self, link):
"""
A link is connected to the node
"""
self._links.add(link)
def remove_link(self, link):
"""
A link is connected to the node
"""
self._links.remove(link)
@property
def links(self):
return self._links
async def create(self, allow_missing_image=False, disk_images_to_reset=None):
"""
Create the node on the compute
:param allow_missing_image: when True, if an image is missing and
cannot be uploaded/synced, the node is not created on the compute
but is kept on the controller and flagged with the missing image(s)
instead of raising.
:param disk_images_to_reset: QEMU disk properties whose linked-clone
overlays must be recreated after replacing a missing base image.
"""
data = self._node_data()
data["node_id"] = self._id
if self._node_type == "qemu" and disk_images_to_reset:
data["disk_images_to_reset"] = sorted(disk_images_to_reset)
if self._node_type == "docker":
timeout = None
await self._add_docker_image_digest(data)
else:
timeout = 1200
trial = 0
last_missing_image = None
last_missing_error = None
while trial != 6:
try:
response = await self._compute.post(
f"/projects/{self._project.id}/{self._node_type}/nodes", data=data, timeout=timeout
)
except ComputeConflictError as e:
response = e.response()
if response.get("exception") == "ImageMissingError":
last_missing_error = e
last_missing_image = response.get("image")
res = False
if last_missing_image:
try:
res = await self._upload_missing_image(self._node_type, last_missing_image)
except ControllerError as upload_error:
# For Docker the image is pulled from a registry: a
# failed pull (unknown tag, no network, private image)
# must not abort the whole project. Treat it as a
# missing image so the user can pick another one.
if not allow_missing_image:
raise
log.warning(
"Could not provide missing image '%s' for node '%s' [%s]: %s",
last_missing_image, self._name, self._id, upload_error
)
if not res:
if allow_missing_image:
missing_images = self._compute_missing_images(last_missing_image)
# A degraded node must always identify at least one
# missing image. Otherwise the controller would keep
# a node that was never created on the compute while
# exposing it as healthy.
if not missing_images:
raise e
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)
)
return False
raise e
else:
raise e
else:
await self.parse_node_response(response.json)
self._missing_images = []
return True
trial += 1
if allow_missing_image:
# The image was repeatedly reported missing (e.g. the upload/sync
# seemed to succeed but did not). Keep the node in degraded state.
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)
)
elif last_missing_error is not None:
# Uploading appeared to succeed, but the compute rejected every
# retry. Do not let the caller register a controller-only node as
# healthy.
raise last_missing_error
return False
def _find_local_image(self, image_type, image):
"""Return an image path contained in an allowed image directory."""
if not image or image_type == "docker":
return None
try:
directories = images_directories(image_type)
except NotImplementedError:
return None
requested = os.path.normpath(image)
for directory in directories:
root = os.path.realpath(directory)
candidate = os.path.realpath(os.path.join(root, requested))
try:
contained = os.path.commonpath((root, candidate)) == root
except ValueError:
contained = False
if contained and os.path.isfile(candidate):
return candidate
# Image API records expose a basename separately from their
# controller-local absolute path. Search subfolders when that
# portable basename is used by a node or remote compute.
if os.path.basename(requested) == requested:
for current_root, _, filenames in os.walk(root):
if requested in filenames:
nested_candidate = os.path.realpath(os.path.join(current_root, requested))
try:
nested_contained = os.path.commonpath((root, nested_candidate)) == root
except ValueError:
nested_contained = False
if nested_contained and os.path.isfile(nested_candidate):
return nested_candidate
return None
def _image_available(self, image_type, image):
"""
Check whether an image is available on the controller (and can
therefore be uploaded to the compute when needed).
:param image_type: image type (e.g. "qemu", "ios", "iou")
:param image: image filename or path
"""
if not image:
return True
if image_type == "docker":
# Docker images are not stored on the controller filesystem
return True
return self._find_local_image(image_type, image) is not None
def _compute_missing_images(self, fallback_image=None):
"""
Build the list of images referenced by this node that are not
available on the controller.
:param fallback_image: image reported as missing by the compute, always
included (used for Docker images and images not directly mapped to
a node property).
"""
missing = []
mapping = IMAGE_PROPERTIES_BY_NODE_TYPE.get(self._node_type, {})
properties = self._properties or {}
for prop, image_type in mapping.items():
value = properties.get(prop)
if not value:
continue
if not self._image_available(image_type, value):
missing.append({"property": prop, "image": value, "image_type": image_type})
if fallback_image:
prop = next((p for p in mapping if properties.get(p) == fallback_image), None)
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
),
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
)
if not already_reported:
image_type = mapping.get(prop, self._node_type)
missing.append({"property": prop, "image": fallback_image, "image_type": image_type})
return missing
def _remove_replaced_image_metadata(self, old_properties, new_properties):
"""Remove metadata that still refers to an image being replaced."""
replaced_properties = {
missing.get("property")
for missing in self._missing_images
if missing.get("property")
and old_properties.get(missing["property"]) != new_properties.get(missing["property"])
}
if self._node_type == "qemu":
for prop in replaced_properties:
new_properties.pop(f"{prop}_md5sum", None)
if prop in {"hda_disk_image", "hdb_disk_image", "hdc_disk_image", "hdd_disk_image"}:
# The QEMU create route treats a backing-file value as the
# authoritative base image. Keeping the old value would
# silently override the replacement selected by the user.
new_properties.pop(f"{prop}_backing_file", None)
elif self._node_type == "dynamips" and "image" in replaced_properties:
new_properties.pop("image_md5sum", None)
elif self._node_type == "iou" and "path" in replaced_properties:
new_properties.pop("md5sum", None)
return replaced_properties
async def update(self, **kwargs):
"""
Update the node on the compute
:param kwargs: Node properties
"""
# When updating properties used only on controller we don't need to call the compute
update_compute = False
old_json = self.asdict()
old_name = self._name
old_properties = copy.deepcopy(self._properties)
old_custom_adapters = copy.deepcopy(self._custom_adapters)
old_missing_images = copy.deepcopy(self._missing_images)
compute_properties = None
# Update node properties with additional elements
for prop in kwargs:
if getattr(self, prop) != kwargs[prop]:
if prop not in self.CONTROLLER_ONLY_PROPERTIES:
update_compute = True
# We update properties on the compute and wait for the answer from the compute 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
):
raise ControllerError("Sorry, it is not possible to rename a node that is already powered on")
setattr(self, prop, kwargs[prop])
if compute_properties and "custom_adapters" in compute_properties:
# we need to check custom adapters to update the custom port names
self.custom_adapters = compute_properties["custom_adapters"]
if self.missing_image and compute_properties is not None:
# The node was never created on the compute (missing image). Apply
# the new properties locally so create() can use them.
replaced_image_properties = self._remove_replaced_image_metadata(old_properties, compute_properties)
self._properties = compute_properties
else:
replaced_image_properties = set()
self._list_ports()
if update_compute:
if self.missing_image:
# Try to create the node on the compute now that an image may
# have been provided. If it is still missing the node simply
# remains in its degraded state.
try:
created = await self.create(
allow_missing_image=True,
disk_images_to_reset=replaced_image_properties,
)
except Exception:
# create() can reject the replacement for reasons other
# than a missing image. Keep the controller state aligned
# with the node that is still absent from the compute.
self._properties = old_properties
self._custom_adapters = old_custom_adapters
self._missing_images = old_missing_images
self._list_ports()
raise
if created:
await self.project.restore_deferred_links(self)
self.project.emit_notification("node.updated", self.asdict())
self.project.dump()
return
data = self._node_data(properties=compute_properties)
try:
response = await self.put(None, data=data)
except ComputeConflictError:
if old_name != self.name:
# special case when the new name is already updated on controller but refused by the compute
self.name = old_name
raise
await self.parse_node_response(response.json)
elif old_json != self.asdict():
# We send notif only if object has changed
self.project.emit_notification("node.updated", self.asdict())
self.project.dump()
async def parse_node_response(self, response):
"""
Update the object with the remote node object
"""
for key, value in response.items():
if key == "console":
self._console = value
elif key == "aux":
self._aux = value
elif key == "node_directory":
self._node_directory = value
elif key == "command_line":
self._command_line = value
elif key == "status":
self._status = value
elif key == "console_type":
self._console_type = value
elif key == "aux_type":
self._aux_type = value
elif key == "name":
self.name = value
elif key in [
"node_id",
"project_id",
"console_host",
"startup_config_content",
"private_config_content",
"startup_script",
"custom_adapters"
]:
if key in self._properties:
del self._properties[key]
else:
self._properties[key] = value
self._list_ports()
for link in self._links:
await link.node_updated(self)
def _node_data(self, properties=None):
"""
Prepare node data to send to the remote controller
:param properties: If properties is None use actual property otherwise use the parameter
"""
if properties:
data = copy.copy(properties)
else:
data = copy.copy(self._properties)
# We replace the startup script name by the content of the file
mapping = {
"base_script_file": "startup_script",
"startup_config": "startup_config_content",
"private_config": "private_config_content",
}
for k, v in mapping.items():
if k in list(self._properties.keys()):
data[v] = self._base_config_file_content(self._properties[k])
del data[k]
del self._properties[k] # We send the file only one time
# IOL runner (Docker) startup-config: translate the config file
# referenced by the GNS3_IOL_STARTUP_CONFIG environment knob into
# content, like the mappings above. Sent only once — afterwards
# the node's configuration lives in its NVRAM on the compute (a
# reloaded node is recreated without the knob and boots from the
# persistent NVRAM, preserving `write memory`).
if self._node_type == "docker":
filename, environment = _extract_iol_startup_config_knob(self._properties.get("environment"))
if filename:
content = self._base_config_file_content(filename)
if content:
data["startup_config_content"] = content
data["environment"] = environment
self._properties["environment"] = environment
else:
log.warning(
f"Cannot load IOL startup-config file '{filename}' for node '{self._name}': file not found"
)
data["name"] = self._name
# For remote computes, convert absolute image paths to relative paths
# The remote compute will search for the image in its own configured directories
if self._compute.id != "local":
# Image path fields for various node types
image_path_fields = {
# Common fields (IOU, Docker, etc.)
"path", "image",
# QEMU-specific fields
"hda_disk_image", "hdb_disk_image", "hdc_disk_image", "hdd_disk_image",
"cdrom_image", "bios_image", "initrd", "kernel_image",
# VMware-specific fields
"vmx_path",
}
for field in image_path_fields:
if field in data and data[field] and os.path.isabs(data[field]):
data[field] = os.path.basename(data[field])
if self._console:
# console is optional for builtin nodes
data["console"] = self._console
if self._console_type and self._node_type not in (BUILTIN_NODE_TYPES - {"ethernet_switch"}):
# console_type is not supported by all builtin nodes excepting Ethernet switch
data["console_type"] = self._console_type
if self._aux:
# aux is optional for builtin nodes
data["aux"] = self._aux
if self._aux_type and self._node_type not in BUILTIN_NODE_TYPES:
# aux_type is not supported by all builtin nodes
data["aux_type"] = self._aux_type
if self.custom_adapters:
data["custom_adapters"] = self.custom_adapters
# None properties are not be sent because it can mean the emulator doesn't support it
for key, value in list(data.items()):
if value is None or value == {} or key in self.CONTROLLER_ONLY_PROPERTIES:
del data[key]
return data
async def destroy(self):
await self.delete()
async def start(self, data=None):
"""
Start a node
"""
if self.missing_image:
images = ", ".join(m["image"] for m in self._missing_images)
raise ControllerError(
f"Cannot start node '{self._name}': the required image(s) ({images}) "
f"are missing. Please provide a compatible image."
)
deferred_links = [link for link in self.links if link.deferred]
if deferred_links:
await self.project.restore_deferred_links(self)
failed_links = [
link
for link in deferred_links
if link.deferred and not any(endpoint["node"].missing_image for endpoint in link._nodes)
]
if failed_links:
raise ControllerError(
f"Cannot start node '{self._name}': {len(failed_links)} deferred link(s) "
"could not be restored. Please try again."
)
try:
# For IOU: we need to send the licence everytime we start a 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})
else:
await self.post("/start", data=data, timeout=240)
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when starting {self._name}")
async def stop(self):
"""
Stop a node
"""
if self.missing_image:
return
try:
await self.post("/stop", timeout=240, dont_connect=True)
# We don't care if a node is down at this step
except (ComputeError, ControllerError):
pass
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when stopping {self._name}")
async def suspend(self):
"""
Suspend a node
"""
try:
await self.post("/suspend", timeout=240)
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when reloading {self._name}")
async def reload(self):
"""
Suspend a node
"""
try:
await self.post("/reload", timeout=240)
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when reloading {self._name}")
async def reset_console(self):
"""
Reset the console
"""
if self._console and self._console_type in ("telnet", "ssh"):
try:
await self.post("/console/reset", timeout=240)
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when reset console {self._name}")
async def get(self, path="", **kwargs):
"""
HTTP get on the node
"""
return await self._compute.get(
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}{path}", **kwargs
)
async def post(self, path, data=None, **kwargs):
"""
HTTP post on the node
"""
if data:
return await self._compute.post(
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}{path}", data=data, **kwargs
)
else:
return await self._compute.post(
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}{path}", **kwargs
)
async def put(self, path, data=None, **kwargs):
"""
HTTP put on the node
"""
if path is None:
path = f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}"
else:
path = f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}{path}"
if data:
return await self._compute.put(path, data=data, **kwargs)
else:
return await self._compute.put(path, **kwargs)
async def delete(self, path=None, **kwargs):
"""
HTTP post on the node
"""
if self.missing_image and path is None:
# The node was never created on the compute (missing image). Any
# partial object there is cleaned up when the project is closed.
return None
if path is None:
return await self._compute.delete(
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}", **kwargs
)
else:
return await self._compute.delete(
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}{path}", **kwargs
)
async def _upload_missing_image(self, type, img):
"""
Search an image on local computer and upload it to remote compute
if the image exists
"""
if self._node_type == "docker":
return await self._sync_missing_docker_image(img)
image = self._find_local_image(type, img)
if image is None:
return False
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
)
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}"})
return True
async def _add_docker_image_digest(self, data):
"""
Pin the Docker image of a node to the image id found on the Docker daemon
of the controller host, so that a compute holding a different image under
the same tag (e.g. a moved :latest) gets it re-synced instead of silently
reusing the stale copy. Not set when the image is not on the controller host.
"""
from gns3server.compute.docker import Docker
from gns3server.compute.docker.docker_error import DockerError
image = data.get("image")
if not image:
return
try:
image_info = await Docker.instance().query("GET", f"images/{image}/json")
except DockerError:
# not available on the controller host: nothing to pin against
return
image_id = image_info.get("Id")
if image_id:
data["image_digest"] = image_id
async def _sync_missing_docker_image(self, image):
"""
Export a Docker image from the Docker daemon on the controller host and
stream it to the remote compute, or ask the compute to pull it when the
image is not available locally
"""
from gns3server.compute.docker import Docker
from gns3server.compute.docker.docker_error import DockerError
try:
response = await Docker.instance().http_query("GET", f"images/{image}/get", timeout=None)
except DockerError:
# the image is not on the Docker daemon of the controller host: ask the
# 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"}
)
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}'"}
)
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}'"}
)
return True
async def dynamips_auto_idlepc(self):
"""
Compute the idle PC for a dynamips node
"""
return (
await self._compute.get(
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}/auto_idlepc", timeout=240
)
).json
async def dynamips_idlepc_proposals(self):
"""
Compute a list of potential idle PC
"""
return (
await self._compute.get(
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}/idlepc_proposals", timeout=240
)
).json
def get_port(self, adapter_number, port_number):
"""
Return the port for this adapter_number and port_number
or returns None if the port is not found
"""
for port in self.ports:
if port.adapter_number == adapter_number and port.port_number == port_number:
return port
return None
def _list_ports(self):
"""
Generate the list of port display in the client
if the compute has sent a list we return it (use by
node where you can not personalize the port naming).
"""
self._ports = []
# Some special cases
if self._node_type == "atm_switch":
atm_port = set()
# Mapping is like {"1:0:100": "10:0:200"}
for source, dest in self._properties["mappings"].items():
atm_port.add(int(source.split(":")[0]))
atm_port.add(int(dest.split(":")[0]))
atm_port = sorted(atm_port)
for port in atm_port:
self._ports.append(PortFactory(f"{port}", 0, 0, port, "atm"))
return
elif self._node_type == "frame_relay_switch":
frame_relay_port = set()
# Mapping is like {"1:101": "10:202"}
for source, dest in self._properties["mappings"].items():
frame_relay_port.add(int(source.split(":")[0]))
frame_relay_port.add(int(dest.split(":")[0]))
frame_relay_port = sorted(frame_relay_port)
for port in frame_relay_port:
self._ports.append(PortFactory(f"{port}", 0, 0, port, "frame_relay"))
return
elif self._node_type == "dynamips":
self._ports = DynamipsPortFactory(self._properties)
return
elif self._node_type == "docker":
if is_iol_runner_environment(self._properties.get("environment")):
# IOL adapters are 4-port units (the IOU model): ports are
# Ethernet0/0-3, Ethernet1/0-3, … addressed as
# (adapter_number, port_number 0-3).
self._ports = StandardPortFactory(
self._properties,
4,
self._first_port_name,
"Ethernet{segment0}/{port0}",
4,
self.custom_adapters,
)
else:
for adapter_number in range(0, self._properties["adapters"]):
custom_adapter_settings = {}
if self.custom_adapters:
for custom_adapter in self.custom_adapters:
if custom_adapter["adapter_number"] == adapter_number:
custom_adapter_settings = custom_adapter
break
port_name = f"eth{adapter_number}"
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)
port = PortFactory(port_name, 0, adapter_number, 0, "ethernet", short_name=port_name)
port.mac_address = mac_address
self._ports.append(port)
elif self._node_type in ("ethernet_switch", "ethernet_hub"):
# Basic node we don't want to have adapter number
port_number = 0
for port in self._properties.get("ports_mapping", []):
self._ports.append(
PortFactory(port["name"], 0, 0, port_number, "ethernet", short_name=f"e{port_number}")
)
port_number += 1
elif self._node_type in ("vpcs"):
self._ports.append(PortFactory("Ethernet0", 0, 0, 0, "ethernet", short_name="e0"))
elif self._node_type in ("cloud", "nat"):
port_number = 0
for port in self._properties.get("ports_mapping", []):
self._ports.append(PortFactory(port["name"], 0, 0, port_number, "ethernet", short_name=port["name"]))
port_number += 1
else:
self._ports = StandardPortFactory(
self._properties,
self._port_by_adapter,
self._first_port_name,
self._port_name_format,
self._port_segment_size,
self._custom_adapters,
)
def __repr__(self):
return f""
def __eq__(self, other):
if not isinstance(other, Node):
return False
return self.id == other.id and other.project.id == self.project.id
def asdict(self, topology_dump=False):
"""
:param topology_dump: Filter to keep only properties required for saving on disk
"""
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,
}
if topology_dump:
return topology
additional_data = {
"project_id": self._project.id,
"command_line": self._command_line,
"status": self._status,
"console_host": str(self._compute.console_host),
"node_directory": self._node_directory,
"ports": [port.asdict() for port in self.ports],
"missing_image": self.missing_image,
"missing_images": self._missing_images,
}
topology.update(additional_data)
return topology