Phase 4: backups w/ volumes, notifications, settings & users, audit page (0.4.0)
- Backup/restore: per-stack tar.gz incl. named-volume snapshots (helper container), upload restore with rename/overwrite/conflict detection. - Notifications: ntfy/Discord/Slack/Gotify/generic webhooks, per-event subscriptions; wired into the update checker and stack lifecycle. - Settings page: update-check interval, webhook CRUD + test, user management (with last-admin safeguards). - Audit log page (searchable, paginated). - Mobile-responsive sidebar/layout. Multi-host agents and remote backup destinations (SFTP/S3) deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
22d9864436
commit
8d19b09abd
+111
-1
@@ -5,7 +5,7 @@ import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlmodel import Session
|
||||
from sqlmodel import Session, select
|
||||
|
||||
import auth as auth_mod
|
||||
from database import get_session
|
||||
@@ -16,6 +16,7 @@ from models.user import (
|
||||
User,
|
||||
UserCreate,
|
||||
UserRead,
|
||||
UserUpdate,
|
||||
)
|
||||
from services import audit_service
|
||||
|
||||
@@ -109,3 +110,112 @@ def refresh(
|
||||
@router.get("/me", response_model=UserRead)
|
||||
def me(user: User = Depends(auth_mod.get_current_user)) -> User:
|
||||
return user
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# User management (admin only)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserRead])
|
||||
def list_users(
|
||||
session: Session = Depends(get_session),
|
||||
_admin: User = Depends(auth_mod.require_admin),
|
||||
) -> list[User]:
|
||||
return session.exec(select(User).order_by(User.id)).all()
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserRead, status_code=201)
|
||||
def create_user(
|
||||
body: UserCreate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(auth_mod.require_admin),
|
||||
) -> User:
|
||||
if not body.username.strip() or not body.password:
|
||||
raise HTTPException(status_code=400, detail="Username and password required")
|
||||
if auth_mod.get_user(session, body.username):
|
||||
raise HTTPException(status_code=409, detail="Username already exists")
|
||||
role = body.role if body.role in ("admin", "user") else "user"
|
||||
user = User(
|
||||
username=body.username,
|
||||
hashed_password=auth_mod.hash_password(body.password),
|
||||
role=role,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
audit_service.record(
|
||||
session, user=admin.username, action="user.create", target=user.username,
|
||||
detail=f"role={role}", ip=_ip(request),
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserRead)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
body: UserUpdate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(auth_mod.require_admin),
|
||||
) -> User:
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
# Guard against locking yourself out / demoting the last admin.
|
||||
demoting = (body.role is not None and body.role != "admin") or body.is_active is False
|
||||
if user.role == "admin" and demoting:
|
||||
other_admins = session.exec(
|
||||
select(User).where(User.role == "admin", User.is_active == True, User.id != user_id) # noqa: E712
|
||||
).first()
|
||||
if not other_admins:
|
||||
raise HTTPException(status_code=400, detail="Cannot demote or disable the last active admin")
|
||||
if body.password:
|
||||
user.hashed_password = auth_mod.hash_password(body.password)
|
||||
if body.role is not None:
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
user.role = body.role
|
||||
if body.is_active is not None:
|
||||
user.is_active = body.is_active
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
audit_service.record(
|
||||
session, user=admin.username, action="user.update", target=user.username,
|
||||
ip=_ip(request),
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(auth_mod.require_admin),
|
||||
) -> dict:
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||
if user.role == "admin":
|
||||
other_admins = session.exec(
|
||||
select(User).where(User.role == "admin", User.is_active == True, User.id != user_id) # noqa: E712
|
||||
).first()
|
||||
if not other_admins:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the last active admin")
|
||||
username = user.username
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
audit_service.record(
|
||||
session, user=admin.username, action="user.delete", target=username,
|
||||
ip=_ip(request),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Stack backup (incl. volumes) and restore."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from auth import require_admin
|
||||
from database import get_session
|
||||
from models.stack import Stack
|
||||
from models.user import User
|
||||
from services import audit_service, backup_service, compose_service
|
||||
|
||||
router = APIRouter(prefix="/api/stacks", tags=["backups"])
|
||||
|
||||
|
||||
def _ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
@router.get("/{stack_id}/backup")
|
||||
async def backup_stack(
|
||||
stack_id: str,
|
||||
request: Request,
|
||||
include_volumes: bool = Query(True),
|
||||
stop_first: bool = Query(True),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
stack = session.get(Stack, stack_id)
|
||||
if not stack:
|
||||
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
||||
try:
|
||||
path = await backup_service.create_backup(
|
||||
stack_id, stack.name, include_volumes=include_volumes, stop_first=stop_first,
|
||||
)
|
||||
except backup_service.BackupError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
audit_service.record(
|
||||
session, user=user.username, action="stack.backup", target=stack_id,
|
||||
detail=f"volumes={include_volumes}", ip=_ip(request),
|
||||
)
|
||||
date = compose_service.now().strftime("%Y%m%d-%H%M%S")
|
||||
suffix = "full" if include_volumes else "config"
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/gzip",
|
||||
filename=f"backup-{stack_id}-{suffix}-{date}.tar.gz",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_stack(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
target_id: str | None = Form(None),
|
||||
overwrite: bool = Form(False),
|
||||
restore_volumes: bool = Form(True),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
|
||||
try:
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
tmp.write(chunk)
|
||||
tmp.close()
|
||||
|
||||
target = compose_service.slugify(target_id) if target_id else None
|
||||
try:
|
||||
result = backup_service.restore_backup(
|
||||
tmp.name,
|
||||
target_id=target,
|
||||
overwrite=overwrite,
|
||||
restore_volumes=restore_volumes,
|
||||
)
|
||||
except backup_service.BackupError as exc:
|
||||
# 409 for the "already exists" conflict, 400 for malformed backups.
|
||||
code = 409 if "already exists" in str(exc) else 400
|
||||
raise HTTPException(status_code=code, detail=str(exc)) from exc
|
||||
|
||||
stack_id = result["stack_id"]
|
||||
stack = session.get(Stack, stack_id)
|
||||
if not stack:
|
||||
session.add(Stack(id=stack_id, name=result.get("name", stack_id)))
|
||||
session.commit()
|
||||
audit_service.record(
|
||||
session, user=user.username, action="stack.restore", target=stack_id,
|
||||
detail=f"volumes={result['volumes_restored']}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
if os.path.exists(tmp.name):
|
||||
os.unlink(tmp.name)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Application settings: update interval + notification webhooks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from auth import get_current_user, require_admin
|
||||
from config import settings as env_settings
|
||||
from database import get_session
|
||||
from models.setting import (
|
||||
ALL_EVENTS,
|
||||
WEBHOOK_TYPES,
|
||||
SettingsRead,
|
||||
SettingsUpdate,
|
||||
Webhook,
|
||||
WebhookCreate,
|
||||
WebhookRead,
|
||||
WebhookUpdate,
|
||||
)
|
||||
from models.user import User
|
||||
from services import audit_service, notify_service, settings_service
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
def _ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _to_read(wh: Webhook) -> WebhookRead:
|
||||
return WebhookRead(
|
||||
id=wh.id,
|
||||
name=wh.name,
|
||||
url=wh.url,
|
||||
type=wh.type,
|
||||
events=[e.strip() for e in (wh.events or "").split(",") if e.strip()],
|
||||
enabled=wh.enabled,
|
||||
created_at=wh.created_at,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# General settings
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsRead)
|
||||
def get_settings(
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> SettingsRead:
|
||||
return SettingsRead(
|
||||
update_check_interval_minutes=settings_service.get_update_interval(session),
|
||||
env_webhook_count=len(env_settings.NOTIFY_WEBHOOKS),
|
||||
available_events=ALL_EVENTS,
|
||||
webhook_types=WEBHOOK_TYPES,
|
||||
)
|
||||
|
||||
|
||||
@router.put("", response_model=SettingsRead)
|
||||
def update_settings(
|
||||
body: SettingsUpdate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> SettingsRead:
|
||||
if body.update_check_interval_minutes is not None:
|
||||
if body.update_check_interval_minutes < 5:
|
||||
raise HTTPException(status_code=400, detail="Interval must be at least 5 minutes")
|
||||
settings_service.set_value(
|
||||
session,
|
||||
settings_service.KEY_UPDATE_INTERVAL,
|
||||
body.update_check_interval_minutes,
|
||||
)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="settings.update",
|
||||
target="update_interval", detail=str(body.update_check_interval_minutes),
|
||||
ip=_ip(request),
|
||||
)
|
||||
return get_settings(session, user)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Webhooks
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _validate(wtype: str, events: list[str]) -> None:
|
||||
if wtype not in WEBHOOK_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown webhook type '{wtype}'")
|
||||
bad = [e for e in events if e not in ALL_EVENTS]
|
||||
if bad:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown event(s): {', '.join(bad)}")
|
||||
|
||||
|
||||
@router.get("/webhooks", response_model=list[WebhookRead])
|
||||
def list_webhooks(
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(require_admin),
|
||||
) -> list[WebhookRead]:
|
||||
rows = session.exec(select(Webhook).order_by(Webhook.id)).all()
|
||||
return [_to_read(w) for w in rows]
|
||||
|
||||
|
||||
@router.post("/webhooks", response_model=WebhookRead, status_code=201)
|
||||
def create_webhook(
|
||||
body: WebhookCreate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> WebhookRead:
|
||||
_validate(body.type, body.events)
|
||||
wh = Webhook(
|
||||
name=body.name,
|
||||
url=body.url,
|
||||
type=body.type,
|
||||
events=",".join(body.events),
|
||||
enabled=body.enabled,
|
||||
)
|
||||
session.add(wh)
|
||||
session.commit()
|
||||
session.refresh(wh)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="webhook.create", target=str(wh.id),
|
||||
detail=body.name, ip=_ip(request),
|
||||
)
|
||||
return _to_read(wh)
|
||||
|
||||
|
||||
@router.put("/webhooks/{webhook_id}", response_model=WebhookRead)
|
||||
def update_webhook(
|
||||
webhook_id: int,
|
||||
body: WebhookUpdate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> WebhookRead:
|
||||
wh = session.get(Webhook, webhook_id)
|
||||
if not wh:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
if body.type is not None or body.events is not None:
|
||||
_validate(body.type or wh.type, body.events if body.events is not None else _to_read(wh).events)
|
||||
if body.name is not None:
|
||||
wh.name = body.name
|
||||
if body.url is not None:
|
||||
wh.url = body.url
|
||||
if body.type is not None:
|
||||
wh.type = body.type
|
||||
if body.events is not None:
|
||||
wh.events = ",".join(body.events)
|
||||
if body.enabled is not None:
|
||||
wh.enabled = body.enabled
|
||||
session.add(wh)
|
||||
session.commit()
|
||||
session.refresh(wh)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="webhook.update", target=str(wh.id),
|
||||
ip=_ip(request),
|
||||
)
|
||||
return _to_read(wh)
|
||||
|
||||
|
||||
@router.delete("/webhooks/{webhook_id}")
|
||||
def delete_webhook(
|
||||
webhook_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
wh = session.get(Webhook, webhook_id)
|
||||
if not wh:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
session.delete(wh)
|
||||
session.commit()
|
||||
audit_service.record(
|
||||
session, user=user.username, action="webhook.delete", target=str(webhook_id),
|
||||
ip=_ip(request),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/test")
|
||||
async def test_webhook(
|
||||
webhook_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
wh = session.get(Webhook, webhook_id)
|
||||
if not wh:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
ok = await notify_service.test_webhook(wh.type, wh.url)
|
||||
return {"ok": ok}
|
||||
@@ -19,8 +19,14 @@ from models.stack import (
|
||||
StackCreate,
|
||||
StackUpdate,
|
||||
)
|
||||
from models.setting import (
|
||||
EVENT_PULL_FAILED,
|
||||
EVENT_STACK_ERROR,
|
||||
EVENT_STACK_START,
|
||||
EVENT_STACK_STOP,
|
||||
)
|
||||
from models.user import User
|
||||
from services import audit_service, compose_service
|
||||
from services import audit_service, compose_service, notify_service
|
||||
from services.convert_service import convert_docker_run
|
||||
|
||||
router = APIRouter(prefix="/api/stacks", tags=["stacks"])
|
||||
@@ -220,6 +226,35 @@ def clone_stack(
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
# which lifecycle actions emit a notification on success
|
||||
_START_ACTIONS = {"start", "restart", "update"}
|
||||
_STOP_ACTIONS = {"stop", "down"}
|
||||
|
||||
|
||||
async def _notify_lifecycle(action_name: str, stack_id: str, ok: bool, detail: str, session) -> None:
|
||||
try:
|
||||
if not ok:
|
||||
event = EVENT_PULL_FAILED if action_name in ("pull", "update") else EVENT_STACK_ERROR
|
||||
await notify_service.notify(
|
||||
event,
|
||||
f"Stack '{stack_id}' {action_name} failed",
|
||||
detail or f"compose {action_name} returned a non-zero exit code.",
|
||||
session,
|
||||
)
|
||||
elif action_name in _START_ACTIONS:
|
||||
await notify_service.notify(
|
||||
EVENT_STACK_START, f"Stack '{stack_id}' started",
|
||||
f"compose {action_name} completed successfully.", session,
|
||||
)
|
||||
elif action_name in _STOP_ACTIONS:
|
||||
await notify_service.notify(
|
||||
EVENT_STACK_STOP, f"Stack '{stack_id}' stopped",
|
||||
f"compose {action_name} completed successfully.", session,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - notifications are best-effort
|
||||
pass
|
||||
|
||||
|
||||
async def _lifecycle(action_fn, action_name, stack_id, request, session, user):
|
||||
_get_stack_or_404(session, stack_id)
|
||||
result = await action_fn(stack_id)
|
||||
@@ -227,12 +262,15 @@ async def _lifecycle(action_fn, action_name, stack_id, request, session, user):
|
||||
session, user=user.username, action=f"stack.{action_name}", target=stack_id,
|
||||
detail=f"rc={result.get('returncode')}", ip=_client_ip(request),
|
||||
)
|
||||
if result.get("returncode") not in (0, None):
|
||||
ok = result.get("returncode") in (0, None)
|
||||
stderr = result.get("stderr", "").strip()[-2000:]
|
||||
await _notify_lifecycle(action_name, stack_id, ok, stderr, session)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"error": f"compose {action_name} failed",
|
||||
"detail": result.get("stderr", "").strip()[-2000:],
|
||||
"detail": stderr,
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user