Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f54082024
|
||
|
|
47b9c52b32 | ||
|
|
d683c20449 | ||
|
|
638e988461 | ||
|
|
8de023efa7 | ||
|
|
90cf7fd158
|
||
|
|
7b492f857c | ||
|
|
9ff55f4c23
|
||
|
|
407b7674c6 | ||
|
|
3ff13a663e
|
||
|
|
85f2ba60ef | ||
|
|
79ae6264f8
|
||
|
|
98114a6482 | ||
|
|
3d72a5d05d
|
||
|
|
4fb8d74458 | ||
|
|
aba80ff62f
|
||
|
|
55a2ec2a09 | ||
|
|
1262f567c8
|
||
|
|
bbf1b55eac | ||
|
|
f7c9e0fce3
|
||
|
|
1cd8810e32
|
||
|
|
d3995734dd
|
||
|
|
8bb2c4eb4f
|
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -5,17 +5,26 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
## [0.1.1] - Unreleased
|
||||
|
||||
### Added
|
||||
- Central `cdn.provider.<id>` dependency health checks for enabled providers, every 900 seconds with a 20000 ms timeout, reusing the existing connection test with bounded, non-secret results.
|
||||
- Ship the host-neutral in-app help bundle with QQQ registration, live references, and coverage checks for all CDN metadata and generated purge actions. Hosts can override entries with a HOST bundle.
|
||||
- Origin header check: Check Origin Headers on Hostnames (and a nightly run over hostnames that are not Off) asks the origin for sample pages and writes `cdn_origin_check` rows. The table had no writer before.
|
||||
- Guided empty states: help bundle `help/qbit-cdn.yaml` (app and all nine tables, with "How rows get created") applied by `CdnHelp`, and a Setup card on the CDN home.
|
||||
- "Create default provider" (No CDN), "Create default policies" (two turned-off examples) and "Create default rules" (a tags-only rule per purgeable table) table actions; all insert-only.
|
||||
- `hostnameRole` is documented as a label that changes nothing; cache policies are documented as applying only where the application calls `CdnResponseHeaderPolicy`.
|
||||
|
||||
### Removed
|
||||
- Connection testing from the daily drift step; drift checks and the manual Test Connection action remain.
|
||||
- The `cdnHealthCard` HTML widget, its renderer and dedicated status summary.
|
||||
|
||||
### Changed
|
||||
- Pin the released QQQ BOM to 4.1.0-RC.7 and replace the temporary help loader with the core bundle registry.
|
||||
- Require released QQQ BOM 4.1.0-RC.5; revalidation hooks default to core Standard Webhooks signing. Per-hook `signatureFormat=LEGACY` preserves the prior header and signature bytes.
|
||||
- Use core `SafeHttpClient` for revalidation requests: HTTPS/public targets by default, explicit `allowPlainHttp`/`internalAllowlist` exceptions, bounded responses and checked DNS pinning. Fan-out preserves Host/TLS identity. Host schema migration and receiver migration are described in README and `docs/revalidation-hook-migration.md`.
|
||||
|
||||
|
||||
### Fixed
|
||||
- CDN health checks no longer sit at UNKNOWN (which the central evaluator never alerts on). CloudFront checks the distribution itself: GetDistribution gives UP (enabled and Deployed), DEGRADED (InProgress, origin header missing, throttled or unreachable, or CloudWatch 5xxErrorRate above 5% over 30 minutes) or DOWN (missing or disabled distribution, rejected or missing credentials). Without `cloudfront:GetDistribution` it falls back to GetDistributionConfig and reports UP with `testable=false`. The NONE provider and adapters without a connection test report UP with `testable=false`. New optional SPI `CdnHealthProbe` / `CdnHealth`.
|
||||
- Derive the CDN qbit metadata version from the Maven artifact version, keeping release metadata and the help bundle in sync.
|
||||
- Tag rules accept ignored fields in `onlyWhenFieldsChange`, such as `!viewCount`, so counter-only updates do not purge rendered pages. Plain allow-lists still work, and ignored fields take precedence in mixed lists. No schema change is needed.
|
||||
- Purge actions warn when providers are NONE or hostnames are OFF, and report requests as recorded but not sent. The home explains recorded-only purges and that this version does not run origin header checks.
|
||||
- Tag rule `{prefix.field}` placeholders now resolve the joined table from the `<prefix>Id` field's possible-value source, so prefixed tables (`cms_blog`) work; a missing joined table is logged instead of silently skipped.
|
||||
|
||||
@@ -25,12 +25,26 @@ Design: QRun-IO/qqq `docs/specs/2026-10-12-qbit-cdn-design.md`.
|
||||
Purge Everything, Test Connection, Import Edge Stats, Check Config Drift;
|
||||
scheduled: dispatcher, daily stats import, daily drift check.
|
||||
- **Admin -> CDN app:** sections Connection / Settings / Activity / Performance,
|
||||
a home widget with honest empty states, and a Health status card.
|
||||
a home widget with honest empty states.
|
||||
- **Providers:** CloudFront (invalidations, connection test, CloudWatch stats,
|
||||
drift against the OpenTofu `expected_config`), Souin (self-hosted, tags),
|
||||
NONE. See `docs/cloudfront-expected-config.md`.
|
||||
- **Permissions:** `cdn.view`, `cdn.purge`, `cdn.purgeAll`, `cdn.admin`.
|
||||
|
||||
## Health checks
|
||||
|
||||
Central System Health registers `cdn.provider.<id>` for each enabled
|
||||
`cdn_provider` row: **DEPENDENCY**, component `cdn`, category **CDN**, every
|
||||
**900 seconds**, with a **20000 ms** timeout. It reuses the read-only connection
|
||||
test: failure is DOWN; success without the origin header is DEGRADED; otherwise
|
||||
UP. Providers without connection-test support report UNKNOWN. Disable a check
|
||||
centrally with `health_check.enabled`.
|
||||
|
||||
Details contain only provider type, a known provider status (or UNKNOWN), and
|
||||
whether the origin header is configured. The manual `cdnTestConnection` action
|
||||
and its `lastHealth*` fields remain; the daily scheduled step checks drift only.
|
||||
The host must use QQQ **4.1.0-RC.8** or later for the central health SPI.
|
||||
|
||||
## Setup
|
||||
|
||||
This qbit ships no DDL and seeds nothing from a changelog (a host chooses its own column names). Open the CDN app: the Setup card lists each part with the next step. On a fresh install run **Create default provider** (No CDN: purges are recorded, nothing is sent), **Create default rules** (tags for every purgeable table), and optionally **Create default policies** (two turned-off examples). Add your CDN and hostnames when you have them. Cache policies change a response only where the application calls `CdnResponseHeaderPolicy.decide`. **Check Origin Headers** on a hostname (and the nightly run) asks the origin, the provider's `originHost` when set, for the home page, prefix policy paths and literal tag-rule paths, and records how many have the right Cache-Control.
|
||||
@@ -78,7 +92,7 @@ variable named by the provider setting `expectedConfigEnv`).
|
||||
|
||||
## Revalidation hooks
|
||||
|
||||
Requires QQQ **4.1.0-RC.5** (the default `qqq-snapshot` profile imports the released BOM;
|
||||
Requires QQQ **4.1.0-RC.8** (the default `qqq-snapshot` profile imports the released BOM;
|
||||
its historical property name does not select a core SNAPSHOT).
|
||||
|
||||
New `cdn_revalidation_hook` rows default `signatureFormat` to `STANDARD_WEBHOOKS`;
|
||||
@@ -106,6 +120,26 @@ old receivers; configure internal network exceptions explicitly before dispatch.
|
||||
See [receiver migration](docs/revalidation-hook-migration.md) for the inspected
|
||||
KoF22 receiver and exact changes required.
|
||||
|
||||
## In-app help
|
||||
|
||||
The qbit registers [help/qbit-cdn.yaml](src/main/resources/help/qbit-cdn.yaml)
|
||||
as a `QBIT` bundle, versioned with the current POM version (`0.1.0-SNAPSHOT`).
|
||||
QQQ 4.1.0-RC.7 syncs it at startup when the host installs the help tables;
|
||||
registration is harmless without them. Keep the literal bundle version in step
|
||||
with the POM when releasing.
|
||||
|
||||
Hosts can register their own YAML with `layer: HOST` using
|
||||
`HelpBundles.register(qInstance, "help/my-host.yaml")`. Entries with the same
|
||||
key and role override this bundle; user-edited help retains priority. Use the
|
||||
configured app name and concrete `cdnPurge_<table>` process keys for overrides.
|
||||
The qbit binds the shared `app:cdnAdmin;slot:purgeRecords` text to each configured
|
||||
record action and adapts app entries when the host changes the app name.
|
||||
|
||||
`CdnHelpBundleTest` uses `HelpBundleAssertions` to check the shipped resource and
|
||||
the registered bundle with a custom app name and generated record actions.
|
||||
It checks live references, summaries, all owned tables, fields and processes,
|
||||
and additionally requires help for widgets and possible-value sources.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
@@ -124,9 +158,8 @@ with purging enabled. Purge From CDN, Purge Everything, and Retry Purge show a
|
||||
Review warning and a recorded-only Result when sending is unavailable. Requests
|
||||
for enabled providers remain visible with `SKIPPED_NONE_PROVIDER` or
|
||||
`SKIPPED_STAGE_OFF`; with no enabled provider, no new request row is created.
|
||||
The home Connection and Health cards explain the unconfigured state, and purge
|
||||
The home Connection card explains the unconfigured state, and purge
|
||||
activity labels recorded-only requests when disconnected.
|
||||
|
||||
This version does not run origin header checks. The Origin headers card says
|
||||
nothing has been checked until the host application writes results to the
|
||||
standard `cdn_origin_check` table; those results continue to appear normally.
|
||||
Run **Check Origin Headers** on a hostname to populate origin-check results.
|
||||
When a scheduler is configured, a nightly job also checks hostnames that are not Off.
|
||||
@@ -106,7 +106,36 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-cdn.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-cdn.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<!-- the artifact version, for the help bundle version test -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
@@ -194,7 +223,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -17,13 +17,19 @@
|
||||
package com.kingsrook.qbits.cdn;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.cdn.app.CdnWidgets;
|
||||
import com.kingsrook.qbits.cdn.health.CdnProviderHealthChecks;
|
||||
import com.kingsrook.qbits.cdn.model.CdnTables;
|
||||
import com.kingsrook.qbits.cdn.processes.CdnProcesses;
|
||||
import com.kingsrook.qbits.cdn.purge.CdnRecordChangeListener;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplierMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
@@ -45,13 +51,41 @@ public class CdnQBitProducer implements QBitMetaDataProducer<CdnQBitConfig>
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-cdn";
|
||||
public static final String VERSION = "0.1.0";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
private CdnQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = CdnQBitProducer.class.getClassLoader().getResourceAsStream("qbit-cdn.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw (new IllegalStateException("Missing qbit-cdn.properties; cannot determine the CDN qbit artifact version."));
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw (new IllegalStateException("Missing or unfiltered version in qbit-cdn.properties; build with Maven resource filtering."));
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not read the CDN qbit artifact version from qbit-cdn.properties.", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
@@ -162,7 +196,9 @@ public class CdnQBitProducer implements QBitMetaDataProducer<CdnQBitConfig>
|
||||
}
|
||||
output.add(CdnWidgets.app(qBitConfig, tables));
|
||||
output.addSelfToInstance(qInstance);
|
||||
com.kingsrook.qbits.cdn.model.CdnHelp.apply(qInstance, qBitConfig.getAppName());
|
||||
HealthChecksMetaData.ofOrWithNew(qInstance).withSupplier(new HealthCheckSupplierMetaData()
|
||||
.withNamePrefix("cdn.provider").withComponent("cdn").withSupplier(new QCodeReference(CdnProviderHealthChecks.class)));
|
||||
com.kingsrook.qbits.cdn.model.CdnHelp.register(qInstance, qBitConfig);
|
||||
|
||||
if(!qBitConfig.getPurgeableTables().isEmpty())
|
||||
{
|
||||
|
||||
@@ -252,24 +252,6 @@ public record CdnHomeData(List<ProviderView> providers, List<HostnameView> hostn
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Overall state for the Health card: NOT_CONNECTED, ATTENTION or OK.
|
||||
*******************************************************************************/
|
||||
public String overallState()
|
||||
{
|
||||
if(!isConnected())
|
||||
{
|
||||
return ("NOT_CONNECTED");
|
||||
}
|
||||
boolean attention = dead24h > 0 || !openDriftByArea.isEmpty()
|
||||
|| providers.stream().anyMatch(p -> p.isConnected() && ("FAILED".equals(p.lastHealthStatus())
|
||||
|| (p.credentialAgeDays() != null && p.credentialAgeDays() >= ROTATION_WARNING_DAYS)))
|
||||
|| budgets.stream().anyMatch(b -> b.billed() > b.soft());
|
||||
return (attention ? "ATTENTION" : "OK");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Paths billed this month for a provider.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -72,26 +72,6 @@ public final class CdnHomeView
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Health status card.
|
||||
*******************************************************************************/
|
||||
public static String renderHealthCard(CdnHomeData data, String appPath)
|
||||
{
|
||||
String state = data.overallState();
|
||||
String text = "All CDN checks are OK.";
|
||||
if("NOT_CONNECTED".equals(state))
|
||||
{
|
||||
text = "No CDN provider configured: purges are recorded only.";
|
||||
}
|
||||
else if("ATTENTION".equals(state))
|
||||
{
|
||||
text = "Needs attention: see the CDN home.";
|
||||
}
|
||||
return ("<div style=\"" + CARD + "\"><strong>CDN</strong><p>" + esc(text) + "</p><a href=\"" + esc(appPath) + "\">Open the CDN home</a></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Connection card.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -37,13 +37,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** The Admin -> CDN app: a home widget (the single owner of CDN data), four
|
||||
** sections of standard tables, and a status card for the app's Health page.
|
||||
** sections of standard tables.
|
||||
** Processes are never added to the nav.
|
||||
*******************************************************************************/
|
||||
public final class CdnWidgets
|
||||
{
|
||||
public static final String HOME_WIDGET = "cdnHome";
|
||||
public static final String HEALTH_CARD_WIDGET = "cdnHealthCard";
|
||||
public static final String HOME_WIDGET = "cdnHome";
|
||||
|
||||
|
||||
|
||||
@@ -63,9 +62,7 @@ public final class CdnWidgets
|
||||
{
|
||||
return (List.of(
|
||||
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("CDN").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false).withPermissionRules(CdnTables.permissionRules()),
|
||||
new QWidgetMetaData().withName(HEALTH_CARD_WIDGET).withLabel("CDN").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(HealthCardRenderer.class)).withGridColumns(12).withIsCard(true).withPermissionRules(CdnTables.permissionRules())));
|
||||
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false).withPermissionRules(CdnTables.permissionRules())));
|
||||
}
|
||||
|
||||
|
||||
@@ -132,21 +129,4 @@ public final class CdnWidgets
|
||||
return (new RenderWidgetOutput(new RawHTML(null, CdnHomeView.render(CdnHomeData.load(), tablePathPrefix()))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Health status card renderer.
|
||||
*******************************************************************************/
|
||||
public static class HealthCardRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML("CDN", CdnHomeView.renderHealthCard(CdnHomeData.load(), appPath()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.cdn.health;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import com.kingsrook.qbits.cdn.CdnRuntime;
|
||||
import com.kingsrook.qbits.cdn.model.CdnTables;
|
||||
import com.kingsrook.qbits.cdn.purge.CdnPurgeDispatcher;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnHealth;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnHealthProbe;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnOperations;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderConfig;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderInterface;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplier;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthDetails;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Central health checks: an adapter's own health probe when it has one
|
||||
** (CloudFront), else the read-only connection test, else UP/not testable.
|
||||
*******************************************************************************/
|
||||
public class CdnProviderHealthChecks implements HealthCheckSupplier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Resolve the enabled provider rows each time the registry refreshes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<HealthCheckMetaData> supply(QInstance qInstance) throws QException
|
||||
{
|
||||
List<HealthCheckMetaData> checks = new ArrayList<>();
|
||||
for(QRecord provider : new QueryAction().execute(new QueryInput(CdnTables.PROVIDER)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)))).getRecords())
|
||||
{
|
||||
Serializable id = provider.getValue("id");
|
||||
String name = "cdn.provider." + id.toString().toLowerCase(Locale.ROOT);
|
||||
checks.add(new HealthCheckMetaData().withName(name).withLabel(name).withComponent("cdn")
|
||||
.withKind(HealthCheckKind.DEPENDENCY).withCategory(HealthCategory.CDN)
|
||||
.withIntervalSeconds(900).withTimeoutMs(20000).withCheckInstance(input -> run(id, input)));
|
||||
}
|
||||
return (checks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reload configuration so disabled or deleted providers are not probed.
|
||||
** Never copy provider messages, addresses or exception text into outcomes.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome run(Serializable id, HealthCheckInput input) throws QException
|
||||
{
|
||||
if(!Instant.now().isBefore(input.getDeadline()))
|
||||
{
|
||||
return (HealthCheckOutcome.down("CDN connection test timed out"));
|
||||
}
|
||||
QRecord provider = new GetAction().executeForRecord(new GetInput(CdnTables.PROVIDER).withPrimaryKey(id));
|
||||
if(provider == null || !Boolean.TRUE.equals(provider.getValueBoolean("enabled")))
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("CDN provider is disabled or removed"));
|
||||
}
|
||||
CdnProviderConfig config = CdnPurgeDispatcher.configOf(provider);
|
||||
CdnProviderInterface adapter = CdnRuntime.provider(config.type());
|
||||
if(adapter instanceof CdnHealthProbe healthProbe)
|
||||
{
|
||||
return (bounded(() -> fromProbe(healthProbe.checkHealth(config), config), input));
|
||||
}
|
||||
if(adapter instanceof CdnOperations operations)
|
||||
{
|
||||
return (bounded(() -> fromConnectionTest(operations.connectionTest(config), config), input));
|
||||
}
|
||||
return (notTestable(config));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An adapter with nothing to probe (the NONE placeholder, or one without a
|
||||
** connection test) is UP and says it is not testable. UNKNOWN would sit on
|
||||
** the dashboard forever: the central evaluator never alerts or changes
|
||||
** state on UNKNOWN.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome notTestable(CdnProviderConfig config)
|
||||
{
|
||||
boolean none = config.type() == CdnProviderType.NONE;
|
||||
return (HealthCheckOutcome.up(none ? "No CDN configured; purges are recorded only" : "CDN provider configured; not testable (no connection test)")
|
||||
.withDetails(new HealthDetails().withValue("providerType", config.type().name()).withValue("testable", false)
|
||||
.withValue("reason", none ? "no CDN provider" : "provider type has no connection test")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map an adapter health probe. Its contract (CdnHealth) is fixed messages
|
||||
** and safe details only.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome fromProbe(CdnHealth health, CdnProviderConfig config)
|
||||
{
|
||||
HealthCheckOutcome outcome = switch(health.state())
|
||||
{
|
||||
case UP -> HealthCheckOutcome.up(health.message());
|
||||
case DEGRADED -> HealthCheckOutcome.degraded(health.message());
|
||||
case DOWN -> HealthCheckOutcome.down(health.message());
|
||||
};
|
||||
HealthDetails details = new HealthDetails().withValue("providerType", config.type().name());
|
||||
health.details().forEach(details::withValue);
|
||||
return (outcome.withDetails(details));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map a provider-neutral connection test.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome fromConnectionTest(CdnOperations.ConnectionTest test, CdnProviderConfig config)
|
||||
{
|
||||
HealthCheckOutcome outcome = !test.ok() ? HealthCheckOutcome.down("CDN connection test failed")
|
||||
: !test.originHeaderConfigured() ? HealthCheckOutcome.degraded("CDN origin header is not configured")
|
||||
: HealthCheckOutcome.up("CDN connection test passed");
|
||||
// Only known provider states are safe; arbitrary status text may contain secrets.
|
||||
String status = "Deployed".equals(test.status()) ? "Deployed" : "InProgress".equals(test.status()) ? "InProgress" : "UNKNOWN";
|
||||
return (outcome.withDetails(new HealthDetails().withValue("providerType", config.type().name())
|
||||
.withValue("status", status).withValue("originHeaderConfigured", test.originHeaderConfigured())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The SPI has no timeout parameter. Bound its wait by the remaining
|
||||
** deadline and interrupt the probe on timeout or runner cancellation.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome bounded(Callable<HealthCheckOutcome> probe, HealthCheckInput input)
|
||||
{
|
||||
long remaining = Duration.between(Instant.now(), input.getDeadline()).toNanos();
|
||||
if(remaining <= 0)
|
||||
{
|
||||
return (HealthCheckOutcome.down("CDN connection test timed out"));
|
||||
}
|
||||
FutureTask<HealthCheckOutcome> task = new FutureTask<>(probe);
|
||||
Thread.startVirtualThread(task);
|
||||
try
|
||||
{
|
||||
return (task.get(remaining, TimeUnit.NANOSECONDS));
|
||||
}
|
||||
catch(TimeoutException e)
|
||||
{
|
||||
return (HealthCheckOutcome.down("CDN connection test timed out"));
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
return (HealthCheckOutcome.down("CDN connection test interrupted"));
|
||||
}
|
||||
catch(ExecutionException e)
|
||||
{
|
||||
return (HealthCheckOutcome.down("CDN connection test failed"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
task.cancel(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,31 +17,20 @@
|
||||
package com.kingsrook.qbits.cdn.model;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qbits.cdn.CdnQBitConfig;
|
||||
import com.kingsrook.qbits.cdn.processes.CdnProcesses;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundle;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleEntry;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-cdn.yaml (the QQQ help-bundle format) to the CDN app
|
||||
** and its cdn_* tables: what each table is for and how its rows get created,
|
||||
** shown on the table screen and when it is empty.
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned QQQ
|
||||
** release includes it; the bundle file stays the same.
|
||||
** Registers CDN help and binds its reusable record-action text to host tables.
|
||||
*******************************************************************************/
|
||||
public final class CdnHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-cdn.yaml";
|
||||
public static final String SLOT = "help";
|
||||
public static final String RESOURCE = "help/qbit-cdn.yaml";
|
||||
public static final String APP_KEY = "app:cdnAdmin;slot:help";
|
||||
|
||||
|
||||
@@ -56,71 +45,29 @@ public final class CdnHelp
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
** Registration is metadata-only. Core's startup sync guards the absence of
|
||||
** helpContent, so hosts without the help tables can still install this qbit.
|
||||
** RC.7 supports suffix wildcards only; bind the dynamic purge names explicitly.
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
public static void register(QInstance qInstance, CdnQBitConfig config)
|
||||
{
|
||||
try(InputStream stream = CdnHelp.class.getResourceAsStream(RESOURCE))
|
||||
HelpBundles.register(qInstance, RESOURCE);
|
||||
var bundles = HelpBundles.of(qInstance).getBundles();
|
||||
HelpBundle bundle = bundles.get(bundles.size() - 1);
|
||||
HelpBundleEntry purgeHelp = bundle.getEntries().stream()
|
||||
.filter(entry -> entry.getKey().equals("app:cdnAdmin;slot:purgeRecords")).findFirst().orElseThrow();
|
||||
for(String table : config.getPurgeableTables())
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IOException("missing resource"));
|
||||
}
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-cdn".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("invalid bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
String summary = entry.path("summary").asText();
|
||||
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("invalid or duplicate entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
bundle.getEntries().add(new HelpBundleEntry()
|
||||
.withKey("process:" + CdnProcesses.PURGE_PREFIX + table + ";slot:help")
|
||||
.withSummary(purgeHelp.getSummary()).withContent(purgeHelp.getContent()).withFormat(purgeHelp.getFormat()));
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
for(HelpBundleEntry entry : bundle.getEntries())
|
||||
{
|
||||
throw (new QException("Could not load " + RESOURCE, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every cdn_* table in the instance. A host's
|
||||
** own help in the slot is kept (the bundle only fills it).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance, String appName) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
|
||||
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
if(entry.getKey().startsWith("app:cdnAdmin;"))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry));
|
||||
entry.setKey(entry.getKey().replace("app:cdnAdmin;", "app:" + config.getAppName() + ";"));
|
||||
}
|
||||
}
|
||||
QAppMetaData app = qInstance.getApp(appName);
|
||||
JsonNode appEntry = entries.get(APP_KEY);
|
||||
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(appEntry));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
|
||||
}
|
||||
}
|
||||
@@ -525,7 +525,7 @@ public final class CdnProviderProcesses
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled: daily drift check (and connection test) for every operable
|
||||
** Scheduled: daily drift check for every operable
|
||||
** provider.
|
||||
*******************************************************************************/
|
||||
public static class ScheduledDriftStep implements BackendStep
|
||||
@@ -538,7 +538,6 @@ public final class CdnProviderProcesses
|
||||
{
|
||||
for(QRecord provider : operableProviders())
|
||||
{
|
||||
testAndRecord(provider);
|
||||
driftAndRecord(provider);
|
||||
}
|
||||
}
|
||||
|
||||
+209
-2
@@ -22,14 +22,18 @@ import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalDouble;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnCapability;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnEdgeStat;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnException;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnHeaderDialect;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnHealth;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnHealthProbe;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderConfig;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderInterface;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderType;
|
||||
@@ -41,6 +45,8 @@ import software.amazon.awssdk.core.exception.SdkException;
|
||||
import software.amazon.awssdk.services.cloudfront.CloudFrontClient;
|
||||
import software.amazon.awssdk.services.cloudfront.model.CreateInvalidationResponse;
|
||||
import software.amazon.awssdk.services.cloudfront.model.Distribution;
|
||||
import software.amazon.awssdk.services.cloudfront.model.GetDistributionConfigRequest;
|
||||
import software.amazon.awssdk.services.cloudfront.model.GetDistributionRequest;
|
||||
import software.amazon.awssdk.services.cloudfront.model.GetInvalidationResponse;
|
||||
import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
|
||||
import software.amazon.awssdk.services.cloudwatch.model.Dimension;
|
||||
@@ -70,7 +76,7 @@ import software.amazon.awssdk.services.cloudwatch.model.ScanBy;
|
||||
** {@link #testConnection}, {@link #importStats} and {@link #readConfig}
|
||||
** (compare the result with {@link CloudFrontDriftCheck#compare}).</p>
|
||||
*******************************************************************************/
|
||||
public class CloudFrontCdnProvider implements CdnProviderInterface, com.kingsrook.qbits.cdn.spi.CdnOperations
|
||||
public class CloudFrontCdnProvider implements CdnProviderInterface, com.kingsrook.qbits.cdn.spi.CdnOperations, CdnHealthProbe
|
||||
{
|
||||
/** Default origin custom header that proves a request came through CloudFront. */
|
||||
public static final String DEFAULT_ORIGIN_VERIFY_HEADER = "X-Origin-Verify";
|
||||
@@ -81,6 +87,22 @@ public class CloudFrontCdnProvider implements CdnProviderInterface, com.kingsroo
|
||||
static final String STATUS_COMPLETED = "Completed";
|
||||
static final int SECONDS_PER_DAY = 86_400;
|
||||
|
||||
/** Distribution states CloudFront reports (anything else is reported as UNKNOWN). */
|
||||
static final String STATUS_DEPLOYED = "Deployed";
|
||||
static final String STATUS_IN_PROGRESS = "InProgress";
|
||||
|
||||
/** 5xx error rate (percent, CloudWatch 5xxErrorRate) above which the health check is DEGRADED. */
|
||||
static final double ERROR_RATE_5XX_DEGRADED_PERCENT = 5.0;
|
||||
|
||||
/** How far back the health check averages the 5xx error rate. */
|
||||
static final int ERROR_RATE_WINDOW_MINUTES = 30;
|
||||
|
||||
/** CloudWatch period for the health check's 5xx error rate. */
|
||||
static final int ERROR_RATE_PERIOD_SECONDS = 300;
|
||||
|
||||
/** Health message when the credentials cannot read the distribution status. */
|
||||
static final String NOT_TESTABLE_MESSAGE = "CloudFront configured; distribution status not testable with current permissions";
|
||||
|
||||
private static final Set<String> RETRYABLE_CODES = Set.of(
|
||||
"TooManyInvalidationsInProgress", "Throttling", "ThrottlingException", "RequestLimitExceeded", "ServiceUnavailable", "InternalError");
|
||||
|
||||
@@ -278,6 +300,181 @@ public class CloudFrontCdnProvider implements CdnProviderInterface, com.kingsroo
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Central health probe. GetDistribution gives existence, Enabled and the
|
||||
** deployment Status: UP when enabled and Deployed, DEGRADED while
|
||||
** InProgress, DOWN when missing or disabled. The section 7.5 IAM policy
|
||||
** grants GetDistributionConfig but not GetDistribution, so on AccessDenied
|
||||
** this falls back to GetDistributionConfig (existence and Enabled, no
|
||||
** Status) and reports {@code testable=false}. If neither read is permitted
|
||||
** it reports UP with {@code testable=false}: UNKNOWN never alerts anyone,
|
||||
** and failed invalidations still show up as FAILED/DEAD purge requests.
|
||||
** The CloudWatch 5xxErrorRate over the last
|
||||
** {@value #ERROR_RATE_WINDOW_MINUTES} minutes is added when readable and
|
||||
** makes the check DEGRADED above {@value #ERROR_RATE_5XX_DEGRADED_PERCENT}
|
||||
** percent. Messages are fixed text; no AWS message, id or hostname is
|
||||
** copied. Never throws.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public CdnHealth checkHealth(CdnProviderConfig config)
|
||||
{
|
||||
String headerName = config.settings().getOrDefault(SETTING_ORIGIN_VERIFY_HEADER, DEFAULT_ORIGIN_VERIFY_HEADER);
|
||||
String distributionId;
|
||||
try
|
||||
{
|
||||
distributionId = distributionId(config);
|
||||
}
|
||||
catch(CdnException e)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DOWN, "CloudFront distribution id is not configured", null);
|
||||
}
|
||||
|
||||
Map<String, Object> details = new LinkedHashMap<>();
|
||||
CloudFrontConfigSnapshot snapshot;
|
||||
String status = null;
|
||||
try(CloudFrontClient client = clientFactory.cloudFront(config))
|
||||
{
|
||||
try
|
||||
{
|
||||
Distribution distribution = client.getDistribution(GetDistributionRequest.builder().id(distributionId).build()).distribution();
|
||||
status = distribution.status();
|
||||
snapshot = CloudFrontConfigSnapshot.from(distribution.distributionConfig());
|
||||
}
|
||||
catch(AwsServiceException e)
|
||||
{
|
||||
if(!isAccessDenied(e))
|
||||
{
|
||||
return apiFailure(e);
|
||||
}
|
||||
details.put("testable", false);
|
||||
try
|
||||
{
|
||||
snapshot = CloudFrontConfigSnapshot.from(client.getDistributionConfig(GetDistributionConfigRequest.builder().id(distributionId).build()).distributionConfig());
|
||||
details.put("reason", "credentials lack cloudfront:GetDistribution");
|
||||
}
|
||||
catch(AwsServiceException e2)
|
||||
{
|
||||
if(!isAccessDenied(e2))
|
||||
{
|
||||
return apiFailure(e2);
|
||||
}
|
||||
details.put("reason", "credentials lack cloudfront:GetDistribution and cloudfront:GetDistributionConfig");
|
||||
return new CdnHealth(CdnHealth.State.UP, NOT_TESTABLE_MESSAGE, details);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(CdnException e)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DOWN, "CloudFront credentials are not configured", null);
|
||||
}
|
||||
catch(SdkClientException e)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DEGRADED, "CloudFront API unreachable; distribution status not checked", Map.of("transient", true));
|
||||
}
|
||||
catch(SdkException e)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DOWN, "CloudFront status check failed", null);
|
||||
}
|
||||
|
||||
boolean statusTestable = !details.containsKey("testable");
|
||||
boolean headerConfigured = snapshot.hasOriginHeader(headerName);
|
||||
details.put("enabled", snapshot.enabled());
|
||||
details.put("status", STATUS_DEPLOYED.equals(status) || STATUS_IN_PROGRESS.equals(status) ? status : "UNKNOWN");
|
||||
details.put("originHeaderConfigured", headerConfigured);
|
||||
if(!snapshot.enabled())
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DOWN, "CloudFront distribution is disabled", details);
|
||||
}
|
||||
|
||||
Double errorRate = errorRate5xx(config, distributionId);
|
||||
details.put("errorRateWindowMinutes", ERROR_RATE_WINDOW_MINUTES);
|
||||
if(errorRate != null)
|
||||
{
|
||||
details.put("errorRate5xxPercent", Math.round(errorRate * 100.0) / 100.0);
|
||||
}
|
||||
|
||||
if(STATUS_IN_PROGRESS.equals(status))
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DEGRADED, "CloudFront distribution is deploying a change (InProgress)", details);
|
||||
}
|
||||
if(!headerConfigured)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DEGRADED, "CDN origin header is not configured", details);
|
||||
}
|
||||
if(errorRate != null && errorRate > ERROR_RATE_5XX_DEGRADED_PERCENT)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DEGRADED, "CloudFront 5xx error rate is above 5% over the last 30 minutes", details);
|
||||
}
|
||||
if(!statusTestable)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.UP, NOT_TESTABLE_MESSAGE, details);
|
||||
}
|
||||
return new CdnHealth(CdnHealth.State.UP, "CloudFront distribution is enabled and deployed", details);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an AWS error is a permissions refusal.
|
||||
*******************************************************************************/
|
||||
private static boolean isAccessDenied(AwsServiceException e)
|
||||
{
|
||||
String code = e.awsErrorDetails() == null ? null : e.awsErrorDetails().errorCode();
|
||||
return "AccessDenied".equals(code) || "AccessDeniedException".equals(code) || (code == null && e.statusCode() == 403);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Health for an AWS error other than AccessDenied: a missing distribution is
|
||||
** DOWN, throttling and AWS-side errors are DEGRADED (transient), anything
|
||||
** else (rejected credentials, bad id) is DOWN.
|
||||
*******************************************************************************/
|
||||
private static CdnHealth apiFailure(AwsServiceException e)
|
||||
{
|
||||
String code = e.awsErrorDetails() == null ? null : e.awsErrorDetails().errorCode();
|
||||
if("NoSuchDistribution".equals(code))
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DOWN, "CloudFront distribution not found", null);
|
||||
}
|
||||
if(toCdnException("GetDistribution", e).isRetryable())
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DEGRADED, "CloudFront API throttled or unavailable; distribution status not checked", Map.of("transient", true));
|
||||
}
|
||||
return new CdnHealth(CdnHealth.State.DOWN, "CloudFront rejected the status check", null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Average CloudWatch 5xxErrorRate (percent) over the health window, or null
|
||||
** when there is no traffic or the metric cannot be read. Best effort: it
|
||||
** never fails the health check.
|
||||
*******************************************************************************/
|
||||
private Double errorRate5xx(CdnProviderConfig config, String distributionId)
|
||||
{
|
||||
Instant end = Instant.now();
|
||||
try(CloudWatchClient client = clientFactory.cloudWatch(config))
|
||||
{
|
||||
GetMetricDataResponse response = client.getMetricData(GetMetricDataRequest.builder()
|
||||
.metricDataQueries(query("err5xx", "5xxErrorRate", "Average", distributionId, ERROR_RATE_PERIOD_SECONDS))
|
||||
.startTime(end.minusSeconds(ERROR_RATE_WINDOW_MINUTES * 60L))
|
||||
.endTime(end)
|
||||
.build());
|
||||
OptionalDouble average = response.metricDataResults().stream()
|
||||
.flatMap(result -> result.values().stream())
|
||||
.mapToDouble(Double::doubleValue)
|
||||
.average();
|
||||
return average.isPresent() ? average.getAsDouble() : null;
|
||||
}
|
||||
catch(CdnException | SdkException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The distribution config facts used for drift checks (GetDistributionConfig).
|
||||
*******************************************************************************/
|
||||
@@ -368,6 +565,16 @@ public class CloudFrontCdnProvider implements CdnProviderInterface, com.kingsroo
|
||||
** One daily CloudFront metric query.
|
||||
*******************************************************************************/
|
||||
private static MetricDataQuery query(String id, String metricName, String stat, String distributionId)
|
||||
{
|
||||
return query(id, metricName, stat, distributionId, SECONDS_PER_DAY);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One CloudFront metric query with the given period (seconds).
|
||||
*******************************************************************************/
|
||||
private static MetricDataQuery query(String id, String metricName, String stat, String distributionId, int periodSeconds)
|
||||
{
|
||||
Metric metric = Metric.builder()
|
||||
.namespace("AWS/CloudFront")
|
||||
@@ -377,7 +584,7 @@ public class CloudFrontCdnProvider implements CdnProviderInterface, com.kingsroo
|
||||
.build();
|
||||
return MetricDataQuery.builder()
|
||||
.id(id)
|
||||
.metricStat(MetricStat.builder().metric(metric).period(SECONDS_PER_DAY).stat(stat).build())
|
||||
.metricStat(MetricStat.builder().metric(metric).period(periodSeconds).stat(stat).build())
|
||||
.returnData(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.cdn.spi;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result of an adapter's own health probe (see {@link CdnHealthProbe}). The
|
||||
** message and every detail value must be fixed text, numbers, booleans or
|
||||
** known provider states: never provider-supplied free text, hostnames, ids
|
||||
** or credentials, because they are copied to the central health tables.
|
||||
**
|
||||
** @param state UP, DEGRADED or DOWN
|
||||
** @param message a short fixed summary
|
||||
** @param details safe facts (may be empty, never null)
|
||||
*******************************************************************************/
|
||||
public record CdnHealth(State state, String message, Map<String, Object> details)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Health as the adapter sees it.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
UP,
|
||||
DEGRADED,
|
||||
DOWN
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null details become an empty map.
|
||||
*******************************************************************************/
|
||||
public CdnHealth
|
||||
{
|
||||
details = details == null ? Map.of() : Map.copyOf(details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.cdn.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional: an adapter that can say whether its CDN is healthy, beyond the
|
||||
** "Test connection" action ({@link CdnOperations}). The central health check
|
||||
** prefers this when an adapter implements it.
|
||||
*******************************************************************************/
|
||||
public interface CdnHealthProbe
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Probe the CDN read-only. Never throws: failures come back as DOWN or
|
||||
** DEGRADED, with fixed messages (see {@link CdnHealth}).
|
||||
*******************************************************************************/
|
||||
CdnHealth checkHealth(CdnProviderConfig config);
|
||||
}
|
||||
@@ -1,58 +1,487 @@
|
||||
bundle: qbit-cdn
|
||||
version: 1
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
# Help for the CDN app and every cdn_* table, in the QQQ help-bundle format
|
||||
# (qqq#1198). Each table entry says what the table is for and how its rows get
|
||||
# created, so an empty table explains itself. CdnHelp applies it until the core
|
||||
# HelpBundles registry ships in a QQQ release (STAND-IN(core=#1198)).
|
||||
# Summaries stay under 120 characters (CdnTablesGuardrailTest).
|
||||
entries:
|
||||
- key: app:cdnAdmin;slot:help
|
||||
summary: "Put a CDN in front of the application: cache headers, change-driven purges, edge statistics and checks."
|
||||
content: |
|
||||
The Setup card on the home lists each part (provider, hostnames, cache policies, tag rules, revalidation hooks, origin checks) with its status and the next step. A new installation has nothing: run Create default provider on Providers (No CDN records purges and sends nothing), Create default rules on Tag rules, and add your CDN when you have one.
|
||||
- key: app:cdnAdmin;slot:help
|
||||
summary: Manage your CDN connection, cache rules, purges and edge health.
|
||||
content: |
|
||||
A CDN keeps copies of your pages close to visitors. Use this app to configure how your application caches responses and clears old copies when records change.
|
||||
|
||||
- key: table:cdn_provider;slot:help
|
||||
summary: "A CDN the application purges: its type, distribution id and the variable that holds its key."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one with New (pick the type, the distribution or zone id, and name the environment variable that holds the API key; the key itself is never stored), or runs Create default provider, which adds "No CDN (record only)": every record change is then recorded as a would-be purge and nothing is sent. Test Connection, Import Edge Stats and Check Config Drift are actions on a provider.
|
||||
The **Setup** card shows what you still need to configure. Open **Connection** for providers and hostnames, **Settings** for cache policies and tag rules, **Activity** for purge requests and revalidation hooks, and **Performance** for statistics, origin checks and drift findings.
|
||||
|
||||
- key: table:cdn_hostname;slot:help
|
||||
summary: "A hostname behind a provider and its rollout stage: Off, Headers only, Live or Locked."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one with New for each hostname the CDN serves, at stage Off, then moves it to Headers only and Live as the rollout proceeds. Only Live and Locked hostnames with purging enabled are sent purges. Check Origin Headers is an action on a hostname, and hostnames that are not Off are checked every night. The role field is a label for your own reference and changes nothing.
|
||||
Start with **Create default provider** and **Create default rules**. Add your real provider and hostnames when you are ready. Purges require an enabled provider and a Live or Locked hostname with purging enabled. With No CDN, requests are recorded but not sent; with no enabled provider, no request is recorded.
|
||||
|
||||
- key: table:cdn_cache_policy;slot:help
|
||||
summary: "Cache-Control rules by path, route class or table; the lowest priority number that matches wins."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one with New, or runs Create default policies, which adds two turned-off examples (immutable static assets, never cache) to edit and turn on. A policy changes a response only where the application calls CdnResponseHeaderPolicy.decide when it answers; the qbit does not rewrite responses on its own. A response that sets a cookie or answers a signed-in user is always private, whatever the policies say.
|
||||
You can use **Purge From CDN** on tables your application makes purgeable, or **{{label:cdnPurgeHostname}}** on a hostname. Your access depends on the CDN view, purge, purge-all and admin permissions granted by your application.
|
||||
- key: table:cdn_provider;slot:help
|
||||
summary: The CDN service in front of the site, with the account details used to talk to it and its latest health.
|
||||
content: |
|
||||
A provider row says which CDN you use (or that you use none), which distribution or zone to clear, and which server settings hold the credentials. While its type is **No CDN**, purge requests are recorded but nothing is sent.
|
||||
|
||||
- key: table:cdn_table_tag_rule;slot:help
|
||||
summary: "How a table's record changes become cache tags and invalidation paths for purges."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one with New for each purgeable table the application lists, or runs Create default rules, which adds a tags-only rule for every purgeable table that has none (tags "table:id" and "table"). Add invalidation paths where your CDN purges by path, and a published field so draft edits purge nothing.
|
||||
Credentials are never stored here. **{{label:cdn_provider.secretRef}}** names the prefix of server settings that hold them. After changing the type or distribution, run **{{label:cdnTestConnection}}**; it records the result and fills in the DNS target and origin.
|
||||
|
||||
- key: table:cdn_purge_request;slot:help
|
||||
summary: "Every purge the qbit recorded or sent, with its status, paths and tags."
|
||||
content: |
|
||||
**How rows get created:** only the qbit writes it: a change to a purgeable record, Purge From CDN on a record, or Purge Everything on a hostname. Without a provider nothing is recorded, so run Create default provider first. Retry Purge is an action here.
|
||||
Other actions: **{{label:cdnImportEdgeStats}}** and **{{label:cdnCheckDrift}}**. When a scheduler is configured, daily jobs run both automatically. Editing needs the CDN admin permission.
|
||||
|
||||
- key: table:cdn_revalidation_hook;slot:help
|
||||
summary: "A signed webhook called before a CDN purge, so a front-end cache (for example Next.js) refreshes first."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one with New: the URL, the signature format and the environment variable that holds the signing secret (never the secret). It is optional; add one only when a front end keeps its own cache.
|
||||
**How rows get created:** you add one with New (pick the type, the distribution or zone id, and name the environment variable that holds the API key; the key itself is never stored), or run Create default provider, which adds "No CDN (record only)": matching changes to configured purgeable tables are then recorded as a would-be purge and nothing is sent. Test Connection, Import Edge Stats and Check Config Drift are actions on a provider.
|
||||
- key: table:cdn_provider;field:name
|
||||
summary: A short name for this CDN connection, shown on the CDN home screen.
|
||||
- key: table:cdn_provider;field:providerType
|
||||
summary: Which CDN service this is. Choose No CDN until a real one is connected.
|
||||
content: |
|
||||
See the list for the values. Only **Amazon CloudFront** and **Souin (self-hosted)** can send purges today; the others are listed for future use. With **No CDN** nothing is sent and purge requests are marked "Not sent (no CDN)".
|
||||
- key: table:cdn_provider;field:enabled
|
||||
summary: Turn off to stop purging and checking this provider without deleting it.
|
||||
content: |
|
||||
Disabled providers get no new purge requests and are skipped by the daily jobs.
|
||||
- key: table:cdn_provider;field:accountRef
|
||||
summary: The account the CDN belongs to, for your own reference, such as the AWS account number.
|
||||
- key: table:cdn_provider;field:zoneOrDistributionId
|
||||
summary: 'The CDN''s own id for what to clear: the CloudFront distribution id, or the Souin API address.'
|
||||
content: |
|
||||
Required for purges and checks to work. Purges to the wrong id clear the wrong cache, so copy it exactly from the CDN console.
|
||||
- key: table:cdn_provider;field:secretRef
|
||||
summary: The name prefix of the server settings that hold the CDN credentials. Not the credentials themselves.
|
||||
content: |
|
||||
For CloudFront, a prefix such as CDN_AWS means the server must have CDN_AWS_ACCESS_KEY_ID and CDN_AWS_SECRET_ACCESS_KEY set. Credentials are set on the server by an administrator and never typed here.
|
||||
- key: table:cdn_provider;field:settingsJson
|
||||
summary: Extra provider settings as JSON, such as the flush window and the invalidation budget.
|
||||
content: |
|
||||
Use JSON for provider settings such as the flush window or path budget. Values are strings. Missing settings use the application defaults. Ask your administrator which settings your provider supports before changing them.
|
||||
- key: table:cdn_provider;field:dnsTarget
|
||||
summary: The address your DNS records should point at to use this CDN. Filled in by Test Connection.
|
||||
- key: table:cdn_provider;field:originHost
|
||||
summary: The server the CDN fetches pages from, with its port. Filled in by Test Connection.
|
||||
- key: table:cdn_provider;field:credentialRotatedDate
|
||||
summary: When the CDN credentials were last replaced. Enter it by hand after you rotate them.
|
||||
content: |
|
||||
Enter this date after you rotate the credentials. The home screen shows their age and warns from 75 days; choose a rotation policy appropriate for your application.
|
||||
- key: table:cdn_provider;field:maskedKeyId
|
||||
summary: A masked access key identifier, if supplied by your application.
|
||||
content: |
|
||||
This version does not populate this field. Never enter a secret here.
|
||||
- key: table:cdn_provider;field:lastHealthStatus
|
||||
summary: Result of the last connection test, OK or FAILED. Filled in by Test Connection and the daily check.
|
||||
- key: table:cdn_provider;field:lastHealthMessage
|
||||
summary: What the last connection test found, or why it failed. Filled in automatically.
|
||||
- key: table:cdn_provider;field:lastHealthCheckDate
|
||||
summary: When the connection was last tested, filled in automatically.
|
||||
- key: table:cdn_provider;field:lastSuccessfulTestDate
|
||||
summary: When a connection test last passed, filled in automatically.
|
||||
- key: table:cdn_hostname;slot:help
|
||||
summary: A web address served through the CDN, and how far along it is in being switched over.
|
||||
content: |
|
||||
Add one row per hostname, such as the site, the API and the media host. **{{label:cdn_hostname.stage}}** lets you roll the CDN out gradually: purges are only sent when at least one hostname of the provider is **Live** or **Locked to CDN** and has purging on.
|
||||
|
||||
- key: table:cdn_edge_stat_daily;slot:help
|
||||
summary: "Daily edge statistics imported from the CDN: requests, bytes, hit rate and error rates."
|
||||
content: |
|
||||
**How rows get created:** Import Edge Stats on a provider writes it, and a daily scheduled run does the same for every provider that supports it. A No CDN provider has no statistics.
|
||||
Use **{{label:cdnPurgeHostname}}** to clear a provider's whole cache. Editing needs the CDN admin permission.
|
||||
|
||||
- key: table:cdn_origin_check;slot:help
|
||||
summary: "Results of asking a hostname's origin for sample pages and comparing their Cache-Control headers."
|
||||
content: |
|
||||
**How rows get created:** Check Origin Headers on a hostname writes one row per hostname, and a nightly scheduled run does the same for every hostname that is not Off. It requests the home page, the paths of prefix cache policies and the literal paths of tag rules over HTTPS and counts the right, missing or wrong, and unreadable ones.
|
||||
**How rows get created:** you add one with New for each hostname the CDN serves, at stage Off, then move it to Headers only and Live as the rollout proceeds. Only Live and Locked hostnames with purging enabled are sent purges. Check Origin Headers is an action on a hostname, and hostnames that are not Off are checked nightly when your application configures a scheduler. The role field is a label for your own reference and changes nothing.
|
||||
- key: table:cdn_hostname;field:hostname
|
||||
summary: Enter the hostname without a scheme or path, for example www.example.com.
|
||||
- key: table:cdn_hostname;field:cdnProviderId
|
||||
summary: The CDN provider that serves this hostname.
|
||||
- key: table:cdn_hostname;field:hostnameRole
|
||||
summary: What the hostname is used for, such as SITE, API or MEDIA. A label for your own reference.
|
||||
- key: table:cdn_hostname;field:stage
|
||||
summary: How far this hostname is switched over to the CDN.
|
||||
content: |
|
||||
- **Off** (default: {{default:cdn_hostname.stage}}): not using the CDN yet.
|
||||
- **Headers only**: a preparation step. Purges are not sent yet.
|
||||
- **Live**: in use, and purges are sent.
|
||||
- **Locked to CDN**: the final step, locked to the CDN. Purges are sent.
|
||||
|
||||
- key: table:cdn_drift_finding;slot:help
|
||||
summary: "Differences between the CDN's expected configuration and what the provider reports."
|
||||
content: |
|
||||
**How rows get created:** Check Config Drift on a provider writes it, and a nightly scheduled run does the same. It needs the expected configuration in the environment variable the provider names (CDN_EXPECTED_CONFIG by default); a finding is resolved when it no longer appears.
|
||||
If no hostname of the provider is Live or Locked, new purge requests are marked "Not sent (hostnames not live)".
|
||||
- key: table:cdn_hostname;field:purgeEnabled
|
||||
summary: Whether purges count for this hostname. Turn off to stop purging for it.
|
||||
content: |
|
||||
A purge is only sent when at least one hostname of the provider is Live or Locked and has this on.
|
||||
- key: table:cdn_hostname;field:notes
|
||||
summary: Free-form notes about this hostname.
|
||||
- key: table:cdn_cache_policy;slot:help
|
||||
summary: Rules that decide how long browsers and the CDN may keep a page or file before asking again.
|
||||
content: |
|
||||
Each rule matches requests by address, kind of route or table, and gives the cache lifetimes to send. The rule with the lowest **{{label:cdn_cache_policy.priority}}** number that matches wins. A rule tied to a **Hostname** applies only there.
|
||||
|
||||
Responses to signed-in visitors, and responses that set a cookie, are always sent as private and never cached, whatever the rules say. A rule with **Never Store** also sends private, no-store.
|
||||
|
||||
Changes can take up to a minute to be noticed. Editing needs the CDN admin permission.
|
||||
|
||||
**How rows get created:** you add one with New, or run Create default policies, which adds two turned-off examples (immutable static assets, never cache) to edit and turn on. A policy changes a response only where the application calls the CDN header policy integration when it answers; the qbit does not rewrite responses on its own. A response that sets a cookie or answers a signed-in user is always private, whatever the policies say.
|
||||
- key: table:cdn_cache_policy;field:name
|
||||
summary: A short name that says what the rule is for, such as Static assets.
|
||||
- key: table:cdn_cache_policy;field:cdnHostnameId
|
||||
summary: Limits the rule to one hostname. Leave empty to apply it to all hostnames.
|
||||
- key: table:cdn_cache_policy;field:matchType
|
||||
summary: How the rule picks the requests it applies to.
|
||||
content: |
|
||||
How the rule picks requests: PREFIX, GLOB, ROUTE_CLASS or TABLE.
|
||||
|
||||
- **PREFIX**: the address starts with the value, for example /blogs/.
|
||||
- **GLOB**: the address matches a pattern where * stands for anything.
|
||||
- **ROUTE_CLASS**: the kind of route the site assigns to the page.
|
||||
- **TABLE**: the response is for records of the named table.
|
||||
Any other value never matches.
|
||||
- key: table:cdn_cache_policy;field:matchValue
|
||||
summary: What to match against, in the form the match type needs, such as /blogs/ for a prefix.
|
||||
- key: table:cdn_cache_policy;field:policyClass
|
||||
summary: 'What kind of content the rule is for: {{values:cdn_policy_class}}.'
|
||||
content: |
|
||||
- **{{label:cdn_cache_policy.immutable}}**: files that never change at the same address.
|
||||
- **Leaf**: a single page or record.
|
||||
- **Hub**: a list page that changes whenever its items do.
|
||||
- **Never cache**: private or live content.
|
||||
It describes the rule; the lifetimes and flags below decide what is actually sent.
|
||||
- key: table:cdn_cache_policy;field:browserMaxAge
|
||||
summary: Seconds a visitor's browser may reuse the page without asking. Empty counts as 0.
|
||||
- key: table:cdn_cache_policy;field:edgeMaxAge
|
||||
summary: Seconds the CDN may keep its copy, sent as s-maxage. Leave empty to not set one.
|
||||
- key: table:cdn_cache_policy;field:staleWhileRevalidate
|
||||
summary: Seconds an out-of-date copy may still be shown while a fresh one is fetched in the background.
|
||||
- key: table:cdn_cache_policy;field:staleIfError
|
||||
summary: Seconds an out-of-date copy may be shown if the site is failing to answer.
|
||||
- key: table:cdn_cache_policy;field:immutable
|
||||
summary: Tells browsers the content at this address never changes, so they skip rechecking.
|
||||
content: |
|
||||
Only use for files whose address changes when their content does.
|
||||
- key: table:cdn_cache_policy;field:noStore
|
||||
summary: Never cache anything that matches this rule. It is sent as private, no-store.
|
||||
content: |
|
||||
The lifetimes on the rule are ignored when this is on.
|
||||
- key: table:cdn_cache_policy;field:priority
|
||||
summary: 'Which rule wins when several match: the lowest number goes first. Default {{default:cdn_cache_policy.priority}}.'
|
||||
- key: table:cdn_cache_policy;field:enabled
|
||||
summary: Turn off to ignore this rule without deleting it.
|
||||
- key: table:cdn_cache_policy;field:seeded
|
||||
summary: Marks example policies created by Create default policies; you cannot edit this flag.
|
||||
- key: table:cdn_table_tag_rule;slot:help
|
||||
summary: Says which cache tags and page addresses to purge when a record of a table is changed.
|
||||
content: |
|
||||
When a record in the table changes, the rule works out what to clear. **{{label:cdn_table_tag_rule.tagTemplates}}** are labels attached to cached copies. **{{label:cdn_table_tag_rule.invalidationPaths}}** are paths on the site. Both are filled from the record's own values, for example {slug}, or a related record's values, for example {blog.slug}.
|
||||
|
||||
One line per tag or address. A line is skipped when one of its values is empty. Put hub: in front of an address to mark it as a list page. List pages are only cleared by a manual **Purge From CDN**, not by automatic purges, because path-based providers bill for each path.
|
||||
|
||||
Editing needs the CDN admin permission.
|
||||
|
||||
**How rows get created:** you add one with New for each purgeable table the application lists, or run Create default rules, which adds a tags-only rule for every purgeable table that has none (tags "table:id" and "table"). Add invalidation paths where your CDN purges by path, and a published field so draft edits purge nothing.
|
||||
- key: table:cdn_table_tag_rule;field:tableName
|
||||
summary: The table whose changes this rule reacts to. Only tables that are set up as purgeable take effect.
|
||||
- key: table:cdn_table_tag_rule;field:tagTemplates
|
||||
summary: Cache tags to clear, one per line, filled in from the record, for example post:{id}.
|
||||
content: |
|
||||
A tag is a lowercase word, a colon, then printable characters without commas, at most 64 characters in all. Tags that do not fit that form are dropped. Providers that purge by path ignore tags.
|
||||
- key: table:cdn_table_tag_rule;field:invalidationPaths
|
||||
summary: Page addresses to clear, one per line; patterns may use record fields and a trailing wildcard.
|
||||
content: |
|
||||
Page addresses to clear, one per line, such as /blogs/{blog.slug}/{slug}*.
|
||||
|
||||
Each address must start with /. A trailing * means everything below it. Start a line with hub: for list pages such as /blogs. When there are many paths, the system merges them into wildcards to stay within the CDN's limits.
|
||||
- key: table:cdn_table_tag_rule;field:onlyWhenFieldsChange
|
||||
summary: Choose which changed fields trigger an automatic purge; leave empty for any change.
|
||||
content: |
|
||||
Enter comma-separated, case-sensitive field names. Plain names allow only those changes. Prefix a name with ! to ignore it, such as !viewCount. With only ignored names, any other changed field counts; in mixed lists ignored fields never count. This filter applies to updates only. The published-field filter still applies to inserts and deletes.
|
||||
- key: table:cdn_table_tag_rule;field:publishedField
|
||||
summary: The yes/no field that marks a record as live, such as isPublished. Unpublished records are ignored.
|
||||
content: |
|
||||
A change counts when the record is published now or was published before the change. So unpublishing a page still clears it, but editing a draft does nothing. Empty means all changes count.
|
||||
- key: table:cdn_table_tag_rule;field:enabled
|
||||
summary: Turn off to stop this rule without deleting it.
|
||||
- key: table:cdn_purge_request;slot:help
|
||||
summary: 'A record of one clear-the-cache request: what was cleared, why, and whether the CDN accepted it.'
|
||||
content: |
|
||||
Use **{{label:cdn_purge_request.status}}** and **{{label:cdn_purge_request.errorMessage}}** to follow a purge. Automatic changes are gathered within the configured flush window. Manual actions bypass that window, but still need a dispatcher run. Enabled revalidation hooks run before the CDN request.
|
||||
|
||||
Failed requests may retry with backoff. Non-retryable errors or the configured attempt limit make a request Dead. Use **{{label:cdnRetryPurge}}** for Failed, Dead or Deferred requests after resolving the cause.
|
||||
|
||||
**How rows get created:** matching changes to configured purgeable tables, **Purge From CDN**, or **{{label:cdnPurgeHostname}}** record requests for enabled providers. No CDN records a skipped request. Without an enabled provider, no new row is created. You cannot edit these rows by hand.
|
||||
- key: table:cdn_purge_request;field:cdnProviderId
|
||||
summary: The CDN provider this request is for.
|
||||
- key: table:cdn_purge_request;field:kind
|
||||
summary: Whether the request clears tags, paths or everything (TAG, PATHS or ALL). Set automatically.
|
||||
content: |
|
||||
Providers that cannot purge by tag, such as CloudFront, are sent paths instead.
|
||||
- key: table:cdn_purge_request;field:tags
|
||||
summary: The cache tags to clear, one per line.
|
||||
- key: table:cdn_purge_request;field:paths
|
||||
summary: 'The page addresses to clear, one per line. Lines starting with hub: are list pages.'
|
||||
content: |
|
||||
Once the request is sent this shows the paths actually used, after merging into wildcards.
|
||||
- key: table:cdn_purge_request;field:includeHubs
|
||||
summary: Whether list pages are included. Yes for actions you run, no for automatic purges.
|
||||
- key: table:cdn_purge_request;field:billablePaths
|
||||
summary: How many paths this request counts against the monthly invalidation allowance.
|
||||
content: |
|
||||
Set when the request is planned for a provider that bills by path, such as CloudFront. A whole-site purge counts as one.
|
||||
- key: table:cdn_purge_request;field:source
|
||||
summary: What caused this request.
|
||||
content: |
|
||||
- **Record change**: someone edited or published a record.
|
||||
- **Record action**: someone ran **Purge From CDN** on a record.
|
||||
- **Manual**: someone ran **{{label:cdnPurgeHostname}}**.
|
||||
- **Deploy**: sent as part of a deployment.
|
||||
- **Retry**: queued again with **{{label:cdnRetryPurge}}**.
|
||||
- key: table:cdn_purge_request;field:sourceTable
|
||||
summary: The table of the record that caused this request, if there was one.
|
||||
- key: table:cdn_purge_request;field:sourceRecordId
|
||||
summary: The id of the record that caused this request, when it was a single record.
|
||||
- key: table:cdn_purge_request;field:requestedBy
|
||||
summary: Who caused the request. Empty for automatic changes made without a signed-in user.
|
||||
- key: table:cdn_purge_request;field:status
|
||||
summary: 'Where the request is: Pending, Sent, Succeeded, Failed, Dead and others.'
|
||||
content: |
|
||||
- **Pending**: waiting for the flush window or the next run.
|
||||
- **Merged**: combined into another request, see **Merged Into**.
|
||||
- **Sent** and **In progress**: the CDN accepted it and is clearing copies.
|
||||
- **Succeeded**: the CDN finished.
|
||||
- **Failed (will retry)**: will be tried again after a pause.
|
||||
- **Dead**: gave up after repeated failures; use **{{label:cdnRetryPurge}}**.
|
||||
- **Deferred (budget)**: held because the monthly path budget would be exceeded; retry it later or raise the budget.
|
||||
- **Not sent (hostnames not live)**, **Not sent (no CDN)** and **Not sent (hub pages only)**: recorded but deliberately not sent.
|
||||
- key: table:cdn_purge_request;field:mergedIntoId
|
||||
summary: For a merged request, the main request it was combined into.
|
||||
- key: table:cdn_purge_request;field:attempts
|
||||
summary: How many times sending was tried.
|
||||
- key: table:cdn_purge_request;field:nextAttemptDate
|
||||
summary: When a failed request will next be tried. Empty when nothing is scheduled.
|
||||
- key: table:cdn_purge_request;field:providerRequestId
|
||||
summary: The CDN's own id for the request, such as a CloudFront invalidation id. Useful when asking the CDN about it.
|
||||
- key: table:cdn_purge_request;field:errorMessage
|
||||
summary: Why the last attempt failed, as reported by the CDN or the revalidation hooks.
|
||||
- key: table:cdn_purge_request;field:sentDate
|
||||
summary: When the request was sent to the CDN.
|
||||
- key: table:cdn_purge_request;field:completedDate
|
||||
summary: 'When the request reached a final state: finished, dead or deliberately not sent.'
|
||||
- key: table:cdn_revalidation_hook;slot:help
|
||||
summary: A call to the website's own servers, made before a purge, so they drop their copies too.
|
||||
content: |
|
||||
If your front end keeps its own cached pages, you can refresh them before clearing the CDN. Before each purge is sent to the CDN, every enabled hook is called with the list of cache tags that changed. If a hook fails, the purge is not sent and is retried later.
|
||||
|
||||
With **{{label:cdn_revalidation_hook.fanOutDns}}** on, the hook is called once for each address behind the host name, so every server hears about it. If a secret is named, the call is signed. The address is checked for safety first: internal addresses are blocked unless configured. Editing needs the CDN admin permission.
|
||||
|
||||
**How rows get created:** you add one with New: the URL, the signature format and the environment variable that holds the signing secret (never the secret). It is optional; add one only when a front end keeps its own cache.
|
||||
- key: table:cdn_revalidation_hook;field:name
|
||||
summary: A short name for the receiver, such as Website frontend pods.
|
||||
- key: table:cdn_revalidation_hook;field:url
|
||||
summary: The address that is called with a POST. It must answer with a success status.
|
||||
content: |
|
||||
The address is resolved and checked before the call. Redirects are not followed, and replies are read only up to 64 KiB.
|
||||
- key: table:cdn_revalidation_hook;field:fanOutDns
|
||||
summary: Call every server address behind the host name, one by one, instead of just one.
|
||||
content: |
|
||||
Use it when the name leads to several servers, such as a Kubernetes headless service, and each keeps its own cache.
|
||||
- key: table:cdn_revalidation_hook;field:secretRef
|
||||
summary: The name of the server setting holding the signing key. Not the key itself. Empty sends the call unsigned.
|
||||
- key: table:cdn_revalidation_hook;field:timeoutMs
|
||||
summary: How long to wait for each call to finish, in milliseconds. Must be above 0.
|
||||
content: |
|
||||
How long to wait for each call to finish, in milliseconds. Default {{default:cdn_revalidation_hook.timeoutMs}}. Must be above 0.
|
||||
- key: table:cdn_revalidation_hook;field:enabled
|
||||
summary: Turn off to skip this hook without deleting it.
|
||||
- key: table:cdn_edge_stat_daily;slot:help
|
||||
summary: 'Daily traffic figures for the CDN: requests, data served, cache hit rate and error rates.'
|
||||
content: |
|
||||
One row per provider and day, filled in automatically by **{{label:cdnImportEdgeStats}}** and by a daily job when a scheduler is configured. Each import covers the last 3 complete days and updates rows that already exist. Days are UTC days. You cannot edit these rows.
|
||||
|
||||
Figures come from the CDN's own monitoring. The cache hit rate is only available if the CDN's additional metrics are switched on; otherwise it stays empty.
|
||||
|
||||
**How rows get created:** Import Edge Stats on a provider writes it, and, when a scheduler is configured, a daily run does the same for every provider that supports it. A No CDN provider has no statistics.
|
||||
- key: table:cdn_edge_stat_daily;field:cdnProviderId
|
||||
summary: The CDN provider these figures came from.
|
||||
- key: table:cdn_edge_stat_daily;field:scope
|
||||
summary: What the figures cover. Currently the whole distribution.
|
||||
- key: table:cdn_edge_stat_daily;field:statDate
|
||||
summary: The UTC day the figures are for.
|
||||
- key: table:cdn_edge_stat_daily;field:requests
|
||||
summary: How many requests the CDN answered that day.
|
||||
- key: table:cdn_edge_stat_daily;field:bytesDownloaded
|
||||
summary: How much data the CDN sent to visitors that day, in bytes.
|
||||
- key: table:cdn_edge_stat_daily;field:cacheHitRate
|
||||
summary: The percentage of requests answered from the CDN's copy. Higher is better. Empty when not reported.
|
||||
- key: table:cdn_edge_stat_daily;field:error4xxRate
|
||||
summary: The percentage of requests that got a client error such as 404.
|
||||
- key: table:cdn_edge_stat_daily;field:error5xxRate
|
||||
summary: The percentage of requests that got a server error. This should stay near zero.
|
||||
- key: table:cdn_edge_stat_daily;field:importedDate
|
||||
summary: When these figures were last fetched from the CDN.
|
||||
- key: table:cdn_origin_check;slot:help
|
||||
summary: Results of asking a hostname's origin for sample pages and comparing their Cache-Control headers.
|
||||
content: |
|
||||
Use these results to find origin responses with missing or incorrect cache headers.
|
||||
|
||||
|
||||
**How rows get created:** Check Origin Headers on a hostname writes one row per hostname, and, when a scheduler is configured, a nightly run does the same for every hostname that is not Off. It requests the home page, the paths of prefix cache policies and the literal paths of tag rules over HTTPS and counts the right, missing or wrong, and unreadable ones.
|
||||
- key: table:cdn_origin_check;field:checkDate
|
||||
summary: When the check ran.
|
||||
- key: table:cdn_origin_check;field:scope
|
||||
summary: The hostname whose origin responses were checked.
|
||||
- key: table:cdn_origin_check;field:objectsChecked
|
||||
summary: How many sample paths were checked at the origin.
|
||||
- key: table:cdn_origin_check;field:correct
|
||||
summary: How many had the correct cache header.
|
||||
- key: table:cdn_origin_check;field:missingOrWrong
|
||||
summary: How many had a missing or wrong cache header. These should be fixed.
|
||||
- key: table:cdn_origin_check;field:unreadable
|
||||
summary: How many could not be read, so their header is unknown.
|
||||
- key: table:cdn_drift_finding;slot:help
|
||||
summary: A difference between how the CDN is actually set up and how it is supposed to be.
|
||||
content: |
|
||||
**{{label:cdnCheckDrift}}** (and a daily job when a scheduler is configured) reads the live CDN settings and compares them with the expected setup defined by the infrastructure code. Each difference becomes a finding with what was expected and what was found.
|
||||
|
||||
A finding stays open until a later check no longer sees it, then it is marked resolved. You cannot edit findings; fix the CDN or the expected setup instead.
|
||||
|
||||
**How rows get created:** Check Config Drift on a provider writes it, and, when a scheduler is configured, a nightly run does the same. It needs the expected configuration in the environment variable the provider names (CDN_EXPECTED_CONFIG by default); a finding is resolved when it no longer appears.
|
||||
- key: table:cdn_drift_finding;field:cdnProviderId
|
||||
summary: The CDN provider that was checked.
|
||||
- key: table:cdn_drift_finding;field:checkedDate
|
||||
summary: When the difference was last seen.
|
||||
- key: table:cdn_drift_finding;field:area
|
||||
summary: What differs, such as aliases, origin.domain, a behavior path or an origin header.
|
||||
- key: table:cdn_drift_finding;field:expected
|
||||
summary: What the expected setup says this should be.
|
||||
- key: table:cdn_drift_finding;field:actual
|
||||
summary: What the CDN actually has. (missing) means it is not set at all.
|
||||
- key: table:cdn_drift_finding;field:severity
|
||||
summary: How serious the difference is. Currently always WARNING.
|
||||
- key: table:cdn_drift_finding;field:resolvedDate
|
||||
summary: When a check stopped finding this difference. Empty while the finding is open.
|
||||
- key: process:cdnRetryPurge;slot:help
|
||||
summary: Queues failed, dead or deferred purge requests to be sent again.
|
||||
content: |
|
||||
Select purge requests and run it. Only requests that are **Failed**, **Dead** or **Deferred (budget)** are retried; anything else is skipped and reported as unchanged. Review the selected requests before continuing. A retried request has its attempt count reset and its source set to **Retry**. If sending is available, it becomes **Pending** for the next dispatcher run; otherwise its status explains why nothing was sent.
|
||||
|
||||
A deferred request is held back by the monthly path budget, so retrying it only helps if the budget has room or you raised it in the provider's settings.
|
||||
|
||||
Needs the CDN purge permission.
|
||||
- key: process:cdnPurgeHostname;slot:help
|
||||
summary: Clears the entire CDN cache for the provider this hostname belongs to.
|
||||
content: |
|
||||
This purges everything the CDN holds, for every hostname on that provider, not only the one you selected. Visitors get fresh pages, but the first requests are slower while the cache refills. For CloudFront it counts as one path of the monthly allowance.
|
||||
|
||||
If you select several hostnames of the same provider, one purge is recorded for it. The request is eligible for the next dispatcher run and is tracked under **CDN Purge Requests**. Use it sparingly; to clear one record, use **Purge From CDN** on that record instead.
|
||||
|
||||
Needs the CDN purge-all permission.
|
||||
|
||||
Review the affected providers before continuing. If no enabled real provider has a Live or Locked hostname with purging enabled, nothing is sent.
|
||||
- key: process:cdnDispatchPurges;slot:help
|
||||
summary: Send pending CDN purges and check requests already in progress.
|
||||
content: |
|
||||
Your application schedules this job when a scheduler is configured. It gathers requests within the flush window, calls revalidation hooks, sends purges and retries eligible failures. Check **{{label:cdn_purge_request}}** for the outcome.
|
||||
- key: process:cdnTestConnection;slot:help
|
||||
summary: Checks that the CDN can be reached with the saved credentials and records the result on the provider.
|
||||
content: |
|
||||
Select a provider and run it. For CloudFront it checks the credentials, the distribution, access to the invalidation list, and whether the origin verification header is set. The result is saved under **Last Health Status** and **Last Health Message**, and a pass saves the reported DNS target and origin when available.
|
||||
|
||||
Providers that cannot be tested, such as No CDN, are skipped. It reads from the CDN and changes nothing there. Needs the CDN admin permission.
|
||||
- key: process:cdnImportEdgeStats;slot:help
|
||||
summary: Fetches the last 3 complete days of traffic figures from the CDN and saves them.
|
||||
content: |
|
||||
Select a provider and run it. It reads daily requests, data served, cache hit rate and error rates from the CDN's monitoring and adds or updates one row per day in **CDN Edge Stats**. With a scheduler configured, a daily job does this automatically, so you only need it to refresh now.
|
||||
|
||||
Only CloudFront supports it; other providers are skipped. Needs the CDN admin permission.
|
||||
- key: process:cdnCheckDrift;slot:help
|
||||
summary: Compares the live CDN setup with the expected setup and records any differences.
|
||||
content: |
|
||||
Select a provider and run it. It reads the CDN's current configuration and compares it with the expected setup that the infrastructure code provides to the server. New differences are added to **CDN Drift Findings**; findings that no longer apply are marked resolved.
|
||||
|
||||
If the expected setup is not provided to the server, the check fails with a message saying so. Only CloudFront supports it. With a scheduler configured, a daily job runs the same check. Needs the CDN admin permission.
|
||||
- key: process:cdnScheduledImportEdgeStats;slot:help
|
||||
summary: Import recent CDN traffic statistics on the configured schedule.
|
||||
content: |
|
||||
When your application configures a scheduler, this job imports the last three complete UTC days for enabled providers that support statistics. Use **{{label:cdnImportEdgeStats}}** to refresh on demand.
|
||||
- key: process:cdnScheduledCheckDrift;slot:help
|
||||
summary: Check enabled CDN providers for configuration drift on the configured schedule.
|
||||
content: |
|
||||
When your application configures a scheduler, this job updates drift findings for supported providers. Connection health is checked separately by central System Health. Use **{{label:cdnCheckDrift}}** to check drift on demand.
|
||||
- key: widget:cdnHome;slot:label
|
||||
summary: 'The CDN at a glance: provider, hostnames, traffic, purge activity, invalidation budget, origin checks and
|
||||
drift.'
|
||||
content: |
|
||||
Each card says plainly when there is nothing yet, for example when no CDN is connected or no statistics have been imported. Traffic figures cover the last 7 and 30 days; purge failures cover 24 hours and 7 days.
|
||||
- key: possibleValueSource:cdn_provider_type;slot:help
|
||||
summary: The CDN services a provider can be.
|
||||
content: |
|
||||
- **Amazon CloudFront**: supported. Purges by path, with connection test, traffic import and drift check.
|
||||
- **Souin (self-hosted)**: supported. Purges by cache tag through its own API.
|
||||
- **No CDN**: nothing is sent; purge requests are only recorded.
|
||||
- **Cloudflare**, **Fastly**, **Bunny.net** and **Varnish (self-hosted)**: listed, but not yet usable for purging.
|
||||
- key: possibleValueSource:cdn_hostname_stage;slot:help
|
||||
summary: How far a hostname is along in being switched over to the CDN.
|
||||
content: |
|
||||
- **Off**: not using the CDN. If every hostname is Off, purges are not sent.
|
||||
- **Headers only**: preparation step; purges are not sent yet.
|
||||
- **Live**: in use; purges are sent.
|
||||
- **Locked to CDN**: the final step; purges are sent.
|
||||
- key: possibleValueSource:cdn_purge_status;slot:help
|
||||
summary: The stage of a purge request.
|
||||
content: |
|
||||
- **Pending**: waiting to be sent.
|
||||
- **Merged**: combined into another request.
|
||||
- **Sent**: handed to the CDN.
|
||||
- **In progress**: the CDN is still clearing copies.
|
||||
- **Succeeded**: finished.
|
||||
- **Failed (will retry)**: failed, will be tried again.
|
||||
- **Dead**: failed too many times; use **{{label:cdnRetryPurge}}**.
|
||||
- **Deferred (budget)**: held back by the monthly path budget.
|
||||
- **Not sent (hostnames not live)**: no hostname is Live yet.
|
||||
- **Not sent (no CDN)**: the provider is No CDN.
|
||||
- **Not sent (hub pages only)**: only list pages were affected, and automatic purges skip those.
|
||||
- key: possibleValueSource:cdn_purge_source;slot:help
|
||||
summary: What caused a purge request.
|
||||
content: |
|
||||
- **Record change**: a record was edited or published.
|
||||
- **Record action**: someone ran Purge From CDN on a record.
|
||||
- **Manual**: someone ran Purge Everything.
|
||||
- **Deploy**: part of a deployment.
|
||||
- **Retry**: queued again with Retry Purge.
|
||||
- key: possibleValueSource:cdn_policy_class;slot:help
|
||||
summary: The kind of content a cache rule is for.
|
||||
content: |
|
||||
- **Immutable**: files whose address changes when the content does, safe to keep a long time.
|
||||
- **Leaf**: a single page or record.
|
||||
- **Hub**: a list page that changes when any of its items do.
|
||||
- **Never cache**: private or live content that must not be kept.
|
||||
- key: table:cdn_revalidation_hook;field:signatureFormat
|
||||
summary: The signing format expected by the revalidation endpoint.
|
||||
content: |
|
||||
New hooks default to Standard Webhooks. Choose Legacy only for an endpoint expecting the older signature format. Signing uses **{{label:cdn_revalidation_hook.secretRef}}**.
|
||||
- key: table:cdn_revalidation_hook;field:allowPlainHttp
|
||||
summary: Allow this hook to call an HTTP endpoint without TLS; off by default.
|
||||
content: |
|
||||
Leave this off for HTTPS endpoints. Enabling it does not remove the internal-address checks.
|
||||
- key: table:cdn_revalidation_hook;field:internalAllowlist
|
||||
summary: Explicit exceptions to the hook's internal-address restrictions.
|
||||
content: |
|
||||
Separate IP addresses, CIDR blocks, exact host names or host suffixes beginning with a dot with commas or whitespace. Leave empty when the endpoint does not require access to an internal destination. Cloud metadata and unspecified addresses remain blocked.
|
||||
- key: process:cdnCheckOrigin;slot:help
|
||||
summary: Check sample origin responses for the expected cache headers.
|
||||
content: |
|
||||
Select hostnames and review the preview before continuing. You can then inspect **{{label:cdn_origin_check}}** for counts of correct, missing or wrong, and unreadable responses. The check uses HTTPS and samples the home page, prefix policy paths and literal tag-rule paths. It reads responses and does not change the origin.
|
||||
- key: process:cdnScheduledCheckOrigin;slot:help
|
||||
summary: Check origin cache headers for hostnames that are not Off.
|
||||
content: |
|
||||
When your application configures a scheduler, this job checks hostnames that are not Off. You can run **{{label:cdnCheckOrigin}}** on demand and inspect **{{label:cdn_origin_check}}** for results.
|
||||
- key: process:cdnCreateDefaultProvider;slot:help
|
||||
summary: Add a No CDN provider so you can record purges before connecting a real CDN.
|
||||
content: |
|
||||
Review the proposed provider before continuing. You get **No CDN (record only)** if no No CDN provider exists. Existing rows are never changed. You need CDN admin permission.
|
||||
- key: process:cdnCreateDefaultPolicies;slot:help
|
||||
summary: Add two disabled cache policy examples for you to review and configure.
|
||||
content: |
|
||||
Review the static-assets and never-cache examples before continuing. Only missing examples are added, by name; existing rows are never changed. Edit their matches and lifetimes before enabling them. Policies affect responses only where your application integrates the CDN header policy. You need CDN admin permission.
|
||||
- key: process:cdnCreateDefaultRules;slot:help
|
||||
summary: Add a tag rule for each configured purgeable table that has no rule.
|
||||
content: |
|
||||
Review the proposed rules before continuing. Existing rules, including disabled ones, are left alone. New rules contain tags but no invalidation paths. Add paths if your provider purges by path, and configure a published field if draft changes should be ignored. You need CDN admin permission.
|
||||
- key: app:cdnAdmin;slot:purgeRecords
|
||||
summary: Clear cached copies associated with the records you select.
|
||||
content: |
|
||||
Use **Purge From CDN** when cached content needs refreshing. Review the selected records and planned tags and paths before continuing. This action does not change your records. Manual purges include hub paths and bypass automatic change filters. Follow requests in **{{label:cdn_purge_request}}**. Sending still requires an enabled real provider, a Live or Locked hostname with purging enabled, and a dispatcher run. You need CDN purge permission.
|
||||
- key: possibleValueSource:cdn_signature_format;slot:help
|
||||
summary: Choose the signing format your revalidation endpoint expects.
|
||||
content: |
|
||||
Use Standard Webhooks for new endpoints, or Legacy for a receiver expecting the older signature. You must configure a signing secret to send signed calls.
|
||||
- key: possibleValueSource:cdn_provider;slot:help
|
||||
summary: Choose the CDN provider this record belongs to.
|
||||
content: |
|
||||
Choose a connection from **{{label:cdn_provider}}**. Configure that provider before using it for purges or checks.
|
||||
- key: possibleValueSource:cdn_hostname;slot:help
|
||||
summary: Choose the hostname this cache policy applies to.
|
||||
content: |
|
||||
Choose a hostname from **{{label:cdn_hostname}}**, or leave the policy hostname empty to apply it across hostnames.
|
||||
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.cdn;
|
||||
|
||||
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleLayer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Coverage for shipped help and names bound from the host configuration.
|
||||
*******************************************************************************/
|
||||
class CdnHelpBundleTest
|
||||
{
|
||||
@TempDir
|
||||
Path temporary;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The resource covers all fixed metadata, without host objects or exclusions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void shippedBundleIsComplete() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
new CdnQBitProducer().withQBitConfig(new CdnQBitConfig().withBackendName("memory")).produce(instance);
|
||||
// Core adds bulk CRUD processes during enrichment; those belong to core.
|
||||
Set<String> ownedProcesses = Set.copyOf(instance.getProcesses().keySet());
|
||||
Set<String> ownedWidgets = Set.copyOf(instance.getWidgets().keySet());
|
||||
Set<String> ownedSources = Set.copyOf(instance.getPossibleValueSources().keySet());
|
||||
new QInstanceEnricher(instance).enrich();
|
||||
HelpBundleAssertions.forInstance(instance).bundles("help/qbit-cdn.yaml")
|
||||
.owning(target -> target.name().startsWith("cdn")
|
||||
&& (!target.kind().equals("process") || ownedProcesses.contains(target.name()))).assertComplete();
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
var bundle = HelpBundles.of(instance).getBundles().get(0);
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-cdn");
|
||||
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CdnQBitProducer.VERSION);
|
||||
assertThat(bundle.getLayer()).isEqualTo(HelpBundleLayer.QBIT);
|
||||
assertThat(bundle.getEntries()).extracting(entry -> entry.getKey()).doesNotHaveDuplicates();
|
||||
// Core's gate does not require widgets or possible-value sources yet.
|
||||
ownedWidgets.forEach(name -> assertThat(bundle.getEntries()).as(name)
|
||||
.anyMatch(entry -> entry.getKey().equals("widget:" + name + ";slot:label")));
|
||||
ownedSources.forEach(name -> assertThat(bundle.getEntries()).as(name)
|
||||
.anyMatch(entry -> entry.getKey().equals("possibleValueSource:" + name + ";slot:help")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check the registered bundle, including host-named actions and a renamed app,
|
||||
** through the core gate using a temporary classpath resource.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configuredBundleIsComplete() throws Exception
|
||||
{
|
||||
QInstance instance = CdnTestSupport.setUp();
|
||||
var config = CdnRuntime.getConfig().withAppName("edgeConsole");
|
||||
QInstance renamed = new QInstance();
|
||||
instance.getBackends().values().forEach(renamed::addBackend);
|
||||
renamed.addTable(instance.getTable("post"));
|
||||
new CdnQBitProducer().withQBitConfig(config).produce(renamed);
|
||||
Set<String> ownedProcesses = Set.copyOf(renamed.getProcesses().keySet());
|
||||
new QInstanceEnricher(renamed).enrich();
|
||||
var bundle = HelpBundles.of(renamed).getBundles().get(0);
|
||||
assertThat(bundle.getEntries()).anyMatch(entry -> entry.getKey().equals("process:cdnPurge_post;slot:help")
|
||||
&& entry.getContent().contains("Review the selected records"));
|
||||
assertThat(bundle.getEntries()).anyMatch(entry -> entry.getKey().equals("app:edgeConsole;slot:help"));
|
||||
assertThat(bundle.getEntries()).noneMatch(entry -> entry.getKey().startsWith("app:cdnAdmin;"));
|
||||
ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
|
||||
mapper.writeValue(temporary.resolve("configured.yaml").toFile(), Map.of("bundle", bundle.getId(),
|
||||
"version", bundle.getVersion(), "layer", bundle.getLayer(), "entries", bundle.getEntries()));
|
||||
ClassLoader original = Thread.currentThread().getContextClassLoader();
|
||||
try(URLClassLoader loader = new URLClassLoader(new java.net.URL[] { temporary.toUri().toURL() }, original))
|
||||
{
|
||||
Thread.currentThread().setContextClassLoader(loader);
|
||||
HelpBundleAssertions.forInstance(renamed).bundles("configured.yaml")
|
||||
.owning(target -> (target.name().startsWith("cdn") || target.name().equals("edgeConsole"))
|
||||
&& (!target.kind().equals("process") || ownedProcesses.contains(target.name()))).assertComplete();
|
||||
mapper.writeValue(temporary.resolve("host.yaml").toFile(), Map.of("bundle", "host", "version", "1",
|
||||
"layer", "HOST", "entries", java.util.List.of(Map.of("key", "table:cdn_provider;slot:help", "summary", "Your host instructions."))));
|
||||
HelpBundles.register(renamed, "host.yaml");
|
||||
assertThat(HelpBundles.of(renamed).resolveWinningEntries()
|
||||
.get(new HelpBundles.EntryKey("table:cdn_provider;slot:help", "ALL_SCREENS")).entry().getSummary())
|
||||
.isEqualTo("Your host instructions.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.currentThread().setContextClassLoader(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,18 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
*******************************************************************************/
|
||||
class CdnQBitProducerTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Metadata must report the version of the artifact being built.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void metadataVersionMatchesArtifact()
|
||||
{
|
||||
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank().isEqualTo(CdnQBitProducer.VERSION);
|
||||
assertThat(new CdnQBitProducer().getQBitMetaData().getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Everything is produced and the instance is valid.
|
||||
*******************************************************************************/
|
||||
@@ -52,6 +64,7 @@ class CdnQBitProducerTest
|
||||
assertThat(instance.getProcess(CdnProcesses.PURGE_HOSTNAME)).isNotNull();
|
||||
assertThat(instance.getProcess(CdnProcesses.DISPATCH_PURGES).getIsHidden()).isTrue();
|
||||
assertThat(instance.getWidget(CdnWidgets.HOME_WIDGET)).isNotNull();
|
||||
assertThat(instance.getWidget("cdnHealthCard")).isNull();
|
||||
assertThat(instance.getApp("cdnAdmin").getSections()).extracting(section -> section.getLabel())
|
||||
.containsExactly("Connection", "Settings", "Activity", "Performance");
|
||||
assertThat(instance.getRecordChangeListeners()).isNotEmpty();
|
||||
|
||||
@@ -34,7 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The CDN home and Health card: honest empty states, then real data.
|
||||
** The CDN home: honest empty states, then real data.
|
||||
*******************************************************************************/
|
||||
class CdnHomeTest
|
||||
{
|
||||
@@ -61,7 +61,6 @@ class CdnHomeTest
|
||||
assertThat(html).contains("No CDN provider configured: purges are recorded only.").contains("No edge statistics yet.").contains("No purges recorded yet.")
|
||||
.contains("No path-billed provider connected yet.").contains("No origin check yet.").contains("Nothing to compare yet.")
|
||||
.contains("minmax(min(100%,22rem),1fr)");
|
||||
assertThat(render(CdnWidgets.HEALTH_CARD_WIDGET)).contains("No CDN provider configured: purges are recorded only.").contains("href=\"/cdnAdmin\"");
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +90,6 @@ class CdnHomeTest
|
||||
CdnTestSupport.insert(CdnTables.DRIFT_FINDING, new QRecord().withValue("cdnProviderId", provider).withValue("area", "BEHAVIOR").withValue("checkedDate", CdnTestSupport.NOW));
|
||||
|
||||
CdnHomeData data = CdnHomeData.load();
|
||||
assertEquals("ATTENTION", data.overallState());
|
||||
assertEquals(1, data.dead24h());
|
||||
assertEquals(4, data.budgets().get(0).billed());
|
||||
assertEquals(age, data.providers().get(0).credentialAgeDays());
|
||||
@@ -100,7 +98,6 @@ class CdnHomeTest
|
||||
assertThat(html).contains("d1.cloudfront.net").contains(age + " days (rotate every 90) - rotation due").contains("1,500").contains("91.5%")
|
||||
.contains("<polyline").contains("396 of 396").contains("BEHAVIOR").contains("1 open").contains("/cdnAdmin/cdn_purge_request/")
|
||||
.contains("www.example.com").doesNotContain("recorded only (not sent)", "nothing has been checked");
|
||||
assertThat(CdnHomeView.renderHealthCard(data, "/cdnAdmin")).contains("Needs attention");
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +139,6 @@ class CdnHomeTest
|
||||
assertThat(render(CdnWidgets.HOME_WIDGET)).contains("No CDN provider configured: purges are recorded only.")
|
||||
.contains("The CloudFront distribution is created in the cutover window.").contains("recorded only (not sent)")
|
||||
.contains("No origin check yet.");
|
||||
assertThat(render(CdnWidgets.HEALTH_CARD_WIDGET)).contains("No CDN provider configured: purges are recorded only.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.cdn.health;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.kingsrook.qbits.cdn.CdnRuntime;
|
||||
import com.kingsrook.qbits.cdn.CdnTestSupport;
|
||||
import com.kingsrook.qbits.cdn.model.CdnTables;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnHealth;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnHealthProbe;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnOperations;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderConfig;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider checks reuse the probe, honor deadlines and expose only safe facts.
|
||||
*******************************************************************************/
|
||||
class CdnProviderHealthChecksTest
|
||||
{
|
||||
private static final String SECRET = "S3NT1NEL-secret";
|
||||
private QInstance instance;
|
||||
private Integer providerId;
|
||||
private CdnTestSupport.FakePathProvider adapter;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A memory-backed instance and the existing fake adapter, with no network.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
instance = CdnTestSupport.setUp();
|
||||
providerId = CdnTestSupport.liveProvider(CdnProviderType.CLOUDFRONT, null);
|
||||
adapter = spy(new CdnTestSupport.FakePathProvider());
|
||||
CdnRuntime.registerProvider(CdnProviderType.CLOUDFRONT, () -> adapter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restore process-wide test state.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
CdnRuntime.setEnvironment(null);
|
||||
CdnRuntime.setClock(null);
|
||||
Thread.interrupted();
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The producer registers a dynamic dependency supplier with the spec defaults.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registrationAndRefresh() throws Exception
|
||||
{
|
||||
var registry = HealthChecksMetaData.ofOrWithNew(instance);
|
||||
assertThat(registry.getSuppliers()).singleElement().satisfies(supplier ->
|
||||
{
|
||||
assertThat(supplier.getNamePrefix()).isEqualTo("cdn.provider");
|
||||
assertThat(supplier.getComponent()).isEqualTo("cdn");
|
||||
});
|
||||
CdnTestSupport.insert(CdnTables.PROVIDER, new QRecord().withValue("name", "disabled").withValue("providerType", "CLOUDFRONT").withValue("enabled", false));
|
||||
// Match the runner's automated context rather than relying on CDN UI permissions.
|
||||
QContext.init(instance, new QSession().withUser(new QUser().withIdReference("health_runner").withFullName("health_runner"))
|
||||
.withSecurityKeyValues(Map.of()).withPermissions("health.read", "health.write"));
|
||||
assertThat(registry.resolveAll(instance)).singleElement().satisfies(check ->
|
||||
{
|
||||
assertThat(check.getName()).isEqualTo("cdn.provider." + providerId).matches("[a-z0-9]+([._-][a-z0-9]+)*");
|
||||
assertThat(check.getComponent()).isEqualTo("cdn");
|
||||
assertThat(check.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY);
|
||||
assertThat(check.getCategory()).isEqualTo(HealthCategory.CDN);
|
||||
assertThat(check.getIntervalSeconds()).isEqualTo(900);
|
||||
assertThat(check.getTimeoutMs()).isEqualTo(20000);
|
||||
assertThat(check.getIncludeInReadiness()).isFalse();
|
||||
});
|
||||
assertThat(check().getCheckInstance().run(input()).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
update(new QRecord().withValue("enabled", false));
|
||||
assertThat(registry.resolveAll(instance)).isEmpty();
|
||||
update(new QRecord().withValue("enabled", true));
|
||||
assertThat(registry.resolveAll(instance)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map success, missing origin protection and failure without writing lastHealth.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void connectionStates() throws Exception
|
||||
{
|
||||
var check = check().getCheckInstance();
|
||||
HealthCheckOutcome up = check.run(input());
|
||||
assertThat(up.getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(up.getDetails().getValues()).containsExactlyInAnyOrderEntriesOf(Map.of(
|
||||
"providerType", "CLOUDFRONT", "status", "Deployed", "originHeaderConfigured", true));
|
||||
doReturn(new CdnOperations.ConnectionTest(true, null, null, null, false, "InProgress", "unused")).when(adapter).connectionTest(any());
|
||||
HealthCheckOutcome degraded = check.run(input());
|
||||
assertThat(degraded.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(degraded.getDetails().getValues()).containsEntry("originHeaderConfigured", false).containsEntry("status", "InProgress");
|
||||
doReturn(new CdnOperations.ConnectionTest(false, null, null, null, false, null, "unused")).when(adapter).connectionTest(any());
|
||||
assertThat(check.run(input()).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
QRecord row = new GetAction().executeForRecord(new GetInput(CdnTables.PROVIDER).withPrimaryKey(providerId));
|
||||
assertThat(row.getValue("lastHealthCheckDate")).isNull();
|
||||
assertThat(row.getValue("lastHealthStatus")).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A previously resolved check reloads config and skips disabled/deleted rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void reloadsProviderAndHandlesUnsupported() throws Exception
|
||||
{
|
||||
var check = check().getCheckInstance();
|
||||
update(new QRecord().withValue("providerType", "NONE"));
|
||||
HealthCheckOutcome none = check.run(input());
|
||||
assertThat(none.getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(none.getMessage()).isEqualTo("No CDN configured; purges are recorded only");
|
||||
assertThat(none.getDetails().getValues()).containsEntry("testable", false).containsEntry("providerType", "NONE");
|
||||
update(new QRecord().withValue("providerType", "SOUIN"));
|
||||
HealthCheckOutcome souin = check.run(input());
|
||||
assertThat(souin.getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(souin.getDetails().getValues()).containsEntry("testable", false).containsEntry("reason", "provider type has no connection test");
|
||||
update(new QRecord().withValue("enabled", false));
|
||||
assertThat(check.run(input()).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
new DeleteAction().execute(new DeleteInput(CdnTables.PROVIDER).withPrimaryKeys(List.of(providerId)));
|
||||
assertThat(check.run(input()).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
verify(adapter, never()).connectionTest(any());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An adapter's own health probe wins over the connection test; its state,
|
||||
** message and details pass through with the provider type added.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void healthProbePreferred() throws Exception
|
||||
{
|
||||
ProbingProvider probing = new ProbingProvider();
|
||||
CdnRuntime.registerProvider(CdnProviderType.CLOUDFRONT, () -> probing);
|
||||
var check = check().getCheckInstance();
|
||||
for(CdnHealth.State state : CdnHealth.State.values())
|
||||
{
|
||||
probing.health = new CdnHealth(state, "probe " + state, Map.of("testable", false));
|
||||
HealthCheckOutcome outcome = check.run(input());
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.valueOf(state.name()));
|
||||
assertThat(outcome.getMessage()).isEqualTo("probe " + state);
|
||||
assertThat(outcome.getDetails().getValues()).containsExactlyInAnyOrderEntriesOf(Map.of("providerType", "CLOUDFRONT", "testable", false));
|
||||
}
|
||||
assertThat(probing.connectionTests).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fake path adapter plus a health probe.
|
||||
*******************************************************************************/
|
||||
private static class ProbingProvider extends CdnTestSupport.FakePathProvider implements CdnHealthProbe
|
||||
{
|
||||
private CdnHealth health;
|
||||
private int connectionTests;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured answer.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public CdnHealth checkHealth(CdnProviderConfig config)
|
||||
{
|
||||
return (health);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Counted so the test can prove it is not used.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ConnectionTest connectionTest(CdnProviderConfig config)
|
||||
{
|
||||
connectionTests++;
|
||||
return (super.connectionTest(config));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Poison credentials, every free-form probe field and thrown error messages.
|
||||
** HealthRunner.sanitize is package-private, so assert the raw boundary here.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void noSecretsInOutcomes() throws Exception
|
||||
{
|
||||
update(new QRecord().withValue("secretRef", "TEST_CDN").withValue("name", SECRET)
|
||||
.withValue("zoneOrDistributionId", SECRET).withValue("settingsJson", "{\"originVerifyHeader\":\"" + SECRET + "\"}"));
|
||||
CdnRuntime.setEnvironment(name -> SECRET);
|
||||
for(HealthStatus expected : List.of(HealthStatus.UP, HealthStatus.DEGRADED, HealthStatus.DOWN))
|
||||
{
|
||||
doAnswer(invocation ->
|
||||
{
|
||||
CdnProviderConfig config = invocation.getArgument(0);
|
||||
assertThat(CdnRuntime.environment(config.secretRef() + "_ACCESS_KEY_ID")).isEqualTo(SECRET);
|
||||
assertThat(CdnRuntime.environment(config.secretRef() + "_SECRET_ACCESS_KEY")).isEqualTo(SECRET);
|
||||
return (new CdnOperations.ConnectionTest(expected != HealthStatus.DOWN, SECRET, SECRET, 443,
|
||||
expected == HealthStatus.UP, SECRET, SECRET));
|
||||
}).when(adapter).connectionTest(any());
|
||||
HealthCheckOutcome outcome = check().getCheckInstance().run(input());
|
||||
assertThat(outcome.getStatus()).isEqualTo(expected);
|
||||
assertClean(outcome);
|
||||
}
|
||||
doThrow(new IllegalStateException(SECRET)).when(adapter).connectionTest(any());
|
||||
HealthCheckOutcome failed = check().getCheckInstance().run(input());
|
||||
assertThat(failed.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertClean(failed);
|
||||
update(new QRecord().withValue("providerType", "NONE"));
|
||||
assertClean(check().getCheckInstance().run(input()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An expired deadline never starts the existing probe.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void expiredDeadline() throws Exception
|
||||
{
|
||||
HealthCheckOutcome outcome = check().getCheckInstance().run(input().withDeadline(Instant.now().minusSeconds(1)));
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(outcome.getMessage()).contains("timed out");
|
||||
verify(adapter, never()).connectionTest(any());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A slow adapter is cancelled at the deadline, without a blocking executor close.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deadlineCancelsProbe() throws Exception
|
||||
{
|
||||
CountDownLatch cancelled = new CountDownLatch(1);
|
||||
doAnswer(invocation ->
|
||||
{
|
||||
try
|
||||
{
|
||||
new CountDownLatch(1).await();
|
||||
return (null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cancelled.countDown();
|
||||
}
|
||||
}).when(adapter).connectionTest(any());
|
||||
HealthCheckOutcome outcome = assertTimeoutPreemptively(Duration.ofSeconds(3), () ->
|
||||
{
|
||||
QContext.init(instance, new QSession());
|
||||
try
|
||||
{
|
||||
return (check().getCheckInstance().run(input().withDeadline(Instant.now().plusMillis(200))));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
});
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(outcome.getMessage()).contains("timed out");
|
||||
assertThat(cancelled.await(2, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runner cancellation preserves the caller's interrupt status.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void interruptedProbe() throws Exception
|
||||
{
|
||||
doAnswer(invocation ->
|
||||
{
|
||||
new CountDownLatch(1).await();
|
||||
return (null);
|
||||
}).when(adapter).connectionTest(any());
|
||||
var check = check().getCheckInstance();
|
||||
Thread.currentThread().interrupt();
|
||||
try
|
||||
{
|
||||
HealthCheckOutcome outcome = check.run(input());
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(outcome.getMessage()).contains("interrupted");
|
||||
assertThat(Thread.currentThread().isInterrupted()).isTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve through the actual registration, including QCodeReference loading.
|
||||
*******************************************************************************/
|
||||
private HealthCheckMetaData check() throws Exception
|
||||
{
|
||||
return (HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance).getFirst());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh deadline for each call.
|
||||
*******************************************************************************/
|
||||
private HealthCheckInput input()
|
||||
{
|
||||
return (new HealthCheckInput().withQInstance(instance).withDeadline(Instant.now().plusSeconds(5)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Modify the real memory row to exercise fresh reads.
|
||||
*******************************************************************************/
|
||||
private void update(QRecord values) throws Exception
|
||||
{
|
||||
new UpdateAction().execute(new UpdateInput(CdnTables.PROVIDER).withRecord(values.withValue("id", providerId)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No provider-controlled text can reach the central health boundary.
|
||||
*******************************************************************************/
|
||||
private static void assertClean(HealthCheckOutcome outcome)
|
||||
{
|
||||
assertThat(outcome.getMessage()).doesNotContain(SECRET, "\n", "\r");
|
||||
assertThat(outcome.getDetails().toJson()).doesNotContain(SECRET, "TEST_CDN", "origin.example.com", "d1.cloudfront.net");
|
||||
}
|
||||
}
|
||||
@@ -199,9 +199,14 @@ class CdnProviderProcessesTest
|
||||
@Test
|
||||
void scheduledSteps() throws Exception
|
||||
{
|
||||
FakePathProvider adapter = org.mockito.Mockito.spy(new FakePathProvider());
|
||||
CdnRuntime.registerProvider(CdnProviderType.CLOUDFRONT, () -> adapter);
|
||||
FakePathProvider.drift = List.of(new CdnOperations.DriftItem("origin.header", "present", "missing"));
|
||||
new CdnProviderProcesses.ScheduledStatsStep().run(null, null);
|
||||
new CdnProviderProcesses.ScheduledDriftStep().run(null, null);
|
||||
assertThat(new QueryAction().execute(new QueryInput(CdnTables.EDGE_STAT_DAILY)).getRecords()).hasSize(2);
|
||||
assertEquals("OK", row().getValueString("lastHealthStatus"));
|
||||
assertThat(new QueryAction().execute(new QueryInput(CdnTables.DRIFT_FINDING)).getRecords()).hasSize(1);
|
||||
assertNull(row().getValueString("lastHealthStatus"));
|
||||
org.mockito.Mockito.verify(adapter, org.mockito.Mockito.never()).connectionTest(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.cdn.providers.cloudfront;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnHealth;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderConfig;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderType;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
|
||||
import software.amazon.awssdk.awscore.exception.AwsServiceException;
|
||||
import software.amazon.awssdk.core.exception.SdkClientException;
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
import software.amazon.awssdk.services.cloudfront.CloudFrontClient;
|
||||
import software.amazon.awssdk.services.cloudfront.model.CloudFrontException;
|
||||
import software.amazon.awssdk.services.cloudfront.model.CustomHeaders;
|
||||
import software.amazon.awssdk.services.cloudfront.model.DefaultCacheBehavior;
|
||||
import software.amazon.awssdk.services.cloudfront.model.Distribution;
|
||||
import software.amazon.awssdk.services.cloudfront.model.DistributionConfig;
|
||||
import software.amazon.awssdk.services.cloudfront.model.GetDistributionConfigRequest;
|
||||
import software.amazon.awssdk.services.cloudfront.model.GetDistributionConfigResponse;
|
||||
import software.amazon.awssdk.services.cloudfront.model.GetDistributionRequest;
|
||||
import software.amazon.awssdk.services.cloudfront.model.GetDistributionResponse;
|
||||
import software.amazon.awssdk.services.cloudfront.model.Origin;
|
||||
import software.amazon.awssdk.services.cloudfront.model.OriginCustomHeader;
|
||||
import software.amazon.awssdk.services.cloudfront.model.Origins;
|
||||
import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
|
||||
import software.amazon.awssdk.services.cloudwatch.model.CloudWatchException;
|
||||
import software.amazon.awssdk.services.cloudwatch.model.Dimension;
|
||||
import software.amazon.awssdk.services.cloudwatch.model.GetMetricDataRequest;
|
||||
import software.amazon.awssdk.services.cloudwatch.model.GetMetricDataResponse;
|
||||
import software.amazon.awssdk.services.cloudwatch.model.MetricDataResult;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The CloudFront health probe against mocked CloudFront and CloudWatch
|
||||
** clients (no network).
|
||||
*******************************************************************************/
|
||||
class CloudFrontHealthTest
|
||||
{
|
||||
private static final String DISTRIBUTION = "E2EXAMPLE123";
|
||||
private static final String SECRET = "FIXTURE-SECRET-NEVER-LEAK";
|
||||
private static final CdnProviderConfig CONFIG = new CdnProviderConfig("kof22-cloudfront", CdnProviderType.CLOUDFRONT, DISTRIBUTION, "CDN_AWS", null);
|
||||
|
||||
private CloudFrontClient cloudFront;
|
||||
private CloudWatchClient cloudWatch;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh mocks; by default CloudWatch has no datapoints.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void mocks()
|
||||
{
|
||||
cloudFront = mock(CloudFrontClient.class);
|
||||
cloudWatch = mock(CloudWatchClient.class);
|
||||
errorRate();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An adapter whose clients are the mocks.
|
||||
*******************************************************************************/
|
||||
private CloudFrontCdnProvider provider()
|
||||
{
|
||||
return new CloudFrontCdnProvider(new CloudFrontClientFactory(name -> null, null, null)
|
||||
{
|
||||
/***************************************************************************
|
||||
** The CloudFront mock.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public CloudFrontClient cloudFront(CdnProviderConfig config)
|
||||
{
|
||||
return cloudFront;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The CloudWatch mock.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public CloudWatchClient cloudWatch(CdnProviderConfig config)
|
||||
{
|
||||
return cloudWatch;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A distribution config, enabled or not, whose origin sends the verify header.
|
||||
*******************************************************************************/
|
||||
private static DistributionConfig distributionConfig(boolean enabled)
|
||||
{
|
||||
Origin origin = Origin.builder().id("origin").domainName("origin.kof22.com")
|
||||
.customHeaders(CustomHeaders.builder().quantity(1).items(OriginCustomHeader.builder().headerName("X-Origin-Verify").headerValue(SECRET).build()).build())
|
||||
.build();
|
||||
return DistributionConfig.builder().enabled(enabled)
|
||||
.origins(Origins.builder().quantity(1).items(origin).build())
|
||||
.defaultCacheBehavior(DefaultCacheBehavior.builder().targetOriginId("origin").build())
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GetDistribution answers with this status and enabled flag.
|
||||
*******************************************************************************/
|
||||
private void distribution(String status, boolean enabled)
|
||||
{
|
||||
when(cloudFront.getDistribution(any(GetDistributionRequest.class))).thenReturn(GetDistributionResponse.builder()
|
||||
.distribution(Distribution.builder().id(DISTRIBUTION).status(status).domainName("d1.cloudfront.net").distributionConfig(distributionConfig(enabled)).build())
|
||||
.build());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CloudWatch answers with these 5xxErrorRate datapoints.
|
||||
*******************************************************************************/
|
||||
private void errorRate(Double... values)
|
||||
{
|
||||
when(cloudWatch.getMetricData(any(GetMetricDataRequest.class))).thenReturn(GetMetricDataResponse.builder()
|
||||
.metricDataResults(MetricDataResult.builder().id("err5xx").values(values).build()).build());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A CloudFront service error.
|
||||
*******************************************************************************/
|
||||
private static AwsServiceException error(String code, int status)
|
||||
{
|
||||
return CloudFrontException.builder().statusCode(status)
|
||||
.awsErrorDetails(AwsErrorDetails.builder().errorCode(code).errorMessage(SECRET).build()).build();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enabled and Deployed is UP; the 5xx rate is averaged into the details from
|
||||
** a five-minute Average query on the distribution's global metric.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deployedIsUp()
|
||||
{
|
||||
distribution("Deployed", true);
|
||||
errorRate(1.0, 2.0);
|
||||
|
||||
CdnHealth health = provider().checkHealth(CONFIG);
|
||||
|
||||
assertThat(health.state()).isEqualTo(CdnHealth.State.UP);
|
||||
assertThat(health.message()).isEqualTo("CloudFront distribution is enabled and deployed");
|
||||
assertThat(health.details()).containsExactlyInAnyOrderEntriesOf(Map.of("enabled", true, "status", "Deployed", "originHeaderConfigured", true,
|
||||
"errorRateWindowMinutes", 30, "errorRate5xxPercent", 1.5));
|
||||
ArgumentCaptor<GetMetricDataRequest> request = ArgumentCaptor.forClass(GetMetricDataRequest.class);
|
||||
verify(cloudWatch).getMetricData(request.capture());
|
||||
var stat = request.getValue().metricDataQueries().getFirst().metricStat();
|
||||
assertThat(stat.metric().metricName()).isEqualTo("5xxErrorRate");
|
||||
assertThat(stat.metric().namespace()).isEqualTo("AWS/CloudFront");
|
||||
assertThat(stat.metric().dimensions()).containsExactly(Dimension.builder().name("DistributionId").value(DISTRIBUTION).build(),
|
||||
Dimension.builder().name("Region").value("Global").build());
|
||||
assertThat(stat.stat()).isEqualTo("Average");
|
||||
assertThat(stat.period()).isEqualTo(300);
|
||||
assertThat(java.time.Duration.between(request.getValue().startTime(), request.getValue().endTime()).toMinutes()).isEqualTo(30);
|
||||
verify(cloudFront, never()).getDistributionConfig(any(GetDistributionConfigRequest.class));
|
||||
assertClean(health);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No traffic (no datapoints) or an unreadable metric leaves the rate out.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void errorRateIsBestEffort()
|
||||
{
|
||||
distribution("Deployed", true);
|
||||
assertThat(provider().checkHealth(CONFIG).details()).doesNotContainKey("errorRate5xxPercent").containsEntry("errorRateWindowMinutes", 30);
|
||||
|
||||
when(cloudWatch.getMetricData(any(GetMetricDataRequest.class))).thenThrow(CloudWatchException.builder().statusCode(403)
|
||||
.awsErrorDetails(AwsErrorDetails.builder().errorCode("AccessDenied").build()).build());
|
||||
CdnHealth health = provider().checkHealth(CONFIG);
|
||||
assertThat(health.state()).isEqualTo(CdnHealth.State.UP);
|
||||
assertThat(health.details()).doesNotContainKey("errorRate5xxPercent");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A 5xx rate above 5% is DEGRADED; at 5% it is still UP.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void highErrorRateIsDegraded()
|
||||
{
|
||||
distribution("Deployed", true);
|
||||
errorRate(5.0);
|
||||
assertThat(provider().checkHealth(CONFIG).state()).isEqualTo(CdnHealth.State.UP);
|
||||
|
||||
errorRate(4.0, 9.0);
|
||||
CdnHealth health = provider().checkHealth(CONFIG);
|
||||
assertThat(health.state()).isEqualTo(CdnHealth.State.DEGRADED);
|
||||
assertThat(health.message()).contains("5xx error rate");
|
||||
assertThat(health.details()).containsEntry("errorRate5xxPercent", 6.5);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A deploying distribution is DEGRADED and says so.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void inProgressIsDegraded()
|
||||
{
|
||||
distribution("InProgress", true);
|
||||
CdnHealth health = provider().checkHealth(CONFIG);
|
||||
assertThat(health.state()).isEqualTo(CdnHealth.State.DEGRADED);
|
||||
assertThat(health.message()).contains("deploying").contains("InProgress");
|
||||
assertThat(health.details()).containsEntry("status", "InProgress");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A disabled distribution is DOWN (and CloudWatch is not consulted); an
|
||||
** unexpected status value is reported as UNKNOWN, never echoed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void disabledIsDown()
|
||||
{
|
||||
distribution(SECRET, false);
|
||||
CdnHealth health = provider().checkHealth(CONFIG);
|
||||
assertThat(health.state()).isEqualTo(CdnHealth.State.DOWN);
|
||||
assertThat(health.message()).isEqualTo("CloudFront distribution is disabled");
|
||||
assertThat(health.details()).containsEntry("enabled", false).containsEntry("status", "UNKNOWN");
|
||||
verify(cloudWatch, never()).getMetricData(any(GetMetricDataRequest.class));
|
||||
assertClean(health);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A different configured header name is reported missing: DEGRADED.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingOriginHeaderIsDegraded()
|
||||
{
|
||||
distribution("Deployed", true);
|
||||
CdnProviderConfig other = new CdnProviderConfig("x", CdnProviderType.CLOUDFRONT, DISTRIBUTION, "CDN_AWS",
|
||||
Map.of(CloudFrontCdnProvider.SETTING_ORIGIN_VERIFY_HEADER, "X-Other-Verify"));
|
||||
CdnHealth health = provider().checkHealth(other);
|
||||
assertThat(health.state()).isEqualTo(CdnHealth.State.DEGRADED);
|
||||
assertThat(health.details()).containsEntry("originHeaderConfigured", false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without GetDistribution (the section 7.5 policy) the probe falls back to
|
||||
** GetDistributionConfig: UP, not testable, Enabled still checked.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void accessDeniedFallsBackToConfig()
|
||||
{
|
||||
when(cloudFront.getDistribution(any(GetDistributionRequest.class))).thenThrow(error("AccessDenied", 403));
|
||||
when(cloudFront.getDistributionConfig(any(GetDistributionConfigRequest.class)))
|
||||
.thenReturn(GetDistributionConfigResponse.builder().distributionConfig(distributionConfig(true)).build());
|
||||
|
||||
CdnHealth health = provider().checkHealth(CONFIG);
|
||||
assertThat(health.state()).isEqualTo(CdnHealth.State.UP);
|
||||
assertThat(health.message()).isEqualTo("CloudFront configured; distribution status not testable with current permissions");
|
||||
assertThat(health.details()).containsEntry("testable", false).containsEntry("reason", "credentials lack cloudfront:GetDistribution")
|
||||
.containsEntry("enabled", true).containsEntry("status", "UNKNOWN");
|
||||
assertClean(health);
|
||||
|
||||
when(cloudFront.getDistributionConfig(any(GetDistributionConfigRequest.class)))
|
||||
.thenReturn(GetDistributionConfigResponse.builder().distributionConfig(distributionConfig(false)).build());
|
||||
assertThat(provider().checkHealth(CONFIG).state()).isEqualTo(CdnHealth.State.DOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** When neither read is permitted the check is UP and not testable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void noReadPermissionIsUpNotTestable()
|
||||
{
|
||||
when(cloudFront.getDistribution(any(GetDistributionRequest.class))).thenThrow(error("AccessDenied", 403));
|
||||
when(cloudFront.getDistributionConfig(any(GetDistributionConfigRequest.class))).thenThrow(error(null, 403));
|
||||
|
||||
CdnHealth health = provider().checkHealth(CONFIG);
|
||||
assertThat(health.state()).isEqualTo(CdnHealth.State.UP);
|
||||
assertThat(health.details()).containsExactlyInAnyOrderEntriesOf(Map.of("testable", false,
|
||||
"reason", "credentials lack cloudfront:GetDistribution and cloudfront:GetDistributionConfig"));
|
||||
verify(cloudWatch, never()).getMetricData(any(GetMetricDataRequest.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** AWS errors: missing distribution and rejected credentials are DOWN,
|
||||
** throttling and AWS-side errors DEGRADED; also after the config fallback.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"NoSuchDistribution, 404, DOWN, CloudFront distribution not found",
|
||||
"InvalidClientTokenId, 403, DOWN, CloudFront rejected the status check",
|
||||
"Throttling, 400, DEGRADED, CloudFront API throttled or unavailable; distribution status not checked",
|
||||
"ServiceUnavailable, 503, DEGRADED, CloudFront API throttled or unavailable; distribution status not checked",
|
||||
})
|
||||
void apiErrors(String code, int status, CdnHealth.State expected, String message)
|
||||
{
|
||||
when(cloudFront.getDistribution(any(GetDistributionRequest.class))).thenThrow(error(code, status));
|
||||
CdnHealth health = provider().checkHealth(CONFIG);
|
||||
assertThat(health.state()).isEqualTo(expected);
|
||||
assertThat(health.message()).isEqualTo(message);
|
||||
assertClean(health);
|
||||
|
||||
when(cloudFront.getDistribution(any(GetDistributionRequest.class))).thenThrow(error("AccessDenied", 403));
|
||||
when(cloudFront.getDistributionConfig(any(GetDistributionConfigRequest.class))).thenThrow(error(code, status));
|
||||
assertThat(provider().checkHealth(CONFIG).state()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Network failures are DEGRADED (transient); missing credentials or
|
||||
** distribution id are DOWN; other SDK failures are DOWN.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void clientAndConfigFailures()
|
||||
{
|
||||
when(cloudFront.getDistribution(any(GetDistributionRequest.class))).thenThrow(SdkClientException.create(SECRET));
|
||||
CdnHealth unreachable = provider().checkHealth(CONFIG);
|
||||
assertThat(unreachable.state()).isEqualTo(CdnHealth.State.DEGRADED);
|
||||
assertThat(unreachable.details()).containsEntry("transient", true);
|
||||
assertClean(unreachable);
|
||||
|
||||
CdnHealth noCredentials = new CloudFrontCdnProvider(new CloudFrontClientFactory(name -> null, null, null)).checkHealth(CONFIG);
|
||||
assertThat(noCredentials.state()).isEqualTo(CdnHealth.State.DOWN);
|
||||
assertThat(noCredentials.message()).isEqualTo("CloudFront credentials are not configured");
|
||||
|
||||
CdnHealth noId = provider().checkHealth(new CdnProviderConfig("x", CdnProviderType.CLOUDFRONT, " ", "CDN_AWS", null));
|
||||
assertThat(noId.state()).isEqualTo(CdnHealth.State.DOWN);
|
||||
assertThat(noId.message()).isEqualTo("CloudFront distribution id is not configured");
|
||||
|
||||
when(cloudFront.getDistribution(any(GetDistributionRequest.class))).thenThrow(SdkException.builder().message(SECRET).build());
|
||||
CdnHealth other = provider().checkHealth(CONFIG);
|
||||
assertThat(other.state()).isEqualTo(CdnHealth.State.DOWN);
|
||||
assertClean(other);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Nothing the AWS side controls as free text reaches the outcome.
|
||||
*******************************************************************************/
|
||||
private static void assertClean(CdnHealth health)
|
||||
{
|
||||
assertThat(health.message()).doesNotContain(SECRET, DISTRIBUTION, "cloudfront.net", "origin.kof22.com");
|
||||
assertThat(List.copyOf(health.details().values()).toString()).doesNotContain(SECRET, DISTRIBUTION, "cloudfront.net", "origin.kof22.com");
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import com.kingsrook.qbits.cdn.model.CdnTables;
|
||||
import com.kingsrook.qbits.cdn.processes.CdnDefaultsProcesses;
|
||||
import com.kingsrook.qbits.cdn.processes.CdnOriginCheckProcesses;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -142,37 +142,17 @@ class CdnTablesGuardrailTest
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
assertThat(CdnHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
var entries = HelpBundles.of(qInstance).getBundles().get(0).getEntries();
|
||||
for(String table : cdnTables())
|
||||
{
|
||||
List<QHelpContent> help = qInstance.getTable(table).getHelpContent() == null ? null : qInstance.getTable(table).getHelpContent().get(CdnHelp.SLOT);
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
var entry = entries.stream().filter(e -> e.getKey().equals("table:" + table + ";slot:help")).findFirst().orElseThrow();
|
||||
assertThat(entry.getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
if(SETUP_ACTIONS.containsKey(table))
|
||||
{
|
||||
assertThat(help.get(0).getContent()).as(table + " help names its setup action").contains(qInstance.getProcess(SETUP_ACTIONS.get(table)).getLabel());
|
||||
assertThat(entry.getContent()).as(table + " help names its setup action").contains(qInstance.getProcess(SETUP_ACTIONS.get(table)).getLabel());
|
||||
}
|
||||
}
|
||||
assertThat(qInstance.getApp("cdnAdmin").getHelpContent()).containsKey(CdnHelp.SLOT);
|
||||
assertThat(CdnHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || qInstance.getTable(key.substring(6, key.indexOf(';'))) != null,
|
||||
"help names only real tables");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host's own help in the slot is never replaced by the bundle.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostHelpIsKept() throws Exception
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(CdnTables.PROVIDER);
|
||||
table.withHelpContent(CdnHelp.SLOT, new QHelpContent("Host help for providers."));
|
||||
qInstance.getApp("cdnAdmin").withHelpContent(CdnHelp.SLOT, new QHelpContent("Host help for the app."));
|
||||
CdnHelp.apply(qInstance, "cdnAdmin");
|
||||
assertThat(table.getHelpContent().get(CdnHelp.SLOT)).singleElement().satisfies(h -> assertThat(h.getContent()).isEqualTo("Host help for providers."));
|
||||
assertThat(qInstance.getApp("cdnAdmin").getHelpContent().get(CdnHelp.SLOT)).singleElement().satisfies(h -> assertThat(h.getContent()).isEqualTo("Host help for the app."));
|
||||
assertThat(qInstance.getTable(CdnTables.HOSTNAME).getHelpContent().get(CdnHelp.SLOT).get(0).getContent()).contains("How rows get created");
|
||||
assertThat(entries).anyMatch(e -> e.getKey().equals(CdnHelp.APP_KEY));
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user