From 6869f2d71df7c0a2c3a83ba2d6a1de26b085a580 Mon Sep 17 00:00:00 2001 From: grossmj Date: Wed, 8 Apr 2026 20:02:01 +0800 Subject: [PATCH] Snapshot refactoring --- gns3server/controller/export_project.py | 6 +- gns3server/controller/project.py | 73 +++++++++++++++---- gns3server/controller/snapshot.py | 34 ++++++--- gns3server/schemas/snapshot.py | 9 ++- gns3server/web/route.py | 2 +- tests/controller/test_export_project.py | 1 - tests/controller/test_snapshot.py | 43 ++++++----- .../handlers/api/controller/test_snapshot.py | 2 +- 8 files changed, 125 insertions(+), 45 deletions(-) diff --git a/gns3server/controller/export_project.py b/gns3server/controller/export_project.py index a2d6b5a87..1a7c43995 100644 --- a/gns3server/controller/export_project.py +++ b/gns3server/controller/export_project.py @@ -87,6 +87,10 @@ async def export_project(zstream, project, temporary_dir, include_images=False, # save empty directories for directory in dirs: path = os.path.join(root, directory) + if path == temporary_dir: + continue + if include_snapshots is False and path.endswith("snapshots"): + continue if not os.listdir(path): zstream.write(path, os.path.relpath(path, project._path)) except FileNotFoundError as e: @@ -142,7 +146,7 @@ def _is_exportable(path, include_snapshots=False): """ # do not export snapshots by default - if include_snapshots is False and path.endswith("snapshots"): + if include_snapshots is False and os.path.dirname(path).endswith("snapshots"): return False # do not export directories of snapshots diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 743758c72..031fb5005 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -96,6 +96,7 @@ class Project: self._show_interface_labels = show_interface_labels self._variables = variables self._supplier = supplier + self._snapshots_config_file = "snapshots.conf" self._loading = False self._closing = False @@ -189,19 +190,7 @@ class Project: self._drawings = {} self._snapshots = {} self._computes = [] - - # List the available snapshots - snapshot_dir = os.path.join(self.path, "snapshots") - if os.path.exists(snapshot_dir): - for snap in os.listdir(snapshot_dir): - if snap.endswith(".gns3project"): - try: - snapshot = Snapshot(self, filename=snap) - except ValueError: - log.error("Invalid snapshot file: {}".format(snap)) - continue - self._snapshots[snapshot.id] = snapshot - + self._load_snapshot_config() # Create the project on demand on the compute node self._project_created_on_compute = set() @@ -782,6 +771,62 @@ class Project: except KeyError: raise aiohttp.web.HTTPNotFound(text="Snapshot ID {} doesn't exist".format(snapshot_id)) + def _load_snapshot_config(self): + + snapshot_dir = os.path.join(self.path, "snapshots") + self._snapshot_conf_path = os.path.join(snapshot_dir, self._snapshots_config_file) + self._snapshot_conf = [] + if os.path.isfile(self._snapshot_conf_path): + try: + with open(self._snapshot_conf_path, encoding="utf-8") as f: + self._snapshot_conf = json.load(f) + except (OSError, UnicodeDecodeError, ValueError) as e: + raise aiohttp.web.HTTPConflict(text="Could not read snapshot config {}: {}".format(self._snapshot_conf_path, str(e))) + + # Load all legacy snapshots (.gns3project files) to create an initial snapshot config if it doesn't exist + if os.path.exists(snapshot_dir) and not self._snapshot_conf: + for snap in os.listdir(snapshot_dir): + if snap.endswith(".gns3project"): + try: + snapshot = Snapshot(self, filename=snap) + except ValueError: + log.error("Invalid snapshot file: {}".format(snap)) + continue + self._snapshots[snapshot.id] = snapshot + else: + # Create the snapshot instances from the snapshot config file + for snapshot_entry in self._snapshot_conf: + try: + name = snapshot_entry["name"] + snapshot_id = snapshot_entry["snapshot_id"] + created_at = snapshot_entry["created_at"] + filename = snapshot_entry["filename"] + path = os.path.join(snapshot_dir, filename) + if not os.path.isfile(path): + log.warning("Snapshot file '{}' does not exist".format(path)) + continue + snapshot = Snapshot(self, name=name, filename=filename, snapshot_id=snapshot_id, created_at=created_at) + self._snapshots[snapshot.id] = snapshot + except KeyError: + log.error("Invalid entry in snapshot config file: {}".format(snapshot_entry)) + continue + + self._save_snapshot_config() + + def _save_snapshot_config(self): + + if not self._snapshots: + return + + self._snapshot_conf = [] + for snapshot in self._snapshots.values(): + self._snapshot_conf.append(snapshot.__json__()) + try: + with open(self._snapshot_conf_path, 'w+') as f: + json.dump(self._snapshot_conf, f, indent=4) + except OSError as e: + log.error("Cannot write snapshot config '{}': {}".format(self._snapshot_conf_path, e)) + @open_required async def snapshot(self, name): """ @@ -795,12 +840,14 @@ class Project: snapshot = Snapshot(self, name=name) await snapshot.create() self._snapshots[snapshot.id] = snapshot + self._save_snapshot_config() return snapshot @open_required async def delete_snapshot(self, snapshot_id): snapshot = self.get_snapshot(snapshot_id) del self._snapshots[snapshot.id] + self._save_snapshot_config() os.remove(snapshot.path) @locking diff --git a/gns3server/controller/snapshot.py b/gns3server/controller/snapshot.py index 4a45dacab..44a52dcc7 100644 --- a/gns3server/controller/snapshot.py +++ b/gns3server/controller/snapshot.py @@ -44,21 +44,31 @@ class Snapshot: A snapshot object """ - def __init__(self, project, name=None, filename=None): + def __init__(self, project, snapshot_id=None, name=None, filename=None, created_at=None): assert filename or name, "You need to pass a name or a filename" - self._id = str(uuid.uuid4()) # We don't need to keep id between project loading because they are use only as key for operation like delete, update.. but have no impact on disk + if snapshot_id: + self._id = snapshot_id + else: + self._id = str(uuid.uuid4()) + self._project = project if name: self._name = name - self._created_at = datetime.now(timezone.utc).timestamp() - filename = self._name + "_" + datetime.fromtimestamp(self._created_at, tz=timezone.utc).replace(tzinfo=None).strftime(FILENAME_TIME_FORMAT) + ".gns3project" + + if created_at: + self._created_at = created_at + else: + self._created_at = int(datetime.now(timezone.utc).timestamp()) + if not filename: + filename = self._name + ".gns3snapshot" else: self._name = filename.rsplit("_", 2)[0] datestring = filename.replace(self._name + "_", "").split(".")[0] - self._created_at = datetime.strptime(datestring, FILENAME_TIME_FORMAT).replace(tzinfo=timezone.utc).timestamp() + self._created_at = int(datetime.strptime(datestring, FILENAME_TIME_FORMAT).replace(tzinfo=timezone.utc).timestamp()) + self._filename = filename self._path = os.path.join(project.path, "snapshots", filename) @property @@ -119,9 +129,14 @@ class Snapshot: if os.path.exists(project_files_path): await wait_run_in_executor(shutil.rmtree, project_files_path) with open(self._path, "rb") as f: - project = await import_project(self._project.controller, self._project.id, f, location=self._project.path, - auto_start=self._project.auto_start, auto_open=self._project.auto_open, - auto_close=self._project.auto_close) + project = await import_project( + self._project.controller, + self._project.id, + f, + location=self._project.path, + auto_start=self._project.auto_start, auto_open=self._project.auto_open, + auto_close=self._project.auto_close + ) except (OSError, PermissionError) as e: raise aiohttp.web.HTTPConflict(text=str(e)) await project.open() @@ -132,6 +147,7 @@ class Snapshot: return { "snapshot_id": self._id, "name": self._name, - "created_at": int(self._created_at), + "created_at": self._created_at, + "filename": self._filename, "project_id": self._project.id } diff --git a/gns3server/schemas/snapshot.py b/gns3server/schemas/snapshot.py index 1c306cd2e..7481c6457 100644 --- a/gns3server/schemas/snapshot.py +++ b/gns3server/schemas/snapshot.py @@ -50,7 +50,12 @@ SNAPSHOT_OBJECT_SCHEMA = { "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" }, "name": { - "description": "Project name", + "description": "Snapshot name", + "type": "string", + "minLength": 1 + }, + "filename": { + "description": "Snapshot filename", "type": "string", "minLength": 1 }, @@ -60,5 +65,5 @@ SNAPSHOT_OBJECT_SCHEMA = { } }, "additionalProperties": False, - "required": ["snapshot_id", "name", "created_at", "project_id"] + "required": ["snapshot_id", "name", "filename", "created_at", "project_id"] } diff --git a/gns3server/web/route.py b/gns3server/web/route.py index 4a7103a3c..b77095d8e 100644 --- a/gns3server/web/route.py +++ b/gns3server/web/route.py @@ -126,7 +126,7 @@ class Route(object): response.set_status(401) response.headers["WWW-Authenticate"] = 'Basic realm="GNS3 server"' # Force close the keep alive. Work around a Qt issue where Qt timeout instead of handling the 401 - # this happen only for the first query send by the client. + # this happens only for the first query send by the client. response.force_close() return response diff --git a/tests/controller/test_export_project.py b/tests/controller/test_export_project.py index de6335a4c..d95936769 100644 --- a/tests/controller/test_export_project.py +++ b/tests/controller/test_export_project.py @@ -68,7 +68,6 @@ def test_exportable_files(): assert not _is_exportable("project-files/tmp") assert not _is_exportable("project-files/test_log.txt") assert not _is_exportable("project-files/test.log") - assert not _is_exportable("test/snapshots") assert not _is_exportable("test/project-files/snapshots") assert not _is_exportable("test/project-files/snapshots/test.gns3p") diff --git a/tests/controller/test_snapshot.py b/tests/controller/test_snapshot.py index 9eaccb7bf..2760bfcac 100644 --- a/tests/controller/test_snapshot.py +++ b/tests/controller/test_snapshot.py @@ -16,22 +16,16 @@ # along with this program. If not, see . import os -import pytest -from unittest.mock import patch, MagicMock +from uuid import uuid4 -from gns3server.controller.project import Project +import pytest +from unittest import mock +from unittest.mock import patch, MagicMock from gns3server.controller.snapshot import Snapshot from tests.utils import AsyncioMagicMock -# @pytest.fixture -# def project(controller): -# project = Project(controller=controller, name="Test") -# controller._projects[project.id] = project -# return project - - def test_snapshot_name(project): """ Test create a snapshot object with a name @@ -40,12 +34,7 @@ def test_snapshot_name(project): snapshot = Snapshot(project, name="test1") assert snapshot.name == "test1" assert snapshot._created_at > 0 - assert snapshot.path.startswith(os.path.join(project.path, "snapshots", "test1_")) - assert snapshot.path.endswith(".gns3project") - - # Check if UTC conversion doesn't corrupt the path - snap2 = Snapshot(project, filename=os.path.basename(snapshot.path)) - assert snap2.path == snapshot.path + assert snapshot.path == os.path.join(project.path, "snapshots", "test1.gns3snapshot") def test_snapshot_filename(project): @@ -53,22 +42,42 @@ def test_snapshot_filename(project): Test create a snapshot object with a filename """ + # legacy snapshot snapshot = Snapshot(project, filename="test1_260716_100439.gns3project") assert snapshot.name == "test1" - assert snapshot._created_at == 1469527479.0 + assert snapshot._created_at == 1469527479 assert snapshot.path == os.path.join(project.path, "snapshots", "test1_260716_100439.gns3project") + # new style snapshot + snapshot_id = str(uuid4()) + snapshot = Snapshot(project, snapshot_id=snapshot_id, name="test2", created_at=1469527479) + assert snapshot.id == snapshot_id + assert snapshot.name == "test2" + assert snapshot.path == os.path.join(project.path, "snapshots", "test2.gns3snapshot") + assert snapshot._created_at == 1469527479 + def test_json(project): + # legacy snapshot snapshot = Snapshot(project, filename="snapshot_test_260716_100439.gns3project") assert snapshot.__json__() == { "snapshot_id": snapshot._id, "name": "snapshot_test", "project_id": project.id, + "filename": "snapshot_test_260716_100439.gns3project", "created_at": 1469527479 } + # new style snapshot + snapshot = Snapshot(project, name="snapshot_test2") + assert snapshot.__json__() == { + "snapshot_id": snapshot._id, + "name": "snapshot_test2", + "project_id": project.id, + "filename": "snapshot_test2.gns3snapshot", + "created_at": mock.ANY + } def test_invalid_snapshot_filename(project): diff --git a/tests/handlers/api/controller/test_snapshot.py b/tests/handlers/api/controller/test_snapshot.py index faed67df1..b8a6966c7 100644 --- a/tests/handlers/api/controller/test_snapshot.py +++ b/tests/handlers/api/controller/test_snapshot.py @@ -63,4 +63,4 @@ async def test_create_snapshot(controller_api, project): response = await controller_api.post("/projects/{}/snapshots".format(project.id), {"name": "snap1"}) assert response.status == 201 - assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 1 + assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 2