Merge origin/3.1 into image-manager-out-of-sync

Preserve image reconciliation, locking, and regression coverage while integrating upstream release, dependency, typing, and formatting updates. Align feature code with the new Ruff checks.
This commit is contained in:
Cristi committed 2026-09-28 23:16:24 +03:00
commit 42d30651da
497 files changed
+22962 -24152

No files matched your search

+4 -5
View File
@@ -26,19 +26,18 @@ log = logging.getLogger(__name__)
class ACE(BaseTable):
__tablename__ = "acl"
ace_id = Column(GUID, primary_key=True, default=generate_uuid)
ace_type: str = Column(String)
ace_type = Column(String)
path = Column(String)
propagate = Column(Boolean, default=True)
allowed = Column(Boolean, default=True)
user_id = Column(GUID, ForeignKey('users.user_id', ondelete="CASCADE"))
user_id = Column(GUID, ForeignKey("users.user_id", ondelete="CASCADE"))
user = relationship("User", back_populates="acl_entries")
group_id = Column(GUID, ForeignKey('user_groups.user_group_id', ondelete="CASCADE"))
group_id = Column(GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE"))
group = relationship("UserGroup", back_populates="acl_entries")
role_id = Column(GUID, ForeignKey('roles.role_id', ondelete="CASCADE"))
role_id = Column(GUID, ForeignKey("roles.role_id", ondelete="CASCADE"))
role = relationship("Role", back_populates="acl_entries")
__table_args__ = (
-1
View File
@@ -20,7 +20,6 @@ from .base import BaseTable, GUID
class ApiKey(BaseTable):
__tablename__ = "api_keys"
api_key_id = Column(GUID, primary_key=True)
+4 -7
View File
@@ -21,11 +21,10 @@ from fastapi.encoders import jsonable_encoder
from sqlalchemy import Column, DateTime, func, inspect
from sqlalchemy.types import TypeDecorator, CHAR, VARCHAR
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import as_declarative
from sqlalchemy.orm import DeclarativeBase
@as_declarative()
class Base:
class Base(DeclarativeBase):
def asdict(self):
return {c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs}
@@ -84,7 +83,7 @@ class ListType(TypeDecorator):
impl = VARCHAR
cache_ok = True
def __init__(self, separator=',', *args, **kwargs):
def __init__(self, separator=",", *args, **kwargs):
self._separator = separator
super().__init__(*args, **kwargs)
@@ -92,8 +91,7 @@ class ListType(TypeDecorator):
def process_bind_param(self, value, dialect):
if value is not None:
if any(self._separator in str(item) for item in value):
raise ListException(f"List values cannot contain '{self._separator}'"
f"Please use a different separator.")
raise ListException(f"List values cannot contain '{self._separator}'Please use a different separator.")
return self._separator.join(map(str, value))
def process_result_value(self, value, dialect):
@@ -104,7 +102,6 @@ class ListType(TypeDecorator):
class BaseTable(Base):
__abstract__ = True
created_at = Column(DateTime, server_default=func.current_timestamp())
-1
View File
@@ -21,7 +21,6 @@ from .base import BaseTable, GUID
class Compute(BaseTable):
__tablename__ = "computes"
compute_id = Column(GUID, primary_key=True)
+1 -2
View File
@@ -25,12 +25,11 @@ image_template_map = Table(
"image_template_map",
Base.metadata,
Column("image_id", Integer, ForeignKey("images.image_id", ondelete="CASCADE")),
Column("template_id", GUID, ForeignKey("templates.template_id", ondelete="CASCADE"))
Column("template_id", GUID, ForeignKey("templates.template_id", ondelete="CASCADE")),
)
class Image(BaseTable):
__tablename__ = "images"
image_id = Column(Integer, primary_key=True, autoincrement=True)
+15 -8
View File
@@ -50,21 +50,28 @@ class LLMModelConfig(BaseTable):
__table_args__ = (
# Ensure a config belongs to either a user or a group, not both
CheckConstraint(
"(user_id IS NOT NULL AND group_id IS NULL) OR "
"(user_id IS NULL AND group_id IS NOT NULL)",
name="single_owner_check"
"(user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL)",
name="single_owner_check",
),
# Validate model_type values
CheckConstraint(
"model_type IN ('text', 'vision', 'stt', 'tts', 'multimodal', 'embedding', 'reranking', 'other')",
name="valid_model_type_check"
name="valid_model_type_check",
),
# Each user can have at most one default config (partial unique index, PostgreSQL only)
Index("unique_user_default", "user_id", unique=True,
postgresql_where=text("is_default = TRUE AND user_id IS NOT NULL")),
Index(
"unique_user_default",
"user_id",
unique=True,
postgresql_where=text("is_default = TRUE AND user_id IS NOT NULL"),
),
# Each group can have at most one default config (partial unique index, PostgreSQL only)
Index("unique_group_default", "group_id", unique=True,
postgresql_where=text("is_default = TRUE AND group_id IS NOT NULL")),
Index(
"unique_group_default",
"group_id",
unique=True,
postgresql_where=text("is_default = TRUE AND group_id IS NOT NULL"),
),
# Indexes for efficient queries
Index("idx_llm_model_configs_user_id", "user_id"),
Index("idx_llm_model_configs_group_id", "group_id"),
+1 -3
View File
@@ -29,12 +29,11 @@ resource_pool_map = Table(
"resource_pool_map",
Base.metadata,
Column("resource_id", GUID, ForeignKey("resources.resource_id", ondelete="CASCADE")),
Column("resource_pool_id", GUID, ForeignKey("resource_pools.resource_pool_id", ondelete="CASCADE"))
Column("resource_pool_id", GUID, ForeignKey("resource_pools.resource_pool_id", ondelete="CASCADE")),
)
class Resource(BaseTable):
__tablename__ = "resources"
resource_id = Column(GUID, primary_key=True)
@@ -44,7 +43,6 @@ class Resource(BaseTable):
class ResourcePool(BaseTable):
__tablename__ = "resource_pools"
resource_pool_id = Column(GUID, primary_key=True, default=generate_uuid)
+60 -216
View File
@@ -29,12 +29,11 @@ privilege_role_map = Table(
"privilege_role_map",
Base.metadata,
Column("privilege_id", GUID, ForeignKey("privileges.privilege_id", ondelete="CASCADE")),
Column("role_id", GUID, ForeignKey("roles.role_id", ondelete="CASCADE"))
Column("role_id", GUID, ForeignKey("roles.role_id", ondelete="CASCADE")),
)
class Privilege(BaseTable):
__tablename__ = "privileges"
privilege_id = Column(GUID, primary_key=True, default=generate_uuid)
@@ -43,210 +42,60 @@ class Privilege(BaseTable):
roles = relationship("Role", secondary=privilege_role_map, back_populates="privileges")
@event.listens_for(Privilege.__table__, 'after_create')
@event.listens_for(Privilege.__table__, "after_create")
def create_default_roles(target, connection, **kw):
default_privileges = [
{
"description": "Create or delete a user",
"name": "User.Allocate"
},
{
"description": "View a user",
"name": "User.Audit"
},
{
"description": "Update a user",
"name": "User.Modify"
},
{
"description": "Create or delete a group",
"name": "Group.Allocate"
},
{
"description": "View a group",
"name": "Group.Audit"
},
{
"description": "Update a group",
"name": "Group.Modify"
},
{
"description": "Create or delete a role",
"name": "Role.Allocate"
},
{
"description": "View a role",
"name": "Role.Audit"
},
{
"description": "Update a role",
"name": "Role.Modify"
},
{
"description": "Create or delete an ACE",
"name": "ACE.Allocate"
},
{
"description": "View an ACE",
"name": "ACE.Audit"
},
{
"description": "Update an ACE",
"name": "ACE.Modify"
},
{
"description": "Create or delete a resource pool",
"name": "Pool.Allocate"
},
{
"description": "View a resource pool",
"name": "Pool.Audit"
},
{
"description": "Update a resource pool",
"name": "Pool.Modify"
},
{
"description": "Create or delete a template",
"name": "Template.Allocate"
},
{
"description": "View a template",
"name": "Template.Audit"
},
{
"description": "Update a template",
"name": "Template.Modify"
},
{
"description": "Create or delete a project",
"name": "Project.Allocate"
},
{
"description": "View a project",
"name": "Project.Audit"
},
{
"description": "Update a project",
"name": "Project.Modify"
},
{
"description": "Create or delete project snapshots",
"name": "Snapshot.Allocate"
},
{
"description": "Restore a snapshot",
"name": "Snapshot.Restore"
},
{
"description": "View a snapshot",
"name": "Snapshot.Audit"
},
{
"description": "Create or delete a node",
"name": "Node.Allocate"
},
{
"description": "View a node",
"name": "Node.Audit"
},
{
"description": "Update a node",
"name": "Node.Modify"
},
{
"description": "Console access to a node",
"name": "Node.Console"
},
{
"description": "Power management for a node",
"name": "Node.PowerMgmt"
},
{
"description": "Create or delete a link",
"name": "Link.Allocate"
},
{
"description": "View a link",
"name": "Link.Audit"
},
{
"description": "Update a link",
"name": "Link.Modify"
},
{
"description": "Capture packets on a link",
"name": "Link.Capture"
},
{
"description": "Create or delete a drawing",
"name": "Drawing.Allocate"
},
{
"description": "View a drawing",
"name": "Drawing.Audit"
},
{
"description": "Update a drawing",
"name": "Drawing.Modify"
},
{
"description": "Create or delete a symbol",
"name": "Symbol.Allocate"
},
{
"description": "View a symbol",
"name": "Symbol.Audit"
},
{
"description": "Create or delete an image",
"name": "Image.Allocate"
},
{
"description": "View an image",
"name": "Image.Audit"
},
{
"description": "Create or delete a compute",
"name": "Compute.Allocate"
},
{
"description": "Update a compute",
"name": "Compute.Modify"
},
{
"description": "View a compute",
"name": "Compute.Audit"
},
{
"description": "Install an appliance",
"name": "Appliance.Allocate"
},
{
"description": "View an appliance",
"name": "Appliance.Audit"
},
{
"description": "Create or delete an LLM model configuration",
"name": "LLMConfig.Allocate"
},
{
"description": "View an LLM model configuration",
"name": "LLMConfig.Audit"
},
{
"description": "Update an LLM model configuration",
"name": "LLMConfig.Modify"
},
{
"description": "View server settings",
"name": "Server.Audit"
},
{
"description": "Update server settings",
"name": "Server.Modify"
}
{"description": "Create or delete a user", "name": "User.Allocate"},
{"description": "View a user", "name": "User.Audit"},
{"description": "Update a user", "name": "User.Modify"},
{"description": "Create or delete a group", "name": "Group.Allocate"},
{"description": "View a group", "name": "Group.Audit"},
{"description": "Update a group", "name": "Group.Modify"},
{"description": "Create or delete a role", "name": "Role.Allocate"},
{"description": "View a role", "name": "Role.Audit"},
{"description": "Update a role", "name": "Role.Modify"},
{"description": "Create or delete an ACE", "name": "ACE.Allocate"},
{"description": "View an ACE", "name": "ACE.Audit"},
{"description": "Update an ACE", "name": "ACE.Modify"},
{"description": "Create or delete a resource pool", "name": "Pool.Allocate"},
{"description": "View a resource pool", "name": "Pool.Audit"},
{"description": "Update a resource pool", "name": "Pool.Modify"},
{"description": "Create or delete a template", "name": "Template.Allocate"},
{"description": "View a template", "name": "Template.Audit"},
{"description": "Update a template", "name": "Template.Modify"},
{"description": "Create or delete a project", "name": "Project.Allocate"},
{"description": "View a project", "name": "Project.Audit"},
{"description": "Update a project", "name": "Project.Modify"},
{"description": "Create or delete project snapshots", "name": "Snapshot.Allocate"},
{"description": "Restore a snapshot", "name": "Snapshot.Restore"},
{"description": "View a snapshot", "name": "Snapshot.Audit"},
{"description": "Create or delete a node", "name": "Node.Allocate"},
{"description": "View a node", "name": "Node.Audit"},
{"description": "Update a node", "name": "Node.Modify"},
{"description": "Console access to a node", "name": "Node.Console"},
{"description": "Power management for a node", "name": "Node.PowerMgmt"},
{"description": "Create or delete a link", "name": "Link.Allocate"},
{"description": "View a link", "name": "Link.Audit"},
{"description": "Update a link", "name": "Link.Modify"},
{"description": "Capture packets on a link", "name": "Link.Capture"},
{"description": "Create or delete a drawing", "name": "Drawing.Allocate"},
{"description": "View a drawing", "name": "Drawing.Audit"},
{"description": "Update a drawing", "name": "Drawing.Modify"},
{"description": "Create or delete a symbol", "name": "Symbol.Allocate"},
{"description": "View a symbol", "name": "Symbol.Audit"},
{"description": "Create or delete an image", "name": "Image.Allocate"},
{"description": "View an image", "name": "Image.Audit"},
{"description": "Create or delete a compute", "name": "Compute.Allocate"},
{"description": "Update a compute", "name": "Compute.Modify"},
{"description": "View a compute", "name": "Compute.Audit"},
{"description": "Install an appliance", "name": "Appliance.Allocate"},
{"description": "View an appliance", "name": "Appliance.Audit"},
{"description": "Create or delete an LLM model configuration", "name": "LLMConfig.Allocate"},
{"description": "View an LLM model configuration", "name": "LLMConfig.Audit"},
{"description": "Update an LLM model configuration", "name": "LLMConfig.Modify"},
{"description": "View server settings", "name": "Server.Audit"},
{"description": "Update server settings", "name": "Server.Modify"},
]
stmt = target.insert().values(default_privileges)
@@ -258,6 +107,7 @@ def create_default_roles(target, connection, **kw):
def add_privileges_to_role(target, connection, role, privileges):
from .roles import Role
roles_table = Role.__table__
privileges_table = Privilege.__table__
@@ -272,10 +122,11 @@ def add_privileges_to_role(target, connection, role, privileges):
connection.execute(stmt)
@event.listens_for(privilege_role_map, 'after_create')
@event.listens_for(privilege_role_map, "after_create")
def add_privileges_to_default_roles(target, connection, **kw):
from .roles import Role
roles_table = Role.__table__
stmt = roles_table.select().where(roles_table.c.name == "Administrator")
result = connection.execute(stmt)
@@ -317,7 +168,7 @@ def add_privileges_to_default_roles(target, connection, **kw):
"Appliance.Allocate",
"Appliance.Audit",
"LLMConfig.Audit",
"LLMConfig.Modify"
"LLMConfig.Modify",
)
add_privileges_to_role(target, connection, "User", user_privileges)
@@ -333,7 +184,7 @@ def add_privileges_to_default_roles(target, connection, **kw):
"Symbol.Audit",
"Image.Audit",
"Compute.Audit",
"Appliance.Audit"
"Appliance.Audit",
)
add_privileges_to_role(target, connection, "Auditor", auditor_privileges)
@@ -348,7 +199,7 @@ def add_privileges_to_default_roles(target, connection, **kw):
"Image.Allocate",
"Image.Audit",
"Appliance.Allocate",
"Appliance.Audit"
"Appliance.Audit",
)
add_privileges_to_role(target, connection, "Template manager", template_manager_privileges)
@@ -360,20 +211,13 @@ def add_privileges_to_default_roles(target, connection, **kw):
"User.Modify",
"Group.Allocate",
"Group.Audit",
"Group.Modify"
"Group.Modify",
)
add_privileges_to_role(target, connection, "User manager", user_manager_privileges)
# add required privileges to the "ACL manager" role
acl_manager_privileges = (
"Role.Allocate",
"Role.Audit",
"Role.Modify",
"ACE.Allocate",
"ACE.Audit",
"ACE.Modify"
)
acl_manager_privileges = ("Role.Allocate", "Role.Audit", "Role.Modify", "ACE.Allocate", "ACE.Audit", "ACE.Modify")
add_privileges_to_role(target, connection, "ACL manager", acl_manager_privileges)
+2 -3
View File
@@ -27,7 +27,6 @@ log = logging.getLogger(__name__)
class Role(BaseTable):
__tablename__ = "roles"
role_id = Column(GUID, primary_key=True, default=generate_uuid)
@@ -38,7 +37,7 @@ class Role(BaseTable):
acl_entries = relationship("ACE")
@event.listens_for(Role.__table__, 'after_create')
@event.listens_for(Role.__table__, "after_create")
def create_default_roles(target, connection, **kw):
default_roles = [
@@ -48,7 +47,7 @@ def create_default_roles(target, connection, **kw):
{"name": "Template manager", "description": "Role to manage templates", "is_builtin": True},
{"name": "User manager", "description": "Role to manage users and groups", "is_builtin": True},
{"name": "ACL manager", "description": "Role to manage other roles and the ACL", "is_builtin": True},
{"name": "No Access", "description": "Role with no privileges (used to forbid access)", "is_builtin": True}
{"name": "No Access", "description": "Role with no privileges (used to forbid access)", "is_builtin": True},
]
stmt = target.insert().values(default_roles)
-11
View File
@@ -24,7 +24,6 @@ from .images import image_template_map
class Template(BaseTable):
__tablename__ = "templates"
template_id = Column(GUID, primary_key=True, default=generate_uuid)
@@ -49,7 +48,6 @@ class Template(BaseTable):
class CloudTemplate(Template):
__tablename__ = "cloud_templates"
template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
@@ -63,7 +61,6 @@ class CloudTemplate(Template):
class DockerTemplate(Template):
__tablename__ = "docker_templates"
template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
@@ -89,7 +86,6 @@ class DockerTemplate(Template):
class DynamipsTemplate(Template):
__tablename__ = "dynamips_templates"
template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
@@ -132,7 +128,6 @@ class DynamipsTemplate(Template):
class EthernetHubTemplate(Template):
__tablename__ = "ethernet_hub_templates"
template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
@@ -142,7 +137,6 @@ class EthernetHubTemplate(Template):
class EthernetSwitchTemplate(Template):
__tablename__ = "ethernet_switch_templates"
template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
@@ -153,7 +147,6 @@ class EthernetSwitchTemplate(Template):
class IOUTemplate(Template):
__tablename__ = "iou_templates"
template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
@@ -173,7 +166,6 @@ class IOUTemplate(Template):
class QemuTemplate(Template):
__tablename__ = "qemu_templates"
template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
@@ -220,7 +212,6 @@ class QemuTemplate(Template):
class VirtualBoxTemplate(Template):
__tablename__ = "virtualbox_templates"
template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
@@ -243,7 +234,6 @@ class VirtualBoxTemplate(Template):
class VMwareTemplate(Template):
__tablename__ = "vmware_templates"
template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
@@ -265,7 +255,6 @@ class VMwareTemplate(Template):
class VPCSTemplate(Template):
__tablename__ = "vpcs_templates"
template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
+5 -10
View File
@@ -31,12 +31,11 @@ user_group_map = Table(
"user_group_map",
Base.metadata,
Column("user_id", GUID, ForeignKey("users.user_id", ondelete="CASCADE")),
Column("user_group_id", GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE"))
Column("user_group_id", GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE")),
)
class User(BaseTable):
__tablename__ = "users"
user_id = Column(GUID, primary_key=True, default=generate_uuid)
@@ -52,7 +51,7 @@ class User(BaseTable):
acl_entries = relationship("ACE")
@event.listens_for(User.__table__, 'after_create')
@event.listens_for(User.__table__, "after_create")
def create_default_super_admin(target, connection, **kw):
config = Config.instance().settings
@@ -63,7 +62,7 @@ def create_default_super_admin(target, connection, **kw):
username=default_admin_username,
full_name="Super Administrator",
hashed_password=hashed_password,
is_superadmin=True
is_superadmin=True,
)
connection.execute(stmt)
connection.commit()
@@ -71,7 +70,6 @@ def create_default_super_admin(target, connection, **kw):
class UserGroup(BaseTable):
__tablename__ = "user_groups"
user_group_id = Column(GUID, primary_key=True, default=generate_uuid)
@@ -81,13 +79,10 @@ class UserGroup(BaseTable):
acl_entries = relationship("ACE")
@event.listens_for(UserGroup.__table__, 'after_create')
@event.listens_for(UserGroup.__table__, "after_create")
def create_default_user_groups(target, connection, **kw):
default_groups = [
{"name": "Administrators", "is_builtin": True},
{"name": "Users", "is_builtin": True}
]
default_groups = [{"name": "Administrators", "is_builtin": True}, {"name": "Users", "is_builtin": True}]
stmt = target.insert().values(default_groups)
connection.execute(stmt)
+4 -21
View File
@@ -30,7 +30,6 @@ log = logging.getLogger(__name__)
class ApiKeysRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
@@ -55,11 +54,7 @@ class ApiKeysRepository(BaseRepository):
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())
)
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())
@@ -69,31 +64,19 @@ class ApiKeysRepository(BaseRepository):
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)
)
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)
)
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())
)
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()
+1 -4
View File
@@ -27,7 +27,6 @@ from gns3server import schemas
class ComputesRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
@@ -72,9 +71,7 @@ class ComputesRepository(BaseRepository):
if compute_update.password is not None:
update_values["password"] = compute_update.password.get_secret_value()
query = update(models.Compute).\
where(models.Compute.compute_id == compute_id).\
values(update_values)
query = update(models.Compute).where(models.Compute.compute_id == compute_id).values(update_values)
await self._db_session.execute(query)
await self._db_session.commit()
+60 -33
View File
@@ -35,7 +35,6 @@ log = logging.getLogger(__name__)
class ImagesRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
@@ -49,9 +48,9 @@ class ImagesRepository(BaseRepository):
if os.path.isabs(image_path):
query = select(models.Image).where(models.Image.path == image_path)
elif image_dir:
query = select(models.Image).\
where(models.Image.filename == image_name,
models.Image.path.endswith(os.sep + image_path, autoescape=True))
query = select(models.Image).where(
models.Image.filename == image_name, models.Image.path.endswith(os.sep + image_path, autoescape=True)
)
else:
query = select(models.Image).where(models.Image.filename == image_name)
result = await self._db_session.execute(query.execution_options(populate_existing=refresh))
@@ -74,6 +73,7 @@ class ImagesRepository(BaseRepository):
async def is_usable(self, image: models.Image) -> bool:
"""Validate a checksum candidate without trusting stale catalog/sidecar data."""
from gns3server.utils.images import inspect_image_file, InvalidImageError
try:
current = await asyncio.to_thread(fingerprint, image.path)
if image.availability == "available" and image.file_fingerprint == current:
@@ -102,15 +102,14 @@ class ImagesRepository(BaseRepository):
Get all templates that an image belongs to.
"""
query = select(models.Template).\
join(models.Template.images).\
filter(models.Image.image_id == image_id)
query = select(models.Template).join(models.Template.images).filter(models.Image.image_id == image_id)
result = await self._db_session.execute(query)
return result.scalars().all()
async def add_image(self, image_name, image_type, image_size, path, checksum, checksum_algorithm,
file_fingerprint=None) -> models.Image:
async def add_image(
self, image_name, image_type, image_size, path, checksum, checksum_algorithm, file_fingerprint=None
) -> models.Image:
"""
Create a new image.
"""
@@ -141,9 +140,11 @@ class ImagesRepository(BaseRepository):
Update an image.
"""
query = update(models.Image).\
where(models.Image.path == image_path).\
values(checksum=checksum, checksum_algorithm=checksum_algorithm)
query = (
update(models.Image)
.where(models.Image.path == image_path)
.values(checksum=checksum, checksum_algorithm=checksum_algorithm)
)
await self._db_session.execute(query)
await self._db_session.commit()
@@ -160,23 +161,41 @@ class ImagesRepository(BaseRepository):
"""
values = dict(info)
values["filename"] = values.pop("image_name")
values.update(availability="available", last_error=None,
last_seen_at=datetime.now(timezone.utc).replace(tzinfo=None),
last_verified_at=datetime.now(timezone.utc).replace(tzinfo=None))
values.update(
availability="available",
last_error=None,
last_seen_at=datetime.now(timezone.utc).replace(tzinfo=None),
last_verified_at=datetime.now(timezone.utc).replace(tzinfo=None),
)
for attempt in range(2):
try:
image = (await self._db_session.execute(
select(models.Image).where(models.Image.path == info["path"])
.execution_options(populate_existing=True))).scalar_one_or_none()
image = (
await self._db_session.execute(
select(models.Image)
.where(models.Image.path == info["path"])
.execution_options(populate_existing=True)
)
).scalar_one_or_none()
if image is None:
# A legacy spelling (e.g. /images/QEMU/./disk.qcow2) can
# refer to the same destination restored by an API upload.
candidates = (await self._db_session.execute(select(models.Image).where(
models.Image.filename == values["filename"]))).scalars().all()
aliases = [candidate for candidate in candidates
if normalized_path(candidate.path) == normalized_path(info["path"])]
candidates = (
(
await self._db_session.execute(
select(models.Image).where(models.Image.filename == values["filename"])
)
)
.scalars()
.all()
)
aliases = [
candidate
for candidate in candidates
if normalized_path(candidate.path) == normalized_path(info["path"])
]
if len(aliases) > 1:
from sqlalchemy.exc import MultipleResultsFound
raise MultipleResultsFound("Ambiguous image path aliases; manual review required")
if aliases:
image = aliases[0]
@@ -212,28 +231,32 @@ class ImagesRepository(BaseRepository):
if os.path.isabs(image_path):
query = delete(models.Image).where(models.Image.path == image_path)
elif image_dir:
query = delete(models.Image).\
where(models.Image.filename == image_name,
models.Image.path.endswith(os.sep + image_path, autoescape=True)).\
execution_options(synchronize_session=False)
query = (
delete(models.Image)
.where(
models.Image.filename == image_name,
models.Image.path.endswith(os.sep + image_path, autoescape=True),
)
.execution_options(synchronize_session=False)
)
else:
query = delete(models.Image).where(models.Image.filename == image_name)
result = await self._db_session.execute(query)
await self._db_session.commit()
return result.rowcount > 0
async def prune_images(self, skip_images: list[str] = None,
is_in_use: Optional[Callable] = None) -> int:
async def prune_images(self, skip_images: list[str] = None, is_in_use: Optional[Callable] = None) -> int:
"""
Prune images not attached to any template.
"""
query = select(models.Image).\
filter(~models.Image.templates.any())
query = select(models.Image).filter(~models.Image.templates.any())
result = await self._db_session.execute(query)
# Snapshot scalar values; commits can expire ORM instances.
images = [(image.image_id, image.filename, image.path, image.checksum, image.file_fingerprint)
for image in result.scalars().all()]
images = [
(image.image_id, image.filename, image.path, image.checksum, image.file_fingerprint)
for image in result.scalars().all()
]
images_deleted = 0
errors = []
for image_id, filename, path, checksum, file_fingerprint in images:
@@ -242,7 +265,10 @@ class ImagesRepository(BaseRepository):
async with image_lock(path):
current = await self.get_image(path, refresh=True)
if current is None or (current.image_id, current.checksum, current.file_fingerprint) != (
image_id, checksum, file_fingerprint):
image_id,
checksum,
file_fingerprint,
):
continue # A concurrent request removed or replaced this image.
if await self.get_image_templates(image_id) or (is_in_use and is_in_use(filename)):
continue
@@ -258,5 +284,6 @@ class ImagesRepository(BaseRepository):
log.info(f"{images_deleted} image(s) have been deleted")
if errors:
from gns3server.controller.controller_error import ControllerError
raise ControllerError("Could not delete image files: " + "; ".join(errors))
return images_deleted
+70 -122
View File
@@ -50,44 +50,36 @@ class LLMModelConfigsRepository(BaseRepository):
async def get_user_config(self, config_id: UUID) -> Optional[models.LLMModelConfig]:
"""Get a user's LLM model configuration by ID."""
query = select(models.LLMModelConfig).where(
and_(
models.LLMModelConfig.config_id == config_id,
models.LLMModelConfig.user_id.isnot(None)
)
and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.user_id.isnot(None))
)
result = await self._db_session.execute(query)
return result.scalars().first()
async def get_user_configs(self, user_id: UUID) -> List[models.LLMModelConfig]:
"""Get all LLM model configurations for a user."""
query = select(models.LLMModelConfig).where(
models.LLMModelConfig.user_id == user_id
).order_by(models.LLMModelConfig.created_at)
query = (
select(models.LLMModelConfig)
.where(models.LLMModelConfig.user_id == user_id)
.order_by(models.LLMModelConfig.created_at)
)
result = await self._db_session.execute(query)
return result.scalars().all()
async def get_user_default_config(self, user_id: UUID) -> Optional[models.LLMModelConfig]:
"""Get a user's default LLM model configuration."""
query = select(models.LLMModelConfig).where(
and_(
models.LLMModelConfig.user_id == user_id,
models.LLMModelConfig.is_default
)
and_(models.LLMModelConfig.user_id == user_id, models.LLMModelConfig.is_default)
)
result = await self._db_session.execute(query)
return result.scalars().first()
async def create_user_config(
self,
user_id: UUID,
name: str,
model_type: str,
config_data: Dict[str, Any],
is_default: bool = False
self, user_id: UUID, name: str, model_type: str, config_data: Dict[str, Any], is_default: bool = False
) -> models.LLMModelConfig:
"""Create a new LLM model configuration for a user."""
# Encrypt API key if present
from gns3server.utils.encryption import encrypt
config_to_store = config_data.copy()
if "api_key" in config_to_store and config_to_store["api_key"]:
try:
@@ -105,7 +97,7 @@ class LLMModelConfigsRepository(BaseRepository):
user_id=user_id,
is_default=is_default,
created_at=now,
updated_at=now
updated_at=now,
)
self._db_session.add(db_config)
await self._db_session.commit()
@@ -113,11 +105,7 @@ class LLMModelConfigsRepository(BaseRepository):
return db_config
async def update_user_config(
self,
config_id: UUID,
user_id: UUID,
updates: Dict[str, Any],
expected_version: Optional[int] = None
self, config_id: UUID, user_id: UUID, updates: Dict[str, Any], expected_version: Optional[int] = None
) -> Optional[models.LLMModelConfig]:
"""
Update a user's LLM model configuration.
@@ -130,10 +118,7 @@ class LLMModelConfigsRepository(BaseRepository):
:raises ValueError: If version mismatch (concurrent modification)
"""
query = select(models.LLMModelConfig).where(
and_(
models.LLMModelConfig.config_id == config_id,
models.LLMModelConfig.user_id == user_id
)
and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.user_id == user_id)
)
result = await self._db_session.execute(query)
db_config = result.scalars().first()
@@ -193,10 +178,7 @@ class LLMModelConfigsRepository(BaseRepository):
async def delete_user_config(self, config_id: UUID, user_id: UUID) -> bool:
"""Delete a user's LLM model configuration."""
query = delete(models.LLMModelConfig).where(
and_(
models.LLMModelConfig.config_id == config_id,
models.LLMModelConfig.user_id == user_id
)
and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.user_id == user_id)
)
result = await self._db_session.execute(query)
await self._db_session.commit()
@@ -208,22 +190,16 @@ class LLMModelConfigsRepository(BaseRepository):
# First, unset current default
await self._db_session.execute(
update(models.LLMModelConfig)
.where(
and_(
models.LLMModelConfig.user_id == user_id,
models.LLMModelConfig.is_default
)
)
.where(and_(models.LLMModelConfig.user_id == user_id, models.LLMModelConfig.is_default))
.values(is_default=False, updated_at=now)
)
# Set new default
query = update(models.LLMModelConfig).where(
and_(
models.LLMModelConfig.config_id == config_id,
models.LLMModelConfig.user_id == user_id
)
).values(is_default=True, updated_at=now)
query = (
update(models.LLMModelConfig)
.where(and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.user_id == user_id))
.values(is_default=True, updated_at=now)
)
result = await self._db_session.execute(query)
await self._db_session.commit()
return result.rowcount > 0
@@ -233,44 +209,36 @@ class LLMModelConfigsRepository(BaseRepository):
async def get_group_config(self, config_id: UUID) -> Optional[models.LLMModelConfig]:
"""Get a group's LLM model configuration by ID."""
query = select(models.LLMModelConfig).where(
and_(
models.LLMModelConfig.config_id == config_id,
models.LLMModelConfig.group_id.isnot(None)
)
and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.group_id.isnot(None))
)
result = await self._db_session.execute(query)
return result.scalars().first()
async def get_group_configs(self, group_id: UUID) -> List[models.LLMModelConfig]:
"""Get all LLM model configurations for a group."""
query = select(models.LLMModelConfig).where(
models.LLMModelConfig.group_id == group_id
).order_by(models.LLMModelConfig.created_at)
query = (
select(models.LLMModelConfig)
.where(models.LLMModelConfig.group_id == group_id)
.order_by(models.LLMModelConfig.created_at)
)
result = await self._db_session.execute(query)
return result.scalars().all()
async def get_group_default_config(self, group_id: UUID) -> Optional[models.LLMModelConfig]:
"""Get a group's default LLM model configuration."""
query = select(models.LLMModelConfig).where(
and_(
models.LLMModelConfig.group_id == group_id,
models.LLMModelConfig.is_default
)
and_(models.LLMModelConfig.group_id == group_id, models.LLMModelConfig.is_default)
)
result = await self._db_session.execute(query)
return result.scalars().first()
async def create_group_config(
self,
group_id: UUID,
name: str,
model_type: str,
config_data: Dict[str, Any],
is_default: bool = False
self, group_id: UUID, name: str, model_type: str, config_data: Dict[str, Any], is_default: bool = False
) -> models.LLMModelConfig:
"""Create a new LLM model configuration for a group."""
# Encrypt API key if present
from gns3server.utils.encryption import encrypt
config_to_store = config_data.copy()
if "api_key" in config_to_store and config_to_store["api_key"]:
try:
@@ -288,7 +256,7 @@ class LLMModelConfigsRepository(BaseRepository):
group_id=group_id,
is_default=is_default,
created_at=now,
updated_at=now
updated_at=now,
)
self._db_session.add(db_config)
await self._db_session.commit()
@@ -296,11 +264,7 @@ class LLMModelConfigsRepository(BaseRepository):
return db_config
async def update_group_config(
self,
config_id: UUID,
group_id: UUID,
updates: Dict[str, Any],
expected_version: Optional[int] = None
self, config_id: UUID, group_id: UUID, updates: Dict[str, Any], expected_version: Optional[int] = None
) -> Optional[models.LLMModelConfig]:
"""
Update a group's LLM model configuration.
@@ -313,10 +277,7 @@ class LLMModelConfigsRepository(BaseRepository):
:raises ValueError: If version mismatch (concurrent modification)
"""
query = select(models.LLMModelConfig).where(
and_(
models.LLMModelConfig.config_id == config_id,
models.LLMModelConfig.group_id == group_id
)
and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.group_id == group_id)
)
result = await self._db_session.execute(query)
db_config = result.scalars().first()
@@ -376,10 +337,7 @@ class LLMModelConfigsRepository(BaseRepository):
async def delete_group_config(self, config_id: UUID, group_id: UUID) -> bool:
"""Delete a group's LLM model configuration."""
query = delete(models.LLMModelConfig).where(
and_(
models.LLMModelConfig.config_id == config_id,
models.LLMModelConfig.group_id == group_id
)
and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.group_id == group_id)
)
result = await self._db_session.execute(query)
await self._db_session.commit()
@@ -391,22 +349,16 @@ class LLMModelConfigsRepository(BaseRepository):
# First, unset current default
await self._db_session.execute(
update(models.LLMModelConfig)
.where(
and_(
models.LLMModelConfig.group_id == group_id,
models.LLMModelConfig.is_default
)
)
.where(and_(models.LLMModelConfig.group_id == group_id, models.LLMModelConfig.is_default))
.values(is_default=False, updated_at=now)
)
# Set new default
query = update(models.LLMModelConfig).where(
and_(
models.LLMModelConfig.config_id == config_id,
models.LLMModelConfig.group_id == group_id
)
).values(is_default=True, updated_at=now)
query = (
update(models.LLMModelConfig)
.where(and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.group_id == group_id))
.values(is_default=True, updated_at=now)
)
result = await self._db_session.execute(query)
await self._db_session.commit()
return result.rowcount > 0
@@ -414,10 +366,7 @@ class LLMModelConfigsRepository(BaseRepository):
# Inheritance methods
async def get_user_effective_configs(
self,
user_id: UUID,
current_user_id: Optional[UUID] = None,
current_user_is_superadmin: bool = False
self, user_id: UUID, current_user_id: Optional[UUID] = None, current_user_is_superadmin: bool = False
) -> Dict[str, Any]:
"""
Get user's effective configurations (own + inherited from groups).
@@ -429,9 +378,7 @@ class LLMModelConfigsRepository(BaseRepository):
user_configs = await self.get_user_configs(user_id)
# Get user's groups
query = select(models.UserGroup).\
join(models.UserGroup.users).\
filter(models.User.user_id == user_id)
query = select(models.UserGroup).join(models.UserGroup.users).filter(models.User.user_id == user_id)
result = await self._db_session.execute(query)
user_groups = result.scalars().all()
@@ -453,28 +400,8 @@ class LLMModelConfigsRepository(BaseRepository):
# Always hide API key for security
config_dict = self._hide_api_key(config.config)
configs_with_source.append({
"config_id": config.config_id,
"name": config.name,
"model_type": config.model_type,
"config": config_dict,
"user_id": config.user_id,
"group_id": config.group_id,
"is_default": config.is_default,
"version": config.version,
"created_at": config.created_at,
"updated_at": config.updated_at,
"source": "user",
"group_name": None
})
# Add inherited group configs (always shown, regardless of user configs)
for group_id, configs in group_configs_map.items():
for config in configs:
# Always hide API key for security
config_dict = self._hide_api_key(config.config)
configs_with_source.append({
configs_with_source.append(
{
"config_id": config.config_id,
"name": config.name,
"model_type": config.model_type,
@@ -485,9 +412,33 @@ class LLMModelConfigsRepository(BaseRepository):
"version": config.version,
"created_at": config.created_at,
"updated_at": config.updated_at,
"source": "group",
"group_name": group_names_map[group_id]
})
"source": "user",
"group_name": None,
}
)
# Add inherited group configs (always shown, regardless of user configs)
for group_id, configs in group_configs_map.items():
for config in configs:
# Always hide API key for security
config_dict = self._hide_api_key(config.config)
configs_with_source.append(
{
"config_id": config.config_id,
"name": config.name,
"model_type": config.model_type,
"config": config_dict,
"user_id": config.user_id,
"group_id": config.group_id,
"is_default": config.is_default,
"version": config.version,
"created_at": config.created_at,
"updated_at": config.updated_at,
"source": "group",
"group_name": group_names_map[group_id],
}
)
# Select default_config with proper priority:
# 1. User's config marked with is_default: true
@@ -508,7 +459,4 @@ class LLMModelConfigsRepository(BaseRepository):
if default_config is None and configs_with_source:
default_config = configs_with_source[0]
return {
"configs": configs_with_source,
"default_config": default_config
}
return {"configs": configs_with_source, "default_config": default_config}
+29 -28
View File
@@ -32,7 +32,6 @@ log = logging.getLogger(__name__)
class ResourcePoolsRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
@@ -61,9 +60,7 @@ class ResourcePoolsRepository(BaseRepository):
"""
db_resource = models.Resource(
resource_id=resource.resource_id,
resource_type=resource.resource_type,
name=resource.name
resource_id=resource.resource_id, resource_type=resource.resource_type, name=resource.name
)
self._db_session.add(db_resource)
await self._db_session.commit()
@@ -85,9 +82,11 @@ class ResourcePoolsRepository(BaseRepository):
Get all resource memberships in resource pools.
"""
query = select(models.ResourcePool).\
join(models.ResourcePool.resources).\
filter(models.Resource.resource_id == resource_id)
query = (
select(models.ResourcePool)
.join(models.ResourcePool.resources)
.filter(models.Resource.resource_id == resource_id)
)
result = await self._db_session.execute(query)
return result.scalars().all()
@@ -131,18 +130,18 @@ class ResourcePoolsRepository(BaseRepository):
return db_resource_pool
async def update_resource_pool(
self,
resource_pool_id: UUID,
resource_pool_update: schemas.ResourcePoolUpdate
self, resource_pool_id: UUID, resource_pool_update: schemas.ResourcePoolUpdate
) -> Optional[models.ResourcePool]:
"""
Update a resource pool.
"""
update_values = resource_pool_update.model_dump(exclude_unset=True)
query = update(models.ResourcePool).\
where(models.ResourcePool.resource_pool_id == resource_pool_id).\
values(update_values)
query = (
update(models.ResourcePool)
.where(models.ResourcePool.resource_pool_id == resource_pool_id)
.values(update_values)
)
await self._db_session.execute(query)
await self._db_session.commit()
@@ -170,17 +169,17 @@ class ResourcePoolsRepository(BaseRepository):
return result.rowcount > 0
async def add_resource_to_pool(
self,
resource_pool_id: UUID,
resource: models.Resource
self, resource_pool_id: UUID, resource: models.Resource
) -> Union[None, models.ResourcePool]:
"""
Add a resource to a resource pool.
"""
query = select(models.ResourcePool).\
options(selectinload(models.ResourcePool.resources)).\
where(models.ResourcePool.resource_pool_id == resource_pool_id)
query = (
select(models.ResourcePool)
.options(selectinload(models.ResourcePool.resources))
.where(models.ResourcePool.resource_pool_id == resource_pool_id)
)
result = await self._db_session.execute(query)
resource_pool_db = result.scalars().first()
if not resource_pool_db:
@@ -192,17 +191,17 @@ class ResourcePoolsRepository(BaseRepository):
return resource_pool_db
async def remove_resource_from_pool(
self,
resource_pool_id: UUID,
resource: models.Resource
self, resource_pool_id: UUID, resource: models.Resource
) -> Union[None, models.ResourcePool]:
"""
Remove a resource from a resource pool.
"""
query = select(models.ResourcePool).\
options(selectinload(models.ResourcePool.resources)).\
where(models.ResourcePool.resource_pool_id == resource_pool_id)
query = (
select(models.ResourcePool)
.options(selectinload(models.ResourcePool.resources))
.where(models.ResourcePool.resource_pool_id == resource_pool_id)
)
result = await self._db_session.execute(query)
resource_pool_db = result.scalars().first()
if not resource_pool_db:
@@ -219,9 +218,11 @@ class ResourcePoolsRepository(BaseRepository):
Get all resources from a resource pool.
"""
query = select(models.Resource).\
join(models.Resource.resource_pools).\
filter(models.ResourcePool.resource_pool_id == resource_pool_id)
query = (
select(models.Resource)
.join(models.Resource.resource_pools)
.filter(models.ResourcePool.resource_pool_id == resource_pool_id)
)
result = await self._db_session.execute(query)
return result.scalars().all()
+52 -83
View File
@@ -33,7 +33,6 @@ log = logging.getLogger(__name__)
class RbacRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
@@ -43,9 +42,7 @@ class RbacRepository(BaseRepository):
Get a role by its ID.
"""
query = select(models.Role).\
options(selectinload(models.Role.privileges)).\
where(models.Role.role_id == role_id)
query = select(models.Role).options(selectinload(models.Role.privileges)).where(models.Role.role_id == role_id)
result = await self._db_session.execute(query)
return result.scalars().first()
@@ -54,9 +51,7 @@ class RbacRepository(BaseRepository):
Get a role by its name.
"""
query = select(models.Role).\
options(selectinload(models.Role.privileges)).\
where(models.Role.name == name)
query = select(models.Role).options(selectinload(models.Role.privileges)).where(models.Role.name == name)
result = await self._db_session.execute(query)
return result.scalars().first()
@@ -82,19 +77,13 @@ class RbacRepository(BaseRepository):
await self._db_session.commit()
return await self.get_role(db_role.role_id)
async def update_role(
self,
role_id: UUID,
role_update: schemas.RoleUpdate
) -> Optional[models.Role]:
async def update_role(self, role_id: UUID, role_update: schemas.RoleUpdate) -> Optional[models.Role]:
"""
Update a role.
"""
update_values = role_update.model_dump(exclude_unset=True)
query = update(models.Role).\
where(models.Role.role_id == role_id).\
values(update_values)
query = update(models.Role).where(models.Role.role_id == role_id).values(update_values)
await self._db_session.execute(query)
await self._db_session.commit()
@@ -113,18 +102,12 @@ class RbacRepository(BaseRepository):
await self._db_session.commit()
return result.rowcount > 0
async def add_privilege_to_role(
self,
role_id: UUID,
privilege: models.Privilege
) -> Union[None, models.Role]:
async def add_privilege_to_role(self, role_id: UUID, privilege: models.Privilege) -> Union[None, models.Role]:
"""
Add a privilege to a role.
"""
query = select(models.Role).\
options(selectinload(models.Role.privileges)).\
where(models.Role.role_id == role_id)
query = select(models.Role).options(selectinload(models.Role.privileges)).where(models.Role.role_id == role_id)
result = await self._db_session.execute(query)
role_db = result.scalars().first()
if not role_db:
@@ -142,18 +125,12 @@ class RbacRepository(BaseRepository):
await self._db_session.refresh(role_db)
return role_db
async def remove_privilege_from_role(
self,
role_id: UUID,
privilege: models.Privilege
) -> Union[None, models.Role]:
async def remove_privilege_from_role(self, role_id: UUID, privilege: models.Privilege) -> Union[None, models.Role]:
"""
Remove a privilege from a role.
"""
query = select(models.Role).\
options(selectinload(models.Role.privileges)).\
where(models.Role.role_id == role_id)
query = select(models.Role).options(selectinload(models.Role.privileges)).where(models.Role.role_id == role_id)
result = await self._db_session.execute(query)
role_db = result.scalars().first()
if not role_db:
@@ -169,9 +146,7 @@ class RbacRepository(BaseRepository):
Get all the role privileges.
"""
query = select(models.Privilege).\
join(models.Privilege.roles).\
filter(models.Role.role_id == role_id)
query = select(models.Privilege).join(models.Privilege.roles).filter(models.Role.role_id == role_id)
result = await self._db_session.execute(query)
return result.scalars().all()
@@ -237,15 +212,11 @@ class RbacRepository(BaseRepository):
This method includes related user, group, and role information.
"""
query = select(models.ACE).\
where(
(models.ACE.path == path) | (models.ACE.path.startswith(path + "/"))
).\
options(
selectinload(models.ACE.user),
selectinload(models.ACE.group),
selectinload(models.ACE.role)
)
query = (
select(models.ACE)
.where((models.ACE.path == path) | (models.ACE.path.startswith(path + "/")))
.options(selectinload(models.ACE.user), selectinload(models.ACE.group), selectinload(models.ACE.role))
)
result = await self._db_session.execute(query)
return result.scalars().all()
@@ -254,8 +225,7 @@ class RbacRepository(BaseRepository):
Check if an ACE exists.
"""
query = select(models.ACE).\
where(models.ACE.path == path)
query = select(models.ACE).where(models.ACE.path == path)
result = await self._db_session.execute(query)
return result.scalars().first() is not None
@@ -271,19 +241,13 @@ class RbacRepository(BaseRepository):
await self._db_session.refresh(db_ace)
return db_ace
async def update_ace(
self,
ace_id: UUID,
ace_update: schemas.ACEUpdate
) -> Optional[models.ACE]:
async def update_ace(self, ace_id: UUID, ace_update: schemas.ACEUpdate) -> Optional[models.ACE]:
"""
Update an ACE
"""
update_values = ace_update.model_dump(exclude_unset=True)
query = update(models.ACE).\
where(models.ACE.ace_id == ace_id).\
values(update_values)
query = update(models.ACE).where(models.ACE.ace_id == ace_id).values(update_values)
await self._db_session.execute(query)
await self._db_session.commit()
@@ -307,9 +271,7 @@ class RbacRepository(BaseRepository):
Delete all ACEs starting with path.
"""
query = delete(models.ACE).\
where(models.ACE.path.startswith(path)).\
execution_options(synchronize_session=False)
query = delete(models.ACE).where(models.ACE.path.startswith(path)).execution_options(synchronize_session=False)
result = await self._db_session.execute(query)
log.debug(f"{result.rowcount} ACE(s) have been deleted")
@@ -344,9 +306,11 @@ class RbacRepository(BaseRepository):
for ace_path, ace_propagate, ace_allowed, ace_privilege in aces:
if ace_path.startswith("/pool"):
resource_pool_id = ace_path.split("/")[2]
query = select(models.Resource). \
join(models.Resource.resource_pools). \
filter(models.ResourcePool.resource_pool_id == resource_pool_id)
query = (
select(models.Resource)
.join(models.Resource.resource_pools)
.filter(models.ResourcePool.resource_pool_id == resource_pool_id)
)
result = await self._db_session.execute(query)
resources = result.scalars().all()
@@ -366,13 +330,15 @@ class RbacRepository(BaseRepository):
Retrieve all user ACEs matching the user_id and privilege name.
"""
query = select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name).\
join(models.Privilege.roles).\
join(models.Role.acl_entries).\
join(models.ACE.user). \
filter(models.User.user_id == user_id).\
filter(models.Privilege.name == privilege_name).\
order_by(models.ACE.path.desc())
query = (
select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name)
.join(models.Privilege.roles)
.join(models.Role.acl_entries)
.join(models.ACE.user)
.filter(models.User.user_id == user_id)
.filter(models.Privilege.name == privilege_name)
.order_by(models.ACE.path.desc())
)
result = await self._db_session.execute(query)
return result.all()
@@ -382,13 +348,15 @@ class RbacRepository(BaseRepository):
Retrieve all group ACEs matching the user_id and privilege name.
"""
query = select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name). \
join(models.Privilege.roles). \
join(models.Role.acl_entries). \
join(models.ACE.group). \
join(models.UserGroup.users).\
filter(models.User.user_id == user_id). \
filter(models.Privilege.name == privilege_name)
query = (
select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name)
.join(models.Privilege.roles)
.join(models.Role.acl_entries)
.join(models.ACE.group)
.join(models.UserGroup.users)
.filter(models.User.user_id == user_id)
.filter(models.Privilege.name == privilege_name)
)
result = await self._db_session.execute(query)
return result.all()
@@ -404,12 +372,7 @@ class RbacRepository(BaseRepository):
pool_resources.extend(await self._get_resources_in_pools(group_aces))
return list(set(pool_resources))
async def get_accessible_project_ids(
self,
user_id: UUID,
privilege_name: str,
all_project_ids: List[str]
):
async def get_accessible_project_ids(self, user_id: UUID, privilege_name: str, all_project_ids: List[str]):
"""
Batch check which projects a user can access via direct ACE or resource pools.
Performs 3 fixed DB queries regardless of project count.
@@ -448,8 +411,11 @@ class RbacRepository(BaseRepository):
user_denied.add(pid)
# --- User ACE: pool check ---
user_pool_ids = {ace_path.split("/")[2] for ace_path, _, ace_allowed, _ in user_aces
if ace_path.startswith("/pools/") and ace_allowed}
user_pool_ids = {
ace_path.split("/")[2]
for ace_path, _, ace_allowed, _ in user_aces
if ace_path.startswith("/pools/") and ace_allowed
}
pool_accessible_ids = set()
for pool_id, project_ids in pool_to_projects.items():
@@ -470,8 +436,11 @@ class RbacRepository(BaseRepository):
pass
# --- Group ACE: pool check ---
group_pool_ids = {ace_path.split("/")[2] for ace_path, _, ace_allowed, _ in group_aces
if ace_path.startswith("/pools/") and ace_allowed}
group_pool_ids = {
ace_path.split("/")[2]
for ace_path, _, ace_allowed, _ in group_aces
if ace_path.startswith("/pools/") and ace_allowed
}
for pool_id, project_ids in pool_to_projects.items():
if pool_id in group_pool_ids:
+39 -37
View File
@@ -49,7 +49,6 @@ TEMPLATE_TYPE_TO_MODEL = {
class TemplatesRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
@@ -59,17 +58,21 @@ class TemplatesRepository(BaseRepository):
async def get_template(self, template_id: UUID) -> Union[None, models.Template]:
query = select(models.Template).\
options(selectinload(models.Template.images)).\
where(models.Template.template_id == template_id)
query = (
select(models.Template)
.options(selectinload(models.Template.images))
.where(models.Template.template_id == template_id)
)
result = await self._db_session.execute(query)
return result.scalars().first()
async def get_template_by_name_and_version(self, name: str, version: str) -> Union[None, models.Template]:
query = select(models.Template).\
options(selectinload(models.Template.images)).\
where(models.Template.name == name, models.Template.version == version)
query = (
select(models.Template)
.options(selectinload(models.Template.images))
.where(models.Template.name == name, models.Template.version == version)
)
result = await self._db_session.execute(query)
return result.scalars().first()
@@ -78,9 +81,9 @@ class TemplatesRepository(BaseRepository):
Return the first template with this name, regardless of version.
"""
query = select(models.Template).\
options(selectinload(models.Template.images)).\
where(models.Template.name == name)
query = (
select(models.Template).options(selectinload(models.Template.images)).where(models.Template.name == name)
)
result = await self._db_session.execute(query)
return result.scalars().first()
@@ -117,9 +120,11 @@ class TemplatesRepository(BaseRepository):
async def duplicate_template(self, template_id: UUID) -> Optional[schemas.Template]:
query = select(models.Template).\
options(selectinload(models.Template.images)).\
where(models.Template.template_id == template_id)
query = (
select(models.Template)
.options(selectinload(models.Template.images))
.where(models.Template.template_id == template_id)
)
db_template = (await self._db_session.execute(query)).scalars().first()
if db_template:
# duplicate db object with new primary key (template_id)
@@ -140,9 +145,9 @@ class TemplatesRepository(BaseRepository):
if os.path.isabs(image_path):
query = select(models.Image).where(models.Image.path == image_path)
elif image_dir:
query = select(models.Image).\
where(models.Image.filename == image_name,
models.Image.path.endswith(os.sep + image_path, autoescape=True))
query = select(models.Image).where(
models.Image.filename == image_name, models.Image.path.endswith(os.sep + image_path, autoescape=True)
)
else:
query = select(models.Image).where(models.Image.filename == image_name)
if not include_unavailable:
@@ -158,23 +163,24 @@ class TemplatesRepository(BaseRepository):
)
return images[0] if images else None
async def add_image_to_template(
self,
template_id: UUID,
image: models.Image
) -> Union[None, models.Template]:
async def add_image_to_template(self, template_id: UUID, image: models.Image) -> Union[None, models.Template]:
"""
Add an image to template.
"""
async with image_lock(image.path):
exists = (await self._db_session.execute(select(models.Image.image_id).where(
models.Image.image_id == image.image_id))).scalar_one_or_none()
exists = (
await self._db_session.execute(
select(models.Image.image_id).where(models.Image.image_id == image.image_id)
)
).scalar_one_or_none()
if exists is None:
raise ControllerNotFoundError(f"Image '{image.path}' was removed while creating the template")
query = select(models.Template).\
options(selectinload(models.Template.images)).\
where(models.Template.template_id == template_id)
query = (
select(models.Template)
.options(selectinload(models.Template.images))
.where(models.Template.template_id == template_id)
)
result = await self._db_session.execute(query)
template_in_db = result.scalars().first()
if not template_in_db:
@@ -185,18 +191,16 @@ class TemplatesRepository(BaseRepository):
await self._db_session.refresh(template_in_db)
return template_in_db
async def remove_image_from_template(
self,
template_id: UUID,
image: models.Image
) -> Union[None, models.Template]:
async def remove_image_from_template(self, template_id: UUID, image: models.Image) -> Union[None, models.Template]:
"""
Remove an image from a template.
"""
query = select(models.Template).\
options(selectinload(models.Template.images)).\
where(models.Template.template_id == template_id)
query = (
select(models.Template)
.options(selectinload(models.Template.images))
.where(models.Template.template_id == template_id)
)
result = await self._db_session.execute(query)
template_in_db = result.scalars().first()
if not template_in_db:
@@ -213,8 +217,6 @@ class TemplatesRepository(BaseRepository):
Return all images attached to a template.
"""
query = select(models.Image).\
join(models.Image.templates).\
filter(models.Template.template_id == template_id)
query = select(models.Image).join(models.Image.templates).filter(models.Template.template_id == template_id)
result = await self._db_session.execute(query)
return result.scalars().all()
+26 -39
View File
@@ -33,7 +33,6 @@ log = logging.getLogger(__name__)
class UsersRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
@@ -82,10 +81,7 @@ class UsersRepository(BaseRepository):
hashed_password = self._auth_service.hash_password(user.password.get_secret_value())
db_user = models.User(
username=user.username,
email=user.email,
full_name=user.full_name,
hashed_password=hashed_password
username=user.username, email=user.email, full_name=user.full_name, hashed_password=hashed_password
)
self._db_session.add(db_user)
await self._db_session.commit()
@@ -102,9 +98,7 @@ class UsersRepository(BaseRepository):
if password:
update_values["hashed_password"] = self._auth_service.hash_password(password=password.get_secret_value())
query = update(models.User).\
where(models.User.user_id == user_id).\
values(update_values)
query = update(models.User).where(models.User.user_id == user_id).values(update_values)
await self._db_session.execute(query)
await self._db_session.commit()
@@ -118,9 +112,11 @@ class UsersRepository(BaseRepository):
Increment token_version to invalidate all existing tokens for the user.
"""
query = update(models.User).\
where(models.User.user_id == user_id).\
values(token_version=models.User.token_version + 1)
query = (
update(models.User)
.where(models.User.user_id == user_id)
.values(token_version=models.User.token_version + 1)
)
await self._db_session.execute(query)
await self._db_session.commit()
@@ -147,8 +143,9 @@ class UsersRepository(BaseRepository):
# this is useful for manual password recovery like:
# sqlite3 gns3_controller.db "UPDATE users SET hashed_password = null WHERE username = 'admin';"
if user.hashed_password is None:
log.warning(f"User '{username}' has been authenticated without a password "
f"configured. Please set a new password.")
log.warning(
f"User '{username}' has been authenticated without a password configured. Please set a new password."
)
return user
if not self._auth_service.verify_password(password, user.hashed_password):
return None
@@ -168,9 +165,7 @@ class UsersRepository(BaseRepository):
Get all user memberships (user groups).
"""
query = select(models.UserGroup).\
join(models.UserGroup.users).\
filter(models.User.user_id == user_id)
query = select(models.UserGroup).join(models.UserGroup.users).filter(models.User.user_id == user_id)
result = await self._db_session.execute(query)
return result.scalars().all()
@@ -214,18 +209,14 @@ class UsersRepository(BaseRepository):
return db_user_group
async def update_user_group(
self,
user_group_id: UUID,
user_group_update: schemas.UserGroupUpdate
self, user_group_id: UUID, user_group_update: schemas.UserGroupUpdate
) -> Optional[models.UserGroup]:
"""
Update a user group.
"""
update_values = user_group_update.model_dump(exclude_unset=True)
query = update(models.UserGroup).\
where(models.UserGroup.user_group_id == user_group_id).\
values(update_values)
query = update(models.UserGroup).where(models.UserGroup.user_group_id == user_group_id).values(update_values)
await self._db_session.execute(query)
await self._db_session.commit()
@@ -244,18 +235,16 @@ class UsersRepository(BaseRepository):
await self._db_session.commit()
return result.rowcount > 0
async def add_member_to_user_group(
self,
user_group_id: UUID,
user: models.User
) -> Union[None, models.UserGroup]:
async def add_member_to_user_group(self, user_group_id: UUID, user: models.User) -> Union[None, models.UserGroup]:
"""
Add a member to a user group.
"""
query = select(models.UserGroup).\
options(selectinload(models.UserGroup.users)).\
where(models.UserGroup.user_group_id == user_group_id)
query = (
select(models.UserGroup)
.options(selectinload(models.UserGroup.users))
.where(models.UserGroup.user_group_id == user_group_id)
)
result = await self._db_session.execute(query)
user_group_db = result.scalars().first()
if not user_group_db:
@@ -267,17 +256,17 @@ class UsersRepository(BaseRepository):
return user_group_db
async def remove_member_from_user_group(
self,
user_group_id: UUID,
user: models.User
self, user_group_id: UUID, user: models.User
) -> Union[None, models.UserGroup]:
"""
Remove a member from a user group.
"""
query = select(models.UserGroup).\
options(selectinload(models.UserGroup.users)).\
where(models.UserGroup.user_group_id == user_group_id)
query = (
select(models.UserGroup)
.options(selectinload(models.UserGroup.users))
.where(models.UserGroup.user_group_id == user_group_id)
)
result = await self._db_session.execute(query)
user_group_db = result.scalars().first()
if not user_group_db:
@@ -293,9 +282,7 @@ class UsersRepository(BaseRepository):
Get all members from a user group.
"""
query = select(models.User).\
join(models.User.groups).\
filter(models.UserGroup.user_group_id == user_group_id)
query = select(models.User).join(models.User.groups).filter(models.UserGroup.user_group_id == user_group_id)
result = await self._db_session.execute(query)
return result.scalars().all()
+22 -13
View File
@@ -74,7 +74,9 @@ 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)
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")
@@ -86,19 +88,21 @@ async def connect_to_db(app: FastAPI) -> None:
# 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)
# alembic_cfg.set_main_option('sqlalchemy.url', db_url)
try:
async with engine.connect() as conn:
current_rev, head_rev = await conn.run_sync(check_revision, alembic_cfg)
@@ -111,35 +115,39 @@ async def connect_to_db(app: FastAPI) -> None:
inspector = sa.inspect(connection)
tables = inspector.get_table_names()
if 'users' not in tables:
return 'new' # Truly new database
if "users" not in tables:
return "new" # Truly new database
# Check for new feature columns that indicate this is already migrated
columns = [col['name'] for col in inspector.get_columns('users')]
if 'llm_model_configs' in tables:
columns = [col["name"] for col in inspector.get_columns("users")]
if "llm_model_configs" in tables:
# The llm_model_configs table already exists (created from code)
return 'new_with_llm_configs'
return "new_with_llm_configs"
else:
# Old database without llm_model_configs table, needs migration
return 'old_needs_migration'
return "old_needs_migration"
db_state = await conn.run_sync(check_db_state)
if db_state == 'new':
if db_state == "new":
# Truly new database: create all tables and stamp
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(run_stamp, alembic_cfg)
await conn.commit()
log.info("Created new database and stamped to head revision")
elif db_state == 'new_with_llm_configs':
elif db_state == "new_with_llm_configs":
# Database already has llm_model_configs table (from Base.metadata.create_all)
# Older unversioned metadata databases lack inventory fields.
# Ensure the additive schema before stamping the current head.
def upgrade_inventory(connection):
from alembic.operations import Operations
from gns3server.db_migrations.versions.d9e8a2b7c401_image_inventory_reconciliation import upgrade
from gns3server.db_migrations.versions.d9e8a2b7c401_image_inventory_reconciliation import (
upgrade,
)
with Operations.context(MigrationContext.configure(connection)):
upgrade()
await conn.run_sync(upgrade_inventory)
await conn.run_sync(run_stamp, alembic_cfg)
await conn.commit()
@@ -188,6 +196,7 @@ async def update_disk_checksums(updated_disks: List[str]) -> None:
"""Refresh complete metadata after a server-managed disk modification."""
from gns3server.api.server import app
from gns3server.utils.image_inventory import image_lock
for path in updated_disks:
async with image_lock(path):
async with AsyncSession(app.state._db_engine, expire_on_commit=False) as session:
@@ -234,7 +243,7 @@ async def get_user_llm_config_full(user_id: str, app: FastAPI) -> Optional[dict]
result = await repo.get_user_effective_configs(
user_uuid,
current_user_id=user_uuid, # Viewing own config
current_user_is_superadmin=False
current_user_is_superadmin=False,
)
if not result or not result.get("default_config"):
@@ -275,7 +284,7 @@ async def get_user_llm_config_full(user_id: str, app: FastAPI) -> Optional[dict]
"group_name": default_config.get("group_name"),
"user_id": str(full_config.user_id) if full_config.user_id else None,
"group_id": str(full_config.group_id) if full_config.group_id else None,
**config_data # provider, api_key, model, temperature, etc.
**config_data, # provider, api_key, model, temperature, etc.
}
# Validate required fields