ca648ccf8b
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/dd261f53-4891-437e-bd71-5561ece62d7d
187 lines
7.5 KiB
Markdown
187 lines
7.5 KiB
Markdown
# ADR 010: Hybrid Configuration Model (Database + Environment Variables)
|
|
|
|
**Status:** Accepted
|
|
**Date:** 2026-02-20
|
|
**Deciders:** Development Team
|
|
|
|
## Context
|
|
|
|
The application requires configuration for many operational parameters:
|
|
|
|
1. **Bootstrap secrets** that must be available before the database is ready: `DATABASE_URL`, `SECRET_KEY`, `ENCRYPTION_KEY`
|
|
2. **SMTP relay settings** that operators want to change without redeployment: host, port, username, password
|
|
3. **Processing tuning parameters** that may need runtime adjustment: check interval, max emails per run, throttle rate
|
|
4. **Feature flags** for Gmail API, notifications, subscriptions
|
|
5. **Tier limits** (max accounts per subscription tier)
|
|
|
|
A pure environment-variable approach requires container redeployment for every config change. A pure database approach creates a chicken-and-egg problem for bootstrap settings.
|
|
|
|
## Decision
|
|
|
|
We will use a **hybrid configuration model** with the following priority chain:
|
|
|
|
```
|
|
1. Database (app_settings table) ← highest priority
|
|
2. Environment variable / .env file
|
|
3. Built-in default ← lowest priority
|
|
```
|
|
|
|
**Bootstrap settings** (`DATABASE_URL`, `SECRET_KEY`, `ENCRYPTION_KEY`) are **always** resolved from environment variables only, because the database connection depends on them.
|
|
|
|
**All other settings** are resolved by `ConfigService`, which checks the database first, then falls back to environment/defaults.
|
|
|
|
## Alternatives Considered
|
|
|
|
### 1. Environment Variables Only
|
|
- **Pros**: Simple, 12-factor compliant, works everywhere
|
|
- **Cons**: Config changes require redeployment, hard to manage across many instances, no UI for non-technical operators
|
|
|
|
### 2. Database-Only Configuration
|
|
- **Pros**: Runtime changes, admin UI possible, audit trail
|
|
- **Cons**: Chicken-and-egg for `DATABASE_URL` itself; database must exist before the app can read its own connection string
|
|
|
|
### 3. External Config Service (Consul, etcd)
|
|
- **Pros**: Distributed config, live reload, service discovery
|
|
- **Cons**: Additional infrastructure to deploy and operate, overkill for initial deployment
|
|
|
|
### 4. TOML / YAML Config Files
|
|
- **Pros**: Version-controlled, human-readable
|
|
- **Cons**: Requires file system mounts in Docker, config changes need file edits + possible restart
|
|
|
|
### 5. Pydantic Settings Only (env + .env files)
|
|
- **Pros**: Type-safe, validated at startup, no database dependency
|
|
- **Cons**: No runtime mutation, no admin UI, requires env changes for any tuning
|
|
|
|
## Rationale
|
|
|
|
The hybrid model was chosen because:
|
|
|
|
1. **Bootstrap Problem Solved**: Database connection string and secrets are always env-only — no circular dependency
|
|
2. **Runtime Mutability**: SMTP settings, tier limits, and feature flags can be changed via admin API without redeployment
|
|
3. **Operator Friendly**: Non-technical operators can use the admin UI or API to adjust settings
|
|
4. **Developer Friendly**: Developers can use `.env` files for local overrides without touching the database
|
|
5. **Gradual Migration**: Existing env-based deployments continue to work; database settings are additive
|
|
|
|
## Implementation
|
|
|
|
### Data Model
|
|
|
|
```python
|
|
# backend/app/models/database_models.py
|
|
class AppSetting(Base):
|
|
__tablename__ = "app_settings"
|
|
|
|
key: Mapped[str] = mapped_column(String(255), primary_key=True)
|
|
value: Mapped[str] = mapped_column(Text, nullable=False)
|
|
description: Mapped[str | None] = mapped_column(Text)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(timezone.utc),
|
|
onupdate=lambda: datetime.now(timezone.utc),
|
|
)
|
|
```
|
|
|
|
### ConfigService
|
|
|
|
```python
|
|
# backend/app/services/config_service.py
|
|
class ConfigService:
|
|
def __init__(self, db: AsyncSession):
|
|
self.db = db
|
|
|
|
async def get(self, key: str, default: str | None = None) -> str | None:
|
|
"""Resolve setting: DB → env → default."""
|
|
# 1. Check database
|
|
result = await self.db.get(AppSetting, key)
|
|
if result is not None:
|
|
return result.value
|
|
# 2. Check environment
|
|
env_value = os.getenv(key)
|
|
if env_value is not None:
|
|
return env_value
|
|
# 3. Return default
|
|
return default
|
|
|
|
async def set(self, key: str, value: str, description: str | None = None) -> None:
|
|
"""Persist setting to database (creates or updates)."""
|
|
setting = AppSetting(key=key, value=value, description=description)
|
|
await self.db.merge(setting)
|
|
await self.db.commit()
|
|
```
|
|
|
|
### Default Seeding at Startup
|
|
|
|
On first boot, sensible defaults are written to the `app_settings` table if they don't already exist:
|
|
|
|
```python
|
|
# Called during lifespan startup
|
|
async def seed_default_settings(db: AsyncSession):
|
|
defaults = {
|
|
"CHECK_INTERVAL_MINUTES": ("5", "How often to check mail accounts"),
|
|
"MAX_EMAILS_PER_RUN": ("50", "Maximum emails to process per run"),
|
|
"SMTP_PORT": ("587", "SMTP relay port"),
|
|
"TIER_FREE_MAX_ACCOUNTS": ("1", "Free tier account limit"),
|
|
# ... etc.
|
|
}
|
|
for key, (value, description) in defaults.items():
|
|
existing = await db.get(AppSetting, key)
|
|
if existing is None:
|
|
await db.add(AppSetting(key=key, value=value, description=description))
|
|
await db.commit()
|
|
```
|
|
|
|
### Admin API Endpoints
|
|
|
|
```
|
|
GET /api/v1/settings # List all database settings (admin only)
|
|
PUT /api/v1/settings/{key} # Create or update a setting
|
|
DELETE /api/v1/settings/{key} # Delete a setting (falls back to env/default)
|
|
POST /api/v1/settings/seed-defaults # Re-seed all defaults
|
|
```
|
|
|
|
### Bootstrap Settings (Env-Only)
|
|
|
|
These are read via `pydantic-settings` (`BaseSettings`) and never looked up in the database:
|
|
|
|
| Setting | Required | Purpose |
|
|
|---------|----------|---------|
|
|
| `DATABASE_URL` | ✅ | PostgreSQL connection string |
|
|
| `SECRET_KEY` | ✅ | JWT signing key (≥32 chars) |
|
|
| `ENCRYPTION_KEY` | ✅ | Fernet master encryption key |
|
|
| `GOOGLE_CLIENT_ID` | Optional | Google OAuth2 |
|
|
| `GOOGLE_CLIENT_SECRET` | Optional | Google OAuth2 |
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
- Bootstrap settings always resolved from env (no circular dependency)
|
|
- Runtime-mutable settings require no redeployment
|
|
- Default values seeded at startup so application works out-of-the-box
|
|
- Clear priority chain: database overrides env, env overrides code default
|
|
- Admin API provides complete CRUD on runtime settings
|
|
|
|
### Negative
|
|
- Two sources of truth for settings requires careful documentation
|
|
- Database settings take precedence over env — operators must know to check the database if env changes seem to have no effect
|
|
- `ConfigService` requires a database session (async), adding overhead for high-frequency config reads
|
|
- Settings cache is not implemented; every lookup hits the database (future improvement: TTL-based cache)
|
|
|
|
### Future Improvements
|
|
|
|
1. **In-Memory Cache**: Cache settings with a short TTL (e.g., 30 seconds) to reduce database load
|
|
2. **Change Notifications**: Pub/sub via Redis to notify workers of setting changes
|
|
3. **Typed Settings Schema**: JSON Schema validation for setting values
|
|
4. **Audit Trail**: Record who changed each setting and when
|
|
|
|
## Related Decisions
|
|
|
|
- See ADR-004 for PostgreSQL (stores `app_settings` table)
|
|
- See ADR-006 for key management (bootstrap secrets are env-only)
|
|
- See ADR-003 for FastAPI dependency injection of `ConfigService`
|
|
|
|
## References
|
|
|
|
- [12-Factor App: Config](https://12factor.net/config)
|
|
- [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
|
|
- [SQLAlchemy ORM](https://docs.sqlalchemy.org/en/20/orm/)
|