feat: action config service, router endpoints, and full test coverage (Tasks 3.1, 3.2, 3.4)

- ActionConfig extended with active, used_by_jails, source_file, has_local_override
- New models: ActionListResponse, ActionUpdateRequest, ActionCreateRequest, AssignActionRequest
- New service functions: list_actions, get_action, update_action, create_action, delete_action, assign_action_to_jail, remove_action_from_jail
- New error classes: ActionNotFoundError, ActionAlreadyExistsError, ActionReadonlyError, ActionNameError
- New router endpoints: GET/PUT/POST/DELETE /api/config/actions, POST/DELETE /api/config/jails/{name}/action
- Service + router tests: 290 tests passing, mypy strict clean, ruff clean
This commit is contained in:
2026-03-13 19:12:31 +01:00
parent 2f60b0915e
commit f7cc130432
6 changed files with 2866 additions and 3 deletions

View File

@@ -508,6 +508,33 @@ class ActionConfig(BaseModel):
default_factory=dict,
description="Runtime parameters that can be overridden per jail.",
)
# Active-status fields — populated by config_file_service.list_actions /
# get_action; default to safe "inactive" values when not computed.
active: bool = Field(
default=False,
description=(
"``True`` when this action is referenced by at least one currently "
"enabled (running) jail."
),
)
used_by_jails: list[str] = Field(
default_factory=list,
description=(
"Names of currently enabled jails that reference this action. "
"Empty when ``active`` is ``False``."
),
)
source_file: str = Field(
default="",
description="Absolute path to the ``.conf`` source file for this action.",
)
has_local_override: bool = Field(
default=False,
description=(
"``True`` when a ``.local`` override file exists alongside the "
"base ``.conf`` file."
),
)
class ActionConfigUpdate(BaseModel):
@@ -527,6 +554,110 @@ class ActionConfigUpdate(BaseModel):
init_vars: dict[str, str] | None = Field(default=None)
class ActionListResponse(BaseModel):
"""Response for ``GET /api/config/actions``."""
model_config = ConfigDict(strict=True)
actions: list[ActionConfig] = Field(
default_factory=list,
description=(
"All discovered actions, each annotated with active/inactive status "
"and the jails that reference them."
),
)
total: int = Field(..., ge=0, description="Total number of actions found.")
class ActionUpdateRequest(BaseModel):
"""Payload for ``PUT /api/config/actions/{name}``.
Accepts only the user-editable ``[Definition]`` lifecycle fields and
``[Init]`` parameters. Fields left as ``None`` are not changed.
"""
model_config = ConfigDict(strict=True)
actionstart: str | None = Field(
default=None,
description="Updated ``actionstart`` command. ``None`` = keep existing.",
)
actionstop: str | None = Field(
default=None,
description="Updated ``actionstop`` command. ``None`` = keep existing.",
)
actioncheck: str | None = Field(
default=None,
description="Updated ``actioncheck`` command. ``None`` = keep existing.",
)
actionban: str | None = Field(
default=None,
description="Updated ``actionban`` command. ``None`` = keep existing.",
)
actionunban: str | None = Field(
default=None,
description="Updated ``actionunban`` command. ``None`` = keep existing.",
)
actionflush: str | None = Field(
default=None,
description="Updated ``actionflush`` command. ``None`` = keep existing.",
)
definition_vars: dict[str, str] | None = Field(
default=None,
description="Additional ``[Definition]`` variables to set. ``None`` = keep existing.",
)
init_vars: dict[str, str] | None = Field(
default=None,
description="``[Init]`` parameters to set. ``None`` = keep existing.",
)
class ActionCreateRequest(BaseModel):
"""Payload for ``POST /api/config/actions``.
Creates a new user-defined action at ``action.d/{name}.local``.
"""
model_config = ConfigDict(strict=True)
name: str = Field(
...,
description="Action base name (e.g. ``my-custom-action``). Must not already exist.",
)
actionstart: str | None = Field(default=None, description="Command to execute at jail start.")
actionstop: str | None = Field(default=None, description="Command to execute at jail stop.")
actioncheck: str | None = Field(default=None, description="Command to execute before each ban.")
actionban: str | None = Field(default=None, description="Command to execute to ban an IP.")
actionunban: str | None = Field(default=None, description="Command to execute to unban an IP.")
actionflush: str | None = Field(default=None, description="Command to flush all bans on shutdown.")
definition_vars: dict[str, str] = Field(
default_factory=dict,
description="Additional ``[Definition]`` variables.",
)
init_vars: dict[str, str] = Field(
default_factory=dict,
description="``[Init]`` runtime parameters.",
)
class AssignActionRequest(BaseModel):
"""Payload for ``POST /api/config/jails/{jail_name}/action``."""
model_config = ConfigDict(strict=True)
action_name: str = Field(
...,
description="Action base name to add to the jail (e.g. ``iptables-multiport``).",
)
params: dict[str, str] = Field(
default_factory=dict,
description=(
"Optional per-jail action parameters written as "
"``action_name[key=value, ...]`` in the jail config."
),
)
# ---------------------------------------------------------------------------
# Jail file config models (Task 6.1)
# ---------------------------------------------------------------------------