refactor: реструктуризация — core/, db/, routes/, web/

guarddog_nexus/
├── core/          scanner, harvester, nexus, llm
├── db/            engine, models, queries
├── routes/        webhooks, api_*, web
└── web/           templates + static

- 11 файлов перемещено (git mv — сохранена история)
- Все импорты обновлены (~15 файлов)
- main.py, tests — исправлены пути
- 50/50 тестов, ruff clean
This commit is contained in:
Marker689
2026-05-10 07:17:41 +03:00
parent 22dc87851a
commit 8726b65808
21 changed files with 80 additions and 80 deletions

View File

@@ -9,17 +9,17 @@ import tempfile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from guarddog_nexus.config import config
from guarddog_nexus.constants import (
from ..config import config
from ..constants import (
DEFAULT_ECOSYSTEM,
ERROR_MESSAGE_MAX_LENGTH,
PACKAGE_EXTENSIONS,
SCAN_ERROR_DOWNLOAD_FAILED,
)
from guarddog_nexus.logging_setup import log
from guarddog_nexus.models import Finding, Scan, ScanStatus
from guarddog_nexus.nexus_client import compute_sha256, download_asset, extract_package_info
from guarddog_nexus.scanner import scan_package
from ..db.models import Finding, Scan, ScanStatus
from ..logging_setup import log
from .nexus import compute_sha256, download_asset, extract_package_info
from .scanner import scan_package
# Per-URL locks to avoid parallel scans of the same asset
_url_locks: dict[str, asyncio.Lock] = {}
@@ -193,7 +193,7 @@ async def harvest(
async def _run_llm_analysis(findings: list[Finding], session: AsyncSession) -> list[dict]:
"""Run LLM analysis on findings and persist reports to the database."""
from guarddog_nexus.llm import analyze_finding
from .llm import analyze_finding
reports = []
for finding in findings:

View File

@@ -7,9 +7,9 @@ import json
import httpx
from guarddog_nexus.config import config
from guarddog_nexus.constants import LLM_ANALYSIS_SYSTEM_PROMPT
from guarddog_nexus.logging_setup import log
from ..config import config
from ..constants import LLM_ANALYSIS_SYSTEM_PROMPT
from ..logging_setup import log
def _build_user_message(finding: dict) -> str:

View File

@@ -5,13 +5,13 @@ import os
import httpx
from guarddog_nexus.config import config
from guarddog_nexus.constants import (
from ..config import config
from ..constants import (
NPM_PATH_PREFIX,
PYPI_PATH_PREFIX,
SHA256_CHUNK_SIZE,
)
from guarddog_nexus.logging_setup import log
from ..logging_setup import log
def extract_pypi_info(asset_path: str) -> tuple[str, str] | None:

View File

@@ -3,8 +3,8 @@
import asyncio
import json
from guarddog_nexus.config import config
from guarddog_nexus.constants import (
from ..config import config
from ..constants import (
DEFAULT_ECOSYSTEM,
DEFAULT_FINDING_SEVERITY,
GUARDDOG_OUTPUT_FORMAT,
@@ -14,7 +14,7 @@ from guarddog_nexus.constants import (
SCAN_ERROR_JSON_PARSE,
SCAN_ERROR_TIMEOUT,
)
from guarddog_nexus.logging_setup import log
from ..logging_setup import log
async def scan_package(filepath: str, ecosystem: str = DEFAULT_ECOSYSTEM) -> dict:

View File

@@ -20,7 +20,7 @@ class Base(DeclarativeBase):
async def _migrate():
"""Add any missing columns from model definitions to existing SQLite tables."""
import guarddog_nexus.models # noqa: F401
import guarddog_nexus.db.models # noqa: F401
async with _engine.connect() as conn:
for table in Base.metadata.sorted_tables:
@@ -63,7 +63,7 @@ async def _migrate():
async def init_db():
import guarddog_nexus.models # noqa: F401
import guarddog_nexus.db.models # noqa: F401
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@@ -6,7 +6,7 @@ from enum import Enum
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from guarddog_nexus.database import Base
from guarddog_nexus.db.engine import Base
class ScanStatus(str, Enum):

View File

@@ -20,7 +20,7 @@ from guarddog_nexus.constants import (
SCAN_SORT_FIELDS,
TOP_RULES_LIMIT,
)
from guarddog_nexus.models import Finding, Scan
from guarddog_nexus.db.models import Finding, Scan
# ---------------------------------------------------------------------------
# Scan list query builder

View File

@@ -5,8 +5,8 @@ import logging
import sys
from logging.handlers import SysLogHandler
from guarddog_nexus.config import config
from guarddog_nexus.constants import APP_PACKAGE
from .config import config
from .constants import APP_PACKAGE
class JsonFormatter(logging.Formatter):

View File

@@ -7,13 +7,13 @@ import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from guarddog_nexus.api import findings, packages, scans
from guarddog_nexus.config import config
from guarddog_nexus.constants import APP_DESCRIPTION, APP_NAME, APP_PACKAGE, STATIC_MOUNT_PATH
from guarddog_nexus.database import init_db
from guarddog_nexus.db.engine import init_db
from guarddog_nexus.logging_setup import log
from guarddog_nexus.web.routes import router as web_router
from guarddog_nexus.webhooks import router as webhook_router
from guarddog_nexus.routes import api_findings, api_packages, api_scans
from guarddog_nexus.routes.web import router as web_router
from guarddog_nexus.routes.webhooks import router as webhook_router
STATIC_DIR = os.path.join(os.path.dirname(__file__), "web", "static")
@@ -34,9 +34,9 @@ app = FastAPI(
)
app.include_router(webhook_router)
app.include_router(scans.router)
app.include_router(packages.router)
app.include_router(findings.router)
app.include_router(api_scans.router)
app.include_router(api_packages.router)
app.include_router(api_findings.router)
app.include_router(web_router)
if os.path.isdir(STATIC_DIR):

View File

View File

@@ -4,16 +4,16 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from guarddog_nexus.config import config
from guarddog_nexus.constants import (
from ..config import config
from ..constants import (
DEFAULT_OFFSET,
DEFAULT_PAGE_SIZE,
JSON_PATH_RULE,
JSON_PATH_SEVERITY,
MAX_PAGE_SIZE,
)
from guarddog_nexus.database import get_session
from guarddog_nexus.models import Finding
from ..db.engine import get_session
from ..db.models import Finding
router = APIRouter(prefix="/api/v1/findings", tags=["findings"])
@@ -70,7 +70,7 @@ async def analyze_finding_endpoint(
if not finding:
return {"detail": "Not found"}
from guarddog_nexus.llm import analyze_finding
from ..core.llm import analyze_finding
report = await analyze_finding(finding.data)
if report is None:

View File

@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from guarddog_nexus.constants import (
from ..constants import (
CSV_MEDIA_TYPE,
DEFAULT_OFFSET,
DEFAULT_PAGE_SIZE,
@@ -16,9 +16,9 @@ from guarddog_nexus.constants import (
DEFAULT_SORT_DIR,
MAX_PAGE_SIZE,
)
from guarddog_nexus.database import get_session
from guarddog_nexus.models import Finding, Scan
from guarddog_nexus.queries import build_package_list_query
from ..db.engine import get_session
from ..db.models import Finding, Scan
from ..db.queries import build_package_list_query
router = APIRouter(prefix="/api/v1/packages", tags=["packages"])

View File

@@ -8,7 +8,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from guarddog_nexus.constants import (
from ..constants import (
CSV_MEDIA_TYPE,
DEFAULT_OFFSET,
DEFAULT_PAGE_SIZE,
@@ -16,9 +16,9 @@ from guarddog_nexus.constants import (
DEFAULT_SORT_DIR,
MAX_PAGE_SIZE,
)
from guarddog_nexus.database import get_session
from guarddog_nexus.models import Scan
from guarddog_nexus.queries import build_scan_list_query, get_dashboard_stats
from ..db.engine import get_session
from ..db.models import Scan
from ..db.queries import build_scan_list_query, get_dashboard_stats
router = APIRouter(prefix="/api/v1/scans", tags=["scans"])

View File

@@ -8,16 +8,16 @@ from jinja2 import Environment, PackageLoader, select_autoescape
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from guarddog_nexus.constants import (
from ..constants import (
APP_PACKAGE,
DEFAULT_SORT_BY_PACKAGES,
DEFAULT_SORT_BY_SCANS,
DEFAULT_SORT_DIR,
WEB_PER_PAGE,
)
from guarddog_nexus.database import get_session
from guarddog_nexus.models import Finding, Scan
from guarddog_nexus.queries import (
from ..db.engine import get_session
from ..db.models import Finding, Scan
from ..db.queries import (
build_package_list_query,
build_scan_list_query,
get_dashboard_stats,
@@ -206,8 +206,8 @@ async def analyze_finding_htmx(
session: AsyncSession = Depends(get_session),
):
"""HTMX fragment: trigger LLM analysis and return styled result HTML."""
from guarddog_nexus.config import config
from guarddog_nexus.llm import analyze_finding
from ..config import config
from ..core.llm import analyze_finding
if not config.llm_enabled:
return HTMLResponse(

View File

@@ -7,8 +7,8 @@ import re
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status
from guarddog_nexus.config import config
from guarddog_nexus.constants import (
from ..config import config
from ..constants import (
DEFAULT_ECOSYSTEM,
METADATA_PATTERNS,
PACKAGE_EXTENSIONS,
@@ -19,9 +19,9 @@ from guarddog_nexus.constants import (
WEBHOOK_STATUS_ACCEPTED,
WEBHOOK_STATUS_IGNORED,
)
from guarddog_nexus.database import get_session
from guarddog_nexus.harvester import harvest
from guarddog_nexus.logging_setup import log
from ..core.harvester import harvest
from ..db.engine import get_session
from ..logging_setup import log
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
@@ -141,7 +141,7 @@ async def nexus_webhook(
async def _scan_component(repository: str, name: str, version: str, ecosystem: str):
from guarddog_nexus.nexus_client import nexus_get
from ..core.nexus import nexus_get
api_path = (
f"/service/rest/v1/search"

View File

@@ -18,9 +18,9 @@ os.environ["LOG_SYSLOG_HOST"] = ""
os.environ["TEMP_DIR"] = "/tmp/guarddog-nexus-test"
from guarddog_nexus.constants import DEFAULT_ECOSYSTEM, SEVERITY_WARNING # noqa: E402
from guarddog_nexus.database import Base, get_session # noqa: E402
from guarddog_nexus.db.engine import Base, get_session # noqa: E402
from guarddog_nexus.db.models import Finding, Scan, ScanStatus # noqa: E402
from guarddog_nexus.main import app # noqa: E402
from guarddog_nexus.models import Finding, Scan, ScanStatus # noqa: E402
@pytest_asyncio.fixture

View File

@@ -5,16 +5,16 @@ from unittest.mock import patch
import pytest
from sqlalchemy import select
from guarddog_nexus.harvester import harvest
from guarddog_nexus.models import Finding
from guarddog_nexus.core.harvester import harvest
from guarddog_nexus.db.models import Finding
@pytest.mark.asyncio
async def test_harvest_new_package(db_session, guarddog_normalized_flagged):
with (
patch("guarddog_nexus.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.harvester.scan_package") as mock_scan,
patch("guarddog_nexus.core.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.core.harvester.scan_package") as mock_scan,
):
mock_dl.return_value = "/tmp/test-package.tar.gz"
mock_sha.return_value = "abc123"
@@ -50,9 +50,9 @@ async def test_harvest_new_package(db_session, guarddog_normalized_flagged):
async def test_harvest_same_sha256_skips(db_session, guarddog_normalized_flagged):
"""Same SHA256 as existing scan → skip, don't re-scan."""
with (
patch("guarddog_nexus.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.harvester.scan_package") as mock_scan,
patch("guarddog_nexus.core.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.core.harvester.scan_package") as mock_scan,
):
mock_dl.return_value = "/tmp/test.tar.gz"
mock_sha.return_value = "deadbeef"
@@ -86,9 +86,9 @@ async def test_harvest_same_sha256_skips(db_session, guarddog_normalized_flagged
async def test_harvest_different_sha256_scans_again(db_session, guarddog_normalized_flagged):
"""Same name/version, different SHA256 → new scan."""
with (
patch("guarddog_nexus.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.harvester.scan_package") as mock_scan,
patch("guarddog_nexus.core.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.core.harvester.scan_package") as mock_scan,
):
mock_dl.return_value = "/tmp/test.tar.gz"
mock_scan.return_value = guarddog_normalized_flagged
@@ -123,9 +123,9 @@ async def test_harvest_different_sha256_scans_again(db_session, guarddog_normali
async def test_harvest_skips_active_scan_same_url(db_session, guarddog_normalized_flagged):
"""Concurrent webhooks for same URL: first proceeding, second skips as PENDING."""
with (
patch("guarddog_nexus.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.harvester.scan_package") as mock_scan,
patch("guarddog_nexus.core.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.core.harvester.scan_package") as mock_scan,
):
mock_dl.return_value = "/tmp/test.tar.gz"
mock_sha.return_value = "aaa"
@@ -147,9 +147,9 @@ async def test_harvest_skips_active_scan_same_url(db_session, guarddog_normalize
async def test_harvest_same_url_sha256_dedup(db_session, guarddog_normalized_flagged):
"""Same URL twice: second run hits SHA256 dedup (first already completed)."""
with (
patch("guarddog_nexus.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.harvester.scan_package") as mock_scan,
patch("guarddog_nexus.core.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.core.harvester.scan_package") as mock_scan,
):
mock_dl.return_value = "/tmp/test.tar.gz"
mock_sha.return_value = "ccc"
@@ -182,9 +182,9 @@ async def test_harvest_same_url_sha256_dedup(db_session, guarddog_normalized_fla
@pytest.mark.asyncio
async def test_harvest_clean_package(db_session, guarddog_normalized_clean):
with (
patch("guarddog_nexus.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.harvester.scan_package") as mock_scan,
patch("guarddog_nexus.core.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.core.harvester.scan_package") as mock_scan,
):
mock_dl.return_value = "/tmp/test.tar.gz"
mock_sha.return_value = "abc"
@@ -205,7 +205,7 @@ async def test_harvest_clean_package(db_session, guarddog_normalized_clean):
@pytest.mark.asyncio
async def test_harvest_download_failure(db_session):
with patch("guarddog_nexus.harvester.download_asset") as mock_dl:
with patch("guarddog_nexus.core.harvester.download_asset") as mock_dl:
mock_dl.return_value = None
scan = await harvest(

View File

@@ -1,6 +1,6 @@
"""Tests for GuardDog scanner integration."""
from guarddog_nexus.scanner import _normalize_output
from guarddog_nexus.core.scanner import _normalize_output
def test_normalize_clean_output(guarddog_output_clean):

View File

@@ -34,7 +34,7 @@ async def test_webhook_ignores_created_action(client, sample_nexus_webhook):
@pytest.mark.asyncio
async def test_webhook_accepts_asset_updated(client, sample_nexus_webhook):
sample_nexus_webhook["action"] = "UPDATED"
with patch("guarddog_nexus.webhooks._scan_in_background") as _mock:
with patch("guarddog_nexus.routes.webhooks._scan_in_background") as _mock:
resp = await client.post("/webhooks/nexus", json=sample_nexus_webhook)
assert resp.status_code == 200
assert resp.json()["status"] == "accepted"
@@ -68,7 +68,7 @@ async def test_webhook_no_asset_or_component(client):
@pytest.mark.asyncio
async def test_webhook_accepts_component(client, sample_nexus_component_webhook):
with patch("guarddog_nexus.webhooks._scan_component") as _mock:
with patch("guarddog_nexus.routes.webhooks._scan_component") as _mock:
resp = await client.post("/webhooks/nexus", json=sample_nexus_component_webhook)
assert resp.status_code == 200
data = resp.json()