mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-25 19:32:24 +02:00
Snapshot refactoring
This commit is contained in:
parent
e871a1aed6
commit
6869f2d71d
@ -87,6 +87,10 @@ async def export_project(zstream, project, temporary_dir, include_images=False,
|
|||||||
# save empty directories
|
# save empty directories
|
||||||
for directory in dirs:
|
for directory in dirs:
|
||||||
path = os.path.join(root, directory)
|
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):
|
if not os.listdir(path):
|
||||||
zstream.write(path, os.path.relpath(path, project._path))
|
zstream.write(path, os.path.relpath(path, project._path))
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
@ -142,7 +146,7 @@ def _is_exportable(path, include_snapshots=False):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# do not export snapshots by default
|
# 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
|
return False
|
||||||
|
|
||||||
# do not export directories of snapshots
|
# do not export directories of snapshots
|
||||||
|
|||||||
@ -96,6 +96,7 @@ class Project:
|
|||||||
self._show_interface_labels = show_interface_labels
|
self._show_interface_labels = show_interface_labels
|
||||||
self._variables = variables
|
self._variables = variables
|
||||||
self._supplier = supplier
|
self._supplier = supplier
|
||||||
|
self._snapshots_config_file = "snapshots.conf"
|
||||||
|
|
||||||
self._loading = False
|
self._loading = False
|
||||||
self._closing = False
|
self._closing = False
|
||||||
@ -189,19 +190,7 @@ class Project:
|
|||||||
self._drawings = {}
|
self._drawings = {}
|
||||||
self._snapshots = {}
|
self._snapshots = {}
|
||||||
self._computes = []
|
self._computes = []
|
||||||
|
self._load_snapshot_config()
|
||||||
# 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
|
|
||||||
|
|
||||||
# Create the project on demand on the compute node
|
# Create the project on demand on the compute node
|
||||||
self._project_created_on_compute = set()
|
self._project_created_on_compute = set()
|
||||||
|
|
||||||
@ -782,6 +771,62 @@ class Project:
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
raise aiohttp.web.HTTPNotFound(text="Snapshot ID {} doesn't exist".format(snapshot_id))
|
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
|
@open_required
|
||||||
async def snapshot(self, name):
|
async def snapshot(self, name):
|
||||||
"""
|
"""
|
||||||
@ -795,12 +840,14 @@ class Project:
|
|||||||
snapshot = Snapshot(self, name=name)
|
snapshot = Snapshot(self, name=name)
|
||||||
await snapshot.create()
|
await snapshot.create()
|
||||||
self._snapshots[snapshot.id] = snapshot
|
self._snapshots[snapshot.id] = snapshot
|
||||||
|
self._save_snapshot_config()
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
@open_required
|
@open_required
|
||||||
async def delete_snapshot(self, snapshot_id):
|
async def delete_snapshot(self, snapshot_id):
|
||||||
snapshot = self.get_snapshot(snapshot_id)
|
snapshot = self.get_snapshot(snapshot_id)
|
||||||
del self._snapshots[snapshot.id]
|
del self._snapshots[snapshot.id]
|
||||||
|
self._save_snapshot_config()
|
||||||
os.remove(snapshot.path)
|
os.remove(snapshot.path)
|
||||||
|
|
||||||
@locking
|
@locking
|
||||||
|
|||||||
@ -44,21 +44,31 @@ class Snapshot:
|
|||||||
A snapshot object
|
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"
|
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
|
self._project = project
|
||||||
if name:
|
if name:
|
||||||
self._name = 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:
|
else:
|
||||||
self._name = filename.rsplit("_", 2)[0]
|
self._name = filename.rsplit("_", 2)[0]
|
||||||
datestring = filename.replace(self._name + "_", "").split(".")[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)
|
self._path = os.path.join(project.path, "snapshots", filename)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -119,9 +129,14 @@ class Snapshot:
|
|||||||
if os.path.exists(project_files_path):
|
if os.path.exists(project_files_path):
|
||||||
await wait_run_in_executor(shutil.rmtree, project_files_path)
|
await wait_run_in_executor(shutil.rmtree, project_files_path)
|
||||||
with open(self._path, "rb") as f:
|
with open(self._path, "rb") as f:
|
||||||
project = await import_project(self._project.controller, self._project.id, f, location=self._project.path,
|
project = await import_project(
|
||||||
auto_start=self._project.auto_start, auto_open=self._project.auto_open,
|
self._project.controller,
|
||||||
auto_close=self._project.auto_close)
|
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:
|
except (OSError, PermissionError) as e:
|
||||||
raise aiohttp.web.HTTPConflict(text=str(e))
|
raise aiohttp.web.HTTPConflict(text=str(e))
|
||||||
await project.open()
|
await project.open()
|
||||||
@ -132,6 +147,7 @@ class Snapshot:
|
|||||||
return {
|
return {
|
||||||
"snapshot_id": self._id,
|
"snapshot_id": self._id,
|
||||||
"name": self._name,
|
"name": self._name,
|
||||||
"created_at": int(self._created_at),
|
"created_at": self._created_at,
|
||||||
|
"filename": self._filename,
|
||||||
"project_id": self._project.id
|
"project_id": self._project.id
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}$"
|
"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": {
|
"name": {
|
||||||
"description": "Project name",
|
"description": "Snapshot name",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"description": "Snapshot filename",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1
|
"minLength": 1
|
||||||
},
|
},
|
||||||
@ -60,5 +65,5 @@ SNAPSHOT_OBJECT_SCHEMA = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
"required": ["snapshot_id", "name", "created_at", "project_id"]
|
"required": ["snapshot_id", "name", "filename", "created_at", "project_id"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -126,7 +126,7 @@ class Route(object):
|
|||||||
response.set_status(401)
|
response.set_status(401)
|
||||||
response.headers["WWW-Authenticate"] = 'Basic realm="GNS3 server"'
|
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
|
# 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()
|
response.force_close()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@ -68,7 +68,6 @@ def test_exportable_files():
|
|||||||
assert not _is_exportable("project-files/tmp")
|
assert not _is_exportable("project-files/tmp")
|
||||||
assert not _is_exportable("project-files/test_log.txt")
|
assert not _is_exportable("project-files/test_log.txt")
|
||||||
assert not _is_exportable("project-files/test.log")
|
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")
|
||||||
assert not _is_exportable("test/project-files/snapshots/test.gns3p")
|
assert not _is_exportable("test/project-files/snapshots/test.gns3p")
|
||||||
|
|
||||||
|
|||||||
@ -16,22 +16,16 @@
|
|||||||
# 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 pytest
|
from uuid import uuid4
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
|
|
||||||
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 gns3server.controller.snapshot import Snapshot
|
||||||
|
|
||||||
from tests.utils import AsyncioMagicMock
|
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):
|
def test_snapshot_name(project):
|
||||||
"""
|
"""
|
||||||
Test create a snapshot object with a name
|
Test create a snapshot object with a name
|
||||||
@ -40,12 +34,7 @@ def test_snapshot_name(project):
|
|||||||
snapshot = Snapshot(project, name="test1")
|
snapshot = Snapshot(project, name="test1")
|
||||||
assert snapshot.name == "test1"
|
assert snapshot.name == "test1"
|
||||||
assert snapshot._created_at > 0
|
assert snapshot._created_at > 0
|
||||||
assert snapshot.path.startswith(os.path.join(project.path, "snapshots", "test1_"))
|
assert snapshot.path == os.path.join(project.path, "snapshots", "test1.gns3snapshot")
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_snapshot_filename(project):
|
def test_snapshot_filename(project):
|
||||||
@ -53,22 +42,42 @@ def test_snapshot_filename(project):
|
|||||||
Test create a snapshot object with a filename
|
Test create a snapshot object with a filename
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# legacy snapshot
|
||||||
snapshot = Snapshot(project, filename="test1_260716_100439.gns3project")
|
snapshot = Snapshot(project, filename="test1_260716_100439.gns3project")
|
||||||
assert snapshot.name == "test1"
|
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")
|
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):
|
def test_json(project):
|
||||||
|
|
||||||
|
# legacy snapshot
|
||||||
snapshot = Snapshot(project, filename="snapshot_test_260716_100439.gns3project")
|
snapshot = Snapshot(project, filename="snapshot_test_260716_100439.gns3project")
|
||||||
assert snapshot.__json__() == {
|
assert snapshot.__json__() == {
|
||||||
"snapshot_id": snapshot._id,
|
"snapshot_id": snapshot._id,
|
||||||
"name": "snapshot_test",
|
"name": "snapshot_test",
|
||||||
"project_id": project.id,
|
"project_id": project.id,
|
||||||
|
"filename": "snapshot_test_260716_100439.gns3project",
|
||||||
"created_at": 1469527479
|
"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):
|
def test_invalid_snapshot_filename(project):
|
||||||
|
|
||||||
|
|||||||
@ -63,4 +63,4 @@ async def test_create_snapshot(controller_api, project):
|
|||||||
|
|
||||||
response = await controller_api.post("/projects/{}/snapshots".format(project.id), {"name": "snap1"})
|
response = await controller_api.post("/projects/{}/snapshots".format(project.id), {"name": "snap1"})
|
||||||
assert response.status == 201
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user