- 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>
83 lines
2.2 KiB
Python
83 lines
2.2 KiB
Python
"""Application settings, loaded from environment variables."""
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
from functools import lru_cache
|
|
from typing import Annotated
|
|
|
|
from pydantic import field_validator
|
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
|
|
# Paths
|
|
STACKS_DIR: str = "/opt/stackpilot/stacks"
|
|
DATA_DIR: str = "/opt/stackpilot/data"
|
|
|
|
# Security
|
|
SECRET_KEY: str = "" # Auto-generated if empty (dev only); set in prod.
|
|
ALGORITHM: str = "HS256"
|
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
|
|
|
# Update checker
|
|
UPDATE_CHECK_INTERVAL_MINUTES: int = 60
|
|
|
|
# Notifications (webhook URLs)
|
|
NOTIFY_WEBHOOKS: Annotated[list[str], NoDecode] = []
|
|
|
|
# Docker
|
|
DOCKER_SOCKET: str = "/var/run/docker.sock"
|
|
HOST_PROC_PATH: str = "/host_proc"
|
|
|
|
# Throwaway image used to read/write named-volume contents during backup.
|
|
BACKUP_HELPER_IMAGE: str = "alpine:latest"
|
|
|
|
# Host browser sandbox roots
|
|
ALLOWED_BROWSE_ROOTS: Annotated[list[str], NoDecode] = [
|
|
"/", "/mnt", "/media", "/srv", "/opt",
|
|
]
|
|
HOST_ROOT_PREFIX: str = "" # e.g. "/host_root" when host / is bind-mounted
|
|
|
|
# CORS
|
|
CORS_ORIGINS: Annotated[list[str], NoDecode] = [
|
|
"http://localhost:5009", "http://localhost:5173",
|
|
]
|
|
|
|
# Server
|
|
PORT: int = 5008
|
|
|
|
@field_validator("SECRET_KEY", mode="after")
|
|
@classmethod
|
|
def _ensure_secret(cls, v: str) -> str:
|
|
return v or secrets.token_urlsafe(48)
|
|
|
|
@field_validator(
|
|
"NOTIFY_WEBHOOKS", "ALLOWED_BROWSE_ROOTS", "CORS_ORIGINS", mode="before"
|
|
)
|
|
@classmethod
|
|
def _split_csv(cls, v):
|
|
if isinstance(v, str):
|
|
v = v.strip()
|
|
if not v:
|
|
return []
|
|
if v.startswith("["): # tolerate a JSON list too
|
|
import json
|
|
|
|
try:
|
|
return json.loads(v)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return [item.strip() for item in v.split(",") if item.strip()]
|
|
return v
|
|
|
|
|
|
@lru_cache
|
|
def get_settings() -> Settings:
|
|
return Settings()
|
|
|
|
|
|
settings = get_settings()
|