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.
|
- The logging/aggregation network must be isolated from the decoy network.
|
||||||
- A publicly accessible real server acts as the bridge between the two networks.
|
- 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.
|
- 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
|
## Development and testing
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ Host NIC (eth0)
|
|||||||
- **Extensive testing** for every function must be created.
|
- **Extensive testing** for every function must be created.
|
||||||
- **Always develop in the `dev` branch, never in `main`.**
|
- **Always develop in the `dev` branch, never in `main`.**
|
||||||
- **Test in the `testing` branch.**
|
- **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
|
## 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.[/]")
|
console.print("[red]Failed to start mutator watcher.[/]")
|
||||||
|
|
||||||
if effective_log_file and not dry_run and not api:
|
if effective_log_file and not dry_run and not api:
|
||||||
import subprocess # noqa: F811 # nosec B404
|
import subprocess # nosec B404
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path as _Path
|
from pathlib import Path as _Path
|
||||||
_collector_err = _Path(effective_log_file).with_suffix(".collector.log")
|
_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"),
|
force_all: bool = typer.Option(False, "--all", help="Force mutate all deckies immediately"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Manually trigger or continuously watch for decky mutation."""
|
"""Manually trigger or continuously watch for decky mutation."""
|
||||||
|
import asyncio
|
||||||
from decnet.mutator import mutate_decky, mutate_all, run_watch_loop
|
from decnet.mutator import mutate_decky, mutate_all, run_watch_loop
|
||||||
|
from decnet.web.dependencies import repo
|
||||||
|
|
||||||
if watch:
|
if watch:
|
||||||
run_watch_loop()
|
asyncio.run(run_watch_loop(repo))
|
||||||
return
|
return
|
||||||
|
|
||||||
if decky_name:
|
if decky_name:
|
||||||
mutate_decky(decky_name)
|
asyncio.run(mutate_decky(decky_name, repo))
|
||||||
elif force_all:
|
elif force_all:
|
||||||
mutate_all(force=True)
|
asyncio.run(mutate_all(force=True, repo=repo))
|
||||||
else:
|
else:
|
||||||
mutate_all(force=False)
|
asyncio.run(mutate_all(force=False, repo=repo))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ from decnet.correlation.graph import AttackerTraversal, TraversalHop
|
|||||||
from decnet.correlation.parser import LogEvent, parse_line
|
from decnet.correlation.parser import LogEvent, parse_line
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CorrelationEngine",
|
|
||||||
"AttackerTraversal",
|
"AttackerTraversal",
|
||||||
"TraversalHop",
|
"CorrelationEngine",
|
||||||
"LogEvent",
|
"LogEvent",
|
||||||
|
"TraversalHop",
|
||||||
"parse_line",
|
"parse_line",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Calculate absolute path to the project root
|
# 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_ADMIN_PASSWORD: str = os.environ.get("DECNET_ADMIN_PASSWORD", "admin")
|
||||||
DECNET_DEVELOPER: bool = os.environ.get("DECNET_DEVELOPER", "False").lower() == "true"
|
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.
|
# 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.
|
# 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
|
# 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.archetypes import get_archetype
|
||||||
from decnet.fleet import all_service_names
|
from decnet.fleet import all_service_names
|
||||||
from decnet.composer import write_compose
|
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
|
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()
|
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.
|
Perform an Intra-Archetype Shuffle for a specific decky.
|
||||||
Returns True if mutation succeeded, False otherwise.
|
Returns True if mutation succeeded, False otherwise.
|
||||||
"""
|
"""
|
||||||
state = load_state()
|
state_dict = await repo.get_state("deployment")
|
||||||
if state is None:
|
if state_dict is None:
|
||||||
console.print("[red]No active deployment found (no decnet-state.json).[/]")
|
console.print("[red]No active deployment found in database.[/]")
|
||||||
return False
|
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)
|
decky: Optional[DeckyConfig] = next((d for d in config.deckies if d.name == decky_name), None)
|
||||||
|
|
||||||
if not decky:
|
if not decky:
|
||||||
@@ -63,31 +67,35 @@ def mutate_decky(decky_name: str) -> bool:
|
|||||||
decky.services = list(chosen)
|
decky.services = list(chosen)
|
||||||
decky.last_mutated = time.time()
|
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)
|
write_compose(config, compose_path)
|
||||||
|
|
||||||
console.print(f"[cyan]Mutating '{decky_name}' to services: {', '.join(decky.services)}[/]")
|
console.print(f"[cyan]Mutating '{decky_name}' to services: {', '.join(decky.services)}[/]")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_compose_with_retry("up", "-d", "--remove-orphans", compose_file=compose_path)
|
# Wrap blocking call in thread
|
||||||
except subprocess.CalledProcessError as e:
|
await anyio.to_thread.run_sync(_compose_with_retry, "up", "-d", "--remove-orphans", compose_path)
|
||||||
console.print(f"[red]Failed to mutate '{decky_name}': {e.stderr}[/]")
|
except Exception as e:
|
||||||
|
console.print(f"[red]Failed to mutate '{decky_name}': {e}[/]")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
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.
|
Check all deckies and mutate those that are due.
|
||||||
If force=True, mutates all deckies regardless of schedule.
|
If force=True, mutates all deckies regardless of schedule.
|
||||||
"""
|
"""
|
||||||
state = load_state()
|
state_dict = await repo.get_state("deployment")
|
||||||
if state is None:
|
if state_dict is None:
|
||||||
console.print("[red]No active deployment found.[/]")
|
console.print("[red]No active deployment found.[/]")
|
||||||
return
|
return
|
||||||
|
|
||||||
config, _ = state
|
config = DecnetConfig(**state_dict["config"])
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
mutated_count = 0
|
mutated_count = 0
|
||||||
@@ -103,7 +111,7 @@ def mutate_all(force: bool = False) -> None:
|
|||||||
due = elapsed_secs >= (interval_mins * 60)
|
due = elapsed_secs >= (interval_mins * 60)
|
||||||
|
|
||||||
if due:
|
if due:
|
||||||
success = mutate_decky(decky.name)
|
success = await mutate_decky(decky.name, repo=repo)
|
||||||
if success:
|
if success:
|
||||||
mutated_count += 1
|
mutated_count += 1
|
||||||
|
|
||||||
@@ -111,12 +119,12 @@ def mutate_all(force: bool = False) -> None:
|
|||||||
console.print("[dim]No deckies are due for mutation.[/]")
|
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."""
|
"""Run an infinite loop checking for deckies that need mutation."""
|
||||||
console.print(f"[green]DECNET Mutator Watcher started (polling every {poll_interval_secs}s).[/]")
|
console.print(f"[green]DECNET Mutator Watcher started (polling every {poll_interval_secs}s).[/]")
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
mutate_all(force=False)
|
await mutate_all(force=False, repo=repo)
|
||||||
time.sleep(poll_interval_secs)
|
await asyncio.sleep(poll_interval_secs)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\n[dim]Mutator watcher stopped.[/]")
|
console.print("\n[dim]Mutator watcher stopped.[/]")
|
||||||
|
|||||||
@@ -32,23 +32,33 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
log.error("DB failed to initialize after 5 attempts — startup may be degraded")
|
log.error("DB failed to initialize after 5 attempts — startup may be degraded")
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
# Start background ingestion task
|
# Start background tasks only if not in contract test mode
|
||||||
if ingestion_task is None or ingestion_task.done():
|
if os.environ.get("DECNET_CONTRACT_TEST") != "true":
|
||||||
ingestion_task = asyncio.create_task(log_ingestion_worker(repo))
|
# Start background ingestion task
|
||||||
|
if ingestion_task is None or ingestion_task.done():
|
||||||
|
ingestion_task = asyncio.create_task(log_ingestion_worker(repo))
|
||||||
|
|
||||||
# Start Docker log collector (writes to log file; ingester reads from it)
|
# Start Docker log collector (writes to log file; ingester reads from it)
|
||||||
_log_file = os.environ.get("DECNET_INGEST_LOG_FILE", DECNET_INGEST_LOG_FILE)
|
_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()):
|
if _log_file and (collector_task is None or collector_task.done()):
|
||||||
collector_task = asyncio.create_task(log_collector_worker(_log_file))
|
collector_task = asyncio.create_task(log_collector_worker(_log_file))
|
||||||
|
elif not _log_file:
|
||||||
|
log.warning("DECNET_INGEST_LOG_FILE not set — Docker log collection disabled.")
|
||||||
else:
|
else:
|
||||||
log.warning("DECNET_INGEST_LOG_FILE not set — Docker log collection disabled.")
|
log.info("Contract Test Mode: skipping background worker startup")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown background tasks
|
# Shutdown background tasks
|
||||||
for task in (ingestion_task, collector_task):
|
for task in (ingestion_task, collector_task):
|
||||||
if task:
|
if task and not task.done():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Task shutdown error: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
app: FastAPI = FastAPI(
|
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)
|
bounty_type: str = Field(index=True)
|
||||||
payload: str
|
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) ---
|
# --- API Request/Response Models (Pydantic) ---
|
||||||
|
|
||||||
class Token(BaseModel):
|
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:
|
async def get_total_bounties(self, bounty_type: Optional[str] = None, search: Optional[str] = None) -> int:
|
||||||
"""Retrieve the total count of bounties, optionally filtered."""
|
"""Retrieve the total count of bounties, optionally filtered."""
|
||||||
pass
|
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.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, Engine
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
# We need both sync and async engines for SQLite
|
# We need both sync and async engines for SQLite
|
||||||
# Sync for initialization (DDL) and async for standard queries
|
# 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
|
# If it's a memory URI, don't add the extra slash that turns it into a relative file
|
||||||
prefix = "sqlite+aiosqlite:///"
|
prefix = "sqlite+aiosqlite:///"
|
||||||
if db_path.startswith("file:"):
|
if db_path.startswith(":memory:"):
|
||||||
prefix = "sqlite+aiosqlite:///"
|
prefix = "sqlite+aiosqlite://"
|
||||||
return create_async_engine(f"{prefix}{db_path}", echo=False, connect_args={"uri": True})
|
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:///"
|
prefix = "sqlite:///"
|
||||||
|
if db_path.startswith(":memory:"):
|
||||||
|
prefix = "sqlite://"
|
||||||
return create_engine(f"{prefix}{db_path}", echo=False, connect_args={"uri": True})
|
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."""
|
"""Synchronously create all tables."""
|
||||||
engine = get_sync_engine(db_path)
|
engine = get_sync_engine(db_path)
|
||||||
# Ensure WAL mode is set
|
# Ensure WAL mode is set
|
||||||
@@ -25,7 +28,7 @@ def init_db(db_path: str):
|
|||||||
conn.exec_driver_sql("PRAGMA synchronous=NORMAL")
|
conn.exec_driver_sql("PRAGMA synchronous=NORMAL")
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
async def get_session(engine) -> AsyncSession:
|
async def get_session(engine: AsyncEngine) -> AsyncGenerator[AsyncSession, None]:
|
||||||
async_session = async_sessionmaker(
|
async_session = async_sessionmaker(
|
||||||
engine, class_=AsyncSession, expire_on_commit=False
|
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 import func, select, desc, asc, text, or_, update, literal_column
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
from sqlmodel.sql.expression import SelectOfScalar
|
||||||
|
|
||||||
from decnet.config import load_state, _ROOT
|
from decnet.config import load_state, _ROOT
|
||||||
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
||||||
from decnet.web.auth import get_password_hash
|
from decnet.web.auth import get_password_hash
|
||||||
from decnet.web.db.repository import BaseRepository
|
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
|
from decnet.web.db.sqlite.database import get_async_engine, init_db
|
||||||
|
|
||||||
|
|
||||||
@@ -93,11 +94,11 @@ class SQLiteRepository(BaseRepository):
|
|||||||
|
|
||||||
def _apply_filters(
|
def _apply_filters(
|
||||||
self,
|
self,
|
||||||
statement,
|
statement: SelectOfScalar,
|
||||||
search: Optional[str],
|
search: Optional[str],
|
||||||
start_time: Optional[str],
|
start_time: Optional[str],
|
||||||
end_time: Optional[str],
|
end_time: Optional[str],
|
||||||
):
|
) -> SelectOfScalar:
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
@@ -128,9 +129,10 @@ class SQLiteRepository(BaseRepository):
|
|||||||
statement = statement.where(core_fields[key] == val)
|
statement = statement.where(core_fields[key] == val)
|
||||||
else:
|
else:
|
||||||
key_safe = re.sub(r"[^a-zA-Z0-9_]", "", key)
|
key_safe = re.sub(r"[^a-zA-Z0-9_]", "", key)
|
||||||
statement = statement.where(
|
if key_safe:
|
||||||
text(f"json_extract(fields, '$.{key_safe}') = :val")
|
statement = statement.where(
|
||||||
).params(val=val)
|
text(f"json_extract(fields, '$.{key_safe}') = :val")
|
||||||
|
).params(val=val)
|
||||||
else:
|
else:
|
||||||
lk = f"%{token}%"
|
lk = f"%{token}%"
|
||||||
statement = statement.where(
|
statement = statement.where(
|
||||||
@@ -206,7 +208,7 @@ class SQLiteRepository(BaseRepository):
|
|||||||
end_time: Optional[str] = None,
|
end_time: Optional[str] = None,
|
||||||
interval_minutes: int = 15,
|
interval_minutes: int = 15,
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
bucket_seconds = interval_minutes * 60
|
bucket_seconds = max(interval_minutes, 1) * 60
|
||||||
bucket_expr = literal_column(
|
bucket_expr = literal_column(
|
||||||
f"datetime((strftime('%s', timestamp) / {bucket_seconds}) * {bucket_seconds}, 'unixepoch')"
|
f"datetime((strftime('%s', timestamp) / {bucket_seconds}) * {bucket_seconds}, 'unixepoch')"
|
||||||
).label("bucket_time")
|
).label("bucket_time")
|
||||||
@@ -299,7 +301,12 @@ class SQLiteRepository(BaseRepository):
|
|||||||
session.add(Bounty(**data))
|
session.add(Bounty(**data))
|
||||||
await session.commit()
|
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:
|
if bounty_type:
|
||||||
statement = statement.where(Bounty.bounty_type == bounty_type)
|
statement = statement.where(Bounty.bounty_type == bounty_type)
|
||||||
if search:
|
if search:
|
||||||
@@ -350,3 +357,29 @@ class SQLiteRepository(BaseRepository):
|
|||||||
async with self.session_factory() as session:
|
async with self.session_factory() as session:
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return result.scalar() or 0
|
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 typing import Any, Optional
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import HTTPException, status, Request
|
from fastapi import HTTPException, status, Request
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
|
||||||
from decnet.web.auth import ALGORITHM, SECRET_KEY
|
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
|
# Shared repository singleton
|
||||||
_ROOT_DIR = Path(__file__).parent.parent.parent.absolute()
|
repo: BaseRepository = get_repository()
|
||||||
DB_PATH = _ROOT_DIR / "decnet.db"
|
|
||||||
|
|
||||||
# Shared repository instance
|
def get_repo() -> BaseRepository:
|
||||||
repo = SQLiteRepository(db_path=str(DB_PATH))
|
"""FastAPI dependency to inject the configured repository."""
|
||||||
|
return repo
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ async def log_ingestion_worker(repo: BaseRepository) -> None:
|
|||||||
_position = _f.tell()
|
_position = _f.tell()
|
||||||
|
|
||||||
except Exception as _e:
|
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}")
|
logger.error(f"Error in log ingestion worker: {_e}")
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ router = APIRouter()
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/auth/change-password",
|
"/auth/change-password",
|
||||||
tags=["Authentication"],
|
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]:
|
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)
|
_user: Optional[dict[str, Any]] = await repo.get_user_by_uuid(current_user)
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ router = APIRouter()
|
|||||||
"/auth/login",
|
"/auth/login",
|
||||||
response_model=Token,
|
response_model=Token,
|
||||||
tags=["Authentication"],
|
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]:
|
async def login(request: LoginRequest) -> dict[str, Any]:
|
||||||
_user: Optional[dict[str, Any]] = await repo.get_user_by_username(request.username)
|
_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"],
|
@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(
|
async def get_bounties(
|
||||||
limit: int = Query(50, ge=1, le=1000),
|
limit: int = Query(50, ge=1, le=1000),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
|
|||||||
@@ -3,17 +3,21 @@ import os
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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.engine import deploy as _deploy
|
||||||
from decnet.ini_loader import load_ini_from_string
|
from decnet.ini_loader import load_ini_from_string
|
||||||
from decnet.network import detect_interface, detect_subnet, get_host_ip
|
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
|
from decnet.web.db.models import DeployIniRequest
|
||||||
|
|
||||||
router = APIRouter()
|
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]:
|
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
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Failed to parse INI: {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")
|
ingest_log_file = os.environ.get("DECNET_INGEST_LOG_FILE")
|
||||||
|
|
||||||
if state:
|
if state_dict:
|
||||||
config, _ = state
|
config = DecnetConfig(**state_dict["config"])
|
||||||
subnet_cidr = ini.subnet or config.subnet
|
subnet_cidr = ini.subnet or config.subnet
|
||||||
gateway = ini.gateway or config.gateway
|
gateway = ini.gateway or config.gateway
|
||||||
host_ip = get_host_ip(config.interface)
|
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`.
|
# We call deploy(config) which regenerates docker-compose and runs `up -d --remove-orphans`.
|
||||||
try:
|
try:
|
||||||
_deploy(config)
|
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:
|
except Exception as e:
|
||||||
logging.getLogger("decnet.web.api").exception("Deployment failed: %s", e)
|
logging.getLogger("decnet.web.api").exception("Deployment failed: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Deployment failed. Check server logs for details.")
|
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"],
|
@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]]:
|
async def get_deckies(current_user: str = Depends(get_current_user)) -> list[dict[str, Any]]:
|
||||||
return await repo.get_deckies()
|
return await repo.get_deckies()
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
|
import os
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||||
|
|
||||||
from decnet.mutator import mutate_decky
|
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 = 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(
|
async def api_mutate_decky(
|
||||||
decky_name: str = Path(..., pattern=r"^[a-z0-9\-]{1,64}$"),
|
decky_name: str = Path(..., pattern=r"^[a-z0-9\-]{1,64}$"),
|
||||||
current_user: str = Depends(get_current_user),
|
current_user: str = Depends(get_current_user),
|
||||||
) -> dict[str, str]:
|
) -> 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:
|
if success:
|
||||||
return {"message": f"Successfully mutated {decky_name}"}
|
return {"message": f"Successfully mutated {decky_name}"}
|
||||||
raise HTTPException(status_code=404, detail=f"Decky {decky_name} not found or failed to mutate")
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from decnet.config import load_state, save_state
|
from decnet.config import DecnetConfig
|
||||||
from decnet.web.dependencies import get_current_user
|
from decnet.web.dependencies import get_current_user, repo
|
||||||
from decnet.web.db.models import MutateIntervalRequest
|
from decnet.web.db.models import MutateIntervalRequest
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.put("/deckies/{decky_name}/mutate-interval", tags=["Fleet Management"],
|
@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]:
|
async def api_update_mutate_interval(decky_name: str, req: MutateIntervalRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||||
state = load_state()
|
state_dict = await repo.get_state("deployment")
|
||||||
if not state:
|
if not state_dict:
|
||||||
raise HTTPException(status_code=500, detail="No active deployment")
|
raise HTTPException(status_code=400, detail="No active deployment")
|
||||||
config, compose_path = state
|
|
||||||
|
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)
|
decky = next((d for d in config.deckies if d.name == decky_name), None)
|
||||||
if not decky:
|
if not decky:
|
||||||
raise HTTPException(status_code=404, detail="Decky not found")
|
raise HTTPException(status_code=404, detail="Decky not found")
|
||||||
|
|
||||||
decky.mutate_interval = req.mutate_interval
|
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"}
|
return {"message": "Mutation interval updated"}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/logs/histogram", tags=["Logs"],
|
@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(
|
async def get_logs_histogram(
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
start_time: Optional[str] = None,
|
start_time: Optional[str] = None,
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ from decnet.web.db.models import LogsResponse
|
|||||||
|
|
||||||
router = APIRouter()
|
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(
|
async def get_logs(
|
||||||
limit: int = Query(50, ge=1, le=1000),
|
limit: int = Query(50, ge=1, le=1000),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_model=StatsResponse, tags=["Observability"],
|
@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]:
|
async def get_stats(current_user: str = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
return await repo.get_stats_summary()
|
return await repo.get_stats_summary()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import AsyncGenerator, Optional
|
|||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from decnet.env import DECNET_DEVELOPER
|
||||||
from decnet.web.dependencies import get_stream_user, repo
|
from decnet.web.dependencies import get_stream_user, repo
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -14,13 +15,22 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stream", tags=["Observability"],
|
@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(
|
async def stream_events(
|
||||||
request: Request,
|
request: Request,
|
||||||
last_event_id: int = Query(0, alias="lastEventId"),
|
last_event_id: int = Query(0, alias="lastEventId"),
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
start_time: Optional[str] = None,
|
start_time: Optional[str] = None,
|
||||||
end_time: Optional[str] = None,
|
end_time: Optional[str] = None,
|
||||||
|
max_output: Optional[int] = Query(None, alias="maxOutput"),
|
||||||
current_user: str = Depends(get_stream_user)
|
current_user: str = Depends(get_stream_user)
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
|
|
||||||
@@ -28,6 +38,7 @@ async def stream_events(
|
|||||||
last_id = last_event_id
|
last_id = last_event_id
|
||||||
stats_interval_sec = 10
|
stats_interval_sec = 10
|
||||||
loops_since_stats = 0
|
loops_since_stats = 0
|
||||||
|
emitted_chunks = 0
|
||||||
try:
|
try:
|
||||||
if last_id == 0:
|
if last_id == 0:
|
||||||
last_id = await repo.get_max_log_id()
|
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"
|
yield f"event: message\ndata: {json.dumps({'type': 'histogram', 'data': histogram})}\n\n"
|
||||||
|
|
||||||
while True:
|
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():
|
if await request.is_disconnected():
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -65,6 +82,7 @@ async def stream_events(
|
|||||||
loops_since_stats = 0
|
loops_since_stats = 0
|
||||||
|
|
||||||
loops_since_stats += 1
|
loops_since_stats += 1
|
||||||
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ decnet = "decnet.cli:app"
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
asyncio_debug = "true"
|
||||||
addopts = "-m 'not fuzz and not live' -v -q -x -n logical"
|
addopts = "-m 'not fuzz and not live' -v -q -x -n logical"
|
||||||
markers = [
|
markers = [
|
||||||
"fuzz: hypothesis-based fuzz tests (slow, run with -m fuzz or -m '' for all)",
|
"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:
|
def _rand_msg_id() -> str:
|
||||||
"""Return a Postfix-style 12-char alphanumeric queue ID."""
|
"""Return a Postfix-style 12-char alphanumeric queue ID."""
|
||||||
chars = string.ascii_uppercase + string.digits
|
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]:
|
def _decode_auth_plain(blob: str) -> tuple[str, str]:
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ def write_syslog_file(line: str) -> None:
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Any
|
from typing import Optional
|
||||||
|
|
||||||
_RFC5424_RE: re.Pattern = re.compile(
|
_RFC5424_RE: re.Pattern = re.compile(
|
||||||
r"^<\d+>1 "
|
r"^<\d+>1 "
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Tests for the mutate interval API endpoint.
|
Tests for the mutate interval API endpoint.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import httpx
|
import httpx
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch, AsyncMock
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from decnet.config import DeckyConfig, DecnetConfig
|
from decnet.config import DeckyConfig, DecnetConfig
|
||||||
|
|
||||||
@@ -37,19 +35,20 @@ class TestMutateInterval:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_active_deployment(self, client: httpx.AsyncClient, auth_token: str):
|
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(
|
resp = await client.put(
|
||||||
"/api/v1/deckies/decky-01/mutate-interval",
|
"/api/v1/deckies/decky-01/mutate-interval",
|
||||||
headers={"Authorization": f"Bearer {auth_token}"},
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
json={"mutate_interval": 60},
|
json={"mutate_interval": 60},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 500
|
assert resp.status_code == 400
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_decky_not_found(self, client: httpx.AsyncClient, auth_token: str):
|
async def test_decky_not_found(self, client: httpx.AsyncClient, auth_token: str):
|
||||||
config = _config()
|
config = _config()
|
||||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
|
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||||
return_value=(config, Path("test.yml"))):
|
mock_repo.get_state.return_value = {"config": config.model_dump(), "compose_path": "c.yml"}
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
"/api/v1/deckies/nonexistent/mutate-interval",
|
"/api/v1/deckies/nonexistent/mutate-interval",
|
||||||
headers={"Authorization": f"Bearer {auth_token}"},
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
@@ -60,30 +59,26 @@ class TestMutateInterval:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_successful_interval_update(self, client: httpx.AsyncClient, auth_token: str):
|
async def test_successful_interval_update(self, client: httpx.AsyncClient, auth_token: str):
|
||||||
config = _config()
|
config = _config()
|
||||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
|
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||||
return_value=(config, Path("test.yml"))):
|
mock_repo.get_state.return_value = {"config": config.model_dump(), "compose_path": "c.yml"}
|
||||||
with patch("decnet.web.router.fleet.api_mutate_interval.save_state") as mock_save:
|
resp = await client.put(
|
||||||
resp = await client.put(
|
"/api/v1/deckies/decky-01/mutate-interval",
|
||||||
"/api/v1/deckies/decky-01/mutate-interval",
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
headers={"Authorization": f"Bearer {auth_token}"},
|
json={"mutate_interval": 120},
|
||||||
json={"mutate_interval": 120},
|
)
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["message"] == "Mutation interval updated"
|
assert resp.json()["message"] == "Mutation interval updated"
|
||||||
mock_save.assert_called_once()
|
mock_repo.set_state.assert_awaited_once()
|
||||||
# Verify the interval was actually updated on the decky config
|
|
||||||
assert config.deckies[0].mutate_interval == 120
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_null_interval_removes_mutation(self, client: httpx.AsyncClient, auth_token: str):
|
async def test_null_interval_removes_mutation(self, client: httpx.AsyncClient, auth_token: str):
|
||||||
config = _config()
|
config = _config()
|
||||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
|
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||||
return_value=(config, Path("test.yml"))):
|
mock_repo.get_state.return_value = {"config": config.model_dump(), "compose_path": "c.yml"}
|
||||||
with patch("decnet.web.router.fleet.api_mutate_interval.save_state"):
|
resp = await client.put(
|
||||||
resp = await client.put(
|
"/api/v1/deckies/decky-01/mutate-interval",
|
||||||
"/api/v1/deckies/decky-01/mutate-interval",
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
headers={"Authorization": f"Bearer {auth_token}"},
|
json={"mutate_interval": None},
|
||||||
json={"mutate_interval": None},
|
)
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
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 datetime import datetime, timedelta
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from hypothesis import given, settings, strategies as st
|
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
|
from ..conftest import _FUZZ_SETTINGS
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def repo(tmp_path):
|
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):
|
def _log(decky="d", service="ssh", ip="1.2.3.4", timestamp=None):
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Direct async tests for SQLiteRepository.
|
Direct async tests for the configured Repository implementation.
|
||||||
These exercise the DB layer without going through the HTTP stack,
|
These exercise the DB layer without going through the HTTP stack.
|
||||||
covering DEBT-006 (zero test coverage on the database layer).
|
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
from hypothesis import given, settings, strategies as st
|
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
|
from .conftest import _FUZZ_SETTINGS
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def repo(tmp_path):
|
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
|
@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.
|
Requires DECNET_DEVELOPER=true (set in tests/conftest.py) to expose /openapi.json.
|
||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
import schemathesis
|
import schemathesis as st
|
||||||
from hypothesis import settings
|
from hypothesis import settings, Verbosity
|
||||||
from schemathesis.checks import not_a_server_error
|
from decnet.web.auth import create_access_token
|
||||||
from decnet.web.api import app
|
|
||||||
|
|
||||||
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
|
@pytest.mark.fuzz
|
||||||
@schemathesis.pytest.parametrize(api=schema)
|
@st.pytest.parametrize(api=schema)
|
||||||
@settings(max_examples=5, deadline=None)
|
@settings(max_examples=3000, deadline=None, verbosity=Verbosity.debug)
|
||||||
def test_schema_compliance(case):
|
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
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("DECNET_JWT_SECRET", "test-jwt-secret-not-for-production-use")
|
os.environ["DECNET_JWT_SECRET"] = "stable-test-secret-key-at-least-32-chars-long"
|
||||||
# Expose OpenAPI schema so schemathesis can load it during tests
|
os.environ["DECNET_ADMIN_PASSWORD"] = "test-password-123"
|
||||||
os.environ.setdefault("DECNET_DEVELOPER", "true")
|
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 add_bounty(self, d): await super().add_bounty(d)
|
||||||
async def get_bounties(self, **kw): await super().get_bounties(**kw)
|
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_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
|
@pytest.mark.asyncio
|
||||||
async def test_base_repo_coverage():
|
async def test_base_repo_coverage():
|
||||||
@@ -37,3 +39,5 @@ async def test_base_repo_coverage():
|
|||||||
await dr.add_bounty({})
|
await dr.add_bounty({})
|
||||||
await dr.get_bounties()
|
await dr.get_bounties()
|
||||||
await dr.get_total_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.
|
Tests for decnet.mutator — mutation engine, retry logic, due-time scheduling.
|
||||||
All subprocess and state I/O is mocked; no Docker or filesystem access.
|
All subprocess and state I/O is mocked; no Docker or filesystem access.
|
||||||
"""
|
"""
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch, AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -41,9 +40,131 @@ def _make_config(deckies=None, mutate_interval=30):
|
|||||||
mutate_interval=mutate_interval,
|
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:
|
class TestComposeWithRetry:
|
||||||
@@ -60,149 +181,3 @@ class TestComposeWithRetry:
|
|||||||
patch("decnet.engine.deployer.time.sleep"):
|
patch("decnet.engine.deployer.time.sleep"):
|
||||||
_compose_with_retry("up", "-d", compose_file=Path("compose.yml"), retries=3)
|
_compose_with_retry("up", "-d", compose_file=Path("compose.yml"), retries=3)
|
||||||
assert mock_run.call_count == 2
|
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