gns3-server/gns3server/api/routes/controller/projects.py

408 lines
14 KiB
Python
Raw Normal View History

2020-10-02 09:37:50 +03:00
#
# Copyright (C) 2020 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/>.
"""
API routes for projects.
2020-10-02 09:37:50 +03:00
"""
import os
import asyncio
import tempfile
import zipfile
import aiofiles
import time
import logging
2021-04-13 12:16:50 +03:00
log = logging.getLogger()
from fastapi import APIRouter, Depends, Request, Body, HTTPException, status, WebSocket, WebSocketDisconnect
2020-10-02 09:37:50 +03:00
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse, FileResponse
from websockets.exceptions import ConnectionClosed, WebSocketException
from typing import List, Optional
2020-10-02 09:37:50 +03:00
from uuid import UUID
from pathlib import Path
2020-10-02 09:37:50 +03:00
2020-10-31 07:32:21 +02:00
from gns3server import schemas
2020-10-02 09:37:50 +03:00
from gns3server.controller import Controller
from gns3server.controller.project import Project
2020-10-02 09:37:50 +03:00
from gns3server.controller.controller_error import ControllerError, ControllerForbiddenError
from gns3server.controller.import_project import import_project as import_controller_project
from gns3server.controller.export_project import export_project as export_controller_project
from gns3server.utils.asyncio import aiozipstream
from gns3server.config import Config
2021-04-13 12:16:50 +03:00
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project"}}
2020-10-02 09:37:50 +03:00
2021-04-13 09:49:56 +03:00
router = APIRouter(responses=responses)
2020-10-02 09:37:50 +03:00
def dep_project(project_id: UUID) -> Project:
"""
Dependency to retrieve a project.
"""
project = Controller.instance().get_project(str(project_id))
return project
2020-10-02 09:37:50 +03:00
CHUNK_SIZE = 1024 * 8 # 8KB
2021-04-13 12:16:50 +03:00
@router.get("", response_model=List[schemas.Project], response_model_exclude_unset=True)
def get_projects() -> List[schemas.Project]:
"""
Return all projects.
"""
controller = Controller.instance()
2021-04-17 17:04:28 +03:00
return [p.asdict() for p in controller.projects.values()]
2021-04-13 12:16:50 +03:00
@router.post(
"",
status_code=status.HTTP_201_CREATED,
response_model=schemas.Project,
response_model_exclude_unset=True,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create project"}},
)
async def create_project(project_data: schemas.ProjectCreate) -> schemas.Project:
"""
Create a new project.
"""
2020-10-02 09:37:50 +03:00
controller = Controller.instance()
project = await controller.add_project(**jsonable_encoder(project_data, exclude_unset=True))
2021-04-17 17:04:28 +03:00
return project.asdict()
2020-10-02 09:37:50 +03:00
2021-04-13 12:16:50 +03:00
@router.get("/{project_id}", response_model=schemas.Project)
def get_project(project: Project = Depends(dep_project)) -> schemas.Project:
"""
Return a project.
"""
2020-10-02 09:37:50 +03:00
2021-04-17 17:04:28 +03:00
return project.asdict()
2020-10-02 09:37:50 +03:00
2021-04-13 12:16:50 +03:00
@router.put("/{project_id}", response_model=schemas.Project, response_model_exclude_unset=True)
async def update_project(
project_data: schemas.ProjectUpdate,
project: Project = Depends(dep_project)
) -> schemas.Project:
"""
Update a project.
"""
2020-10-02 09:37:50 +03:00
await project.update(**jsonable_encoder(project_data, exclude_unset=True))
2021-04-17 17:04:28 +03:00
return project.asdict()
2020-10-02 09:37:50 +03:00
2021-04-13 12:16:50 +03:00
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(project: Project = Depends(dep_project)) -> None:
"""
Delete a project.
"""
2020-10-02 09:37:50 +03:00
controller = Controller.instance()
await project.delete()
controller.remove_project(project)
2021-04-13 09:49:56 +03:00
@router.get("/{project_id}/stats")
def get_project_stats(project: Project = Depends(dep_project)) -> dict:
"""
Return a project statistics.
"""
2020-10-02 09:37:50 +03:00
return project.stats()
2021-04-13 12:16:50 +03:00
@router.post(
"/{project_id}/close",
status_code=status.HTTP_204_NO_CONTENT,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not close project"}},
)
async def close_project(project: Project = Depends(dep_project)) -> None:
"""
Close a project.
"""
2020-10-02 09:37:50 +03:00
await project.close()
2021-04-13 12:16:50 +03:00
@router.post(
"/{project_id}/open",
status_code=status.HTTP_201_CREATED,
response_model=schemas.Project,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not open project"}},
)
async def open_project(project: Project = Depends(dep_project)) -> schemas.Project:
"""
Open a project.
"""
2020-10-02 09:37:50 +03:00
await project.open()
2021-04-17 17:04:28 +03:00
return project.asdict()
2020-10-02 09:37:50 +03:00
2021-04-13 12:16:50 +03:00
@router.post(
"/load",
status_code=status.HTTP_201_CREATED,
response_model=schemas.Project,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not load project"}},
)
async def load_project(path: str = Body(..., embed=True)) -> schemas.Project:
"""
Load a project (local server only).
"""
2020-10-02 09:37:50 +03:00
controller = Controller.instance()
dot_gns3_file = path
if Config.instance().settings.Server.local is False:
2021-04-13 12:07:58 +03:00
log.error(f"Cannot load '{dot_gns3_file}' because the server has not been started with the '--local' parameter")
2020-10-02 09:37:50 +03:00
raise ControllerForbiddenError("Cannot load project when server is not local")
2021-04-13 12:16:50 +03:00
project = await controller.load_project(
dot_gns3_file,
)
2021-04-17 17:04:28 +03:00
return project.asdict()
2020-10-02 09:37:50 +03:00
@router.get("/{project_id}/notifications")
async def notification(project_id: UUID) -> StreamingResponse:
"""
Receive project notifications about the controller from HTTP stream.
"""
controller = Controller.instance()
project = controller.get_project(str(project_id))
2021-04-13 12:07:58 +03:00
log.info(f"New client has connected to the notification stream for project ID '{project.id}' (HTTP steam method)")
async def event_stream():
try:
with controller.notification.project_queue(project.id) as queue:
while True:
msg = await queue.get_json(5)
2021-04-13 12:07:58 +03:00
yield (f"{msg}\n").encode("utf-8")
finally:
2021-04-13 12:07:58 +03:00
log.info(f"Client has disconnected from notification for project ID '{project.id}' (HTTP stream method)")
if project.auto_close:
# To avoid trouble with client connecting disconnecting we sleep few seconds before checking
# if someone else is not connected
await asyncio.sleep(5)
if not controller.notification.project_has_listeners(project.id):
2021-04-13 12:07:58 +03:00
log.info(f"Project '{project.id}' is automatically closing due to no client listening")
await project.close()
return StreamingResponse(event_stream(), media_type="application/json")
2020-10-02 09:37:50 +03:00
@router.websocket("/{project_id}/notifications/ws")
async def notification_ws(project_id: UUID, websocket: WebSocket) -> None:
"""
Receive project notifications about the controller from WebSocket.
"""
2020-10-02 09:37:50 +03:00
controller = Controller.instance()
project = controller.get_project(str(project_id))
await websocket.accept()
2021-04-13 12:07:58 +03:00
log.info(f"New client has connected to the notification stream for project ID '{project.id}' (WebSocket method)")
2020-10-02 09:37:50 +03:00
try:
with controller.notification.project_queue(project.id) as queue:
while True:
notification = await queue.get_json(5)
await websocket.send_text(notification)
except (ConnectionClosed, WebSocketDisconnect):
2021-04-13 12:07:58 +03:00
log.info(f"Client has disconnected from notification stream for project ID '{project.id}' (WebSocket method)")
except WebSocketException as e:
2021-04-13 12:07:58 +03:00
log.warning(f"Error while sending to project event to WebSocket client: {e}")
2020-10-02 09:37:50 +03:00
finally:
await websocket.close()
if project.auto_close:
# To avoid trouble with client connecting disconnecting we sleep few seconds before checking
# if someone else is not connected
await asyncio.sleep(5)
if not controller.notification.project_has_listeners(project.id):
2021-04-13 12:07:58 +03:00
log.info(f"Project '{project.id}' is automatically closing due to no client listening")
2020-10-02 09:37:50 +03:00
await project.close()
2021-04-13 09:49:56 +03:00
@router.get("/{project_id}/export")
2021-04-13 12:16:50 +03:00
async def export_project(
project: Project = Depends(dep_project),
include_snapshots: bool = False,
include_images: bool = False,
reset_mac_addresses: bool = False,
compression: str = "zip",
) -> StreamingResponse:
2020-10-02 09:37:50 +03:00
"""
Export a project as a portable archive.
"""
compression_query = compression.lower()
if compression_query == "zip":
compression = zipfile.ZIP_DEFLATED
elif compression_query == "none":
compression = zipfile.ZIP_STORED
elif compression_query == "bzip2":
compression = zipfile.ZIP_BZIP2
elif compression_query == "lzma":
compression = zipfile.ZIP_LZMA
try:
begin = time.time()
# use the parent directory as a temporary working dir
working_dir = os.path.abspath(os.path.join(project.path, os.pardir))
async def streamer():
with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir:
with aiozipstream.ZipFile(compression=compression) as zstream:
2021-04-13 12:16:50 +03:00
await export_controller_project(
zstream,
project,
tmpdir,
include_snapshots=include_snapshots,
include_images=include_images,
reset_mac_addresses=reset_mac_addresses,
)
2020-10-02 09:37:50 +03:00
async for chunk in zstream:
yield chunk
2021-04-13 12:07:58 +03:00
log.info(f"Project '{project.name}' exported in {time.time() - begin:.4f} seconds")
2020-10-02 09:37:50 +03:00
# Will be raise if you have no space left or permission issue on your temporary directory
# RuntimeError: something was wrong during the zip process
except (ValueError, OSError, RuntimeError) as e:
2021-04-13 12:07:58 +03:00
raise ConnectionError(f"Cannot export project: {e}")
2020-10-02 09:37:50 +03:00
2021-04-13 12:07:58 +03:00
headers = {"CONTENT-DISPOSITION": f'attachment; filename="{project.name}.gns3project"'}
2020-10-02 09:37:50 +03:00
return StreamingResponse(streamer(), media_type="application/gns3project", headers=headers)
2021-04-13 12:16:50 +03:00
@router.post("/{project_id}/import", status_code=status.HTTP_201_CREATED, response_model=schemas.Project)
async def import_project(
project_id: UUID,
request: Request,
path: Optional[Path] = None,
name: Optional[str] = None
) -> schemas.Project:
2020-10-02 09:37:50 +03:00
"""
Import a project from a portable archive.
"""
controller = Controller.instance()
if Config.instance().settings.Server.local is False:
2020-10-02 09:37:50 +03:00
raise ControllerForbiddenError("The server is not local")
# We write the content to a temporary location and after we extract it all.
# It could be more optimal to stream this but it is not implemented in Python.
try:
begin = time.time()
# use the parent directory or projects dir as a temporary working dir
if path:
working_dir = os.path.abspath(os.path.join(path, os.pardir))
else:
working_dir = controller.projects_directory()
with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir:
temp_project_path = os.path.join(tmpdir, "project.zip")
2021-04-13 12:16:50 +03:00
async with aiofiles.open(temp_project_path, "wb") as f:
2020-10-02 09:37:50 +03:00
async for chunk in request.stream():
await f.write(chunk)
with open(temp_project_path, "rb") as f:
project = await import_controller_project(controller, str(project_id), f, location=path, name=name)
2021-04-13 12:07:58 +03:00
log.info(f"Project '{project.name}' imported in {time.time() - begin:.4f} seconds")
2020-10-02 09:37:50 +03:00
except OSError as e:
2021-04-13 12:07:58 +03:00
raise ControllerError(f"Could not import the project: {e}")
2021-04-17 17:04:28 +03:00
return project.asdict()
2020-10-02 09:37:50 +03:00
2021-04-13 12:16:50 +03:00
@router.post(
"/{project_id}/duplicate",
status_code=status.HTTP_201_CREATED,
response_model=schemas.Project,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not duplicate project"}},
)
async def duplicate_project(
project_data: schemas.ProjectDuplicate,
project: Project = Depends(dep_project)
) -> schemas.Project:
"""
Duplicate a project.
"""
2020-10-02 09:37:50 +03:00
if project_data.path:
if Config.instance().settings.Server.local is False:
2020-10-02 09:37:50 +03:00
raise ControllerForbiddenError("The server is not a local server")
location = project_data.path
else:
location = None
reset_mac_addresses = project_data.reset_mac_addresses
2021-04-13 12:16:50 +03:00
new_project = await project.duplicate(
name=project_data.name, location=location, reset_mac_addresses=reset_mac_addresses
)
2021-04-17 17:04:28 +03:00
return new_project.asdict()
2020-10-02 09:37:50 +03:00
@router.get("/{project_id}/files/{file_path:path}")
async def get_file(file_path: str, project: Project = Depends(dep_project)) -> FileResponse:
2020-10-02 09:37:50 +03:00
"""
Return a file from a project.
2020-10-02 09:37:50 +03:00
"""
2021-04-13 12:16:50 +03:00
path = os.path.normpath(file_path).strip("/")
2020-10-02 09:37:50 +03:00
# Raise error if user try to escape
if path[0] == ".":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
path = os.path.join(project.path, path)
if not os.path.exists(path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return FileResponse(path, media_type="application/octet-stream")
2021-04-13 12:16:50 +03:00
@router.post("/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
async def write_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None:
"""
Write a file from a project.
"""
2020-10-02 09:37:50 +03:00
path = os.path.normpath(file_path).strip("/")
# Raise error if user try to escape
if path[0] == ".":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
path = os.path.join(project.path, path)
try:
2021-04-13 12:16:50 +03:00
async with aiofiles.open(path, "wb+") as f:
2020-10-02 09:37:50 +03:00
async for chunk in request.stream():
await f.write(chunk)
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
except PermissionError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
except OSError as e:
raise ControllerError(str(e))