Snapshot refactoring

This commit is contained in:
grossmj 2026-04-08 20:02:01 +08:00
parent e871a1aed6
commit 6869f2d71d
No known key found for this signature in database
GPG Key ID: 1E7DD6DBB53FF3D7
8 changed files with 125 additions and 45 deletions

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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"]
}

View File

@ -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

View File

@ -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")

View File

@ -16,22 +16,16 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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):

View File

@ -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