Replace snapshots by import / export

Fix #1042
This commit is contained in:
Julien Duponchelle 2016-07-26 10:32:43 +02:00
parent 814fd1fcfb
commit 68eca6c111
No known key found for this signature in database
GPG Key ID: CE8B29639E07F5E8
14 changed files with 606 additions and 18 deletions

View File

@ -296,9 +296,9 @@ class Controller:
topo_data.pop("type") topo_data.pop("type")
if topo_data["project_id"] in self._projects: if topo_data["project_id"] in self._projects:
project = self._projects[topo_data["project_id"]] self.remove_project(self._projects[topo_data["project_id"]])
else:
project = yield from self.add_project(path=os.path.dirname(path), status="closed", filename=os.path.basename(path), **topo_data) project = yield from self.add_project(path=os.path.dirname(path), status="closed", filename=os.path.basename(path), **topo_data)
if load: if load:
yield from project.open() yield from project.open()
return project return project

View File

@ -91,6 +91,10 @@ def _filter_files(path):
:returns: True if file should not be included in the final archive :returns: True if file should not be included in the final archive
""" """
s = os.path.normpath(path).split(os.path.sep) s = os.path.normpath(path).split(os.path.sep)
if path.endswith("snapshots"):
return True
try: try:
i = s.index("project-files") i = s.index("project-files")
if s[i + 1] in ("tmp", "captures", "snapshots"): if s[i + 1] in ("tmp", "captures", "snapshots"):

View File

@ -43,7 +43,7 @@ def import_project(controller, project_id, stream, location=None, name=None, kee
:param controller: GNS3 Controller :param controller: GNS3 Controller
:param project_id: ID of the project to import :param project_id: ID of the project to import
:param stream: A io.BytesIO of the zipfile :param stream: A io.BytesIO of the zipfile
:param location: Parent directory for the project if None put in the default directory :param location: Directory for the project if None put in the default directory
:param name: Wanted project name, generate one from the .gns3 if None :param name: Wanted project name, generate one from the .gns3 if None
:param keep_compute_id: If true do not touch the compute id :param keep_compute_id: If true do not touch the compute id
:returns: Project :returns: Project
@ -53,11 +53,16 @@ def import_project(controller, project_id, stream, location=None, name=None, kee
try: try:
topology = json.loads(myzip.read("project.gns3").decode()) topology = json.loads(myzip.read("project.gns3").decode())
# If the project name is already used we generate a new one
if name: # We import the project on top of an existing project (snapshots)
project_name = controller.get_free_project_name(name) if topology["project_id"] == project_id:
project_name = topology["name"]
else: else:
project_name = controller.get_free_project_name(topology["name"]) # If the project name is already used we generate a new one
if name:
project_name = controller.get_free_project_name(name)
else:
project_name = controller.get_free_project_name(topology["name"])
except KeyError: except KeyError:
raise aiohttp.web.HTTPConflict(text="Can't import topology the .gns3 is corrupted or missing") raise aiohttp.web.HTTPConflict(text="Can't import topology the .gns3 is corrupted or missing")
@ -66,7 +71,7 @@ def import_project(controller, project_id, stream, location=None, name=None, kee
else: else:
projects_path = controller.projects_directory() projects_path = controller.projects_directory()
path = os.path.join(projects_path, project_name) path = os.path.join(projects_path, project_name)
os.makedirs(path) os.makedirs(path, exist_ok=True)
myzip.extractall(path) myzip.extractall(path)
topology = load_topology(os.path.join(path, "project.gns3")) topology = load_topology(os.path.join(path, "project.gns3"))

View File

@ -26,6 +26,7 @@ import tempfile
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from .node import Node from .node import Node
from .snapshot import Snapshot
from .drawing import Drawing from .drawing import Drawing
from .topology import project_to_topology, load_topology from .topology import project_to_topology, load_topology
from .udp_link import UDPLink from .udp_link import UDPLink
@ -105,6 +106,15 @@ class Project:
self._nodes = {} self._nodes = {}
self._links = {} self._links = {}
self._drawings = {} self._drawings = {}
self._snapshots = {}
# 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"):
snapshot = Snapshot(self, filename=snap)
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()
@ -378,12 +388,62 @@ class Project:
""" """
return self._links return self._links
@property
def snapshots(self):
"""
:returns: Dictionary of snapshots
"""
return self._snapshots
@open_required
def get_snapshot(self, snapshot_id):
"""
Return the snapshot or raise a 404 if the snapshot is unknown
"""
try:
return self._snapshots[snapshot_id]
except KeyError:
raise aiohttp.web.HTTPNotFound(text="Snapshot ID {} doesn't exist".format(snapshot_id))
@open_required
@asyncio.coroutine
def snapshot(self, name):
"""
Snapshot the project
:param name: Name of the snapshot
"""
snapshot = Snapshot(self, name=name)
try:
if os.path.exists(snapshot.path):
raise aiohttp.web_exceptions.HTTPConflict(text="The snapshot {} already exist".format(name))
os.makedirs(os.path.join(self.path, "snapshots"), exist_ok=True)
with tempfile.TemporaryDirectory() as tmpdir:
zipstream = yield from export_project(self, tmpdir, keep_compute_id=True, allow_all_nodes=True)
with open(snapshot.path, "wb+") as f:
for data in zipstream:
f.write(data)
except OSError as e:
raise aiohttp.web.HTTPInternalServerError(text="Could not create project directory: {}".format(e))
self._snapshots[snapshot.id] = snapshot
return snapshot
@open_required
@asyncio.coroutine
def delete_snapshot(self, snapshot_id):
snapshot = self.get_snapshot(snapshot_id)
del self._snapshots[snapshot.id]
os.remove(snapshot.path)
@asyncio.coroutine @asyncio.coroutine
def close(self): def close(self):
for compute in self._project_created_on_compute: for compute in self._project_created_on_compute:
yield from compute.post("/projects/{}/close".format(self._id)) yield from compute.post("/projects/{}/close".format(self._id))
self._cleanPictures() self._cleanPictures()
self.reset()
self._status = "closed" self._status = "closed"
def _cleanPictures(self): def _cleanPictures(self):
@ -405,10 +465,19 @@ class Project:
@asyncio.coroutine @asyncio.coroutine
def delete(self): def delete(self):
yield from self.close() yield from self.close()
for compute in self._project_created_on_compute: yield from self.delete_on_computes()
yield from compute.delete("/projects/{}".format(self._id))
shutil.rmtree(self.path, ignore_errors=True) shutil.rmtree(self.path, ignore_errors=True)
@asyncio.coroutine
def delete_on_computes(self):
"""
Delete the project on computes but not on controller
"""
for compute in self._project_created_on_compute:
if compute.id != "local":
yield from compute.delete("/projects/{}".format(self._id))
self._project_created_on_compute = set()
@classmethod @classmethod
def _get_default_project_directory(cls): def _get_default_project_directory(cls):
""" """

View File

@ -0,0 +1,93 @@
#!/usr/bin/env python
#
# Copyright (C) 2016 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import uuid
import shutil
import asyncio
from datetime import datetime, timezone
from .import_project import import_project
# The string use to extract the date from the filename
FILENAME_TIME_FORMAT = "%d%m%y_%H%M%S"
class Snapshot:
"""
A snapshot object
"""
def __init__(self, project, name=None, filename=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
self._project = project
if name:
self._name = name
self._created_at = datetime.now().timestamp()
filename = self._name + "_" + datetime.utcfromtimestamp(self._created_at).replace(tzinfo=None).strftime(FILENAME_TIME_FORMAT) + ".gns3project"
else:
self._name = filename.split("_")[0]
datestring = filename.replace(self._name + "_", "").split(".")[0]
try:
self._created_at = datetime.strptime(datestring, FILENAME_TIME_FORMAT).replace(tzinfo=timezone.utc).timestamp()
except ValueError:
self._created_at = datetime.utcnow().timestamp()
self._path = os.path.join(project.path, "snapshots", filename)
@property
def id(self):
return self._id
@property
def name(self):
return self._name
@property
def path(self):
return self._path
@property
def created_at(self):
return int(self._created_at)
@asyncio.coroutine
def restore(self):
"""
Restore the snapshot
"""
yield from self._project.delete_on_computes()
yield from self._project.close()
shutil.rmtree(os.path.join(self._project.path, "project-files"))
with open(self._path, "rb") as f:
project = yield from import_project(self._project.controller, self._project.id, f, location=self._project.path)
yield from project.open()
return project
def __json__(self):
return {
"snapshot_id": self._id,
"name": self._name,
"created_at": int(self._created_at),
"project_id": self._project.id
}

View File

@ -22,4 +22,5 @@ from .link_handler import LinkHandler
from .server_handler import ServerHandler from .server_handler import ServerHandler
from .drawing_handler import DrawingHandler from .drawing_handler import DrawingHandler
from .symbol_handler import SymbolHandler from .symbol_handler import SymbolHandler
from .snapshot_handler import SnapshotHandler
from .gns3_vm_handler import GNS3VMHandler from .gns3_vm_handler import GNS3VMHandler

View File

@ -0,0 +1,106 @@
#!/usr/bin/env python
#
# Copyright (C) 2016 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
log = logging.getLogger()
from gns3server.web.route import Route
from gns3server.controller import Controller
from gns3server.schemas.snapshot import (
SNAPSHOT_OBJECT_SCHEMA,
SNAPSHOT_CREATE_SCHEMA
)
from gns3server.schemas.project import PROJECT_OBJECT_SCHEMA
class SnapshotHandler:
@Route.post(
r"/projects/{project_id}/snapshots",
description="Create snapshot of a project",
parameters={
"project_id": "Project UUID",
},
input=SNAPSHOT_CREATE_SCHEMA,
output=SNAPSHOT_OBJECT_SCHEMA,
status_codes={
201: "Snasphot created",
404: "The project doesn't exist"
})
def create(request, response):
controller = Controller.instance()
project = controller.get_project(request.match_info["project_id"])
snapshot = yield from project.snapshot(request.json["name"])
response.json(snapshot)
response.set_status(201)
@Route.get(
r"/projects/{project_id}/snapshots",
description="List snapshots of a project",
parameters={
"project_id": "Project UUID",
},
status_codes={
200: "Snasphot list returned",
404: "The project doesn't exist"
})
def list(request, response):
controller = Controller.instance()
project = controller.get_project(request.match_info["project_id"])
snapshots = [s for s in project.snapshots.values()]
response.json(sorted(snapshots, key=lambda s: s.created_at))
@Route.delete(
r"/projects/{project_id}/snapshots/{snapshot_id}",
description="Delete a snapshot from disk",
parameters={
"project_id": "Project UUID",
"snapshot_id": "Snasphot UUID"
},
status_codes={
204: "Changes have been written on disk",
404: "The project or snapshot doesn't exist"
})
def delete(request, response):
controller = Controller.instance()
project = controller.get_project(request.match_info["project_id"])
yield from project.delete_snapshot(request.match_info["snapshot_id"])
response.set_status(204)
@Route.post(
r"/projects/{project_id}/snapshots/{snapshot_id}/restore",
description="Restore a snapshot from disk",
parameters={
"project_id": "Project UUID",
"snapshot_id": "Snasphot UUID"
},
output=PROJECT_OBJECT_SCHEMA,
status_codes={
201: "The snapshot has been restored",
404: "The project or snapshot doesn't exist"
})
def restore(request, response):
controller = Controller.instance()
project = controller.get_project(request.match_info["project_id"])
snapshot = project.get_snapshot(request.match_info["snapshot_id"])
project = yield from snapshot.restore()
response.set_status(201)
response.json(project)

View File

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2015 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
SNAPSHOT_CREATE_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Request validation to create a new snapshot",
"type": "object",
"properties": {
"name": {
"description": "Snapshot name",
"minLength": 1
},
},
"additionalProperties": False,
"required": ["name"]
}
SNAPSHOT_OBJECT_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Request validation to update a Project instance",
"type": "object",
"properties": {
"snapshot_id": {
"description": "Snapshot UUID",
"type": "string",
"minLength": 36,
"maxLength": 36,
"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}$"
},
"project_id": {
"description": "Project UUID",
"type": "string",
"minLength": 36,
"maxLength": 36,
"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",
"type": "string",
"minLength": 1
},
"created_at": {
"description": "Date of the snapshot (UTC timestamp)",
"type": "integer"
}
},
"additionalProperties": False,
"required": ["snapshot_id", "name", "created_at", "project_id"]
}

View File

@ -338,11 +338,6 @@ def test_load_project(controller, async_run, tmpdir):
node1 = project.get_node("50d66d7b-0dd7-4e9f-b720-6eb621ae6543") node1 = project.get_node("50d66d7b-0dd7-4e9f-b720-6eb621ae6543")
assert node1.name == "PC1" assert node1.name == "PC1"
# Reload the same project should do nothing
with asyncio_patch("gns3server.controller.Controller.add_project") as mock_add_project:
project = async_run(controller.load_project(str(tmpdir / "test.gns3")))
assert not mock_add_project.called
def test_get_free_project_name(controller, async_run): def test_get_free_project_name(controller, async_run):

View File

@ -54,6 +54,7 @@ def test_filter_files():
assert _filter_files("project-files/tmp") assert _filter_files("project-files/tmp")
assert _filter_files("project-files/test_log.txt") assert _filter_files("project-files/test_log.txt")
assert _filter_files("project-files/test.log") assert _filter_files("project-files/test.log")
assert _filter_files("test/snapshots")
assert _filter_files("test/project-files/snapshots") assert _filter_files("test/project-files/snapshots")
assert _filter_files("test/project-files/snapshots/test.gns3p") assert _filter_files("test/project-files/snapshots/test.gns3p")

View File

@ -56,7 +56,7 @@ def test_import_project(async_run, tmpdir, controller):
project = async_run(import_project(controller, project_id, f)) project = async_run(import_project(controller, project_id, f))
assert project.name == "test" assert project.name == "test"
assert project.id == project_id # The project should changed assert project.id == project_id
assert os.path.exists(os.path.join(project.path, "b.png")) assert os.path.exists(os.path.join(project.path, "b.png"))
assert not os.path.exists(os.path.join(project.path, "project.gns3")) assert not os.path.exists(os.path.join(project.path, "project.gns3"))
@ -70,6 +70,41 @@ def test_import_project(async_run, tmpdir, controller):
assert project.name != "test" assert project.name != "test"
def test_import_project_override(async_run, tmpdir, controller):
"""
In the case of snapshot we will import a project for
override the previous keeping the same project id & location
"""
project_id = str(uuid.uuid4())
topology = {
"project_id": project_id,
"name": "test",
"topology": {
},
"version": "2.0.0"
}
with open(str(tmpdir / "project.gns3"), 'w+') as f:
json.dump(topology, f)
zip_path = str(tmpdir / "project.zip")
with zipfile.ZipFile(zip_path, 'w') as myzip:
myzip.write(str(tmpdir / "project.gns3"), "project.gns3")
with open(zip_path, "rb") as f:
project = async_run(import_project(controller, project_id, f, location=str(tmpdir)))
assert project.name == "test"
assert project.id == project_id
# Overide the project with same project
with open(zip_path, "rb") as f:
project = async_run(import_project(controller, project_id, f, location=str(tmpdir)))
assert project.id == project_id
assert project.name == "test"
def test_import_upgrade(async_run, tmpdir, controller): def test_import_upgrade(async_run, tmpdir, controller):
""" """
Topology made for previous GNS3 version are upgraded during the process Topology made for previous GNS3 version are upgraded during the process

View File

@ -373,3 +373,60 @@ def test_duplicate(project, async_run, controller):
assert new_project.get_node(remote_vpcs.id).compute.id == "remote" assert new_project.get_node(remote_vpcs.id).compute.id == "remote"
assert new_project.get_node(remote_virtualbox.id).compute.id == "remote" assert new_project.get_node(remote_virtualbox.id).compute.id == "remote"
def test_snapshots(project):
"""
List the snapshots
"""
os.makedirs(os.path.join(project.path, "snapshots"))
open(os.path.join(project.path, "snapshots", "test1_260716_103713.gns3project"), "w+").close()
project.reset()
assert len(project.snapshots) == 1
assert list(project.snapshots.values())[0].name == "test1"
def test_get_snapshot(project):
os.makedirs(os.path.join(project.path, "snapshots"))
open(os.path.join(project.path, "snapshots", "test1.gns3project"), "w+").close()
project.reset()
snapshot = list(project.snapshots.values())[0]
assert project.get_snapshot(snapshot.id) == snapshot
with pytest.raises(aiohttp.web_exceptions.HTTPNotFound):
project.get_snapshot("BLU")
def test_delete_snapshot(project, async_run):
os.makedirs(os.path.join(project.path, "snapshots"))
open(os.path.join(project.path, "snapshots", "test1_260716_103713.gns3project"), "w+").close()
project.reset()
snapshot = list(project.snapshots.values())[0]
assert project.get_snapshot(snapshot.id) == snapshot
async_run(project.delete_snapshot(snapshot.id))
with pytest.raises(aiohttp.web_exceptions.HTTPNotFound):
project.get_snapshot(snapshot.id)
assert not os.path.exists(os.path.join(project.path, "snapshots", "test1.gns3project"))
def test_snapshot(project, async_run):
"""
Create a snapshot
"""
assert len(project.snapshots) == 0
snapshot = async_run(project.snapshot("test1"))
assert snapshot.name == "test1"
assert len(project.snapshots) == 1
assert list(project.snapshots.values())[0].name == "test1"
# Raise a conflict if name is already use
with pytest.raises(aiohttp.web_exceptions.HTTPConflict):
snapshot = async_run(project.snapshot("test1"))

View File

@ -0,0 +1,98 @@
#!/usr/bin/env python
#
# Copyright (C) 2016 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import pytest
from unittest.mock import patch
from gns3server.controller.project import Project
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
"""
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
def test_snapshot_filename(project):
"""
Test create a snapshot object with a filename
"""
snapshot = Snapshot(project, filename="test1_260716_100439.gns3project")
assert snapshot.name == "test1"
assert snapshot._created_at == 1469527479.0
assert snapshot.path == os.path.join(project.path, "snapshots", "test1_260716_100439.gns3project")
def test_json(project):
snapshot = Snapshot(project, filename="test1_260716_100439.gns3project")
assert snapshot.__json__() == {
"snapshot_id": snapshot._id,
"name": "test1",
"project_id": project.id,
"created_at": 1469527479
}
def test_restore(project, controller, async_run):
compute = AsyncioMagicMock()
compute.id = "local"
controller._computes["local"] = compute
response = AsyncioMagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
async_run(project.add_node(compute, "test1", None, node_type="vpcs", properties={"startup_config": "test.cfg"}))
snapshot = async_run(project.snapshot(name="test"))
# We add a node after the snapshots
async_run(project.add_node(compute, "test2", None, node_type="vpcs", properties={"startup_config": "test.cfg"}))
# project-files should be reset when reimporting
test_file = os.path.join(project.path, "project-files", "test.txt")
os.makedirs(os.path.join(project.path, "project-files"))
open(test_file, "a+").close()
assert os.path.exists(test_file)
assert len(project.nodes) == 2
with patch("gns3server.config.Config.get_section_config", return_value={"local": True}):
async_run(snapshot.restore())
project = controller.get_project(project.id)
assert not os.path.exists(test_file)
assert len(project.nodes) == 1

View File

@ -0,0 +1,60 @@
#!/usr/bin/env python
#
# Copyright (C) 2016 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import uuid
import pytest
@pytest.fixture
def project(http_controller, controller):
u = str(uuid.uuid4())
query = {"name": "test", "project_id": u}
response = http_controller.post("/projects", query)
project = controller.get_project(u)
return project
@pytest.fixture
def snapshot(project, async_run):
snapshot = async_run(project.snapshot("test"))
return snapshot
def test_list_snapshots(http_controller, project, snapshot):
response = http_controller.get("/projects/{}/snapshots".format(project.id), example=True)
assert response.status == 200
assert len(response.json) == 1
def test_delete_snapshot(http_controller, project, snapshot):
response = http_controller.delete("/projects/{}/snapshots/{}".format(project.id, snapshot.id), example=True)
assert response.status == 204
assert not os.path.exists(snapshot.path)
def test_restore_snapshot(http_controller, project, snapshot):
response = http_controller.post("/projects/{}/snapshots/{}/restore".format(project.id, snapshot.id), example=True)
assert response.status == 201
assert response.json["name"] == project.name
def test_create_snapshot(http_controller, project):
response = http_controller.post("/projects/{}/snapshots".format(project.id), {"name": "snap1"}, example=True)
assert response.status == 201
assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 1