Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ The repository now includes the files needed to host the app on Vercel productio
| `DJANGO_SECRET_KEY` | Required | Required | Use a unique secret per environment. Hosted builds fail fast until this is set. |
| `DATABASE_URL` | Required | Required | Point previews at an isolated PostgreSQL database or branch database, not production. |
| `APP_BASE_URL` | `https://deep-workflow.vercel.app` | Optional | Sets the canonical production URL and anchors the production host/origin configuration. |
| `DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK` | Emergency only | Emergency only | Leave unset for normal deploys. Set to `1` only when intentionally activating the temporary hosted SQLite recovery mode. |
| `DJANGO_SECURE_HSTS_PRELOAD` | Optional | Optional | Leave unset unless you intentionally want preload and already satisfy the preload requirements (`includeSubDomains` plus at least `31536000` seconds). |
| `VERCEL_RUN_MIGRATIONS` | Set to `1` only for deliberate schema rollouts | Set to `1` only for isolated preview databases | Build-time migrations are always opt-in. |

Expand Down Expand Up @@ -181,6 +182,12 @@ Vercel injects `VERCEL_ENV`, `VERCEL_URL`, `VERCEL_BRANCH_URL`, and `VERCEL_PROJ
4. Let pushes to non-`main` branches create Preview deployments automatically. Leave `VERCEL_RUN_MIGRATIONS` unset for normal deploys; only turn it on for isolated preview databases or a coordinated production schema rollout, then redeploy intentionally.
5. After each deployment, check `GET /health/ready/` for database readiness and `GET /health/live/` for the lightweight liveness probe.

#### Emergency hosted SQLite recovery

Normal hosted deploys should continue to fail fast if `DATABASE_URL` is missing or broken. If you need a temporary outage recovery mode, you can opt into the hosted SQLite fallback by setting `DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK=1` alongside a SQLite `DATABASE_URL`.

This mode is for emergency recovery only. It moves sessions and flash messages to signed cookies so the app can run from a bundled SQLite snapshot on Vercel, but it should not replace the normal PostgreSQL-backed production path. Only use it when your session and message payloads are small enough to fit within browser cookie limits, and do not treat the cookie contents as secret: signed cookies are tamper-resistant, but their contents remain readable by the client.

### Health checks and monitoring hooks

- `GET /health/live/` is a cheap liveness probe that does not touch the database.
Expand Down
199 changes: 199 additions & 0 deletions core/tests/test_deployment.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import json
import os
import subprocess
import sys

from django.conf import settings

from deep_workflow.deployment import (
Expand All @@ -11,6 +16,23 @@
hsts_preload_enabled,
)

IMPORT_HOSTED_SETTINGS_COMMAND = (
"import environ; "
"environ.Env.read_env = staticmethod(lambda *args, **kwargs: None); "
"import deep_workflow.settings as settings; "
"from django.conf import settings as django_settings; "
"print(json.dumps(["
"settings.HOSTED_SQLITE_FALLBACK, "
"django_settings.SESSION_ENGINE, "
"django_settings.MESSAGE_STORAGE"
"]))"
)
IMPORT_HOSTED_SETTINGS_SNIPPET = (
"import environ; "
"environ.Env.read_env = staticmethod(lambda *args, **kwargs: None); "
"import deep_workflow.settings"
)


def test_build_allowed_hosts_adds_vercel_runtime_hosts() -> None:
environ = {
Expand Down Expand Up @@ -117,3 +139,180 @@ def test_request_id_middleware_precedes_whitenoise() -> None:
) < settings.MIDDLEWARE.index(
"whitenoise.middleware.WhiteNoiseMiddleware",
)


def load_hosted_settings(
*,
enable_fallback: bool | None,
database_url: str | None = "sqlite:///db.sqlite3",
) -> tuple[bool, str, str]:
env = os.environ.copy()
env.update(
{
"DJANGO_DEBUG": "False",
"DJANGO_SETTINGS_MODULE": "deep_workflow.settings",
"DJANGO_SECRET_KEY": "x" * 64,
"VERCEL_ENV": "production",
"VERCEL": "1",
}
)
if database_url is None:
env.pop("DATABASE_URL", None)
else:
env["DATABASE_URL"] = database_url
if enable_fallback is None:
env.pop("DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK", None)
else:
env["DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK"] = "1" if enable_fallback else "0"
command = [
sys.executable,
"-c",
f"import json; {IMPORT_HOSTED_SETTINGS_COMMAND}",
]
result = subprocess.run(
command,
env=env,
check=True,
capture_output=True,
text=True,
)
return tuple(json.loads(result.stdout))
Comment thread
mathemage marked this conversation as resolved.


def run_hosted_settings_import(
*,
database_url: str | None,
enable_fallback: bool | None,
) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env.update(
{
"DJANGO_DEBUG": "False",
"DJANGO_SECRET_KEY": "x" * 64,
"VERCEL_ENV": "production",
"VERCEL": "1",
}
)
if database_url is None:
env.pop("DATABASE_URL", None)
else:
env["DATABASE_URL"] = database_url
if enable_fallback is None:
env.pop("DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK", None)
else:
env["DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK"] = "1" if enable_fallback else "0"
return subprocess.run(
[sys.executable, "-c", IMPORT_HOSTED_SETTINGS_SNIPPET],
env=env,
capture_output=True,
text=True,
)


def test_hosted_sqlite_fallback_is_disabled_by_default_when_flag_is_zero() -> None:
result = run_hosted_settings_import(
database_url="sqlite:///db.sqlite3",
enable_fallback=False,
)
assert result.returncode != 0
assert "ImproperlyConfigured" in result.stderr
Comment thread
mathemage marked this conversation as resolved.
assert "Hosted deployments require one of these database configurations" in (
result.stderr
)
Comment thread
mathemage marked this conversation as resolved.
assert "DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK=1" in result.stderr


def test_hosted_sqlite_fallback_is_disabled_by_default_when_flag_is_unset() -> None:
result = run_hosted_settings_import(
database_url="sqlite:///db.sqlite3",
enable_fallback=None,
)
assert result.returncode != 0
assert "ImproperlyConfigured" in result.stderr
assert "Hosted deployments require one of these database configurations" in (
result.stderr
)
assert "DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK=1" in result.stderr


def test_hosted_sqlite_fallback_is_disabled_when_database_url_is_unset() -> None:
result = run_hosted_settings_import(
database_url=None,
enable_fallback=None,
)
Comment thread
mathemage marked this conversation as resolved.
assert result.returncode != 0
assert "ImproperlyConfigured" in result.stderr
assert "Hosted deployments require one of these database configurations" in (
result.stderr
)
assert "DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK=1" in result.stderr


def test_hosted_sqlite_fallback_requires_explicit_opt_in() -> None:
hosted_settings = load_hosted_settings(enable_fallback=True)
hosted_sqlite_fallback, session_engine, message_storage = hosted_settings

assert hosted_sqlite_fallback is True
assert session_engine == "django.contrib.sessions.backends.signed_cookies"
assert message_storage == "django.contrib.messages.storage.cookie.CookieStorage"
Comment thread
mathemage marked this conversation as resolved.


def test_hosted_sqlite_fallback_requires_explicit_database_url() -> None:
result = run_hosted_settings_import(
database_url=None,
enable_fallback=True,
)
assert result.returncode != 0
assert "ImproperlyConfigured" in result.stderr
assert "Hosted deployments require one of these database configurations" in (
result.stderr
)
assert "DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK=1" in result.stderr


def test_hosted_sqlite_fallback_flag_ignored_for_postgresql() -> None:
hosted_sqlite_fallback, session_engine, message_storage = load_hosted_settings(
enable_fallback=True,
database_url="postgresql://testuser:testpass@localhost:5432/testdb",
)

assert hosted_sqlite_fallback is False
assert session_engine != "django.contrib.sessions.backends.signed_cookies"
assert message_storage != "django.contrib.messages.storage.cookie.CookieStorage"


def test_hosted_sqlite_fallback_is_not_applied_to_invalid_database_url() -> None:
env = os.environ.copy()
env.update(
{
"DJANGO_DEBUG": "False",
"DJANGO_SECRET_KEY": "x" * 64,
"VERCEL_ENV": "production",
"VERCEL": "1",
"DATABASE_URL": "invalid://definitely-not-a-supported-database-url",
"DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK": "1",
}
)
result = subprocess.run(
[
sys.executable,
"-c",
(
"import environ; "
"environ.Env.read_env = staticmethod(lambda *args, **kwargs: None); "
"import deep_workflow.settings"
),
],
env=env,
capture_output=True,
text=True,
)

assert result.returncode != 0
assert "ImproperlyConfigured" in result.stderr
assert "Hosted deployments require one of these database configurations" in (
result.stderr
)
assert "DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK=1" in result.stderr
assert "django.contrib.sessions.backends.signed_cookies" not in result.stderr
assert "django.contrib.messages.storage.cookie.CookieStorage" not in result.stderr
43 changes: 39 additions & 4 deletions deep_workflow/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
DJANGO_DB_CONN_MAX_AGE=(int, 60),
DJANGO_DB_SSL_MODE=(str, "require"),
DJANGO_DEBUG=(bool, DEFAULT_DEBUG),
DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK=(bool, False),
DJANGO_LOG_LEVEL=(str, "DEBUG" if DEFAULT_DEBUG else "INFO"),
DJANGO_REQUEST_LOG_LEVEL=(str, "WARNING"),
DJANGO_SECRET_KEY=(str, "unsafe-local-development-key"),
Expand Down Expand Up @@ -63,6 +64,13 @@
if not DEBUG and SECRET_KEY == "unsafe-local-development-key":
raise ImproperlyConfigured("Set DJANGO_SECRET_KEY before disabling DJANGO_DEBUG.")

HOSTED_DATABASE_CONFIGURATION_ERROR = (
"Hosted deployments require one of these database configurations: "
"(1) a valid PostgreSQL DATABASE_URL; or "
"(2) for emergency recovery only, DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK=1 "
"together with an explicit SQLite DATABASE_URL."
)

INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
Expand Down Expand Up @@ -107,13 +115,36 @@
WSGI_APPLICATION = "deep_workflow.wsgi.application"


DATABASES = {
"default": env.db("DATABASE_URL", default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}")
}
try:
default_database = env.db(
"DATABASE_URL",
default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}",
)
except ImproperlyConfigured as exc:
if HOSTED_ENV:
raise ImproperlyConfigured(HOSTED_DATABASE_CONFIGURATION_ERROR) from exc
raise

DATABASES = {"default": default_database}
DATABASES["default"]["CONN_MAX_AGE"] = env.int("DJANGO_DB_CONN_MAX_AGE", default=60)
DATABASES["default"]["CONN_HEALTH_CHECKS"] = True
raw_database_url = os.environ.get("DATABASE_URL", "").strip()
database_engine = DATABASES["default"].get("ENGINE")
HOSTED_SQLITE_FALLBACK = (
HOSTED_ENV
and env.bool("DJANGO_ENABLE_HOSTED_SQLITE_FALLBACK")
and bool(raw_database_url)
and database_engine == "django.db.backends.sqlite3"
)

Comment thread
mathemage marked this conversation as resolved.
if HOSTED_ENV and DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql":
if (
HOSTED_ENV
and database_engine != "django.db.backends.postgresql"
and not HOSTED_SQLITE_FALLBACK
):
raise ImproperlyConfigured(HOSTED_DATABASE_CONFIGURATION_ERROR)

if HOSTED_ENV and database_engine == "django.db.backends.postgresql":
DATABASES["default"].setdefault("OPTIONS", {})
DATABASES["default"]["OPTIONS"].setdefault(
"sslmode",
Expand Down Expand Up @@ -211,6 +242,10 @@
LOGIN_REDIRECT_URL = "home"
LOGOUT_REDIRECT_URL = "login"

if HOSTED_SQLITE_FALLBACK:
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
MESSAGE_STORAGE = "django.contrib.messages.storage.cookie.CookieStorage"

LOG_LEVEL = env("DJANGO_LOG_LEVEL").upper()
REQUEST_LOG_LEVEL = env("DJANGO_REQUEST_LOG_LEVEL").upper()
LOGGING = {
Expand Down
Loading