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
|
||||||
@@ -30,7 +31,7 @@ def _require_env(name: str) -> str:
|
|||||||
f"Required environment variable '{name}' is not set. "
|
f"Required environment variable '{name}' is not set. "
|
||||||
f"Set it in .env.local or export it before starting DECNET."
|
f"Set it in .env.local or export it before starting DECNET."
|
||||||
)
|
)
|
||||||
|
|
||||||
if any(k.startswith("PYTEST") for k in os.environ):
|
if any(k.startswith("PYTEST") for k in os.environ):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ def _parse_configparser(cp: configparser.ConfigParser) -> IniConfig:
|
|||||||
for section in cp.sections():
|
for section in cp.sections():
|
||||||
if section == "general":
|
if section == "general":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# A service sub-section is identified if the section name has at least one dot
|
# A service sub-section is identified if the section name has at least one dot
|
||||||
# AND the last segment is a known service name.
|
# AND the last segment is a known service name.
|
||||||
# e.g. "decky-01.ssh" -> sub-section
|
# e.g. "decky-01.ssh" -> sub-section
|
||||||
@@ -151,7 +151,7 @@ def _parse_configparser(cp: configparser.ConfigParser) -> IniConfig:
|
|||||||
services = [sv.strip() for sv in svc_raw.split(",")] if svc_raw else None
|
services = [sv.strip() for sv in svc_raw.split(",")] if svc_raw else None
|
||||||
archetype = s.get("archetype")
|
archetype = s.get("archetype")
|
||||||
nmap_os = s.get("nmap_os") or s.get("nmap-os") or None
|
nmap_os = s.get("nmap_os") or s.get("nmap-os") or None
|
||||||
|
|
||||||
mi_raw = s.get("mutate_interval") or s.get("mutate-interval")
|
mi_raw = s.get("mutate_interval") or s.get("mutate-interval")
|
||||||
mutate_interval = None
|
mutate_interval = None
|
||||||
if mi_raw:
|
if mi_raw:
|
||||||
@@ -199,11 +199,11 @@ def _parse_configparser(cp: configparser.ConfigParser) -> IniConfig:
|
|||||||
for section in cp.sections():
|
for section in cp.sections():
|
||||||
if "." not in section:
|
if "." not in section:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
decky_name, dot, svc_name = section.rpartition(".")
|
decky_name, dot, svc_name = section.rpartition(".")
|
||||||
if svc_name not in known_services:
|
if svc_name not in known_services:
|
||||||
continue # not a service sub-section
|
continue # not a service sub-section
|
||||||
|
|
||||||
svc_cfg = {k: v for k, v in cp[section].items()}
|
svc_cfg = {k: v for k, v in cp[section].items()}
|
||||||
if decky_name in decky_map:
|
if decky_name in decky_map:
|
||||||
# Direct match — single decky
|
# Direct match — single decky
|
||||||
|
|||||||
@@ -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,28 +32,38 @@ 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(
|
||||||
title="DECNET Web Dashboard API",
|
title="DECNET Web Dashboard API",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
docs_url="/docs" if DECNET_DEVELOPER else None,
|
docs_url="/docs" if DECNET_DEVELOPER else None,
|
||||||
redoc_url="/redoc" if DECNET_DEVELOPER else None,
|
redoc_url="/redoc" if DECNET_DEVELOPER else None,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
|
|||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
return bcrypt.checkpw(
|
return bcrypt.checkpw(
|
||||||
plain_password.encode("utf-8")[:72],
|
plain_password.encode("utf-8")[:72],
|
||||||
hashed_password.encode("utf-8")
|
hashed_password.encode("utf-8")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ def create_access_token(data: dict[str, Any], expires_delta: Optional[timedelta]
|
|||||||
_expire = datetime.now(timezone.utc) + expires_delta
|
_expire = datetime.now(timezone.utc) + expires_delta
|
||||||
else:
|
else:
|
||||||
_expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
_expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||||
|
|
||||||
_to_encode.update({"exp": _expire})
|
_to_encode.update({"exp": _expire})
|
||||||
_to_encode.update({"iat": datetime.now(timezone.utc)})
|
_to_encode.update({"iat": datetime.now(timezone.utc)})
|
||||||
_encoded_jwt: str = jwt.encode(_to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
_encoded_jwt: str = jwt.encode(_to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|||||||
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}")
|
||||||
@@ -22,7 +22,7 @@ class Log(SQLModel, table=True):
|
|||||||
event_type: str = Field(index=True)
|
event_type: str = Field(index=True)
|
||||||
attacker_ip: str = Field(index=True)
|
attacker_ip: str = Field(index=True)
|
||||||
raw_line: str
|
raw_line: str
|
||||||
fields: str
|
fields: str
|
||||||
msg: Optional[str] = None
|
msg: Optional[str] = None
|
||||||
|
|
||||||
class Bounty(SQLModel, table=True):
|
class Bounty(SQLModel, table=True):
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ class BaseRepository(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_logs(
|
async def get_logs(
|
||||||
self,
|
self,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
search: Optional[str] = None
|
search: Optional[str] = None
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Retrieve paginated log entries."""
|
"""Retrieve paginated log entries."""
|
||||||
@@ -67,9 +67,9 @@ class BaseRepository(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_bounties(
|
async def get_bounties(
|
||||||
self,
|
self,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
bounty_type: Optional[str] = None,
|
bounty_type: Optional[str] = None,
|
||||||
search: Optional[str] = None
|
search: Optional[str] = None
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ async def get_current_user(request: Request) -> str:
|
|||||||
detail="Could not validate credentials",
|
detail="Could not validate credentials",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
auth_header = request.headers.get("Authorization")
|
auth_header = request.headers.get("Authorization")
|
||||||
token: str | None = (
|
token: str | None = (
|
||||||
auth_header.split(" ", 1)[1]
|
auth_header.split(" ", 1)[1]
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async def log_ingestion_worker(repo: BaseRepository) -> None:
|
|||||||
|
|
||||||
_json_log_path: Path = Path(_base_log_file).with_suffix(".json")
|
_json_log_path: Path = Path(_base_log_file).with_suffix(".json")
|
||||||
_position: int = 0
|
_position: int = 0
|
||||||
|
|
||||||
logger.info(f"Starting JSON log ingestion from {_json_log_path}")
|
logger.info(f"Starting JSON log ingestion from {_json_log_path}")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -29,24 +29,24 @@ async def log_ingestion_worker(repo: BaseRepository) -> None:
|
|||||||
if not _json_log_path.exists():
|
if not _json_log_path.exists():
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_stat: os.stat_result = _json_log_path.stat()
|
_stat: os.stat_result = _json_log_path.stat()
|
||||||
if _stat.st_size < _position:
|
if _stat.st_size < _position:
|
||||||
# File rotated or truncated
|
# File rotated or truncated
|
||||||
_position = 0
|
_position = 0
|
||||||
|
|
||||||
if _stat.st_size == _position:
|
if _stat.st_size == _position:
|
||||||
# No new data
|
# No new data
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
with open(_json_log_path, "r", encoding="utf-8", errors="replace") as _f:
|
with open(_json_log_path, "r", encoding="utf-8", errors="replace") as _f:
|
||||||
_f.seek(_position)
|
_f.seek(_position)
|
||||||
while True:
|
while True:
|
||||||
_line: str = _f.readline()
|
_line: str = _f.readline()
|
||||||
if not _line:
|
if not _line:
|
||||||
break # EOF reached
|
break # EOF reached
|
||||||
|
|
||||||
if not _line.endswith('\n'):
|
if not _line.endswith('\n'):
|
||||||
# Partial line read, don't process yet, don't advance position
|
# Partial line read, don't process yet, don't advance position
|
||||||
break
|
break
|
||||||
@@ -58,14 +58,19 @@ async def log_ingestion_worker(repo: BaseRepository) -> None:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.error(f"Failed to decode JSON log line: {_line}")
|
logger.error(f"Failed to decode JSON log line: {_line}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Update position after successful line read
|
# Update position after successful line read
|
||||||
_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)
|
||||||
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -78,7 +83,7 @@ async def _extract_bounty(repo: BaseRepository, log_data: dict[str, Any]) -> Non
|
|||||||
# 1. Credentials (User/Pass)
|
# 1. Credentials (User/Pass)
|
||||||
_user = _fields.get("username")
|
_user = _fields.get("username")
|
||||||
_pass = _fields.get("password")
|
_pass = _fields.get("password")
|
||||||
|
|
||||||
if _user and _pass:
|
if _user and _pass:
|
||||||
await repo.add_bounty({
|
await repo.add_bounty({
|
||||||
"decky": log_data.get("decky"),
|
"decky": log_data.get("decky"),
|
||||||
@@ -90,5 +95,5 @@ async def _extract_bounty(repo: BaseRepository, log_data: dict[str, Any]) -> Non
|
|||||||
"password": _pass
|
"password": _pass
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
# 2. Add more extractors here later (e.g. file hashes, crypto keys)
|
# 2. Add more extractors here later (e.g. file hashes, crypto keys)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -21,7 +25,7 @@ async def change_password(request: ChangePasswordRequest, current_user: str = De
|
|||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Incorrect old password",
|
detail="Incorrect old password",
|
||||||
)
|
)
|
||||||
|
|
||||||
_new_hash: str = get_password_hash(request.new_password)
|
_new_hash: str = get_password_hash(request.new_password)
|
||||||
await repo.update_user_password(current_user, _new_hash, must_change_password=False)
|
await repo.update_user_password(current_user, _new_hash, must_change_password=False)
|
||||||
return {"message": "Password updated successfully"}
|
return {"message": "Password updated successfully"}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -35,7 +39,7 @@ async def login(request: LoginRequest) -> dict[str, Any]:
|
|||||||
data={"uuid": _user["uuid"]}, expires_delta=_access_token_expires
|
data={"uuid": _user["uuid"]}, expires_delta=_access_token_expires
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"access_token": _access_token,
|
"access_token": _access_token,
|
||||||
"token_type": "bearer", # nosec B105
|
"token_type": "bearer", # nosec B105
|
||||||
"must_change_password": bool(_user.get("must_change_password", False))
|
"must_change_password": bool(_user.get("must_change_password", False))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -66,12 +70,20 @@ async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(
|
|||||||
existing_deckies_map = {d.name: d for d in config.deckies}
|
existing_deckies_map = {d.name: d for d in config.deckies}
|
||||||
for new_decky in new_decky_configs:
|
for new_decky in new_decky_configs:
|
||||||
existing_deckies_map[new_decky.name] = new_decky
|
existing_deckies_map[new_decky.name] = new_decky
|
||||||
|
|
||||||
config.deckies = list(existing_deckies_map.values())
|
config.deckies = list(existing_deckies_map.values())
|
||||||
|
|
||||||
# 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,20 +15,30 @@ 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:
|
||||||
|
|
||||||
async def event_generator() -> AsyncGenerator[str, None]:
|
async def event_generator() -> AsyncGenerator[str, None]:
|
||||||
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
|
||||||
@@ -29,12 +29,12 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
|||||||
def _setup_bait_fs() -> str:
|
def _setup_bait_fs() -> str:
|
||||||
bait_dir = Path("/tmp/ftp_bait")
|
bait_dir = Path("/tmp/ftp_bait")
|
||||||
bait_dir.mkdir(parents=True, exist_ok=True)
|
bait_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
(bait_dir / "backup.tar.gz").write_bytes(b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00")
|
(bait_dir / "backup.tar.gz").write_bytes(b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00")
|
||||||
(bait_dir / "db_dump.sql").write_text("CREATE TABLE users (id INT, username VARCHAR(50), password VARCHAR(50));\nINSERT INTO users VALUES (1, 'admin', 'pbkdf2:sha256:5000$...');\n")
|
(bait_dir / "db_dump.sql").write_text("CREATE TABLE users (id INT, username VARCHAR(50), password VARCHAR(50));\nINSERT INTO users VALUES (1, 'admin', 'pbkdf2:sha256:5000$...');\n")
|
||||||
(bait_dir / "config.ini").write_text("[database]\nuser = dbadmin\npassword = db_super_admin_pass_!\nhost = localhost\n")
|
(bait_dir / "config.ini").write_text("[database]\nuser = dbadmin\npassword = db_super_admin_pass_!\nhost = localhost\n")
|
||||||
(bait_dir / "credentials.txt").write_text("admin:super_secret_admin_pw\nroot:toor\nalice:wonderland\n")
|
(bait_dir / "credentials.txt").write_text("admin:super_secret_admin_pw\nroot:toor\nalice:wonderland\n")
|
||||||
|
|
||||||
return str(bait_dir)
|
return str(bait_dir)
|
||||||
|
|
||||||
class ServerFTP(FTP):
|
class ServerFTP(FTP):
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ def _publish(topic: str, value: str, retain: bool = True) -> bytes:
|
|||||||
payload = str(value).encode()
|
payload = str(value).encode()
|
||||||
fixed = 0x31 if retain else 0x30
|
fixed = 0x31 if retain else 0x30
|
||||||
remaining = len(topic_len) + len(topic_bytes) + len(payload)
|
remaining = len(topic_len) + len(topic_bytes) + len(payload)
|
||||||
|
|
||||||
# variable length encoding
|
# variable length encoding
|
||||||
rem_bytes = []
|
rem_bytes = []
|
||||||
while remaining > 0:
|
while remaining > 0:
|
||||||
@@ -108,7 +108,7 @@ def _publish(topic: str, value: str, retain: bool = True) -> bytes:
|
|||||||
rem_bytes.append(encoded)
|
rem_bytes.append(encoded)
|
||||||
if not rem_bytes:
|
if not rem_bytes:
|
||||||
rem_bytes = [0]
|
rem_bytes = [0]
|
||||||
|
|
||||||
return bytes([fixed]) + bytes(rem_bytes) + topic_len + topic_bytes + payload
|
return bytes([fixed]) + bytes(rem_bytes) + topic_len + topic_bytes + payload
|
||||||
|
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ def _generate_topics() -> dict:
|
|||||||
return topics
|
return topics
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log("config_error", severity=4, error=str(e))
|
_log("config_error", severity=4, error=str(e))
|
||||||
|
|
||||||
if MQTT_PERSONA == "water_plant":
|
if MQTT_PERSONA == "water_plant":
|
||||||
topics.update({
|
topics.update({
|
||||||
"plant/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}",
|
"plant/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}",
|
||||||
@@ -186,7 +186,7 @@ class MQTTProtocol(asyncio.Protocol):
|
|||||||
pkt_type = (pkt_byte >> 4) & 0x0f
|
pkt_type = (pkt_byte >> 4) & 0x0f
|
||||||
flags = pkt_byte & 0x0f
|
flags = pkt_byte & 0x0f
|
||||||
qos = (flags >> 1) & 0x03
|
qos = (flags >> 1) & 0x03
|
||||||
|
|
||||||
# Decode remaining length (variable-length encoding)
|
# Decode remaining length (variable-length encoding)
|
||||||
pos = 1
|
pos = 1
|
||||||
remaining = 0
|
remaining = 0
|
||||||
@@ -225,7 +225,7 @@ class MQTTProtocol(asyncio.Protocol):
|
|||||||
packet_id, subs = _parse_subscribe(payload)
|
packet_id, subs = _parse_subscribe(payload)
|
||||||
granted_qos = [1] * len(subs) # grant QoS 1 for all
|
granted_qos = [1] * len(subs) # grant QoS 1 for all
|
||||||
self._transport.write(_suback(packet_id, granted_qos))
|
self._transport.write(_suback(packet_id, granted_qos))
|
||||||
|
|
||||||
# Immediately send retained publishes matching topics
|
# Immediately send retained publishes matching topics
|
||||||
for sub_topic, _ in subs:
|
for sub_topic, _ in subs:
|
||||||
_log("subscribe", src=self._peer[0], topics=[sub_topic])
|
_log("subscribe", src=self._peer[0], topics=[sub_topic])
|
||||||
@@ -245,11 +245,11 @@ class MQTTProtocol(asyncio.Protocol):
|
|||||||
topic, packet_id, data = _parse_publish(payload, qos)
|
topic, packet_id, data = _parse_publish(payload, qos)
|
||||||
# Attacker command received!
|
# Attacker command received!
|
||||||
_log("publish", src=self._peer[0], topic=topic, payload=data.decode(errors="replace"))
|
_log("publish", src=self._peer[0], topic=topic, payload=data.decode(errors="replace"))
|
||||||
|
|
||||||
if qos == 1:
|
if qos == 1:
|
||||||
puback = bytes([0x40, 0x02]) + struct.pack(">H", packet_id)
|
puback = bytes([0x40, 0x02]) + struct.pack(">H", packet_id)
|
||||||
self._transport.write(puback)
|
self._transport.write(puback)
|
||||||
|
|
||||||
elif pkt_type == 12: # PINGREQ
|
elif pkt_type == 12: # PINGREQ
|
||||||
self._transport.write(b"\xd0\x00") # PINGRESP
|
self._transport.write(b"\xd0\x00") # PINGRESP
|
||||||
elif pkt_type == 14: # DISCONNECT
|
elif pkt_type == 14: # DISCONNECT
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ class RedisProtocol(asyncio.Protocol):
|
|||||||
elif pattern != '*':
|
elif pattern != '*':
|
||||||
pat = pattern.encode()
|
pat = pattern.encode()
|
||||||
keys = [k for k in keys if k == pat]
|
keys = [k for k in keys if k == pat]
|
||||||
|
|
||||||
resp = f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys)
|
resp = f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys)
|
||||||
self._transport.write(resp)
|
self._transport.write(resp)
|
||||||
elif verb == "GET":
|
elif verb == "GET":
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -153,11 +153,11 @@ def _parse_snmp(data: bytes):
|
|||||||
# PDU type (0xa0 = GetRequest, 0xa1 = GetNextRequest)
|
# PDU type (0xa0 = GetRequest, 0xa1 = GetNextRequest)
|
||||||
if pos >= len(data):
|
if pos >= len(data):
|
||||||
raise ValueError("Missing PDU type")
|
raise ValueError("Missing PDU type")
|
||||||
|
|
||||||
pdu_type = data[pos]
|
pdu_type = data[pos]
|
||||||
if pdu_type not in (0xa0, 0xa1):
|
if pdu_type not in (0xa0, 0xa1):
|
||||||
raise ValueError(f"Invalid PDU type {pdu_type}")
|
raise ValueError(f"Invalid PDU type {pdu_type}")
|
||||||
|
|
||||||
pos += 1
|
pos += 1
|
||||||
_, pos = _read_ber_length(data, pos)
|
_, pos = _read_ber_length(data, pos)
|
||||||
# request-id
|
# request-id
|
||||||
|
|||||||
@@ -155,13 +155,13 @@ def write_syslog_file(line: str) -> None:
|
|||||||
"""Append a syslog line to the rotating log file."""
|
"""Append a syslog line to the rotating log file."""
|
||||||
try:
|
try:
|
||||||
_get_file_logger().info(line)
|
_get_file_logger().info(line)
|
||||||
|
|
||||||
# Also parse and write JSON log
|
# Also parse and write JSON log
|
||||||
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 "
|
||||||
r"(\S+) " # 1: TIMESTAMP
|
r"(\S+) " # 1: TIMESTAMP
|
||||||
@@ -174,7 +174,7 @@ def write_syslog_file(line: str) -> None:
|
|||||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||||
|
|
||||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||||
if _m:
|
if _m:
|
||||||
_ts_raw: str
|
_ts_raw: str
|
||||||
@@ -183,10 +183,10 @@ def write_syslog_file(line: str) -> None:
|
|||||||
_event_type: str
|
_event_type: str
|
||||||
_sd_rest: str
|
_sd_rest: str
|
||||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||||
|
|
||||||
_fields: dict[str, str] = {}
|
_fields: dict[str, str] = {}
|
||||||
_msg: str = ""
|
_msg: str = ""
|
||||||
|
|
||||||
if _sd_rest.startswith("-"):
|
if _sd_rest.startswith("-"):
|
||||||
_msg = _sd_rest[1:].lstrip()
|
_msg = _sd_rest[1:].lstrip()
|
||||||
elif _sd_rest.startswith("["):
|
elif _sd_rest.startswith("["):
|
||||||
@@ -194,27 +194,27 @@ def write_syslog_file(line: str) -> None:
|
|||||||
if _block:
|
if _block:
|
||||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||||
|
|
||||||
# extract msg after the block
|
# extract msg after the block
|
||||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||||
if _msg_match:
|
if _msg_match:
|
||||||
_msg = _msg_match.group(1).strip()
|
_msg = _msg_match.group(1).strip()
|
||||||
else:
|
else:
|
||||||
_msg = _sd_rest
|
_msg = _sd_rest
|
||||||
|
|
||||||
_attacker_ip: str = "Unknown"
|
_attacker_ip: str = "Unknown"
|
||||||
for _fname in _IP_FIELDS:
|
for _fname in _IP_FIELDS:
|
||||||
if _fname in _fields:
|
if _fname in _fields:
|
||||||
_attacker_ip = _fields[_fname]
|
_attacker_ip = _fields[_fname]
|
||||||
break
|
break
|
||||||
|
|
||||||
# Parse timestamp to normalize it
|
# Parse timestamp to normalize it
|
||||||
_ts_formatted: str
|
_ts_formatted: str
|
||||||
try:
|
try:
|
||||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
_ts_formatted = _ts_raw
|
_ts_formatted = _ts_raw
|
||||||
|
|
||||||
_payload: dict[str, Any] = {
|
_payload: dict[str, Any] = {
|
||||||
"timestamp": _ts_formatted,
|
"timestamp": _ts_formatted,
|
||||||
"decky": _decky,
|
"decky": _decky,
|
||||||
@@ -226,7 +226,7 @@ def write_syslog_file(line: str) -> None:
|
|||||||
"raw_line": line
|
"raw_line": line
|
||||||
}
|
}
|
||||||
_get_json_logger().info(json.dumps(_payload))
|
_get_json_logger().info(json.dumps(_payload))
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ async def test_fuzz_get_logs(client: httpx.AsyncClient, auth_token: str, limit:
|
|||||||
_params: dict[str, Any] = {"limit": limit, "offset": offset}
|
_params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||||
if search is not None:
|
if search is not None:
|
||||||
_params["search"] = search
|
_params["search"] = search
|
||||||
|
|
||||||
_response: httpx.Response = await client.get(
|
_response: httpx.Response = await client.get(
|
||||||
"/api/v1/logs",
|
"/api/v1/logs",
|
||||||
params=_params,
|
params=_params,
|
||||||
headers={"Authorization": f"Bearer {auth_token}"}
|
headers={"Authorization": f"Bearer {auth_token}"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert _response.status_code in (200, 422)
|
assert _response.status_code in (200, 422)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class TestStreamEvents:
|
|||||||
# We force the generator to exit immediately by making the first awaitable raise
|
# We force the generator to exit immediately by making the first awaitable raise
|
||||||
with patch("decnet.web.router.stream.api_stream_events.repo") as mock_repo:
|
with patch("decnet.web.router.stream.api_stream_events.repo") as mock_repo:
|
||||||
mock_repo.get_max_log_id = AsyncMock(side_effect=StopAsyncIteration)
|
mock_repo.get_max_log_id = AsyncMock(side_effect=StopAsyncIteration)
|
||||||
|
|
||||||
# This will hit the 'except Exception' or just exit the generator
|
# This will hit the 'except Exception' or just exit the generator
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"/api/v1/stream",
|
"/api/v1/stream",
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ def _publish_packet(topic: str, payload: str, qos: int = 1, pid: int = 1) -> byt
|
|||||||
packet_payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + pid.to_bytes(2, "big") + payload_bytes
|
packet_payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + pid.to_bytes(2, "big") + payload_bytes
|
||||||
else:
|
else:
|
||||||
packet_payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + payload_bytes
|
packet_payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + payload_bytes
|
||||||
|
|
||||||
return bytes([byte0, len(packet_payload)]) + packet_payload
|
return bytes([byte0, len(packet_payload)]) + packet_payload
|
||||||
|
|
||||||
def _pingreq_packet() -> bytes:
|
def _pingreq_packet() -> bytes:
|
||||||
@@ -128,10 +128,10 @@ def test_subscribe_wildcard_retained(mqtt_mod):
|
|||||||
written.clear()
|
written.clear()
|
||||||
|
|
||||||
_send(proto, _subscribe_packet("plant/#"))
|
_send(proto, _subscribe_packet("plant/#"))
|
||||||
|
|
||||||
assert len(written) >= 2 # At least SUBACK + some publishes
|
assert len(written) >= 2 # At least SUBACK + some publishes
|
||||||
assert written[0].startswith(b"\x90") # SUBACK
|
assert written[0].startswith(b"\x90") # SUBACK
|
||||||
|
|
||||||
combined = b"".join(written[1:])
|
combined = b"".join(written[1:])
|
||||||
# Should contain some water plant topics
|
# Should contain some water plant topics
|
||||||
assert b"plant/water/tank1/level" in combined
|
assert b"plant/water/tank1/level" in combined
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ def _make_protocol(mod):
|
|||||||
proto = mod.SNMPProtocol()
|
proto = mod.SNMPProtocol()
|
||||||
transport = MagicMock()
|
transport = MagicMock()
|
||||||
sent: list[tuple] = []
|
sent: list[tuple] = []
|
||||||
|
|
||||||
def sendto(data, addr):
|
def sendto(data, addr):
|
||||||
sent.append((data, addr))
|
sent.append((data, addr))
|
||||||
|
|
||||||
transport.sendto = sendto
|
transport.sendto = sendto
|
||||||
proto.connection_made(transport)
|
proto.connection_made(transport)
|
||||||
sent.clear()
|
sent.clear()
|
||||||
@@ -104,11 +104,11 @@ def test_sysdescr_default(snmp_default):
|
|||||||
proto, transport, sent = _make_protocol(snmp_default)
|
proto, transport, sent = _make_protocol(snmp_default)
|
||||||
packet = _get_request_packet("public", 1, SYS_DESCR_OID_ENC)
|
packet = _get_request_packet("public", 1, SYS_DESCR_OID_ENC)
|
||||||
_send(proto, packet)
|
_send(proto, packet)
|
||||||
|
|
||||||
assert len(sent) == 1
|
assert len(sent) == 1
|
||||||
resp, addr = sent[0]
|
resp, addr = sent[0]
|
||||||
assert addr == ("127.0.0.1", 12345)
|
assert addr == ("127.0.0.1", 12345)
|
||||||
|
|
||||||
# default sysDescr has "Ubuntu SMP" in it
|
# default sysDescr has "Ubuntu SMP" in it
|
||||||
assert b"Ubuntu SMP" in resp
|
assert b"Ubuntu SMP" in resp
|
||||||
|
|
||||||
@@ -116,10 +116,10 @@ def test_sysdescr_water_plant(snmp_water_plant):
|
|||||||
proto, transport, sent = _make_protocol(snmp_water_plant)
|
proto, transport, sent = _make_protocol(snmp_water_plant)
|
||||||
packet = _get_request_packet("public", 2, SYS_DESCR_OID_ENC)
|
packet = _get_request_packet("public", 2, SYS_DESCR_OID_ENC)
|
||||||
_send(proto, packet)
|
_send(proto, packet)
|
||||||
|
|
||||||
assert len(sent) == 1
|
assert len(sent) == 1
|
||||||
resp, _ = sent[0]
|
resp, _ = sent[0]
|
||||||
|
|
||||||
assert b"Debian" in resp
|
assert b"Debian" in resp
|
||||||
|
|
||||||
# ── Negative Tests ────────────────────────────────────────────────────────────
|
# ── Negative Tests ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from decnet.collector import parse_rfc5424, is_service_container, is_service_event
|
from decnet.collector import parse_rfc5424, is_service_container, is_service_event
|
||||||
from decnet.collector.worker import (
|
from decnet.collector.worker import (
|
||||||
_stream_container,
|
_stream_container,
|
||||||
_load_service_container_names,
|
_load_service_container_names,
|
||||||
log_collector_worker
|
log_collector_worker
|
||||||
)
|
)
|
||||||
@@ -291,13 +291,13 @@ class TestLogCollectorWorker:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_worker_initial_discovery(self, tmp_path):
|
async def test_worker_initial_discovery(self, tmp_path):
|
||||||
log_file = str(tmp_path / "decnet.log")
|
log_file = str(tmp_path / "decnet.log")
|
||||||
|
|
||||||
mock_container = MagicMock()
|
mock_container = MagicMock()
|
||||||
mock_container.id = "c1"
|
mock_container.id = "c1"
|
||||||
mock_container.name = "/s-1"
|
mock_container.name = "/s-1"
|
||||||
# Mock labels to satisfy is_service_container
|
# Mock labels to satisfy is_service_container
|
||||||
mock_container.labels = {"com.docker.compose.project": "decnet"}
|
mock_container.labels = {"com.docker.compose.project": "decnet"}
|
||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.containers.list.return_value = [mock_container]
|
mock_client.containers.list.return_value = [mock_container]
|
||||||
# Make events return an empty generator/iterator immediately
|
# Make events return an empty generator/iterator immediately
|
||||||
@@ -310,17 +310,17 @@ class TestLogCollectorWorker:
|
|||||||
await asyncio.wait_for(log_collector_worker(log_file), timeout=0.1)
|
await asyncio.wait_for(log_collector_worker(log_file), timeout=0.1)
|
||||||
except (asyncio.TimeoutError, StopIteration):
|
except (asyncio.TimeoutError, StopIteration):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Should have tried to list and watch events
|
# Should have tried to list and watch events
|
||||||
mock_client.containers.list.assert_called_once()
|
mock_client.containers.list.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_worker_handles_events(self, tmp_path):
|
async def test_worker_handles_events(self, tmp_path):
|
||||||
log_file = str(tmp_path / "decnet.log")
|
log_file = str(tmp_path / "decnet.log")
|
||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.containers.list.return_value = []
|
mock_client.containers.list.return_value = []
|
||||||
|
|
||||||
event = {
|
event = {
|
||||||
"id": "c2",
|
"id": "c2",
|
||||||
"Actor": {"Attributes": {"name": "s-2", "com.docker.compose.project": "decnet"}}
|
"Actor": {"Attributes": {"name": "s-2", "com.docker.compose.project": "decnet"}}
|
||||||
@@ -333,7 +333,7 @@ class TestLogCollectorWorker:
|
|||||||
await asyncio.wait_for(log_collector_worker(log_file), timeout=0.1)
|
await asyncio.wait_for(log_collector_worker(log_file), timeout=0.1)
|
||||||
except (asyncio.TimeoutError, StopIteration):
|
except (asyncio.TimeoutError, StopIteration):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
mock_client.events.assert_called_once()
|
mock_client.events.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -341,7 +341,7 @@ class TestLogCollectorWorker:
|
|||||||
log_file = str(tmp_path / "decnet.log")
|
log_file = str(tmp_path / "decnet.log")
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.containers.list.side_effect = Exception("Docker down")
|
mock_client.containers.list.side_effect = Exception("Docker down")
|
||||||
|
|
||||||
with patch("docker.from_env", return_value=mock_client):
|
with patch("docker.from_env", return_value=mock_client):
|
||||||
# Should not raise
|
# Should not raise
|
||||||
await log_collector_worker(log_file)
|
await log_collector_worker(log_file)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from decnet.services.smtp_relay import SMTPRelayService
|
|||||||
def test_smtp_relay_compose_fragment():
|
def test_smtp_relay_compose_fragment():
|
||||||
svc = SMTPRelayService()
|
svc = SMTPRelayService()
|
||||||
fragment = svc.compose_fragment("test-decky", log_target="log-server")
|
fragment = svc.compose_fragment("test-decky", log_target="log-server")
|
||||||
|
|
||||||
assert fragment["container_name"] == "test-decky-smtp_relay"
|
assert fragment["container_name"] == "test-decky-smtp_relay"
|
||||||
assert fragment["environment"]["SMTP_OPEN_RELAY"] == "1"
|
assert fragment["environment"]["SMTP_OPEN_RELAY"] == "1"
|
||||||
assert fragment["environment"]["LOG_TARGET"] == "log-server"
|
assert fragment["environment"]["LOG_TARGET"] == "log-server"
|
||||||
@@ -15,7 +15,7 @@ def test_smtp_relay_compose_fragment():
|
|||||||
def test_smtp_relay_custom_cfg():
|
def test_smtp_relay_custom_cfg():
|
||||||
svc = SMTPRelayService()
|
svc = SMTPRelayService()
|
||||||
fragment = svc.compose_fragment(
|
fragment = svc.compose_fragment(
|
||||||
"test-decky",
|
"test-decky",
|
||||||
service_cfg={"banner": "Welcome", "mta": "Postfix"}
|
service_cfg={"banner": "Welcome", "mta": "Postfix"}
|
||||||
)
|
)
|
||||||
assert fragment["environment"]["SMTP_BANNER"] == "Welcome"
|
assert fragment["environment"]["SMTP_BANNER"] == "Welcome"
|
||||||
|
|||||||
Reference in New Issue
Block a user