Files
stackpilot/backend/config.py
T
menzeljandClaude Opus 5 052a85067b
CI / check (push) Successful in 13m48s
CI / build-and-push (push) Successful in 4m5s
Stop handing StackPilot's own settings to the stacks it deploys (0.66.3)
docker compose ran with StackPilot's whole environment, and compose
resolves ${VAR} from the calling environment before a stack's own .env.
A stack whose compose file says SECRET_KEY: ${SECRET_KEY} therefore ran
with StackPilot's SECRET_KEY instead of the one in its .env — and with
its own again whenever it was started from a shell. AnsiblePilot hit
exactly this: credentials saved under one key became unreadable under
the other, twice.

config.child_env() is os.environ minus every StackPilot setting
(SECRET_KEY, DATA_DIR, STACKS_DIR, PORT, ...); the docker CLI and git
subprocesses now get that instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 13:49:52 +02:00

136 lines
4.8 KiB
Python

"""Application settings, loaded from environment variables."""
from __future__ import annotations
import os
import secrets
import stat
from functools import lru_cache
from typing import Annotated
from pydantic import ValidationInfo, 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
# Auto-generated and persisted to ${DATA_DIR}/secret_key when left empty.
SECRET_KEY: str = ""
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"
# Vulnerability scanner, run as a throwaway container (services/scan_service).
# Pinning a tag here is the supported way to hold a scanner version still.
SCANNER_IMAGE: str = "aquasec/trivy:latest"
# Host browser sandbox roots. Deliberately does NOT contain "/": that entry
# makes _is_allowed() wave through every path, i.e. it switches the sandbox
# off. Add it back explicitly if you really want the whole filesystem.
ALLOWED_BROWSE_ROOTS: Annotated[list[str], NoDecode] = [
"/mnt", "/media", "/srv", "/opt", "/home",
]
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, info: ValidationInfo) -> str:
"""Return the configured key, or a persisted auto-generated one.
Generating a fresh key per process (the old behaviour) silently
invalidated every session on each restart, and would now also make the
encrypted backup-destination credentials undecryptable. So the
generated key is written next to the database instead, mode 0600, and
read back on the next start. An explicitly configured SECRET_KEY always
wins and nothing is written.
"""
if v:
return v
data_dir = info.data.get("DATA_DIR") or "/opt/stackpilot/data"
key_file = os.path.join(data_dir, "secret_key")
try:
with open(key_file, "r", encoding="utf-8") as fh:
if existing := fh.read().strip():
return existing
except OSError:
pass
generated = secrets.token_urlsafe(48)
try:
os.makedirs(data_dir, exist_ok=True)
with open(key_file, "w", encoding="utf-8") as fh:
fh.write(generated + "\n")
os.chmod(key_file, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
# Read-only data dir: fall back to the old per-process behaviour
# rather than refusing to boot. Sessions won't survive a restart.
pass
return generated
@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()
def child_env() -> dict[str, str]:
"""os.environ without StackPilot's own settings, for docker/git subprocesses.
Compose resolves ``${VAR}`` from the calling environment *before* the
stack's own ``.env``, so everything StackPilot was configured with leaked
into every stack it deployed. A stack whose compose file says
``SECRET_KEY: ${SECRET_KEY}`` silently ran with StackPilot's key instead of
its own — and flipped back whenever it was started from a shell, orphaning
whatever it had encrypted in between. Names like SECRET_KEY, PORT or
DATA_DIR are far too common to share.
"""
own = {name.upper() for name in Settings.model_fields}
return {k: v for k, v in os.environ.items() if k.upper() not in own}