Merge branch '3.1' into base-configs-3.0

This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-08-08 22:11:39 +02:00
commit 909ccf8fcd
177 files changed
+16013 -1745

No files matched your search

+1
View File
@@ -24,6 +24,7 @@ from .computes import Compute
from .images import Image
from .pools import Resource, ResourcePool
from .llm_model_configs import LLMModelConfig
from .api_keys import ApiKey
from .templates import (
Template,
CloudTemplate,
+33
View File
@@ -0,0 +1,33 @@
#
# Copyright (C) 2026 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/>.
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, func
from .base import BaseTable, GUID
class ApiKey(BaseTable):
__tablename__ = "api_keys"
api_key_id = Column(GUID, primary_key=True)
user_id = Column(GUID, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(128), nullable=False)
key_hash = Column(String(128), nullable=False)
key_prefix = Column(String(8), nullable=False)
last_used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.current_timestamp(), nullable=False)
revoked = Column(Boolean, default=False, nullable=False)
+104
View File
@@ -0,0 +1,104 @@
#
# Copyright (C) 2026 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/>.
from uuid import UUID
from typing import Optional, List
from datetime import datetime, timezone
from sqlalchemy import select, update, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from .base import BaseRepository
import gns3server.db.models as models
import logging
log = logging.getLogger(__name__)
class ApiKeysRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
async def create_api_key(
self, api_key_id: UUID, user_id: UUID, name: str, key_hash: str, key_prefix: str
) -> models.ApiKey:
db_api_key = models.ApiKey(
api_key_id=api_key_id,
user_id=user_id,
name=name,
key_hash=key_hash,
key_prefix=key_prefix,
)
self._db_session.add(db_api_key)
await self._db_session.commit()
await self._db_session.refresh(db_api_key)
return db_api_key
async def get_api_key(self, api_key_id: UUID) -> Optional[models.ApiKey]:
query = select(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id)
result = await self._db_session.execute(query)
return result.scalars().first()
async def get_api_keys_by_user(self, user_id: UUID) -> List[models.ApiKey]:
query = (
select(models.ApiKey)
.where(models.ApiKey.user_id == user_id)
.order_by(models.ApiKey.created_at.desc())
)
result = await self._db_session.execute(query)
return list(result.scalars().all())
async def get_api_key_by_hash(self, key_hash: str) -> Optional[models.ApiKey]:
query = select(models.ApiKey).where(models.ApiKey.key_hash == key_hash)
result = await self._db_session.execute(query)
return result.scalars().first()
async def revoke_api_key(self, api_key_id: UUID) -> bool:
query = (
update(models.ApiKey)
.where(models.ApiKey.api_key_id == api_key_id)
.values(revoked=True)
)
result = await self._db_session.execute(query)
await self._db_session.commit()
return result.rowcount > 0
async def restore_api_key(self, api_key_id: UUID) -> bool:
query = (
update(models.ApiKey)
.where(models.ApiKey.api_key_id == api_key_id)
.values(revoked=False)
)
result = await self._db_session.execute(query)
await self._db_session.commit()
return result.rowcount > 0
async def update_last_used(self, api_key_id: UUID) -> None:
query = (
update(models.ApiKey)
.where(models.ApiKey.api_key_id == api_key_id)
.values(last_used_at=func.now())
)
await self._db_session.execute(query)
await self._db_session.commit()
async def delete_api_key(self, api_key_id: UUID) -> bool:
query = delete(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
return result.rowcount > 0
+12 -1
View File
@@ -16,6 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import logging
from uuid import UUID
from typing import List, Union, Optional
@@ -29,6 +30,8 @@ from .base import BaseRepository
import gns3server.db.models as models
from gns3server import schemas
log = logging.getLogger(__name__)
TEMPLATE_TYPE_TO_MODEL = {
"cloud": models.CloudTemplate,
"docker": models.DockerTemplate,
@@ -126,8 +129,16 @@ class TemplatesRepository(BaseRepository):
where(models.Image.filename == image_name, models.Image.path.endswith(image_path))
else:
query = select(models.Image).where(models.Image.filename == image_name)
query = query.order_by(models.Image.image_id)
result = await self._db_session.execute(query)
return result.scalars().one_or_none()
images = result.scalars().all()
if len(images) > 1:
log.warning(
f"Multiple DB entries found for image '{image_path}' "
f"({len(images)} rows). This indicates a data integrity issue. "
f"Using the entry with the lowest image_id ({images[0].image_id})."
)
return images[0] if images else None
async def add_image_to_template(
self,
+21 -10
View File
@@ -80,6 +80,27 @@ async def connect_to_db(app: FastAPI) -> None:
db_path = os.path.join(Config.instance().config_dir, "gns3_controller.db")
db_url = os.environ.get("GNS3_DATABASE_URI", f"sqlite+aiosqlite:///{db_path}")
engine = create_async_engine(db_url, connect_args={"check_same_thread": False, "timeout": 20}, future=True, pool_size=512, max_overflow=1024)
# Register PRAGMA on the sync engine to ensure it fires for async connections
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
# Verify WAL mode is active
async with engine.connect() as _verify_conn:
def _check_wal(conn):
cursor = conn.connection.cursor()
cursor.execute("PRAGMA journal_mode")
row = cursor.fetchone()
cursor.close()
return row[0] if row else "unknown"
wal_mode = await _verify_conn.run_sync(_check_wal)
log.info(f"SQLite journal mode: {wal_mode}")
if wal_mode and wal_mode.upper() != "WAL":
log.warning("WAL mode not active - concurrent writes may cause 'database is locked' errors")
alembic_cfg = config.Config()
alembic_cfg.set_main_option("script_location", "gns3server:db_migrations")
#alembic_cfg.set_main_option('sqlalchemy.url', db_url)
@@ -146,16 +167,6 @@ async def disconnect_from_db(app: FastAPI) -> None:
log.info(f"Disconnected from database")
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
# Enable SQL foreign key support for SQLite
# https://docs.sqlalchemy.org/en/14/dialects/sqlite.html#foreign-key-support
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
async def get_computes(app: FastAPI) -> List[dict]:
computes = []