mirror of
https://github.com/GNS3/gns3-server
synced 2024-12-26 00:38:10 +00:00
Add prune images endpoint.
Use many-to-many relationship between images and templates.
This commit is contained in:
parent
078c42f185
commit
4d9e4e1059
@ -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)
|
||||||
|
@ -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")
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user