diff --git a/README.md b/README.md index 3419597..610987e 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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. diff --git a/core/tests/test_deployment.py b/core/tests/test_deployment.py index b3d5768..bc5d2e1 100644 --- a/core/tests/test_deployment.py +++ b/core/tests/test_deployment.py @@ -1,3 +1,8 @@ +import json +import os +import subprocess +import sys + from django.conf import settings from deep_workflow.deployment import ( @@ -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 = { @@ -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)) + + +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 + 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_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, + ) + 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" + + +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 diff --git a/deep_workflow/settings.py b/deep_workflow/settings.py index ab95a21..c70ecf2 100644 --- a/deep_workflow/settings.py +++ b/deep_workflow/settings.py @@ -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"), @@ -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", @@ -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" +) -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", @@ -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 = {