Compare commits

...
Author SHA1 Message Date
james.maesandCodex 9f54082024 ci: centralize QBit source scanning
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:18:40 -05:00
james.maesandCodex 47b9c52b32 ci: adopt central orb 0.7.12 and stable gates (#34)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:56:25 -05:00
james.maesandCodex d683c20449 ci: centralize rc release completion in qqq orb (#33)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:25:33 -05:00
james.maesandCodex 638e988461 ci: adopt shared orb 0.7.11 (#32)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:59:29 -05:00
james.maes 8de023efa7 Merge pull request #31 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:06:47 -05:00
james.maesandCodex 90cf7fd158 ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:02:48 -05:00
james.maes 7b492f857c Merge pull request #29 from QRun-IO/feature/cloudfront-health-check
fix(health): real CloudFront health probe; UP/not-testable instead of UNKNOWN
2026-10-16 11:51:29 -05:00
james.maesandClaude Opus 5.5 9ff55f4c23 fix(health): real CloudFront health probe; UP/not-testable instead of UNKNOWN
The cdn.provider.<id> health check returned UNKNOWN ("CDN provider does not
support connection tests") for any adapter without CdnOperations, i.e. the
NONE placeholder and Souin. The central evaluator never alerts or changes
state on UNKNOWN, so such a check is permanently useless.

- New optional SPI CdnHealthProbe/CdnHealth; the health check prefers it,
  then the connection test, then reports UP with testable=false.
- CloudFront implements it: GetDistribution -> UP when enabled and
  Deployed, DEGRADED while InProgress, DOWN when NoSuchDistribution or
  disabled; throttling/5xx/network errors DEGRADED; rejected or missing
  credentials DOWN. On AccessDenied it falls back to GetDistributionConfig
  (the kof22 policy grants that, not GetDistribution) and reports
  testable=false; if both are denied, UP with testable=false.
- CloudWatch AWS/CloudFront 5xxErrorRate (Average, 5-min periods, last 30
  minutes) goes into details; above 5% the check is DEGRADED. Best effort.
- Messages are fixed text; details hold only booleans, numbers and known
  states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:38:34 -05:00
james.maes 407b7674c6 Merge pull request #28 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:05:05 -05:00
james.maesandClaude Opus 5.5 3ff13a663e ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:40 -05:00
james.maes 85f2ba60ef Merge pull request #27 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:57 -05:00
james.maesandClaude Opus 5.5 79ae6264f8 fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-cdn.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:22:59 -05:00
james.maes 98114a6482 Merge pull request #26 from QRun-IO/fix/help-bundle-version-test
test(help): bundle version test compares with the artifact version
2026-10-14 23:18:42 -05:00
james.maesandClaude Opus 5.5 3d72a5d05d test(help): compare the help bundle version with the artifact version, not a hardcoded SNAPSHOT
The bundle version is now Maven-filtered, so a literal SNAPSHOT in the test
fails every release build. Surefire passes the artifact version in
qbit.artifactVersion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:07:25 -05:00
james.maes 4fb8d74458 Merge pull request #25 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:21 -05:00
james.maesandClaude Opus 5.5 aba80ff62f fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:01:28 -05:00
james.maes 55a2ec2a09 Merge pull request #22 from QRun-IO/feature/health-checks
feat(health): register CDN provider checks with core central health
2026-10-14 21:27:00 -05:00
james.maesandClaude Opus 5.5 1262f567c8 Merge origin/develop into feature/health-checks; pin QQQ 4.1.0-RC.8
RC.8 ships the central health kernel (QRun-IO/qqq#1207).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:15:03 -05:00
james.maes bbf1b55eac Merge pull request #24 from QRun-IO/feature/help-bundle
feat(help): qbit-cdn help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:29:38 -05:00
james.maesandClaude Opus 5.5 1cd8810e32 docs: CDN health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:08 -05:00
james.maesandClaude Opus 5.5 d3995734dd refactor(cdn): drop the HTML health card and the scheduled connection test (central health covers them)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:07 -05:00
james.maesandClaude Opus 5.5 8bb2c4eb4f feat(health): register CDN provider checks with core central health
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:06 -05:00
24 changed files with 1523 additions and 155 deletions

No files matched your search

+11 -57
View File
@@ -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+$/
+23
View File
@@ -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
+62 -13
View File
@@ -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
View File
@@ -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
+7
View File
@@ -5,12 +5,17 @@ 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.
@@ -18,6 +23,8 @@ All notable changes to this project are documented here. The format follows Keep
### 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.
+17 -3
View File
@@ -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.7** (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`;
@@ -144,7 +158,7 @@ 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.
Run **Check Origin Headers** on a hostname to populate origin-check results.
+30 -1
View File
@@ -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.7</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,6 +196,8 @@ public class CdnQBitProducer implements QBitMetaDataProducer<CdnQBitConfig>
}
output.add(CdnWidgets.app(qBitConfig, tables));
output.addSelfToInstance(qInstance);
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);
}
}
}
@@ -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);
}
}
@@ -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);
}
+4 -8
View File
@@ -1,5 +1,5 @@
bundle: qbit-cdn
version: 0.1.0-SNAPSHOT
version: ${project.version}
layer: QBIT
entries:
- key: app:cdnAdmin;slot:help
@@ -324,7 +324,7 @@ entries:
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. Open findings turn the CDN status on the Health screen to needing attention. You cannot edit findings; fix the CDN or the expected setup instead.
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
@@ -386,18 +386,14 @@ entries:
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: Test enabled CDN providers and check their configuration on the configured schedule.
summary: Check enabled CDN providers for configuration drift on the configured schedule.
content: |
When your application configures a scheduler, this job updates connection health and drift findings for supported providers. Use **{{label:cdnCheckDrift}}** to check drift on demand.
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: widget:cdnHealthCard;slot:label
summary: 'A one-line CDN status for the Health screen: not connected, needs attention, or all checks OK.'
content: |
It needs attention when a purge went dead in the last 24 hours, a drift finding is open, the last connection test failed, the credentials are 75 or more days old, or this month's billed paths passed the soft limit.
- key: possibleValueSource:cdn_provider_type;slot:help
summary: The CDN services a provider can be.
content: |
+1
View File
@@ -0,0 +1 @@
version=${project.version}
@@ -65,7 +65,7 @@ class CdnHelpBundleTest
assertThat(instance.getTable("helpContent")).isNull();
var bundle = HelpBundles.of(instance).getBundles().get(0);
assertThat(bundle.getId()).isEqualTo("qbit-cdn");
assertThat(bundle.getVersion()).isEqualTo("0.1.0-SNAPSHOT");
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.
@@ -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");
}
}