mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-06 07:11:13 +02:00
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:
commit
42d30651da
497 files changed
+22962
-24152
No files matched your search
@@ -48,16 +48,15 @@ def _extract_alg(token: str) -> str:
|
||||
|
||||
|
||||
class AuthService:
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
|
||||
salt = bcrypt.gensalt()
|
||||
hashed_password = bcrypt.hashpw(password=password.encode('utf-8'), salt=salt)
|
||||
return hashed_password.decode('utf-8')
|
||||
hashed_password = bcrypt.hashpw(password=password.encode("utf-8"), salt=salt)
|
||||
return hashed_password.decode("utf-8")
|
||||
|
||||
def verify_password(self, password, hashed_password) -> bool:
|
||||
|
||||
return bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hashed_password.encode('utf-8'))
|
||||
return bcrypt.checkpw(password=password.encode("utf-8"), hashed_password=hashed_password.encode("utf-8"))
|
||||
|
||||
def _create_token(self, username, token_version, token_type, expires_in, secret_key=None) -> str:
|
||||
"""Shared helper to create any kind of signed JWT token."""
|
||||
@@ -80,7 +79,9 @@ class AuthService:
|
||||
expires_in = Config.instance().settings.Controller.jwt_access_token_expire_minutes
|
||||
return self._create_token(username, token_version, "access", expires_in, secret_key)
|
||||
|
||||
def create_refresh_token(self, username, token_version: int = 0, secret_key: str = None, expires_in: int = 0) -> str:
|
||||
def create_refresh_token(
|
||||
self, username, token_version: int = 0, secret_key: str = None, expires_in: int = 0
|
||||
) -> str:
|
||||
|
||||
if not expires_in:
|
||||
expires_in = Config.instance().settings.Controller.jwt_refresh_token_expire_minutes
|
||||
|
||||
@@ -17,13 +17,29 @@ from gns3server.config import Config
|
||||
from gns3server.db.models import Image, ImageSyncJob
|
||||
from gns3server.db.repositories.images import ImagesRepository
|
||||
from gns3server.utils.image_inventory import (
|
||||
ImageLock, ImageLockBusy, contained_path, fingerprint, image_lock, normalized_path,
|
||||
ImageLock,
|
||||
ImageLockBusy,
|
||||
contained_path,
|
||||
fingerprint,
|
||||
image_lock,
|
||||
normalized_path,
|
||||
)
|
||||
from gns3server.utils.images import inspect_image_file, InvalidImageError, ImageChangedError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
COUNTERS = ("scanned", "added", "updated", "unchanged", "missing", "unavailable",
|
||||
"invalid", "deferred", "out_of_scope", "errors", "bytes_hashed")
|
||||
COUNTERS = (
|
||||
"scanned",
|
||||
"added",
|
||||
"updated",
|
||||
"unchanged",
|
||||
"missing",
|
||||
"unavailable",
|
||||
"invalid",
|
||||
"deferred",
|
||||
"out_of_scope",
|
||||
"errors",
|
||||
"bytes_hashed",
|
||||
)
|
||||
RAW_EXTENSIONS = {".raw", ".img", ".iso", ".fd", ".vhd", ".vdi", ".bin"}
|
||||
|
||||
|
||||
@@ -57,10 +73,18 @@ def enumerate_root(root):
|
||||
root_stat = os.stat(root)
|
||||
if not stat.S_ISDIR(root_stat.st_mode):
|
||||
raise NotADirectoryError(root)
|
||||
for directory, dirs, names in os.walk(root, onerror=lambda e: errors.append(
|
||||
{"path": str(e.filename or root), "reason": str(e)}), followlinks=False):
|
||||
dirs[:] = [name for name in dirs if not ignored(name) and name not in ("lib", "lib64")
|
||||
and not os.path.islink(os.path.join(directory, name))]
|
||||
for directory, dirs, names in os.walk(
|
||||
root,
|
||||
onerror=lambda e: errors.append({"path": str(e.filename or root), "reason": str(e)}),
|
||||
followlinks=False,
|
||||
):
|
||||
dirs[:] = [
|
||||
name
|
||||
for name in dirs
|
||||
if not ignored(name)
|
||||
and name not in ("lib", "lib64")
|
||||
and not os.path.islink(os.path.join(directory, name))
|
||||
]
|
||||
for name in names:
|
||||
if ignored(name):
|
||||
continue
|
||||
@@ -125,17 +149,27 @@ class ImageReconciliationService:
|
||||
async with AsyncSession(self.engine, expire_on_commit=False) as session:
|
||||
# The OS lock proves no previous worker using this catalog is
|
||||
# active. Recover jobs abandoned by a crash before admitting one.
|
||||
await session.execute(update(ImageSyncJob).where(
|
||||
ImageSyncJob.status.in_(["queued", "running"])).values(
|
||||
status="interrupted", finished_at=utcnow()))
|
||||
job = ImageSyncJob(job_id=str(uuid.uuid4()), status="queued", dry_run=dry_run,
|
||||
force_checksum=force_checksum,
|
||||
counts=dict.fromkeys(COUNTERS, 0), errors=[])
|
||||
await session.execute(
|
||||
update(ImageSyncJob)
|
||||
.where(ImageSyncJob.status.in_(["queued", "running"]))
|
||||
.values(status="interrupted", finished_at=utcnow())
|
||||
)
|
||||
job = ImageSyncJob(
|
||||
job_id=str(uuid.uuid4()),
|
||||
status="queued",
|
||||
dry_run=dry_run,
|
||||
force_checksum=force_checksum,
|
||||
counts=dict.fromkeys(COUNTERS, 0),
|
||||
errors=[],
|
||||
)
|
||||
session.add(job)
|
||||
# Bound history; active jobs are never removed.
|
||||
old_jobs = select(ImageSyncJob.job_id).where(
|
||||
ImageSyncJob.status.notin_(["queued", "running"])).order_by(
|
||||
ImageSyncJob.created_at.desc()).offset(99)
|
||||
old_jobs = (
|
||||
select(ImageSyncJob.job_id)
|
||||
.where(ImageSyncJob.status.notin_(["queued", "running"]))
|
||||
.order_by(ImageSyncJob.created_at.desc())
|
||||
.offset(99)
|
||||
)
|
||||
await session.execute(delete(ImageSyncJob).where(ImageSyncJob.job_id.in_(old_jobs)))
|
||||
await session.commit()
|
||||
await session.refresh(job)
|
||||
@@ -158,9 +192,11 @@ class ImageReconciliationService:
|
||||
try:
|
||||
async with ImageLock("image-inventory", wait=False):
|
||||
async with AsyncSession(self.engine) as session:
|
||||
await session.execute(update(ImageSyncJob).where(
|
||||
ImageSyncJob.status.in_(["queued", "running"])).values(
|
||||
status="interrupted", finished_at=utcnow()))
|
||||
await session.execute(
|
||||
update(ImageSyncJob)
|
||||
.where(ImageSyncJob.status.in_(["queued", "running"]))
|
||||
.values(status="interrupted", finished_at=utcnow())
|
||||
)
|
||||
await session.commit()
|
||||
except ImageLockBusy:
|
||||
pass
|
||||
@@ -169,14 +205,21 @@ class ImageReconciliationService:
|
||||
if job is None:
|
||||
return None
|
||||
result = job.asdict()
|
||||
result["errors"] = result["errors"][offset:offset + limit]
|
||||
result["errors"] = result["errors"][offset : offset + limit]
|
||||
return result
|
||||
|
||||
async def _persist(self, job):
|
||||
async with AsyncSession(self.engine) as session:
|
||||
await session.execute(update(ImageSyncJob).where(ImageSyncJob.job_id == job["job_id"]).values(
|
||||
status=job["status"], counts=dict(job["counts"]), errors=list(job["errors"]),
|
||||
finished_at=job.get("finished_at")))
|
||||
await session.execute(
|
||||
update(ImageSyncJob)
|
||||
.where(ImageSyncJob.job_id == job["job_id"])
|
||||
.values(
|
||||
status=job["status"],
|
||||
counts=dict(job["counts"]),
|
||||
errors=list(job["errors"]),
|
||||
finished_at=job.get("finished_at"),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
def _error(self, job, path, reason):
|
||||
@@ -186,8 +229,7 @@ class ImageReconciliationService:
|
||||
job["errors"].append({"path": path, "reason": str(reason)})
|
||||
|
||||
async def _inspect(self, path, expected, allow_raw):
|
||||
worker = asyncio.create_task(asyncio.to_thread(
|
||||
inspect_image_file, path, expected, allow_raw, self.stopping))
|
||||
worker = asyncio.create_task(asyncio.to_thread(inspect_image_file, path, expected, allow_raw, self.stopping))
|
||||
try:
|
||||
return await asyncio.shield(worker)
|
||||
except asyncio.CancelledError:
|
||||
@@ -203,10 +245,16 @@ class ImageReconciliationService:
|
||||
return
|
||||
async with AsyncSession(self.engine) as session:
|
||||
# Revision guard for legacy writers not yet taking the path lock.
|
||||
await session.execute(update(Image).where(
|
||||
Image.image_id == row["image_id"], Image.path == row["path"],
|
||||
Image.file_fingerprint == row["file_fingerprint"],
|
||||
Image.checksum == row["checksum"]).values(availability=availability, last_error=str(reason)))
|
||||
await session.execute(
|
||||
update(Image)
|
||||
.where(
|
||||
Image.image_id == row["image_id"],
|
||||
Image.path == row["path"],
|
||||
Image.file_fingerprint == row["file_fingerprint"],
|
||||
Image.checksum == row["checksum"],
|
||||
)
|
||||
.values(availability=availability, last_error=str(reason))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def _row(self, path):
|
||||
@@ -215,7 +263,7 @@ class ImageReconciliationService:
|
||||
row = (await session.execute(select(Image).where(Image.path.in_([stored, path])))).scalar_one_or_none()
|
||||
return row.asdict() if row else None
|
||||
|
||||
async def _file(self, path, observed, job, root):
|
||||
async def _file(self, path, observed, job, root): # noqa: C901 - keep per-file outcomes under one path lock
|
||||
if path in self._ambiguous_paths:
|
||||
job["counts"]["deferred"] += 1
|
||||
return
|
||||
@@ -229,7 +277,12 @@ class ImageReconciliationService:
|
||||
current = await asyncio.to_thread(fingerprint, path)
|
||||
if current != observed:
|
||||
raise ImageChangedError("File changed during settling interval")
|
||||
if row and row["availability"] == "available" and row["file_fingerprint"] == current and not job["force_checksum"]:
|
||||
if (
|
||||
row
|
||||
and row["availability"] == "available"
|
||||
and row["file_fingerprint"] == current
|
||||
and not job["force_checksum"]
|
||||
):
|
||||
counts["unchanged"] += 1
|
||||
return
|
||||
expected = row["image_type"] if row else None
|
||||
@@ -238,8 +291,9 @@ class ImageReconciliationService:
|
||||
component = os.path.relpath(path, main_root).split(os.sep)[0]
|
||||
expected = {"QEMU": "qemu", "IOS": "ios", "IOU": "iou"}.get(component)
|
||||
allow_raw = Config.instance().settings.Server.allow_raw_images and (
|
||||
expected == "qemu" or
|
||||
(expected in (None, "qemu") and os.path.splitext(path)[1].lower() in RAW_EXTENSIONS))
|
||||
expected == "qemu"
|
||||
or (expected in (None, "qemu") and os.path.splitext(path)[1].lower() in RAW_EXTENSIONS)
|
||||
)
|
||||
info = await self._inspect(path, expected, allow_raw)
|
||||
if row:
|
||||
# Preserve legacy path spelling as well as image identity.
|
||||
@@ -274,7 +328,7 @@ class ImageReconciliationService:
|
||||
await self._state(row, "unavailable", e, job["dry_run"])
|
||||
self._error(job, path, e)
|
||||
|
||||
async def _run(self, job, lock):
|
||||
async def _run(self, job, lock): # noqa: C901 - one job owns root health, progress and lock lifetime
|
||||
job["status"] = "running"
|
||||
try:
|
||||
await self._persist(job)
|
||||
@@ -330,7 +384,11 @@ class ImageReconciliationService:
|
||||
if self.stopping.is_set():
|
||||
raise asyncio.CancelledError
|
||||
path = row["path"]
|
||||
if not contained_path(path, root) or normalized_path(path) in files or normalized_path(path) in self._ambiguous_paths:
|
||||
if (
|
||||
not contained_path(path, root)
|
||||
or normalized_path(path) in files
|
||||
or normalized_path(path) in self._ambiguous_paths
|
||||
):
|
||||
continue
|
||||
async with image_lock(path):
|
||||
row = await self._row(path)
|
||||
@@ -338,7 +396,9 @@ class ImageReconciliationService:
|
||||
continue
|
||||
if errors:
|
||||
job["counts"]["unavailable"] += 1
|
||||
await self._state(row, "unavailable", "Image root was not completely scanned", job["dry_run"])
|
||||
await self._state(
|
||||
row, "unavailable", "Image root was not completely scanned", job["dry_run"]
|
||||
)
|
||||
continue
|
||||
try:
|
||||
if not contained_path(os.path.realpath(path), resolved_root):
|
||||
|
||||
@@ -158,11 +158,11 @@ BUILTIN_TEMPLATES = [
|
||||
|
||||
|
||||
class TemplatesService:
|
||||
|
||||
def __init__(self, templates_repo: TemplatesRepository):
|
||||
|
||||
self._templates_repo = templates_repo
|
||||
from gns3server.controller import Controller
|
||||
|
||||
self._controller = Controller.instance()
|
||||
|
||||
# resolve built-in template symbols
|
||||
@@ -233,8 +233,10 @@ class TemplatesService:
|
||||
|
||||
if await self._templates_repo.get_template_by_name_and_version(template_create.name, template_create.version):
|
||||
if template_create.version:
|
||||
raise ControllerError(f"A template with name '{template_create.name}' and "
|
||||
f"version {template_create.version} already exists")
|
||||
raise ControllerError(
|
||||
f"A template with name '{template_create.name}' and "
|
||||
f"version {template_create.version} already exists"
|
||||
)
|
||||
else:
|
||||
raise ControllerError(f"A template with name '{template_create.name}' already exists")
|
||||
|
||||
@@ -365,11 +367,7 @@ class TemplatesService:
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
|
||||
return [
|
||||
{"filename": f}
|
||||
for f in sorted(os.listdir(path))
|
||||
if os.path.isfile(os.path.join(path, f))
|
||||
]
|
||||
return [{"filename": f} for f in sorted(os.listdir(path)) if os.path.isfile(os.path.join(path, f))]
|
||||
|
||||
def get_file(self, template_id: str, filename: str):
|
||||
safe_filename = os.path.basename(filename)
|
||||
@@ -396,4 +394,4 @@ class TemplatesService:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
except OSError as e:
|
||||
raise ControllerError(str(e))
|
||||
raise ControllerError(str(e))
|
||||
Reference in new issue
Block a user