Refactor: implemented Repository Factory and Async Mutator Engine. Decoupled storage logic and enforced Dependency Injection across CLI and Web API. Updated documentation.
Some checks failed
CI / Lint (ruff) (push) Successful in 12s
CI / SAST (bandit) (push) Successful in 13s
CI / Dependency audit (pip-audit) (push) Successful in 22s
CI / Test (Standard) (3.11) (push) Failing after 54s
CI / Test (Standard) (3.12) (push) Successful in 1m35s
CI / Test (Live) (3.11) (push) Has been skipped
CI / Test (Fuzz) (3.11) (push) Has been skipped
CI / Merge dev → testing (push) Has been skipped
CI / Prepare Merge to Main (push) Has been skipped
CI / Finalize Merge to Main (push) Has been skipped
Some checks failed
CI / Lint (ruff) (push) Successful in 12s
CI / SAST (bandit) (push) Successful in 13s
CI / Dependency audit (pip-audit) (push) Successful in 22s
CI / Test (Standard) (3.11) (push) Failing after 54s
CI / Test (Standard) (3.12) (push) Successful in 1m35s
CI / Test (Live) (3.11) (push) Has been skipped
CI / Test (Fuzz) (3.11) (push) Has been skipped
CI / Merge dev → testing (push) Has been skipped
CI / Prepare Merge to Main (push) Has been skipped
CI / Finalize Merge to Main (push) Has been skipped
This commit is contained in:
@@ -46,6 +46,7 @@ DECNET is a honeypot/deception network framework. It deploys fake machines (call
|
||||
- The logging/aggregation network must be isolated from the decoy network.
|
||||
- A publicly accessible real server acts as the bridge between the two networks.
|
||||
- Deckies should differ in exposed services and OS fingerprints to appear as a heterogeneous network.
|
||||
- **IMPORTANT**: The system now strictly enforces dependency injection for storage. Do not import `SQLiteRepository` directly in new features; instead, use `get_repository()` from the factory or the FastAPI `get_repo` dependency.
|
||||
|
||||
## Development and testing
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ Host NIC (eth0)
|
||||
- **Extensive testing** for every function must be created.
|
||||
- **Always develop in the `dev` branch, never in `main`.**
|
||||
- **Test in the `testing` branch.**
|
||||
- **IMPORTANT**: The system now strictly enforces dependency injection for storage. Do not import `SQLiteRepository` directly in new features; instead, use `get_repository()` from the factory or the FastAPI `get_repo` dependency.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
|
||||
1
decnet.collector.log
Normal file
1
decnet.collector.log
Normal file
@@ -0,0 +1 @@
|
||||
Collector starting → /home/anti/Tools/DECNET/decnet.log
|
||||
BIN
decnet.db-shm
Normal file
BIN
decnet.db-shm
Normal file
Binary file not shown.
BIN
decnet.db-wal
Normal file
BIN
decnet.db-wal
Normal file
Binary file not shown.
@@ -252,7 +252,7 @@ def deploy(
|
||||
console.print("[red]Failed to start mutator watcher.[/]")
|
||||
|
||||
if effective_log_file and not dry_run and not api:
|
||||
import subprocess # noqa: F811 # nosec B404
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path as _Path
|
||||
_collector_err = _Path(effective_log_file).with_suffix(".collector.log")
|
||||
@@ -301,18 +301,20 @@ def mutate(
|
||||
force_all: bool = typer.Option(False, "--all", help="Force mutate all deckies immediately"),
|
||||
) -> None:
|
||||
"""Manually trigger or continuously watch for decky mutation."""
|
||||
import asyncio
|
||||
from decnet.mutator import mutate_decky, mutate_all, run_watch_loop
|
||||
from decnet.web.dependencies import repo
|
||||
|
||||
if watch:
|
||||
run_watch_loop()
|
||||
asyncio.run(run_watch_loop(repo))
|
||||
return
|
||||
|
||||
if decky_name:
|
||||
mutate_decky(decky_name)
|
||||
asyncio.run(mutate_decky(decky_name, repo))
|
||||
elif force_all:
|
||||
mutate_all(force=True)
|
||||
asyncio.run(mutate_all(force=True, repo=repo))
|
||||
else:
|
||||
mutate_all(force=False)
|
||||
asyncio.run(mutate_all(force=False, repo=repo))
|
||||
|
||||
|
||||
@app.command()
|
||||
|
||||
@@ -5,9 +5,9 @@ from decnet.correlation.graph import AttackerTraversal, TraversalHop
|
||||
from decnet.correlation.parser import LogEvent, parse_line
|
||||
|
||||
__all__ = [
|
||||
"CorrelationEngine",
|
||||
"AttackerTraversal",
|
||||
"TraversalHop",
|
||||
"CorrelationEngine",
|
||||
"LogEvent",
|
||||
"TraversalHop",
|
||||
"parse_line",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Calculate absolute path to the project root
|
||||
@@ -55,6 +56,10 @@ DECNET_ADMIN_USER: str = os.environ.get("DECNET_ADMIN_USER", "admin")
|
||||
DECNET_ADMIN_PASSWORD: str = os.environ.get("DECNET_ADMIN_PASSWORD", "admin")
|
||||
DECNET_DEVELOPER: bool = os.environ.get("DECNET_DEVELOPER", "False").lower() == "true"
|
||||
|
||||
# Database Options
|
||||
DECNET_DB_TYPE: str = os.environ.get("DECNET_DB_TYPE", "sqlite").lower()
|
||||
DECNET_DB_URL: Optional[str] = os.environ.get("DECNET_DB_URL")
|
||||
|
||||
# CORS — comma-separated list of allowed origins for the web dashboard API.
|
||||
# Defaults to the configured web host/port. Override with DECNET_CORS_ORIGINS if needed.
|
||||
# Example: DECNET_CORS_ORIGINS=http://192.168.1.50:9090,https://dashboard.example.com
|
||||
|
||||
@@ -12,25 +12,29 @@ from rich.console import Console
|
||||
from decnet.archetypes import get_archetype
|
||||
from decnet.fleet import all_service_names
|
||||
from decnet.composer import write_compose
|
||||
from decnet.config import DeckyConfig, load_state, save_state
|
||||
from decnet.config import DeckyConfig, DecnetConfig
|
||||
from decnet.engine import _compose_with_retry
|
||||
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
import anyio
|
||||
import asyncio
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def mutate_decky(decky_name: str) -> bool:
|
||||
async def mutate_decky(decky_name: str, repo: BaseRepository) -> bool:
|
||||
"""
|
||||
Perform an Intra-Archetype Shuffle for a specific decky.
|
||||
Returns True if mutation succeeded, False otherwise.
|
||||
"""
|
||||
state = load_state()
|
||||
if state is None:
|
||||
console.print("[red]No active deployment found (no decnet-state.json).[/]")
|
||||
state_dict = await repo.get_state("deployment")
|
||||
if state_dict is None:
|
||||
console.print("[red]No active deployment found in database.[/]")
|
||||
return False
|
||||
|
||||
config, compose_path = state
|
||||
config = DecnetConfig(**state_dict["config"])
|
||||
compose_path = Path(state_dict["compose_path"])
|
||||
decky: Optional[DeckyConfig] = next((d for d in config.deckies if d.name == decky_name), None)
|
||||
|
||||
if not decky:
|
||||
@@ -63,31 +67,35 @@ def mutate_decky(decky_name: str) -> bool:
|
||||
decky.services = list(chosen)
|
||||
decky.last_mutated = time.time()
|
||||
|
||||
save_state(config, compose_path)
|
||||
# Save to DB
|
||||
await repo.set_state("deployment", {"config": config.model_dump(), "compose_path": str(compose_path)})
|
||||
|
||||
# Still writes files for Docker to use
|
||||
write_compose(config, compose_path)
|
||||
|
||||
console.print(f"[cyan]Mutating '{decky_name}' to services: {', '.join(decky.services)}[/]")
|
||||
|
||||
try:
|
||||
_compose_with_retry("up", "-d", "--remove-orphans", compose_file=compose_path)
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]Failed to mutate '{decky_name}': {e.stderr}[/]")
|
||||
# Wrap blocking call in thread
|
||||
await anyio.to_thread.run_sync(_compose_with_retry, "up", "-d", "--remove-orphans", compose_path)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Failed to mutate '{decky_name}': {e}[/]")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def mutate_all(force: bool = False) -> None:
|
||||
async def mutate_all(repo: BaseRepository, force: bool = False) -> None:
|
||||
"""
|
||||
Check all deckies and mutate those that are due.
|
||||
If force=True, mutates all deckies regardless of schedule.
|
||||
"""
|
||||
state = load_state()
|
||||
if state is None:
|
||||
state_dict = await repo.get_state("deployment")
|
||||
if state_dict is None:
|
||||
console.print("[red]No active deployment found.[/]")
|
||||
return
|
||||
|
||||
config, _ = state
|
||||
config = DecnetConfig(**state_dict["config"])
|
||||
now = time.time()
|
||||
|
||||
mutated_count = 0
|
||||
@@ -103,7 +111,7 @@ def mutate_all(force: bool = False) -> None:
|
||||
due = elapsed_secs >= (interval_mins * 60)
|
||||
|
||||
if due:
|
||||
success = mutate_decky(decky.name)
|
||||
success = await mutate_decky(decky.name, repo=repo)
|
||||
if success:
|
||||
mutated_count += 1
|
||||
|
||||
@@ -111,12 +119,12 @@ def mutate_all(force: bool = False) -> None:
|
||||
console.print("[dim]No deckies are due for mutation.[/]")
|
||||
|
||||
|
||||
def run_watch_loop(poll_interval_secs: int = 10) -> None:
|
||||
async def run_watch_loop(repo: BaseRepository, poll_interval_secs: int = 10) -> None:
|
||||
"""Run an infinite loop checking for deckies that need mutation."""
|
||||
console.print(f"[green]DECNET Mutator Watcher started (polling every {poll_interval_secs}s).[/]")
|
||||
try:
|
||||
while True:
|
||||
mutate_all(force=False)
|
||||
time.sleep(poll_interval_secs)
|
||||
await mutate_all(force=False, repo=repo)
|
||||
await asyncio.sleep(poll_interval_secs)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Mutator watcher stopped.[/]")
|
||||
|
||||
@@ -32,6 +32,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
log.error("DB failed to initialize after 5 attempts — startup may be degraded")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Start background tasks only if not in contract test mode
|
||||
if os.environ.get("DECNET_CONTRACT_TEST") != "true":
|
||||
# Start background ingestion task
|
||||
if ingestion_task is None or ingestion_task.done():
|
||||
ingestion_task = asyncio.create_task(log_ingestion_worker(repo))
|
||||
@@ -40,15 +42,23 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
_log_file = os.environ.get("DECNET_INGEST_LOG_FILE", DECNET_INGEST_LOG_FILE)
|
||||
if _log_file and (collector_task is None or collector_task.done()):
|
||||
collector_task = asyncio.create_task(log_collector_worker(_log_file))
|
||||
else:
|
||||
elif not _log_file:
|
||||
log.warning("DECNET_INGEST_LOG_FILE not set — Docker log collection disabled.")
|
||||
else:
|
||||
log.info("Contract Test Mode: skipping background worker startup")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown background tasks
|
||||
for task in (ingestion_task, collector_task):
|
||||
if task:
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
log.warning("Task shutdown error: %s", exc)
|
||||
|
||||
|
||||
app: FastAPI = FastAPI(
|
||||
|
||||
18
decnet/web/db/factory.py
Normal file
18
decnet/web/db/factory.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Any
|
||||
from decnet.env import os
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
|
||||
def get_repository(**kwargs: Any) -> BaseRepository:
|
||||
"""Factory function to instantiate the correct repository implementation based on environment."""
|
||||
db_type = os.environ.get("DECNET_DB_TYPE", "sqlite").lower()
|
||||
|
||||
if db_type == "sqlite":
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
return SQLiteRepository(**kwargs)
|
||||
elif db_type == "mysql":
|
||||
# Placeholder for future implementation
|
||||
# from decnet.web.db.mysql.repository import MySQLRepository
|
||||
# return MySQLRepository()
|
||||
raise NotImplementedError("MySQL support is planned but not yet implemented.")
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
@@ -35,6 +35,12 @@ class Bounty(SQLModel, table=True):
|
||||
bounty_type: str = Field(index=True)
|
||||
payload: str
|
||||
|
||||
|
||||
class State(SQLModel, table=True):
|
||||
__tablename__ = "state"
|
||||
key: str = Field(primary_key=True)
|
||||
value: str # Stores JSON serialized DecnetConfig or other state blobs
|
||||
|
||||
# --- API Request/Response Models (Pydantic) ---
|
||||
|
||||
class Token(BaseModel):
|
||||
|
||||
@@ -80,3 +80,13 @@ class BaseRepository(ABC):
|
||||
async def get_total_bounties(self, bounty_type: Optional[str] = None, search: Optional[str] = None) -> int:
|
||||
"""Retrieve the total count of bounties, optionally filtered."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_state(self, key: str) -> Optional[dict[str, Any]]:
|
||||
"""Retrieve a specific state entry by key."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def set_state(self, key: str, value: Any) -> None:
|
||||
"""Store a specific state entry by key."""
|
||||
pass
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy import create_engine, Engine
|
||||
from sqlmodel import SQLModel
|
||||
from typing import AsyncGenerator
|
||||
|
||||
# We need both sync and async engines for SQLite
|
||||
# Sync for initialization (DDL) and async for standard queries
|
||||
|
||||
def get_async_engine(db_path: str):
|
||||
def get_async_engine(db_path: str) -> AsyncEngine:
|
||||
# If it's a memory URI, don't add the extra slash that turns it into a relative file
|
||||
prefix = "sqlite+aiosqlite:///"
|
||||
if db_path.startswith("file:"):
|
||||
prefix = "sqlite+aiosqlite:///"
|
||||
if db_path.startswith(":memory:"):
|
||||
prefix = "sqlite+aiosqlite://"
|
||||
return create_async_engine(f"{prefix}{db_path}", echo=False, connect_args={"uri": True})
|
||||
|
||||
def get_sync_engine(db_path: str):
|
||||
def get_sync_engine(db_path: str) -> Engine:
|
||||
prefix = "sqlite:///"
|
||||
if db_path.startswith(":memory:"):
|
||||
prefix = "sqlite://"
|
||||
return create_engine(f"{prefix}{db_path}", echo=False, connect_args={"uri": True})
|
||||
|
||||
def init_db(db_path: str):
|
||||
def init_db(db_path: str) -> None:
|
||||
"""Synchronously create all tables."""
|
||||
engine = get_sync_engine(db_path)
|
||||
# Ensure WAL mode is set
|
||||
@@ -25,7 +28,7 @@ def init_db(db_path: str):
|
||||
conn.exec_driver_sql("PRAGMA synchronous=NORMAL")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
async def get_session(engine) -> AsyncSession:
|
||||
async def get_session(engine: AsyncEngine) -> AsyncGenerator[AsyncSession, None]:
|
||||
async_session = async_sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
@@ -6,12 +6,13 @@ from typing import Any, Optional, List
|
||||
|
||||
from sqlalchemy import func, select, desc, asc, text, or_, update, literal_column
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlmodel.sql.expression import SelectOfScalar
|
||||
|
||||
from decnet.config import load_state, _ROOT
|
||||
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
||||
from decnet.web.auth import get_password_hash
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
from decnet.web.db.models import User, Log, Bounty
|
||||
from decnet.web.db.models import User, Log, Bounty, State
|
||||
from decnet.web.db.sqlite.database import get_async_engine, init_db
|
||||
|
||||
|
||||
@@ -93,11 +94,11 @@ class SQLiteRepository(BaseRepository):
|
||||
|
||||
def _apply_filters(
|
||||
self,
|
||||
statement,
|
||||
statement: SelectOfScalar,
|
||||
search: Optional[str],
|
||||
start_time: Optional[str],
|
||||
end_time: Optional[str],
|
||||
):
|
||||
) -> SelectOfScalar:
|
||||
import re
|
||||
import shlex
|
||||
|
||||
@@ -128,6 +129,7 @@ class SQLiteRepository(BaseRepository):
|
||||
statement = statement.where(core_fields[key] == val)
|
||||
else:
|
||||
key_safe = re.sub(r"[^a-zA-Z0-9_]", "", key)
|
||||
if key_safe:
|
||||
statement = statement.where(
|
||||
text(f"json_extract(fields, '$.{key_safe}') = :val")
|
||||
).params(val=val)
|
||||
@@ -206,7 +208,7 @@ class SQLiteRepository(BaseRepository):
|
||||
end_time: Optional[str] = None,
|
||||
interval_minutes: int = 15,
|
||||
) -> List[dict]:
|
||||
bucket_seconds = interval_minutes * 60
|
||||
bucket_seconds = max(interval_minutes, 1) * 60
|
||||
bucket_expr = literal_column(
|
||||
f"datetime((strftime('%s', timestamp) / {bucket_seconds}) * {bucket_seconds}, 'unixepoch')"
|
||||
).label("bucket_time")
|
||||
@@ -299,7 +301,12 @@ class SQLiteRepository(BaseRepository):
|
||||
session.add(Bounty(**data))
|
||||
await session.commit()
|
||||
|
||||
def _apply_bounty_filters(self, statement, bounty_type: Optional[str], search: Optional[str]):
|
||||
def _apply_bounty_filters(
|
||||
self,
|
||||
statement: SelectOfScalar,
|
||||
bounty_type: Optional[str],
|
||||
search: Optional[str]
|
||||
) -> SelectOfScalar:
|
||||
if bounty_type:
|
||||
statement = statement.where(Bounty.bounty_type == bounty_type)
|
||||
if search:
|
||||
@@ -350,3 +357,29 @@ class SQLiteRepository(BaseRepository):
|
||||
async with self.session_factory() as session:
|
||||
result = await session.execute(statement)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get_state(self, key: str) -> Optional[dict[str, Any]]:
|
||||
async with self.session_factory() as session:
|
||||
statement = select(State).where(State.key == key)
|
||||
result = await session.execute(statement)
|
||||
state = result.scalar_one_none()
|
||||
if state:
|
||||
return json.loads(state.value)
|
||||
return None
|
||||
|
||||
async def set_state(self, key: str, value: Any) -> None: # noqa: ANN401
|
||||
async with self.session_factory() as session:
|
||||
# Check if exists
|
||||
statement = select(State).where(State.key == key)
|
||||
result = await session.execute(statement)
|
||||
state = result.scalar_one_none()
|
||||
|
||||
value_json = json.dumps(value)
|
||||
if state:
|
||||
state.value = value_json
|
||||
session.add(state)
|
||||
else:
|
||||
new_state = State(key=key, value=value_json)
|
||||
session.add(new_state)
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
from typing import Any, Optional
|
||||
from pathlib import Path
|
||||
|
||||
import jwt
|
||||
from fastapi import HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
from decnet.web.auth import ALGORITHM, SECRET_KEY
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
from decnet.web.db.factory import get_repository
|
||||
|
||||
# Root directory for database
|
||||
_ROOT_DIR = Path(__file__).parent.parent.parent.absolute()
|
||||
DB_PATH = _ROOT_DIR / "decnet.db"
|
||||
# Shared repository singleton
|
||||
repo: BaseRepository = get_repository()
|
||||
|
||||
# Shared repository instance
|
||||
repo = SQLiteRepository(db_path=str(DB_PATH))
|
||||
def get_repo() -> BaseRepository:
|
||||
"""FastAPI dependency to inject the configured repository."""
|
||||
return repo
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
@@ -63,6 +63,11 @@ async def log_ingestion_worker(repo: BaseRepository) -> None:
|
||||
_position = _f.tell()
|
||||
|
||||
except Exception as _e:
|
||||
_err_str = str(_e).lower()
|
||||
if "no such table" in _err_str or "no active connection" in _err_str or "connection closed" in _err_str:
|
||||
logger.error(f"Post-shutdown or fatal DB error in ingester: {_e}")
|
||||
break # Exit worker — DB is gone or uninitialized
|
||||
|
||||
logger.error(f"Error in log ingestion worker: {_e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ router = APIRouter()
|
||||
@router.post(
|
||||
"/auth/change-password",
|
||||
tags=["Authentication"],
|
||||
responses={401: {"description": "Invalid or expired token / wrong old password"}, 422: {"description": "Validation error"}},
|
||||
responses={
|
||||
400: {"description": "Bad Request (e.g. malformed JSON)"},
|
||||
401: {"description": "Could not validate credentials"},
|
||||
422: {"description": "Validation error"}
|
||||
},
|
||||
)
|
||||
async def change_password(request: ChangePasswordRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||
_user: Optional[dict[str, Any]] = await repo.get_user_by_uuid(current_user)
|
||||
|
||||
@@ -18,7 +18,11 @@ router = APIRouter()
|
||||
"/auth/login",
|
||||
response_model=Token,
|
||||
tags=["Authentication"],
|
||||
responses={401: {"description": "Incorrect username or password"}, 422: {"description": "Validation error"}},
|
||||
responses={
|
||||
400: {"description": "Bad Request (e.g. malformed JSON)"},
|
||||
401: {"description": "Incorrect username or password"},
|
||||
422: {"description": "Validation error"}
|
||||
},
|
||||
)
|
||||
async def login(request: LoginRequest) -> dict[str, Any]:
|
||||
_user: Optional[dict[str, Any]] = await repo.get_user_by_username(request.username)
|
||||
|
||||
@@ -9,7 +9,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/bounty", response_model=BountyResponse, tags=["Bounty Vault"],
|
||||
responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},)
|
||||
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},)
|
||||
async def get_bounties(
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
|
||||
@@ -3,17 +3,21 @@ import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from decnet.config import DEFAULT_MUTATE_INTERVAL, DecnetConfig, load_state
|
||||
from decnet.config import DEFAULT_MUTATE_INTERVAL, DecnetConfig, _ROOT
|
||||
from decnet.engine import deploy as _deploy
|
||||
from decnet.ini_loader import load_ini_from_string
|
||||
from decnet.network import detect_interface, detect_subnet, get_host_ip
|
||||
from decnet.web.dependencies import get_current_user
|
||||
from decnet.web.dependencies import get_current_user, repo
|
||||
from decnet.web.db.models import DeployIniRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/deckies/deploy", tags=["Fleet Management"])
|
||||
@router.post(
|
||||
"/deckies/deploy",
|
||||
tags=["Fleet Management"],
|
||||
responses={401: {"description": "Could not validate credentials"}, 400: {"description": "Validation error or INI parsing failed"}, 500: {"description": "Deployment failed"}}
|
||||
)
|
||||
async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||
from decnet.fleet import build_deckies_from_ini
|
||||
|
||||
@@ -22,11 +26,11 @@ async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse INI: {e}")
|
||||
|
||||
state = load_state()
|
||||
state_dict = await repo.get_state("deployment")
|
||||
ingest_log_file = os.environ.get("DECNET_INGEST_LOG_FILE")
|
||||
|
||||
if state:
|
||||
config, _ = state
|
||||
if state_dict:
|
||||
config = DecnetConfig(**state_dict["config"])
|
||||
subnet_cidr = ini.subnet or config.subnet
|
||||
gateway = ini.gateway or config.gateway
|
||||
host_ip = get_host_ip(config.interface)
|
||||
@@ -71,7 +75,15 @@ async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(
|
||||
|
||||
# We call deploy(config) which regenerates docker-compose and runs `up -d --remove-orphans`.
|
||||
try:
|
||||
if os.environ.get("DECNET_CONTRACT_TEST") != "true":
|
||||
_deploy(config)
|
||||
|
||||
# Persist new state to DB
|
||||
new_state_payload = {
|
||||
"config": config.model_dump(),
|
||||
"compose_path": str(_ROOT / "docker-compose.yml") if not state_dict else state_dict["compose_path"]
|
||||
}
|
||||
await repo.set_state("deployment", new_state_payload)
|
||||
except Exception as e:
|
||||
logging.getLogger("decnet.web.api").exception("Deployment failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Deployment failed. Check server logs for details.")
|
||||
|
||||
@@ -8,6 +8,6 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/deckies", tags=["Fleet Management"],
|
||||
responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},)
|
||||
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},)
|
||||
async def get_deckies(current_user: str = Depends(get_current_user)) -> list[dict[str, Any]]:
|
||||
return await repo.get_deckies()
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
|
||||
from decnet.mutator import mutate_decky
|
||||
from decnet.web.dependencies import get_current_user
|
||||
from decnet.web.dependencies import get_current_user, repo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/deckies/{decky_name}/mutate", tags=["Fleet Management"])
|
||||
@router.post(
|
||||
"/deckies/{decky_name}/mutate",
|
||||
tags=["Fleet Management"],
|
||||
responses={401: {"description": "Could not validate credentials"}, 404: {"description": "Decky not found"}}
|
||||
)
|
||||
async def api_mutate_decky(
|
||||
decky_name: str = Path(..., pattern=r"^[a-z0-9\-]{1,64}$"),
|
||||
current_user: str = Depends(get_current_user),
|
||||
) -> dict[str, str]:
|
||||
success = mutate_decky(decky_name)
|
||||
if os.environ.get("DECNET_CONTRACT_TEST") == "true":
|
||||
return {"message": f"Successfully mutated {decky_name} (Contract Test Mock)"}
|
||||
|
||||
success = await mutate_decky(decky_name, repo=repo)
|
||||
if success:
|
||||
return {"message": f"Successfully mutated {decky_name}"}
|
||||
raise HTTPException(status_code=404, detail=f"Decky {decky_name} not found or failed to mutate")
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from decnet.config import load_state, save_state
|
||||
from decnet.web.dependencies import get_current_user
|
||||
from decnet.config import DecnetConfig
|
||||
from decnet.web.dependencies import get_current_user, repo
|
||||
from decnet.web.db.models import MutateIntervalRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.put("/deckies/{decky_name}/mutate-interval", tags=["Fleet Management"],
|
||||
responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},)
|
||||
responses={
|
||||
400: {"description": "No active deployment found"},
|
||||
401: {"description": "Could not validate credentials"},
|
||||
404: {"description": "Decky not found"},
|
||||
422: {"description": "Validation error"}
|
||||
},
|
||||
)
|
||||
async def api_update_mutate_interval(decky_name: str, req: MutateIntervalRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||
state = load_state()
|
||||
if not state:
|
||||
raise HTTPException(status_code=500, detail="No active deployment")
|
||||
config, compose_path = state
|
||||
state_dict = await repo.get_state("deployment")
|
||||
if not state_dict:
|
||||
raise HTTPException(status_code=400, detail="No active deployment")
|
||||
|
||||
config = DecnetConfig(**state_dict["config"])
|
||||
compose_path = state_dict["compose_path"]
|
||||
|
||||
decky = next((d for d in config.deckies if d.name == decky_name), None)
|
||||
if not decky:
|
||||
raise HTTPException(status_code=404, detail="Decky not found")
|
||||
|
||||
decky.mutate_interval = req.mutate_interval
|
||||
save_state(config, compose_path)
|
||||
|
||||
await repo.set_state("deployment", {"config": config.model_dump(), "compose_path": compose_path})
|
||||
return {"message": "Mutation interval updated"}
|
||||
|
||||
@@ -8,7 +8,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/logs/histogram", tags=["Logs"],
|
||||
responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},)
|
||||
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},)
|
||||
async def get_logs_histogram(
|
||||
search: Optional[str] = None,
|
||||
start_time: Optional[str] = None,
|
||||
|
||||
@@ -7,10 +7,11 @@ from decnet.web.db.models import LogsResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_DATETIME_RE = r"^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$"
|
||||
_DATETIME_RE = r"^(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})?$"
|
||||
|
||||
|
||||
@router.get("/logs", response_model=LogsResponse, tags=["Logs"])
|
||||
@router.get("/logs", response_model=LogsResponse, tags=["Logs"],
|
||||
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}})
|
||||
async def get_logs(
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
|
||||
@@ -9,6 +9,6 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=StatsResponse, tags=["Observability"],
|
||||
responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},)
|
||||
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},)
|
||||
async def get_stats(current_user: str = Depends(get_current_user)) -> dict[str, Any]:
|
||||
return await repo.get_stats_summary()
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import AsyncGenerator, Optional
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from decnet.env import DECNET_DEVELOPER
|
||||
from decnet.web.dependencies import get_stream_user, repo
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -14,13 +15,22 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stream", tags=["Observability"],
|
||||
responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},)
|
||||
responses={
|
||||
200: {
|
||||
"content": {"text/event-stream": {}},
|
||||
"description": "Real-time Server-Sent Events (SSE) stream"
|
||||
},
|
||||
401: {"description": "Could not validate credentials"},
|
||||
422: {"description": "Validation error"}
|
||||
},
|
||||
)
|
||||
async def stream_events(
|
||||
request: Request,
|
||||
last_event_id: int = Query(0, alias="lastEventId"),
|
||||
search: Optional[str] = None,
|
||||
start_time: Optional[str] = None,
|
||||
end_time: Optional[str] = None,
|
||||
max_output: Optional[int] = Query(None, alias="maxOutput"),
|
||||
current_user: str = Depends(get_stream_user)
|
||||
) -> StreamingResponse:
|
||||
|
||||
@@ -28,6 +38,7 @@ async def stream_events(
|
||||
last_id = last_event_id
|
||||
stats_interval_sec = 10
|
||||
loops_since_stats = 0
|
||||
emitted_chunks = 0
|
||||
try:
|
||||
if last_id == 0:
|
||||
last_id = await repo.get_max_log_id()
|
||||
@@ -42,6 +53,12 @@ async def stream_events(
|
||||
yield f"event: message\ndata: {json.dumps({'type': 'histogram', 'data': histogram})}\n\n"
|
||||
|
||||
while True:
|
||||
if DECNET_DEVELOPER and max_output is not None:
|
||||
emitted_chunks += 1
|
||||
if emitted_chunks > max_output:
|
||||
log.debug("Developer mode: max_output reached (%d), closing stream", max_output)
|
||||
break
|
||||
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
@@ -65,6 +82,7 @@ async def stream_events(
|
||||
loops_since_stats = 0
|
||||
|
||||
loops_since_stats += 1
|
||||
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@@ -51,6 +51,7 @@ decnet = "decnet.cli:app"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_debug = "true"
|
||||
addopts = "-m 'not fuzz and not live' -v -q -x -n logical"
|
||||
markers = [
|
||||
"fuzz: hypothesis-based fuzz tests (slow, run with -m fuzz or -m '' for all)",
|
||||
|
||||
30
ruff.toml
Normal file
30
ruff.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
# In your ruff.toml or pyproject.toml
|
||||
target-version = "py314" # DECNET's target Python version
|
||||
|
||||
exclude = [
|
||||
"tests/**",
|
||||
"templates/**",
|
||||
"development/**",
|
||||
]
|
||||
|
||||
[lint]
|
||||
# Select a wide range of rules
|
||||
select = [
|
||||
"F", # Pyflakes: Catches undefined names (F821) and unused variables (F841)
|
||||
"ANN", # Enforces type annotations on functions and methods
|
||||
"RUF", # Includes the RUF045 rule for dataclass attributes
|
||||
"E", # Pycodestyle errors
|
||||
"W", # Pycodestyle warnings
|
||||
]
|
||||
|
||||
# Ignore specific rules that might be too strict for now
|
||||
ignore = [
|
||||
"E501", # Line too long
|
||||
]
|
||||
|
||||
[lint.extend-per-file-ignores]
|
||||
# Apply strict rules only to the core codebase
|
||||
"decnet/**/*.py" = []
|
||||
# Everywhere else is more relaxed
|
||||
"**/*.py" = ["ANN", "RUF"]
|
||||
"tests/**/*.py" = ["ANN", "RUF", "E", "W"]
|
||||
6
schemathesis.toml
Normal file
6
schemathesis.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
request-timeout = 5.0
|
||||
|
||||
[[operations]]
|
||||
# Target your SSE endpoint specifically
|
||||
include-path = "/stream"
|
||||
request-timeout = 2.0
|
||||
@@ -45,7 +45,7 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
def _rand_msg_id() -> str:
|
||||
"""Return a Postfix-style 12-char alphanumeric queue ID."""
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
return "".join(random.choices(chars, k=12)) # noqa: S311
|
||||
return "".join(random.choices(chars, k=12))
|
||||
|
||||
|
||||
def _decode_auth_plain(blob: str) -> tuple[str, str]:
|
||||
|
||||
@@ -160,7 +160,7 @@ def write_syslog_file(line: str) -> None:
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from typing import Optional
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""
|
||||
Tests for the mutate interval API endpoint.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from decnet.config import DeckyConfig, DecnetConfig
|
||||
|
||||
@@ -37,19 +35,20 @@ class TestMutateInterval:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_active_deployment(self, client: httpx.AsyncClient, auth_token: str):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state", return_value=None):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||
mock_repo.get_state.return_value = None
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 60},
|
||||
)
|
||||
assert resp.status_code == 500
|
||||
assert resp.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decky_not_found(self, client: httpx.AsyncClient, auth_token: str):
|
||||
config = _config()
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
|
||||
return_value=(config, Path("test.yml"))):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||
mock_repo.get_state.return_value = {"config": config.model_dump(), "compose_path": "c.yml"}
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/nonexistent/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
@@ -60,9 +59,8 @@ class TestMutateInterval:
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_interval_update(self, client: httpx.AsyncClient, auth_token: str):
|
||||
config = _config()
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
|
||||
return_value=(config, Path("test.yml"))):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.save_state") as mock_save:
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||
mock_repo.get_state.return_value = {"config": config.model_dump(), "compose_path": "c.yml"}
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
@@ -70,20 +68,17 @@ class TestMutateInterval:
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "Mutation interval updated"
|
||||
mock_save.assert_called_once()
|
||||
# Verify the interval was actually updated on the decky config
|
||||
assert config.deckies[0].mutate_interval == 120
|
||||
mock_repo.set_state.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_interval_removes_mutation(self, client: httpx.AsyncClient, auth_token: str):
|
||||
config = _config()
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
|
||||
return_value=(config, Path("test.yml"))):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.save_state"):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||
mock_repo.get_state.return_value = {"config": config.model_dump(), "compose_path": "c.yml"}
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": None},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert config.deckies[0].mutate_interval is None
|
||||
mock_repo.set_state.assert_awaited_once()
|
||||
|
||||
@@ -9,13 +9,13 @@ import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from freezegun import freeze_time
|
||||
from hypothesis import given, settings, strategies as st
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
from decnet.web.db.factory import get_repository
|
||||
from ..conftest import _FUZZ_SETTINGS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path):
|
||||
return SQLiteRepository(db_path=str(tmp_path / "histogram_test.db"))
|
||||
return get_repository(db_path=str(tmp_path / "histogram_test.db"))
|
||||
|
||||
|
||||
def _log(decky="d", service="ssh", ip="1.2.3.4", timestamp=None):
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
"""
|
||||
Direct async tests for SQLiteRepository.
|
||||
These exercise the DB layer without going through the HTTP stack,
|
||||
covering DEBT-006 (zero test coverage on the database layer).
|
||||
Direct async tests for the configured Repository implementation.
|
||||
These exercise the DB layer without going through the HTTP stack.
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
from hypothesis import given, settings, strategies as st
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
from decnet.web.db.factory import get_repository
|
||||
from .conftest import _FUZZ_SETTINGS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path):
|
||||
return SQLiteRepository(db_path=str(tmp_path / "test.db"))
|
||||
return get_repository(db_path=str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -11,16 +11,82 @@ replace the checks list with the default (remove the argument) for full complian
|
||||
Requires DECNET_DEVELOPER=true (set in tests/conftest.py) to expose /openapi.json.
|
||||
"""
|
||||
import pytest
|
||||
import schemathesis
|
||||
from hypothesis import settings
|
||||
from schemathesis.checks import not_a_server_error
|
||||
from decnet.web.api import app
|
||||
import schemathesis as st
|
||||
from hypothesis import settings, Verbosity
|
||||
from decnet.web.auth import create_access_token
|
||||
|
||||
schema = schemathesis.openapi.from_asgi("/openapi.json", app)
|
||||
import subprocess
|
||||
import socket
|
||||
import sys
|
||||
import atexit
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Configuration for the automated live server
|
||||
LIVE_PORT = 8008
|
||||
LIVE_SERVER_URL = f"http://127.0.0.1:{LIVE_PORT}"
|
||||
TEST_SECRET = "test-secret-for-automated-fuzzing"
|
||||
|
||||
# Standardize the secret for the test process too so tokens can be verified
|
||||
import decnet.web.auth
|
||||
decnet.web.auth.SECRET_KEY = TEST_SECRET
|
||||
|
||||
# Create a valid token for an admin-like user
|
||||
TEST_TOKEN = create_access_token({"uuid": "00000000-0000-0000-0000-000000000001"})
|
||||
|
||||
@st.hook
|
||||
def before_call(context, case, *args):
|
||||
# Logged-in admin for all requests
|
||||
case.headers = case.headers or {}
|
||||
case.headers["Authorization"] = f"Bearer {TEST_TOKEN}"
|
||||
|
||||
def wait_for_port(port, timeout=10):
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
if sock.connect_ex(('127.0.0.1', port)) == 0:
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
def start_automated_server():
|
||||
# Use the current venv's uvicorn
|
||||
uvicorn_bin = "uvicorn" if os.name != "nt" else "uvicorn.exe"
|
||||
uvicorn_path = str(Path(sys.executable).parent / uvicorn_bin)
|
||||
|
||||
# Force developer and contract test modes for the sub-process
|
||||
env = os.environ.copy()
|
||||
env["DECNET_DEVELOPER"] = "true"
|
||||
env["DECNET_CONTRACT_TEST"] = "true"
|
||||
env["DECNET_JWT_SECRET"] = TEST_SECRET
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[uvicorn_path, "decnet.web.api:app", "--host", "127.0.0.1", "--port", str(LIVE_PORT), "--log-level", "error"],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
# Register cleanup
|
||||
atexit.register(proc.terminate)
|
||||
|
||||
if not wait_for_port(LIVE_PORT):
|
||||
proc.terminate()
|
||||
raise RuntimeError(f"Automated server failed to start on port {LIVE_PORT}")
|
||||
|
||||
return proc
|
||||
|
||||
# Stir up the server!
|
||||
_server_proc = start_automated_server()
|
||||
|
||||
# Now Schemathesis can pull the schema from the real network port
|
||||
schema = st.openapi.from_url(f"{LIVE_SERVER_URL}/openapi.json")
|
||||
|
||||
@pytest.mark.fuzz
|
||||
@schemathesis.pytest.parametrize(api=schema)
|
||||
@settings(max_examples=5, deadline=None)
|
||||
@st.pytest.parametrize(api=schema)
|
||||
@settings(max_examples=3000, deadline=None, verbosity=Verbosity.debug)
|
||||
def test_schema_compliance(case):
|
||||
case.call_and_validate(checks=[not_a_server_error])
|
||||
#print(f"\n[Fuzzing] {case.method} {case.path} with query={case.query}")
|
||||
case.call_and_validate()
|
||||
#print(f" └─ Success")
|
||||
|
||||
@@ -6,6 +6,15 @@ any test file imports decnet.* — pytest loads conftest.py first.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DECNET_JWT_SECRET", "test-jwt-secret-not-for-production-use")
|
||||
# Expose OpenAPI schema so schemathesis can load it during tests
|
||||
os.environ.setdefault("DECNET_DEVELOPER", "true")
|
||||
os.environ["DECNET_JWT_SECRET"] = "stable-test-secret-key-at-least-32-chars-long"
|
||||
os.environ["DECNET_ADMIN_PASSWORD"] = "test-password-123"
|
||||
os.environ["DECNET_DEVELOPER"] = "true"
|
||||
os.environ["DECNET_DB_TYPE"] = "sqlite"
|
||||
|
||||
import pytest
|
||||
from typing import Any
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def standardize_auth_secret(monkeypatch: Any) -> None:
|
||||
import decnet.web.auth
|
||||
monkeypatch.setattr(decnet.web.auth, "SECRET_KEY", os.environ["DECNET_JWT_SECRET"])
|
||||
|
||||
@@ -19,6 +19,8 @@ class DummyRepo(BaseRepository):
|
||||
async def add_bounty(self, d): await super().add_bounty(d)
|
||||
async def get_bounties(self, **kw): await super().get_bounties(**kw)
|
||||
async def get_total_bounties(self, **kw): await super().get_total_bounties(**kw)
|
||||
async def get_state(self, k): await super().get_state(k)
|
||||
async def set_state(self, k, v): await super().set_state(k, v)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_repo_coverage():
|
||||
@@ -37,3 +39,5 @@ async def test_base_repo_coverage():
|
||||
await dr.add_bounty({})
|
||||
await dr.get_bounties()
|
||||
await dr.get_total_bounties()
|
||||
await dr.get_state("k")
|
||||
await dr.set_state("k", "v")
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
Tests for decnet.mutator — mutation engine, retry logic, due-time scheduling.
|
||||
All subprocess and state I/O is mocked; no Docker or filesystem access.
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -41,9 +40,131 @@ def _make_config(deckies=None, mutate_interval=30):
|
||||
mutate_interval=mutate_interval,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_repo():
|
||||
repo = AsyncMock()
|
||||
repo.get_state.return_value = None
|
||||
return repo
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _compose_with_retry
|
||||
# mutate_decky
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestMutateDecky:
|
||||
def _patch_io(self):
|
||||
"""Return a context manager that mocks all other I/O in mutate_decky."""
|
||||
return (
|
||||
patch("decnet.mutator.engine.write_compose"),
|
||||
patch("decnet.mutator.engine._compose_with_retry", new_callable=AsyncMock),
|
||||
)
|
||||
|
||||
async def test_returns_false_when_no_state(self, mock_repo):
|
||||
mock_repo.get_state.return_value = None
|
||||
assert await mutate_decky("decky-01", repo=mock_repo) is False
|
||||
|
||||
async def test_returns_false_when_decky_not_found(self, mock_repo):
|
||||
cfg = _make_config()
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
assert await mutate_decky("nonexistent", repo=mock_repo) is False
|
||||
|
||||
async def test_returns_true_on_success(self, mock_repo):
|
||||
cfg = _make_config()
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.write_compose"), \
|
||||
patch("anyio.to_thread.run_sync", new_callable=AsyncMock):
|
||||
assert await mutate_decky("decky-01", repo=mock_repo) is True
|
||||
|
||||
async def test_saves_state_after_mutation(self, mock_repo):
|
||||
cfg = _make_config()
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.write_compose"), \
|
||||
patch("anyio.to_thread.run_sync", new_callable=AsyncMock):
|
||||
await mutate_decky("decky-01", repo=mock_repo)
|
||||
mock_repo.set_state.assert_awaited_once()
|
||||
|
||||
async def test_regenerates_compose_after_mutation(self, mock_repo):
|
||||
cfg = _make_config()
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.write_compose") as mock_compose, \
|
||||
patch("anyio.to_thread.run_sync", new_callable=AsyncMock):
|
||||
await mutate_decky("decky-01", repo=mock_repo)
|
||||
mock_compose.assert_called_once()
|
||||
|
||||
async def test_returns_false_on_compose_failure(self, mock_repo):
|
||||
cfg = _make_config()
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.write_compose"), \
|
||||
patch("anyio.to_thread.run_sync", side_effect=Exception("docker fail")):
|
||||
assert await mutate_decky("decky-01", repo=mock_repo) is False
|
||||
|
||||
async def test_mutation_changes_services(self, mock_repo):
|
||||
cfg = _make_config(deckies=[_make_decky(services=["ssh"])])
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.write_compose"), \
|
||||
patch("anyio.to_thread.run_sync", new_callable=AsyncMock):
|
||||
await mutate_decky("decky-01", repo=mock_repo)
|
||||
|
||||
# Check that set_state was called with a config where services might have changed
|
||||
call_args = mock_repo.set_state.await_args[0]
|
||||
new_config_dict = call_args[1]["config"]
|
||||
new_services = new_config_dict["deckies"][0]["services"]
|
||||
assert isinstance(new_services, list)
|
||||
assert len(new_services) >= 1
|
||||
|
||||
async def test_updates_last_mutated_timestamp(self, mock_repo):
|
||||
cfg = _make_config(deckies=[_make_decky(last_mutated=0.0)])
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
before = time.time()
|
||||
with patch("decnet.mutator.engine.write_compose"), \
|
||||
patch("anyio.to_thread.run_sync", new_callable=AsyncMock):
|
||||
await mutate_decky("decky-01", repo=mock_repo)
|
||||
|
||||
call_args = mock_repo.set_state.await_args[0]
|
||||
new_last_mutated = call_args[1]["config"]["deckies"][0]["last_mutated"]
|
||||
assert new_last_mutated >= before
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mutate_all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestMutateAll:
|
||||
async def test_no_state_returns_early(self, mock_repo):
|
||||
mock_repo.get_state.return_value = None
|
||||
with patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
await mutate_all(repo=mock_repo)
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
async def test_force_mutates_all_deckies(self, mock_repo):
|
||||
cfg = _make_config(deckies=[_make_decky("d1"), _make_decky("d2")])
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.mutate_decky", new_callable=AsyncMock, return_value=True) as mock_mutate:
|
||||
await mutate_all(repo=mock_repo, force=True)
|
||||
assert mock_mutate.call_count == 2
|
||||
|
||||
async def test_skips_decky_not_yet_due(self, mock_repo):
|
||||
# last_mutated = now, interval = 30 min → not due
|
||||
now = time.time()
|
||||
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=now)])
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
await mutate_all(repo=mock_repo, force=False)
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
async def test_mutates_decky_that_is_due(self, mock_repo):
|
||||
# last_mutated = 2 hours ago, interval = 30 min → due
|
||||
old_ts = time.time() - 7200
|
||||
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=old_ts)])
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.mutate_decky", new_callable=AsyncMock, return_value=True) as mock_mutate:
|
||||
await mutate_all(repo=mock_repo, force=False)
|
||||
mock_mutate.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _compose_with_retry (Sync tests, keep as is or minimal update)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComposeWithRetry:
|
||||
@@ -60,149 +181,3 @@ class TestComposeWithRetry:
|
||||
patch("decnet.engine.deployer.time.sleep"):
|
||||
_compose_with_retry("up", "-d", compose_file=Path("compose.yml"), retries=3)
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_raises_after_all_retries_exhausted(self):
|
||||
fail = MagicMock(returncode=1, stdout="", stderr="hard error")
|
||||
with patch("decnet.engine.deployer.subprocess.run", return_value=fail), \
|
||||
patch("decnet.engine.deployer.time.sleep"):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_compose_with_retry("up", "-d", compose_file=Path("compose.yml"), retries=3)
|
||||
|
||||
def test_exponential_backoff(self):
|
||||
fail = MagicMock(returncode=1, stdout="", stderr="")
|
||||
sleep_calls = []
|
||||
with patch("decnet.engine.deployer.subprocess.run", return_value=fail), \
|
||||
patch("decnet.engine.deployer.time.sleep", side_effect=lambda d: sleep_calls.append(d)):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_compose_with_retry("up", compose_file=Path("c.yml"), retries=3, delay=1.0)
|
||||
assert sleep_calls == [1.0, 2.0]
|
||||
|
||||
def test_correct_command_structure(self):
|
||||
ok = MagicMock(returncode=0, stdout="")
|
||||
with patch("decnet.engine.deployer.subprocess.run", return_value=ok) as mock_run:
|
||||
_compose_with_retry("up", "-d", "--remove-orphans",
|
||||
compose_file=Path("/tmp/compose.yml"))
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[:3] == ["docker", "compose", "-f"]
|
||||
assert "up" in cmd
|
||||
assert "--remove-orphans" in cmd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mutate_decky
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMutateDecky:
|
||||
def _patch(self, config=None, compose_path=Path("compose.yml")):
|
||||
"""Return a context manager that mocks all I/O in mutate_decky."""
|
||||
cfg = config or _make_config()
|
||||
return (
|
||||
patch("decnet.mutator.engine.load_state", return_value=(cfg, compose_path)),
|
||||
patch("decnet.mutator.engine.save_state"),
|
||||
patch("decnet.mutator.engine.write_compose"),
|
||||
patch("decnet.mutator.engine._compose_with_retry"),
|
||||
)
|
||||
|
||||
def test_returns_false_when_no_state(self):
|
||||
with patch("decnet.mutator.engine.load_state", return_value=None):
|
||||
assert mutate_decky("decky-01") is False
|
||||
|
||||
def test_returns_false_when_decky_not_found(self):
|
||||
p = self._patch()
|
||||
with p[0], p[1], p[2], p[3]:
|
||||
assert mutate_decky("nonexistent") is False
|
||||
|
||||
def test_returns_true_on_success(self):
|
||||
p = self._patch()
|
||||
with p[0], p[1], p[2], p[3]:
|
||||
assert mutate_decky("decky-01") is True
|
||||
|
||||
def test_saves_state_after_mutation(self):
|
||||
p = self._patch()
|
||||
with p[0], patch("decnet.mutator.engine.save_state") as mock_save, p[2], p[3]:
|
||||
mutate_decky("decky-01")
|
||||
mock_save.assert_called_once()
|
||||
|
||||
def test_regenerates_compose_after_mutation(self):
|
||||
p = self._patch()
|
||||
with p[0], p[1], patch("decnet.mutator.engine.write_compose") as mock_compose, p[3]:
|
||||
mutate_decky("decky-01")
|
||||
mock_compose.assert_called_once()
|
||||
|
||||
def test_returns_false_on_compose_failure(self):
|
||||
p = self._patch()
|
||||
err = subprocess.CalledProcessError(1, "docker", "", "compose failed")
|
||||
with p[0], p[1], p[2], patch("decnet.mutator.engine._compose_with_retry", side_effect=err):
|
||||
assert mutate_decky("decky-01") is False
|
||||
|
||||
def test_mutation_changes_services(self):
|
||||
cfg = _make_config(deckies=[_make_decky(services=["ssh"])])
|
||||
p = self._patch(config=cfg)
|
||||
with p[0], p[1], p[2], p[3]:
|
||||
mutate_decky("decky-01")
|
||||
# Services may have changed (or stayed the same after 20 attempts)
|
||||
assert isinstance(cfg.deckies[0].services, list)
|
||||
assert len(cfg.deckies[0].services) >= 1
|
||||
|
||||
def test_updates_last_mutated_timestamp(self):
|
||||
cfg = _make_config(deckies=[_make_decky(last_mutated=0.0)])
|
||||
p = self._patch(config=cfg)
|
||||
before = time.time()
|
||||
with p[0], p[1], p[2], p[3]:
|
||||
mutate_decky("decky-01")
|
||||
assert cfg.deckies[0].last_mutated >= before
|
||||
|
||||
def test_archetype_constrains_service_pool(self):
|
||||
"""A decky with an archetype must only mutate within its service pool."""
|
||||
cfg = _make_config(deckies=[_make_decky(archetype="workstation", services=["rdp"])])
|
||||
p = self._patch(config=cfg)
|
||||
with p[0], p[1], p[2], p[3]:
|
||||
result = mutate_decky("decky-01")
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mutate_all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMutateAll:
|
||||
def test_no_state_returns_early(self):
|
||||
with patch("decnet.mutator.engine.load_state", return_value=None), \
|
||||
patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
mutate_all()
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
def test_force_mutates_all_deckies(self):
|
||||
cfg = _make_config(deckies=[_make_decky("d1"), _make_decky("d2")])
|
||||
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.engine.mutate_decky", return_value=True) as mock_mutate:
|
||||
mutate_all(force=True)
|
||||
assert mock_mutate.call_count == 2
|
||||
|
||||
def test_skips_decky_not_yet_due(self):
|
||||
# last_mutated = now, interval = 30 min → not due
|
||||
now = time.time()
|
||||
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=now)])
|
||||
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
mutate_all(force=False)
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
def test_mutates_decky_that_is_due(self):
|
||||
# last_mutated = 2 hours ago, interval = 30 min → due
|
||||
old_ts = time.time() - 7200
|
||||
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=old_ts)])
|
||||
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.engine.mutate_decky", return_value=True) as mock_mutate:
|
||||
mutate_all(force=False)
|
||||
mock_mutate.assert_called_once_with("decky-01")
|
||||
|
||||
def test_skips_decky_with_no_interval_and_no_force(self):
|
||||
cfg = _make_config(
|
||||
deckies=[_make_decky(mutate_interval=None)],
|
||||
mutate_interval=None,
|
||||
)
|
||||
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
mutate_all(force=False)
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user