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
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,68 @@ tools/vendor/bin/phpcs --standard=phpcs.xml.dist
environment variable.
- Adds validation to webform email handler form, restricting configuration
emails configured in SMTP_WHITELIST envvar.
- Provides the optional `marina_cf_cachetags` module. It sends cacheable
responses' Drupal cache tags in the `x-amz-meta-cache-tag` header and
invalidates those tags through the local CloudFront SigV4 sidecar.

### CloudFront cache tags

Enable the submodule with:

```sh
drush en marina_cf_cachetags
```

On install it registers Purge's HTTP bundled purger (instance
`marina_cf_cachetags`), enables the `coretags` queuer and the `lateruntime`
processor, and empties the purge queue so nothing queued for a previous purger
lingers (`bay_platform_dependencies_update_10007()`).

**Response header.** Cacheable responses carry `x-amz-meta-cache-tag`, a
comma-separated list of the response's Drupal cache tags hashed with xxHash3
and truncated to 6 hex characters (`node:203` → `#` + first 6 chars of
`hash('xxh3', 'node:203')`). Tags matching the `purge_queuer_coretags`
blacklist are dropped, entity tags are listed first, and the list is capped at
the 50 tags CloudFront stores per object. Hashing keeps the header far below
CloudFront's 1,783-character limit.

**Invalidation.** The purger sends a `POST` to
`http://localhost:8083/prod/cache-invalidation/{project}/{environment}` (the
platform's SigV4 sidecar) with a JSON body of the same hashes, each prefixed
with `tag:`:

```json
{"tagsCsv":"tag:b9917e,tag:4c1d2a"}
```

The cache invalidation API rewrites `tag:<hash>` to `#<hash>`, which is
CloudFront's tag-invalidation syntax. Both sides must use the same hash, so
they share the `marina_cf_cachetags.cache_tags_hash` service. Override
`marina_cf_cachetags.cache_tag_filter` or
`marina_cf_cachetags.cache_tag_prioritizer` in a site's `services.yml` to
change which tags are kept and in what order.

**Site configuration.** Override the purger's `path` with the deployment's
project and environment names (the placeholders are not substituted
anywhere else), e.g. in `settings.php`:

```php
$config['purge_purger_http.settings.marina_cf_cachetags']['path'] =
sprintf('/prod/cache-invalidation/%s/%s', 'my-project', getenv('MARINA_ENVIRONMENT'));
```

**Platform requirements.** The CloudFront distribution must declare
`CacheTagConfig` with `HeaderName: x-amz-meta-cache-tag`; distributions
without it ignore the header and every tag invalidation is a no-op. Objects
cached before `CacheTagConfig` is enabled carry no tags — issue one `/*`
invalidation after enabling it.

To invalidate a tag by hand, hash it first:

```sh
aws cloudfront create-invalidation --distribution-id <id> \
--paths "#$(php -r 'echo substr(hash("xxh3", "node:203"), 0, 6);')"
```

## Patches

Expand Down
1 change: 0 additions & 1 deletion bay_platform_dependencies.info.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,5 @@ dependencies:
- bay_monitoring:bay_monitoring
- purge:purge
- redis:redis
- section_purge:section_purge
- smtp:smtp
- tide_logs:tide_logs
61 changes: 61 additions & 0 deletions bay_platform_dependencies.install
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,64 @@ function bay_platform_dependencies_update_10005(&$sandbox) {

return implode("\n", $message);
}

/**
* Replace the Section.io purger with the Marina CloudFront integration.
*/
function bay_platform_dependencies_update_10006() {
$module_installer = \Drupal::service('module_installer');

if (!\Drupal::moduleHandler()->moduleExists('marina_cf_cachetags')) {
$module_installer->install(['marina_cf_cachetags']);
}

// Remove the legacy Section.io purger from Purge's plugin configuration.
$config_factory = \Drupal::configFactory();
$purge_plugins = $config_factory->getEditable('purge.plugins');
$purgers = $purge_plugins->get('purgers') ?? [];
$purgers = array_values(array_filter($purgers, static function (array $purger) {
return !in_array($purger['plugin_id'] ?? NULL, ['section', 'sectionbundled'], TRUE);
}));
$purge_plugins->set('purgers', $purgers)->save();

// Remove the logger channel associated with the legacy Section.io purger.
$logger_config = $config_factory->getEditable('purge.logger_channels');
$channels = $logger_config->get('channels') ?? [];
$channels = array_values(array_filter($channels, static function (array $channel) {
return !str_starts_with($channel['id'] ?? '', 'purger_section');
}));
$logger_config->set('channels', $channels)->save();

// Remove configuration and credentials that are no longer used.
$config_factory
->getEditable('section_purge.settings.8714ff77fc')
->delete();
$config_factory
->getEditable('key.key.section_io_password')
->delete();

// Keep the Composer package for this release so Drupal can uninstall it.
if (\Drupal::moduleHandler()->moduleExists('section_purge')) {
$module_installer->uninstall(['section_purge']);
}
}

/**
* Empty the purge queue after switching to the Marina CloudFront purger.
*
* Items queued for the Section.io purger (URL and wildcard invalidations) can
* never be handled by the tag-only Marina purger. Purge marks them
* NOT_SUPPORTED and returns them to the queue, so they recirculate on every
* run and, because the queue is ordered by creation time, always occupy the
* first slot of every claimed batch. purge_tokens_tokens() only builds token
* replacements when the batch offered to a purger starts at index 0, so one
* stale item is enough to send every real invalidation with the literal
* `[invalidations:separated_comma]` token as its request body.
*/
function bay_platform_dependencies_update_10007() {
if (!\Drupal::hasService('purge.queue')) {
return t('Purge queue service unavailable; nothing to empty.');
}
\Drupal::service('purge.queue')->emptyQueue();
return t('Emptied the purge queue to discard invalidations queued for the retired Section.io purger.');
}
6 changes: 6 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"require": {
"php": "^8.3",
"drupal/purge": "^3.4",
"drupal/purge_purger_http": "^1.3",
"drupal/section_purge": "4.x",
"drupal/redis": "1.11.0",
"drupal/smtp": "^1.2",
Expand All @@ -28,6 +29,11 @@
"composer-exit-on-patch-failure": true,
"enable-patching": true,
"patches": {
"drupal/purge": {
"Fix duplicate Drush command registration - https://www.drupal.org/project/purge/issues/3460094#comment-15821421": "https://www.drupal.org/files/issues/2024-10-18/3460094-remove_drush_services_yml.patch",
"Handle missing $data['invalidations'][0] in invalidation check (token replacement skipped when the offered set is not indexed from 0) - https://www.drupal.org/project/purge/issues/3484260": "https://www.drupal.org/files/issues/2024-10-29/3484260.patch"

},
"drupal/redis": {
"Add RedisCluster client support": "https://www.drupal.org/files/issues/2026-05-12/2900947-98.patch",
"Forward the configured TLS context to RedisCluster": "https://gist.githubusercontent.com/GROwen/c29a081160f81c98414cf7c74f00fbbd/raw/e3cdbed53874c012dc86713fdeda95702daa45cd/redis-cluster-tls-context.patch"
Expand Down
13 changes: 0 additions & 13 deletions config/optional/key.key.section_io_password.yml

This file was deleted.

5 changes: 0 additions & 5 deletions config/optional/purge.logger_channels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@ channels:
- 0
- 2
- 3
- id: purger_sectionbundled_8714ff77fc
grants:
- 0
- 2
- 3
- id: diagnostics
grants:
- 3
5 changes: 1 addition & 4 deletions config/optional/purge.plugins.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
purgers:
- order_index: 2
instance_id: 8714ff77fc
plugin_id: sectionbundled
purgers: []
processors:
- plugin_id: drush_purge_queue_work
status: true
Expand Down
28 changes: 0 additions & 28 deletions config/optional/section_purge.settings.8714ff77fc.yml

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
id: marina_cf_cachetags
label: 'Marina CloudFront'
name: 'Marina CloudFront cache invalidation'
invalidationtype: tag
hostname: localhost
port: 8083
# Override this in settings.php with the deployment's project and environment.
path: /prod/cache-invalidation/{project}/{environment}
request_method: POST
scheme: http
verify: true
headers: { }
body: '{"tagsCsv":"[invalidations:separated_comma]"}'
body_content_type: application/json
runtime_measurement: true
timeout: 1.0
connect_timeout: 1.0
cooldown_time: 0.0
max_requests: 100
http_errors: true
12 changes: 12 additions & 0 deletions modules/marina_cf_cachetags/marina_cf_cachetags.info.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: 'Marina CloudFront Cache Tags'
type: module
description: 'Exports Drupal cache tags for CloudFront and dispatches tag invalidations through the platform sidecar.'
package: 'SDP Bay'
core_version_requirement: ^10.2 || ^11
dependencies:
- bay_platform_dependencies:bay_platform_dependencies
- purge:purge
- purge:purge_processor_lateruntime
- purge:purge_queuer_coretags
- purge:purge_tokens
- purge_purger_http:purge_purger_http
136 changes: 136 additions & 0 deletions modules/marina_cf_cachetags/marina_cf_cachetags.install
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

/**
* @file
* Installs and uninstalls the Marina CloudFront cache tags integration.
*/

/**
* Implements hook_install().
*/
function marina_cf_cachetags_install() {
marina_cf_cachetags_configure_purge();
}

/**
* Configure the purger, queuer, and processor used by the integration.
*/
function marina_cf_cachetags_configure_purge() {
$config = \Drupal::configFactory()->getEditable('purge.plugins');

// Register the bundled HTTP purger so Purge sends tags to the sidecar.
$purgers = $config->get('purgers') ?? [];
$purger_exists = FALSE;
foreach ($purgers as &$purger) {
if (($purger['instance_id'] ?? NULL) === 'marina_cf_cachetags') {
$purger['plugin_id'] = 'httpbundled';
$purger_exists = TRUE;
break;
}
}
unset($purger);
if (empty($purger_exists)) {
$purgers[] = [
'order_index' => 3,
'instance_id' => 'marina_cf_cachetags',
'plugin_id' => 'httpbundled',
];
}
$config->set('purgers', $purgers);

// Queue cache tags invalidated by Drupal core and contributed modules.
$queuers = $config->get('queuers') ?? [];
$coretags_exists = FALSE;
foreach ($queuers as &$queuer) {
if (($queuer['plugin_id'] ?? NULL) === 'coretags') {
$queuer['status'] = TRUE;
$coretags_exists = TRUE;
break;
}
}
unset($queuer);
if (empty($coretags_exists)) {
$queuers[] = [
'plugin_id' => 'coretags',
'status' => TRUE,
];
}
$config->set('queuers', $queuers);

// Process pending invalidations after the response has been sent.
$processors = $config->get('processors') ?? [];
$lateruntime_exists = FALSE;
foreach ($processors as &$processor) {
if (($processor['plugin_id'] ?? NULL) === 'lateruntime') {
$processor['status'] = TRUE;
$lateruntime_exists = TRUE;
break;
}
}
unset($processor);
if (empty($lateruntime_exists)) {
$processors[] = [
'plugin_id' => 'lateruntime',
'status' => TRUE,
];
}
$config->set('processors', $processors)->save();

// Log failures from the HTTP bundled purger.
$logger_config = \Drupal::configFactory()
->getEditable('purge.logger_channels');
$channels = $logger_config->get('channels') ?? [];
$channel_id = 'purger_httpbundled_marina_cf_cachetags';
$channel_exists = FALSE;
foreach ($channels as $channel) {
if (($channel['id'] ?? NULL) === $channel_id) {
$channel_exists = TRUE;
break;
}
}
if (empty($channel_exists)) {
$channels[] = [
'id' => $channel_id,
'grants' => [0, 2, 3],
];
$logger_config->set('channels', $channels)->save();
}
}

/**
* Install the correctly named HTTP purger config on existing sites.
*/
function marina_cf_cachetags_update_10001() {
$config_factory = \Drupal::configFactory();
$config_name = 'purge_purger_http.settings.marina_cf_cachetags';

if ($config_factory->get($config_name)->isNew()) {
\Drupal::service('config.installer')
->installDefaultConfig('module', 'marina_cf_cachetags');
}

marina_cf_cachetags_configure_purge();
}

/**
* Implements hook_uninstall().
*/
function marina_cf_cachetags_uninstall() {
$config_factory = \Drupal::configFactory();
$config = $config_factory->getEditable('purge.plugins');
$purgers = $config->get('purgers') ?? [];

// Avoid leaving Purge with a reference to this module's deleted settings.
$purgers = array_values(array_filter($purgers, static function (array $purger) {
return ($purger['instance_id'] ?? NULL) !== 'marina_cf_cachetags';
}));

$config->set('purgers', $purgers)->save();

$logger_config = $config_factory->getEditable('purge.logger_channels');
$channels = $logger_config->get('channels') ?? [];
$channels = array_values(array_filter($channels, static function (array $channel) {
return ($channel['id'] ?? NULL) !== 'purger_httpbundled_marina_cf_cachetags';
}));
$logger_config->set('channels', $channels)->save();
}
Loading