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
2 changes: 1 addition & 1 deletion Procfile
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
release: python3 manage.py migrate
release: python3 manage.py migrate && python3 manage.py purgeCloudflare
web: gunicorn rcvis.wsgi
27 changes: 22 additions & 5 deletions common/cloudflare.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions visualizer/management/commands/purgeCloudflare.py
Original file line number Diff line number Diff line change
@@ -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')
41 changes: 41 additions & 0 deletions visualizer/tests/testSimple.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading