1
0
mirror of https://github.com/GNS3/gns3-server synced 2024-12-26 16:58:28 +00:00

Add prune images endpoint.

Use many-to-many relationship between images and templates.
This commit is contained in:
grossmj 2021-08-20 15:58:41 +09:30
parent 078c42f185
commit 4d9e4e1059
5 changed files with 90 additions and 13 deletions

View File

@ -22,7 +22,7 @@ import os
import logging import logging
import urllib.parse import urllib.parse
from fastapi import APIRouter, Request, Depends, status from fastapi import APIRouter, Request, Response, Depends, status
from typing import List from typing import List
from gns3server import schemas from gns3server import schemas
@ -125,3 +125,15 @@ async def delete_image(
success = await images_repo.delete_image(image_name) success = await images_repo.delete_image(image_name)
if not success: if not success:
raise ControllerError(f"Image '{image_name}' could not be deleted") raise ControllerError(f"Image '{image_name}' could not be deleted")
@router.post("/prune", status_code=status.HTTP_204_NO_CONTENT)
async def prune_images(
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
) -> Response:
"""
Prune images not attached to any template.
"""
await images_repo.prune_images()
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@ -15,10 +15,18 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from sqlalchemy import Column, String, Integer, BigInteger from sqlalchemy import Table, Column, String, Integer, ForeignKey, BigInteger
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from .base import BaseTable from .base import Base, BaseTable, GUID
image_template_link = Table(
"images_templates_link",
Base.metadata,
Column("image_id", Integer, ForeignKey("images.id", ondelete="CASCADE")),
Column("template_id", GUID, ForeignKey("templates.template_id", ondelete="CASCADE"))
)
class Image(BaseTable): class Image(BaseTable):
@ -32,4 +40,4 @@ class Image(BaseTable):
path = Column(String) path = Column(String)
checksum = Column(String) checksum = Column(String)
checksum_algorithm = Column(String) checksum_algorithm = Column(String)
templates = relationship("Template") templates = relationship("Template", secondary=image_template_link, back_populates="images")

View File

@ -17,8 +17,10 @@
from sqlalchemy import Boolean, Column, String, Integer, ForeignKey, PickleType from sqlalchemy import Boolean, Column, String, Integer, ForeignKey, PickleType
from sqlalchemy.orm import relationship
from .base import BaseTable, generate_uuid, GUID from .base import BaseTable, generate_uuid, GUID
from .images import image_template_link
class Template(BaseTable): class Template(BaseTable):
@ -34,8 +36,7 @@ class Template(BaseTable):
compute_id = Column(String) compute_id = Column(String)
usage = Column(String) usage = Column(String)
template_type = Column(String) template_type = Column(String)
images = relationship("Image", secondary=image_template_link, back_populates="templates")
image_id = Column(Integer, ForeignKey('images.id', ondelete="CASCADE"))
__mapper_args__ = { __mapper_args__ = {
"polymorphic_identity": "templates", "polymorphic_identity": "templates",

View File

@ -15,6 +15,8 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# 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
from typing import Optional, List from typing import Optional, List
from sqlalchemy import select, delete from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -23,6 +25,10 @@ from .base import BaseRepository
import gns3server.db.models as models import gns3server.db.models as models
import logging
log = logging.getLogger(__name__)
class ImagesRepository(BaseRepository): class ImagesRepository(BaseRepository):
@ -31,33 +37,48 @@ class ImagesRepository(BaseRepository):
super().__init__(db_session) super().__init__(db_session)
async def get_image(self, image_name: str) -> Optional[models.Image]: async def get_image(self, image_name: str) -> Optional[models.Image]:
"""
Get an image by its name (filename).
"""
query = select(models.Image).where(models.Image.filename == image_name) query = select(models.Image).where(models.Image.filename == image_name)
result = await self._db_session.execute(query) result = await self._db_session.execute(query)
return result.scalars().first() return result.scalars().first()
async def get_image_by_checksum(self, checksum: str) -> Optional[models.Image]:
"""
Get an image by its checksum.
"""
query = select(models.Image).where(models.Image.checksum == checksum)
result = await self._db_session.execute(query)
return result.scalars().first()
async def get_images(self) -> List[models.Image]: async def get_images(self) -> List[models.Image]:
"""
Get all images.
"""
query = select(models.Image) query = select(models.Image)
result = await self._db_session.execute(query) result = await self._db_session.execute(query)
return result.scalars().all() return result.scalars().all()
async def get_image_templates(self, image_id: int) -> Optional[List[models.Template]]: async def get_image_templates(self, image_id: int) -> Optional[List[models.Template]]:
"""
Get all templates that an image belongs to.
"""
query = select(models.Template).\ query = select(models.Template).\
join(models.Image.templates). \ join(models.Template.images).\
filter(models.Image.id == image_id) filter(models.Image.id == image_id)
result = await self._db_session.execute(query) result = await self._db_session.execute(query)
return result.scalars().all() return result.scalars().all()
async def get_image_by_checksum(self, checksum: str) -> Optional[models.Image]:
query = select(models.Image).where(models.Image.checksum == checksum)
result = await self._db_session.execute(query)
return result.scalars().first()
async def add_image(self, image_name, image_type, image_size, path, checksum, checksum_algorithm) -> models.Image: async def add_image(self, image_name, image_type, image_size, path, checksum, checksum_algorithm) -> models.Image:
"""
Create a new image.
"""
db_image = models.Image( db_image = models.Image(
id=None, id=None,
@ -75,8 +96,31 @@ class ImagesRepository(BaseRepository):
return db_image return db_image
async def delete_image(self, image_name: str) -> bool: async def delete_image(self, image_name: str) -> bool:
"""
Delete an image.
"""
query = delete(models.Image).where(models.Image.filename == image_name) query = delete(models.Image).where(models.Image.filename == image_name)
result = await self._db_session.execute(query) result = await self._db_session.execute(query)
await self._db_session.commit() await self._db_session.commit()
return result.rowcount > 0 return result.rowcount > 0
async def prune_images(self) -> int:
"""
Prune images not attached to any template.
"""
query = select(models.Image).\
filter(~models.Image.templates.any())
result = await self._db_session.execute(query)
images = result.scalars().all()
images_deleted = 0
for image in images:
try:
os.remove(image.path)
except OSError:
log.warning(f"Could not delete image file {image.path}")
if await self.delete_image(image.filename):
images_deleted += 1
log.info(f"{images_deleted} image have been deleted")
return images_deleted

View File

@ -19,9 +19,12 @@ import os
import pytest import pytest
import hashlib import hashlib
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import FastAPI, status from fastapi import FastAPI, status
from httpx import AsyncClient from httpx import AsyncClient
from gns3server.db.repositories.images import ImagesRepository
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@ -193,3 +196,12 @@ class TestImageRoutes:
response = await client.delete(app.url_path_for("delete_image", image_name=image_name)) response = await client.delete(app.url_path_for("delete_image", image_name=image_name))
assert response.status_code == status.HTTP_204_NO_CONTENT assert response.status_code == status.HTTP_204_NO_CONTENT
assert not os.path.exists(os.path.join(images_dir, "QEMU", image_name)) assert not os.path.exists(os.path.join(images_dir, "QEMU", image_name))
async def test_prune_images(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None:
response = await client.post(app.url_path_for("prune_images"))
assert response.status_code == status.HTTP_204_NO_CONTENT
images_repo = ImagesRepository(db_session)
images_in_db = await images_repo.get_images()
assert len(images_in_db) == 0