Merge pull request #2878 from cristian-ciobanu/project-missing-images

Allow projects containing unavailable images to open in a degraded state instead of failing to load
This commit is contained in:
Jeremy Grossmann 2026-09-22 15:34:36 +02:00 committed by GitHub
commit efd6bb77fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1221 additions and 35 deletions

View File

@ -64,6 +64,7 @@ async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> s
qemu = Qemu.instance() qemu = Qemu.instance()
node_data = jsonable_encoder(node_data, exclude_unset=True) node_data = jsonable_encoder(node_data, exclude_unset=True)
disk_images_to_reset = set(node_data.pop("disk_images_to_reset", []))
vm = await qemu.create_node( vm = await qemu.create_node(
node_data.pop("name"), node_data.pop("name"),
str(project_id), str(project_id),
@ -80,12 +81,35 @@ async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> s
# update the disk image with the backing file if provided # update the disk image with the backing file if provided
# this is needed when duplicating a node that uses backed disk images # this is needed when duplicating a node that uses backed disk images
drives = ["a", "b", "c", "d"] drives = ["a", "b", "c", "d"]
for disk_index, drive in enumerate(drives): for drive in drives:
disk_image_backing_file = node_data.get(f"hd{drive}_disk_image_backing_file") disk_image_backing_file = node_data.get(f"hd{drive}_disk_image_backing_file")
if disk_image_backing_file: if disk_image_backing_file:
log.debug(f"Updating disk image for drive {drive} with backing file {disk_image_backing_file}") log.debug(f"Updating disk image for drive {drive} with backing file {disk_image_backing_file}")
node_data[f"hd{drive}_disk_image"] = disk_image_backing_file node_data[f"hd{drive}_disk_image"] = disk_image_backing_file
# Validate every explicitly replaced disk before removing its stale
# overlay. Other unresolved disks may still make this create request fail,
# but a valid replacement applied in stages must not retain its old layer.
for drive in drives:
disk_image_property = f"hd{drive}_disk_image"
if disk_image_property in disk_images_to_reset:
replacement_image = node_data.get(disk_image_property)
if replacement_image:
vm.manager.get_abs_image_path(replacement_image, vm.working_dir)
local_disk_name = f"hd{drive}_disk.qcow2"
local_disk = os.path.join(vm.working_dir, local_disk_name)
if vm.linked_clone and os.path.exists(local_disk):
# A degraded linked clone is being assigned a new base image.
# Its old overlay depends on the unavailable base and cannot
# safely be rebased onto an arbitrary replacement. Discard it
# so start creates a fresh overlay from the selected image.
log.info(
"Removing stale linked-clone disk '%s' before using replacement image '%s'",
local_disk,
node_data.get(disk_image_property),
)
vm.delete_disk_image(local_disk_name)
for name, value in node_data.items(): for name, value in node_data.items():
if hasattr(vm, name) and getattr(vm, name) != value: if hasattr(vm, name) and getattr(vm, name) != value:
setattr(vm, name, value) setattr(vm, name, value)

View File

@ -1777,6 +1777,9 @@ class QemuVM(BaseNode):
try: try:
os.remove(disk_path) os.remove(disk_path)
md5sum_path = disk_path + ".md5sum"
if os.path.exists(md5sum_path):
os.remove(md5sum_path)
except OSError as e: except OSError as e:
raise QemuError(f"Could not delete '{disk_name}' disk image: {e}") raise QemuError(f"Could not delete '{disk_name}' disk image: {e}")

View File

@ -40,6 +40,26 @@ import logging
log = logging.getLogger(__name__) 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): def _extract_iol_startup_config_knob(environment):
""" """
Split the GNS3_IOL_STARTUP_CONFIG knob out of a Docker environment Split the GNS3_IOL_STARTUP_CONFIG knob out of a Docker environment
@ -143,6 +163,11 @@ class Node:
self._netmiko_device_type = None self._netmiko_device_type = None
self._default_username = None self._default_username = None
self._default_password = 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 # This properties will be recomputed
ignore_properties = ("width", "height", "hover_symbol") ignore_properties = ("width", "height", "hover_symbol")
@ -187,6 +212,18 @@ class Node:
def status(self): def status(self):
return self._status 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 @property
def template_id(self): def template_id(self):
return self._template_id return self._template_id
@ -457,18 +494,29 @@ class Node:
def links(self): def links(self):
return self._links return self._links
async def create(self): async def create(self, allow_missing_image=False, disk_images_to_reset=None):
""" """
Create the node on the compute 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 = self._node_data()
data["node_id"] = self._id 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": if self._node_type == "docker":
timeout = None timeout = None
await self._add_docker_image_digest(data) await self._add_docker_image_digest(data)
else: else:
timeout = 1200 timeout = 1200
trial = 0 trial = 0
last_missing_image = None
last_missing_error = None
while trial != 6: while trial != 6:
try: try:
response = await self._compute.post( response = await self._compute.post(
@ -477,17 +525,177 @@ class Node:
except ComputeConflictError as e: except ComputeConflictError as e:
response = e.response() response = e.response()
if response.get("exception") == "ImageMissingError": if response.get("exception") == "ImageMissingError":
res = await self._upload_missing_image(self._node_type, response["image"]) 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 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 raise e
else: else:
raise e raise e
else: else:
await self.parse_node_response(response.json) await self.parse_node_response(response.json)
self._missing_images = []
return True return True
trial += 1 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 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): async def update(self, **kwargs):
""" """
Update the node on the compute Update the node on the compute
@ -499,6 +707,9 @@ class Node:
update_compute = False update_compute = False
old_json = self.asdict() old_json = self.asdict()
old_name = self._name 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 compute_properties = None
# Update node properties with additional elements # Update node properties with additional elements
@ -509,7 +720,7 @@ class Node:
# We update properties on the compute and wait for the answer from the compute node # We update properties on the compute and wait for the answer from the compute node
if prop == "properties": if prop == "properties":
compute_properties = kwargs[prop] compute_properties = copy.deepcopy(kwargs[prop])
else: else:
if ( if (
prop == "name" prop == "name"
@ -522,8 +733,38 @@ class Node:
if compute_properties and "custom_adapters" in compute_properties: if compute_properties and "custom_adapters" in compute_properties:
# we need to check custom adapters to update the custom port names # we need to check custom adapters to update the custom port names
self.custom_adapters = compute_properties["custom_adapters"] 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() self._list_ports()
if update_compute: 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) data = self._node_data(properties=compute_properties)
try: try:
response = await self.put(None, data=data) response = await self.put(None, data=data)
@ -666,6 +907,25 @@ class Node:
""" """
Start a node 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: try:
# For IOU: we need to send the licence everytime we start a node # For IOU: we need to send the licence everytime we start a node
if self.node_type == "iou": if self.node_type == "iou":
@ -681,6 +941,8 @@ class Node:
""" """
Stop a node Stop a node
""" """
if self.missing_image:
return
try: try:
await self.post("/stop", timeout=240, dont_connect=True) await self.post("/stop", timeout=240, dont_connect=True)
# We don't care if a node is down at this step # We don't care if a node is down at this step
@ -756,6 +1018,10 @@ class Node:
""" """
HTTP post on the node 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: if path is None:
return await self._compute.delete( return await self._compute.delete(
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}", **kwargs f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}", **kwargs
@ -774,20 +1040,19 @@ class Node:
if self._node_type == "docker": if self._node_type == "docker":
return await self._sync_missing_docker_image(img) return await self._sync_missing_docker_image(img)
for directory in images_directories(type): image = self._find_local_image(type, img)
image = os.path.join(directory, img) if image is None:
if os.path.exists(image): return False
self.project.emit_notification("log.info", {"message": f"Uploading missing image {img}"}) self.project.emit_notification("log.info", {"message": f"Uploading missing image {img}"})
try: try:
with open(image, "rb") as f: with open(image, "rb") as f:
await self._compute.post( await self._compute.post(
f"/{self._node_type}/images/{os.path.basename(img)}", data=f, timeout=None f"/{self._node_type}/images/{os.path.basename(img)}", data=f, timeout=None
) )
except OSError as e: except OSError as e:
raise ControllerError(f"Can't upload {image}: {str(e)}") raise ControllerError(f"Can't upload {image}: {str(e)}")
self.project.emit_notification("log.info", {"message": f"Upload finished for {img}"}) self.project.emit_notification("log.info", {"message": f"Upload finished for {img}"})
return True return True
return False
async def _add_docker_image_digest(self, data): async def _add_docker_image_digest(self, data):
""" """
@ -1018,7 +1283,9 @@ class Node:
"status": self._status, "status": self._status,
"console_host": str(self._compute.console_host), "console_host": str(self._compute.console_host),
"node_directory": self._node_directory, "node_directory": self._node_directory,
"ports": [port.asdict() for port in self.ports] "ports": [port.asdict() for port in self.ports],
"missing_image": self.missing_image,
"missing_images": self._missing_images,
} }
topology.update(additional_data) topology.update(additional_data)
return topology return topology

View File

@ -632,7 +632,7 @@ class Project:
node = await self.add_node(compute, name, node_id, node_type=node_type, **template) node = await self.add_node(compute, name, node_id, node_type=node_type, **template)
return node return node
async def _create_node(self, compute, name, node_id, node_type=None, **kwargs): async def _create_node(self, compute, name, node_id, node_type=None, allow_missing_image=False, **kwargs):
node = Node(self, compute, name, node_id=node_id, node_type=node_type, **kwargs) node = Node(self, compute, name, node_id=node_id, node_type=node_type, **kwargs)
# Hold the lock across the check + POST + register so that concurrent # Hold the lock across the check + POST + register so that concurrent
@ -651,17 +651,21 @@ class Project:
await compute.post("/projects", data=data) await compute.post("/projects", data=data)
self._project_created_on_compute.add(compute) self._project_created_on_compute.add(compute)
await node.create() await node.create(allow_missing_image=allow_missing_image)
self._nodes[node.id] = node self._nodes[node.id] = node
return node return node
@open_required @open_required
async def add_node(self, compute, name, node_id, dump=True, node_type=None, **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 Create a node or return an existing node
:param dump: Dump topology to disk :param dump: Dump topology to disk
:param allow_missing_image: Keep the node on the controller in a
degraded state instead of failing when its image is missing
:param kwargs: See the documentation of node :param kwargs: See the documentation of node
""" """
@ -683,7 +687,9 @@ class Project:
) )
elif "application_id" not in kwargs.keys() and not kwargs.get("properties"): elif "application_id" not in kwargs.keys() and not kwargs.get("properties"):
kwargs["application_id"] = get_next_application_id(self._controller.projects, self._computes) kwargs["application_id"] = get_next_application_id(self._controller.projects, self._computes)
node = await self._create_node(compute, name, node_id, node_type, **kwargs) node = await self._create_node(
compute, name, node_id, node_type, allow_missing_image=allow_missing_image, **kwargs
)
elif node_type == "docker" and _is_iol_docker_kwargs(kwargs): elif node_type == "docker" and _is_iol_docker_kwargs(kwargs):
# IOL Docker nodes derive interface MACs from the application ID # IOL Docker nodes derive interface MACs from the application ID
# exactly like IOU; they draw from the disjoint upper half of the # exactly like IOU; they draw from the disjoint upper half of the
@ -700,9 +706,13 @@ class Project:
kwargs["application_id"] = get_next_application_id( kwargs["application_id"] = get_next_application_id(
self._controller.projects, self._computes, iol_docker=True self._controller.projects, self._computes, iol_docker=True
) )
node = await self._create_node(compute, name, node_id, node_type, **kwargs) node = await self._create_node(
compute, name, node_id, node_type, allow_missing_image=allow_missing_image, **kwargs
)
else: else:
node = await self._create_node(compute, name, node_id, node_type, **kwargs) node = await self._create_node(
compute, name, node_id, node_type, allow_missing_image=allow_missing_image, **kwargs
)
self.emit_notification("node.created", node.asdict()) self.emit_notification("node.created", node.asdict())
if dump: if dump:
self.dump() self.dump()
@ -975,6 +985,20 @@ class Project:
# a link should have 2 attached nodes, this can happen with corrupted projects # a link should have 2 attached nodes, this can happen with corrupted projects
await self.delete_link(link.id, force_delete=True) await self.delete_link(link.id, force_delete=True)
return None return None
if any(n["node"].missing_image for n in link._nodes):
# One of the endpoints could not be created on its compute because
# an image is missing. Keep the link on the controller (so the
# topology is preserved) but defer the NIO creation until the
# missing image is resolved.
for n in link._nodes:
n["node"].add_link(link)
n["port"].link = link
link._deferred = True
log.info(
"Project '%s' [%s]: deferring link %s until missing image(s) are resolved",
self._name, self._id, link.id,
)
return None
# Apply project-level marker definitions onto the link's memory # Apply project-level marker definitions onto the link's memory
# (memory_only) before _prepare() so the inherited markers ride the # (memory_only) before _prepare() so the inherited markers ride the
# batch NIO dispatch — zero extra HTTP round-trips. The final # batch NIO dispatch — zero extra HTTP round-trips. The final
@ -987,6 +1011,33 @@ class Project:
entries = await link._prepare() entries = await link._prepare()
return (link, entries) return (link, entries)
async def restore_deferred_links(self, node):
"""
Create on the computes the NIOs of the links that were deferred while
one of their endpoints had a missing image. Called once the node has
been successfully created.
:param node: node that has just been created on its compute
"""
restored = []
for link in list(node.links):
if not link.deferred:
continue
if any(n["node"].missing_image for n in link._nodes):
# the other endpoint is still missing an image
continue
try:
await link.create()
link._deferred = False
restored.append(link)
except Exception as e:
log.exception("Could not restore deferred link %s: %s", link.id, e)
for link in restored:
self.emit_notification("link.updated", link.asdict())
if restored:
self.dump()
@open_required @open_required
async def add_link(self, link_id=None, dump=True): async def add_link(self, link_id=None, dump=True):
""" """
@ -1867,7 +1918,15 @@ class Project:
log.info("Project '%s' [%s]: loading %d nodes...", self._name, self._id, len(nodes_to_create)) log.info("Project '%s' [%s]: loading %d nodes...", self._name, self._id, len(nodes_to_create))
pool = Pool(concurrency=100) pool = Pool(concurrency=100)
for compute, name, node_id, node_data in nodes_to_create: for compute, name, node_id, node_data in nodes_to_create:
pool.append(self.add_node, compute, name, node_id, dump=False, **node_data) pool.append(
self.add_node,
compute,
name,
node_id,
dump=False,
allow_missing_image=True,
**node_data,
)
await pool.join() await pool.join()
log.info("Project '%s' [%s]: loaded %d nodes", self._name, self._id, len(nodes_to_create)) log.info("Project '%s' [%s]: loaded %d nodes", self._name, self._id, len(nodes_to_create))
# Pre-allocate UDP ports for all links in batch to reduce HTTP round-trips # Pre-allocate UDP ports for all links in batch to reduce HTTP round-trips
@ -1875,9 +1934,12 @@ class Project:
for link_data in topology.get("links", []): for link_data in topology.get("links", []):
if "link_id" not in link_data.keys(): if "link_id" not in link_data.keys():
continue continue
for node_link in link_data.get("nodes", []): link_nodes = [self._nodes.get(nl["node_id"]) for nl in link_data.get("nodes", [])]
node = self._nodes.get(node_link["node_id"]) if any(node is not None and node.missing_image for node in link_nodes):
if node: # the link will be deferred, no NIO/port will be created now
continue
for node in link_nodes:
if node is not None:
ports_per_compute[node.compute.id] = ports_per_compute.get(node.compute.id, 0) + 1 ports_per_compute[node.compute.id] = ports_per_compute.get(node.compute.id, 0) + 1
for compute in self.computes: for compute in self.computes:
count = ports_per_compute.get(compute.id, 0) count = ports_per_compute.get(compute.id, 0)
@ -2215,7 +2277,7 @@ class Project:
""" """
Start all nodes (except always-running types like Ethernet switch, Cloud, NAT, etc.) Start all nodes (except always-running types like Ethernet switch, Cloud, NAT, etc.)
""" """
nodes_to_start = [n for n in self.nodes.values() if not n.is_always_running()] nodes_to_start = [n for n in self.nodes.values() if not n.is_always_running() and not n.missing_image]
if not nodes_to_start: if not nodes_to_start:
return return
log.info("Project '%s' [%s]: starting %d nodes...", self._name, self._id, len(nodes_to_start)) log.info("Project '%s' [%s]: starting %d nodes...", self._name, self._id, len(nodes_to_start))
@ -2230,7 +2292,7 @@ class Project:
""" """
Stop all nodes (except always-running types like Ethernet switch, Cloud, NAT, etc.) Stop all nodes (except always-running types like Ethernet switch, Cloud, NAT, etc.)
""" """
nodes_to_stop = [n for n in self.nodes.values() if not n.is_always_running()] nodes_to_stop = [n for n in self.nodes.values() if not n.is_always_running() and not n.missing_image]
if not nodes_to_stop: if not nodes_to_stop:
return return
log.info("Project '%s' [%s]: stopping %d nodes...", self._name, self._id, len(nodes_to_stop)) log.info("Project '%s' [%s]: stopping %d nodes...", self._name, self._id, len(nodes_to_stop))
@ -2247,6 +2309,8 @@ class Project:
""" """
pool = Pool(concurrency=50) pool = Pool(concurrency=50)
for node in self.nodes.values(): for node in self.nodes.values():
if node.missing_image:
continue
pool.append(node.suspend) pool.append(node.suspend)
await pool.join() await pool.join()
@ -2258,6 +2322,8 @@ class Project:
pool = Pool(concurrency=3) pool = Pool(concurrency=3)
for node in self.nodes.values(): for node in self.nodes.values():
if node.missing_image:
continue
pool.append(node.reset_console) pool.append(node.reset_console)
await pool.join() await pool.join()
@ -2347,4 +2413,3 @@ class Project:
def __repr__(self): def __repr__(self):
return f"<gns3server.controller.Project {self._name} {self._id}>" return f"<gns3server.controller.Project {self._name} {self._id}>"

View File

@ -42,6 +42,10 @@ class UDPLink(Link):
super().__init__(project, link_id=link_id) super().__init__(project, link_id=link_id)
self._created = False self._created = False
self._link_data = [] self._link_data = []
# True when the link could not be created on the computes because one
# of its endpoints has a missing image. The NIO creation is retried
# once the image is resolved.
self._deferred = False
@property @property
def debug_link_data(self): def debug_link_data(self):
@ -49,6 +53,11 @@ class UDPLink(Link):
Use for the debug exports Use for the debug exports
""" """
return self._link_data return self._link_data
@property
def deferred(self):
"""Whether NIO creation is waiting for missing node images."""
return self._deferred
def _get_node_filters(self, node1, node2): def _get_node_filters(self, node1, node2):
""" """
@ -258,6 +267,10 @@ class UDPLink(Link):
Delete the link and free the resources Delete the link and free the resources
""" """
if not self._created: if not self._created:
if self._deferred:
# There is no NIO on the computes to delete, but the local
# back-references created while loading must be cleared.
await super().delete()
return return
try: try:
node1 = self._nodes[0]["node"] node1 = self._nodes[0]["node"]

View File

@ -221,7 +221,10 @@ class QemuCreate(QemuBase):
Properties to create a Qemu node. Properties to create a Qemu node.
""" """
pass disk_images_to_reset: Optional[List[str]] = Field(
None,
description="Disk image properties whose stale linked-clone overlays must be recreated",
)
class QemuUpdate(QemuBase): class QemuUpdate(QemuBase):

View File

@ -99,6 +99,16 @@ class NodePort(BaseModel):
mac_address: Union[str, None] = Field(None, pattern="^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$") mac_address: Union[str, None] = Field(None, pattern="^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$")
class MissingImage(BaseModel):
"""
A missing image referenced by a node.
"""
property: Optional[str] = Field(None, description="Node property referencing the image")
image: str = Field(..., description="Requested image filename")
image_type: Optional[str] = Field(None, description="Type of image (qemu/ios/iou/docker)")
class NodeBase(BaseModel): class NodeBase(BaseModel):
""" """
Node data. Node data.
@ -182,6 +192,13 @@ class Node(NodeBase):
None, None,
description="Console host. Warning if the host is 0.0.0.0 or :: (listen on all interfaces) you need to use the same address you use to connect to the controller", description="Console host. Warning if the host is 0.0.0.0 or :: (listen on all interfaces) you need to use the same address you use to connect to the controller",
) )
missing_image: bool = Field(
False,
description="True when the node could not be created on its compute because a required image is missing. Read only",
)
missing_images: List[MissingImage] = Field(
default_factory=list, description="List of missing images referenced by the node. Read only"
)
class NodeDuplicate(BaseModel): class NodeDuplicate(BaseModel):

View File

@ -20,6 +20,7 @@ import pytest_asyncio
import os import os
import stat import stat
import shutil import shutil
import uuid
from fastapi import FastAPI, status from fastapi import FastAPI, status
from httpx import AsyncClient from httpx import AsyncClient
@ -144,6 +145,128 @@ class TestQemuNodesRoutes:
assert response.json()["project_id"] == compute_project.id assert response.json()["project_id"] == compute_project.id
assert response.json()["ram"] == 1024 assert response.json()["ram"] == 1024
assert response.json()["hda_disk_image"] == "linux载.img" assert response.json()["hda_disk_image"] == "linux载.img"
async def test_qemu_create_discards_stale_overlay_for_replacement(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
base_params: dict,
fake_qemu_vm: str
):
node_id = str(uuid.uuid4())
working_dir = os.path.join(compute_project.path, "project-files", "qemu", node_id)
os.makedirs(working_dir)
stale_overlay = os.path.join(working_dir, "hda_disk.qcow2")
with open(stale_overlay, "w+") as f:
f.write("stale linked clone")
stale_checksum = stale_overlay + ".md5sum"
with open(stale_checksum, "w+") as f:
f.write("0" * 32)
params = {
**base_params,
"node_id": node_id,
"linked_clone": True,
"hda_disk_image": "linux载.img",
"disk_images_to_reset": ["hda_disk_image"],
}
response = await compute_client.post(
app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), json=params
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["hda_disk_image"] == "linux载.img"
assert not os.path.exists(stale_overlay)
assert not os.path.exists(stale_checksum)
async def test_qemu_create_preserves_existing_overlay_without_reset_marker(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
base_params: dict,
fake_qemu_vm: str
):
node_id = str(uuid.uuid4())
working_dir = os.path.join(compute_project.path, "project-files", "qemu", node_id)
os.makedirs(working_dir)
overlay = os.path.join(working_dir, "hda_disk.qcow2")
shutil.copy("tests/resources/empty8G.qcow2", overlay)
params = {
**base_params,
"node_id": node_id,
"linked_clone": True,
"hda_disk_image": "linux载.img",
}
response = await compute_client.post(
app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), json=params
)
assert response.status_code == status.HTTP_201_CREATED
assert os.path.exists(overlay)
async def test_qemu_create_preserves_overlay_when_replacement_is_missing(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
base_params: dict,
fake_qemu_vm: str
):
node_id = str(uuid.uuid4())
working_dir = os.path.join(compute_project.path, "project-files", "qemu", node_id)
os.makedirs(working_dir)
overlay = os.path.join(working_dir, "hda_disk.qcow2")
shutil.copy("tests/resources/empty8G.qcow2", overlay)
params = {
**base_params,
"node_id": node_id,
"linked_clone": True,
"hda_disk_image": "does-not-exist.qcow2",
"disk_images_to_reset": ["hda_disk_image"],
}
response = await compute_client.post(
app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), json=params
)
assert response.status_code == status.HTTP_409_CONFLICT
assert os.path.exists(overlay)
async def test_qemu_create_resets_valid_replacement_when_another_disk_is_missing(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
base_params: dict,
fake_qemu_vm: str
):
node_id = str(uuid.uuid4())
working_dir = os.path.join(compute_project.path, "project-files", "qemu", node_id)
os.makedirs(working_dir)
stale_overlay = os.path.join(working_dir, "hda_disk.qcow2")
shutil.copy("tests/resources/empty8G.qcow2", stale_overlay)
params = {
**base_params,
"node_id": node_id,
"linked_clone": True,
"hda_disk_image": "linux载.img",
"hdb_disk_image": "does-not-exist.qcow2",
"disk_images_to_reset": ["hda_disk_image"],
}
response = await compute_client.post(
app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), json=params
)
assert response.status_code == status.HTTP_409_CONFLICT
assert not os.path.exists(stale_overlay)
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@ -103,7 +103,20 @@ class TestNodeRoutes:
response = await client.get(app.url_path_for("get_nodes", project_id=project.id)) response = await client.get(app.url_path_for("get_nodes", project_id=project.id))
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert response.json()[0]["name"] == "test" assert response.json()[0]["name"] == "test"
async def test_list_node_with_missing_image(
self, app: FastAPI, client: AsyncClient, project: Project, node: Node
) -> None:
node._missing_images = [
{"property": "path", "image": "i86bi-linux-l2.bin", "image_type": "iou"}
]
response = await client.get(app.url_path_for("get_nodes", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
data = response.json()[0]
assert data["missing_image"] is True
assert data["missing_images"] == [
{"property": "path", "image": "i86bi-linux-l2.bin", "image_type": "iou"}
]
@pytest.mark.parametrize( @pytest.mark.parametrize(
"tags, expected_match", "tags, expected_match",

View File

@ -806,6 +806,19 @@ def test_hda_disk_image(vm, images_dir):
assert vm.hda_disk_image == force_unix_path(os.path.join(images_dir, "QEMU", "test2")) assert vm.hda_disk_image == force_unix_path(os.path.join(images_dir, "QEMU", "test2"))
def test_delete_disk_image_removes_cached_checksum(vm):
disk_path = os.path.join(vm.working_dir, "hda_disk.qcow2")
checksum_path = disk_path + ".md5sum"
open(disk_path, "w+").close()
open(checksum_path, "w+").close()
vm.delete_disk_image("hda_disk.qcow2")
assert not os.path.exists(disk_path)
assert not os.path.exists(checksum_path)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_hda_disk_image_non_linked_clone(vm, images_dir, compute_project, manager, fake_qemu_binary): async def test_hda_disk_image_non_linked_clone(vm, images_dir, compute_project, manager, fake_qemu_binary):
""" """

View File

@ -26,6 +26,7 @@ from gns3server.compute.docker.docker_error import DockerError
from gns3server.controller.node import Node from gns3server.controller.node import Node
from gns3server.controller.project import Project from gns3server.controller.project import Project
from gns3server.controller.controller_error import ComputeConflictError, ControllerError
@pytest.fixture @pytest.fixture
@ -234,7 +235,9 @@ def test_json(node, compute):
"port_number": 0, "port_number": 0,
"short_name": "e0" "short_name": "e0"
} }
] ],
"missing_image": False,
"missing_images": []
} }
assert node.asdict(topology_dump=True) == { assert node.asdict(topology_dump=True) == {
@ -319,6 +322,353 @@ async def test_create_image_missing(node, compute):
#assert node._upload_missing_image.called is True #assert node._upload_missing_image.called is True
@pytest.mark.asyncio
async def test_create_image_missing_kept_in_degraded_state(project, compute, tmpdir, config):
"""
With allow_missing_image=True a node whose image cannot be provided is kept
on the controller and flagged instead of aborting the whole project open.
"""
config.settings.Server.images_path = str(tmpdir)
node = Node(project, compute, "r1",
node_id=str(uuid.uuid4()),
node_type="qemu",
properties={"hda_disk_image": "missing.qcow2", "ram": 256})
async def resp(*args, **kwargs):
raise ComputeConflictError(
"/projects/{}/qemu/nodes".format(project.id),
{"message": "The image is missing", "image": "missing.qcow2", "exception": "ImageMissingError"}
)
compute.post = AsyncioMagicMock(side_effect=resp)
node._upload_missing_image = AsyncioMagicMock(return_value=False)
assert await node.create(allow_missing_image=True) is False
assert node.missing_image is True
assert node.missing_images == [
{"property": "hda_disk_image", "image": "missing.qcow2", "image_type": "qemu"}
]
@pytest.mark.asyncio
async def test_create_image_missing_raises_by_default(project, compute, tmpdir, config):
"""
Without allow_missing_image the historical behaviour is preserved: the
ImageMissingError is re-raised.
"""
config.settings.Server.images_path = str(tmpdir)
node = Node(project, compute, "r1",
node_id=str(uuid.uuid4()),
node_type="qemu",
properties={"hda_disk_image": "missing.qcow2", "ram": 256})
async def resp(*args, **kwargs):
raise ComputeConflictError(
"/projects/{}/qemu/nodes".format(project.id),
{"message": "The image is missing", "image": "missing.qcow2", "exception": "ImageMissingError"}
)
compute.post = AsyncioMagicMock(side_effect=resp)
node._upload_missing_image = AsyncioMagicMock(return_value=False)
with pytest.raises(ComputeConflictError):
await node.create()
assert node.missing_image is False
@pytest.mark.asyncio
async def test_create_image_missing_raises_after_upload_retries(project, compute):
node = Node(
project,
compute,
"r1",
node_id=str(uuid.uuid4()),
node_type="qemu",
properties={"hda_disk_image": "missing.qcow2", "ram": 256},
)
async def resp(*args, **kwargs):
raise ComputeConflictError(
f"/projects/{project.id}/qemu/nodes",
{"message": "missing", "image": "missing.qcow2", "exception": "ImageMissingError"},
)
compute.post = AsyncioMagicMock(side_effect=resp)
node._upload_missing_image = AsyncioMagicMock(return_value=True)
with pytest.raises(ComputeConflictError):
await node.create()
assert compute.post.call_count == 6
assert node.missing_image is False
@pytest.mark.asyncio
async def test_create_image_missing_without_image_name_is_not_degraded(project, compute):
"""A malformed conflict must not leave a controller-only node marked healthy."""
node = Node(
project,
compute,
"r1",
node_id=str(uuid.uuid4()),
node_type="qemu",
properties={"hda_disk_image": "present.qcow2", "ram": 256},
)
async def resp(*args, **kwargs):
raise ComputeConflictError(
f"/projects/{project.id}/qemu/nodes",
{"message": "The image is missing", "exception": "ImageMissingError"},
)
compute.post = AsyncioMagicMock(side_effect=resp)
node._image_available = MagicMock(return_value=True)
with pytest.raises(ComputeConflictError):
await node.create(allow_missing_image=True)
assert node.missing_image is False
def test_compute_missing_images_matches_reported_basename_to_property(project, compute):
node = Node(
project,
compute,
"r1",
node_id=str(uuid.uuid4()),
node_type="qemu",
properties={
"hda_disk_image": "/images/qemu/disk.qcow2",
"cdrom_image": "/images/qemu/installer.iso",
"ram": 256,
},
)
node._image_available = MagicMock(return_value=True)
assert node._compute_missing_images("installer.iso") == [
{"property": "cdrom_image", "image": "installer.iso", "image_type": "qemu"}
]
def test_compute_missing_images_deduplicates_path_and_reported_basename(project, compute):
node = Node(
project,
compute,
"r1",
node_id=str(uuid.uuid4()),
node_type="qemu",
properties={"cdrom_image": "/images/qemu/installer.iso", "ram": 256},
)
node._image_available = MagicMock(return_value=False)
assert node._compute_missing_images("installer.iso") == [
{
"property": "cdrom_image",
"image": "/images/qemu/installer.iso",
"image_type": "qemu",
}
]
@pytest.mark.asyncio
async def test_create_docker_image_missing_after_failed_pull(project, compute):
"""
A Docker image that cannot be pulled from the registry keeps the node in a
degraded state instead of aborting the project open.
"""
node = Node(project, compute, "web",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"image": "ghost:latest", "adapters": 1})
async def resp(*args, **kwargs):
raise ComputeConflictError(
"/projects/{}/docker/nodes".format(project.id),
{"message": "The image is missing", "image": "ghost:latest", "exception": "ImageMissingError"}
)
compute.post = AsyncioMagicMock(side_effect=resp)
node._upload_missing_image = AsyncioMagicMock(
side_effect=ControllerError("Failed to pull Docker image 'ghost:latest'")
)
assert await node.create(allow_missing_image=True) is False
assert node.missing_image is True
assert node.missing_images == [
{"property": "image", "image": "ghost:latest", "image_type": "docker"}
]
@pytest.mark.asyncio
async def test_create_docker_image_missing_pull_error_raises_by_default(project, compute):
"""
Without allow_missing_image a failed Docker pull is still surfaced.
"""
node = Node(project, compute, "web",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"image": "ghost:latest", "adapters": 1})
async def resp(*args, **kwargs):
raise ComputeConflictError(
"/projects/{}/docker/nodes".format(project.id),
{"message": "The image is missing", "image": "ghost:latest", "exception": "ImageMissingError"}
)
compute.post = AsyncioMagicMock(side_effect=resp)
node._upload_missing_image = AsyncioMagicMock(
side_effect=ControllerError("Failed to pull Docker image 'ghost:latest'")
)
with pytest.raises(ControllerError):
await node.create()
def test_compute_missing_images_lists_all_unavailable_slots(project, compute, tmpdir, config):
"""
All the image slots of a multi-image node are reported, not only the first
one the compute complained about.
"""
config.settings.Server.images_path = str(tmpdir)
node = Node(project, compute, "r1",
node_id=str(uuid.uuid4()),
node_type="qemu",
properties={
"hda_disk_image": "present.qcow2",
"hdc_disk_image": "missing.qcow2",
"initrd": "missing.initrd",
"ram": 256,
})
# make one image available on the controller
os.makedirs(os.path.join(str(tmpdir), "QEMU"), exist_ok=True)
with open(os.path.join(str(tmpdir), "QEMU", "present.qcow2"), "w") as f:
f.write("x")
missing = node._compute_missing_images()
assert {m["property"] for m in missing} == {"hdc_disk_image", "initrd"}
assert all(m["image_type"] == "qemu" for m in missing)
@pytest.mark.asyncio
async def test_start_missing_image(node):
node._missing_images = [
{"property": "hda_disk_image", "image": "missing.qcow2", "image_type": "qemu"}
]
with pytest.raises(ControllerError):
await node.start()
@pytest.mark.asyncio
async def test_update_recreates_missing_image_node(project, compute, node):
"""
Updating the image of a degraded node creates it on the compute and clears
the missing image state.
"""
node._missing_images = [
{"property": "image", "image": "missing.image", "image_type": "ios"}
]
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
project.restore_deferred_links = AsyncioMagicMock()
await node.update(properties={"image": "available.image"})
assert node.missing_image is False
assert node.missing_images == []
assert project.restore_deferred_links.called is True
@pytest.mark.asyncio
async def test_update_replaces_qemu_linked_clone_backing_image(project, compute):
"""Stale linked-clone metadata must not override a replacement image."""
qemu_node = Node(
project,
compute,
"old-qemu",
node_id=str(uuid.uuid4()),
node_type="qemu",
properties={
"hda_disk_image": "hda_disk.qcow2",
"hda_disk_image_backing_file": "missing.qcow2",
"hda_disk_image_md5sum": "old-checksum",
},
)
qemu_node._missing_images = [
{"property": "hda_disk_image", "image": "missing.qcow2", "image_type": "qemu"}
]
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
project.restore_deferred_links = AsyncioMagicMock()
await qemu_node.update(
properties={
**qemu_node.properties,
"hda_disk_image": "replacement.qcow2",
}
)
request_data = compute.post.call_args.kwargs["data"]
assert request_data["hda_disk_image"] == "replacement.qcow2"
assert request_data["disk_images_to_reset"] == ["hda_disk_image"]
assert "hda_disk_image_backing_file" not in request_data
assert "hda_disk_image_md5sum" not in request_data
assert qemu_node.missing_image is False
@pytest.mark.asyncio
async def test_update_missing_image_node_rolls_back_after_create_error(project, compute, node):
original_properties = {"image": "missing.image"}
node._properties = original_properties.copy()
node._missing_images = [
{"property": "image", "image": "missing.image", "image_type": "ios"}
]
compute.post = AsyncioMagicMock(side_effect=ControllerError("create failed"))
with pytest.raises(ControllerError):
await node.update(properties={"image": "invalid.image"})
assert node.properties == original_properties
assert node.missing_images == [
{"property": "image", "image": "missing.image", "image_type": "ios"}
]
@pytest.mark.asyncio
async def test_start_retries_ready_deferred_links(node, project):
peer = MagicMock()
peer.missing_image = False
link = MagicMock()
link.deferred = True
link._nodes = [{"node": node}, {"node": peer}]
node._links.add(link)
project.restore_deferred_links = AsyncioMagicMock()
with pytest.raises(ControllerError, match="deferred link"):
await node.start()
project.restore_deferred_links.assert_called_once_with(node)
@pytest.mark.asyncio
async def test_stop_missing_image_node_does_not_contact_compute(node, compute):
node._missing_images = [
{"property": "image", "image": "missing.image", "image_type": "ios"}
]
compute.post = AsyncioMagicMock()
await node.stop()
compute.post.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_base_script(node, config, compute, tmpdir): async def test_create_base_script(node, config, compute, tmpdir):
@ -694,6 +1044,22 @@ async def test_upload_missing_image(compute, controller, images_dir):
compute.post.assert_called_with("/qemu/images/linux.img", data=ANY, timeout=None) compute.post.assert_called_with("/qemu/images/linux.img", data=ANY, timeout=None)
@pytest.mark.asyncio
async def test_upload_missing_image_from_nested_directory(compute, controller, images_dir):
project = Project(str(uuid.uuid4()), controller=controller)
node = Node(project, compute, "demo",
node_id=str(uuid.uuid4()),
node_type="qemu",
properties={"hda_disk_image": "nested.img"})
nested_dir = os.path.join(images_dir, "vendor")
os.makedirs(nested_dir)
open(os.path.join(nested_dir, "nested.img"), "w+").close()
assert await node._upload_missing_image("qemu", "nested.img") is True
compute.post.assert_called_with("/qemu/images/nested.img", data=ANY, timeout=None)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sync_missing_docker_image_from_controller_daemon(compute, controller): async def test_sync_missing_docker_image_from_controller_daemon(compute, controller):

View File

@ -17,6 +17,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import os import os
import json
import uuid import uuid
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@ -28,7 +29,12 @@ from uuid import uuid4
from gns3server.controller.project import Project from gns3server.controller.project import Project
from gns3server.controller.node import Node from gns3server.controller.node import Node
from gns3server.controller.ports.ethernet_port import EthernetPort from gns3server.controller.ports.ethernet_port import EthernetPort
from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError, ControllerForbiddenError from gns3server.controller.controller_error import (
ControllerError,
ControllerNotFoundError,
ControllerForbiddenError,
ComputeConflictError,
)
from gns3server.config import Config from gns3server.config import Config
@ -1006,6 +1012,19 @@ async def test_stop_all(project):
assert len(compute.post.call_args_list) == 10 assert len(compute.post.call_args_list) == 10
@pytest.mark.asyncio
async def test_stop_all_skips_nodes_with_missing_images(project):
node = MagicMock()
node.is_always_running.return_value = False
node.missing_image = True
node.stop = AsyncioMagicMock()
project._nodes[node.id] = node
await project.stop_all()
node.stop.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_suspend_all(project): async def test_suspend_all(project):
@ -1023,6 +1042,18 @@ async def test_suspend_all(project):
assert len(compute.post.call_args_list) == 10 assert len(compute.post.call_args_list) == 10
@pytest.mark.asyncio
async def test_suspend_all_skips_nodes_with_missing_images(project):
node = MagicMock()
node.missing_image = True
node.suspend = AsyncioMagicMock()
project._nodes[node.id] = node
await project.suspend_all()
node.suspend.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_console_reset_all(project): async def test_console_reset_all(project):
@ -1040,6 +1071,18 @@ async def test_console_reset_all(project):
assert len(compute.post.call_args_list) == 10 assert len(compute.post.call_args_list) == 10
@pytest.mark.asyncio
async def test_console_reset_all_skips_nodes_with_missing_images(project):
node = MagicMock()
node.missing_image = True
node.reset_console = AsyncioMagicMock()
project._nodes[node.id] = node
await project.reset_console_all()
node.reset_console.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_node_name(project): async def test_node_name(project):
@ -1085,3 +1128,201 @@ async def test_duplicate_node(project):
}) })
new_node = await project.duplicate_node(original, 42, 10, 11) new_node = await project.duplicate_node(original, 42, 10, 11)
assert new_node.x == 42 assert new_node.x == 42
@pytest.mark.asyncio
async def test_add_node_missing_image_kept_in_degraded_state(controller):
"""
A node whose image is missing is kept on the controller with the missing
image list instead of failing, when allow_missing_image is set.
"""
compute = MagicMock()
compute.id = "local"
compute.connected = True
project = Project(controller=controller, name="Test")
project.emit_notification = MagicMock()
async def post(url, data=None, **kwargs):
if url.endswith("/qemu/nodes"):
raise ComputeConflictError(
url, {"message": "missing", "image": "missing.qcow2", "exception": "ImageMissingError"}
)
response = MagicMock()
response.json = {}
return response
compute.post = AsyncioMagicMock(side_effect=post)
node = await project.add_node(
compute,
"r1",
None,
node_type="qemu",
allow_missing_image=True,
properties={"hda_disk_image": "missing.qcow2", "ram": 256},
)
assert node.missing_image is True
assert node.id in project._nodes
assert node.missing_images == [
{"property": "hda_disk_image", "image": "missing.qcow2", "image_type": "qemu"}
]
@pytest.mark.asyncio
async def test_open_with_missing_image_defers_links(controller, projects_dir):
"""
Opening a project with a missing image succeeds, keeps the degraded node
and its links in the topology, but does not create the NIOs.
"""
project_id = "3c1be6f9-b4ba-4737-b209-63c47c23359f"
qemu_id = "11111111-1111-1111-1111-111111111111"
iou_id = "33333333-3333-3333-3333-333333333333"
vpcs_id = "22222222-2222-2222-2222-222222222222"
link_id = "5a3e3a64-e853-4055-9503-4a14e01290f1"
topology = {
"auto_close": True,
"auto_open": False,
"auto_start": False,
"name": "demo",
"project_id": project_id,
"revision": 5,
"topology": {
"computes": [],
"drawings": [],
"links": [
{
"link_id": link_id,
"nodes": [
{"adapter_number": 0, "node_id": qemu_id, "port_number": 0},
{"adapter_number": 0, "node_id": vpcs_id, "port_number": 0},
],
}
],
"nodes": [
{
"compute_id": "local",
"name": "R1",
"node_id": qemu_id,
"node_type": "qemu",
"properties": {"hda_disk_image": "missing.qcow2", "adapters": 1, "ram": 256},
"symbol": ":/symbols/router.svg",
"x": 0,
"y": 0,
},
{
"compute_id": "local",
"name": "IOU1",
"node_id": iou_id,
"node_type": "iou",
"properties": {"path": "missing.bin"},
"symbol": ":/symbols/router.svg",
"x": 200,
"y": 0,
},
{
"compute_id": "local",
"name": "PC1",
"node_id": vpcs_id,
"node_type": "vpcs",
"properties": {},
"symbol": ":/symbols/computer.svg",
"x": 100,
"y": 0,
},
],
},
"type": "topology",
"version": "2.0.0",
}
project_dir = os.path.join(projects_dir, "demo")
os.makedirs(project_dir, exist_ok=True)
with open(os.path.join(project_dir, "demo.gns3"), "w+") as f:
json.dump(topology, f)
compute = MagicMock()
compute.id = "local"
compute.connected = True
compute.name = "local"
compute.console_host = "127.0.0.1"
controller._computes["local"] = compute
async def post(url, data=None, **kwargs):
if url.endswith("/qemu/nodes"):
raise ComputeConflictError(
url, {"message": "missing", "image": "missing.qcow2", "exception": "ImageMissingError"}
)
if url.endswith("/iou/nodes"):
raise ComputeConflictError(
url, {"message": "missing", "image": "missing.bin", "exception": "ImageMissingError"}
)
response = MagicMock()
if "ports/udp/batch" in url:
response.json = {"udp_ports": [20000]}
else:
response.json = {}
return response
compute.post = AsyncioMagicMock(side_effect=post)
project = Project(
name="demo",
project_id=project_id,
path=project_dir,
controller=controller,
filename="demo.gns3",
status="closed",
)
await project.open()
assert project.status == "opened"
qemu_node = project.get_node(qemu_id)
assert qemu_node.missing_image is True
assert qemu_node.missing_images[0]["image"] == "missing.qcow2"
iou_node = project.get_node(iou_id)
assert iou_node.missing_image is True
assert iou_node.missing_images[0]["image"] == "missing.bin"
assert iou_node.missing_images[0]["image_type"] == "iou"
link = project._links[link_id]
assert link._deferred is True
assert link.created is False
assert link in qemu_node.links
@pytest.mark.asyncio
async def test_restore_deferred_links(project):
"""
Once a degraded node is created, the deferred links are created on the
computes and no longer flagged as deferred.
"""
compute = MagicMock()
compute.id = "local"
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
node1 = await project.add_node(compute, "n1", None, node_type="vpcs", properties={})
node2 = await project.add_node(compute, "n2", None, node_type="vpcs", properties={})
link = await project.add_link()
await link.add_node(node1, 0, 0, batch=True)
await link.add_node(node2, 0, 0, batch=True)
link._nodes[0]["port"].link = link
link._nodes[1]["port"].link = link
node1.add_link(link)
node2.add_link(link)
link._deferred = True
link.create = AsyncioMagicMock()
project.emit_notification = MagicMock()
await project.restore_deferred_links(node1)
assert link._deferred is False
assert link.create.called is True

View File

@ -187,6 +187,44 @@ async def test_delete(project):
compute2.delete.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), timeout=120) compute2.delete.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), timeout=120)
@pytest.mark.asyncio
async def test_delete_deferred_link_clears_local_references(project):
node1 = Node(project, MagicMock(), "node1", node_type="vpcs")
node1._ports = [EthernetPort("E0", 0, 0, 0)]
node2 = Node(project, MagicMock(), "node2", node_type="vpcs")
node2._ports = [EthernetPort("E0", 0, 0, 0)]
link = UDPLink(project)
await link.add_node(node1, 0, 0, batch=True)
await link.add_node(node2, 0, 0, batch=True)
for entry in link._nodes:
entry["node"].add_link(link)
entry["port"].link = link
link._deferred = True
await link.delete()
assert link not in node1.links
assert link not in node2.links
assert node1.get_port(0, 0).link is None
assert node2.get_port(0, 0).link is None
@pytest.mark.asyncio
async def test_delete_uncreated_non_deferred_link_preserves_existing_behavior(project):
node = Node(project, MagicMock(), "node1", node_type="vpcs")
node._ports = [EthernetPort("E0", 0, 0, 0)]
link = UDPLink(project)
await link.add_node(node, 0, 0, batch=True)
node.add_link(link)
node.get_port(0, 0).link = link
await link.delete()
assert link in node.links
assert node.get_port(0, 0).link is link
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reset(project): async def test_reset(project):
""" """