From 422d9088a4dcc9f2951c4f65c7a072d739fe8083 Mon Sep 17 00:00:00 2001 From: Armin Samii Date: Wed, 14 Oct 2026 13:24:53 -0400 Subject: [PATCH] purge cloudflare on deploy --- Procfile | 2 +- common/cloudflare.py | 27 +++++++++--- .../management/commands/purgeCloudflare.py | 23 +++++++++++ visualizer/tests/testSimple.py | 41 +++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 visualizer/management/commands/purgeCloudflare.py diff --git a/Procfile b/Procfile index f3c7c2fe..640cf272 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1,2 @@ -release: python3 manage.py migrate +release: python3 manage.py migrate && python3 manage.py purgeCloudflare web: gunicorn rcvis.wsgi diff --git a/common/cloudflare.py b/common/cloudflare.py index 8fbba373..0d05b2fc 100644 --- a/common/cloudflare.py +++ b/common/cloudflare.py @@ -66,20 +66,37 @@ def purge_paths_cache(cls, paths): if not cls._is_api_enabled(): return + info = f"{len(paths)} starting with {paths[0]}" + cls._send_purge_request({'files': cls.get_absolute_paths_for(paths)}, info) + + @classmethod + def purge_entire_cache(cls): + """ + Purges everything: every page and every static file, on every domain. + Run on each deploy (see the Procfile release phase): rendered HTML + references hashed static files that change on deploy, and an edge copy + cached before the deploy would keep satisfying browsers' revalidation + requests with 304s, pinning HTML that points at deleted assets. + """ + cache.clear() + + if not cls._is_api_enabled(): + return + + cls._send_purge_request({'purge_everything': True}, 'purge_everything') + + @classmethod + def _send_purge_request(cls, data, info): + """ POSTs a purge request to the cloudflare API and logs the outcome """ zoneId = settings.CLOUDFLARE_ZONE_ID apiUrl = f"https://api.cloudflare.com/client/v4/zones/{zoneId}/purge_cache" - # Absolute URLs - data = {'files': cls.get_absolute_paths_for(paths)} - - # Send it off response = requests.post( apiUrl, headers=cls._get_auth_headers(), data=json.dumps(data), timeout=8) - info = f"{len(paths)} starting with {paths[0]}" if response.status_code == 200: logger.info("Cleared cloudflare cache for %s: %s", info, response.json()) else: diff --git a/visualizer/management/commands/purgeCloudflare.py b/visualizer/management/commands/purgeCloudflare.py new file mode 100644 index 00000000..2148fb0d --- /dev/null +++ b/visualizer/management/commands/purgeCloudflare.py @@ -0,0 +1,23 @@ +""" +Management command to purge the entire Cloudflare cache, run on each deploy +via the Procfile release phase. Rendered HTML references hashed static files +that change on deploy, so edge copies cached before the deploy would keep +serving (and 304-renewing) HTML that points at deleted assets. +""" +from django.core.management.base import BaseCommand + +from common.cloudflare import CloudflareAPI + + +class Command(BaseCommand): + """ The command itself """ + + def handle(self, *args, **kwargs): + # Never fail over a cache purge: a failed release phase would block + # the very deploy this purge exists to clean up after. + try: + CloudflareAPI.purge_entire_cache() + except Exception as exception: # pylint: disable=broad-except + self.stderr.write(f'Cloudflare purge failed: {exception}\n') + return + self.stdout.write('Purged cloudflare cache\n') diff --git a/visualizer/tests/testSimple.py b/visualizer/tests/testSimple.py index 1af034cd..e4a803ca 100644 --- a/visualizer/tests/testSimple.py +++ b/visualizer/tests/testSimple.py @@ -502,6 +502,47 @@ def test_cloudflare_purge(self, requestPostResponse): data=json.dumps(expectedData), timeout=8) + @patch('requests.post') + def test_cloudflare_purge_everything_on_deploy(self, requestPostResponse): + """ + The deploy-time management command asks cloudflare to purge everything, + so edge copies of pre-deploy HTML can't keep 304-renewing browsers' + stale copies that point at deleted static assets. + """ + requestPostResponse.side_effect = TestHelpers.create_request_mock({'a': 0}, 200) + + with self.settings( + CLOUDFLARE_AUTH_TOKEN='mytoken', + CLOUDFLARE_ZONE_ID='zoneid'): + out = StringIO() + call_command('purgeCloudflare', stdout=out) + self.assertIn('Purged cloudflare cache', out.getvalue()) + + expectedUrl = 'https://api.cloudflare.com/client/v4/zones/zoneid/purge_cache' + expectedHeaders = { + "Content-Type": "application/json", + "Authorization": "Bearer mytoken" + } + requestPostResponse.assert_called_with(expectedUrl, + headers=expectedHeaders, + data=json.dumps({'purge_everything': True}), + timeout=8) + + @patch('requests.post') + def test_cloudflare_purge_failure_does_not_fail_release(self, requestPostResponse): + """ + A cloudflare outage must not fail the command: it runs in the release + phase, and a failed release would block the deploy itself. + """ + requestPostResponse.side_effect = IOError("cloudflare is down") + + with self.settings( + CLOUDFLARE_AUTH_TOKEN='mytoken', + CLOUDFLARE_ZONE_ID='zoneid'): + err = StringIO() + call_command('purgeCloudflare', stderr=err) + self.assertIn('Cloudflare purge failed', err.getvalue()) + def test_conditional_get_returns_304_when_fresh(self): """ When the client sends If-Modified-Since matching the object's updatedAt,