Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f54082024
|
||
|
|
47b9c52b32 | ||
|
|
d683c20449 | ||
|
|
638e988461 | ||
|
|
8de023efa7 | ||
|
|
90cf7fd158
|
||
|
|
7b492f857c | ||
|
|
9ff55f4c23
|
||
|
|
407b7674c6 | ||
|
|
3ff13a663e
|
||
|
|
85f2ba60ef | ||
|
|
79ae6264f8
|
||
|
|
98114a6482 | ||
|
|
3d72a5d05d
|
||
|
|
4fb8d74458 | ||
|
|
aba80ff62f
|
||
|
|
55a2ec2a09 | ||
|
|
1262f567c8
|
||
|
|
bbf1b55eac | ||
|
|
1cd8810e32
|
||
|
|
d3995734dd
|
||
|
|
8bb2c4eb4f
|
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -5,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+209
-2
@@ -22,14 +22,18 @@ import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalDouble;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnCapability;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnEdgeStat;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnException;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnHeaderDialect;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnHealth;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnHealthProbe;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderConfig;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderInterface;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnProviderType;
|
||||
@@ -41,6 +45,8 @@ import software.amazon.awssdk.core.exception.SdkException;
|
||||
import software.amazon.awssdk.services.cloudfront.CloudFrontClient;
|
||||
import software.amazon.awssdk.services.cloudfront.model.CreateInvalidationResponse;
|
||||
import software.amazon.awssdk.services.cloudfront.model.Distribution;
|
||||
import software.amazon.awssdk.services.cloudfront.model.GetDistributionConfigRequest;
|
||||
import software.amazon.awssdk.services.cloudfront.model.GetDistributionRequest;
|
||||
import software.amazon.awssdk.services.cloudfront.model.GetInvalidationResponse;
|
||||
import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
|
||||
import software.amazon.awssdk.services.cloudwatch.model.Dimension;
|
||||
@@ -70,7 +76,7 @@ import software.amazon.awssdk.services.cloudwatch.model.ScanBy;
|
||||
** {@link #testConnection}, {@link #importStats} and {@link #readConfig}
|
||||
** (compare the result with {@link CloudFrontDriftCheck#compare}).</p>
|
||||
*******************************************************************************/
|
||||
public class CloudFrontCdnProvider implements CdnProviderInterface, com.kingsrook.qbits.cdn.spi.CdnOperations
|
||||
public class CloudFrontCdnProvider implements CdnProviderInterface, com.kingsrook.qbits.cdn.spi.CdnOperations, CdnHealthProbe
|
||||
{
|
||||
/** Default origin custom header that proves a request came through CloudFront. */
|
||||
public static final String DEFAULT_ORIGIN_VERIFY_HEADER = "X-Origin-Verify";
|
||||
@@ -81,6 +87,22 @@ public class CloudFrontCdnProvider implements CdnProviderInterface, com.kingsroo
|
||||
static final String STATUS_COMPLETED = "Completed";
|
||||
static final int SECONDS_PER_DAY = 86_400;
|
||||
|
||||
/** Distribution states CloudFront reports (anything else is reported as UNKNOWN). */
|
||||
static final String STATUS_DEPLOYED = "Deployed";
|
||||
static final String STATUS_IN_PROGRESS = "InProgress";
|
||||
|
||||
/** 5xx error rate (percent, CloudWatch 5xxErrorRate) above which the health check is DEGRADED. */
|
||||
static final double ERROR_RATE_5XX_DEGRADED_PERCENT = 5.0;
|
||||
|
||||
/** How far back the health check averages the 5xx error rate. */
|
||||
static final int ERROR_RATE_WINDOW_MINUTES = 30;
|
||||
|
||||
/** CloudWatch period for the health check's 5xx error rate. */
|
||||
static final int ERROR_RATE_PERIOD_SECONDS = 300;
|
||||
|
||||
/** Health message when the credentials cannot read the distribution status. */
|
||||
static final String NOT_TESTABLE_MESSAGE = "CloudFront configured; distribution status not testable with current permissions";
|
||||
|
||||
private static final Set<String> RETRYABLE_CODES = Set.of(
|
||||
"TooManyInvalidationsInProgress", "Throttling", "ThrottlingException", "RequestLimitExceeded", "ServiceUnavailable", "InternalError");
|
||||
|
||||
@@ -278,6 +300,181 @@ public class CloudFrontCdnProvider implements CdnProviderInterface, com.kingsroo
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Central health probe. GetDistribution gives existence, Enabled and the
|
||||
** deployment Status: UP when enabled and Deployed, DEGRADED while
|
||||
** InProgress, DOWN when missing or disabled. The section 7.5 IAM policy
|
||||
** grants GetDistributionConfig but not GetDistribution, so on AccessDenied
|
||||
** this falls back to GetDistributionConfig (existence and Enabled, no
|
||||
** Status) and reports {@code testable=false}. If neither read is permitted
|
||||
** it reports UP with {@code testable=false}: UNKNOWN never alerts anyone,
|
||||
** and failed invalidations still show up as FAILED/DEAD purge requests.
|
||||
** The CloudWatch 5xxErrorRate over the last
|
||||
** {@value #ERROR_RATE_WINDOW_MINUTES} minutes is added when readable and
|
||||
** makes the check DEGRADED above {@value #ERROR_RATE_5XX_DEGRADED_PERCENT}
|
||||
** percent. Messages are fixed text; no AWS message, id or hostname is
|
||||
** copied. Never throws.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public CdnHealth checkHealth(CdnProviderConfig config)
|
||||
{
|
||||
String headerName = config.settings().getOrDefault(SETTING_ORIGIN_VERIFY_HEADER, DEFAULT_ORIGIN_VERIFY_HEADER);
|
||||
String distributionId;
|
||||
try
|
||||
{
|
||||
distributionId = distributionId(config);
|
||||
}
|
||||
catch(CdnException e)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DOWN, "CloudFront distribution id is not configured", null);
|
||||
}
|
||||
|
||||
Map<String, Object> details = new LinkedHashMap<>();
|
||||
CloudFrontConfigSnapshot snapshot;
|
||||
String status = null;
|
||||
try(CloudFrontClient client = clientFactory.cloudFront(config))
|
||||
{
|
||||
try
|
||||
{
|
||||
Distribution distribution = client.getDistribution(GetDistributionRequest.builder().id(distributionId).build()).distribution();
|
||||
status = distribution.status();
|
||||
snapshot = CloudFrontConfigSnapshot.from(distribution.distributionConfig());
|
||||
}
|
||||
catch(AwsServiceException e)
|
||||
{
|
||||
if(!isAccessDenied(e))
|
||||
{
|
||||
return apiFailure(e);
|
||||
}
|
||||
details.put("testable", false);
|
||||
try
|
||||
{
|
||||
snapshot = CloudFrontConfigSnapshot.from(client.getDistributionConfig(GetDistributionConfigRequest.builder().id(distributionId).build()).distributionConfig());
|
||||
details.put("reason", "credentials lack cloudfront:GetDistribution");
|
||||
}
|
||||
catch(AwsServiceException e2)
|
||||
{
|
||||
if(!isAccessDenied(e2))
|
||||
{
|
||||
return apiFailure(e2);
|
||||
}
|
||||
details.put("reason", "credentials lack cloudfront:GetDistribution and cloudfront:GetDistributionConfig");
|
||||
return new CdnHealth(CdnHealth.State.UP, NOT_TESTABLE_MESSAGE, details);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(CdnException e)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DOWN, "CloudFront credentials are not configured", null);
|
||||
}
|
||||
catch(SdkClientException e)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DEGRADED, "CloudFront API unreachable; distribution status not checked", Map.of("transient", true));
|
||||
}
|
||||
catch(SdkException e)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DOWN, "CloudFront status check failed", null);
|
||||
}
|
||||
|
||||
boolean statusTestable = !details.containsKey("testable");
|
||||
boolean headerConfigured = snapshot.hasOriginHeader(headerName);
|
||||
details.put("enabled", snapshot.enabled());
|
||||
details.put("status", STATUS_DEPLOYED.equals(status) || STATUS_IN_PROGRESS.equals(status) ? status : "UNKNOWN");
|
||||
details.put("originHeaderConfigured", headerConfigured);
|
||||
if(!snapshot.enabled())
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DOWN, "CloudFront distribution is disabled", details);
|
||||
}
|
||||
|
||||
Double errorRate = errorRate5xx(config, distributionId);
|
||||
details.put("errorRateWindowMinutes", ERROR_RATE_WINDOW_MINUTES);
|
||||
if(errorRate != null)
|
||||
{
|
||||
details.put("errorRate5xxPercent", Math.round(errorRate * 100.0) / 100.0);
|
||||
}
|
||||
|
||||
if(STATUS_IN_PROGRESS.equals(status))
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DEGRADED, "CloudFront distribution is deploying a change (InProgress)", details);
|
||||
}
|
||||
if(!headerConfigured)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DEGRADED, "CDN origin header is not configured", details);
|
||||
}
|
||||
if(errorRate != null && errorRate > ERROR_RATE_5XX_DEGRADED_PERCENT)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DEGRADED, "CloudFront 5xx error rate is above 5% over the last 30 minutes", details);
|
||||
}
|
||||
if(!statusTestable)
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.UP, NOT_TESTABLE_MESSAGE, details);
|
||||
}
|
||||
return new CdnHealth(CdnHealth.State.UP, "CloudFront distribution is enabled and deployed", details);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an AWS error is a permissions refusal.
|
||||
*******************************************************************************/
|
||||
private static boolean isAccessDenied(AwsServiceException e)
|
||||
{
|
||||
String code = e.awsErrorDetails() == null ? null : e.awsErrorDetails().errorCode();
|
||||
return "AccessDenied".equals(code) || "AccessDeniedException".equals(code) || (code == null && e.statusCode() == 403);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Health for an AWS error other than AccessDenied: a missing distribution is
|
||||
** DOWN, throttling and AWS-side errors are DEGRADED (transient), anything
|
||||
** else (rejected credentials, bad id) is DOWN.
|
||||
*******************************************************************************/
|
||||
private static CdnHealth apiFailure(AwsServiceException e)
|
||||
{
|
||||
String code = e.awsErrorDetails() == null ? null : e.awsErrorDetails().errorCode();
|
||||
if("NoSuchDistribution".equals(code))
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DOWN, "CloudFront distribution not found", null);
|
||||
}
|
||||
if(toCdnException("GetDistribution", e).isRetryable())
|
||||
{
|
||||
return new CdnHealth(CdnHealth.State.DEGRADED, "CloudFront API throttled or unavailable; distribution status not checked", Map.of("transient", true));
|
||||
}
|
||||
return new CdnHealth(CdnHealth.State.DOWN, "CloudFront rejected the status check", null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Average CloudWatch 5xxErrorRate (percent) over the health window, or null
|
||||
** when there is no traffic or the metric cannot be read. Best effort: it
|
||||
** never fails the health check.
|
||||
*******************************************************************************/
|
||||
private Double errorRate5xx(CdnProviderConfig config, String distributionId)
|
||||
{
|
||||
Instant end = Instant.now();
|
||||
try(CloudWatchClient client = clientFactory.cloudWatch(config))
|
||||
{
|
||||
GetMetricDataResponse response = client.getMetricData(GetMetricDataRequest.builder()
|
||||
.metricDataQueries(query("err5xx", "5xxErrorRate", "Average", distributionId, ERROR_RATE_PERIOD_SECONDS))
|
||||
.startTime(end.minusSeconds(ERROR_RATE_WINDOW_MINUTES * 60L))
|
||||
.endTime(end)
|
||||
.build());
|
||||
OptionalDouble average = response.metricDataResults().stream()
|
||||
.flatMap(result -> result.values().stream())
|
||||
.mapToDouble(Double::doubleValue)
|
||||
.average();
|
||||
return average.isPresent() ? average.getAsDouble() : null;
|
||||
}
|
||||
catch(CdnException | SdkException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The distribution config facts used for drift checks (GetDistributionConfig).
|
||||
*******************************************************************************/
|
||||
@@ -368,6 +565,16 @@ public class CloudFrontCdnProvider implements CdnProviderInterface, com.kingsroo
|
||||
** One daily CloudFront metric query.
|
||||
*******************************************************************************/
|
||||
private static MetricDataQuery query(String id, String metricName, String stat, String distributionId)
|
||||
{
|
||||
return query(id, metricName, stat, distributionId, SECONDS_PER_DAY);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One CloudFront metric query with the given period (seconds).
|
||||
*******************************************************************************/
|
||||
private static MetricDataQuery query(String id, String metricName, String stat, String distributionId, int periodSeconds)
|
||||
{
|
||||
Metric metric = Metric.builder()
|
||||
.namespace("AWS/CloudFront")
|
||||
@@ -377,7 +584,7 @@ public class CloudFrontCdnProvider implements CdnProviderInterface, com.kingsroo
|
||||
.build();
|
||||
return MetricDataQuery.builder()
|
||||
.id(id)
|
||||
.metricStat(MetricStat.builder().metric(metric).period(SECONDS_PER_DAY).stat(stat).build())
|
||||
.metricStat(MetricStat.builder().metric(metric).period(periodSeconds).stat(stat).build())
|
||||
.returnData(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.cdn.spi;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result of an adapter's own health probe (see {@link CdnHealthProbe}). The
|
||||
** message and every detail value must be fixed text, numbers, booleans or
|
||||
** known provider states: never provider-supplied free text, hostnames, ids
|
||||
** or credentials, because they are copied to the central health tables.
|
||||
**
|
||||
** @param state UP, DEGRADED or DOWN
|
||||
** @param message a short fixed summary
|
||||
** @param details safe facts (may be empty, never null)
|
||||
*******************************************************************************/
|
||||
public record CdnHealth(State state, String message, Map<String, Object> details)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Health as the adapter sees it.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
UP,
|
||||
DEGRADED,
|
||||
DOWN
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null details become an empty map.
|
||||
*******************************************************************************/
|
||||
public CdnHealth
|
||||
{
|
||||
details = details == null ? Map.of() : Map.copyOf(details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.cdn.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional: an adapter that can say whether its CDN is healthy, beyond the
|
||||
** "Test connection" action ({@link CdnOperations}). The central health check
|
||||
** prefers this when an adapter implements it.
|
||||
*******************************************************************************/
|
||||
public interface CdnHealthProbe
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Probe the CDN read-only. Never throws: failures come back as DOWN or
|
||||
** DEGRADED, with fixed messages (see {@link CdnHealth}).
|
||||
*******************************************************************************/
|
||||
CdnHealth checkHealth(CdnProviderConfig config);
|
||||
}
|
||||
@@ -1,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: |
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user