mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
fix(images): reconcile the image inventory with filesystem changes
This commit is contained in:
1 parent
efd6bb77fe
commit
046dff4d08
22 files changed
+1809
-335
No files matched your search
@@ -0,0 +1,130 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from gns3server.db.repositories.images import ImagesRepository
|
||||
from gns3server.services.image_reconciliation import ImageReconciliationService
|
||||
from gns3server.services import auth_service
|
||||
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
QCOW = b"QFI\xfb\x00\x00\x00"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sync_service(app, db_engine, db_session, monkeypatch):
|
||||
service = ImageReconciliationService(db_engine, settle_seconds=0)
|
||||
monkeypatch.setattr(app.state, "image_reconciliation", service, raising=False)
|
||||
try:
|
||||
yield service
|
||||
finally:
|
||||
await service.close()
|
||||
|
||||
|
||||
class TestImageSyncRoutes:
|
||||
async def test_manual_sync_with_auto_discovery_disabled(self, app, client, sync_service, config, images_dir):
|
||||
assert not config.settings.Server.auto_discover_images
|
||||
path = Path(images_dir) / "QEMU" / "manual.qcow2"
|
||||
path.write_bytes(QCOW)
|
||||
response = await client.post("/v3/images/sync", json={})
|
||||
assert response.status_code == 202
|
||||
assert response.json()["job_id"] in response.headers["Location"]
|
||||
await sync_service.task
|
||||
job = await client.get(response.headers["Location"])
|
||||
assert job.status_code == 200
|
||||
assert job.json()["status"] == "completed"
|
||||
assert job.json()["counts"]["added"] == 1
|
||||
listed = await client.get("/v3/images", params={"availability": "available"})
|
||||
assert any(image["path"] == str(path) for image in listed.json())
|
||||
path.unlink()
|
||||
await client.post("/v3/images/sync", json={})
|
||||
await sync_service.task
|
||||
missing = await client.get("/v3/images", params={"availability": "missing"})
|
||||
assert any(image["path"] == str(path) for image in missing.json())
|
||||
|
||||
async def test_dry_run_and_request_validation(self, client, sync_service, images_dir):
|
||||
path = Path(images_dir) / "QEMU" / "preview.qcow2"
|
||||
path.write_bytes(QCOW)
|
||||
response = await client.post("/v3/images/sync", json={"dry_run": True, "force_checksum": True})
|
||||
assert response.status_code == 202
|
||||
await sync_service.task
|
||||
result = await client.get(response.headers["Location"], params={"offset": 0, "limit": 1})
|
||||
assert result.json()["counts"]["added"] == 1
|
||||
listed = await client.get("/v3/images")
|
||||
assert all(image["path"] != str(path) for image in listed.json())
|
||||
assert (await client.post("/v3/images/sync", json={"path": "/etc"})).status_code == 422
|
||||
assert (await client.get(response.headers["Location"], params={"limit": 1001})).status_code == 422
|
||||
assert (await client.get("/v3/images/sync/jobs/does-not-exist")).status_code == 404
|
||||
|
||||
async def test_overlapping_sync_is_conflict(self, client, sync_service):
|
||||
from gns3server.utils.image_inventory import ImageLock
|
||||
async with ImageLock("image-inventory"):
|
||||
response = await client.post("/v3/images/sync", json={})
|
||||
assert response.status_code == 409
|
||||
|
||||
async def test_delete_and_prune_keep_rows_on_permission_error(self, client, db_session, images_dir):
|
||||
response = await client.post("/v3/images/upload/protected.qcow2", content=QCOW)
|
||||
assert response.status_code == 201
|
||||
path = response.json()["path"]
|
||||
with patch("gns3server.api.routes.controller.images.os.remove", side_effect=PermissionError("denied")):
|
||||
response = await client.delete("/v3/images/protected.qcow2")
|
||||
assert response.status_code == 409
|
||||
response = await client.delete("/v3/images/prune")
|
||||
assert response.status_code == 409
|
||||
assert os.path.exists(path)
|
||||
assert await ImagesRepository(db_session).get_image(path) is not None
|
||||
os.unlink(path)
|
||||
assert (await client.delete("/v3/images/protected.qcow2")).status_code == 204
|
||||
|
||||
async def test_create_does_not_overwrite_unindexed_file(self, client, images_dir):
|
||||
path = Path(images_dir) / "QEMU" / "existing.qcow2"
|
||||
path.write_bytes(QCOW + b"preserve me")
|
||||
response = await client.post("/v3/images/qemu/existing.qcow2", json={"format": "qcow2", "size": 1})
|
||||
assert response.status_code == 400
|
||||
assert path.read_bytes() == QCOW + b"preserve me"
|
||||
|
||||
async def test_upload_rejects_path_prefix_sibling_and_symlink(self, client, images_dir, tmp_path):
|
||||
sibling = images_dir + "-outside/escape.qcow2"
|
||||
response = await client.post("/v3/images/upload/" + sibling, content=QCOW)
|
||||
assert response.status_code == 403
|
||||
assert not os.path.exists(sibling)
|
||||
link = Path(images_dir) / "external"
|
||||
link.symlink_to(tmp_path, target_is_directory=True)
|
||||
response = await client.post("/v3/images/upload/external/escape.qcow2", content=QCOW)
|
||||
assert response.status_code == 403
|
||||
assert not (tmp_path / "escape.qcow2").exists()
|
||||
|
||||
async def test_sync_requires_allocate_privilege(self, client, sync_service, test_user):
|
||||
token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
|
||||
with patch("gns3server.db.repositories.rbac.RbacRepository.check_user_has_privilege", new=AsyncMock(return_value=False)):
|
||||
response = await client.post("/v3/images/sync", json={}, headers={"Authorization": f"Bearer {token}"})
|
||||
assert response.status_code == 403
|
||||
assert "Image.Allocate" in response.text
|
||||
response = await client.get("/v3/images/sync/jobs/test", headers={"Authorization": f"Bearer {token}"})
|
||||
assert response.status_code == 403
|
||||
assert "Image.Audit" in response.text
|
||||
response = await client.post("/v3/images/sync", json={}, headers={"Authorization": ""})
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_template_can_detach_missing_image(self, app, client, sync_service, db_session):
|
||||
uploaded = await client.post('/v3/images/upload/detach-missing.qcow2', content=QCOW)
|
||||
assert uploaded.status_code == 201
|
||||
image = uploaded.json()
|
||||
image_id = (await ImagesRepository(db_session).get_image(image['path'])).image_id
|
||||
created = await client.post('/v3/templates', json={
|
||||
'name': 'Detach missing image', 'compute_id': 'local',
|
||||
'template_type': 'qemu', 'hda_disk_image': image['filename'],
|
||||
})
|
||||
assert created.status_code == 201
|
||||
template_id = created.json()['template_id']
|
||||
os.unlink(image['path'])
|
||||
await client.post('/v3/images/sync', json={})
|
||||
await sync_service.task
|
||||
updated = await client.put(f'/v3/templates/{template_id}', json={'hda_disk_image': ''})
|
||||
assert updated.status_code == 200
|
||||
assert not await ImagesRepository(db_session).get_image_templates(image_id)
|
||||
# Detaching a template does not silently remove the missing catalog row.
|
||||
assert await ImagesRepository(db_session).get_image(image['path']) is not None
|
||||
@@ -111,14 +111,17 @@ class TestImageRoutes:
|
||||
|
||||
async def test_create_image(self, app: FastAPI, client: AsyncClient, images_dir) -> None:
|
||||
|
||||
Qemu.instance().create_disk_image = AsyncioMagicMock()
|
||||
async def create_disk(path, options):
|
||||
with open(path, "wb") as f:
|
||||
f.write(b'QFI\xfb\x00\x00\x00')
|
||||
|
||||
Qemu.instance().create_disk_image = AsyncioMagicMock(side_effect=create_disk)
|
||||
path = os.path.join(os.path.join(images_dir, "QEMU", "new_image.qcow2"))
|
||||
with open(path, "wb+") as f:
|
||||
f.write(b'QFI\xfb\x00\x00\x00')
|
||||
image_name = os.path.basename(path)
|
||||
response = await client.post(
|
||||
app.url_path_for("create_qemu_image", image_path=image_name), json={"format": "qcow2", "size": 30})
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert os.path.isfile(path)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"image_type, fixture_name, valid_request",
|
||||
@@ -473,4 +476,4 @@ class TestImageRoutes:
|
||||
templates = await templates_repo.get_templates()
|
||||
assert len(templates) == 1
|
||||
assert templates[0].name == "Empty VM"
|
||||
await templates_repo.delete_template(templates[0].template_id)
|
||||
await templates_repo.delete_template(templates[0].template_id)
|
||||
+27
-6
@@ -1,4 +1,5 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
import pytest_asyncio
|
||||
import tempfile
|
||||
import shutil
|
||||
@@ -100,12 +101,32 @@ async def base_client(app: FastAPI, db_session: AsyncSession) -> AsyncGenerator[
|
||||
|
||||
app.dependency_overrides[get_db_session] = _get_test_db
|
||||
|
||||
async with AsyncClient(
|
||||
base_url="http://test-api",
|
||||
headers={"Content-Type": "application/json"},
|
||||
transport=ASGIWebSocketTransport(app=app)
|
||||
) as async_client:
|
||||
yield async_client
|
||||
# AnyIO cancel scopes must be entered and exited by the same task. Recent
|
||||
# pytest-asyncio versions finalize yield fixtures in a different task.
|
||||
ready = asyncio.get_running_loop().create_future()
|
||||
finished = asyncio.Event()
|
||||
|
||||
async def own_client():
|
||||
try:
|
||||
async with AsyncClient(
|
||||
base_url="http://test-api",
|
||||
headers={"Content-Type": "application/json"},
|
||||
transport=ASGIWebSocketTransport(app=app)
|
||||
) as async_client:
|
||||
ready.set_result(async_client)
|
||||
await finished.wait()
|
||||
except BaseException as e:
|
||||
if not ready.done():
|
||||
ready.set_exception(e)
|
||||
raise
|
||||
|
||||
owner = asyncio.create_task(own_client())
|
||||
try:
|
||||
yield await ready
|
||||
finally:
|
||||
finished.set()
|
||||
await owner
|
||||
app.dependency_overrides.pop(get_db_session, None)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="class", scope="class")
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select, text, event
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from watchdog.events import FileMovedEvent, FileDeletedEvent, FileModifiedEvent
|
||||
|
||||
from gns3server.db.models import Base, Image, ImageSyncJob, Template
|
||||
from gns3server.db.models.images import image_template_map
|
||||
from gns3server.db.repositories.images import ImagesRepository
|
||||
from gns3server.services.image_reconciliation import ImageReconciliationService, InventoryEvents, enumerate_root
|
||||
from gns3server.utils.image_inventory import ImageLockBusy, image_lock, fingerprint
|
||||
from gns3server.utils.images import write_image, inspect_image_file, md5sum, InvalidImageError
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
QCOW = b"QFI\xfb\x00\x00\x00"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def inventory(tmp_path, config):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'catalog.db'}")
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def foreign_keys(connection, record):
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
async with engine.connect() as conn:
|
||||
with patch("gns3server.services.authentication.AuthService.hash_password", return_value="test"):
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await conn.commit()
|
||||
service = ImageReconciliationService(engine, settle_seconds=0)
|
||||
try:
|
||||
yield service
|
||||
finally:
|
||||
await service.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def image_file(config, name="QEMU/image.qcow2", data=QCOW):
|
||||
path = Path(config.settings.Server.images_path) / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
async def scan(service, **kwargs):
|
||||
job = await service.start(**kwargs)
|
||||
await service.task
|
||||
return await service.get_job(job["job_id"])
|
||||
|
||||
|
||||
async def rows(service):
|
||||
async with AsyncSession(service.engine) as session:
|
||||
return [image.asdict() for image in (await session.execute(select(Image))).scalars()]
|
||||
|
||||
|
||||
async def test_add_change_missing_restore_preserves_identity_and_template(inventory, config):
|
||||
path = image_file(config)
|
||||
assert (await scan(inventory))["counts"]["added"] == 1
|
||||
original = (await rows(inventory))[0]
|
||||
async with AsyncSession(inventory.engine) as session:
|
||||
image = await session.get(Image, original["image_id"])
|
||||
session.add(Template(name="Keep this reference", images=[image]))
|
||||
await session.commit()
|
||||
path.write_bytes(QCOW + b"changed")
|
||||
assert (await scan(inventory))["counts"]["updated"] == 1
|
||||
changed = (await rows(inventory))[0]
|
||||
assert changed["image_id"] == original["image_id"]
|
||||
assert changed["image_size"] == len(QCOW + b"changed")
|
||||
assert changed["checksum"] == hashlib.md5(QCOW + b"changed").hexdigest()
|
||||
path.unlink()
|
||||
assert (await scan(inventory))["counts"]["missing"] == 1
|
||||
assert (await rows(inventory))[0]["availability"] == "missing"
|
||||
path.write_bytes(QCOW)
|
||||
await scan(inventory)
|
||||
restored = (await rows(inventory))[0]
|
||||
assert restored["image_id"] == original["image_id"]
|
||||
assert restored["availability"] == "available"
|
||||
async with AsyncSession(inventory.engine) as session:
|
||||
assert len((await session.execute(select(image_template_map))).all()) == 1
|
||||
|
||||
|
||||
async def test_fingerprints_skip_hashing_and_force_bypasses_sidecars(inventory, config):
|
||||
path = image_file(config)
|
||||
await scan(inventory)
|
||||
with patch("gns3server.services.image_reconciliation.inspect_image_file", side_effect=AssertionError("unnecessary hash")):
|
||||
result = await scan(inventory)
|
||||
assert result["status"] == "completed"
|
||||
assert result["counts"]["unchanged"] == 1
|
||||
assert result["counts"]["bytes_hashed"] == 0
|
||||
path.write_bytes(QCOW[:-1] + b"x") # same-size modification
|
||||
sidecar = Path(str(path) + ".md5sum")
|
||||
sidecar.write_text("0" * 32)
|
||||
assert md5sum(str(path), cache_to_md5file=False) == "0" * 32
|
||||
assert md5sum(str(path), cache_to_md5file=False, use_cache=False) == hashlib.md5(path.read_bytes()).hexdigest()
|
||||
result = await scan(inventory, force_checksum=True)
|
||||
assert result["counts"]["bytes_hashed"] == len(QCOW)
|
||||
assert (await rows(inventory))[0]["checksum"] == hashlib.md5(path.read_bytes()).hexdigest()
|
||||
assert not sidecar.exists()
|
||||
|
||||
|
||||
async def test_dry_run_does_not_write_catalog_or_sidecar(inventory, config):
|
||||
path = image_file(config)
|
||||
sidecar = Path(str(path) + ".md5sum")
|
||||
sidecar.write_text("0" * 32)
|
||||
result = await scan(inventory, dry_run=True, force_checksum=True)
|
||||
assert result["counts"]["added"] == 1
|
||||
assert await rows(inventory) == []
|
||||
assert sidecar.read_text() == "0" * 32
|
||||
await scan(inventory)
|
||||
before = await rows(inventory)
|
||||
path.unlink()
|
||||
assert (await scan(inventory, dry_run=True))["counts"]["missing"] == 1
|
||||
assert await rows(inventory) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["missing_root", "partial_walk", "unreadable_file"])
|
||||
async def test_failed_scopes_do_not_remove_rows(inventory, config, failure):
|
||||
path = image_file(config)
|
||||
await scan(inventory)
|
||||
root = str(config.settings.Server.images_path)
|
||||
if failure == "missing_root":
|
||||
os.rename(root, root + "-offline")
|
||||
result = await scan(inventory)
|
||||
elif failure == "partial_walk":
|
||||
with patch("gns3server.services.image_reconciliation.enumerate_root", return_value=(
|
||||
{}, [{"path": root, "reason": "Permission denied"}])):
|
||||
result = await scan(inventory)
|
||||
else:
|
||||
with patch("gns3server.services.image_reconciliation.inspect_image_file", side_effect=PermissionError("denied")):
|
||||
result = await scan(inventory, force_checksum=True)
|
||||
assert result["status"] == "partial"
|
||||
assert result["counts"]["missing"] == 0
|
||||
assert len(await rows(inventory)) == 1
|
||||
assert (await rows(inventory))[0]["availability"] == "unavailable"
|
||||
|
||||
|
||||
async def test_extra_nested_and_overlapping_roots(inventory, config, tmp_path):
|
||||
extra = tmp_path / "extra"
|
||||
extra.mkdir()
|
||||
(extra / "image.qcow2").write_bytes(QCOW)
|
||||
image_file(config, "QEMU/nested/image.qcow2")
|
||||
image_file(config, "legacy.qcow2")
|
||||
config.settings.Server.additional_images_paths = [str(extra), str(extra), config.settings.Server.images_path]
|
||||
result = await scan(inventory)
|
||||
assert result["counts"]["added"] == 3
|
||||
assert len(await rows(inventory)) == 3
|
||||
|
||||
|
||||
async def test_rename_retains_old_reference(inventory, config):
|
||||
path = image_file(config)
|
||||
await scan(inventory)
|
||||
old = (await rows(inventory))[0]
|
||||
path.rename(path.with_name("renamed.qcow2"))
|
||||
result = await scan(inventory)
|
||||
assert result["counts"]["added"] == result["counts"]["missing"] == 1
|
||||
assert next(row for row in await rows(inventory) if row["image_id"] == old["image_id"])["availability"] == "missing"
|
||||
|
||||
|
||||
async def test_symlinks_hidden_files_and_libraries_are_not_imported(inventory, config, tmp_path):
|
||||
outside = tmp_path / "outside.qcow2"
|
||||
outside.write_bytes(QCOW)
|
||||
path = image_file(config)
|
||||
path.unlink()
|
||||
path.symlink_to(outside)
|
||||
image_file(config, ".hidden/hidden.qcow2")
|
||||
image_file(config, "IOU/lib/library.so")
|
||||
image_file(config, "QEMU/upload.tmp")
|
||||
result = await scan(inventory)
|
||||
assert result["counts"]["added"] == 0
|
||||
assert outside.read_bytes() == QCOW
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,data,expected", [
|
||||
("QEMU/disk.raw", b"raw image bytes", "qemu"),
|
||||
("IOS/ios.bin", b"\x7fELF\x01\x02\x01", "ios"),
|
||||
("IOU/iou.bin", b"\x7fELF\x02\x01\x01", "iou"),
|
||||
])
|
||||
async def test_image_types_and_raw_policy(inventory, config, name, data, expected):
|
||||
image_file(config, name, data)
|
||||
result = await scan(inventory)
|
||||
assert result["counts"]["added"] == 1
|
||||
assert (await rows(inventory))[0]["image_type"] == expected
|
||||
|
||||
|
||||
async def test_invalid_replacement_not_usable(inventory, config):
|
||||
path = image_file(config)
|
||||
await scan(inventory)
|
||||
path.write_bytes(b"bad")
|
||||
assert (await scan(inventory))["counts"]["invalid"] == 1
|
||||
async with AsyncSession(inventory.engine) as session:
|
||||
repo = ImagesRepository(session)
|
||||
assert await repo.get_image_by_checksum(hashlib.md5(QCOW).hexdigest()) is None
|
||||
assert (await rows(inventory))[0]["checksum"] == hashlib.md5(QCOW).hexdigest()
|
||||
|
||||
|
||||
async def test_changed_during_inspection_is_deferred(inventory, config):
|
||||
path = image_file(config)
|
||||
original = inspect_image_file
|
||||
def replace_after_hash(*args):
|
||||
info = original(*args)
|
||||
path.write_bytes(QCOW + b"still copying")
|
||||
return info
|
||||
with patch("gns3server.services.image_reconciliation.inspect_image_file", side_effect=replace_after_hash):
|
||||
result = await scan(inventory)
|
||||
assert result["counts"]["deferred"] == 1
|
||||
assert await rows(inventory) == []
|
||||
assert (await scan(inventory))["counts"]["added"] == 1
|
||||
|
||||
|
||||
async def test_per_file_database_failure_does_not_poison_next_file(inventory, config):
|
||||
image_file(config, "QEMU/first.qcow2")
|
||||
image_file(config, "QEMU/second.qcow2")
|
||||
original = ImagesRepository.save_verified_image
|
||||
calls = 0
|
||||
async def fail_first(repo, info):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
# Real failed SQL statement in the file's session.
|
||||
await repo._db_session.execute(text("INSERT INTO nonexistent_image_table VALUES (1)"))
|
||||
return await original(repo, info)
|
||||
with patch.object(ImagesRepository, "save_verified_image", fail_first):
|
||||
result = await scan(inventory)
|
||||
assert result["status"] == "partial"
|
||||
assert result["counts"]["added"] == 1
|
||||
assert (await scan(inventory))["counts"]["added"] == 1
|
||||
assert len(await rows(inventory)) == 2
|
||||
|
||||
|
||||
async def test_overlapping_jobs_rejected_and_interrupted_job_recovers(inventory, config):
|
||||
path = image_file(config)
|
||||
async with image_lock(str(path)):
|
||||
job = await inventory.start()
|
||||
other = ImageReconciliationService(inventory.engine, settle_seconds=0)
|
||||
with pytest.raises(ImageLockBusy):
|
||||
await other.start()
|
||||
await inventory.task
|
||||
async with AsyncSession(inventory.engine) as session:
|
||||
stale = await session.get(ImageSyncJob, job["job_id"])
|
||||
stale.status = "running"
|
||||
await session.commit()
|
||||
await scan(inventory)
|
||||
assert (await inventory.get_job(job["job_id"]))["status"] == "interrupted"
|
||||
|
||||
|
||||
async def test_close_immediately_releases_lock_and_finishes_job(inventory, config):
|
||||
image_file(config)
|
||||
job = await inventory.start()
|
||||
await inventory.close()
|
||||
assert (await inventory.get_job(job["job_id"]))["status"] == "cancelled"
|
||||
other = ImageReconciliationService(inventory.engine, settle_seconds=0)
|
||||
try:
|
||||
assert (await scan(other))["status"] == "completed"
|
||||
finally:
|
||||
await other.close()
|
||||
|
||||
|
||||
async def test_watcher_coalesces_move_delete_modify_events():
|
||||
dirty = asyncio.Event()
|
||||
handler = InventoryEvents(asyncio.get_running_loop(), dirty)
|
||||
for event in [FileMovedEvent("/images/upload.tmp", "/images/new.qcow2"),
|
||||
FileDeletedEvent("/images/deleted.qcow2"), FileModifiedEvent("/images/changed.qcow2")]:
|
||||
dirty.clear()
|
||||
handler.dispatch(event)
|
||||
await asyncio.sleep(0)
|
||||
assert dirty.is_set()
|
||||
dirty.clear()
|
||||
handler.dispatch(FileModifiedEvent("/images/new.qcow2.md5sum"))
|
||||
await asyncio.sleep(0)
|
||||
assert not dirty.is_set()
|
||||
|
||||
|
||||
async def stream(data, chunk_size=1):
|
||||
for offset in range(0, len(data), chunk_size):
|
||||
yield data[offset:offset + chunk_size]
|
||||
|
||||
|
||||
async def test_upload_fragmented_header_and_missing_row_reuse(inventory, config):
|
||||
path = image_file(config)
|
||||
await scan(inventory)
|
||||
old_id = (await rows(inventory))[0]["image_id"]
|
||||
path.unlink()
|
||||
await scan(inventory)
|
||||
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
|
||||
image = await write_image("QEMU/image.qcow2", str(path), stream(QCOW), ImagesRepository(session))
|
||||
assert image.image_id == old_id
|
||||
assert image.availability == "available"
|
||||
assert path.read_bytes() == QCOW
|
||||
assert not list(path.parent.glob("*.tmp"))
|
||||
|
||||
|
||||
async def test_upload_db_failure_is_recovered_by_scan(inventory, config):
|
||||
path = Path(config.settings.Server.images_path) / "QEMU/orphan.qcow2"
|
||||
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
|
||||
repo = ImagesRepository(session)
|
||||
with patch.object(repo, "save_verified_image", side_effect=SQLAlchemyError("commit failed")):
|
||||
with pytest.raises(SQLAlchemyError):
|
||||
await write_image("QEMU/orphan.qcow2", str(path), stream(QCOW), repo)
|
||||
assert path.read_bytes() == QCOW
|
||||
assert (await scan(inventory))["counts"]["added"] == 1
|
||||
|
||||
|
||||
async def test_concurrent_uploads_never_overwrite(inventory, config):
|
||||
path = Path(config.settings.Server.images_path) / "QEMU/same.qcow2"
|
||||
async def upload(data):
|
||||
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
|
||||
return await write_image("QEMU/same.qcow2", str(path), stream(data), ImagesRepository(session))
|
||||
result = await asyncio.gather(upload(QCOW), upload(QCOW + b"different"), return_exceptions=True)
|
||||
assert sum(isinstance(item, InvalidImageError) for item in result) == 1
|
||||
assert len(await rows(inventory)) == 1
|
||||
assert (await rows(inventory))[0]["checksum"] == hashlib.md5(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
async def test_checksum_lookup_ignores_missing_duplicate(inventory, config):
|
||||
first = image_file(config, "QEMU/first.qcow2")
|
||||
second = image_file(config, "QEMU/second.qcow2")
|
||||
await scan(inventory)
|
||||
first.unlink()
|
||||
# No sync is needed to reject a stale candidate at the point of use.
|
||||
async with AsyncSession(inventory.engine) as session:
|
||||
image = await ImagesRepository(session).get_image_by_checksum(hashlib.md5(QCOW).hexdigest(), str(second.parent))
|
||||
assert image.path == str(second)
|
||||
|
||||
|
||||
async def test_path_matching_does_not_interpret_sql_wildcards(inventory, config):
|
||||
path = image_file(config, "QEMU/name_%/disk.qcow2")
|
||||
image_file(config, "QEMU/name_AB/disk.qcow2")
|
||||
await scan(inventory)
|
||||
async with AsyncSession(inventory.engine) as session:
|
||||
repo = ImagesRepository(session)
|
||||
assert (await repo.get_image("name_%/disk.qcow2")).path == str(path)
|
||||
assert await repo.get_image("me_%/disk.qcow2") is None
|
||||
|
||||
|
||||
async def test_migration_roundtrip_preserves_rows_and_relationships(inventory, config):
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
image_file(config)
|
||||
await scan(inventory)
|
||||
original = (await rows(inventory))[0]
|
||||
async with AsyncSession(inventory.engine) as session:
|
||||
image = await session.get(Image, original["image_id"])
|
||||
session.add(Template(name="Migration reference", images=[image]))
|
||||
await session.commit()
|
||||
|
||||
def migrate(connection):
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", "gns3server:db_migrations")
|
||||
cfg.attributes["connection"] = connection
|
||||
command.stamp(cfg, "d9e8a2b7c401")
|
||||
command.downgrade(cfg, "c7e4a9f1d2b6")
|
||||
assert connection.execute(text("SELECT count(*) FROM image_template_map")).scalar_one() == 1
|
||||
assert connection.execute(text("SELECT image_id FROM images")).scalar_one() == original["image_id"]
|
||||
command.upgrade(cfg, "head")
|
||||
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
|
||||
|
||||
async with inventory.engine.connect() as connection:
|
||||
await connection.run_sync(migrate)
|
||||
await connection.commit()
|
||||
migrated = (await rows(inventory))[0]
|
||||
assert migrated["availability"] == "unknown"
|
||||
assert migrated["file_fingerprint"] is None
|
||||
assert migrated["image_id"] == original["image_id"]
|
||||
await scan(inventory)
|
||||
assert (await rows(inventory))[0]["availability"] == "available"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="Native Linux watcher integration")
|
||||
async def test_native_watcher_covers_extra_roots_and_stops(inventory, config, tmp_path):
|
||||
extra = tmp_path / "extra-watch"
|
||||
extra.mkdir()
|
||||
config.settings.Server.additional_images_paths = [str(extra)]
|
||||
config.settings.Server.auto_discover_images = True
|
||||
await inventory._watch()
|
||||
observer = inventory.observer
|
||||
assert observer.is_alive()
|
||||
inventory.dirty.clear()
|
||||
temporary = extra / "incoming.tmp"
|
||||
temporary.write_bytes(QCOW)
|
||||
temporary.rename(extra / "finished.qcow2")
|
||||
await asyncio.wait_for(inventory.dirty.wait(), 5)
|
||||
assert (await scan(inventory))["counts"]["added"] == 1
|
||||
inventory.dirty.clear()
|
||||
(extra / "finished.qcow2").unlink()
|
||||
await asyncio.wait_for(inventory.dirty.wait(), 5)
|
||||
assert (await scan(inventory))["counts"]["missing"] == 1
|
||||
await inventory.close()
|
||||
assert not observer.is_alive()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX lock integration")
|
||||
async def test_image_lock_excludes_another_process(config, tmp_path):
|
||||
path = str(tmp_path / "image.qcow2")
|
||||
code = (
|
||||
"import fcntl, sys; f = open(sys.argv[1], 'a+b'); "
|
||||
"fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)"
|
||||
)
|
||||
async with image_lock(path) as lock:
|
||||
lock_path = lock._file.name
|
||||
result = await asyncio.to_thread(subprocess.run, [sys.executable, "-c", code, lock_path], capture_output=True)
|
||||
assert result.returncode != 0
|
||||
assert b"BlockingIOError" in result.stderr
|
||||
result = await asyncio.to_thread(subprocess.run, [sys.executable, "-c", code, lock_path], capture_output=True)
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
async def test_shutdown_during_hash_waits_for_worker_before_releasing_lock(inventory, config):
|
||||
image_file(config)
|
||||
entered = threading.Event()
|
||||
exited = threading.Event()
|
||||
def slow_hash(path, expected, raw, stopped):
|
||||
entered.set()
|
||||
stopped.wait(5)
|
||||
exited.set()
|
||||
raise InterruptedError("stopped")
|
||||
with patch("gns3server.services.image_reconciliation.inspect_image_file", side_effect=slow_hash):
|
||||
job = await inventory.start()
|
||||
assert await asyncio.to_thread(entered.wait, 5)
|
||||
await inventory.close()
|
||||
assert exited.is_set()
|
||||
assert (await inventory.get_job(job["job_id"]))["status"] == "cancelled"
|
||||
assert await rows(inventory) == []
|
||||
|
||||
|
||||
async def test_appliance_fallback_repairs_existing_row_and_rejects_wrong_content(inventory, config):
|
||||
from gns3server.controller.appliance_manager import ApplianceManager
|
||||
from gns3server.controller.controller_error import ControllerError
|
||||
path = image_file(config)
|
||||
await scan(inventory)
|
||||
old_id = (await rows(inventory))[0]["image_id"]
|
||||
data = QCOW + b"replacement"
|
||||
path.write_bytes(data)
|
||||
appliance = SimpleNamespace(images=[{"filename": path.name, "md5sum": hashlib.md5(data).hexdigest()}])
|
||||
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
|
||||
await ApplianceManager()._find_appliance_version_images(
|
||||
appliance, {"images": {"hda_disk_image": path.name}}, ImagesRepository(session), str(path.parent))
|
||||
assert (await rows(inventory))[0]["image_id"] == old_id
|
||||
appliance.images[0]["md5sum"] = "0" * 32
|
||||
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
|
||||
with pytest.raises(ControllerError, match="checksum"):
|
||||
await ApplianceManager()._find_appliance_version_images(
|
||||
appliance, {"images": {"hda_disk_image": path.name}}, ImagesRepository(session), str(path.parent))
|
||||
|
||||
|
||||
async def test_legacy_path_spelling_preserves_identity(inventory, config):
|
||||
path = image_file(config)
|
||||
legacy_path = str(path.parent) + os.sep + "." + os.sep + path.name
|
||||
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
|
||||
original = await ImagesRepository(session).add_image(path.name, "qemu", len(QCOW), legacy_path, "0" * 32, "md5")
|
||||
original_id = original.image_id
|
||||
await scan(inventory)
|
||||
images = await rows(inventory)
|
||||
assert len(images) == 1
|
||||
assert images[0]["image_id"] == original_id
|
||||
assert images[0]["path"] == legacy_path
|
||||
assert images[0]["checksum"] == hashlib.md5(QCOW).hexdigest()
|
||||
|
||||
|
||||
async def test_root_disappearing_during_scan_does_not_mark_images_missing(inventory, config):
|
||||
first = image_file(config, "QEMU/first.qcow2")
|
||||
image_file(config, "QEMU/second.qcow2")
|
||||
await scan(inventory)
|
||||
first.unlink()
|
||||
root = config.settings.Server.images_path
|
||||
original = inventory._file
|
||||
async def disconnect_root(*args):
|
||||
await original(*args)
|
||||
os.rename(root, root + "-disconnected")
|
||||
with patch.object(inventory, "_file", side_effect=disconnect_root):
|
||||
result = await scan(inventory)
|
||||
assert result["status"] == "partial"
|
||||
assert result["counts"]["missing"] == 0
|
||||
images = await rows(inventory)
|
||||
assert next(image for image in images if image["path"] == str(first))["availability"] == "unavailable"
|
||||
|
||||
|
||||
async def test_polling_recovers_crashed_job_without_starting_a_scan(inventory, config):
|
||||
image_file(config)
|
||||
job = await scan(inventory)
|
||||
async with AsyncSession(inventory.engine) as session:
|
||||
row = await session.get(ImageSyncJob, job["job_id"])
|
||||
row.status = "running"
|
||||
await session.commit()
|
||||
assert not config.settings.Server.auto_discover_images
|
||||
assert (await inventory.get_job(job["job_id"]))["status"] == "interrupted"
|
||||
|
||||
|
||||
async def test_periodic_scan_recovers_without_watcher_events(inventory, config):
|
||||
config.settings.Server.auto_discover_images = True
|
||||
image_file(config)
|
||||
# Use a short interval and initial wait in the service, leaving asyncio's
|
||||
# event-loop scheduling and database operations intact.
|
||||
real_sleep = asyncio.sleep
|
||||
async def short_sleep(seconds):
|
||||
await real_sleep(0.01)
|
||||
scans = []
|
||||
original_start = inventory.start
|
||||
async def observed_start(*args, **kwargs):
|
||||
job = await original_start(*args, **kwargs)
|
||||
scans.append(job["job_id"])
|
||||
return job
|
||||
async def missed_event(*args, **kwargs):
|
||||
# Consume/close the unused Event.wait coroutine, then simulate a timeout.
|
||||
args[0].close()
|
||||
await real_sleep(0.01)
|
||||
raise asyncio.TimeoutError
|
||||
with patch.object(inventory, "_watch", new=AsyncMock()), \
|
||||
patch.object(inventory, "start", side_effect=observed_start), \
|
||||
patch("gns3server.services.image_reconciliation.asyncio.sleep", side_effect=short_sleep), \
|
||||
patch("gns3server.services.image_reconciliation.asyncio.wait_for", side_effect=missed_event):
|
||||
inventory.start_background()
|
||||
for _ in range(500):
|
||||
if len(scans) >= 2:
|
||||
break
|
||||
await real_sleep(0.01)
|
||||
await inventory.close()
|
||||
assert len(scans) >= 2
|
||||
assert len(await rows(inventory)) == 1
|
||||
|
||||
|
||||
async def test_unchanged_collection_avoids_hashing_and_keeps_loop_responsive(inventory, config):
|
||||
import time
|
||||
for index in range(200):
|
||||
image_file(config, f"QEMU/bulk/{index}.qcow2", QCOW + b"x" * 65536)
|
||||
samples = []
|
||||
done = asyncio.Event()
|
||||
async def heartbeat():
|
||||
previous = time.monotonic()
|
||||
while not done.is_set():
|
||||
await asyncio.sleep(0.01)
|
||||
current = time.monotonic()
|
||||
samples.append(current - previous)
|
||||
previous = current
|
||||
pulse = asyncio.create_task(heartbeat())
|
||||
try:
|
||||
first = await scan(inventory)
|
||||
second = await scan(inventory)
|
||||
finally:
|
||||
done.set()
|
||||
await pulse
|
||||
assert first["counts"]["added"] == 200
|
||||
assert second["counts"]["unchanged"] == 200
|
||||
assert second["counts"]["bytes_hashed"] == 0
|
||||
assert len(samples) > 2
|
||||
# Generous stall threshold; this checks responsiveness rather than host speed.
|
||||
assert max(samples) < 2
|
||||
|
||||
|
||||
async def test_startup_upgrades_unversioned_existing_catalog(inventory, config, monkeypatch):
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi import FastAPI
|
||||
from gns3server.db.tasks import connect_to_db, disconnect_from_db
|
||||
image_file(config)
|
||||
await scan(inventory)
|
||||
original_id = (await rows(inventory))[0]["image_id"]
|
||||
|
||||
def remove_inventory_schema(connection):
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", "gns3server:db_migrations")
|
||||
cfg.attributes["connection"] = connection
|
||||
command.stamp(cfg, "d9e8a2b7c401")
|
||||
command.downgrade(cfg, "c7e4a9f1d2b6")
|
||||
connection.execute(text("DROP TABLE alembic_version"))
|
||||
|
||||
async with inventory.engine.connect() as connection:
|
||||
await connection.run_sync(remove_inventory_schema)
|
||||
await connection.commit()
|
||||
monkeypatch.setenv("GNS3_DATABASE_URI", str(inventory.engine.url))
|
||||
app = FastAPI()
|
||||
await connect_to_db(app)
|
||||
try:
|
||||
restored = (await rows(inventory))[0]
|
||||
assert restored["image_id"] == original_id
|
||||
assert restored["availability"] == "unknown"
|
||||
finally:
|
||||
await disconnect_from_db(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["delete", "prune"])
|
||||
@pytest.mark.parametrize("reuse_id", [False, True])
|
||||
async def test_delete_preserves_replacement_created_while_waiting_for_lock(inventory, config, operation, reuse_id):
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy import delete
|
||||
from gns3server.api.routes.controller.images import delete_image
|
||||
from gns3server.controller.controller_error import ControllerError
|
||||
|
||||
path = image_file(config)
|
||||
await scan(inventory)
|
||||
original = (await rows(inventory))[0]
|
||||
replacement = QCOW + b"replacement uploaded by another request"
|
||||
replacement_id = original["image_id"] if reuse_id else original["image_id"] + 100
|
||||
|
||||
@asynccontextmanager
|
||||
async def concurrent_replacement(locked_path):
|
||||
assert locked_path == str(path)
|
||||
async with AsyncSession(inventory.engine) as writer:
|
||||
await writer.execute(delete(Image).where(Image.image_id == original["image_id"]))
|
||||
path.write_bytes(replacement)
|
||||
info = inspect_image_file(str(path))
|
||||
info["filename"] = info.pop("image_name")
|
||||
writer.add(Image(image_id=replacement_id, **info))
|
||||
await writer.commit()
|
||||
yield
|
||||
|
||||
module = "gns3server.api.routes.controller.images" if operation == "delete" else "gns3server.db.repositories.images"
|
||||
with patch(module + ".image_lock", concurrent_replacement), patch(
|
||||
"gns3server.api.routes.controller.images.Controller.instance",
|
||||
return_value=SimpleNamespace(find_projects_using_image=lambda filename: []),
|
||||
):
|
||||
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
|
||||
repository = ImagesRepository(session)
|
||||
if operation == "delete":
|
||||
with pytest.raises(ControllerError, match="changed"):
|
||||
await delete_image(str(path), repository)
|
||||
else:
|
||||
assert await repository.prune_images() == 0
|
||||
assert path.read_bytes() == replacement
|
||||
assert (await rows(inventory))[0]["image_id"] == replacement_id
|
||||
Reference in new issue
Block a user