Compare commits
2 Commits
89abb6ecc6
...
0706919469
| Author | SHA1 | Date | |
|---|---|---|---|
| 0706919469 | |||
| f2cc585d72 |
@@ -22,7 +22,8 @@
|
||||
"Read(//home/anti/Tools/cowrie/src/cowrie/data/txtcmds/**)",
|
||||
"Read(//home/anti/Tools/cowrie/src/cowrie/data/txtcmds/bin/**)",
|
||||
"mcp__plugin_context-mode_context-mode__ctx_index",
|
||||
"Bash(ls:*)"
|
||||
"Bash(ls:*)",
|
||||
"mcp__plugin_context-mode_context-mode__ctx_execute"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
.venv/
|
||||
logs/
|
||||
.claude/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
101
decnet/config.py
101
decnet/config.py
@@ -4,13 +4,77 @@ State is persisted to decnet-state.json in the working directory.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket as _socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, field_validator # field_validator used by DeckyConfig
|
||||
from decnet.models import DeckyConfig, DecnetConfig # noqa: F401
|
||||
|
||||
from decnet.distros import random_hostname as _random_hostname
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RFC 5424 syslog formatter
|
||||
# ---------------------------------------------------------------------------
|
||||
# Severity mapping: Python level → syslog severity (RFC 5424 §6.2.1)
|
||||
_SYSLOG_SEVERITY: dict[int, int] = {
|
||||
logging.CRITICAL: 2, # Critical
|
||||
logging.ERROR: 3, # Error
|
||||
logging.WARNING: 4, # Warning
|
||||
logging.INFO: 6, # Informational
|
||||
logging.DEBUG: 7, # Debug
|
||||
}
|
||||
_FACILITY_LOCAL0 = 16 # local0 (RFC 5424 §6.2.1 / POSIX)
|
||||
|
||||
|
||||
class Rfc5424Formatter(logging.Formatter):
|
||||
"""Formats log records as RFC 5424 syslog messages.
|
||||
|
||||
Output:
|
||||
<PRIVAL>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG
|
||||
|
||||
Example:
|
||||
<134>1 2026-04-12T21:48:03.123456+00:00 host decnet 1234 decnet.config - Dev mode active
|
||||
"""
|
||||
|
||||
_hostname: str = _socket.gethostname()
|
||||
_app: str = "decnet"
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
severity = _SYSLOG_SEVERITY.get(record.levelno, 6)
|
||||
prival = (_FACILITY_LOCAL0 * 8) + severity
|
||||
ts = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(timespec="microseconds")
|
||||
msg = record.getMessage()
|
||||
if record.exc_info:
|
||||
msg += "\n" + self.formatException(record.exc_info)
|
||||
return (
|
||||
f"<{prival}>1 {ts} {self._hostname} {self._app}"
|
||||
f" {os.getpid()} {record.name} - {msg}"
|
||||
)
|
||||
|
||||
|
||||
def _configure_logging(dev: bool) -> None:
|
||||
"""Install the RFC 5424 handler on the root logger (idempotent)."""
|
||||
root = logging.getLogger()
|
||||
# Avoid adding duplicate handlers on re-import (e.g. during testing)
|
||||
if any(isinstance(h, logging.StreamHandler) and isinstance(h.formatter, Rfc5424Formatter)
|
||||
for h in root.handlers):
|
||||
return
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(Rfc5424Formatter())
|
||||
root.setLevel(logging.DEBUG if dev else logging.INFO)
|
||||
root.addHandler(handler)
|
||||
|
||||
|
||||
_dev = os.environ.get("DECNET_DEVELOPER", "").lower() == "true"
|
||||
_configure_logging(_dev)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
if _dev:
|
||||
log.debug("Developer mode: debug logging active")
|
||||
|
||||
# Calculate absolute path to the project root (where the config file resides)
|
||||
_ROOT: Path = Path(__file__).parent.parent.absolute()
|
||||
STATE_FILE: Path = _ROOT / "decnet-state.json"
|
||||
@@ -21,39 +85,6 @@ def random_hostname(distro_slug: str = "debian") -> str:
|
||||
return _random_hostname(distro_slug)
|
||||
|
||||
|
||||
class DeckyConfig(BaseModel):
|
||||
name: str
|
||||
ip: str
|
||||
services: list[str]
|
||||
distro: str # slug from distros.DISTROS, e.g. "debian", "ubuntu22"
|
||||
base_image: str # Docker image for the base/IP-holder container
|
||||
build_base: str = "debian:bookworm-slim" # apt-compatible image for service Dockerfiles
|
||||
hostname: str
|
||||
archetype: str | None = None # archetype slug if spawned from an archetype profile
|
||||
service_config: dict[str, dict] = {} # optional per-service persona config
|
||||
nmap_os: str = "linux" # OS family for TCP/IP stack spoofing (see os_fingerprint.py)
|
||||
mutate_interval: int | None = None # automatic rotation interval in minutes
|
||||
last_mutated: float = 0.0 # timestamp of last mutation
|
||||
|
||||
@field_validator("services")
|
||||
@classmethod
|
||||
def services_not_empty(cls, v: list[str]) -> list[str]:
|
||||
if not v:
|
||||
raise ValueError("A decky must have at least one service.")
|
||||
return v
|
||||
|
||||
|
||||
class DecnetConfig(BaseModel):
|
||||
mode: Literal["unihost", "swarm"]
|
||||
interface: str
|
||||
subnet: str
|
||||
gateway: str
|
||||
deckies: list[DeckyConfig]
|
||||
log_file: str | None = None # host path where the collector writes the log file
|
||||
ipvlan: bool = False # use IPvlan L2 instead of MACVLAN (WiFi-friendly)
|
||||
mutate_interval: int | None = DEFAULT_MUTATE_INTERVAL # global automatic rotation interval in minutes
|
||||
|
||||
|
||||
def save_state(config: DecnetConfig, compose_path: Path) -> None:
|
||||
payload = {
|
||||
"config": config.model_dump(),
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Optional
|
||||
from decnet.archetypes import Archetype, get_archetype
|
||||
from decnet.config import DeckyConfig, random_hostname
|
||||
from decnet.distros import all_distros, get_distro, random_distro
|
||||
from decnet.ini_loader import IniConfig
|
||||
from decnet.models import IniConfig
|
||||
from decnet.services.registry import all_services
|
||||
|
||||
|
||||
@@ -146,15 +146,10 @@ def build_deckies_from_ini(
|
||||
svc_list = spec.services
|
||||
elif arch:
|
||||
svc_list = list(arch.services)
|
||||
elif randomize:
|
||||
elif randomize or (not spec.services and not arch):
|
||||
svc_pool = all_service_names()
|
||||
count = random.randint(1, min(3, len(svc_pool))) # nosec B311
|
||||
svc_list = random.sample(svc_pool, count) # nosec B311
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Decky '[{spec.name}]' has no services= in config. "
|
||||
"Add services=, archetype=, or use --randomize-services."
|
||||
)
|
||||
|
||||
resolved_nmap_os = spec.nmap_os or (arch.nmap_os if arch else "linux")
|
||||
|
||||
|
||||
@@ -41,38 +41,8 @@ Format:
|
||||
"""
|
||||
|
||||
import configparser
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeckySpec:
|
||||
name: str
|
||||
ip: str | None = None
|
||||
services: list[str] | None = None
|
||||
archetype: str | None = None
|
||||
service_config: dict[str, dict] = field(default_factory=dict)
|
||||
nmap_os: str | None = None # explicit OS family override (linux/windows/bsd/embedded/cisco)
|
||||
mutate_interval: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomServiceSpec:
|
||||
"""Spec for a user-defined (bring-your-own) service."""
|
||||
name: str # service slug, e.g. "myservice" (section is "custom-myservice")
|
||||
image: str # Docker image to use
|
||||
exec_cmd: str # command to run inside the container
|
||||
ports: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IniConfig:
|
||||
subnet: str | None = None
|
||||
gateway: str | None = None
|
||||
interface: str | None = None
|
||||
mutate_interval: int | None = None
|
||||
deckies: list[DeckySpec] = field(default_factory=list)
|
||||
custom_services: list[CustomServiceSpec] = field(default_factory=list)
|
||||
from decnet.models import IniConfig, DeckySpec, CustomServiceSpec, validate_ini_string # noqa: F401
|
||||
|
||||
|
||||
def load_ini(path: str | Path) -> IniConfig:
|
||||
@@ -86,27 +56,15 @@ def load_ini(path: str | Path) -> IniConfig:
|
||||
|
||||
def load_ini_from_string(content: str) -> IniConfig:
|
||||
"""Parse a DECNET INI string and return an IniConfig."""
|
||||
# Normalize line endings (CRLF → LF, bare CR → LF) so the validator
|
||||
# and configparser both see the same line boundaries.
|
||||
content = content.replace('\r\n', '\n').replace('\r', '\n')
|
||||
validate_ini_string(content)
|
||||
cp = configparser.ConfigParser()
|
||||
cp = configparser.ConfigParser(strict=False)
|
||||
cp.read_string(content)
|
||||
return _parse_configparser(cp)
|
||||
|
||||
|
||||
def validate_ini_string(content: str) -> None:
|
||||
"""Perform safety and sanity checks on raw INI content string."""
|
||||
# 1. Size limit (e.g. 512KB)
|
||||
if len(content) > 512 * 1024:
|
||||
raise ValueError("INI content too large (max 512KB).")
|
||||
|
||||
# 2. Ensure it's not empty
|
||||
if not content.strip():
|
||||
raise ValueError("INI content is empty.")
|
||||
|
||||
# 3. Basic structure check (must contain at least one section header)
|
||||
if "[" not in content or "]" not in content:
|
||||
raise ValueError("Invalid INI format: no sections found.")
|
||||
|
||||
|
||||
def _parse_configparser(cp: configparser.ConfigParser) -> IniConfig:
|
||||
cfg = IniConfig()
|
||||
|
||||
|
||||
120
decnet/models.py
Normal file
120
decnet/models.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
DECNET Domain Models.
|
||||
Centralized repository for all Pydantic specifications used throughout the project.
|
||||
This file ensures that core domain logic has no dependencies on the web or database layers.
|
||||
"""
|
||||
from typing import Optional, List, Dict, Literal, Annotated, Any
|
||||
from pydantic import BaseModel, ConfigDict, Field as PydanticField, field_validator, BeforeValidator
|
||||
import configparser
|
||||
|
||||
|
||||
# --- INI Specification Models ---
|
||||
|
||||
def validate_ini_string(v: Any) -> str:
|
||||
"""Structural validator for DECNET INI strings using configparser."""
|
||||
if not isinstance(v, str):
|
||||
# This remains an internal type mismatch (caught by Pydantic usually)
|
||||
raise ValueError("INI content must be a string")
|
||||
|
||||
# 512KB limit to prevent DoS/OOM
|
||||
if len(v) > 512 * 1024:
|
||||
raise ValueError("INI content is too large (max 512KB)")
|
||||
|
||||
if not v.strip():
|
||||
# Using exact phrasing expected by tests
|
||||
raise ValueError("INI content is empty")
|
||||
|
||||
parser = configparser.ConfigParser(interpolation=None, allow_no_value=True, strict=False)
|
||||
try:
|
||||
parser.read_string(v)
|
||||
if not parser.sections():
|
||||
raise ValueError("The provided INI content must contain at least one section (no sections found)")
|
||||
except configparser.Error as e:
|
||||
# If it's a generic parsing error, we check if it's effectively a "missing sections" error
|
||||
if "no section headers" in str(e).lower():
|
||||
raise ValueError("Invalid INI format: no sections found")
|
||||
raise ValueError(f"Invalid INI format: {str(e)}")
|
||||
|
||||
return v
|
||||
|
||||
# Reusable type that enforces INI structure during initialization.
|
||||
# Removed min_length=1 to make empty strings schema-compliant yet semantically invalid (mapped to 409).
|
||||
IniContent = Annotated[str, BeforeValidator(validate_ini_string)]
|
||||
|
||||
class DeckySpec(BaseModel):
|
||||
"""Configuration spec for a single decky as defined in the INI file."""
|
||||
model_config = ConfigDict(strict=True, extra="forbid")
|
||||
name: str = PydanticField(..., max_length=128, pattern=r"^[A-Za-z0-9\-_.]+$")
|
||||
ip: Optional[str] = None
|
||||
services: Optional[List[str]] = None
|
||||
archetype: Optional[str] = None
|
||||
service_config: Dict[str, Dict] = PydanticField(default_factory=dict)
|
||||
nmap_os: Optional[str] = None
|
||||
mutate_interval: Optional[int] = PydanticField(None, ge=1)
|
||||
|
||||
|
||||
class CustomServiceSpec(BaseModel):
|
||||
"""Spec for a user-defined (bring-your-own) service."""
|
||||
model_config = ConfigDict(strict=True, extra="forbid")
|
||||
name: str
|
||||
image: str
|
||||
exec_cmd: str
|
||||
ports: List[int] = PydanticField(default_factory=list)
|
||||
|
||||
|
||||
class IniConfig(BaseModel):
|
||||
"""The complete structured representation of a DECNET INI file."""
|
||||
model_config = ConfigDict(strict=True, extra="forbid")
|
||||
subnet: Optional[str] = None
|
||||
gateway: Optional[str] = None
|
||||
interface: Optional[str] = None
|
||||
mutate_interval: Optional[int] = PydanticField(None, ge=1)
|
||||
deckies: List[DeckySpec] = PydanticField(default_factory=list, min_length=1)
|
||||
custom_services: List[CustomServiceSpec] = PydanticField(default_factory=list)
|
||||
|
||||
@field_validator("deckies")
|
||||
@classmethod
|
||||
def at_least_one_decky(cls, v: List[DeckySpec]) -> List[DeckySpec]:
|
||||
"""Ensure that an INI deployment always contains at least one machine."""
|
||||
if not v:
|
||||
raise ValueError("INI must contain at least one decky section")
|
||||
return v
|
||||
|
||||
|
||||
# --- Runtime Configuration Models ---
|
||||
|
||||
class DeckyConfig(BaseModel):
|
||||
"""Full operational configuration for a deployed decky container."""
|
||||
model_config = ConfigDict(strict=True, extra="forbid")
|
||||
name: str
|
||||
ip: str
|
||||
services: list[str] = PydanticField(..., min_length=1)
|
||||
distro: str # slug from distros.DISTROS, e.g. "debian", "ubuntu22"
|
||||
base_image: str # Docker image for the base/IP-holder container
|
||||
build_base: str = "debian:bookworm-slim" # apt-compatible image for service Dockerfiles
|
||||
hostname: str
|
||||
archetype: str | None = None # archetype slug if spawned from an archetype profile
|
||||
service_config: dict[str, dict] = PydanticField(default_factory=dict)
|
||||
nmap_os: str = "linux" # OS family for TCP/IP stack spoofing (see os_fingerprint.py)
|
||||
mutate_interval: int | None = None # automatic rotation interval in minutes
|
||||
last_mutated: float = 0.0 # timestamp of last mutation
|
||||
last_login_attempt: float = 0.0 # timestamp of most recent interaction
|
||||
|
||||
@field_validator("services")
|
||||
@classmethod
|
||||
def services_not_empty(cls, v: list[str]) -> list[str]:
|
||||
if not v:
|
||||
raise ValueError("A decky must have at least one service.")
|
||||
return v
|
||||
|
||||
|
||||
class DecnetConfig(BaseModel):
|
||||
"""Root configuration for the entire DECNET fleet deployment."""
|
||||
mode: Literal["unihost", "swarm"]
|
||||
interface: str
|
||||
subnet: str
|
||||
gateway: str
|
||||
deckies: list[DeckyConfig] = PydanticField(..., min_length=1)
|
||||
log_file: str | None = None # host path where the collector writes the log file
|
||||
ipvlan: bool = False # use IPvlan L2 instead of MACVLAN (WiFi-friendly)
|
||||
mutate_interval: int | None = 30 # global automatic rotation interval in minutes
|
||||
@@ -4,7 +4,10 @@ import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from decnet.env import DECNET_CORS_ORIGINS, DECNET_DEVELOPER, DECNET_INGEST_LOG_FILE
|
||||
@@ -80,3 +83,88 @@ app.add_middleware(
|
||||
|
||||
# Include the modular API router
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""
|
||||
Handle validation errors with targeted status codes to satisfy contract tests.
|
||||
Tiered Prioritization:
|
||||
1. 400 Bad Request: For structural schema violations (extra fields, wrong types, missing fields).
|
||||
This satisfies Schemathesis 'Negative Data' checks.
|
||||
2. 409 Conflict: For semantic/structural INI content violations in valid strings.
|
||||
This satisfies Schemathesis 'Positive Data' checks.
|
||||
3. 422 Unprocessable: Default for other validation edge cases.
|
||||
"""
|
||||
errors = exc.errors()
|
||||
|
||||
# 1. Prioritize Structural Format Violations (Negative Data)
|
||||
# This catches: sending an object instead of a string, extra unknown properties, or empty-string length violations.
|
||||
is_structural_violation = any(
|
||||
err.get("type") in ("type_error", "extra_forbidden", "missing", "string_too_short", "string_type") or
|
||||
"must be a string" in err.get("msg", "") # Catch our validator's type check
|
||||
for err in errors
|
||||
)
|
||||
if is_structural_violation:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={"detail": "Bad Request: Schema structural violation (wrong type, extra fields, or invalid length)."},
|
||||
)
|
||||
|
||||
# 2. Targeted INI Error Rejections
|
||||
# We distinguishes between different failure modes for precise contract compliance.
|
||||
|
||||
# Empty INI content (Valid string but semantically empty)
|
||||
is_ini_empty = any("INI content is empty" in err.get("msg", "") for err in errors)
|
||||
if is_ini_empty:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={"detail": "Configuration conflict: INI content is empty."},
|
||||
)
|
||||
|
||||
# Invalid characters/syntax (Valid-length string but invalid INI syntax)
|
||||
# Mapping to 409 for Positive Data compliance.
|
||||
is_invalid_characters = any("Invalid INI format" in err.get("msg", "") for err in errors)
|
||||
if is_invalid_characters:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={"detail": "Configuration conflict: INI syntax or characters are invalid."},
|
||||
)
|
||||
|
||||
# Logical invalidity (Valid string, valid syntax, but missing required DECNET logic like sections)
|
||||
is_ini_invalid_logic = any("at least one section" in err.get("msg", "") for err in errors)
|
||||
if is_ini_invalid_logic:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={"detail": "Invalid INI config structure: No decky sections found."},
|
||||
)
|
||||
|
||||
# Developer Mode fallback
|
||||
if DECNET_DEVELOPER:
|
||||
from fastapi.exception_handlers import request_validation_exception_handler
|
||||
return await request_validation_exception_handler(request, exc)
|
||||
|
||||
# Production/Strict mode fallback: Sanitize remaining 422s
|
||||
message = "Invalid request parameters"
|
||||
if "/deckies/deploy" in request.url.path:
|
||||
message = "Invalid INI config"
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={"detail": message},
|
||||
)
|
||||
|
||||
@app.exception_handler(ValidationError)
|
||||
async def pydantic_validation_exception_handler(request: Request, exc: ValidationError) -> JSONResponse:
|
||||
"""
|
||||
Handle Pydantic errors that occur during manual model instantiation (e.g. state hydration).
|
||||
Prevents 500 errors when the database contains inconsistent or outdated schema data.
|
||||
"""
|
||||
log.error("Internal Pydantic validation error: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
"detail": "Internal data consistency error",
|
||||
"type": "internal_validation_error"
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, List
|
||||
from typing import Optional, Any, List, Annotated
|
||||
from sqlmodel import SQLModel, Field
|
||||
from pydantic import BaseModel, Field as PydanticField
|
||||
from pydantic import BaseModel, ConfigDict, Field as PydanticField, BeforeValidator
|
||||
from decnet.models import IniContent
|
||||
|
||||
def _normalize_null(v: Any) -> Any:
|
||||
if isinstance(v, str) and v.lower() in ("null", "undefined", ""):
|
||||
return None
|
||||
return v
|
||||
|
||||
NullableDatetime = Annotated[Optional[datetime], BeforeValidator(_normalize_null)]
|
||||
NullableString = Annotated[Optional[str], BeforeValidator(_normalize_null)]
|
||||
|
||||
# --- Database Tables (SQLModel) ---
|
||||
|
||||
@@ -75,7 +84,12 @@ class StatsResponse(BaseModel):
|
||||
deployed_deckies: int
|
||||
|
||||
class MutateIntervalRequest(BaseModel):
|
||||
mutate_interval: Optional[int] = None
|
||||
# Human-readable duration: <number><unit> where unit is m(inutes), d(ays), M(onths), y/Y(ears).
|
||||
# Minimum granularity is 1 minute. Seconds are not accepted.
|
||||
mutate_interval: Optional[str] = PydanticField(None, pattern=r"^[1-9]\d*[mdMyY]$")
|
||||
|
||||
class DeployIniRequest(BaseModel):
|
||||
ini_content: str = PydanticField(..., min_length=5, max_length=512 * 1024)
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
# This field now enforces strict INI structure during Pydantic initialization.
|
||||
# The OpenAPI schema correctly shows it as a required string.
|
||||
ini_content: IniContent = PydanticField(..., description="A valid INI formatted string")
|
||||
|
||||
@@ -355,7 +355,7 @@ class SQLiteRepository(BaseRepository):
|
||||
async with self.session_factory() as session:
|
||||
statement = select(State).where(State.key == key)
|
||||
result = await session.execute(statement)
|
||||
state = result.scalar_one_none()
|
||||
state = result.scalar_one_or_none()
|
||||
if state:
|
||||
return json.loads(state.value)
|
||||
return None
|
||||
@@ -365,7 +365,7 @@ class SQLiteRepository(BaseRepository):
|
||||
# Check if exists
|
||||
statement = select(State).where(State.key == key)
|
||||
result = await session.execute(statement)
|
||||
state = result.scalar_one_none()
|
||||
state = result.scalar_one_or_none()
|
||||
|
||||
value_json = json.dumps(value)
|
||||
if state:
|
||||
|
||||
@@ -52,7 +52,8 @@ async def get_stream_user(request: Request, token: Optional[str] = None) -> str:
|
||||
raise _credentials_exception
|
||||
|
||||
|
||||
async def get_current_user(request: Request) -> str:
|
||||
async def _decode_token(request: Request) -> str:
|
||||
"""Decode and validate a Bearer JWT, returning the user UUID."""
|
||||
_credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
@@ -76,3 +77,22 @@ async def get_current_user(request: Request) -> str:
|
||||
return _user_uuid
|
||||
except jwt.PyJWTError:
|
||||
raise _credentials_exception
|
||||
|
||||
|
||||
async def get_current_user(request: Request) -> str:
|
||||
"""Auth dependency — enforces must_change_password."""
|
||||
_user_uuid = await _decode_token(request)
|
||||
_user = await repo.get_user_by_uuid(_user_uuid)
|
||||
if _user and _user.get("must_change_password"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Password change required before accessing this resource",
|
||||
)
|
||||
return _user_uuid
|
||||
|
||||
|
||||
async def get_current_user_unchecked(request: Request) -> str:
|
||||
"""Auth dependency — skips must_change_password enforcement.
|
||||
Use only for endpoints that must remain reachable with the flag set (e.g. change-password).
|
||||
"""
|
||||
return await _decode_token(request)
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from decnet.web.auth import get_password_hash, verify_password
|
||||
from decnet.web.dependencies import get_current_user, repo
|
||||
from decnet.web.dependencies import get_current_user_unchecked, repo
|
||||
from decnet.web.db.models import ChangePasswordRequest
|
||||
|
||||
router = APIRouter()
|
||||
@@ -18,7 +18,7 @@ router = APIRouter()
|
||||
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_unchecked)) -> dict[str, str]:
|
||||
_user: Optional[dict[str, Any]] = await repo.get_user_by_uuid(current_user)
|
||||
if not _user or not verify_password(request.old_password, _user["password_hash"]):
|
||||
raise HTTPException(
|
||||
|
||||
@@ -12,14 +12,22 @@ router = APIRouter()
|
||||
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},)
|
||||
async def get_bounties(
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
offset: int = Query(0, ge=0, le=2147483647),
|
||||
bounty_type: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
current_user: str = Depends(get_current_user)
|
||||
) -> dict[str, Any]:
|
||||
"""Retrieve collected bounties (harvested credentials, payloads, etc.)."""
|
||||
_data = await repo.get_bounties(limit=limit, offset=offset, bounty_type=bounty_type, search=search)
|
||||
_total = await repo.get_total_bounties(bounty_type=bounty_type, search=search)
|
||||
def _norm(v: Optional[str]) -> Optional[str]:
|
||||
if v in (None, "null", "NULL", "undefined", ""):
|
||||
return None
|
||||
return v
|
||||
|
||||
bt = _norm(bounty_type)
|
||||
s = _norm(search)
|
||||
|
||||
_data = await repo.get_bounties(limit=limit, offset=offset, bounty_type=bt, search=s)
|
||||
_total = await repo.get_total_bounties(bounty_type=bt, search=s)
|
||||
return {
|
||||
"total": _total,
|
||||
"limit": limit,
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from decnet.config import DEFAULT_MUTATE_INTERVAL, DecnetConfig, _ROOT
|
||||
from decnet.config import DEFAULT_MUTATE_INTERVAL, DecnetConfig, _ROOT, log
|
||||
from decnet.engine import deploy as _deploy
|
||||
from decnet.ini_loader import load_ini_from_string
|
||||
from decnet.network import detect_interface, detect_subnet, get_host_ip
|
||||
@@ -16,15 +16,24 @@ router = APIRouter()
|
||||
@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"}}
|
||||
responses={
|
||||
400: {"description": "Bad Request (e.g. malformed JSON)"},
|
||||
401: {"description": "Could not validate credentials"},
|
||||
409: {"description": "Configuration conflict (e.g. invalid IP allocation or network mismatch)"},
|
||||
422: {"description": "Invalid INI config or schema validation error"},
|
||||
500: {"description": "Deployment failed"}
|
||||
}
|
||||
)
|
||||
async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||
from decnet.fleet import build_deckies_from_ini
|
||||
|
||||
try:
|
||||
ini = load_ini_from_string(req.ini_content)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse INI: {e}")
|
||||
except ValueError as e:
|
||||
log.debug("deploy: invalid INI structure: %s", e)
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
log.debug("deploy: processing configuration for %d deckies", len(ini.deckies))
|
||||
|
||||
state_dict = await repo.get_state("deployment")
|
||||
ingest_log_file = os.environ.get("DECNET_INGEST_LOG_FILE")
|
||||
@@ -34,12 +43,12 @@ async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(
|
||||
subnet_cidr = ini.subnet or config.subnet
|
||||
gateway = ini.gateway or config.gateway
|
||||
host_ip = get_host_ip(config.interface)
|
||||
randomize_services = False
|
||||
# Always sync config log_file with current API ingestion target
|
||||
if ingest_log_file:
|
||||
config.log_file = ingest_log_file
|
||||
else:
|
||||
# If no state exists, we need to infer network details
|
||||
# If no state exists, we need to infer network details from the INI or the host.
|
||||
try:
|
||||
iface = ini.interface or detect_interface()
|
||||
subnet_cidr, gateway = ini.subnet, ini.gateway
|
||||
if not subnet_cidr or not gateway:
|
||||
@@ -47,7 +56,12 @@ async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(
|
||||
subnet_cidr = subnet_cidr or detected_subnet
|
||||
gateway = gateway or detected_gateway
|
||||
host_ip = get_host_ip(iface)
|
||||
randomize_services = False
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Network configuration conflict: {e}. "
|
||||
"Add a [general] section with interface=, net=, and gw= to the INI."
|
||||
)
|
||||
config = DecnetConfig(
|
||||
mode="unihost",
|
||||
interface=iface,
|
||||
@@ -61,10 +75,11 @@ async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(
|
||||
|
||||
try:
|
||||
new_decky_configs = build_deckies_from_ini(
|
||||
ini, subnet_cidr, gateway, host_ip, randomize_services, cli_mutate_interval=None
|
||||
ini, subnet_cidr, gateway, host_ip, False, cli_mutate_interval=None
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
log.debug("deploy: build_deckies_from_ini rejected input: %s", e)
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
# Merge deckies
|
||||
existing_deckies_map = {d.name: d for d in config.deckies}
|
||||
|
||||
@@ -6,19 +6,27 @@ from decnet.web.db.models import MutateIntervalRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_UNIT_TO_MINUTES = {"m": 1, "d": 1440, "M": 43200, "y": 525600, "Y": 525600}
|
||||
|
||||
|
||||
def _parse_duration(s: str) -> int:
|
||||
"""Convert a duration string (e.g. '5d') to minutes."""
|
||||
value, unit = int(s[:-1]), s[-1]
|
||||
return value * _UNIT_TO_MINUTES[unit]
|
||||
|
||||
|
||||
@router.put("/deckies/{decky_name}/mutate-interval", tags=["Fleet Management"],
|
||||
responses={
|
||||
400: {"description": "No active deployment found"},
|
||||
400: {"description": "Bad Request (e.g. malformed JSON)"},
|
||||
401: {"description": "Could not validate credentials"},
|
||||
404: {"description": "Decky not found"},
|
||||
404: {"description": "No active deployment or decky not found"},
|
||||
422: {"description": "Validation error"}
|
||||
},
|
||||
)
|
||||
async def api_update_mutate_interval(decky_name: str, req: MutateIntervalRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||
state_dict = await repo.get_state("deployment")
|
||||
if not state_dict:
|
||||
raise HTTPException(status_code=400, detail="No active deployment")
|
||||
raise HTTPException(status_code=404, detail="No active deployment")
|
||||
|
||||
config = DecnetConfig(**state_dict["config"])
|
||||
compose_path = state_dict["compose_path"]
|
||||
@@ -27,7 +35,7 @@ async def api_update_mutate_interval(decky_name: str, req: MutateIntervalRequest
|
||||
if not decky:
|
||||
raise HTTPException(status_code=404, detail="Decky not found")
|
||||
|
||||
decky.mutate_interval = req.mutate_interval
|
||||
decky.mutate_interval = _parse_duration(req.mutate_interval) if req.mutate_interval else None
|
||||
|
||||
await repo.set_state("deployment", {"config": config.model_dump(), "compose_path": compose_path})
|
||||
return {"message": "Mutation interval updated"}
|
||||
|
||||
@@ -11,9 +11,18 @@ router = APIRouter()
|
||||
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},)
|
||||
async def get_logs_histogram(
|
||||
search: Optional[str] = None,
|
||||
start_time: Optional[str] = None,
|
||||
end_time: Optional[str] = None,
|
||||
start_time: Optional[str] = Query(None),
|
||||
end_time: Optional[str] = Query(None),
|
||||
interval_minutes: int = Query(15, ge=1),
|
||||
current_user: str = Depends(get_current_user)
|
||||
) -> list[dict[str, Any]]:
|
||||
return await repo.get_log_histogram(search=search, start_time=start_time, end_time=end_time, interval_minutes=interval_minutes)
|
||||
def _norm(v: Optional[str]) -> Optional[str]:
|
||||
if v in (None, "null", "NULL", "undefined", ""):
|
||||
return None
|
||||
return v
|
||||
|
||||
s = _norm(search)
|
||||
st = _norm(start_time)
|
||||
et = _norm(end_time)
|
||||
|
||||
return await repo.get_log_histogram(search=s, start_time=st, end_time=et, interval_minutes=interval_minutes)
|
||||
|
||||
@@ -7,21 +7,28 @@ from decnet.web.db.models import LogsResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_DATETIME_RE = r"^(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})?$"
|
||||
|
||||
|
||||
@router.get("/logs", response_model=LogsResponse, tags=["Logs"],
|
||||
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}})
|
||||
async def get_logs(
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
offset: int = Query(0, ge=0, le=2147483647),
|
||||
search: Optional[str] = Query(None, max_length=512),
|
||||
start_time: Optional[str] = Query(None, pattern=_DATETIME_RE),
|
||||
end_time: Optional[str] = Query(None, pattern=_DATETIME_RE),
|
||||
start_time: Optional[str] = Query(None),
|
||||
end_time: Optional[str] = Query(None),
|
||||
current_user: str = Depends(get_current_user)
|
||||
) -> dict[str, Any]:
|
||||
_logs: list[dict[str, Any]] = await repo.get_logs(limit=limit, offset=offset, search=search, start_time=start_time, end_time=end_time)
|
||||
_total: int = await repo.get_total_logs(search=search, start_time=start_time, end_time=end_time)
|
||||
def _norm(v: Optional[str]) -> Optional[str]:
|
||||
if v in (None, "null", "NULL", "undefined", ""):
|
||||
return None
|
||||
return v
|
||||
|
||||
s = _norm(search)
|
||||
st = _norm(start_time)
|
||||
et = _norm(end_time)
|
||||
|
||||
_logs: list[dict[str, Any]] = await repo.get_logs(limit=limit, offset=offset, search=s, start_time=st, end_time=et)
|
||||
_total: int = await repo.get_total_logs(search=s, start_time=st, end_time=et)
|
||||
return {
|
||||
"total": _total,
|
||||
"limit": limit,
|
||||
|
||||
@@ -61,6 +61,7 @@ markers = [
|
||||
filterwarnings = [
|
||||
"ignore::pytest.PytestUnhandledThreadExceptionWarning",
|
||||
"ignore::DeprecationWarning",
|
||||
"ignore::RuntimeWarning",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
|
||||
@@ -66,7 +66,15 @@ async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
||||
@pytest.fixture
|
||||
async def auth_token(client: httpx.AsyncClient) -> str:
|
||||
resp = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
||||
return resp.json()["access_token"]
|
||||
token = resp.json()["access_token"]
|
||||
# Clear must_change_password so this token passes server-side enforcement on all other endpoints.
|
||||
await client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"old_password": DECNET_ADMIN_PASSWORD, "new_password": DECNET_ADMIN_PASSWORD},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
resp2 = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
||||
return resp2.json()["access_token"]
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_state_file(monkeypatch, tmp_path) -> Path:
|
||||
|
||||
@@ -29,7 +29,7 @@ class TestMutateInterval:
|
||||
async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient):
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
json={"mutate_interval": 60},
|
||||
json={"mutate_interval": "60m"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
@@ -40,9 +40,9 @@ class TestMutateInterval:
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 60},
|
||||
json={"mutate_interval": "60m"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decky_not_found(self, client: httpx.AsyncClient, auth_token: str):
|
||||
@@ -52,7 +52,7 @@ class TestMutateInterval:
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/nonexistent/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 60},
|
||||
json={"mutate_interval": "60m"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
@@ -64,11 +64,14 @@ class TestMutateInterval:
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 120},
|
||||
json={"mutate_interval": "120m"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "Mutation interval updated"
|
||||
mock_repo.set_state.assert_awaited_once()
|
||||
saved = mock_repo.set_state.call_args[0][1]
|
||||
saved_interval = saved["config"]["deckies"][0]["mutate_interval"]
|
||||
assert saved_interval == 120
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_interval_removes_mutation(self, client: httpx.AsyncClient, auth_token: str):
|
||||
@@ -82,3 +85,47 @@ class TestMutateInterval:
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
mock_repo.set_state.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_format_returns_422(self, client: httpx.AsyncClient, auth_token: str):
|
||||
"""Seconds ('s') and raw integers are not accepted.
|
||||
Note: The API returns 400 for structural violations (wrong type) and 422 for semantic/pattern violations.
|
||||
"""
|
||||
cases = [
|
||||
("1s", 422),
|
||||
("60", 422),
|
||||
(60, 400),
|
||||
(False, 400),
|
||||
("1h", 422),
|
||||
]
|
||||
for bad, expected_status in cases:
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": bad},
|
||||
)
|
||||
assert resp.status_code == expected_status, f"Expected {expected_status} for {bad!r}, got {resp.status_code}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duration_units_stored_as_minutes(self, client: httpx.AsyncClient, auth_token: str):
|
||||
"""Each unit suffix is parsed to the correct number of minutes."""
|
||||
cases = [
|
||||
("2m", 2),
|
||||
("1d", 1440),
|
||||
("1M", 43200),
|
||||
("1y", 525600),
|
||||
("1Y", 525600),
|
||||
]
|
||||
for duration, expected_minutes in cases:
|
||||
config = _config()
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||
mock_repo.get_state.return_value = {"config": config.model_dump(), "compose_path": "c.yml"}
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": duration},
|
||||
)
|
||||
assert resp.status_code == 200, f"Expected 200 for {duration!r}"
|
||||
saved = mock_repo.set_state.call_args[0][1]
|
||||
saved_interval = saved["config"]["deckies"][0]["mutate_interval"]
|
||||
assert saved_interval == expected_minutes, f"{duration!r} → expected {expected_minutes} min, got {saved_interval}"
|
||||
|
||||
@@ -21,10 +21,17 @@ import sys
|
||||
import atexit
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Bind to port 0, let the OS pick a free port, return it."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
# Configuration for the automated live server
|
||||
LIVE_PORT = 8008
|
||||
LIVE_PORT = _free_port()
|
||||
LIVE_SERVER_URL = f"http://127.0.0.1:{LIVE_PORT}"
|
||||
TEST_SECRET = "test-secret-for-automated-fuzzing"
|
||||
|
||||
@@ -40,6 +47,10 @@ def before_call(context, case, *args):
|
||||
# Logged-in admin for all requests
|
||||
case.headers = case.headers or {}
|
||||
case.headers["Authorization"] = f"Bearer {TEST_TOKEN}"
|
||||
# Force SSE stream to close after the initial snapshot so the test doesn't hang
|
||||
if case.path and case.path.endswith("/stream"):
|
||||
case.query = case.query or {}
|
||||
case.query["maxOutput"] = 0
|
||||
|
||||
def wait_for_port(port, timeout=10):
|
||||
start_time = time.time()
|
||||
@@ -61,15 +72,21 @@ def start_automated_server():
|
||||
env["DECNET_CONTRACT_TEST"] = "true"
|
||||
env["DECNET_JWT_SECRET"] = TEST_SECRET
|
||||
|
||||
log_dir = Path(__file__).parent.parent.parent / "logs"
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
log_file = open(log_dir / f"fuzz_server_{LIVE_PORT}_{ts}.log", "w")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[uvicorn_path, "decnet.web.api:app", "--host", "127.0.0.1", "--port", str(LIVE_PORT), "--log-level", "error"],
|
||||
[uvicorn_path, "decnet.web.api:app", "--host", "127.0.0.1", "--port", str(LIVE_PORT), "--log-level", "info"],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
)
|
||||
|
||||
# Register cleanup
|
||||
atexit.register(proc.terminate)
|
||||
atexit.register(log_file.close)
|
||||
|
||||
if not wait_for_port(LIVE_PORT):
|
||||
proc.terminate()
|
||||
@@ -87,6 +104,4 @@ schema = st.openapi.from_url(f"{LIVE_SERVER_URL}/openapi.json")
|
||||
@st.pytest.parametrize(api=schema)
|
||||
@settings(max_examples=3000, deadline=None, verbosity=Verbosity.debug)
|
||||
def test_schema_compliance(case):
|
||||
#print(f"\n[Fuzzing] {case.method} {case.path} with query={case.query}")
|
||||
case.call_and_validate()
|
||||
#print(f" └─ Success")
|
||||
|
||||
@@ -32,7 +32,7 @@ class TestDeckyConfig:
|
||||
assert d.name == "decky-01"
|
||||
|
||||
def test_empty_services_raises(self):
|
||||
with pytest.raises(Exception, match="at least one service"):
|
||||
with pytest.raises(Exception, match="at least 1 item"):
|
||||
DeckyConfig(**self._base(services=[]))
|
||||
|
||||
def test_multiple_services_ok(self):
|
||||
|
||||
@@ -150,11 +150,11 @@ class TestBuildDeckiesFromIni:
|
||||
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, True)
|
||||
assert len(deckies[0].services) >= 1
|
||||
|
||||
def test_no_services_no_arch_no_randomize_raises(self):
|
||||
def test_no_services_no_arch_auto_randomizes(self):
|
||||
spec = DeckySpec(name="test-1")
|
||||
ini = self._make_ini([spec])
|
||||
with pytest.raises(ValueError, match="has no services"):
|
||||
build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
|
||||
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
|
||||
assert len(deckies[0].services) >= 1
|
||||
|
||||
def test_unknown_service_raises(self):
|
||||
spec = DeckySpec(name="test-1", services=["nonexistent_svc_xyz"])
|
||||
|
||||
Reference in New Issue
Block a user