learning-website-django1-12 Exercise 1: Run the Production Settings and Smoke-Test Them Over Real HTTP
=======================================================================================================
Django 6.1.2. The test suite runs under the development settings, so it cannot say whether the
PRODUCTION settings work. Two things answer that: Django's own deployment check, and real HTTP
requests to a server started with the production settings.
Step 1: Django's deployment check, with the production settings (a throwaway secret key):
DJANGO_SETTINGS_MODULE=config.settings.prod python manage.py check --deploy
WARNINGS:
?: (security.W005) You have not set the SECURE_HSTS_INCLUDE_SUBDOMAINS setting to True. ...
?: (security.W021) You have not set the SECURE_HSTS_PRELOAD setting to True. ...
System check identified 2 issues (0 silenced).
These two warnings are LEFT ON PURPOSE. Include-subdomains would make browsers insist on HTTPS for
EVERY name under osztromok.com, including any old or unrelated host that does not have a
certificate, and preload is close to permanent. HSTS is currently 3600 seconds; raise it in steps
only once every host is known to work over HTTPS.
Step 2: a health address on every site (and on the admin host) for monitors and the deploy script.
Save as apps/core/health.py:
from django.db import DatabaseError, connection
from django.http import JsonResponse
from apps.content.models import Page
def healthz(request, site=None):
"""For the load balancer, the monitor and the deploy script: 200 only when this process can really serve a page.
It checks what a visitor needs (the database answers and holds pages), says so in a line of JSON and leaks nothing
else. It is never cached. A failure is 503, so a deploy script can refuse to switch to a broken release."""
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
pages = Page.objects.count() if site is None else Page.objects.filter(site=site).count()
except DatabaseError:
response = JsonResponse({"status": "database error"}, status=503)
else:
if pages == 0:
response = JsonResponse({"status": "no pages", "pages": 0}, status=503)
else:
response = JsonResponse({"status": "ok", "pages": pages})
response["Cache-Control"] = "no-store"
return response
It answers 200 only when this process can really serve: the database answers AND holds pages (for
a site, that site's pages). Otherwise 503. It is never cached, and says nothing else.
Step 3: a smoke test that sends real HTTP and does not care what is behind the address.
Save as apps/core/smoke.py:
"""A smoke test that talks real HTTP to a running copy of the sites.
It connects to one address (the Gunicorn port, or Apache) and sends the Host header of each site, so the same check
works on a laptop, on the server before the switch, and from outside after it. It does not replace the test suite: it
answers a different question, "is the thing that is actually running healthy?"."""
import http.client
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from config.sites_config import SITES
SITEMAP_NS = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
@dataclass
class Report:
checks: int = 0
failures: list = field(default_factory=list)
def check(self, ok, label, detail=""):
self.checks += 1
if not ok:
self.failures.append(f"{label} {detail}".strip())
return ok
def fetch(connect, host, path, *, tls=False, headers=None, timeout=20):
"""GET path from `host`. With `connect` ("127.0.0.1:8000") the request goes to that address and only pretends to be
`host` (the Host header), which is right for plain HTTP to Gunicorn. With connect=None it connects to `host` itself
by its real name, so DNS, the certificate and Apache's choice of virtual host (SNI) are checked too: that is what
HTTPS needs. Never follows redirects."""
where, _, port = (connect or host).partition(":")
cls = http.client.HTTPSConnection if tls else http.client.HTTPConnection
conn = cls(where, int(port) if port else (443 if tls else 80), timeout=timeout)
try:
conn.request("GET", path, headers={"Host": host, **(headers or {})})
response = conn.getresponse()
return response.status, dict((k.lower(), v) for k, v in response.getheaders()), response.read()
finally:
conn.close()
def smoke(connect, hosts, *, admin_host=None, per_site=3, headers=None, tls=False):
"""hosts: {site: host name}. Returns a Report."""
report = Report()
get = lambda host, path: fetch(connect, host, path, tls=tls, headers=headers)
for site, host in hosts.items():
label = f"[{site}]"
status, _, body = get(host, "/healthz/")
report.check(status == 200, f"{label} /healthz/", f"-> {status}")
status, _, body = get(host, "/")
report.check(status == 200, f"{label} front page", f"-> {status}")
css = re.search(rb'href="(/static/[^"]+\.css)"', body)
if report.check(css is not None, f"{label} front page links a stylesheet"):
status, resp_headers, _ = get(host, css.group(1).decode())
report.check(status == 200, f"{label} stylesheet", f"-> {status}")
status, _, body = get(host, "/robots.txt")
report.check(status == 200 and b"User-agent" in body, f"{label} robots.txt", f"-> {status}")
status, _, body = get(host, "/sitemap.xml")
locs = []
if report.check(status == 200, f"{label} sitemap.xml", f"-> {status}"):
locs = [u.find("s:loc", SITEMAP_NS).text for u in ET.fromstring(body).findall("s:url", SITEMAP_NS)]
report.check(len(locs) > 0, f"{label} sitemap has pages")
status, _, _ = get(host, "/search/?q=the")
report.check(status == 200, f"{label} search", f"-> {status}")
step = max(1, len(locs) // per_site) if locs else 1
for loc in locs[::step][:per_site]:
path = "/" + loc.split("/", 3)[3] # the part after https://host/
status, _, page = get(host, path)
ok = status == 200 and f' {status}, canonical link {'ok' if ok else 'missing or different'}")
if admin_host:
status, resp_headers, _ = get(admin_host, "/")
report.check(status == 302 and "login" in resp_headers.get("location", ""), "[admin] asks for a login", f"-> {status}")
first = next(iter(hosts.values()))
status, _, _ = get(first, "/admin/")
report.check(status == 404, "[admin] is not on a content site", f"-> {status}")
return report
Save as apps/core/management/commands/smoke_test.py:
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from apps.core.smoke import smoke
from config.sites_config import SITES
class Command(BaseCommand):
help = "Send real HTTP requests to a running copy of the sites and check that they are healthy."
def add_arguments(self, parser):
parser.add_argument("--connect", default=None, help="address to connect to, such as 127.0.0.1:8000 "
"(default: connect to each site's own host name, as a visitor does)")
parser.add_argument("--tls", action="store_true", help="connect with HTTPS")
parser.add_argument("--domain", default=None, help="host names are . (default: from the settings)")
parser.add_argument("--forwarded-proto", default=None, help="send X-Forwarded-Proto (what Apache does)")
parser.add_argument("--per-site", type=int, default=3)
def handle(self, connect, tls, domain, forwarded_proto, per_site, **options):
domain = domain or ("osztromok.com" if not settings.DEBUG else "localhost")
if connect is None and settings.DEBUG:
connect = "127.0.0.1:8000"
hosts = {site: f"{site}.{domain}" for site in SITES}
headers = {"X-Forwarded-Proto": forwarded_proto} if forwarded_proto else None
try:
report = smoke(connect, hosts, admin_host=getattr(settings, "ADMIN_HOST", None), per_site=per_site,
headers=headers, tls=tls)
except OSError as error: # nothing is listening, a name does not resolve, a certificate is wrong
raise CommandError(f"could not connect ({connect or 'each site by name'}): {error}")
for failure in report.failures:
self.stdout.write(self.style.ERROR("FAIL " + failure))
self.stdout.write(f"{report.checks} checks, {len(report.failures)} failed")
if report.failures:
raise CommandError("the smoke test failed")
For each site it checks the health address, the front page, that the front page links a stylesheet
and that the stylesheet loads, robots.txt, the sitemap, search, and a few pages from the sitemap,
whose canonical link must equal the sitemap address. For the admin host it checks that it asks
for a login and that /admin/ is a 404 on the content sites. With --connect it goes to an address
and sends each site's Host header (right for plain HTTP to Gunicorn); without it, it connects to
each site's real name, so DNS, the certificate and Apache's choice of virtual host are tested too.
Step 4: run it on the real project, with the production settings.
python manage.py collectstatic --noinput
134 static files copied to 'C:\lwdj\proj\staticfiles', 402 post-processed.
DJANGO_SETTINGS_MODULE=config.settings.prod python manage.py runserver 127.0.0.1:8123 --noreload
curl -H "Host: languages.osztromok.com" http://127.0.0.1:8123/
301 https://languages.osztromok.com/ (production redirects plain HTTP: correct)
python manage.py smoke_test --connect 127.0.0.1:8123 --forwarded-proto https --per-site 3
90 checks, 0 failed (6 seconds, 8 sites and the admin host)
(--forwarded-proto https is what Apache sends; Django only believes it for the proxy. The same
command without it fails as it should:)
FAIL [languages] /healthz/ -> 301
FAIL [languages] front page -> 301
FAIL [languages] front page links a stylesheet
FAIL [languages] robots.txt -> 301
CommandError: the smoke test failed (exit code 1)
and with nothing listening: "CommandError: could not connect (127.0.0.1:9): ... refused".
What was NOT run: Gunicorn (it does not run on Windows; it needs Linux), Apache, and a real
certificate. The development server stood in for Gunicorn, so process management, workers and the
socket are untested. The static files and the settings were the real production ones.
WHY THIS WORKS AS AN ANSWER
---------------------------
It checks the thing that is actually running, from outside, and it has been seen to FAIL: a
check that has never failed has not been shown to check anything.