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.