Compare commits

...
Author SHA1 Message Date
aias00 c2755a37b1 Merge branch 'master' into fix/008-questdb-sql-injection 2026-08-17 13:50:00 +08:00
NekoPunchandaias00 33284bb955 [fix] strip NUL bytes before tdengine history insert (#4310)
Co-authored-by: aias00 <liuhongyu@apache.org>
2026-08-17 13:46:55 +08:00
NekoPunchandaias00 1573236b15 fix(deps): replace bouncycastle jdk15on with jdk18on 1.85 (#4312)
Co-authored-by: aias00 <liuhongyu@apache.org>
2026-08-17 12:41:56 +08:00
NekoPunchandaias00 a08a365952 fix(collector): keep partial telnet metrics, surface raw reply (#4311)
Co-authored-by: aias00 <liuhongyu@apache.org>
2026-08-17 11:33:06 +08:00
NekoPunchandaias00 1ebfe76c37 [fix] evaluate threshold rules with empty field values (#4307)
Co-authored-by: aias00 <liuhongyu@apache.org>
2026-08-17 11:05:32 +08:00
NekoPunchandaias00 e12a1ddead [fix] keep one-row metric data when command output is short (#4308)
Co-authored-by: aias00 <liuhongyu@apache.org>
2026-08-17 09:45:54 +08:00
NekoPunchandTomsun28 b61dbfcacf fix(collector): correct jsonpath alias parsing for rows missing path (#4265)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-08-17 09:18:42 +08:00
Bhavya SonigraandTomsun28 f952d47eb4 [security] Restrict plugin upload endpoint to admin role (#4149) (#4257)
Signed-off-by: Bhavya Sonigra <sonigrabhavya28@gmail.com>
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-08-15 01:44:43 +08:00
DuansgandClaude Opus 5 cf737db2ee [fix] stop serving the openapi document to anonymous callers (#4276)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:44:48 +08:00
Duansg cbb62a3358 [fix] bound what one anonymous push request can consume (#4273) 2026-08-14 10:41:02 +08:00
shown 001e229236 fix(ai): persist parameters for scheduled skills (#4317) 2026-08-13 22:38:55 +08:00
shownandDuansg 2cea398910 fix(ai): create conversation when conversation ID is missing (#4315)
Co-authored-by: Duansg <siguoduan@gmail.com>
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
2026-08-13 00:03:24 +08:00
NekoPunchandDuansg 8a62215e8b test(web): make web-app unit tests runnable (#4314)
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-12 23:26:38 +08:00
NekoPunch f07215639d [feat] paginate the monitoring metrics data table (#4309) 2026-08-12 22:41:23 +08:00
Duansg 41c4fb7ff5 [fix] add missing rbac rules for the remaining unruled routes (#4271) 2026-08-11 00:50:11 +08:00
Duansg cfb3dee490 [fix] fix align cors configuration with header based authentication (#4267) 2026-08-10 01:45:40 +08:00
NekoPunchandDuansg 4feff12e32 fix(alert): keep datasource in alert rule export/import (#4264)
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-10 01:34:47 +08:00
lynx009andDuansg 3e7c2bc67f feat(alerter): support Alibaba Cloud Monitor webhook (#4296)
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-10 00:41:18 +08:00
NekoPunchandDuansg 42307a9928 [feat] Support etcd monitoring (#4306)
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-10 00:10:00 +08:00
Duansg 3a73a34daf Merge branch 'master' into fix/008-questdb-sql-injection 2026-08-10 00:01:14 +08:00
NekoPunchandDuansg fd8056b066 [fix] keep zero-row metrics data in redis queue codec (#4305)
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-09 23:50:47 +08:00
68ecb7118b [fix] add missing alert.setting.window i18n keys for non-zh-CN locales (#4298)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-09 22:38:59 +08:00
Duansg a5496d6804 [fix] validate the metric history query inputs (#4274) 2026-08-09 22:09:21 +08:00
DuansgandClaude Opus 5 4566f62de3 [fix] restrict the actuator endpoints to admin (#4277)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:07:48 +08:00
Duansg 1a7b45ec26 [fix] restrict monitoring template writes to admin (#4278) 2026-08-09 20:46:58 +08:00
06078adc60 [fix] stop silent SSL-to-plaintext downgrade in R2DBC MySQL route (#4260)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-09 18:47:38 +08:00
Tanay Paulandlynx009 c4b4e859e6 docs(help): add alert notification overview page (#4300)
Co-authored-by: lynx009 <2030509072@qq.com>
2026-08-08 10:10:03 +08:00
Duansg e3cb203d37 [fix] restore dropped rbac rule for single monitor deletion (#4269) 2026-08-07 07:07:34 -07:00
shown df843942cc fix(ai): improve SOP schedule error handling (#4301)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
2026-08-06 23:47:00 +08:00
LogicandDuansg 7eb4b1cd44 maintenance: preserve metric string values (#4287)
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-06 14:46:59 +08:00
LogicandDuansg bf3bb4fa45 Define bulletin write fields explicitly (#4292)
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-05 13:35:59 +08:00
Logic 76d28add2e maintenance: bound SMS client failure details (#4282) 2026-08-05 07:11:46 +08:00
Logic 12463f6ab7 maintenance: derive rule-scoped notice groups (#4291) 2026-08-03 14:05:39 +08:00
LogicandDuansg e03e0e02e1 Generate metric scheduling intervals lazily (#4290)
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-03 13:54:28 +08:00
LogicandDuansg 4c9ebb7d0a maintenance: canonicalize alert label fingerprints (#4283)
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-03 13:51:02 +08:00
LogicandDuansg e6ceea94f8 maintenance: normalize Zabbix alert identity (#4284)
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-03 13:37:02 +08:00
LogicandDuansg 4a4e582036 maintenance: bound alert persistence locks (#4285)
Co-authored-by: Duansg <siguoduan@gmail.com>
2026-08-03 12:12:36 +08:00
Logic db72f1e402 maintenance: scope Grafana request authentication (#4288) 2026-08-03 11:53:10 +08:00
Duansg b1a4f9630e [fix] stop exposing the jwt and aes master keys over the config api (#4270) 2026-08-03 11:30:56 +08:00
Duansgandshown 16d420a245 [fix] restrict /api/ai SOP endpoints to admin role (#4261)
Co-authored-by: shown <yuluo08290126@gmail.com>
2026-08-03 11:10:43 +08:00
Duansg 6897c8720b [fix] stop jdbc connection properties injected through the database name (#4299) 2026-08-03 09:52:51 +08:00
shown 85dff9b36f fix(ai): handle invalid skill params and failed reports (#4297)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
2026-08-02 20:19:37 +08:00
54e3154be4 [fix] bind greptime compose data-store ports to localhost (#4258)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: shown <yuluo08290126@gmail.com>
2026-08-02 18:35:01 +08:00
shown 358fbc0c28 Merge branch 'master' into fix/008-questdb-sql-injection 2026-07-29 22:40:53 +08:00
shown 59c003aaef [fix] prevent masked notice secret replay to attacker URLs (#4262)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
2026-07-29 20:54:26 +08:00
liuhyandClaude 0b2476e751 [fix] prevent SQL injection in QuestDB history queries
QuestDB history queries build their SQL with String.format, interpolating
the metric (column), table, and instance values straight into the
templates. Those values trace back to rest path variables
(/api/monitor/{instance}/metric/{metricFull}), so an attacker-controlled
instance or metricFull could break out of the templated SQL.

Two gaps:

1. Identifiers (metric column, table name) were placed inside double
   quotes with no charset check. A path value carrying a double quote
   or other SQL metacharacter could escape the identifier and inject.
2. The instance string literal was escaped with
   replace("'", "\\'") which is not a valid QuestDB escape
   (QuestDB/ANSI doubles the quote), so a stored metric_labels value
   containing a single quote stayed injectable.

Fix:
- validateIdentifier() rejects metric/table values outside
  ^[A-Za-z0-9_-]+$ before they reach String.format, failing closed.
- escapeStringLiteral() doubles single quotes (the QuestDB string
  literal escape) for the instance value in the WHERE clause.

QuestDB's HTTP /exec endpoint does not support bind parameters, so the
read path is validated rather than parameterized.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 07:14:33 -07:00
Duansg b1b242f9bf [chore] upgrade hertzbeat version (#4255) 2026-07-28 09:40:56 +08:00
shown cdcb5f74b7 fix(ai): improve SOP schedule error handling (#4254)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
2026-07-27 22:38:00 +08:00
Duansg 0f6b995be2 [fix] skip SQL Server integration test on arm64 (#4253) 2026-07-27 05:20:30 -07:00
shown a4b486d992 fix(ai): improve alert rule validation and parsing (#4251)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
2026-07-26 23:07:48 +08:00
eb7ca9728d improve(dashboard): hide empty category cards, add empty state (#4242)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
Co-authored-by: aias00 <liuhongyu@apache.org>
2026-07-26 18:32:23 +08:00
NekoPunch 0ae41860e3 test: fix two flaky tests (#4250) 2026-07-26 18:21:10 +08:00
NekoPunchandTomsun28 4a8f78b6f9 fix(alerter): keep webhook query params intact (#4249)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-26 17:36:47 +08:00
NekoPunchandaias00 70ddfda1de [docs] refactor blog list UI to card grid (#4219)
Co-authored-by: aias00 <liuhongyu@apache.org>
2026-07-26 15:51:32 +08:00
79c48a4bfc fix(ai): remove SOP schedules when deleting conversations (#4247)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
Signed-off-by: shown <yuluo08290126@gmail.com>
Co-authored-by: Duansg <siguoduan@gmail.com>
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-26 14:38:41 +08:00
Tomsun28andDevin 9ec371a5c5 ci(doc-deploy): fix pnpm cache setup and upgrade Node to 22 (#4248)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-26 14:22:28 +08:00
Tomsun28 c805d633b6 docs: add new contributors and update notice year (#4245) 2026-07-25 20:22:02 +08:00
NekoPunchandTomsun28 c12372d82d fix(web-app): replace removed stylelint --syntax flag (#4241)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-25 14:28:14 +08:00
NekoPunchandTomsun28 63cc017bba fix(web-app): point threshold user guide link to correct docs (#4239)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-25 14:19:44 +08:00
NekoPunchandTomsun28 9531a2fcd3 fix(web-app): add missing Korean observability menu translations (#4240)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-25 14:13:10 +08:00
Yang Chen 717521369c fix: update GreptimeDB Docker Compose to v1.1.3 (#4244) 2026-07-25 14:08:51 +08:00
Yang Chen c8f2da472e fix: add SSE authentication and correct GreptimeDB SQL (#4243) 2026-07-25 13:09:13 +08:00
Logic 6152c07320 refactor: evolve common-core payload compatibility (#4237) 2026-07-24 15:07:35 +08:00
Duansg b832d7ace4 [fix] fix horizontal scroll not working in periodic threshold preview table (#4238) 2026-07-24 10:39:36 +08:00
DuansgandTomsun28 3ab68b5814 [improve] degrade observability consoles gracefully when storage lacks log/trace/metric support (#4233)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-24 00:54:05 +08:00
shown 1d9edb244c feat: use spring ai MethodToolCallbackProvider replace custom reflect… (#4234)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
2026-07-23 20:41:28 +08:00
Duansg 1429115282 [improve] pre-create hertzbeat_logs table during greptime signal initialization (#4231) 2026-07-23 11:34:35 +08:00
4b217e7097 feat(status-page): add configurable time range for component history (#4222)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Duansg <siguoduan@gmail.com>
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-23 00:23:37 +08:00
shown 032a4852f7 fix(ai): propagate security context to tool callbacks (#4230)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
2026-07-22 23:46:33 +08:00
Duansg 6b78b784a2 [improve] remove unused log management translations (#4229) 2026-07-22 14:33:17 +08:00
Duansg 11cd5bb0c7 [improve] reject masked receiver edit when the stored receiver does not exist (#4225) 2026-07-22 01:32:24 +08:00
hengyussandTomsun28 a9eb231989 fix:prometheus timeout monitor key mismatch (#4215)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-21 23:59:29 +08:00
shownandTomsun28 9b31baec8a fix(ai): improve SOP execution and provider config handling (#4224)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-21 23:34:06 +08:00
DuansgandTomsun28 0a38a73756 [improve] form-urlencode SQL request body in GreptimeSqlQueryExecutor (#4223)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-21 23:19:43 +08:00
Duansg c9e3338c9f [improve] mask secret fields of notice receiver in rest api response (#4220) 2026-07-21 23:11:30 +08:00
shown 107f7d02ff fix(log): send initial SSE event and periodic heartbeat (#4221)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
2026-07-20 22:42:25 +08:00
shown e3acbbd6db chore(ci): Add workflow_dispatch trigger to nightly build (#4213) 2026-07-18 01:45:13 +08:00
31bc7d8a51 [feature] Add Korean i18n (#4197)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Duansg <siguoduan@gmail.com>
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-17 21:51:27 +08:00
Duansg 79427099a2 [improve] bump grpc-java from 1.56.1 to 1.76.3 (#4211) 2026-07-17 10:40:17 +08:00
Duansg 0edc363bcc fix: remove duplicated ASF license headers left by license-eye auto-fix (#4210) 2026-07-16 22:58:42 +08:00
47538dbc1a feat(observability): add Angular three-signal transition (#4195)
Signed-off-by: Duansg <siguoduan@gmail.com>
Signed-off-by: Tomsun28 <tomsun28@outlook.com>
Co-authored-by: Duansg <siguoduan@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
Co-authored-by: aias00 <liuhongyu@apache.org>
2026-07-16 20:39:17 +08:00
543 changed files with 23753 additions and 3764 deletions
+204
View File
@@ -2713,6 +2713,210 @@
"contributions": [
"code"
]
},
{
"login": "orangeCatDeveloper",
"name": "NekoPunch",
"avatar_url": "https://avatars.githubusercontent.com/u/95899648?v=4",
"profile": "https://github.com/orangeCatDeveloper",
"contributions": [
"code",
"doc"
]
},
{
"login": "wy471x",
"name": "wy471x",
"avatar_url": "https://avatars.githubusercontent.com/u/52033069?v=4",
"profile": "https://wy471x.github.io/",
"contributions": [
"code"
]
},
{
"login": "hengyuss",
"name": "hengyuss",
"avatar_url": "https://avatars.githubusercontent.com/u/81064732?v=4",
"profile": "https://github.com/hengyuss",
"contributions": [
"code"
]
},
{
"login": "moduvoice",
"name": "moduvoice",
"avatar_url": "https://avatars.githubusercontent.com/u/291867022?v=4",
"profile": "https://moduvoice.com/",
"contributions": [
"doc"
]
},
{
"login": "hutiefang76",
"name": "hutiefang76",
"avatar_url": "https://avatars.githubusercontent.com/u/137664623?v=4",
"profile": "https://github.com/hutiefang76",
"contributions": [
"code"
]
},
{
"login": "ZhouYinLong-lab",
"name": "柳含知 Liu Hanzhi",
"avatar_url": "https://avatars.githubusercontent.com/u/250777154?v=4",
"profile": "https://zylatent.com/",
"contributions": [
"doc"
]
},
{
"login": "wilmerdooley",
"name": "wilmerdooley",
"avatar_url": "https://avatars.githubusercontent.com/u/259930736?v=4",
"profile": "https://github.com/wilmerdooley",
"contributions": [
"code"
]
},
{
"login": "Zmjjeff7",
"name": "Zhenhong Guo",
"avatar_url": "https://avatars.githubusercontent.com/u/175370943?v=4",
"profile": "https://github.com/Zmjjeff7",
"contributions": [
"code"
]
},
{
"login": "abhyudayareddy",
"name": "abhyudayareddy",
"avatar_url": "https://avatars.githubusercontent.com/u/54602866?v=4",
"profile": "https://github.com/abhyudayareddy",
"contributions": [
"code"
]
},
{
"login": "neon-hippo",
"name": "neon-hippo",
"avatar_url": "https://avatars.githubusercontent.com/u/165560498?v=4",
"profile": "https://github.com/neon-hippo",
"contributions": [
"code"
]
},
{
"login": "P-Peaceful",
"name": "P_Peaceful",
"avatar_url": "https://avatars.githubusercontent.com/u/52856161?v=4",
"profile": "https://github.com/P-Peaceful",
"contributions": [
"code",
"doc"
]
},
{
"login": "zhusaidong",
"name": "zhusaidong",
"avatar_url": "https://avatars.githubusercontent.com/u/3039961?v=4",
"profile": "https://github.com/zhusaidong",
"contributions": [
"code"
]
},
{
"login": "zhehenlu",
"name": "zhlu",
"avatar_url": "https://avatars.githubusercontent.com/u/31504542?v=4",
"profile": "https://github.com/zhehenlu",
"contributions": [
"code",
"doc"
]
},
{
"login": "brettgervasoni",
"name": "brettgervasoni",
"avatar_url": "https://avatars.githubusercontent.com/u/34056000?v=4",
"profile": "https://github.com/brettgervasoni",
"contributions": [
"code"
]
},
{
"login": "Darshan-paul",
"name": "Darshan-paul",
"avatar_url": "https://avatars.githubusercontent.com/u/211450705?v=4",
"profile": "https://github.com/Darshan-paul",
"contributions": [
"code"
]
},
{
"login": "04cb",
"name": "layla",
"avatar_url": "https://avatars.githubusercontent.com/u/111667698?v=4",
"profile": "https://github.com/04cb",
"contributions": [
"doc"
]
},
{
"login": "miantalha45",
"name": "Talha Amjad",
"avatar_url": "https://avatars.githubusercontent.com/u/155809113?v=4",
"profile": "https://github.com/miantalha45",
"contributions": [
"code"
]
},
{
"login": "turanalmammadov",
"name": "Turan Almammadov",
"avatar_url": "https://avatars.githubusercontent.com/u/16321061?v=4",
"profile": "https://turanalmammadov.com/",
"contributions": [
"code",
"doc"
]
},
{
"login": "zhaoyangplus",
"name": "zhaoyangplus",
"avatar_url": "https://avatars.githubusercontent.com/u/245090302?v=4",
"profile": "https://github.com/zhaoyangplus",
"contributions": [
"code"
]
},
{
"login": "yexuanyang",
"name": "Yang Yexuan",
"avatar_url": "https://avatars.githubusercontent.com/u/73885401?v=4",
"profile": "https://github.com/yexuanyang",
"contributions": [
"code"
]
},
{
"login": "markguo123",
"name": "markguo123",
"avatar_url": "https://avatars.githubusercontent.com/u/155072651?v=4",
"profile": "https://github.com/markguo123",
"contributions": [
"doc"
]
},
{
"login": "leo-934",
"name": "leo",
"avatar_url": "https://avatars.githubusercontent.com/u/55838224?v=4",
"profile": "https://github.com/leo-934",
"contributions": [
"code",
"doc",
"blog"
]
}
],
"contributorsPerLine": 7,
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
version: 10
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: pnpm
cache-dependency-path: home/pnpm-lock.yaml
- uses: actions/setup-python@v4
+7 -7
View File
@@ -47,20 +47,20 @@ jobs:
with:
fetch-depth: 0
# Setup pnpm (must run before setup-node so the pnpm cache can be configured)
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
# Setup Node.js environment
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: pnpm
cache-dependency-path: home/pnpm-lock.yaml
# Setup pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
# Install dependencies in home directory
- name: Install Dependencies
working-directory: home
@@ -37,6 +37,7 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
@@ -53,3 +54,6 @@ jobs:
- name: EsLint Test
working-directory: web-app
run: pnpm lint:ts
- name: Unit Test
working-directory: web-app
run: pnpm test
+1
View File
@@ -23,6 +23,7 @@ on:
- cron: '0 0 * * *'
push:
branches: [ action* ]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
+4
View File
@@ -56,3 +56,7 @@ application-mysql.yml
application-pg.yml
/package.json
/yarn.lock
# Local Codex workspace files
.codex/
AGENTS.md
+1 -1
View File
@@ -1,5 +1,5 @@
Apache HertzBeat™
Copyright 2024-2025 The Apache Software Foundation
Copyright 2024-2026 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
+28 -46
View File
@@ -49,52 +49,6 @@
![HertzBeat](home/static/img/docs/hertzbeat-arch.png)
## ⛄ Supported
> We define all metrics collection types such as `mysql`, `jvm`, and `k8s` as `YML` templates, allowing users to import them to support corresponding types of monitoring.
> Welcome everyone to contribute your customized general metrics type YML template during use.
- [Website](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-website.yml), [Port Telnet](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-port.yml),
[Http Api](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-api.yml), [Ping Connect](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ping.yml),
[Jvm](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-jvm.yml), [SiteMap](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-fullsite.yml),
[Ssl Certificate](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ssl_cert.yml), [SpringBoot2](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-springboot2.yml),
[FTP Server](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ftp.yml), [SpringBoot3](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-springboot3.yml),
[Udp Port](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-udp_port.yml), [Dns](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dns.yml),
[Pop3](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-pop3.yml), [Ntp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ntp.yml),
[Api Code](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-api_code.yml), [Smtp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-smtp.yml),
[Nginx](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-nginx.yml)
- [Mysql](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mysql.yml), [PostgreSQL](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-postgresql.yml),
[MariaDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mariadb.yml), [Redis](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis.yml),
[ElasticSearch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-elasticsearch.yml), [SqlServer](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-sqlserver.yml),
[Oracle](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-oracle.yml), [MongoDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mongodb.yml),
[DM](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dm.yml), [OpenGauss](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-opengauss.yml),
[ClickHouse](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-clickhouse.yml), [IoTDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-iotdb.yml),
[Redis Cluster](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis_cluster.yml), [Redis Sentinel](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis_sentinel.yml)
[Doris BE](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-doris_be.yml), [Doris FE](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-doris_fe.yml),
[Memcached](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-memcached.yml), [NebulaGraph](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-nebula_graph.yml)
- [Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-linux.yml), [Ubuntu](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ubuntu.yml),
[CentOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-centos.yml), [Windows](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-windows.yml),
[EulerOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-euleros.yml), [Fedora CoreOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-coreos.yml),
[OpenSUSE](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-opensuse.yml), [Rocky Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-rockylinux.yml),
[Red Hat](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redhat.yml), [FreeBSD](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-freebsd.yml),
[AlmaLinux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-almalinux.yml), [Debian Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-debian.yml)
- [Tomcat](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tomcat.yml), [Nacos](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-nacos.yml),
[Zookeeper](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-zookeeper.yml), [RabbitMQ](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-rabbitmq.yml),
[Flink](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-flink.yml), [Kafka](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-kafka.yml),
[ShenYu](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-shenyu.yml), [DynamicTp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dynamic_tp.yml),
[Jetty](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-jetty.yml), [ActiveMQ](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-activemq.yml),
[Spring Gateway](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-spring_gateway.yml), [EMQX MQTT](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-emqx.yml),
[AirFlow](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-airflow.yml), [Hive](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hive.yml),
[Spark](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-spark.yml), [Hadoop](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hadoop.yml)
- [Kubernetes](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-kubernetes.yml), [Docker](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-docker.yml)
- [CiscoSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-cisco_switch.yml), [HpeSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hpe_switch.yml),
[HuaweiSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-huawei_switch.yml), [TpLinkSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tplink_switch.yml),
[H3cSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-h3c_switch.yml)
- And More Your Custom Template.
- Notified Support `Discord` `Slack` `Telegram` `Email` `DingTalk` `WeChat` `FeiShu` `Webhook` `SMS` `ServerChan`.
## 🐕 Quick Start
- If you wish to deploy HertzBeat locally, please refer to the following Deployment Documentation for instructions.
@@ -551,6 +505,34 @@ Thanks to these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://shadwal.space/"><img src="https://avatars.githubusercontent.com/u/119167601?v=4?s=100" width="100px;" alt="Sahil Shadwal"/><br /><sub><b>Sahil Shadwal</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Sahil-Shadwal" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/BhanuNidumolu"><img src="https://avatars.githubusercontent.com/u/180380413?v=4?s=100" width="100px;" alt="N.Bhanu Prasad"/><br /><sub><b>N.Bhanu Prasad</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=BhanuNidumolu" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://prakashh-portfolio.vercel.app/"><img src="https://avatars.githubusercontent.com/u/183058331?v=4?s=100" width="100px;" alt="Prakash Kumar"/><br /><sub><b>Prakash Kumar</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Prakash1185" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/orangeCatDeveloper"><img src="https://avatars.githubusercontent.com/u/95899648?v=4?s=100" width="100px;" alt="NekoPunch"/><br /><sub><b>NekoPunch</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=orangeCatDeveloper" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=orangeCatDeveloper" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://wy471x.github.io/"><img src="https://avatars.githubusercontent.com/u/52033069?v=4?s=100" width="100px;" alt="wy471x"/><br /><sub><b>wy471x</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=wy471x" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hengyuss"><img src="https://avatars.githubusercontent.com/u/81064732?v=4?s=100" width="100px;" alt="hengyuss"/><br /><sub><b>hengyuss</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=hengyuss" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://moduvoice.com/"><img src="https://avatars.githubusercontent.com/u/291867022?v=4?s=100" width="100px;" alt="moduvoice"/><br /><sub><b>moduvoice</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=moduvoice" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hutiefang76"><img src="https://avatars.githubusercontent.com/u/137664623?v=4?s=100" width="100px;" alt="hutiefang76"/><br /><sub><b>hutiefang76</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=hutiefang76" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://zylatent.com/"><img src="https://avatars.githubusercontent.com/u/250777154?v=4?s=100" width="100px;" alt="柳含知 Liu Hanzhi"/><br /><sub><b>柳含知 Liu Hanzhi</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=ZhouYinLong-lab" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wilmerdooley"><img src="https://avatars.githubusercontent.com/u/259930736?v=4?s=100" width="100px;" alt="wilmerdooley"/><br /><sub><b>wilmerdooley</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=wilmerdooley" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zmjjeff7"><img src="https://avatars.githubusercontent.com/u/175370943?v=4?s=100" width="100px;" alt="Zhenhong Guo"/><br /><sub><b>Zhenhong Guo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Zmjjeff7" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/abhyudayareddy"><img src="https://avatars.githubusercontent.com/u/54602866?v=4?s=100" width="100px;" alt="abhyudayareddy"/><br /><sub><b>abhyudayareddy</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=abhyudayareddy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/neon-hippo"><img src="https://avatars.githubusercontent.com/u/165560498?v=4?s=100" width="100px;" alt="neon-hippo"/><br /><sub><b>neon-hippo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=neon-hippo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/P-Peaceful"><img src="https://avatars.githubusercontent.com/u/52856161?v=4?s=100" width="100px;" alt="P_Peaceful"/><br /><sub><b>P_Peaceful</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=P-Peaceful" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=P-Peaceful" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhusaidong"><img src="https://avatars.githubusercontent.com/u/3039961?v=4?s=100" width="100px;" alt="zhusaidong"/><br /><sub><b>zhusaidong</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhusaidong" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhehenlu"><img src="https://avatars.githubusercontent.com/u/31504542?v=4?s=100" width="100px;" alt="zhlu"/><br /><sub><b>zhlu</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhehenlu" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=zhehenlu" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/brettgervasoni"><img src="https://avatars.githubusercontent.com/u/34056000?v=4?s=100" width="100px;" alt="brettgervasoni"/><br /><sub><b>brettgervasoni</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=brettgervasoni" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Darshan-paul"><img src="https://avatars.githubusercontent.com/u/211450705?v=4?s=100" width="100px;" alt="Darshan-paul"/><br /><sub><b>Darshan-paul</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Darshan-paul" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/04cb"><img src="https://avatars.githubusercontent.com/u/111667698?v=4?s=100" width="100px;" alt="layla"/><br /><sub><b>layla</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=04cb" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miantalha45"><img src="https://avatars.githubusercontent.com/u/155809113?v=4?s=100" width="100px;" alt="Talha Amjad"/><br /><sub><b>Talha Amjad</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=miantalha45" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://turanalmammadov.com/"><img src="https://avatars.githubusercontent.com/u/16321061?v=4?s=100" width="100px;" alt="Turan Almammadov"/><br /><sub><b>Turan Almammadov</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=turanalmammadov" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=turanalmammadov" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhaoyangplus"><img src="https://avatars.githubusercontent.com/u/245090302?v=4?s=100" width="100px;" alt="zhaoyangplus"/><br /><sub><b>zhaoyangplus</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhaoyangplus" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yexuanyang"><img src="https://avatars.githubusercontent.com/u/73885401?v=4?s=100" width="100px;" alt="Yang Yexuan"/><br /><sub><b>Yang Yexuan</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=yexuanyang" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/markguo123"><img src="https://avatars.githubusercontent.com/u/155072651?v=4?s=100" width="100px;" alt="markguo123"/><br /><sub><b>markguo123</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=markguo123" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/leo-934"><img src="https://avatars.githubusercontent.com/u/55838224?v=4?s=100" width="100px;" alt="leo"/><br /><sub><b>leo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=leo-934" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=leo-934" title="Documentation">📖</a> <a href="#blog-leo-934" title="Blogposts">📝</a></td>
</tr>
</tbody>
</table>
+42 -58
View File
@@ -19,13 +19,13 @@
[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod&color=green)](https://gitpod.io/#https://github.com/apache/hertzbeat)
**官网: [hertzbeat.apache.org](https://hertzbeat.apache.org)**
**官网: [hertzbeat.apache.org](https://hertzbeat.apache.org)**
**邮件: <a href="mailto:dev-subscribe@hertzbeat.apache.org">发送至 ```dev-subscribe@hertzbeat.apache.org```</a>** 订阅邮件列表
## 🎡 <font color="green">介绍</font>
[Apache HertzBeat™](https://github.com/apache/hertzbeat) 是 AI 驱动的下一代开源实时观测系统。指标日志统一收集,告警一站分发,智能管控分析。无需 Agent,高性能集群,提供强大的自定义监控和状态页构建能力。
[Apache HertzBeat™](https://github.com/apache/hertzbeat) 是 AI 驱动的下一代开源实时观测系统。指标日志统一收集,告警一站分发,智能管控分析。无需 Agent,高性能集群,提供强大的自定义监控和状态页构建能力。
### 特点
@@ -39,70 +39,26 @@
- 提供强大的状态页构建能力,轻松向用户传达您产品服务的实时状态。
> `HertzBeat`的统一平台,AI智能,强大自定义,多类型支持,高性能,易扩展,希望能帮助用户快速方便实现观测需求。
> `HertzBeat`的统一平台,AI智能,强大自定义,多类型支持,高性能,易扩展,希望能帮助用户快速方便实现观测需求。
----
----
## 🥐 模块
## 🥐 模块
![hertzBeat](home/static/img/docs/hertzbeat-arch.png)
![hertzBeat](home/static/img/docs/hertzbeat-arch.png)
## ⛄ 已支持
## 🐕 快速开始
> 我们将监控采集类型(mysql,jvm,k8s)都定义为 `yml` 模板,用户可以导入这些模板来支持对应类型的监控!
> 欢迎大家一起贡献你使用过程中自定义的通用指标模板。
- [Website](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-website.yml), [Port Telnet](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-port.yml),
[Http Api](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-api.yml), [Ping Connect](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ping.yml),
[Jvm](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-jvm.yml), [SiteMap](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-fullsite.yml),
[Ssl Certificate](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ssl_cert.yml), [SpringBoot2](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-springboot2.yml),
[FTP Server](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ftp.yml), [SpringBoot3](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-springboot3.yml),
[Udp Port](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-udp_port.yml), [Dns](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dns.yml),
[Pop3](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-pop3.yml), [Ntp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ntp.yml),
[Api Code](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-api_code.yml), [Smtp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-smtp.yml),
[Nginx](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-nginx.yml)
- [Mysql](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mysql.yml), [PostgreSQL](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-postgresql.yml),
[MariaDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mariadb.yml), [Redis](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis.yml),
[ElasticSearch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-elasticsearch.yml), [SqlServer](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-sqlserver.yml),
[Oracle](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-oracle.yml), [MongoDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mongodb.yml),
[DM](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dm.yml), [OpenGauss](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-opengauss.yml),
[ClickHouse](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-clickhouse.yml), [IoTDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-iotdb.yml),
[Redis Cluster](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis_cluster.yml), [Redis Sentinel](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis_sentinel.yml)
[Doris BE](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-doris_be.yml), [Doris FE](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-doris_fe.yml),
[Memcached](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-memcached.yml), [NebulaGraph](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-nebula_graph.yml)
- [Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-linux.yml), [Ubuntu](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ubuntu.yml),
[CentOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-centos.yml), [Windows](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-windows.yml),
[EulerOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-euleros.yml), [Fedora CoreOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-coreos.yml),
[OpenSUSE](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-opensuse.yml), [Rocky Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-rockylinux.yml),
[Red Hat](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redhat.yml), [FreeBSD](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-freebsd.yml),
[AlmaLinux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-almalinux.yml), [Debian Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-debian.yml)
- [Tomcat](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tomcat.yml), [Nacos](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-nacos.yml),
[Zookeeper](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-zookeeper.yml), [RabbitMQ](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-rabbitmq.yml),
[Flink](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-flink.yml), [Kafka](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-kafka.yml),
[ShenYu](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-shenyu.yml), [DynamicTp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dynamic_tp.yml),
[Jetty](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-jetty.yml), [ActiveMQ](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-activemq.yml),
[Spring Gateway](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-spring_gateway.yml), [EMQX MQTT](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-emqx.yml),
[AirFlow](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-airflow.yml), [Hive](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hive.yml),
[Spark](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-spark.yml), [Hadoop](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hadoop.yml)
- [Kubernetes](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-kubernetes.yml), [Docker](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-docker.yml)
- [CiscoSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-cisco_switch.yml), [HpeSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hpe_switch.yml),
[HuaweiSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-huawei_switch.yml), [TpLinkSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tplink_switch.yml),
[H3cSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-h3c_switch.yml)
- 和更多自定义指标模板。
- 通知支持 `Discord` `Slack` `Telegram` `邮件` `钉钉` `微信` `飞书` `短信` `Webhook` `Server酱`
## 🐕 快速开始
- 如果您是想将 HertzBeat 部署到内网环境搭建监控系统,请参考下面的部署文档进行操作。
- 如果您是想将 HertzBeat 部署到内网环境搭建监控系统,请参考下面的部署文档进行操作。
### 🍞 HertzBeat 安装
> HertzBeat 支持通过源码安装启动,Docker 容器运行和安装包方式安装部署,CPU 架构支持 x86/arm64。
##### 方式一:Docker 方式快速安装
##### 方式一:Docker 方式快速安装
1. `docker` 环境仅需一条命令即可开始
1. `docker` 环境仅需一条命令即可开始
```shell
docker run -d -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat
@@ -164,7 +120,7 @@
通过 [Docker-Compose 部署脚本](script/docker-compose) 一次性把 postgresql/mysql 数据库, victoria-metrics/iotdb/tdengine 时序数据库和 hertzbeat 安装部署。
详细步骤参考 [通过 Docker-Compose 安装 HertzBeat](script/docker-compose/README.md)
详细步骤参考 [通过 Docker-Compose 安装 HertzBeat](script/docker-compose/README.md)
##### 方式五:Kubernetes Helm Charts 部署 hertzbeat+collector+postgresql+tsdb
@@ -176,8 +132,8 @@
## ✨ Contributors
Thanks these wonderful people, welcome to join us:
[贡献者指南](CONTRIBUTING.md)
Thanks these wonderful people, welcome to join us:
[贡献者指南](CONTRIBUTING.md)
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
@@ -548,6 +504,34 @@ Thanks these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://shadwal.space/"><img src="https://avatars.githubusercontent.com/u/119167601?v=4?s=100" width="100px;" alt="Sahil Shadwal"/><br /><sub><b>Sahil Shadwal</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Sahil-Shadwal" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/BhanuNidumolu"><img src="https://avatars.githubusercontent.com/u/180380413?v=4?s=100" width="100px;" alt="N.Bhanu Prasad"/><br /><sub><b>N.Bhanu Prasad</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=BhanuNidumolu" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://prakashh-portfolio.vercel.app/"><img src="https://avatars.githubusercontent.com/u/183058331?v=4?s=100" width="100px;" alt="Prakash Kumar"/><br /><sub><b>Prakash Kumar</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Prakash1185" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/orangeCatDeveloper"><img src="https://avatars.githubusercontent.com/u/95899648?v=4?s=100" width="100px;" alt="NekoPunch"/><br /><sub><b>NekoPunch</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=orangeCatDeveloper" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=orangeCatDeveloper" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://wy471x.github.io/"><img src="https://avatars.githubusercontent.com/u/52033069?v=4?s=100" width="100px;" alt="wy471x"/><br /><sub><b>wy471x</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=wy471x" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hengyuss"><img src="https://avatars.githubusercontent.com/u/81064732?v=4?s=100" width="100px;" alt="hengyuss"/><br /><sub><b>hengyuss</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=hengyuss" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://moduvoice.com/"><img src="https://avatars.githubusercontent.com/u/291867022?v=4?s=100" width="100px;" alt="moduvoice"/><br /><sub><b>moduvoice</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=moduvoice" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hutiefang76"><img src="https://avatars.githubusercontent.com/u/137664623?v=4?s=100" width="100px;" alt="hutiefang76"/><br /><sub><b>hutiefang76</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=hutiefang76" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://zylatent.com/"><img src="https://avatars.githubusercontent.com/u/250777154?v=4?s=100" width="100px;" alt="柳含知 Liu Hanzhi"/><br /><sub><b>柳含知 Liu Hanzhi</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=ZhouYinLong-lab" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wilmerdooley"><img src="https://avatars.githubusercontent.com/u/259930736?v=4?s=100" width="100px;" alt="wilmerdooley"/><br /><sub><b>wilmerdooley</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=wilmerdooley" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zmjjeff7"><img src="https://avatars.githubusercontent.com/u/175370943?v=4?s=100" width="100px;" alt="Zhenhong Guo"/><br /><sub><b>Zhenhong Guo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Zmjjeff7" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/abhyudayareddy"><img src="https://avatars.githubusercontent.com/u/54602866?v=4?s=100" width="100px;" alt="abhyudayareddy"/><br /><sub><b>abhyudayareddy</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=abhyudayareddy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/neon-hippo"><img src="https://avatars.githubusercontent.com/u/165560498?v=4?s=100" width="100px;" alt="neon-hippo"/><br /><sub><b>neon-hippo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=neon-hippo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/P-Peaceful"><img src="https://avatars.githubusercontent.com/u/52856161?v=4?s=100" width="100px;" alt="P_Peaceful"/><br /><sub><b>P_Peaceful</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=P-Peaceful" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=P-Peaceful" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhusaidong"><img src="https://avatars.githubusercontent.com/u/3039961?v=4?s=100" width="100px;" alt="zhusaidong"/><br /><sub><b>zhusaidong</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhusaidong" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhehenlu"><img src="https://avatars.githubusercontent.com/u/31504542?v=4?s=100" width="100px;" alt="zhlu"/><br /><sub><b>zhlu</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhehenlu" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=zhehenlu" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/brettgervasoni"><img src="https://avatars.githubusercontent.com/u/34056000?v=4?s=100" width="100px;" alt="brettgervasoni"/><br /><sub><b>brettgervasoni</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=brettgervasoni" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Darshan-paul"><img src="https://avatars.githubusercontent.com/u/211450705?v=4?s=100" width="100px;" alt="Darshan-paul"/><br /><sub><b>Darshan-paul</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Darshan-paul" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/04cb"><img src="https://avatars.githubusercontent.com/u/111667698?v=4?s=100" width="100px;" alt="layla"/><br /><sub><b>layla</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=04cb" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miantalha45"><img src="https://avatars.githubusercontent.com/u/155809113?v=4?s=100" width="100px;" alt="Talha Amjad"/><br /><sub><b>Talha Amjad</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=miantalha45" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://turanalmammadov.com/"><img src="https://avatars.githubusercontent.com/u/16321061?v=4?s=100" width="100px;" alt="Turan Almammadov"/><br /><sub><b>Turan Almammadov</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=turanalmammadov" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=turanalmammadov" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhaoyangplus"><img src="https://avatars.githubusercontent.com/u/245090302?v=4?s=100" width="100px;" alt="zhaoyangplus"/><br /><sub><b>zhaoyangplus</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhaoyangplus" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yexuanyang"><img src="https://avatars.githubusercontent.com/u/73885401?v=4?s=100" width="100px;" alt="Yang Yexuan"/><br /><sub><b>Yang Yexuan</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=yexuanyang" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/markguo123"><img src="https://avatars.githubusercontent.com/u/155072651?v=4?s=100" width="100px;" alt="markguo123"/><br /><sub><b>markguo123</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=markguo123" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/leo-934"><img src="https://avatars.githubusercontent.com/u/55838224?v=4?s=100" width="100px;" alt="leo"/><br /><sub><b>leo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=leo-934" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=leo-934" title="Documentation">📖</a> <a href="#blog-leo-934" title="Blogposts">📝</a></td>
</tr>
</tbody>
</table>
@@ -555,11 +539,11 @@ Thanks these wonderful people, welcome to join us:
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
## 💬 社区交流
##### Channel
##### Channel
[订阅邮件列表](https://lists.apache.org/list.html?dev@hertzbeat.apache.org) : 发送邮件至 `dev-subscribe@hertzbeat.apache.org` 来订阅邮件列表.
+28 -44
View File
@@ -48,50 +48,6 @@
![hertzBeat](home/static/img/docs/hertzbeat-arch.png)
## ⛄ 利用可能
> `mysql`、`Java仮想マシン`、`k8s` などのすべての監視コレクション タイプを `YML` 監視テンプレートとして定義し、ユーザーがそれらをインポートして対応するタイプの監視をサポートできるようにしています。
> カスタマイズされた一般的なモニターのテンプレートを提供することを歓迎します。
- [Website](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-website.yml), [Port Telnet](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-port.yml),
[Http Api](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-api.yml), [Ping Connect](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ping.yml),
[Jvm](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-jvm.yml), [SiteMap](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-fullsite.yml),
[Ssl Certificate](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ssl_cert.yml), [SpringBoot2](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-springboot2.yml),
[FTP Server](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ftp.yml), [SpringBoot3](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-springboot3.yml),
[Udp Port](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-udp_port.yml), [Dns](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dns.yml),
[Pop3](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-pop3.yml), [Ntp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ntp.yml),
[Api Code](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-api_code.yml), [Smtp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-smtp.yml),
[Nginx](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-nginx.yml)
- [Mysql](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mysql.yml), [PostgreSQL](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-postgresql.yml),
[MariaDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mariadb.yml), [Redis](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis.yml),
[ElasticSearch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-elasticsearch.yml), [SqlServer](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-sqlserver.yml),
[Oracle](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-oracle.yml), [MongoDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mongodb.yml),
[DM](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dm.yml), [OpenGauss](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-opengauss.yml),
[ClickHouse](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-clickhouse.yml), [IoTDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-iotdb.yml),
[Redis Cluster](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis_cluster.yml), [Redis Sentinel](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis_sentinel.yml)
[Doris BE](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-doris_be.yml), [Doris FE](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-doris_fe.yml),
[Memcached](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-memcached.yml), [NebulaGraph](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-nebula_graph.yml)
- [Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-linux.yml), [Ubuntu](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ubuntu.yml),
[CentOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-centos.yml), [Windows](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-windows.yml),
[EulerOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-euleros.yml), [Fedora CoreOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-coreos.yml),
[OpenSUSE](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-opensuse.yml), [Rocky Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-rockylinux.yml),
[Red Hat](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redhat.yml), [FreeBSD](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-freebsd.yml),
[AlmaLinux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-almalinux.yml), [Debian Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-debian.yml)
- [Tomcat](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tomcat.yml), [Nacos](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-nacos.yml),
[Zookeeper](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-zookeeper.yml), [RabbitMQ](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-rabbitmq.yml),
[Flink](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-flink.yml), [Kafka](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-kafka.yml),
[ShenYu](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-shenyu.yml), [DynamicTp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dynamic_tp.yml),
[Jetty](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-jetty.yml), [ActiveMQ](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-activemq.yml),
[Spring Gateway](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-spring_gateway.yml), [EMQX MQTT](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-emqx.yml),
[AirFlow](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-airflow.yml), [Hive](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hive.yml),
[Spark](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-spark.yml), [Hadoop](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hadoop.yml)
- [Kubernetes](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-kubernetes.yml), [Docker](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-docker.yml)
- [CiscoSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-cisco_switch.yml), [HpeSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hpe_switch.yml),
[HuaweiSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-huawei_switch.yml), [TpLinkSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tplink_switch.yml),
[H3cSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-h3c_switch.yml)
- その他、カスタマイズされたモニターテンプレート。
- 通知利用可能 `Discord` `Slack` `Telegram` `Email` `DingTalk` `WeChat` `FeiShu` `Webhook` `SMS` `ServerChan`
## 🐕 クイックスタート
- HertzBeat をローカルに展開する場合は、以下のデプロイメントドキュメントを参照してください。
@@ -551,6 +507,34 @@ Thanks these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://shadwal.space/"><img src="https://avatars.githubusercontent.com/u/119167601?v=4?s=100" width="100px;" alt="Sahil Shadwal"/><br /><sub><b>Sahil Shadwal</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Sahil-Shadwal" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/BhanuNidumolu"><img src="https://avatars.githubusercontent.com/u/180380413?v=4?s=100" width="100px;" alt="N.Bhanu Prasad"/><br /><sub><b>N.Bhanu Prasad</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=BhanuNidumolu" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://prakashh-portfolio.vercel.app/"><img src="https://avatars.githubusercontent.com/u/183058331?v=4?s=100" width="100px;" alt="Prakash Kumar"/><br /><sub><b>Prakash Kumar</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Prakash1185" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/orangeCatDeveloper"><img src="https://avatars.githubusercontent.com/u/95899648?v=4?s=100" width="100px;" alt="NekoPunch"/><br /><sub><b>NekoPunch</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=orangeCatDeveloper" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=orangeCatDeveloper" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://wy471x.github.io/"><img src="https://avatars.githubusercontent.com/u/52033069?v=4?s=100" width="100px;" alt="wy471x"/><br /><sub><b>wy471x</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=wy471x" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hengyuss"><img src="https://avatars.githubusercontent.com/u/81064732?v=4?s=100" width="100px;" alt="hengyuss"/><br /><sub><b>hengyuss</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=hengyuss" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://moduvoice.com/"><img src="https://avatars.githubusercontent.com/u/291867022?v=4?s=100" width="100px;" alt="moduvoice"/><br /><sub><b>moduvoice</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=moduvoice" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hutiefang76"><img src="https://avatars.githubusercontent.com/u/137664623?v=4?s=100" width="100px;" alt="hutiefang76"/><br /><sub><b>hutiefang76</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=hutiefang76" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://zylatent.com/"><img src="https://avatars.githubusercontent.com/u/250777154?v=4?s=100" width="100px;" alt="柳含知 Liu Hanzhi"/><br /><sub><b>柳含知 Liu Hanzhi</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=ZhouYinLong-lab" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wilmerdooley"><img src="https://avatars.githubusercontent.com/u/259930736?v=4?s=100" width="100px;" alt="wilmerdooley"/><br /><sub><b>wilmerdooley</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=wilmerdooley" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zmjjeff7"><img src="https://avatars.githubusercontent.com/u/175370943?v=4?s=100" width="100px;" alt="Zhenhong Guo"/><br /><sub><b>Zhenhong Guo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Zmjjeff7" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/abhyudayareddy"><img src="https://avatars.githubusercontent.com/u/54602866?v=4?s=100" width="100px;" alt="abhyudayareddy"/><br /><sub><b>abhyudayareddy</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=abhyudayareddy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/neon-hippo"><img src="https://avatars.githubusercontent.com/u/165560498?v=4?s=100" width="100px;" alt="neon-hippo"/><br /><sub><b>neon-hippo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=neon-hippo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/P-Peaceful"><img src="https://avatars.githubusercontent.com/u/52856161?v=4?s=100" width="100px;" alt="P_Peaceful"/><br /><sub><b>P_Peaceful</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=P-Peaceful" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=P-Peaceful" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhusaidong"><img src="https://avatars.githubusercontent.com/u/3039961?v=4?s=100" width="100px;" alt="zhusaidong"/><br /><sub><b>zhusaidong</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhusaidong" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhehenlu"><img src="https://avatars.githubusercontent.com/u/31504542?v=4?s=100" width="100px;" alt="zhlu"/><br /><sub><b>zhlu</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhehenlu" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=zhehenlu" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/brettgervasoni"><img src="https://avatars.githubusercontent.com/u/34056000?v=4?s=100" width="100px;" alt="brettgervasoni"/><br /><sub><b>brettgervasoni</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=brettgervasoni" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Darshan-paul"><img src="https://avatars.githubusercontent.com/u/211450705?v=4?s=100" width="100px;" alt="Darshan-paul"/><br /><sub><b>Darshan-paul</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Darshan-paul" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/04cb"><img src="https://avatars.githubusercontent.com/u/111667698?v=4?s=100" width="100px;" alt="layla"/><br /><sub><b>layla</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=04cb" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miantalha45"><img src="https://avatars.githubusercontent.com/u/155809113?v=4?s=100" width="100px;" alt="Talha Amjad"/><br /><sub><b>Talha Amjad</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=miantalha45" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://turanalmammadov.com/"><img src="https://avatars.githubusercontent.com/u/16321061?v=4?s=100" width="100px;" alt="Turan Almammadov"/><br /><sub><b>Turan Almammadov</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=turanalmammadov" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=turanalmammadov" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhaoyangplus"><img src="https://avatars.githubusercontent.com/u/245090302?v=4?s=100" width="100px;" alt="zhaoyangplus"/><br /><sub><b>zhaoyangplus</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhaoyangplus" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yexuanyang"><img src="https://avatars.githubusercontent.com/u/73885401?v=4?s=100" width="100px;" alt="Yang Yexuan"/><br /><sub><b>Yang Yexuan</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=yexuanyang" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/markguo123"><img src="https://avatars.githubusercontent.com/u/155072651?v=4?s=100" width="100px;" alt="markguo123"/><br /><sub><b>markguo123</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=markguo123" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/leo-934"><img src="https://avatars.githubusercontent.com/u/55838224?v=4?s=100" width="100px;" alt="leo"/><br /><sub><b>leo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=leo-934" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=leo-934" title="Documentation">📖</a> <a href="#blog-leo-934" title="Blogposts">📝</a></td>
</tr>
</tbody>
</table>
@@ -18,14 +18,21 @@
package org.apache.hertzbeat.ai.config;
import com.usthe.sureness.subject.SubjectSum;
import org.springframework.core.NamedInheritableThreadLocal;
import com.usthe.sureness.util.SurenessContextHolder;
import java.util.Map;
import java.util.function.Supplier;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.core.NamedThreadLocal;
/**
* Context holder for AI agent security context.
*/
public final class McpContextHolder {
static final String SUBJECT_CONTEXT_KEY = McpContextHolder.class.getName() + ".subject";
private static final ThreadLocal<SubjectSum> subjectHolder =
new NamedInheritableThreadLocal<>("MCP Security and User Identification Context");
new NamedThreadLocal<>("MCP Security and User Identification Context");
private McpContextHolder() {}
@@ -44,10 +51,73 @@ public final class McpContextHolder {
return subjectHolder.get();
}
/**
* Creates a security context that is propagated only through the Spring AI tool invocation chain.
*
* @param subject current authenticated subject, may be {@code null}
* @return tool context without null values
*/
public static Map<String, Object> createToolContext(SubjectSum subject) {
return subject == null ? Map.of() : Map.of(SUBJECT_CONTEXT_KEY, subject);
}
/**
* Retrieves and validates the authenticated subject from the Spring AI tool context.
*
* @param toolContext Spring AI tool context
* @return authenticated subject, or {@code null} when absent
*/
public static SubjectSum getSubject(ToolContext toolContext) {
if (toolContext == null) {
return null;
}
Object subject = toolContext.getContext().get(SUBJECT_CONTEXT_KEY);
return subject instanceof SubjectSum subjectSum ? subjectSum : null;
}
/**
* Executes a synchronous tool invocation within the given subject scope and restores the original thread
* contexts afterward.
*
* <p>Spring AI tool callbacks may run on pooled threads, so they cannot rely on a {@link ThreadLocal} left on
* the Servlet request thread. This method binds both MCP and Sureness contexts so the tool and downstream
* services observe the same user identity.</p>
*
* @param subject current request subject, may be {@code null}
* @param operation synchronous tool invocation
* @param <T> tool result type
* @return tool invocation result
*/
public static <T> T callWithSubject(SubjectSum subject, Supplier<T> operation) {
SubjectSum previousMcpSubject = getSubject();
SubjectSum previousSurenessSubject = SurenessContextHolder.getBindSubject();
replaceSubjects(subject);
try {
return operation.get();
} finally {
replaceSubjects(previousMcpSubject, previousSurenessSubject);
}
}
/**
* Clears the context from the thread to prevent memory leaks.
*/
public static void clear() {
subjectHolder.remove();
}
private static void replaceSubjects(SubjectSum subject) {
replaceSubjects(subject, subject);
}
private static void replaceSubjects(SubjectSum mcpSubject, SubjectSum surenessSubject) {
clear();
SurenessContextHolder.unbindSubject();
if (mcpSubject != null) {
setSubject(mcpSubject);
}
if (surenessSubject != null) {
SurenessContextHolder.bindSubject(surenessSubject);
}
}
}
@@ -0,0 +1,60 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.ai.config;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
/**
* Establishes a request-scoped security context for Spring AI tool callbacks.
*
* <p>The wrapper keeps the tool definition and metadata unchanged. It binds the identity only for the delegated
* call and restores the worker thread's original state after both successful and failed invocations.</p>
*/
public final class SecurityContextToolCallback implements ToolCallback {
private final ToolCallback delegate;
public SecurityContextToolCallback(ToolCallback delegate) {
this.delegate = delegate;
}
@Override
public ToolDefinition getToolDefinition() {
return delegate.getToolDefinition();
}
@Override
public ToolMetadata getToolMetadata() {
return delegate.getToolMetadata();
}
@Override
public String call(String toolInput) {
return call(toolInput, null);
}
@Override
public String call(String toolInput, ToolContext toolContext) {
return McpContextHolder.callWithSubject(
McpContextHolder.getSubject(toolContext),
() -> delegate.call(toolInput, toolContext));
}
}
@@ -17,13 +17,10 @@
package org.apache.hertzbeat.ai.controller;
import com.usthe.sureness.subject.SubjectSum;
import com.usthe.sureness.util.SurenessContextHolder;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.config.McpContextHolder;
import org.apache.hertzbeat.ai.pojo.dto.ChatRequestContext;
import org.apache.hertzbeat.ai.pojo.dto.ChatResponseChunk;
import org.apache.hertzbeat.ai.pojo.dto.SecurityData;
@@ -75,8 +72,6 @@ public class ChatController {
public Flux<ServerSentEvent<ChatResponseChunk>> streamChat(@Valid @RequestBody ChatRequestContext context) {
try {
// Validate message is not empty
SubjectSum subject = SurenessContextHolder.getBindSubject();
McpContextHolder.setSubject(subject);
if (context.getMessage() == null || context.getMessage().trim().isEmpty()) {
ChatResponseChunk errorResponse = ChatResponseChunk.builder()
.conversationId(context.getConversationId())
@@ -30,6 +30,7 @@ import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -143,7 +144,7 @@ public class SopController {
.status("FAILED")
.error("SOP skill not found: " + skillName)
.build();
return ResponseEntity.notFound().build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResult);
}
Map<String, Object> inputParams = params != null ? params : new HashMap<>();
@@ -18,12 +18,13 @@
package org.apache.hertzbeat.ai.pojo.dto;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.usthe.sureness.subject.SubjectSum;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import org.apache.hertzbeat.common.entity.ai.ChatMessage;
/**
@@ -47,4 +48,11 @@ public class ChatRequestContext {
* Conversation history messages for context
*/
private List<ChatMessage> conversationHistory;
/**
* Authenticated subject captured by the server. It cannot be supplied through client JSON and is never included
* in model messages.
*/
@JsonIgnore
private SubjectSum subject;
}
@@ -36,6 +36,7 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import tools.jackson.core.type.TypeReference;
/**
* Scheduled executor that checks for due SOP schedules and executes them.
@@ -82,7 +83,11 @@ public class SopScheduleExecutor {
log.info("Found {} due schedules to execute", dueSchedules.size());
for (SopSchedule schedule : dueSchedules) {
executeSchedule(schedule);
try {
executeSchedule(schedule);
} catch (Exception e) {
log.error("Unexpected error processing scheduled SOP {}", schedule.getId(), e);
}
}
} catch (Exception e) {
log.error("Error checking due schedules", e);
@@ -100,19 +105,19 @@ public class SopScheduleExecutor {
// Check if skill exists
var definition = skillRegistry.getSkill(schedule.getSopName());
if (definition == null) {
log.warn("Skill {} not found, skipping schedule {}",
schedule.getSopName(), schedule.getId());
return;
// Do not silently skip an invalid schedule because its execution time will still be advanced.
throw new IllegalStateException("SOP skill not found: " + schedule.getSopName());
}
// Parse parameters
Map<String, Object> params = new HashMap<>();
if (schedule.getSopParams() != null && !schedule.getSopParams().isEmpty()) {
try {
params = JsonUtil.fromJson(schedule.getSopParams(), Map.class);
} catch (Exception e) {
log.warn("Failed to parse SOP params: {}", schedule.getSopParams());
Map<String, Object> parsedParams = JsonUtil.fromJson(
schedule.getSopParams(), new TypeReference<>() {});
if (parsedParams == null) {
throw new IllegalArgumentException("SOP schedule parameters must be a valid JSON object");
}
params = parsedParams;
}
// Execute SOP
@@ -19,10 +19,13 @@
package org.apache.hertzbeat.ai.service.impl;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.config.McpContextHolder;
import org.apache.hertzbeat.ai.config.SecurityContextToolCallback;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopParameter;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
@@ -45,9 +48,11 @@ import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.ApplicationContext;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import java.io.IOException;
@@ -69,7 +74,7 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
private final GeneralConfigDao generalConfigDao;
private ModelProviderConfig modelProviderConfig;
private volatile ModelProviderConfig modelProviderConfig;
private final SkillRegistry skillRegistry;
@@ -78,7 +83,9 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
@Qualifier("hertzbeatTools")
private ToolCallbackProvider toolCallbackProvider;
private boolean isConfigured = false;
private volatile boolean configured;
private volatile boolean configurationLoaded;
@Value("classpath:/prompt/system-message.st")
private Resource systemResource;
@@ -122,11 +129,15 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
// Build system prompt with dynamic skills list and conversation ID
// The conversationId is injected into the prompt so AI can pass it to schedule tools
String systemPrompt = buildSystemPrompt(context.getConversationId());
ToolCallback[] toolCallbacks = Arrays.stream(toolCallbackProvider.getToolCallbacks())
.map(SecurityContextToolCallback::new)
.toArray(ToolCallback[]::new);
return chatClient.prompt()
.messages(messages)
.system(systemPrompt)
.tools(toolCallbackProvider)
.tools((Object[]) toolCallbacks)
.toolContext(McpContextHolder.createToolContext(context.getSubject()))
.stream()
.content()
.doOnComplete(() -> log.info("Streaming completed for conversation: {}", context.getConversationId()))
@@ -150,7 +161,8 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
.replace(CONVERSATION_ID_PLACEHOLDER, String.valueOf(conversationId));
// add extra prompt for protected model to guide it to use protected tools
if (Objects.equals(modelProviderConfig.getParticipationModel(), "PROTECTED")) {
ModelProviderConfig currentConfig = modelProviderConfig;
if (currentConfig != null && Objects.equals(currentConfig.getParticipationModel(), "PROTECTED")) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("conversationId", conversationId);
return template + SystemPromptTemplate.builder().resource(extraResourceProtected).build()
@@ -202,19 +214,32 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
@EventListener(AiProviderConfigChangeEvent.class)
public void onAiProviderConfigChange(AiProviderConfigChangeEvent event) {
GeneralConfig providerConfig = generalConfigDao.findByType("provider");
this.modelProviderConfig = JsonUtil.fromJson(providerConfig.getContent(), ModelProviderConfig.class);
refreshProviderConfiguration();
}
@Override
public boolean isConfigured() {
if (!isConfigured) {
GeneralConfig providerConfig = generalConfigDao.findByType("provider");
ModelProviderConfig modelProviderConfig = JsonUtil.fromJson(providerConfig.getContent(),
ModelProviderConfig.class);
isConfigured = modelProviderConfig != null && modelProviderConfig.getApiKey() != null;
this.modelProviderConfig = modelProviderConfig;
if (!configurationLoaded) {
synchronized (this) {
if (!configurationLoaded) {
refreshProviderConfiguration();
}
}
}
return isConfigured;
return configured;
}
/**
* Atomically refreshes the configuration snapshot after enabling, disabling, or switching providers.
*/
private synchronized void refreshProviderConfiguration() {
GeneralConfig providerConfig = generalConfigDao.findByType("provider");
ModelProviderConfig refreshedConfig = null;
if (providerConfig != null && StringUtils.hasText(providerConfig.getContent())) {
refreshedConfig = JsonUtil.fromJson(providerConfig.getContent(), ModelProviderConfig.class);
}
modelProviderConfig = refreshedConfig;
configured = refreshedConfig != null && StringUtils.hasText(refreshedConfig.getApiKey());
configurationLoaded = true;
}
}
@@ -17,10 +17,13 @@
package org.apache.hertzbeat.ai.service.impl;
import com.usthe.sureness.subject.SubjectSum;
import com.usthe.sureness.util.SurenessContextHolder;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.dao.ChatConversationDao;
import org.apache.hertzbeat.ai.dao.ChatMessageDao;
import org.apache.hertzbeat.ai.dao.SopScheduleDao;
import org.apache.hertzbeat.ai.pojo.dto.ChatRequestContext;
import org.apache.hertzbeat.ai.pojo.dto.ChatResponseChunk;
import org.apache.hertzbeat.ai.pojo.dto.SecurityData;
@@ -55,6 +58,9 @@ public class ConversationServiceImpl implements ConversationService {
@Autowired
private ChatMessageDao messageDao;
@Autowired
private SopScheduleDao sopScheduleDao;
@Autowired
private ChatClientProviderService chatClientProviderService;
@@ -72,24 +78,32 @@ public class ConversationServiceImpl implements ConversationService {
.build());
}
log.info("Starting streaming conversation: {}", conversationId);
ChatConversation conversation = conversationDao.findById(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Conversation not found: " + conversationId));
ChatConversation conversation;
if (conversationId == null) {
// The API contract makes conversationId optional, so create a conversation for the first message.
conversation = new ChatConversation();
conversation.setTitle(buildConversationTitle(message));
conversation = conversationDao.save(conversation);
} else {
conversation = conversationDao.findById(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Conversation not found: " + conversationId));
}
Long currentConversationId = conversation.getId();
log.info("Starting streaming conversation: {}", currentConversationId);
// Manually load messages for conversation history
List<ChatMessage> messages = messageDao.findByConversationIdOrderByGmtCreateAsc(conversationId);
List<ChatMessage> messages = messageDao.findByConversationIdOrderByGmtCreateAsc(currentConversationId);
conversation.setMessages(messages);
if (conversation.getTitle().startsWith("conversation")) {
// Auto-generate title from first user message
String title = message.length() > 30 ? message.substring(0, 27) + "..." : message;
conversation.setTitle(title);
conversation.setTitle(buildConversationTitle(message));
conversationDao.save(conversation);
}
// Add user message to conversation
ChatMessage chatMessage = ChatMessage.builder()
.conversationId(conversationId)
.conversationId(currentConversationId)
.content(message)
.role("user")
.build();
@@ -97,18 +111,20 @@ public class ConversationServiceImpl implements ConversationService {
ChatRequestContext context = ChatRequestContext.builder()
.message(message)
.conversationId(conversationId)
.conversationId(currentConversationId)
.conversationHistory(messages)
.build();
// Stream response from AI service
StringBuilder fullResponse = new StringBuilder();
ChatMessage finalChatMessage = chatMessage;
SubjectSum subject = SurenessContextHolder.getBindSubject();
context.setSubject(subject);
return chatClientProviderService.streamChat(context)
.map(chunk -> {
fullResponse.append(chunk);
ChatResponseChunk responseChunk = ChatResponseChunk.builder()
.conversationId(conversationId)
.conversationId(currentConversationId)
.userMessageId(finalChatMessage.getId())
.response(chunk)
.build();
@@ -120,13 +136,13 @@ public class ConversationServiceImpl implements ConversationService {
.concatWith(Flux.defer(() -> {
// Add the complete AI response to conversation
ChatMessage assistantMessage = ChatMessage.builder()
.conversationId(conversationId)
.conversationId(currentConversationId)
.content(fullResponse.toString())
.role("assistant")
.build();
assistantMessage = messageDao.save(assistantMessage);
ChatResponseChunk finalResponse = ChatResponseChunk.builder()
.conversationId(conversationId)
.conversationId(currentConversationId)
.response("")
.assistantMessageId(assistantMessage.getId())
.build();
@@ -135,12 +151,12 @@ public class ConversationServiceImpl implements ConversationService {
.event("complete")
.build());
}))
.doOnComplete(() -> log.info("Streaming completed for conversation: {}", conversationId))
.doOnError(error -> log.error("Error in streaming chat for conversation {}: {}", conversationId,
.doOnComplete(() -> log.info("Streaming completed for conversation: {}", currentConversationId))
.doOnError(error -> log.error("Error in streaming chat for conversation {}: {}", currentConversationId,
error.getMessage(), error))
.onErrorResume(error -> {
ChatResponseChunk errorResponse = ChatResponseChunk.builder()
.conversationId(conversationId)
.conversationId(currentConversationId)
.response("An error occurred: " + error.getMessage())
.userMessageId(finalChatMessage.getId())
.build();
@@ -157,6 +173,10 @@ public class ConversationServiceImpl implements ConversationService {
return conversationDao.save(conversation);
}
private String buildConversationTitle(String message) {
return message.length() > 30 ? message.substring(0, 27) + "..." : message;
}
@Override
public ChatConversation getConversation(Long conversationId) {
if (conversationId == null) {
@@ -193,6 +213,8 @@ public class ConversationServiceImpl implements ConversationService {
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteConversation(Long conversationId) {
// Delete associated schedules first to prevent tasks from writing orphaned messages.
sopScheduleDao.deleteByConversationId(conversationId);
List<ChatMessage> messages = messageDao.findByConversationIdOrderByGmtCreateAsc(conversationId);
if (!messages.isEmpty()) {
messageDao.deleteAll(messages);
@@ -148,10 +148,17 @@ public class SopScheduleServiceImpl implements SopScheduleService {
private LocalDateTime calculateNextRunTime(String cronExpression) {
try {
CronExpression cron = CronExpression.parse(cronExpression);
return cron.next(LocalDateTime.now());
} catch (Exception e) {
log.error("Failed to calculate next run time for cron: {}", cronExpression, e);
return null;
LocalDateTime nextRunTime = cron.next(LocalDateTime.now());
if (nextRunTime == null) {
// Expressions such as February 31 are syntactically valid but can never be triggered.
throw new IllegalArgumentException(
"Cron expression has no future execution time: " + cronExpression);
}
return nextRunTime;
} catch (IllegalArgumentException e) {
throw e;
} catch (RuntimeException e) {
throw new IllegalArgumentException("Failed to calculate next run time: " + cronExpression, e);
}
}
}
@@ -41,9 +41,6 @@ import reactor.core.publisher.Flux;
@Service
public class SopEngineImpl implements SopEngine {
// Thread-local context for each execution
private static final ThreadLocal<Map<String, Object>> CONTEXT_BUS = ThreadLocal.withInitial(HashMap::new);
private final List<SopExecutor> executors;
@Autowired
@@ -55,22 +52,12 @@ public class SopEngineImpl implements SopEngine {
public Flux<String> execute(SopDefinition definition, Map<String, Object> inputParams) {
return Flux.create(sink -> {
try {
OutputConfig outputConfig = resolveOutputConfig(definition);
Map<String, Object> context = prepareContext(definition, inputParams, outputConfig);
log.info("Starting execution of SOP: {}", definition.getName());
sink.next("Starting SOP: " + definition.getName() + " (v" + definition.getVersion() + ")");
// Initialize context with input parameters
Map<String, Object> context = CONTEXT_BUS.get();
context.clear();
context.putAll(inputParams);
// Add language configuration to context
OutputConfig outputConfig = definition.getOutput();
if (outputConfig != null) {
context.put("_language", outputConfig.getLanguageCode());
} else {
context.put("_language", "zh");
}
for (SopStep step : definition.getSteps()) {
sink.next("Executing step [" + step.getId() + "]: " + step.getType());
@@ -106,10 +93,9 @@ public class SopEngineImpl implements SopEngine {
sink.next("SOP " + definition.getName() + " completed successfully.");
sink.complete();
} catch (Exception e) {
log.error("Error executing SOP {}: {}", definition.getName(), e.getMessage(), e);
String sopName = definition == null ? "unknown" : definition.getName();
log.error("Error executing SOP {}: {}", sopName, e.getMessage(), e);
sink.error(e);
} finally {
CONTEXT_BUS.remove();
}
});
}
@@ -118,40 +104,20 @@ public class SopEngineImpl implements SopEngine {
public SopResult executeSync(SopDefinition definition, Map<String, Object> inputParams) {
long startTime = System.currentTimeMillis();
List<StepResult> stepResults = new ArrayList<>();
Map<String, Object> context = new HashMap<>(inputParams);
// Apply default values for parameters that are not provided
if (definition.getParameters() != null) {
for (SopParameter param : definition.getParameters()) {
if (!context.containsKey(param.getName()) && param.getDefaultValue() != null) {
context.put(param.getName(), param.getDefaultValue());
}
}
}
// Get output configuration first
OutputConfig outputConfig = definition.getOutput();
if (outputConfig == null) {
outputConfig = OutputConfig.builder()
.type("simple")
.format("text")
.language("zh")
.build();
}
// Add language configuration to context
context.put("_language", outputConfig.getLanguageCode());
SopResult.SopResultBuilder resultBuilder = SopResult.builder()
.sopName(definition.getName())
.sopVersion(definition.getVersion())
.sopName(definition == null ? null : definition.getName())
.sopVersion(definition == null ? null : definition.getVersion())
.startTime(startTime);
resultBuilder.outputType(outputConfig.getOutputType());
resultBuilder.outputFormat(outputConfig.getFormat() != null ? outputConfig.getFormat() : "text");
resultBuilder.language(outputConfig.getLanguageCode());
try {
OutputConfig outputConfig = resolveOutputConfig(definition);
Map<String, Object> context = prepareContext(definition, inputParams, outputConfig);
resultBuilder.outputType(outputConfig.getOutputType());
resultBuilder.outputFormat(outputConfig.getFormat() != null ? outputConfig.getFormat() : "text");
resultBuilder.language(outputConfig.getLanguageCode());
log.info("Starting sync execution of SOP: {}", definition.getName());
for (SopStep step : definition.getSteps()) {
@@ -213,10 +179,55 @@ public class SopEngineImpl implements SopEngine {
return resultBuilder.build();
} catch (Exception e) {
log.error("Error executing SOP {}: {}", definition.getName(), e.getMessage(), e);
String sopName = definition == null ? "unknown" : definition.getName();
log.error("Error executing SOP {}: {}", sopName, e.getMessage(), e);
return buildFailedResult(resultBuilder, stepResults, e.getMessage(), startTime);
}
}
/**
* Applies defaults and validates required parameters consistently for every execution entry point.
*/
private Map<String, Object> prepareContext(SopDefinition definition, Map<String, Object> inputParams,
OutputConfig outputConfig) {
if (definition.getSteps() == null || definition.getSteps().isEmpty()) {
throw new IllegalArgumentException("SOP must contain at least one step");
}
Map<String, Object> context = inputParams == null ? new HashMap<>() : new HashMap<>(inputParams);
if (definition.getParameters() != null) {
for (SopParameter parameter : definition.getParameters()) {
Object value = context.get(parameter.getName());
if (isMissing(value) && parameter.getDefaultValue() != null) {
context.put(parameter.getName(), parameter.getDefaultValue());
value = parameter.getDefaultValue();
}
if (parameter.isRequired() && isMissing(value)) {
throw new IllegalArgumentException("Required SOP parameter is missing: " + parameter.getName());
}
}
}
context.put("_language", outputConfig.getLanguageCode());
return context;
}
private boolean isMissing(Object value) {
return value == null || value instanceof String text && text.isBlank();
}
private OutputConfig resolveOutputConfig(SopDefinition definition) {
if (definition == null) {
throw new IllegalArgumentException("SOP definition must not be null");
}
if (definition.getOutput() != null) {
return definition.getOutput();
}
return OutputConfig.builder()
.type("simple")
.format("text")
.language("zh")
.build();
}
private SopResult buildFailedResult(SopResult.SopResultBuilder builder,
List<StepResult> stepResults, String error, long startTime) {
@@ -17,8 +17,16 @@
package org.apache.hertzbeat.ai.sop.registry;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.ai.sop.engine.SopEngine;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopParameter;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
@@ -49,33 +57,78 @@ public class SopToolCallback implements ToolCallback {
@Override
public String call(String arguments) {
// TODO: Parse arguments and execute SOP
return "SOP " + definition.getName() + " execution started.";
Map<String, Object> inputParams = parseArguments(arguments);
SopResult result = sopEngine.executeSync(definition, inputParams);
return result.toAiResponse();
}
private String buildInputSchema() {
// Build JSON Schema for SOP parameters
StringBuilder schema = new StringBuilder();
schema.append("{\"type\":\"object\",\"properties\":{");
if (definition.getParameters() != null && !definition.getParameters().isEmpty()) {
boolean first = true;
for (var param : definition.getParameters()) {
if (!first) {
schema.append(",");
Map<String, Object> schema = new LinkedHashMap<>();
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
if (definition.getParameters() != null) {
for (SopParameter parameter : definition.getParameters()) {
Map<String, Object> property = new LinkedHashMap<>();
property.put("type", mapType(parameter.getType()));
if (parameter.getDescription() != null) {
property.put("description", parameter.getDescription());
}
schema.append("\"").append(param.getName()).append("\":");
schema.append("{\"type\":\"").append(mapType(param.getType())).append("\"");
if (param.getDescription() != null) {
schema.append(",\"description\":\"").append(param.getDescription()).append("\"");
if (parameter.getDefaultValue() != null) {
property.put("default", convertDefaultValue(parameter));
}
properties.put(parameter.getName(), property);
if (parameter.isRequired()) {
required.add(parameter.getName());
}
schema.append("}");
first = false;
}
}
schema.append("}}");
return schema.toString();
schema.put("type", "object");
schema.put("properties", properties);
if (!required.isEmpty()) {
schema.put("required", required);
}
schema.put("additionalProperties", false);
return JsonUtil.toJson(schema);
}
@SuppressWarnings("unchecked")
private Map<String, Object> parseArguments(String arguments) {
if (arguments == null || arguments.isBlank()) {
return new HashMap<>();
}
Map<String, Object> inputParams = JsonUtil.fromJson(arguments, Map.class);
if (inputParams == null) {
throw new IllegalArgumentException("SOP tool arguments must be a valid JSON object");
}
return inputParams;
}
private Object convertDefaultValue(SopParameter parameter) {
String defaultValue = parameter.getDefaultValue();
try {
return switch (mapType(parameter.getType())) {
case "boolean" -> parseBooleanDefault(parameter, defaultValue);
case "integer" -> Long.valueOf(defaultValue);
case "number" -> Double.valueOf(defaultValue);
default -> defaultValue;
};
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid default value for SOP parameter: " + parameter.getName(), e);
}
}
private boolean parseBooleanDefault(SopParameter parameter, String defaultValue) {
String normalizedValue = defaultValue.trim();
if ("true".equalsIgnoreCase(normalizedValue)) {
return true;
}
if ("false".equalsIgnoreCase(normalizedValue)) {
return false;
}
throw new IllegalArgumentException(
"Invalid boolean default value for SOP parameter: " + parameter.getName());
}
private String mapType(String type) {
@@ -17,31 +17,38 @@
package org.apache.hertzbeat.ai.sop.registry;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;
/**
* Registry for all @Tool annotated methods.
* Automatically discovers and registers tool methods on first use.
* Registry for Spring AI tools.
*
* <p>Scans beans containing {@link Tool} methods on first use and delegates argument deserialization,
* proxy method resolution, and result conversion to Spring AI's {@link MethodToolCallbackProvider}.
*/
@Slf4j
@Component
public class ToolRegistry {
private final ApplicationContext applicationContext;
private final Map<String, ToolMethod> tools = new HashMap<>();
private volatile Map<String, RegisteredTool> tools = Map.of();
private volatile boolean initialized = false;
@Autowired
@@ -49,16 +56,11 @@ public class ToolRegistry {
this.applicationContext = applicationContext;
}
/**
* Initialize the registry by scanning all beans for @Tool methods.
* Uses double-checked locking for thread safety.
*/
private void ensureInitialized() {
if (!initialized) {
synchronized (this) {
if (!initialized) {
log.info("Scanning for @Tool annotated methods...");
scanAllBeans();
tools = scanAllBeans();
initialized = true;
log.info("Registered {} tool methods: {}", tools.size(), tools.keySet());
}
@@ -66,58 +68,79 @@ public class ToolRegistry {
}
}
private void scanAllBeans() {
String[] beanNames = applicationContext.getBeanDefinitionNames();
for (String beanName : beanNames) {
try {
Object bean = applicationContext.getBean(beanName);
scanBeanForTools(bean);
} catch (Exception e) {
// Skip beans that cannot be instantiated
log.trace("Skipping bean {}: {}", beanName, e.getMessage());
}
private Map<String, RegisteredTool> scanAllBeans() {
log.info("Scanning for @Tool annotated methods...");
List<Object> toolBeans = Arrays.stream(applicationContext.getBeanDefinitionNames())
.filter(this::containsToolMethod)
.map(applicationContext::getBean)
.toList();
if (toolBeans.isEmpty()) {
return Map.of();
}
ToolCallback[] callbacks = MethodToolCallbackProvider.builder().toolObjects(toolBeans.toArray())
.build().getToolCallbacks();
Map<String, RegisteredTool> discoveredTools = new LinkedHashMap<>();
for (ToolCallback callback : callbacks) {
String toolName = callback.getToolDefinition().name();
discoveredTools.put(toolName, new RegisteredTool(callback, getRequiredParameters(callback)));
}
return Collections.unmodifiableMap(discoveredTools);
}
private void scanBeanForTools(Object bean) {
Class<?> clazz = bean.getClass();
// Handle Spring proxies
if (clazz.getName().contains("$$")) {
clazz = clazz.getSuperclass();
private boolean containsToolMethod(String beanName) {
Class<?> beanType = applicationContext.getType(beanName, false);
if (beanType == null) {
return false;
}
for (Method method : clazz.getMethods()) {
Tool toolAnnotation = method.getAnnotation(Tool.class);
if (toolAnnotation != null) {
String toolName = toolAnnotation.name();
if (toolName.isEmpty()) {
toolName = method.getName();
}
ToolMethod toolMethod = new ToolMethod(bean, method, toolAnnotation);
tools.put(toolName, toolMethod);
log.debug("Registered tool: {} -> {}.{}",
toolName, clazz.getSimpleName(), method.getName());
}
return Arrays.stream(ReflectionUtils.getDeclaredMethods(ClassUtils.getUserClass(beanType)))
.anyMatch(this::isToolMethod);
}
private boolean isToolMethod(Method method) {
return AnnotationUtils.findAnnotation(method, Tool.class) != null;
}
@SuppressWarnings("unchecked")
private Set<String> getRequiredParameters(ToolCallback callback) {
Map<String, Object> schema = JsonUtil.fromJson(callback.getToolDefinition().inputSchema(), Map.class);
if (schema == null || !(schema.get("required") instanceof List<?> required)) {
return Set.of();
}
return required.stream()
.map(String::valueOf)
.collect(Collectors.toUnmodifiableSet());
}
/**
* Invoke a tool by name with the given arguments.
* Invokes a tool with the provided arguments.
*/
public String invoke(String toolName, Map<String, Object> args) {
ensureInitialized();
ToolMethod toolMethod = tools.get(toolName);
if (toolMethod == null) {
throw new IllegalArgumentException("Unknown tool: " + toolName
RegisteredTool tool = tools.get(toolName);
if (tool == null) {
throw new IllegalArgumentException("Unknown tool: " + toolName
+ ". Available tools: " + tools.keySet());
}
return toolMethod.invoke(args);
Map<String, Object> safeArgs = args == null ? Map.of() : args;
validateRequiredParameters(tool.requiredParameters(), safeArgs);
String result = tool.callback().call(JsonUtil.toJson(safeArgs));
Object convertedResult = JsonUtil.fromJson(result, Object.class);
return convertedResult instanceof String text ? text : result;
}
private void validateRequiredParameters(Set<String> requiredParameters, Map<String, Object> args) {
for (String parameter : requiredParameters) {
Object value = args.get(parameter);
if (value == null || value instanceof String text && text.isBlank()) {
throw new IllegalArgumentException("Required tool parameter is missing: " + parameter);
}
}
}
/**
* Check if a tool exists.
* Checks whether a tool exists.
*/
public boolean hasMethod(String toolName) {
ensureInitialized();
@@ -125,158 +148,13 @@ public class ToolRegistry {
}
/**
* Get all registered tool names.
* Returns an immutable snapshot of all registered tool names.
*/
public Set<String> getToolNames() {
ensureInitialized();
return tools.keySet();
return Set.copyOf(tools.keySet());
}
/**
* Get tool method info.
*/
public ToolMethod getToolMethod(String toolName) {
ensureInitialized();
return tools.get(toolName);
}
/**
* Represents a registered tool method.
*/
public static class ToolMethod {
private final Object bean;
private final Method method;
private final Tool annotation;
private final List<ParamInfo> paramInfos;
public ToolMethod(Object bean, Method method, Tool annotation) {
this.bean = bean;
this.method = method;
this.annotation = annotation;
this.paramInfos = extractParamInfos(method);
}
private List<ParamInfo> extractParamInfos(Method method) {
List<ParamInfo> infos = new ArrayList<>();
Parameter[] parameters = method.getParameters();
for (Parameter param : parameters) {
ToolParam toolParam = param.getAnnotation(ToolParam.class);
String name = (toolParam != null && !toolParam.description().isEmpty())
? param.getName() : param.getName();
boolean required = toolParam != null && toolParam.required();
infos.add(new ParamInfo(name, param.getType(), required));
}
return infos;
}
/**
* Invoke this tool method with the given arguments.
*/
public String invoke(Map<String, Object> args) {
try {
Object[] methodArgs = new Object[paramInfos.size()];
for (int i = 0; i < paramInfos.size(); i++) {
ParamInfo paramInfo = paramInfos.get(i);
Object value = args.get(paramInfo.name);
methodArgs[i] = convertValue(value, paramInfo.type);
}
Object result = method.invoke(bean, methodArgs);
return result != null ? result.toString() : "";
} catch (Exception e) {
log.error("Failed to invoke tool {}: {}", annotation.name(), e.getMessage(), e);
throw new RuntimeException("Tool invocation failed: " + annotation.name(), e);
}
}
private Object convertValue(Object value, Class<?> targetType) {
if (value == null) {
return null;
}
if (targetType.isAssignableFrom(value.getClass())) {
return value;
}
String strValue = String.valueOf(value);
if (targetType == String.class) {
return strValue;
} else if (targetType == Integer.class || targetType == int.class) {
return strValue.isEmpty() ? null : Integer.valueOf(strValue);
} else if (targetType == Long.class || targetType == long.class) {
return strValue.isEmpty() ? null : Long.valueOf(strValue);
} else if (targetType == Boolean.class || targetType == boolean.class) {
return Boolean.valueOf(strValue);
} else if (targetType == Byte.class || targetType == byte.class) {
return strValue.isEmpty() ? null : Byte.valueOf(strValue);
} else if (targetType == Double.class || targetType == double.class) {
return strValue.isEmpty() ? null : Double.valueOf(strValue);
} else if (targetType == Float.class || targetType == float.class) {
return strValue.isEmpty() ? null : Float.valueOf(strValue);
} else if (targetType == List.class) {
return parseList(strValue);
}
return value;
}
@SuppressWarnings("unchecked")
private List<Long> parseList(String value) {
if (value == null || value.isEmpty()) {
return new ArrayList<>();
}
List<Long> result = new ArrayList<>();
for (String s : value.split(",")) {
if (!s.trim().isEmpty()) {
result.add(Long.valueOf(s.trim()));
}
}
return result;
}
public String getName() {
return annotation.name();
}
public String getDescription() {
return annotation.description();
}
public List<ParamInfo> getParamInfos() {
return paramInfos;
}
}
/**
* Parameter information for a tool method.
*/
public static class ParamInfo {
private final String name;
private final Class<?> type;
private final boolean required;
public ParamInfo(String name, Class<?> type, boolean required) {
this.name = name;
this.type = type;
this.required = required;
}
public String getName() {
return name;
}
public Class<?> getType() {
return type;
}
public boolean isRequired() {
return required;
}
private record RegisteredTool(ToolCallback callback, Set<String> requiredParameters) {
}
}
@@ -30,6 +30,9 @@ import org.apache.hertzbeat.ai.tools.DatabaseTools;
import org.apache.hertzbeat.common.entity.manager.Monitor;
import org.apache.hertzbeat.common.entity.manager.Param;
import org.apache.hertzbeat.common.util.AesUtil;
import org.apache.hertzbeat.common.util.CommonUtil;
import org.apache.hertzbeat.common.util.IpDomainUtil;
import org.apache.hertzbeat.common.util.JdbcUrlSafetyUtil;
import org.apache.hertzbeat.manager.pojo.dto.MonitorDto;
import org.apache.hertzbeat.manager.service.MonitorService;
import org.springframework.ai.tool.annotation.Tool;
@@ -240,11 +243,21 @@ public class DatabaseToolsImpl implements DatabaseTools {
private String buildJdbcUrl(String platform, String host, String port, String database) {
String effectivePort = (port == null || port.isEmpty()) ? "3306" : port;
String effectiveDb = (database == null || database.isEmpty()) ? "" : database;
return "jdbc:mysql://" + host + ":" + effectivePort + "/" + effectiveDb
// host, port and database come from monitor parameters and are concatenated into the url,
// so they must not carry url syntax of their own
String effectiveDb = JdbcUrlSafetyUtil.requireSafeDatabaseName(database);
if (!IpDomainUtil.validateIpDomain(host)) {
throw new IllegalArgumentException("Invalid database host: " + host);
}
if (!CommonUtil.isNumeric(effectivePort)) {
throw new IllegalArgumentException("Invalid database port: " + effectivePort);
}
String url = "jdbc:mysql://" + host + ":" + effectivePort + "/" + effectiveDb
+ "?useUnicode=true&characterEncoding=utf-8&useSSL=false"
+ "&allowPublicKeyRetrieval=true&connectTimeout=5000";
JdbcUrlSafetyUtil.requireSafeJdbcUrl(url);
return url;
}
private String executeAndFormat(String url, String username, String password,
@@ -19,17 +19,23 @@ package org.apache.hertzbeat.ai.tools.impl;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.service.SopScheduleService;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopParameter;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
import org.apache.hertzbeat.ai.utils.SopMessageUtil;
import org.apache.hertzbeat.ai.tools.ScheduleTools;
import org.apache.hertzbeat.common.entity.ai.SopSchedule;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import tools.jackson.core.type.TypeReference;
/**
* Implementation of ScheduleTools for AI-driven schedule management.
@@ -78,18 +84,22 @@ public class ScheduleToolsImpl implements ScheduleTools {
@Tool(name = "createScheduleWithConversation",
description = "Create a scheduled task for a specific conversation. "
+ "Use the conversationId from the system context. "
+ "Pass skill parameters as a JSON object when the skill requires inputs. "
+ "The cron expression should be in 6-digit Spring format.")
public String createScheduleWithConversation(
@ToolParam(description = "Conversation ID from the system context", required = true) Long conversationId,
@ToolParam(description = "Name of the skill to schedule (e.g., 'daily_inspection')", required = true) String skillName,
@ToolParam(description = "Cron expression in Spring format (e.g., '0 0 9 * * ?')", required = true) String cronExpression,
@ToolParam(description = "Description of the schedule", required = false) String description) {
@ToolParam(description = "Description of the schedule", required = false) String description,
@ToolParam(description = "Skill parameters as a JSON object (e.g., '{\"monitorId\":123}')",
required = false) String paramsJson) {
log.info("AI creating schedule: conversationId={}, skill={}, cron={}, desc={}",
conversationId, skillName, cronExpression, description);
// Validate skill exists
if (skillRegistry.getSkill(skillName) == null) {
SopDefinition skill = skillRegistry.getSkill(skillName);
if (skill == null) {
String available = String.join(", ",
skillRegistry.getAllSkills().stream()
.map(s -> s.getName())
@@ -103,11 +113,16 @@ public class ScheduleToolsImpl implements ScheduleTools {
}
try {
// Check for duplicate schedule (same skill + cron expression)
Map<String, Object> params = parseSkillParams(paramsJson);
validateRequiredParameters(skill, params);
String serializedParams = params.isEmpty() ? null : JsonUtil.toJson(params);
// Parameters are part of a schedule's identity so the same skill and cron can target different inputs.
List<SopSchedule> existing = scheduleService.getSchedulesByConversation(conversationId);
boolean duplicate = existing.stream()
.anyMatch(s -> s.getSopName().equals(skillName)
&& s.getCronExpression().equals(cronExpression));
.anyMatch(schedule -> Objects.equals(schedule.getSopName(), skillName)
&& Objects.equals(schedule.getCronExpression(), cronExpression)
&& hasSameParams(schedule.getSopParams(), params));
if (duplicate) {
return SopMessageUtil.getMessage("schedule.create.duplicate",
new Object[]{skillName, cronExpression}, null)
@@ -118,6 +133,7 @@ public class ScheduleToolsImpl implements ScheduleTools {
schedule.setConversationId(conversationId);
schedule.setSopName(skillName);
schedule.setCronExpression(cronExpression);
schedule.setSopParams(serializedParams);
schedule.setEnabled(true);
SopSchedule created = scheduleService.createSchedule(schedule);
@@ -150,6 +166,55 @@ public class ScheduleToolsImpl implements ScheduleTools {
}
}
private Map<String, Object> parseSkillParams(String paramsJson) {
if (paramsJson == null || paramsJson.isBlank()) {
return Map.of();
}
Map<String, Object> params;
try {
params = JsonUtil.fromJson(paramsJson, new TypeReference<>() {});
} catch (RuntimeException e) {
throw new IllegalArgumentException("Skill parameters must be a valid JSON object", e);
}
if (params == null) {
throw new IllegalArgumentException("Skill parameters must be a valid JSON object");
}
return params;
}
private void validateRequiredParameters(SopDefinition skill, Map<String, Object> params) {
if (skill.getParameters() == null) {
return;
}
for (SopParameter parameter : skill.getParameters()) {
Object value = params.get(parameter.getName());
if (isMissing(value)) {
value = parameter.getDefaultValue();
}
if (parameter.isRequired() && isMissing(value)) {
throw new IllegalArgumentException(
"Required skill parameter is missing: " + parameter.getName());
}
}
}
private boolean isMissing(Object value) {
return value == null || value instanceof String text && text.isBlank();
}
private boolean hasSameParams(String existingJson, Map<String, Object> params) {
if (existingJson == null || existingJson.isBlank()) {
return params.isEmpty();
}
try {
Map<String, Object> existingParams = JsonUtil.fromJson(existingJson, new TypeReference<>() {});
return Objects.equals(existingParams, params);
} catch (RuntimeException e) {
log.warn("Failed to parse parameters of an existing schedule", e);
return false;
}
}
@Override
@Tool(name = "listSchedulesForConversation",
description = "List all scheduled tasks for a specific conversation. "
@@ -125,12 +125,16 @@ public class SkillToolsImpl implements SkillTools {
// Parse parameters
Map<String, Object> params = parseParams(paramsJson);
if (params == null) {
return "Error: Skill parameters must be a valid JSON object.";
}
// Validate required parameters
if (skill.getParameters() != null) {
for (SopParameter paramDef : skill.getParameters()) {
if (paramDef.isRequired()) {
if (!params.containsKey(paramDef.getName()) || params.get(paramDef.getName()) == null) {
Object value = params.get(paramDef.getName());
if (value == null || value instanceof String text && text.isBlank()) {
return "Error: Required parameter '" + paramDef.getName() + "' is missing. "
+ "Description: " + paramDef.getDescription();
}
@@ -143,7 +147,9 @@ public class SkillToolsImpl implements SkillTools {
SopResult result = sopEngine.executeSync(skill, params);
// Check output type
if (result.getOutputType() == OutputType.REPORT) {
if ("SUCCESS".equals(result.getStatus())
&& result.getOutputType() == OutputType.REPORT
&& result.getContent() != null) {
// Report type: return with marker for direct display to user
log.info("Skill {} returned report-type output, marking for direct display", skillName);
return SKILL_REPORT_MARKER + "\n" + result.getContent();
@@ -167,8 +173,8 @@ public class SkillToolsImpl implements SkillTools {
try {
return JsonUtil.fromJson(paramsJson, Map.class);
} catch (Exception e) {
log.warn("Failed to parse params JSON: {}, returning empty map", paramsJson);
return new HashMap<>();
log.warn("Failed to parse params JSON: {}", paramsJson);
return null;
}
}
}
@@ -27,8 +27,12 @@ import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.hertzbeat.manager.pojo.dto.Hierarchy;
import tools.jackson.databind.ObjectMapper;
@@ -42,6 +46,14 @@ import tools.jackson.databind.ObjectMapper;
@lombok.experimental.UtilityClass
public class UtilityClass {
private static final Pattern SINGLE_EQUALS_PATTERN = Pattern.compile("(?<![<>=!])=(?!=)");
private static final Pattern UPPERCASE_LOGICAL_PATTERN = Pattern.compile("\\b(AND|OR)\\b");
private static final Pattern FUNCTION_FIELD_PATTERN = Pattern.compile(
"!?\\b(?:equals|contains|matches|exists)\\s*\\(\\s*([a-zA-Z_][a-zA-Z0-9_]*)");
private static final Pattern COMPARISON_FIELD_PATTERN = Pattern.compile(
"(?:^|[\\s(])([a-zA-Z_][a-zA-Z0-9_]*)\\s*(?:>=|<=|==|!=|>|<)");
private static final Pattern QUOTED_VALUE_PATTERN = Pattern.compile("\"[^\"]*\"|'[^']*'");
/**
* Validates the syntax of field conditions expression
* @param fieldConditions Field conditions string to validate
@@ -111,31 +123,26 @@ public class UtilityClass {
* Validates that only supported operators are used
*/
public String validateOperators(String fieldConditions) {
// Define supported operators for different field types
String[] numericOperators = {">", "<", ">=", "<=", "==", "!=", "exists()", "!exists()"};
String[] stringOperators = {"equals(", "contains(", "matches(", "exists()", "!equals(", "!contains(", "!matches(", "!exists()"};
String[] logicalOperators = {" and ", " or "};
// Remove quotes and function calls temporarily for operator checking
String tempExpression = fieldConditions
.replaceAll("\"[^\"]*\"", "VALUE") // Remove quoted strings
.replaceAll("'[^']*'", "VALUE") // Remove single quoted strings
.replaceAll("\\w+\\([^)]*\\)", "FUNCTION"); // Remove function calls
// Check for invalid operators (common mistakes)
String[] invalidOperators = {"&&", "||", "AND", "OR", "=", "!="};
for (String invalidOp : invalidOperators) {
if (tempExpression.contains(invalidOp)) {
if (invalidOp.equals("&&") || invalidOp.equals("||")) {
return String.format("Error: Use 'and'/'or' instead of '%s' for logical operations", invalidOp);
}
if (invalidOp.equals("AND") || invalidOp.equals("OR")) {
return String.format("Error: Use lowercase '%s' for logical operations", invalidOp.toLowerCase());
}
if (invalidOp.equals("=")) {
return "Error: Use '==' for equality comparison, not '='";
}
}
if (tempExpression.contains("&&") || tempExpression.contains("||")) {
String invalidOp = tempExpression.contains("&&") ? "&&" : "||";
return String.format("Error: Use 'and'/'or' instead of '%s' for logical operations", invalidOp);
}
Matcher uppercaseLogicalMatcher = UPPERCASE_LOGICAL_PATTERN.matcher(tempExpression);
if (uppercaseLogicalMatcher.find()) {
return String.format("Error: Use lowercase '%s' for logical operations",
uppercaseLogicalMatcher.group(1).toLowerCase());
}
// Reject only a standalone equals sign without rejecting >=, <=, ==, or !=.
if (SINGLE_EQUALS_PATTERN.matcher(tempExpression).find()) {
return "Error: Use '==' for equality comparison, not '='";
}
// Check for unsupported special characters that might indicate syntax errors
@@ -150,11 +157,6 @@ public class UtilityClass {
* Validates logical connectors syntax
*/
public String validateLogicalConnectors(String fieldConditions) {
// Check for proper spacing around logical operators
if (fieldConditions.matches(".*(\\S(and|or)\\S).*")) {
return "Error: Logical operators 'and'/'or' must be surrounded by spaces";
}
// Check for consecutive logical operators
if (fieldConditions.matches(".*(and\\s+and|or\\s+or|and\\s+or\\s+and|or\\s+and\\s+or).*")) {
return "Error: Consecutive logical operators found. Use parentheses to group conditions properly.";
@@ -237,7 +239,7 @@ public class UtilityClass {
String[] pairs = input.split(",");
for (String pair : pairs) {
String[] keyValue = pair.split(":");
String[] keyValue = pair.split(":", 2);
if (keyValue.length == 2) {
result.put(keyValue[0].trim(), keyValue[1].trim());
}
@@ -299,34 +301,22 @@ public class UtilityClass {
* Handles simple cases like "field > 80", "equals(field, 'value')", complex expressions
*/
public List<String> extractFieldNamesFromConditions(String fieldConditions) {
List<String> fieldNames = new ArrayList<>();
Set<String> fieldNames = new LinkedHashSet<>();
// Split by logical operators (and, or) and parentheses, but preserve the field names
// This is a simple implementation - could be enhanced with a proper parser
String[] parts = fieldConditions.split("\\s+(and|or|&&|\\|\\|)\\s+|[()]+");
for (String part : parts) {
part = part.trim();
if (part.isEmpty()) {
continue;
}
// Handle equals() function: equals(fieldName, "value")
if (part.contains("equals(")) {
String fieldName = extractFieldFromEquals(part);
if (fieldName != null && !fieldNames.contains(fieldName)) {
fieldNames.add(fieldName);
}
} else {
// Handle simple comparisons: fieldName > value, fieldName <= value
String fieldName = extractFieldFromComparison(part);
if (fieldName != null && !fieldNames.contains(fieldName)) {
fieldNames.add(fieldName);
}
}
// Extract the first function argument without splitting parentheses and bypassing field validation.
Matcher functionMatcher = FUNCTION_FIELD_PATTERN.matcher(fieldConditions);
while (functionMatcher.find()) {
fieldNames.add(functionMatcher.group(1));
}
return fieldNames;
// Remove quoted values before scanning comparisons to avoid treating text such as "value > 1" as a field.
Matcher comparisonMatcher = COMPARISON_FIELD_PATTERN.matcher(
QUOTED_VALUE_PATTERN.matcher(fieldConditions).replaceAll(""));
while (comparisonMatcher.find()) {
fieldNames.add(comparisonMatcher.group(1));
}
return new ArrayList<>(fieldNames);
}
/**
@@ -391,7 +381,8 @@ public class UtilityClass {
} else {
// Category, app, or metric node
// Determine node type based on children
boolean hasLeafChildren = hierarchy.getChildren().stream()
List<Hierarchy> children = hierarchy.getChildren();
boolean hasLeafChildren = children != null && children.stream()
.anyMatch(child -> child.getIsLeaf() != null && child.getIsLeaf());
if (hasLeafChildren) {
@@ -402,9 +393,9 @@ public class UtilityClass {
node.put("description", "Application with available metrics");
}
if (hierarchy.getChildren() != null && !hierarchy.getChildren().isEmpty()) {
if (children != null && !children.isEmpty()) {
ArrayNode childrenArray = mapper.createArrayNode();
for (Hierarchy child : hierarchy.getChildren()) {
for (Hierarchy child : children) {
childrenArray.add(formatHierarchyAsJson(mapper, child));
}
node.set("children", childrenArray);
@@ -13,23 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
# SOP Result Messages - Default (Chinese)
sop.result.title=SOP执行结果
sop.result.name=SOP名称
@@ -13,23 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
# SOP Result Messages - English
sop.result.title=SOP Execution Result
sop.result.name=SOP Name
@@ -13,23 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
# SOP Result Messages - Chinese
sop.result.title=SOP执行结果
sop.result.name=SOP名称
@@ -65,12 +65,16 @@ POST /api/ai/sop/execute/{skillName}/ai
```
YAML 定义 → SkillRegistry → SopEngine → Executors → SopResult
ToolExecutor / LlmExecutor
SopToolCallback ToolExecutor / LlmExecutor
ToolRegistry(自动发现 @Tool 方法)
```
所有执行入口都由 `SopEngine` 统一补齐默认参数并校验必填参数。技能注册为 Spring AI
工具时,`SopToolCallback` 会根据参数定义生成 JSON Schema、解析模型传入的 JSON 对象,
同步执行 SOP 并返回 `SopResult`;调用方不应在控制器或工具外层重复实现参数校验。
## 添加新工具
只需添加 `@Tool` 注解,无需修改其他代码:
@@ -13,23 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
name: daily_inspection
description: "Execute daily health inspection on all monitors and generate a comprehensive report"
version: "1.0"
@@ -13,23 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
name: mysql_slow_query_diagnosis
description: "Diagnose MySQL slow queries and provide optimization recommendations"
version: "1.0"
@@ -0,0 +1,146 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.ai.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import com.usthe.sureness.subject.SubjectSum;
import com.usthe.sureness.util.SurenessContextHolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
/**
* Verifies the security context scope used when model tools execute across threads.
*/
class McpContextHolderTest {
@AfterEach
void clearContext() {
McpContextHolder.clear();
SurenessContextHolder.clear();
}
@Test
void callWithSubjectShouldBindAndClearBothContexts() {
SubjectSum subject = mock(SubjectSum.class);
String result = McpContextHolder.callWithSubject(subject, () -> {
assertSame(subject, McpContextHolder.getSubject());
assertSame(subject, SurenessContextHolder.getBindSubject());
return "result";
});
assertEquals("result", result);
assertNull(McpContextHolder.getSubject());
assertNull(SurenessContextHolder.getBindSubject());
}
@Test
void callWithSubjectShouldRestoreIndependentPreviousContexts() {
SubjectSum previousMcpSubject = mock(SubjectSum.class);
SubjectSum previousSurenessSubject = mock(SubjectSum.class);
SubjectSum currentSubject = mock(SubjectSum.class);
McpContextHolder.setSubject(previousMcpSubject);
SurenessContextHolder.bindSubject(previousSurenessSubject);
McpContextHolder.callWithSubject(currentSubject, () -> {
assertSame(currentSubject, McpContextHolder.getSubject());
assertSame(currentSubject, SurenessContextHolder.getBindSubject());
return null;
});
assertSame(previousMcpSubject, McpContextHolder.getSubject());
assertSame(previousSurenessSubject, SurenessContextHolder.getBindSubject());
}
@Test
void callWithSubjectShouldRestoreContextsAfterFailure() {
SubjectSum previousSubject = mock(SubjectSum.class);
SubjectSum currentSubject = mock(SubjectSum.class);
McpContextHolder.setSubject(previousSubject);
SurenessContextHolder.bindSubject(previousSubject);
assertThrows(IllegalStateException.class, () ->
McpContextHolder.callWithSubject(currentSubject, () -> {
throw new IllegalStateException("tool failed");
}));
assertSame(previousSubject, McpContextHolder.getSubject());
assertSame(previousSubject, SurenessContextHolder.getBindSubject());
}
@Test
void toolContextShouldCarryOnlyValidSubject() {
SubjectSum subject = mock(SubjectSum.class);
ToolContext toolContext = new ToolContext(McpContextHolder.createToolContext(subject));
assertSame(subject, McpContextHolder.getSubject(toolContext));
assertNull(McpContextHolder.getSubject(null));
assertNull(McpContextHolder.getSubject(new ToolContext(
java.util.Map.of(McpContextHolder.SUBJECT_CONTEXT_KEY, "invalid"))));
assertEquals(java.util.Map.of(), McpContextHolder.createToolContext(null));
}
@Test
void callbackShouldExposeSubjectOnlyDuringDelegateCall() {
SubjectSum subject = mock(SubjectSum.class);
ToolContext toolContext = new ToolContext(McpContextHolder.createToolContext(subject));
ToolCallback delegate = new ToolCallback() {
@Override
public ToolDefinition getToolDefinition() {
return ToolDefinition.builder()
.name("test")
.description("test")
.inputSchema("{}")
.build();
}
@Override
public String call(String input) {
return input;
}
@Override
public String call(String input, ToolContext context) {
if (context == null) {
return input;
}
assertSame(subject, McpContextHolder.getSubject());
assertSame(subject, SurenessContextHolder.getBindSubject());
return "ok";
}
};
SecurityContextToolCallback callback = new SecurityContextToolCallback(delegate);
assertEquals("test", callback.getToolDefinition().name());
assertEquals(delegate.getToolMetadata().returnDirect(), callback.getToolMetadata().returnDirect());
assertEquals("plain", callback.call("plain"));
String result = callback.call("{}", toolContext);
assertEquals("ok", result);
assertNull(McpContextHolder.getSubject());
assertNull(SurenessContextHolder.getBindSubject());
}
}
@@ -0,0 +1,61 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.ai.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.when;
import org.apache.hertzbeat.ai.sop.engine.SopEngine;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
/**
* Verifies that synchronous SOP failures include a diagnostic response body.
*/
@ExtendWith(MockitoExtension.class)
class SopControllerTest {
@Mock
private SkillRegistry skillRegistry;
@Mock
private SopEngine sopEngine;
@InjectMocks
private SopController controller;
@Test
void executeSopSyncShouldReturnFailureBodyWhenSkillDoesNotExist() {
when(skillRegistry.getSkill("missing")).thenReturn(null);
ResponseEntity<SopResult> response = controller.executeSopSync("missing", null);
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals("FAILED", response.getBody().getStatus());
assertEquals("SOP skill not found: missing", response.getBody().getError());
}
}
@@ -0,0 +1,128 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.ai.schedule;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.List;
import org.apache.hertzbeat.ai.dao.ChatMessageDao;
import org.apache.hertzbeat.ai.service.SopScheduleService;
import org.apache.hertzbeat.ai.sop.engine.SopEngine;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
import org.apache.hertzbeat.common.entity.ai.ChatMessage;
import org.apache.hertzbeat.common.entity.ai.SopSchedule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Verifies that due SOP schedules are isolated from each other and reject invalid parameters.
*/
@ExtendWith(MockitoExtension.class)
class SopScheduleExecutorTest {
@Mock
private SopScheduleService scheduleService;
@Mock
private SopEngine sopEngine;
@Mock
private SkillRegistry skillRegistry;
@Mock
private ChatMessageDao chatMessageDao;
private SopScheduleExecutor executor;
@BeforeEach
void setUp() {
executor = new SopScheduleExecutor(scheduleService, sopEngine, skillRegistry, chatMessageDao);
}
@Test
void checkShouldContinueAfterOneScheduleFailsToUpdate() {
SopSchedule first = schedule(1L, null);
SopSchedule second = schedule(2L, null);
SopDefinition definition = SopDefinition.builder().name("daily_inspection").build();
SopResult result = SopResult.builder()
.status("SUCCESS")
.content("ok")
.build();
when(scheduleService.getDueSchedules()).thenReturn(List.of(first, second));
when(skillRegistry.getSkill("daily_inspection")).thenReturn(definition);
when(sopEngine.executeSync(any(SopDefinition.class), anyMap())).thenReturn(result);
doThrow(new IllegalStateException("database unavailable"))
.when(scheduleService).updateAfterExecution(1L);
executor.checkAndExecuteDueSchedules();
verify(sopEngine, times(2)).executeSync(any(SopDefinition.class), anyMap());
verify(scheduleService).updateAfterExecution(2L);
}
@Test
void checkShouldRejectInvalidScheduleParameters() {
SopSchedule schedule = schedule(1L, "not-json");
when(scheduleService.getDueSchedules()).thenReturn(List.of(schedule));
when(skillRegistry.getSkill("daily_inspection"))
.thenReturn(SopDefinition.builder().name("daily_inspection").build());
executor.checkAndExecuteDueSchedules();
verifyNoInteractions(sopEngine);
verify(chatMessageDao).save(any(ChatMessage.class));
verify(scheduleService).updateAfterExecution(1L);
}
@Test
void checkShouldPushErrorWhenScheduledSkillNoLongerExists() {
SopSchedule schedule = schedule(1L, null);
when(scheduleService.getDueSchedules()).thenReturn(List.of(schedule));
when(skillRegistry.getSkill("daily_inspection")).thenReturn(null);
executor.checkAndExecuteDueSchedules();
ArgumentCaptor<ChatMessage> messageCaptor = ArgumentCaptor.forClass(ChatMessage.class);
verify(chatMessageDao).save(messageCaptor.capture());
assertTrue(messageCaptor.getValue().getContent().contains("SOP skill not found: daily_inspection"));
verifyNoInteractions(sopEngine);
verify(scheduleService).updateAfterExecution(1L);
}
private SopSchedule schedule(Long id, String params) {
return SopSchedule.builder()
.id(id)
.conversationId(10L)
.sopName("daily_inspection")
.sopParams(params)
.build();
}
}
@@ -0,0 +1,141 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.ai.service.impl;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.usthe.sureness.subject.SubjectSum;
import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hertzbeat.ai.config.McpContextHolder;
import org.apache.hertzbeat.ai.config.SecurityContextToolCallback;
import org.apache.hertzbeat.ai.pojo.dto.ChatRequestContext;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
import org.apache.hertzbeat.base.dao.GeneralConfigDao;
import org.apache.hertzbeat.common.entity.dto.ModelProviderConfig;
import org.apache.hertzbeat.common.entity.manager.GeneralConfig;
import org.apache.hertzbeat.common.support.event.AiProviderConfigChangeEvent;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.StaticApplicationContext;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.test.util.ReflectionTestUtils;
import reactor.core.publisher.Flux;
/**
* Verifies that the provider configuration cache reacts to enable and disable events.
*/
class ChatClientProviderServiceImplTest {
@Test
void configurationChangeShouldRefreshConfiguredState() {
AtomicReference<GeneralConfig> currentConfig = new AtomicReference<>();
GeneralConfigDao configDao = configDao(currentConfig);
ChatClientProviderServiceImpl service = new ChatClientProviderServiceImpl(null, configDao, null);
assertFalse(service.isConfigured());
currentConfig.set(providerConfig("sk-test"));
service.onAiProviderConfigChange(changeEvent());
assertTrue(service.isConfigured());
currentConfig.set(providerConfig(" "));
service.onAiProviderConfigChange(changeEvent());
assertFalse(service.isConfigured());
}
@Test
@SuppressWarnings("unchecked")
void streamChatShouldAttachSubjectAndWrapEveryToolCallback() {
ApplicationContext applicationContext = mock(ApplicationContext.class);
SkillRegistry skillRegistry = mock(SkillRegistry.class);
ChatClient chatClient = mock(ChatClient.class);
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
ChatClient.StreamResponseSpec streamSpec = mock(ChatClient.StreamResponseSpec.class);
ToolCallback delegate = mock(ToolCallback.class);
SubjectSum subject = mock(SubjectSum.class);
ChatClientProviderServiceImpl service = new ChatClientProviderServiceImpl(
applicationContext, configDao(new AtomicReference<>()), skillRegistry);
when(applicationContext.getBean("openAiChatClient", ChatClient.class)).thenReturn(chatClient);
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.messages(anyList())).thenReturn(requestSpec);
when(requestSpec.system(anyString())).thenReturn(requestSpec);
when(requestSpec.tools(any(Object[].class))).thenReturn(requestSpec);
when(requestSpec.toolContext(anyMap())).thenReturn(requestSpec);
when(requestSpec.stream()).thenReturn(streamSpec);
when(streamSpec.content()).thenReturn(Flux.just("answer"));
when(skillRegistry.getAllSkills()).thenReturn(java.util.List.of());
ReflectionTestUtils.setField(service, "systemResource", new ByteArrayResource(
"skills={dynamically_injected_skills_list}; conversation={current_conversation_id}"
.getBytes(StandardCharsets.UTF_8)));
ReflectionTestUtils.setField(service, "toolCallbackProvider", ToolCallbackProvider.from(delegate));
ChatRequestContext context = ChatRequestContext.builder()
.message("question")
.conversationId(42L)
.subject(subject)
.build();
service.streamChat(context).collectList().block();
ArgumentCaptor<Object[]> callbacksCaptor = ArgumentCaptor.forClass(Object[].class);
ArgumentCaptor<Map<String, Object>> contextCaptor = ArgumentCaptor.forClass(Map.class);
verify(requestSpec).tools(callbacksCaptor.capture());
verify(requestSpec).toolContext(contextCaptor.capture());
assertInstanceOf(SecurityContextToolCallback.class, callbacksCaptor.getValue()[0]);
assertSame(subject, McpContextHolder.getSubject(new org.springframework.ai.chat.model.ToolContext(
contextCaptor.getValue())));
}
private GeneralConfigDao configDao(AtomicReference<GeneralConfig> currentConfig) {
return (GeneralConfigDao) Proxy.newProxyInstance(
GeneralConfigDao.class.getClassLoader(),
new Class<?>[]{GeneralConfigDao.class},
(proxy, method, args) -> "findByType".equals(method.getName()) ? currentConfig.get() : null);
}
private GeneralConfig providerConfig(String apiKey) {
ModelProviderConfig modelConfig = new ModelProviderConfig();
modelConfig.setApiKey(apiKey);
return GeneralConfig.builder()
.type("provider")
.content(JsonUtil.toJson(modelConfig))
.build();
}
private AiProviderConfigChangeEvent changeEvent() {
return new AiProviderConfigChangeEvent(new StaticApplicationContext());
}
}
@@ -20,23 +20,30 @@ package org.apache.hertzbeat.ai.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import com.usthe.sureness.subject.SubjectSum;
import com.usthe.sureness.util.SurenessContextHolder;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.hertzbeat.ai.dao.ChatConversationDao;
import org.apache.hertzbeat.ai.dao.ChatMessageDao;
import org.apache.hertzbeat.ai.dao.SopScheduleDao;
import org.apache.hertzbeat.ai.pojo.dto.ChatRequestContext;
import org.apache.hertzbeat.ai.pojo.dto.ChatResponseChunk;
import org.apache.hertzbeat.ai.service.ChatClientProviderService;
import org.apache.hertzbeat.common.entity.ai.ChatConversation;
import org.apache.hertzbeat.common.entity.ai.ChatMessage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.codec.ServerSentEvent;
@@ -56,14 +63,24 @@ class ConversationServiceImplTest {
@Mock
private ChatMessageDao messageDao;
@Mock
private SopScheduleDao sopScheduleDao;
@Mock
private ChatClientProviderService chatClientProviderService;
@InjectMocks
private ConversationServiceImpl conversationService;
@AfterEach
void clearSecurityContext() {
SurenessContextHolder.clear();
}
@Test
void streamChatShouldKeepCompleteConversationHistory() {
SubjectSum subject = org.mockito.Mockito.mock(SubjectSum.class);
SurenessContextHolder.bindSubject(subject);
ChatConversation conversation = ChatConversation.builder()
.id(CONVERSATION_ID)
.title("已命名会话")
@@ -104,5 +121,69 @@ class ConversationServiceImplTest {
ArgumentCaptor<ChatRequestContext> contextCaptor = ArgumentCaptor.forClass(ChatRequestContext.class);
verify(chatClientProviderService).streamChat(contextCaptor.capture());
assertEquals(history, contextCaptor.getValue().getConversationHistory());
assertEquals(subject, contextCaptor.getValue().getSubject());
}
/**
* The service should create a conversation and return its ID when the client omits the optional conversation ID.
*/
@Test
void streamChatShouldCreateConversationWhenConversationIdIsMissing() {
AtomicLong messageId = new AtomicLong(20L);
when(chatClientProviderService.isConfigured()).thenReturn(true);
when(conversationDao.save(any(ChatConversation.class))).thenAnswer(invocation -> {
ChatConversation savedConversation = invocation.getArgument(0);
savedConversation.setId(CONVERSATION_ID);
return savedConversation;
});
when(messageDao.findByConversationIdOrderByGmtCreateAsc(CONVERSATION_ID)).thenReturn(List.of());
when(messageDao.save(any(ChatMessage.class))).thenAnswer(invocation -> {
ChatMessage savedMessage = invocation.getArgument(0);
savedMessage.setId(messageId.getAndIncrement());
return savedMessage;
});
when(chatClientProviderService.streamChat(any(ChatRequestContext.class)))
.thenReturn(Flux.just("本轮回答"));
List<ServerSentEvent<ChatResponseChunk>> events = conversationService
.streamChat("本轮问题", null)
.collectList()
.block();
assertNotNull(events);
assertEquals(2, events.size());
assertEquals(CONVERSATION_ID, events.get(0).data().getConversationId());
assertEquals(CONVERSATION_ID, events.get(1).data().getConversationId());
ArgumentCaptor<ChatRequestContext> contextCaptor = ArgumentCaptor.forClass(ChatRequestContext.class);
verify(chatClientProviderService).streamChat(contextCaptor.capture());
assertEquals(CONVERSATION_ID, contextCaptor.getValue().getConversationId());
assertEquals(List.of(), contextCaptor.getValue().getConversationHistory());
ArgumentCaptor<ChatConversation> conversationCaptor = ArgumentCaptor.forClass(ChatConversation.class);
verify(conversationDao).save(conversationCaptor.capture());
assertEquals("本轮问题", conversationCaptor.getValue().getTitle());
verifyNoMoreInteractions(conversationDao);
}
/**
* Deleting a conversation must remove its schedules before they can push more messages.
*/
@Test
void deleteConversationShouldRemoveSchedulesMessagesAndConversationInOrder() {
ChatMessage message = ChatMessage.builder()
.id(11L)
.conversationId(CONVERSATION_ID)
.role("user")
.content("message to delete")
.build();
when(messageDao.findByConversationIdOrderByGmtCreateAsc(CONVERSATION_ID))
.thenReturn(List.of(message));
conversationService.deleteConversation(CONVERSATION_ID);
InOrder deletionOrder = inOrder(sopScheduleDao, messageDao, conversationDao);
deletionOrder.verify(sopScheduleDao).deleteByConversationId(CONVERSATION_ID);
deletionOrder.verify(messageDao).deleteAll(List.of(message));
deletionOrder.verify(conversationDao).deleteById(CONVERSATION_ID);
}
}
@@ -0,0 +1,58 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.ai.service.impl;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verifyNoInteractions;
import org.apache.hertzbeat.ai.dao.SopScheduleDao;
import org.apache.hertzbeat.common.entity.ai.SopSchedule;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Verifies that SOP schedules with no future execution time are not persisted.
*/
@ExtendWith(MockitoExtension.class)
class SopScheduleServiceImplTest {
@Mock
private SopScheduleDao sopScheduleDao;
@InjectMocks
private SopScheduleServiceImpl scheduleService;
@Test
void createScheduleShouldRejectCronWithoutFutureExecutionTime() {
SopSchedule schedule = SopSchedule.builder()
.conversationId(1L)
.sopName("daily_inspection")
.cronExpression("0 0 0 31 2 *")
.build();
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class, () -> scheduleService.createSchedule(schedule));
assertTrue(exception.getMessage().contains("no future execution time"));
verifyNoInteractions(sopScheduleDao);
}
}
@@ -0,0 +1,125 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.ai.sop.engine;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hertzbeat.ai.sop.executor.SopExecutor;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopParameter;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.ai.sop.model.SopStep;
import org.junit.jupiter.api.Test;
/**
* Verifies that all SOP execution entry points follow the same parameter contract.
*/
class SopEngineImplTest {
@Test
void executeSyncShouldReturnFailureForNullDefinition() {
SopEngineImpl engine = new SopEngineImpl(List.of());
SopResult result = engine.executeSync(null, Map.of());
assertEquals("FAILED", result.getStatus());
assertTrue(result.getError().contains("must not be null"));
}
@Test
void executeSyncShouldRejectMissingRequiredParameter() {
SopEngineImpl engine = new SopEngineImpl(List.of(new RecordingExecutor()));
SopResult result = engine.executeSync(definition(requiredParameter()), Map.of());
assertEquals("FAILED", result.getStatus());
assertTrue(result.getError().contains("monitorId"));
assertTrue(result.getSteps().isEmpty());
}
@Test
void executeSyncShouldApplyDefaultParameter() {
AtomicReference<Map<String, Object>> capturedContext = new AtomicReference<>();
SopEngineImpl engine = new SopEngineImpl(List.of(new RecordingExecutor(capturedContext)));
SopResult result = engine.executeSync(definition(defaultParameter()), Map.of());
assertEquals("SUCCESS", result.getStatus());
assertEquals("10", capturedContext.get().get("limit"));
assertEquals("zh", capturedContext.get().get("_language"));
assertEquals("10", result.getData().get("limit"));
}
@Test
void executeStreamShouldApplyTheSameParameterRules() {
AtomicReference<Map<String, Object>> capturedContext = new AtomicReference<>();
SopEngineImpl engine = new SopEngineImpl(List.of(new RecordingExecutor(capturedContext)));
List<String> events = engine.execute(definition(defaultParameter()), null).collectList().block();
assertNotNull(events);
assertTrue(events.getLast().contains("completed successfully"));
assertEquals("10", capturedContext.get().get("limit"));
}
private SopDefinition definition(SopParameter parameter) {
return SopDefinition.builder()
.name("test-sop")
.version("1.0")
.parameters(List.of(parameter))
.steps(List.of(SopStep.builder().id("query").type("tool").build()))
.build();
}
private SopParameter requiredParameter() {
return SopParameter.builder().name("monitorId").type("long").required(true).build();
}
private SopParameter defaultParameter() {
return SopParameter.builder().name("limit").type("integer").defaultValue("10").build();
}
private static final class RecordingExecutor implements SopExecutor {
private final AtomicReference<Map<String, Object>> capturedContext;
private RecordingExecutor() {
this(new AtomicReference<>());
}
private RecordingExecutor(AtomicReference<Map<String, Object>> capturedContext) {
this.capturedContext = capturedContext;
}
@Override
public boolean support(String type) {
return "tool".equals(type);
}
@Override
public Object execute(SopStep step, Map<String, Object> context) {
capturedContext.set(Map.copyOf(context));
return "ok";
}
}
}
@@ -0,0 +1,136 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.ai.sop.registry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hertzbeat.ai.sop.engine.SopEngine;
import org.apache.hertzbeat.ai.sop.model.OutputType;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopParameter;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
/**
* Verifies the input contract and execution behavior of a SOP exposed as a Spring AI tool.
*/
class SopToolCallbackTest {
@Test
@SuppressWarnings("unchecked")
void schemaShouldDeclareRequiredParametersAndEscapeDescriptions() {
SopDefinition definition = definition();
SopToolCallback callback = new SopToolCallback(definition, new RecordingEngine());
Map<String, Object> schema = JsonUtil.fromJson(callback.getToolDefinition().inputSchema(), Map.class);
Map<String, Object> properties = (Map<String, Object>) schema.get("properties");
Map<String, Object> monitorId = (Map<String, Object>) properties.get("monitorId");
Map<String, Object> limit = (Map<String, Object>) properties.get("limit");
assertEquals(List.of("monitorId"), schema.get("required"));
assertEquals(false, schema.get("additionalProperties"));
assertEquals("integer", monitorId.get("type"));
assertEquals("包含 \"引号\" 的说明", monitorId.get("description"));
assertEquals(10, limit.get("default"));
}
@Test
void callShouldParseArgumentsAndExecuteSop() {
RecordingEngine engine = new RecordingEngine();
SopToolCallback callback = new SopToolCallback(definition(), engine);
String response = callback.call("{\"monitorId\":42}");
assertEquals(42, engine.inputParams.get().get("monitorId"));
assertTrue(response.contains("SUCCESS"));
assertFalse(response.contains("execution started"));
}
@Test
void callShouldRejectInvalidJson() {
SopToolCallback callback = new SopToolCallback(definition(), new RecordingEngine());
assertThrows(IllegalArgumentException.class, () -> callback.call("not-json"));
}
@Test
void schemaShouldRejectInvalidBooleanDefault() {
SopParameter enabled = SopParameter.builder()
.name("enabled")
.type("boolean")
.defaultValue("yes")
.build();
SopDefinition definition = SopDefinition.builder()
.name("invalid-default")
.description("包含非法布尔默认值")
.parameters(List.of(enabled))
.build();
assertThrows(IllegalArgumentException.class,
() -> new SopToolCallback(definition, new RecordingEngine()));
}
private SopDefinition definition() {
SopParameter monitorId = SopParameter.builder()
.name("monitorId")
.type("long")
.description("包含 \"引号\" 的说明")
.required(true)
.build();
SopParameter limit = SopParameter.builder()
.name("limit")
.type("integer")
.defaultValue("10")
.build();
return SopDefinition.builder()
.name("diagnose")
.description("诊断工具")
.version("1.0")
.parameters(List.of(monitorId, limit))
.build();
}
private static final class RecordingEngine implements SopEngine {
private final AtomicReference<Map<String, Object>> inputParams = new AtomicReference<>();
@Override
public Flux<String> execute(SopDefinition definition, Map<String, Object> inputParams) {
return Flux.empty();
}
@Override
public SopResult executeSync(SopDefinition definition, Map<String, Object> inputParams) {
this.inputParams.set(inputParams);
return SopResult.builder()
.sopName(definition.getName())
.sopVersion(definition.getVersion())
.status("SUCCESS")
.outputType(OutputType.SIMPLE)
.build();
}
}
}
@@ -0,0 +1,136 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.ai.sop.registry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.context.support.GenericApplicationContext;
/**
* Verifies tool discovery, argument validation, and type conversion by Spring AI callbacks.
*/
class ToolRegistryTest {
@Test
void invokeShouldConvertStructuredArguments() {
try (GenericApplicationContext context = contextWith(ToolFixture.class)) {
ToolRegistry registry = new ToolRegistry(context);
String result = registry.invoke("inspect", Map.of(
"monitorId", 42,
"ids", List.of(1, 2),
"enabled", true));
assertEquals("Long|Long|Boolean", result);
assertTrue(registry.hasMethod("inspect"));
assertEquals(Set.of("inspect", "optional"), registry.getToolNames());
}
}
@Test
void invokeShouldRejectMissingRequiredParameter() {
try (GenericApplicationContext context = contextWith(ToolFixture.class)) {
ToolRegistry registry = new ToolRegistry(context);
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> registry.invoke("inspect", Map.of("ids", List.of(1), "enabled", true)));
assertEquals("Required tool parameter is missing: monitorId", error.getMessage());
}
}
@Test
void invokeShouldRejectInvalidBooleanInsteadOfSilentlyUsingFalse() {
try (GenericApplicationContext context = contextWith(ToolFixture.class)) {
ToolRegistry registry = new ToolRegistry(context);
assertThrows(ToolExecutionException.class, () -> registry.invoke("inspect", Map.of(
"monitorId", 42,
"ids", List.of(1),
"enabled", "not-a-boolean")));
}
}
@Test
void initializationShouldRejectDuplicateToolNames() {
try (GenericApplicationContext context = contextWith(ToolFixture.class, DuplicateToolFixture.class)) {
ToolRegistry registry = new ToolRegistry(context);
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class, registry::getToolNames);
assertTrue(error.getMessage().contains("inspect"));
}
}
@Test
void emptyRegistryShouldReportUnknownTool() {
try (GenericApplicationContext context = contextWith()) {
ToolRegistry registry = new ToolRegistry(context);
assertTrue(registry.getToolNames().isEmpty());
assertThrows(IllegalArgumentException.class, () -> registry.invoke("missing", null));
}
}
private GenericApplicationContext contextWith(Class<?>... beanTypes) {
GenericApplicationContext context = new GenericApplicationContext();
for (Class<?> beanType : beanTypes) {
context.registerBean(beanType);
}
context.refresh();
return context;
}
public static final class ToolFixture {
@Tool(name = "inspect", description = "Inspect argument types")
public String inspect(
@ToolParam(description = "Monitor ID", required = true) Long monitorId,
@ToolParam(description = "Monitor ID list", required = false) List<Long> ids,
@ToolParam(description = "Whether enabled", required = true) Boolean enabled) {
return "%s|%s|%s".formatted(
monitorId.getClass().getSimpleName(),
ids.getFirst().getClass().getSimpleName(),
enabled.getClass().getSimpleName());
}
@Tool(name = "optional", description = "Tool without arguments")
public String optional() {
return "optional";
}
}
public static final class DuplicateToolFixture {
@Tool(name = "inspect", description = "Duplicate-name test tool")
public String inspect() {
return "duplicate";
}
}
}
@@ -0,0 +1,129 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.ai.tools.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.apache.hertzbeat.ai.service.SopScheduleService;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopParameter;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
import org.apache.hertzbeat.common.entity.ai.SopSchedule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Verifies that AI-created SOP schedules validate and persist skill parameters.
*/
@ExtendWith(MockitoExtension.class)
class ScheduleToolsImplTest {
private static final String CRON = "0 0 9 * * ?";
@Mock
private SopScheduleService scheduleService;
@Mock
private SkillRegistry skillRegistry;
private ScheduleToolsImpl scheduleTools;
@BeforeEach
void setUp() {
scheduleTools = new ScheduleToolsImpl(scheduleService, skillRegistry);
}
@Test
void createScheduleShouldPersistSkillParameters() {
when(skillRegistry.getSkill("diagnosis")).thenReturn(parameterizedSkill());
when(scheduleService.getSchedulesByConversation(7L)).thenReturn(List.of());
when(scheduleService.createSchedule(any())).thenAnswer(invocation -> {
SopSchedule schedule = invocation.getArgument(0);
schedule.setId(9L);
return schedule;
});
String result = scheduleTools.createScheduleWithConversation(
7L, "diagnosis", CRON, "daily diagnosis", "{\"monitorId\":42}");
ArgumentCaptor<SopSchedule> captor = ArgumentCaptor.forClass(SopSchedule.class);
verify(scheduleService).createSchedule(captor.capture());
assertEquals("{\"monitorId\":42}", captor.getValue().getSopParams());
assertTrue(result.contains("9"));
}
@Test
void createScheduleShouldAllowDifferentParametersAtTheSameTime() {
SopSchedule existing = SopSchedule.builder()
.sopName("diagnosis")
.cronExpression(CRON)
.sopParams("{\"monitorId\":41}")
.build();
when(skillRegistry.getSkill("diagnosis")).thenReturn(parameterizedSkill());
when(scheduleService.getSchedulesByConversation(7L)).thenReturn(List.of(existing));
when(scheduleService.createSchedule(any())).thenAnswer(invocation -> invocation.getArgument(0));
scheduleTools.createScheduleWithConversation(
7L, "diagnosis", CRON, null, "{\"monitorId\":42}");
verify(scheduleService).createSchedule(any());
}
@Test
void createScheduleShouldRejectMissingRequiredParameter() {
when(skillRegistry.getSkill("diagnosis")).thenReturn(parameterizedSkill());
String result = scheduleTools.createScheduleWithConversation(
7L, "diagnosis", CRON, null, "{}");
assertTrue(result.contains("monitorId"));
verify(scheduleService, never()).createSchedule(any());
}
@Test
void createScheduleShouldRejectInvalidParameterJson() {
when(skillRegistry.getSkill("diagnosis")).thenReturn(parameterizedSkill());
String result = scheduleTools.createScheduleWithConversation(
7L, "diagnosis", CRON, null, "not-json");
assertTrue(result.contains("valid JSON object"));
verify(scheduleService, never()).createSchedule(any());
}
private SopDefinition parameterizedSkill() {
SopParameter monitorId = SopParameter.builder()
.name("monitorId")
.required(true)
.build();
return SopDefinition.builder()
.name("diagnosis")
.parameters(List.of(monitorId))
.build();
}
}
@@ -0,0 +1,124 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.ai.tools.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.List;
import org.apache.hertzbeat.ai.sop.engine.SopEngine;
import org.apache.hertzbeat.ai.sop.model.OutputType;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopParameter;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Verifies that skill parameter errors and report execution failures return accurate, identifiable results.
*/
@ExtendWith(MockitoExtension.class)
class SkillToolsImplTest {
private static final String SKILL_NAME = "diagnose";
@Mock
private SkillRegistry skillRegistry;
@Mock
private SopEngine sopEngine;
private SkillToolsImpl skillTools;
@BeforeEach
void setUp() {
skillTools = new SkillToolsImpl(skillRegistry, sopEngine);
}
@Test
void executeSkillShouldRejectInvalidJsonObject() {
when(skillRegistry.getSkill(SKILL_NAME)).thenReturn(skill());
String response = skillTools.executeSkill(SKILL_NAME, "not-json");
assertEquals("Error: Skill parameters must be a valid JSON object.", response);
verifyNoInteractions(sopEngine);
}
@Test
void executeSkillShouldRejectBlankRequiredParameter() {
when(skillRegistry.getSkill(SKILL_NAME)).thenReturn(skill());
String response = skillTools.executeSkill(SKILL_NAME, "{\"monitorId\":\" \"}");
assertTrue(response.contains("Required parameter 'monitorId' is missing"));
verifyNoInteractions(sopEngine);
}
@Test
void executeSkillShouldNotMarkFailedReportForDirectDisplay() {
SopDefinition skill = skill();
SopResult failedResult = SopResult.builder()
.sopName(SKILL_NAME)
.status("FAILED")
.outputType(OutputType.REPORT)
.error("database unavailable")
.build();
when(skillRegistry.getSkill(SKILL_NAME)).thenReturn(skill);
when(sopEngine.executeSync(skill, java.util.Map.of("monitorId", 1))).thenReturn(failedResult);
String response = skillTools.executeSkill(SKILL_NAME, "{\"monitorId\":1}");
assertFalse(response.startsWith(SkillToolsImpl.SKILL_REPORT_MARKER));
assertTrue(response.contains("database unavailable"));
}
@Test
void executeSkillShouldMarkSuccessfulReportForDirectDisplay() {
SopDefinition skill = skill();
SopResult successResult = SopResult.builder()
.status("SUCCESS")
.outputType(OutputType.REPORT)
.content("diagnostic report")
.build();
when(skillRegistry.getSkill(SKILL_NAME)).thenReturn(skill);
when(sopEngine.executeSync(skill, java.util.Map.of("monitorId", 1))).thenReturn(successResult);
String response = skillTools.executeSkill(SKILL_NAME, "{\"monitorId\":1}");
assertEquals(SkillToolsImpl.SKILL_REPORT_MARKER + "\ndiagnostic report", response);
}
private SopDefinition skill() {
return SopDefinition.builder()
.name(SKILL_NAME)
.parameters(List.of(SopParameter.builder()
.name("monitorId")
.required(true)
.description("Monitor ID")
.build()))
.build();
}
}
@@ -0,0 +1,99 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.ai.utils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.manager.pojo.dto.Hierarchy;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Verifies input parsing and hierarchy-data tolerance in the AI alert rule utilities.
*/
class UtilityClassTest {
@ParameterizedTest
@ValueSource(strings = {
"cpu_usage >= 80",
"cpu_usage <= 80",
"cpu_usage == 80",
"cpu_usage != 80"
})
void validateExpressionSyntaxShouldAcceptSupportedComparisonOperators(String expression) {
assertEquals("VALID", UtilityClass.validateExpressionSyntax(expression));
}
@Test
void validateExpressionSyntaxShouldNotTreatLogicalTextInsideFieldNameAsOperator() {
assertEquals("VALID", UtilityClass.validateExpressionSyntax("processor_count > 1"));
}
@Test
void validateExpressionSyntaxShouldStillRejectSingleEquals() {
assertEquals("Error: Use '==' for equality comparison, not '='",
UtilityClass.validateExpressionSyntax("cpu_usage = 80"));
}
@Test
void parseKeyValuePairsShouldPreserveColonsInValue() {
Map<String, String> result = UtilityClass.parseKeyValuePairs(
"runbook:https://example.org:8443/alerts, severity:critical");
assertEquals("https://example.org:8443/alerts", result.get("runbook"));
assertEquals("critical", result.get("severity"));
}
@Test
void extractFieldNamesShouldHandleFunctionsAndComparisons() {
List<String> fields = UtilityClass.extractFieldNamesFromConditions(
"equals(VmName, \"prod\") and contains(host, \"db\") "
+ "and (cpu_usage >= 80 or cpu_usage <= 20)");
assertEquals(List.of("VmName", "host", "cpu_usage"), fields);
}
@Test
void extractFieldNamesShouldIgnoreComparisonTextInsideQuotedValue() {
List<String> fields = UtilityClass.extractFieldNamesFromConditions(
"equals(message, \"fake_field > 1\")");
assertEquals(List.of("message"), fields);
}
@Test
void formatHierarchyAsJsonShouldAcceptNonLeafWithoutChildren() {
Hierarchy hierarchy = new Hierarchy();
hierarchy.setValue("linux");
hierarchy.setLabel("Linux");
hierarchy.setIsLeaf(false);
ObjectNode result = UtilityClass.formatHierarchyAsJson(new ObjectMapper(), hierarchy);
assertEquals("app", result.get("type").asText());
assertFalse(result.has("children"));
assertTrue(result.has("value"));
}
}
@@ -251,20 +251,14 @@ public class MetricsRealTimeAlertCalculator {
}
final int fieldType = field.getType();
// strict jexl aborts the whole rule on undefined variables,
// so define every field even when its value is empty or unparseable
if (fieldType == CommonConstants.TYPE_NUMBER) {
final Double doubleValue;
if ((doubleValue = CommonUtil.parseStrDouble(valueStr)) != null) {
fieldValueMap.put(field.getName(), doubleValue);
}
fieldValueMap.put(field.getName(), CommonUtil.parseStrDouble(valueStr));
} else if (fieldType == CommonConstants.TYPE_TIME) {
final Integer integerValue;
if ((integerValue = CommonUtil.parseStrInteger(valueStr)) != null) {
fieldValueMap.put(field.getName(), integerValue);
}
fieldValueMap.put(field.getName(), CommonUtil.parseStrInteger(valueStr));
} else {
if (StringUtils.isNotEmpty(valueStr)) {
fieldValueMap.put(field.getName(), valueStr);
}
fieldValueMap.put(field.getName(), valueStr);
}
if (field.getLabel()) {
@@ -30,6 +30,7 @@ import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
import org.apache.hertzbeat.alert.service.NoticeConfigService;
import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
@@ -87,14 +88,16 @@ public class NoticeConfigController {
@Parameter(description = "en: Recipient name,support fuzzy query", example = "tom") @RequestParam(required = false) final String name,
@Parameter(description = "en: List current page", example = "0") @RequestParam(defaultValue = "0") final int pageIndex,
@Parameter(description = "en: Number of list pages", example = "8") @RequestParam(defaultValue = "8") final int pageSize) {
return ResponseEntity.ok(Message.success(noticeConfigService.getNoticeReceivers(name, pageIndex, pageSize)));
return ResponseEntity.ok(Message.success(noticeConfigService.getNoticeReceivers(name, pageIndex, pageSize)
.map(NoticeReceiverMaskUtil::mask)));
}
@GetMapping(path = "/receivers/all")
@Operation(summary = "Get a list of all message notification recipients",
description = "Get a list of all message notification recipients")
public ResponseEntity<Message<List<NoticeReceiver>>> getAllReceivers() {
return ResponseEntity.ok(Message.success(noticeConfigService.getAllNoticeReceivers()));
return ResponseEntity.ok(Message.success(noticeConfigService.getAllNoticeReceivers().stream()
.map(NoticeReceiverMaskUtil::mask).toList()));
}
@GetMapping(path = "/receiver/{id}")
@@ -106,7 +109,7 @@ public class NoticeConfigController {
if (noticeReceiver == null) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, "The relevant information of the recipient could not be found, please check whether the parameters are correct or refresh the page"));
}
return ResponseEntity.ok(Message.success(noticeReceiver));
return ResponseEntity.ok(Message.success(NoticeReceiverMaskUtil.mask(noticeReceiver)));
}
@PostMapping(path = "/rule")
@@ -51,4 +51,6 @@ public class AlertDefineDTO {
private String template;
@Excel(name = "Enable")
private Boolean enable;
@Excel(name = "Datasource")
private String datasource;
}
@@ -0,0 +1,142 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.alert.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Alibaba Cloud Monitor 2.0 webhook alert entity.
*
* @see <a href="https://help.aliyun.com/zh/cms/cloudmonitor-2-0/notification-object">
* Alibaba Cloud Monitor webhook payload fields</a>
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AlibabaCloudCmsExternAlert {
private String specversion;
private String id;
private String type;
private String subtype;
private String source;
private String sourcetype;
private String time;
private Long timestamp;
private String subject;
private String datacontenttype;
private String severity;
private String status;
private String userId;
private String ruleId;
private String workspace;
private String traceId;
private String alertMessage;
private String alertEntityId;
private Resource resource;
private Map<String, Object> labels;
private Map<String, Object> annotations;
private AlertData data;
private Map<String, Object> alertEntityFields;
private String ruleUrl;
private String entityUrl;
private String alertRuleUrl;
private String alertHistoryUrl;
/**
* Alert resource.
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class Resource {
private Entity entity;
private Map<String, Object> tags;
}
/**
* Alert resource entity.
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class Entity {
private String domain;
@JsonAlias("entity_type")
private String entityType;
@JsonAlias("entity_id")
private String entityId;
private Map<String, Object> prop;
}
/**
* Threshold alert data.
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class AlertData {
private Object value;
private Object threshold;
private String comparisonOperator;
}
}
@@ -18,10 +18,16 @@
package org.apache.hertzbeat.alert.notice;
import com.google.common.collect.Maps;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.RejectedExecutionException;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.AlerterWorkerPool;
import org.apache.hertzbeat.alert.config.AlertSseManager;
@@ -29,7 +35,9 @@ import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.alert.service.NoticeConfigService;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.apache.hertzbeat.plugin.PostAlertPlugin;
import org.apache.hertzbeat.plugin.Plugin;
@@ -124,6 +132,7 @@ public class AlertNoticeDispatch {
private void sendNotify(GroupAlert alert) {
matchNoticeRulesByAlert(alert).ifPresent(noticeRules -> noticeRules.forEach(rule -> {
NoticeTemplate noticeTemplate = getOneTemplateById(rule.getTemplateId());
GroupAlert noticeAlert = scopeAlertToRule(alert, rule);
rule.getReceiverId().forEach(receiverId -> {
NoticeReceiver receiver = getOneReceiverById(receiverId);
if (receiver == null || receiver.getType() == null) {
@@ -133,7 +142,7 @@ public class AlertNoticeDispatch {
try {
workerPool.executeNotify(receiver.getType(), () -> {
try {
sendNoticeMsg(receiver, noticeTemplate, alert);
sendNoticeMsg(receiver, noticeTemplate, noticeAlert);
} catch (AlertNoticeException e) {
log.warn("DispatchTask sendNoticeMsg error, message: {}", e.getMessage());
}
@@ -145,4 +154,100 @@ public class AlertNoticeDispatch {
});
}));
}
private GroupAlert scopeAlertToRule(GroupAlert alert, NoticeRule rule) {
if (rule.isFilterAll() || rule.getLabels() == null || rule.getLabels().isEmpty()
|| alert.getAlerts() == null) {
return alert;
}
List<SingleAlert> matchingAlerts = alert.getAlerts().stream()
.filter(singleAlert -> singleAlert.getLabels() != null
&& rule.getLabels().entrySet().stream().allMatch(label ->
Objects.equals(label.getValue(), singleAlert.getLabels().get(label.getKey()))))
.toList();
Map<String, String> commonLabels = extractCommonAttributes(matchingAlerts, SingleAlert::getLabels);
Map<String, String> commonAnnotations =
extractCommonAttributes(matchingAlerts, SingleAlert::getAnnotations);
Map<String, String> groupLabels = extractGroupLabels(alert.getGroupLabels(), commonLabels);
String groupKey = Objects.equals(groupLabels, alert.getGroupLabels())
? alert.getGroupKey() : generateGroupKey(groupLabels);
return GroupAlert.builder()
.id(alert.getId())
.groupKey(groupKey)
.status(determineGroupStatus(matchingAlerts))
.groupLabels(groupLabels)
.commonLabels(commonLabels)
.commonAnnotations(commonAnnotations)
.alertFingerprints(matchingAlerts.stream()
.map(SingleAlert::getFingerprint)
.filter(Objects::nonNull)
.toList())
.creator(alert.getCreator())
.modifier(alert.getModifier())
.gmtCreate(firstTime(matchingAlerts, SingleAlert::getGmtCreate, alert.getGmtCreate()))
.gmtUpdate(lastTime(matchingAlerts, SingleAlert::getGmtUpdate, alert.getGmtUpdate()))
.alerts(matchingAlerts)
.build();
}
private Map<String, String> extractCommonAttributes(
Collection<SingleAlert> alerts,
Function<SingleAlert, Map<String, String>> attributes) {
if (alerts.isEmpty()) {
return new HashMap<>(0);
}
Map<String, String> firstAttributes = attributes.apply(alerts.iterator().next());
Map<String, String> common =
firstAttributes == null ? new HashMap<>(0) : new HashMap<>(firstAttributes);
for (SingleAlert alert : alerts) {
Map<String, String> current = attributes.apply(alert);
common.keySet().removeIf(key ->
current == null || !current.containsKey(key)
|| !Objects.equals(common.get(key), current.get(key)));
}
return common;
}
private Map<String, String> extractGroupLabels(
Map<String, String> originalGroupLabels,
Map<String, String> commonLabels) {
Map<String, String> groupLabels = new HashMap<>();
if (originalGroupLabels != null) {
originalGroupLabels.keySet().forEach(key -> {
if (commonLabels.containsKey(key)) {
groupLabels.put(key, commonLabels.get(key));
}
});
}
return groupLabels;
}
private String generateGroupKey(Map<String, String> groupLabels) {
return groupLabels.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> entry.getKey() + ":" + entry.getValue())
.collect(Collectors.joining(","));
}
private String determineGroupStatus(List<SingleAlert> alerts) {
return alerts.stream().anyMatch(alert ->
CommonConstants.ALERT_STATUS_FIRING.equals(alert.getStatus()))
? CommonConstants.ALERT_STATUS_FIRING : CommonConstants.ALERT_STATUS_RESOLVED;
}
private LocalDateTime firstTime(
List<SingleAlert> alerts,
Function<SingleAlert, LocalDateTime> time,
LocalDateTime fallback) {
return alerts.stream().map(time).filter(Objects::nonNull)
.min(LocalDateTime::compareTo).orElse(fallback);
}
private LocalDateTime lastTime(
List<SingleAlert> alerts,
Function<SingleAlert, LocalDateTime> time,
LocalDateTime fallback) {
return alerts.stream().map(time).filter(Objects::nonNull)
.max(LocalDateTime::compareTo).orElse(fallback);
}
}
@@ -42,6 +42,10 @@ import org.springframework.stereotype.Component;
@Slf4j
final class DbAlertStoreHandlerImpl implements AlertStoreHandler {
static final int LOCK_STRIPE_COUNT = 256;
private static final Object[] KEY_LOCKS = createKeyLocks();
private final GroupAlertDao groupAlertDao;
private final SingleAlertDao singleAlertDao;
@@ -58,7 +62,7 @@ final class DbAlertStoreHandlerImpl implements AlertStoreHandler {
List<SingleAlert> newAlerts = new ArrayList<>();
for (SingleAlert singleAlert : originalAlerts) {
synchronized (singleAlert.getFingerprint().intern()) {
synchronized (lockFor(singleAlert.getFingerprint())) {
SingleAlert existAlert = singleAlertDao.findByFingerprint(singleAlert.getFingerprint());
if (existAlert != null) {
// Update the existing alert with the ID and creation time from the database
@@ -90,7 +94,7 @@ final class DbAlertStoreHandlerImpl implements AlertStoreHandler {
}
groupAlert.setAlerts(newAlerts);
// Find existing alert group
synchronized (groupAlert.getGroupKey().intern()) {
synchronized (lockFor(groupAlert.getGroupKey())) {
GroupAlert existGroupAlert = groupAlertDao.findByGroupKey(groupAlert.getGroupKey());
// Process resolved alerts
if (existGroupAlert != null) {
@@ -132,4 +136,16 @@ final class DbAlertStoreHandlerImpl implements AlertStoreHandler {
return savedGroupAlert;
}
}
static Object lockFor(String key) {
return KEY_LOCKS[Math.floorMod(key.hashCode(), LOCK_STRIPE_COUNT)];
}
private static Object[] createKeyLocks() {
Object[] locks = new Object[LOCK_STRIPE_COUNT];
for (int index = 0; index < locks.length; index++) {
locks[index] = new Object();
}
return locks;
}
}
@@ -17,6 +17,7 @@
package org.apache.hertzbeat.alert.notice.impl;
import java.net.URI;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.hertzbeat.alert.notice.AlertNoticeException;
@@ -47,6 +48,15 @@ final class WebHookAlertNotifyHandlerImpl extends AbstractAlertNotifyHandlerImpl
throw new AlertNoticeException("Webhook URL is null or empty");
}
// Send the URL verbatim via the URI overload: the String overload treats it
// as a URI template and re-encodes it, corrupting pre-encoded query params
URI hookUri;
try {
hookUri = URI.create(hookUrl);
} catch (IllegalArgumentException e) {
throw new AlertNoticeException("Invalid webhook URL: " + e.getMessage());
}
HttpHeaders headers = new HttpHeaders();
if ("Basic".equalsIgnoreCase(receiver.getHookAuthType())) {
headers.setBasicAuth(receiver.getHookAuthToken());
@@ -59,7 +69,7 @@ final class WebHookAlertNotifyHandlerImpl extends AbstractAlertNotifyHandlerImpl
webhookJson = webhookJson.replace(",\n }", "\n }");
HttpEntity<String> alertHttpEntity = new HttpEntity<>(webhookJson, headers);
ResponseEntity<String> entity = restTemplate.postForEntity(hookUrl, alertHttpEntity, String.class);
ResponseEntity<String> entity = restTemplate.postForEntity(hookUri, alertHttpEntity, String.class);
if (entity.getStatusCode().value() < HttpStatus.BAD_REQUEST.value()) {
log.debug("Send WebHook: {} Success", hookUrl);
} else {
@@ -155,6 +155,7 @@ public class AlertDefineExcelImExportServiceImpl extends AlertDefineAbstractImEx
alertDefineDTO.setAnnotations(JsonUtil.fromJson(getCellValueAsString(row.getCell(6)), typeReference));
alertDefineDTO.setTemplate(getCellValueAsString(row.getCell(7)));
alertDefineDTO.setEnable(getCellValueAsBoolean(row.getCell(8)));
alertDefineDTO.setDatasource(getCellValueAsString(row.getCell(9)));
return alertDefineDTO;
}
@@ -186,7 +187,7 @@ public class AlertDefineExcelImExportServiceImpl extends AlertDefineAbstractImEx
CellStyle cellStyle = workbook.createCellStyle();
cellStyle.setAlignment(HorizontalAlignment.CENTER);
// set header
String[] headers = {"Name", "Type", "Expr", "Period", "Times", "Labels", "Annotations", "Template", "Enable"};
String[] headers = {"Name", "Type", "Expr", "Period", "Times", "Labels", "Annotations", "Template", "Enable", "Datasource"};
Row headerRow = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
@@ -227,6 +228,9 @@ public class AlertDefineExcelImExportServiceImpl extends AlertDefineAbstractImEx
Cell enableCell = row.createCell(8);
enableCell.setCellValue(alertDefineDTO.getEnable());
enableCell.setCellStyle(cellStyle);
Cell datasourceCell = row.createCell(9);
datasourceCell.setCellValue(alertDefineDTO.getDatasource());
datasourceCell.setCellStyle(cellStyle);
}
workbook.write(os);
os.close();
@@ -0,0 +1,195 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.alert.service.impl;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.hertzbeat.alert.dto.AlibabaCloudCmsExternAlert;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.ExternAlertService;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.springframework.stereotype.Service;
/**
* Alibaba Cloud Monitor 2.0 external alert service.
*/
@Slf4j
@Service
public class AlibabaCloudCmsExternAlertService implements ExternAlertService {
private static final String SOURCE = "alibabacloud-cms";
private final AlarmCommonReduce alarmCommonReduce;
public AlibabaCloudCmsExternAlertService(AlarmCommonReduce alarmCommonReduce) {
this.alarmCommonReduce = alarmCommonReduce;
}
@Override
public void addExternAlert(String content) {
AlibabaCloudCmsExternAlert externAlert = JsonUtil.fromJson(content, AlibabaCloudCmsExternAlert.class);
if (externAlert == null || StringUtils.isBlank(externAlert.getStatus())) {
log.warn("Failed to parse Alibaba Cloud Monitor external alert content: {}", content);
return;
}
alarmCommonReduce.reduceAndSendAlarm(convert(externAlert));
}
@Override
public String supportSource() {
return SOURCE;
}
private SingleAlert convert(AlibabaCloudCmsExternAlert externAlert) {
boolean resolved = isResolved(externAlert);
long eventTime = getEventTime(externAlert);
return SingleAlert.builder()
.content(getAlertContent(externAlert))
.status(resolved ? CommonConstants.ALERT_STATUS_RESOLVED : CommonConstants.ALERT_STATUS_FIRING)
.startAt(eventTime)
.activeAt(resolved ? null : eventTime)
.endAt(resolved ? eventTime : null)
.labels(buildLabels(externAlert))
.annotations(buildAnnotations(externAlert))
.triggerTimes(1)
.build();
}
private boolean isResolved(AlibabaCloudCmsExternAlert externAlert) {
return "RESOLVED".equalsIgnoreCase(externAlert.getStatus())
|| "RECOVERED".equalsIgnoreCase(externAlert.getStatus())
|| "NORMAL_RESOLVE".equalsIgnoreCase(externAlert.getSubtype());
}
private long getEventTime(AlibabaCloudCmsExternAlert externAlert) {
if (externAlert.getTimestamp() != null && externAlert.getTimestamp() > 0) {
return externAlert.getTimestamp();
}
if (StringUtils.isNotBlank(externAlert.getTime())) {
try {
return Instant.parse(externAlert.getTime()).toEpochMilli();
} catch (DateTimeParseException e) {
log.warn("Failed to parse Alibaba Cloud Monitor event time: {}", externAlert.getTime());
}
}
return Instant.now().toEpochMilli();
}
private Map<String, String> buildLabels(AlibabaCloudCmsExternAlert externAlert) {
Map<String, String> labels = new HashMap<>(16);
putValues(labels, externAlert.getLabels());
AlibabaCloudCmsExternAlert.Resource resource = externAlert.getResource();
if (resource != null) {
putValues(labels, resource.getTags());
AlibabaCloudCmsExternAlert.Entity entity = resource.getEntity();
if (entity != null) {
putIfNotBlank(labels, "resourceDomain", entity.getDomain());
putIfNotBlank(labels, "resourceType", entity.getEntityType());
putIfNotBlank(labels, "resourceId", entity.getEntityId());
}
}
labels.put("__source__", SOURCE);
putIfNotBlank(labels, CommonConstants.LABEL_ALERT_NAME, externAlert.getSubject());
putIfNotBlank(labels, CommonConstants.LABEL_ALERT_SEVERITY, convertSeverity(externAlert.getSeverity()));
putIfNotBlank(labels, "ruleId", externAlert.getRuleId());
putIfNotBlank(labels, "workspace", externAlert.getWorkspace());
putIfNotBlank(labels, "alertEntityId", externAlert.getAlertEntityId());
putIfNotBlank(labels, "userId", externAlert.getUserId());
return labels;
}
private Map<String, String> buildAnnotations(AlibabaCloudCmsExternAlert externAlert) {
Map<String, String> annotations = new HashMap<>(16);
putValues(annotations, externAlert.getAnnotations());
AlibabaCloudCmsExternAlert.Resource resource = externAlert.getResource();
if (resource != null && resource.getEntity() != null) {
putValues(annotations, resource.getEntity().getProp());
}
putValues(annotations, externAlert.getAlertEntityFields());
AlibabaCloudCmsExternAlert.AlertData data = externAlert.getData();
if (data != null) {
putValue(annotations, "value", data.getValue());
putValue(annotations, "threshold", data.getThreshold());
putIfNotBlank(annotations, "comparisonOperator", data.getComparisonOperator());
}
putIfNotBlank(annotations, "alertMessage", externAlert.getAlertMessage());
putIfNotBlank(annotations, "traceId", externAlert.getTraceId());
putIfNotBlank(annotations, "ruleUrl", externAlert.getRuleUrl());
putIfNotBlank(annotations, "entityUrl", externAlert.getEntityUrl());
putIfNotBlank(annotations, "alertRuleUrl", externAlert.getAlertRuleUrl());
putIfNotBlank(annotations, "alertHistoryUrl", externAlert.getAlertHistoryUrl());
return annotations;
}
private String getAlertContent(AlibabaCloudCmsExternAlert externAlert) {
if (StringUtils.isNotBlank(externAlert.getAlertMessage())) {
return externAlert.getAlertMessage();
}
if (StringUtils.isNotBlank(externAlert.getSubject())) {
return externAlert.getSubject();
}
return "Alibaba Cloud Monitor alert";
}
private String convertSeverity(String severity) {
if (StringUtils.isBlank(severity)) {
return null;
}
return switch (severity.toUpperCase(Locale.ROOT)) {
case "EMERGENCY" -> CommonConstants.ALERT_SEVERITY_EMERGENCY;
case "CRITICAL" -> CommonConstants.ALERT_SEVERITY_CRITICAL;
case "WARN", "WARNING" -> CommonConstants.ALERT_SEVERITY_WARNING;
case "INFO", "INFORMATIONAL" -> CommonConstants.ALERT_SEVERITY_INFO;
default -> severity.toLowerCase(Locale.ROOT);
};
}
private void putValues(Map<String, String> target, Map<String, Object> values) {
if (values == null || values.isEmpty()) {
return;
}
values.forEach((key, value) -> putValue(target, key, value));
}
private void putValue(Map<String, String> target, String key, Object value) {
if (StringUtils.isBlank(key) || value == null) {
return;
}
String stringValue;
if (value instanceof Map<?, ?> || value instanceof Collection<?>) {
stringValue = JsonUtil.toJson(value);
} else {
stringValue = String.valueOf(value);
}
putIfNotBlank(target, key, stringValue);
}
private void putIfNotBlank(Map<String, String> target, String key, String value) {
if (StringUtils.isNotBlank(value)) {
target.put(key, value);
}
}
}
@@ -26,18 +26,14 @@ import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
import org.apache.hertzbeat.common.support.exception.SendMessageException;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.apache.hertzbeat.common.util.LogUtil;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@@ -65,7 +61,6 @@ public class AlibabaSmsClientImpl implements SmsClient {
private final String accessKeySecret;
private final String signName;
private final String templateCode;
private static final Logger logger = LoggerFactory.getLogger(AlibabaSmsClientImpl.class);
public AlibabaSmsClientImpl(AlibabaSmsProperties config) {
if (config != null) {
@@ -154,31 +149,37 @@ public class AlibabaSmsClientImpl implements SmsClient {
httpPost.setHeader("x-acs-content-sha256",
CryptoUtils.sha256Hex(""));
log.info("Sending Alibaba SMS request to {}", url + ", params: " + templateParam + "headers: " + Arrays.toString(httpPost.getAllHeaders()));
log.debug("Sending SMS request via Alibaba Cloud");
// Send request and handle response
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
int statusCode = response.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(response.getEntity());
log.info("SMS response status: {}, body: {}", statusCode, responseBody);
log.debug("Alibaba Cloud SMS response status: {}", statusCode);
if (statusCode != 200) {
throw new SendMessageException("HTTP request failed with status code: " + statusCode + ", response: " + responseBody);
throw SmsFailureMessages.httpStatus("Alibaba Cloud SMS", statusCode);
}
JsonNode jsonResponse = JsonUtil.fromJson(responseBody);
if (jsonResponse == null || jsonResponse.get("Code") == null) {
throw SmsFailureMessages.invalidResponse("Alibaba Cloud SMS");
}
String code = jsonResponse.get("Code").asText();
if (!"OK".equals(code)) {
String message = jsonResponse.get("Message").asText();
throw new SendMessageException(code + ":" + message);
throw SmsFailureMessages.providerCode("Alibaba Cloud SMS", code);
}
log.info("Successfully sent SMS to phone: {}", phoneNumber);
log.info("Successfully sent SMS via Alibaba Cloud");
}
} catch (SendMessageException e) {
log.warn("Failed to send SMS via Alibaba Cloud");
throw e;
} catch (Exception e) {
LogUtil.warn(logger, "Failed to send SMS: {0}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to send SMS via Alibaba Cloud, failure type: {}",
e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("Alibaba Cloud SMS");
}
}
@@ -196,7 +197,8 @@ public class AlibabaSmsClientImpl implements SmsClient {
// Step 4: Build authorization header
return ALGORITHM + " Credential=" + accessKeyId + ",SignedHeaders=host;x-acs-action;x-acs-content-sha256;x-acs-date;" + "x-acs-signature-nonce;x-acs-version,Signature=" + signature;
} catch (Exception e) {
LogUtil.warn(logger, "Failed to calculate authorization {0}", e.getMessage());
log.warn("Failed to calculate Alibaba Cloud authorization, failure type: {}",
e.getClass().getSimpleName());
throw new RuntimeException("Failed to calculate authorization", e);
}
}
@@ -40,7 +40,6 @@ import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -116,11 +115,14 @@ public class AwsSmsClientImpl implements SmsClient {
URI requestUri = new URI(endpoint);
HttpPost httpPost = createHttpPost(requestUri, amzDate, payloadInString);
log.info("Sending AWS SMS request to {}", requestUri + "," + "headers: " + Arrays.toString(httpPost.getAllHeaders()));
executeRequest(httpClient, httpPost, phoneNumber);
log.debug("Sending SMS request via AWS");
executeRequest(httpClient, httpPost);
} catch (SendMessageException e) {
log.warn("Failed to send SMS via AWS");
throw e;
} catch (Exception e) {
log.warn("Failed to send SMS: {}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to send SMS via AWS, failure type: {}", e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("AWS SMS");
}
}
@@ -149,28 +151,27 @@ public class AwsSmsClientImpl implements SmsClient {
return httpPost;
}
private void executeRequest(CloseableHttpClient httpClient, HttpPost httpPost, String phoneNumber) throws Exception {
private void executeRequest(CloseableHttpClient httpClient, HttpPost httpPost) throws Exception {
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
int statusCode = response.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(response.getEntity());
log.info("SMS response status: {}, body: {}", statusCode, responseBody);
log.debug("AWS SMS response status: {}", statusCode);
if (statusCode != 200) {
throw new SendMessageException("HTTP request failed with status code: " + statusCode + ", response: " + responseBody);
throw SmsFailureMessages.httpStatus("AWS SMS", statusCode);
}
JsonNode jsonResponse = JsonUtil.fromJson(responseBody);
if (jsonResponse == null) {
throw new SendMessageException(statusCode + ":" + responseBody);
throw SmsFailureMessages.invalidResponse("AWS SMS");
}
JsonNode responseNode = jsonResponse.get("MessageId");
if (responseNode == null) {
throw new SendMessageException(statusCode + ":" + responseBody);
throw SmsFailureMessages.invalidResponse("AWS SMS");
}
String messageId = responseNode.asText();
log.info("Successfully sent SMS to phone: {}, messageId: {}", phoneNumber, messageId);
log.info("Successfully sent SMS via AWS");
}
}
@@ -285,5 +286,3 @@ public class AwsSmsClientImpl implements SmsClient {
}
}
@@ -31,6 +31,7 @@ import org.apache.hertzbeat.alert.dao.NoticeReceiverDao;
import org.apache.hertzbeat.alert.dao.NoticeRuleDao;
import org.apache.hertzbeat.alert.dao.NoticeTemplateDao;
import org.apache.hertzbeat.alert.service.NoticeConfigService;
import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
@@ -176,9 +177,43 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine
@Override
public void editReceiver(NoticeReceiver noticeReceiver) {
resolveMaskedSecrets(noticeReceiver);
noticeReceiverDao.save(noticeReceiver);
}
/**
* The rest api returns receivers with masked secret fields, so a receiver submitted
* from the ui may carry the mask placeholder instead of the real secret.
* Restore such fields from the stored entity before using the receiver.
* @throws IllegalArgumentException if the receiver carries an id but no stored receiver
* exists for it: without the stored entity the mask cannot
* be resolved, and saving would persist the placeholder
*/
private void resolveMaskedSecrets(NoticeReceiver noticeReceiver) {
resolveMaskedSecrets(noticeReceiver, false);
}
/**
* Bind stored secrets to their original notification type and destination for test messages.
*/
private void resolveMaskedSecretsForTest(NoticeReceiver noticeReceiver) {
resolveMaskedSecrets(noticeReceiver, true);
}
private void resolveMaskedSecrets(NoticeReceiver noticeReceiver, boolean testMessage) {
if (noticeReceiver == null || noticeReceiver.getId() == null) {
return;
}
NoticeReceiver existing = noticeReceiverDao.findById(noticeReceiver.getId())
.orElseThrow(() -> new IllegalArgumentException(
"The receiver with id " + noticeReceiver.getId() + " does not exist."));
if (testMessage) {
NoticeReceiverMaskUtil.resolveMaskForTest(noticeReceiver, existing);
} else {
NoticeReceiverMaskUtil.resolveMask(noticeReceiver, existing);
}
}
@Override
public void deleteReceiver(Long receiverId) {
noticeReceiverDao.deleteById(receiverId);
@@ -211,11 +246,7 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine
CacheFactory.setNoticeCache(rules);
}
// The temporary rule is to forward all, and then implement more matching rules: alarm status selection, monitoring type selection, etc.
// TODO: This matches an already-grouped alert against notice rules (group-then-route). It cannot fully
// separate alerts that were grouped together but should reach different receivers, so a rule matched by
// one alert still notifies the whole group. The ideal design is route-then-group (like Alertmanager):
// route each single alert by its labels first, then group per receiver. Tracked as a follow-up to #3852.
// Match grouped alerts here; dispatch scopes each notification to the single alerts matching its rule.
return rules.stream()
.filter(rule -> {
if (!rule.isFilterAll()) {
@@ -319,6 +350,7 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine
@Override
public boolean sendTestMsg(NoticeReceiver noticeReceiver) {
resolveMaskedSecretsForTest(noticeReceiver);
Map<String, String> labels = new HashMap<>(8);
labels.put(CommonConstants.LABEL_INSTANCE, "127.0.0.1");
labels.put(CommonConstants.LABEL_ALERT_NAME, "CPU Usage Alert");
@@ -0,0 +1,55 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.alert.service.impl;
import java.util.regex.Pattern;
import org.apache.hertzbeat.common.support.exception.SendMessageException;
/**
* Builds bounded SMS failures without copying provider-controlled response
* bodies, request URLs, or transport exception messages.
*/
final class SmsFailureMessages {
private static final Pattern SAFE_PROVIDER_CODE = Pattern.compile("[-A-Za-z0-9_.]{1,64}");
private static final String UNKNOWN_PROVIDER_CODE = "UNKNOWN_PROVIDER_ERROR";
private SmsFailureMessages() {
}
static SendMessageException requestFailed(String providerLabel) {
return new SendMessageException(providerLabel + " request failed");
}
static SendMessageException httpStatus(String providerLabel, int statusCode) {
return new SendMessageException(
providerLabel + " request failed with HTTP status " + statusCode);
}
static SendMessageException providerCode(String providerLabel, String code) {
String safeCode = code != null && SAFE_PROVIDER_CODE.matcher(code).matches()
? code
: UNKNOWN_PROVIDER_CODE;
return new SendMessageException(
providerLabel + " request failed (code: " + safeCode + ")");
}
static SendMessageException invalidResponse(String providerLabel) {
return new SendMessageException(providerLabel + " provider returned an invalid response");
}
}
@@ -61,7 +61,7 @@ public class SmsLocalSmsClientImpl implements SmsClient {
@Override
public void sendMessage(NoticeReceiver receiver, NoticeTemplate noticeTemplate, GroupAlert alert) {
if (Objects.isNull(receiver) || Objects.isNull(alert)) {
log.warn("receiver and alert can not be null! receiver: {}, alert:{}", receiver, alert);
log.warn("SMSLocal receiver and alert cannot be null");
return;
}
@@ -79,36 +79,42 @@ public class SmsLocalSmsClientImpl implements SmsClient {
httpPost.setHeader("Token", config.getApiKey());
httpPost.setEntity(new StringEntity(payload, StandardCharsets.UTF_8));
log.debug("Sending SMS request to {}, payload: {}", httpPost.getURI(), payload);
log.debug("Sending SMS request via SMSLocal");
// send http request and handle response
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
int statusCode = response.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(response.getEntity());
log.debug("SMS response status: {}, body: {}", statusCode, responseBody);
log.debug("SMSLocal response status: {}", statusCode);
if (statusCode != 200) {
throw new SendMessageException("HTTP request failed with status code: " + statusCode);
throw SmsFailureMessages.httpStatus("SMSLocal", statusCode);
}
JsonNode jsonResponse = JsonUtil.fromJson(responseBody);
JsonNode jsonNode = jsonResponse.get(0);
if (Objects.isNull(jsonNode)) {
log.warn("jsonResponse parse errorCode failed: {}", jsonResponse);
return;
if (jsonResponse == null || !jsonResponse.isArray() || jsonResponse.isEmpty()) {
throw SmsFailureMessages.invalidResponse("SMSLocal");
}
String errorCode = jsonNode.get("errorCode").asText();
JsonNode jsonNode = jsonResponse.get(0);
JsonNode errorCodeNode = jsonNode.get("errorCode");
if (errorCodeNode == null) {
throw SmsFailureMessages.invalidResponse("SMSLocal");
}
String errorCode = errorCodeNode.asText();
if (!SUCCESS_CODE.equals(errorCode)) {
String msgid = jsonNode.get("id").asText();
throw new SendMessageException(errorCode + ":" + msgid);
throw SmsFailureMessages.providerCode("SMSLocal", errorCode);
}
log.info("Successfully sent SMS to phone: {}", receiver.getPhone());
log.info("Successfully sent SMS via SMSLocal");
}
} catch (SendMessageException e) {
log.warn("Failed to send SMS via SMSLocal");
throw e;
} catch (Exception e) {
log.error("Failed to send SMS: {}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to send SMS via SMSLocal, failure type: {}",
e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("SMSLocal");
}
}
@@ -121,7 +127,7 @@ public class SmsLocalSmsClientImpl implements SmsClient {
@Override
public boolean checkConfig() {
if (Objects.isNull(config) || Objects.isNull(config.getApiKey()) || config.getApiKey().isBlank()) {
log.warn("smslocal properties can not be null: {}", config);
log.warn("SMSLocal properties cannot be null or blank");
return false;
}
return true;
@@ -134,41 +134,56 @@ public class TencentSmsClientImpl implements SmsClient {
httpPost.setHeader("Authorization", authorization);
httpPost.setEntity(new StringEntity(payload, StandardCharsets.UTF_8));
log.debug("Sending SMS request to {}, payload: {}", httpPost.getURI(), payload);
log.debug("Sending SMS request via Tencent Cloud");
// send http request and handle response
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
int statusCode = response.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(response.getEntity());
log.debug("SMS response status: {}, body: {}", statusCode, responseBody);
log.debug("Tencent Cloud SMS response status: {}", statusCode);
if (statusCode != 200) {
throw new SendMessageException("HTTP request failed with status code: " + statusCode);
throw SmsFailureMessages.httpStatus("Tencent Cloud SMS", statusCode);
}
JsonNode jsonResponse = JsonUtil.fromJson(responseBody);
if (jsonResponse == null) {
throw SmsFailureMessages.invalidResponse("Tencent Cloud SMS");
}
JsonNode responseNode = jsonResponse.get("Response");
if (responseNode == null) {
throw SmsFailureMessages.invalidResponse("Tencent Cloud SMS");
}
JsonNode error = responseNode.get("Error");
if (error != null) {
String code = error.get("Code").asText();
String message = error.get("Message").asText();
throw new SendMessageException(code + ":" + message);
JsonNode codeNode = error.get("Code");
if (codeNode == null) {
throw SmsFailureMessages.invalidResponse("Tencent Cloud SMS");
}
throw SmsFailureMessages.providerCode("Tencent Cloud SMS", codeNode.asText());
}
JsonNode sendStatusSet = responseNode.get("SendStatusSet");
if (sendStatusSet != null && sendStatusSet.isArray() && sendStatusSet.size() > 0) {
JsonNode firstStatus = sendStatusSet.get(0);
String code = firstStatus.get("Code").asText();
String message = firstStatus.get("Message").asText();
if (!RESPONSE_OK.equals(code)) {
throw new SendMessageException(code + ":" + message);
}
if (sendStatusSet == null || !sendStatusSet.isArray() || sendStatusSet.isEmpty()) {
throw SmsFailureMessages.invalidResponse("Tencent Cloud SMS");
}
log.info("Successfully sent SMS to phones: {}", String.join(",", phones));
JsonNode codeNode = sendStatusSet.get(0).get("Code");
if (codeNode == null) {
throw SmsFailureMessages.invalidResponse("Tencent Cloud SMS");
}
String code = codeNode.asText();
if (!RESPONSE_OK.equals(code)) {
throw SmsFailureMessages.providerCode("Tencent Cloud SMS", code);
}
log.info("Successfully sent SMS via Tencent Cloud");
}
} catch (SendMessageException e) {
log.warn("Failed to send SMS via Tencent Cloud");
throw e;
} catch (Exception e) {
log.warn("Failed to send SMS: {}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to send SMS via Tencent Cloud, failure type: {}",
e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("Tencent Cloud SMS");
}
}
@@ -97,11 +97,15 @@ public class TwilioSmsClientImpl implements SmsClient {
URI requestUri = new URI(endpoint);
HttpPost httpPost = createHttpPost(requestUri, phoneNumber, message);
log.info("Sending Twilio SMS request to {}", requestUri);
executeRequest(httpClient, httpPost, phoneNumber);
log.debug("Sending SMS request via Twilio");
executeRequest(httpClient, httpPost);
} catch (SendMessageException e) {
log.warn("Failed to send SMS via Twilio");
throw e;
} catch (Exception e) {
log.warn("Failed to send SMS: {}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to send SMS via Twilio, failure type: {}",
e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("Twilio SMS");
}
}
@@ -121,41 +125,36 @@ public class TwilioSmsClientImpl implements SmsClient {
httpPost.setEntity(new UrlEncodedFormEntity(parameters));
return httpPost;
} catch (Exception e) {
log.error("Failed to create HTTP request: {}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to create Twilio SMS request, failure type: {}",
e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("Twilio SMS");
}
}
private void executeRequest(CloseableHttpClient httpClient, HttpPost httpPost, String phoneNumber)
throws Exception {
private void executeRequest(CloseableHttpClient httpClient, HttpPost httpPost) throws Exception {
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
int statusCode = response.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(response.getEntity());
log.info("SMS response status: {}, body: {}", statusCode, responseBody);
log.debug("Twilio SMS response status: {}", statusCode);
if (statusCode < 200 || statusCode >= 300) {
if (responseBody.contains("21608")) {
throw new SendMessageException(
"The Twilio trial account can only send SMS to verified phone numbers");
} else {
throw new SendMessageException(
"HTTP request failed with status code: " + statusCode + ", response: " + responseBody);
throw SmsFailureMessages.providerCode("Twilio SMS", "21608");
}
throw SmsFailureMessages.httpStatus("Twilio SMS", statusCode);
}
JsonNode jsonResponse = JsonUtil.fromJson(responseBody);
if (jsonResponse == null) {
throw new SendMessageException(statusCode + ":" + responseBody);
throw SmsFailureMessages.invalidResponse("Twilio SMS");
}
JsonNode sidNode = jsonResponse.get("sid");
if (sidNode == null) {
throw new SendMessageException(statusCode + ":" + responseBody);
throw SmsFailureMessages.invalidResponse("Twilio SMS");
}
String sid = sidNode.asText();
log.info("Successfully sent SMS to phone: {}, sid: {}", phoneNumber, sid);
log.info("Successfully sent SMS via Twilio");
}
}
@@ -102,14 +102,17 @@ public class UniSmsClientImpl implements SmsClient {
String payload = JsonUtil.toJson(params);
httpPost.setEntity(new StringEntity(payload, StandardCharsets.UTF_8));
log.info("Sending SMS request to UniSMS, payload: {}, url: {}", payload, url);
log.debug("Sending SMS request via UniSMS");
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
handleResponse(response, receiver.getPhone());
handleResponse(response);
}
} catch (SendMessageException e) {
log.warn("Failed to send SMS via UniSMS");
throw e;
} catch (Exception e) {
log.error("Failed to send SMS via UniSMS: {}", e.getMessage());
throw new SendMessageException(e.getMessage());
log.warn("Failed to send SMS via UniSMS, failure type: {}", e.getClass().getSimpleName());
throw SmsFailureMessages.requestFailed("UniSMS");
}
}
@@ -145,24 +148,26 @@ public class UniSmsClientImpl implements SmsClient {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
private void handleResponse(CloseableHttpResponse response, String phone) throws IOException {
private void handleResponse(CloseableHttpResponse response) throws IOException {
int statusCode = response.getStatusLine().getStatusCode();
String responseBody = EntityUtils.toString(response.getEntity());
log.info("UniSMS response status: {}, body: {}", statusCode, responseBody);
log.debug("UniSMS response status: {}", statusCode);
if (statusCode != 200) {
throw new SendMessageException("HTTP request failed with status code: " + statusCode + ", response: " + responseBody);
throw SmsFailureMessages.httpStatus("UniSMS", statusCode);
}
JsonNode jsonResponse = JsonUtil.fromJson(responseBody);
if (jsonResponse == null || jsonResponse.get("code") == null) {
throw SmsFailureMessages.invalidResponse("UniSMS");
}
String code = jsonResponse.get("code").asText();
if (!SUCCESS_CODE.equals(code)) {
String message = jsonResponse.get("message").asText();
throw new SendMessageException(code + ":" + message);
throw SmsFailureMessages.providerCode("UniSMS", code);
}
log.info("Successfully sent SMS to phone: {}", phone);
log.info("Successfully sent SMS via UniSMS");
}
@Override
@@ -42,6 +42,7 @@ public class ZabbixExternAlertServiceImpl implements ExternAlertService {
log.warn("parse extern alert content failed! content: {}", content);
return;
}
alert.setId(null);
alarmCommonReduce.reduceAndSendAlarm(alert);
}
@@ -17,10 +17,8 @@
package org.apache.hertzbeat.alert.util;
import java.util.Arrays;
import java.util.List;
import java.util.Comparator;
import java.util.Map;
import java.util.Objects;
/**
* alert util
@@ -28,13 +26,31 @@ import java.util.Objects;
public class AlertUtil {
/**
* calculate fingerprint
* @param fingerPrints finger prints
* Calculate an in-memory alert cache coordinate.
*
* <p>This value is rebuilt from persisted alert labels when the process
* starts. It is not the durable {@code SingleAlert.fingerprint} used by
* persistence, grouping, silence, or inhibition.</p>
*
* @param fingerPrints labels used by the calculator cache
* @return deterministic cache coordinate
*/
public static String calculateFingerprint(Map<String, String> fingerPrints) {
List<String> keyList = fingerPrints.keySet().stream().filter(Objects::nonNull).sorted().toList();
List<String> valueList = fingerPrints.values().stream().filter(Objects::nonNull).sorted().toList();
return Arrays.hashCode(keyList.toArray(new String[0])) + "-"
+ Arrays.hashCode(valueList.toArray(new String[0]));
StringBuilder canonicalLabels = new StringBuilder();
fingerPrints.entrySet().stream()
.sorted(Map.Entry.comparingByKey(Comparator.nullsFirst(Comparator.naturalOrder())))
.forEach(entry -> {
appendLengthPrefixed(canonicalLabels, entry.getKey());
appendLengthPrefixed(canonicalLabels, entry.getValue());
});
return CryptoUtils.sha256Hex(canonicalLabels.toString());
}
private static void appendLengthPrefixed(StringBuilder target, String value) {
if (value == null) {
target.append("-1:");
return;
}
target.append(value.length()).append(':').append(value);
}
}
@@ -0,0 +1,184 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.alert.util;
import java.util.List;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.function.Function;
import org.apache.commons.lang3.StringUtils;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
/**
* Masks the secret fields of {@link NoticeReceiver} before it is exposed through the rest api,
* and resolves masked values back to the stored secrets when an edited receiver is submitted.
* A secret long enough keeps its last characters visible so different tokens stay
* distinguishable in the ui, while an unchanged secret round-trips through the ui
* without ever leaving the server.
*/
public final class NoticeReceiverMaskUtil {
/**
* Fixed-length placeholder replacing the hidden part of a secret. Its length is
* constant on purpose so the mask never reveals how long the real secret is.
*/
public static final String SECRET_MASK = "******";
/**
* A secret shorter than this is masked entirely: revealing a suffix of a short
* secret would give away too large a fraction of it.
*/
private static final int MIN_LENGTH_TO_SHOW_SUFFIX = 12;
/**
* Number of trailing characters kept visible for a long secret,
* enough to tell configured tokens apart.
*/
private static final int VISIBLE_SUFFIX_LENGTH = 4;
private record SecretField(Function<NoticeReceiver, String> getter, BiConsumer<NoticeReceiver, String> setter) {
}
private static final List<SecretField> SECRET_FIELDS = List.of(
new SecretField(NoticeReceiver::getHookAuthToken, NoticeReceiver::setHookAuthToken),
new SecretField(NoticeReceiver::getAccessToken, NoticeReceiver::setAccessToken),
new SecretField(NoticeReceiver::getTgBotToken, NoticeReceiver::setTgBotToken),
new SecretField(NoticeReceiver::getSlackWebHookUrl, NoticeReceiver::setSlackWebHookUrl),
new SecretField(NoticeReceiver::getAppSecret, NoticeReceiver::setAppSecret),
new SecretField(NoticeReceiver::getDiscordBotToken, NoticeReceiver::setDiscordBotToken),
new SecretField(NoticeReceiver::getSmnAk, NoticeReceiver::setSmnAk),
new SecretField(NoticeReceiver::getSmnSk, NoticeReceiver::setSmnSk),
new SecretField(NoticeReceiver::getServerChanToken, NoticeReceiver::setServerChanToken),
new SecretField(NoticeReceiver::getGotifyToken, NoticeReceiver::setGotifyToken),
new SecretField(NoticeReceiver::getNtfyToken, NoticeReceiver::setNtfyToken));
private NoticeReceiverMaskUtil() {
}
/**
* Return a copy of the receiver with all secret fields masked.
* The given entity is not modified as it may still be attached to the persistence context.
* @param receiver receiver to mask, may be null
* @return masked copy, or null if receiver is null
*/
public static NoticeReceiver mask(NoticeReceiver receiver) {
if (receiver == null) {
return null;
}
NoticeReceiver masked = receiver.toBuilder().build();
for (SecretField field : SECRET_FIELDS) {
field.setter().accept(masked, maskValue(field.getter().apply(masked)));
}
return masked;
}
/**
* Replace every secret field of the incoming receiver that still holds the masked form
* of the stored secret with the stored value, so an edit that did not touch a secret keeps it.
* A re-entered secret or a cleared field is left untouched.
* @param incoming receiver submitted by the ui, modified in place
* @param existing receiver currently stored in the database
* @throws IllegalArgumentException if a submitted mask does not match the stored secret
*/
public static void resolveMask(NoticeReceiver incoming, NoticeReceiver existing) {
if (incoming == null || existing == null) {
return;
}
for (SecretField field : SECRET_FIELDS) {
String submitted = field.getter().apply(incoming);
String stored = field.getter().apply(existing);
if (isMaskOf(submitted, stored)) {
field.setter().accept(incoming, stored);
} else if (isMaskValue(submitted)) {
throw new IllegalArgumentException(
"The submitted secret mask does not match the stored secret.");
}
}
}
/**
* Resolve masked secrets for a test message while binding them to their persisted destination.
* A caller that changes the notification type or a URL receiving authentication data
* must submit the new secret explicitly instead of reusing a stored secret mask.
* @param incoming receiver submitted for a test message, modified in place
* @param existing receiver currently stored in the database
* @throws IllegalArgumentException if a masked secret is combined with a changed destination
*/
public static void resolveMaskForTest(NoticeReceiver incoming, NoticeReceiver existing) {
if (incoming == null || existing == null) {
return;
}
boolean containsStoredSecretMask = SECRET_FIELDS.stream()
.anyMatch(field -> isMaskOf(field.getter().apply(incoming), field.getter().apply(existing)));
if (containsStoredSecretMask && !Objects.equals(incoming.getType(), existing.getType())) {
throw new IllegalArgumentException(
"The notification type cannot be changed when reusing a masked secret.");
}
rejectChangedSecretDestination(
incoming.getHookAuthToken(),
existing.getHookAuthToken(),
incoming.getHookUrl(),
existing.getHookUrl(),
"webhook URL");
rejectChangedSecretDestination(
incoming.getNtfyToken(),
existing.getNtfyToken(),
incoming.getNtfyServerUrl(),
existing.getNtfyServerUrl(),
"ntfy server URL");
resolveMask(incoming, existing);
}
private static void rejectChangedSecretDestination(
String submittedSecret,
String storedSecret,
String submittedDestination,
String storedDestination,
String destinationName) {
if (isMaskOf(submittedSecret, storedSecret)
&& !Objects.equals(submittedDestination, storedDestination)) {
throw new IllegalArgumentException(
"The " + destinationName + " cannot be changed when reusing a masked secret.");
}
}
private static boolean isMaskOf(String submitted, String stored) {
if (submitted == null || StringUtils.isBlank(stored)) {
return false;
}
return submitted.equals(maskValue(stored));
}
private static boolean isMaskValue(String value) {
return value != null
&& value.startsWith(SECRET_MASK)
&& (value.length() == SECRET_MASK.length()
|| value.length() == SECRET_MASK.length() + VISIBLE_SUFFIX_LENGTH);
}
private static String maskValue(String value) {
if (StringUtils.isBlank(value)) {
return value;
}
if (value.length() < MIN_LENGTH_TO_SHOW_SUFFIX) {
return SECRET_MASK;
}
return SECRET_MASK + value.substring(value.length() - VISIBLE_SUFFIX_LENGTH);
}
}
@@ -34,6 +34,7 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.when;
/**
@@ -132,4 +133,28 @@ public class AlarmCacheManagerTest {
historicalSingleAlert = alarmCacheManager.getFiring(4L, fingerprint);
assertNull(historicalSingleAlert);
}
}
@Test
void restartShouldRebuildCacheKeyWithoutChangingPersistedFingerprint() {
Map<String, String> labels = Map.of(
CommonConstants.LABEL_DEFINE_ID, "7",
CommonConstants.LABEL_ALERT_NAME, "disk_full",
"instance", "db-1");
SingleAlert persistedAlert = SingleAlert.builder()
.id(99L)
.fingerprint("alertname:disk_full,define_id:7,instance:db-1")
.labels(labels)
.status(CommonConstants.ALERT_STATUS_FIRING)
.build();
when(singleAlertDao.querySingleAlertsByStatus(CommonConstants.ALERT_STATUS_FIRING))
.thenReturn(Collections.singletonList(persistedAlert));
alarmCacheManager = new AlarmCacheManager(singleAlertDao);
String rebuiltCacheKey = AlertUtil.calculateFingerprint(labels);
SingleAlert resolved = alarmCacheManager.removeFiring(7L, rebuiltCacheKey);
assertSame(persistedAlert, resolved);
assertEquals("alertname:disk_full,define_id:7,instance:db-1", resolved.getFingerprint());
assertNull(alarmCacheManager.getFiring(7L, rebuiltCacheKey));
}
}
@@ -27,11 +27,13 @@ import org.apache.hertzbeat.alert.service.AlertDefineService;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.constants.MetricDataConstants;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.apache.hertzbeat.common.queue.impl.InMemoryCommonDataQueue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@@ -43,6 +45,7 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -254,4 +257,89 @@ public class MetricsRealTimeAlertCalculatorMatchTest {
verify(alarmCommonReduce, times(1)).reduceAndSendAlarm(any());
}
@Test
void testEmptyStringFieldStillTriggersAlert() throws InterruptedException {
CollectRep.MetricsData.Builder builder = CollectRep.MetricsData.newBuilder();
builder.setId(518679137103104L)
.setApp("fullsite")
.setMetrics("summary")
.setPriority(1)
.setCode(CollectRep.Code.SUCCESS);
CollectRep.Field url = CollectRep.Field.newBuilder().setName("url").setType(CommonConstants.TYPE_STRING).setLabel(true).build();
CollectRep.Field statusCode = CollectRep.Field.newBuilder().setName("statusCode").setType(CommonConstants.TYPE_STRING).build();
CollectRep.Field errorMsg = CollectRep.Field.newBuilder().setName("errorMsg").setType(CommonConstants.TYPE_STRING).build();
Map<String, String> meta = new HashMap<>();
meta.put(MetricDataConstants.INSTANCE_NAME, "site");
meta.put(MetricDataConstants.INSTANCE, "127.0.0.1");
builder.addMetadataAll(meta);
builder.addAllFields(Lists.newArrayList(url, statusCode, errorMsg));
builder.addValueRow(CollectRep.ValueRow.newBuilder()
.addColumn("https://example.com/broken").addColumn("404").addColumn("").build());
CollectRep.MetricsData metricsData = builder.build();
AlertDefine matchDefine = new AlertDefine();
matchDefine.setId(1L);
matchDefine.setName("sitemap-status");
matchDefine.setExpr("equals(__app__,\"fullsite\") && !matches(statusCode,\"^2[0-9]+\") && !contains(errorMsg,\"timed out\")");
matchDefine.setTemplate("site down: ${url}");
matchDefine.setTimes(1);
when(alertDefineService.getMetricsRealTimeAlertDefines()).thenReturn(Collections.singletonList(matchDefine));
when(dataQueue.pollMetricsDataToAlerter()).thenReturn(metricsData).thenThrow(new InterruptedException());
metricsRealTimeAlertCalculator.startCalculate();
Thread.sleep(3000);
ArgumentCaptor<SingleAlert> alertCaptor = ArgumentCaptor.forClass(SingleAlert.class);
verify(alarmCommonReduce, times(1)).reduceAndSendAlarm(alertCaptor.capture());
assertEquals("site down: https://example.com/broken", alertCaptor.getValue().getContent());
}
@Test
void testUnparseableNumberFieldDoesNotAbortRule() throws InterruptedException {
CollectRep.MetricsData.Builder builder = CollectRep.MetricsData.newBuilder();
builder.setId(518679137103105L)
.setApp("fullsite")
.setMetrics("summary")
.setPriority(1)
.setCode(CollectRep.Code.SUCCESS);
CollectRep.Field url = CollectRep.Field.newBuilder().setName("url").setType(CommonConstants.TYPE_STRING).setLabel(true).build();
CollectRep.Field responseTime = CollectRep.Field.newBuilder().setName("responseTime").setType(CommonConstants.TYPE_NUMBER).build();
Map<String, String> meta = new HashMap<>();
meta.put(MetricDataConstants.INSTANCE_NAME, "site");
meta.put(MetricDataConstants.INSTANCE, "127.0.0.1");
builder.addMetadataAll(meta);
builder.addAllFields(Lists.newArrayList(url, responseTime));
builder.addValueRow(CollectRep.ValueRow.newBuilder()
.addColumn("https://example.com/a").addColumn("").build());
CollectRep.MetricsData metricsData = builder.build();
AlertDefine guardedDefine = new AlertDefine();
guardedDefine.setId(2L);
guardedDefine.setName("slow-site");
guardedDefine.setExpr("equals(__app__,\"fullsite\") && exists(responseTime) && responseTime > 100");
guardedDefine.setTemplate("slow: ${url}");
guardedDefine.setTimes(1);
when(alertDefineService.getMetricsRealTimeAlertDefines()).thenReturn(Collections.singletonList(guardedDefine));
when(dataQueue.pollMetricsDataToAlerter()).thenReturn(metricsData).thenThrow(new InterruptedException());
metricsRealTimeAlertCalculator.startCalculate();
Thread.sleep(3000);
// unparseable number is defined as null: exists() short-circuits to false, no alarm and no abort
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any());
verify(alarmCacheManager, times(1)).removeFiring(any(), any());
}
}
@@ -31,6 +31,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.apache.hertzbeat.alert.service.impl.NoticeConfigServiceImpl;
import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
@@ -174,6 +175,7 @@ class NoticeConfigControllerTest {
NoticeReceiver receiver1 = new NoticeReceiver();
receiver1.setId(1L);
receiver1.setName("Receiver1");
receiver1.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM");
NoticeReceiver receiver2 = new NoticeReceiver();
receiver2.setId(2L);
@@ -197,6 +199,7 @@ class NoticeConfigControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.content[0].id").value(1))
.andExpect(jsonPath("$.data.content[0].name").value("Receiver1"))
.andExpect(jsonPath("$.data.content[0].tgBotToken").value(NoticeReceiverMaskUtil.SECRET_MASK + "voJM"))
.andExpect(jsonPath("$.data.content[1].id").value(2))
.andExpect(jsonPath("$.data.content[1].name").value("Receiver2"))
.andExpect(jsonPath("$.data.totalElements").value(2))
@@ -222,6 +225,8 @@ class NoticeConfigControllerTest {
this.mockMvc.perform(MockMvcRequestBuilders.get("/api/notice/receiver/{id}", 7565463543L))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.accessToken").value(NoticeReceiverMaskUtil.SECRET_MASK + "739d"))
.andExpect(jsonPath("$.data.email").value("2762242004@qq.com"))
.andReturn();
}
@@ -17,31 +17,38 @@
package org.apache.hertzbeat.alert.notice;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyByte;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.alert.AlerterWorkerPool;
import org.apache.hertzbeat.alert.config.AlertSseManager;
import org.apache.hertzbeat.alert.service.NoticeConfigService;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.plugin.runner.PluginRunner;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
/**
* Test case for Alert Notice Dispatch
@@ -180,4 +187,155 @@ class AlertNoticeDispatchTest {
verify(alertNotifyHandler).send(eq(receiver), eq(template), eq(alert));
verify(emitterManager).broadcast(any(String.class));
}
@Test
void testDispatchAlarmRecomputesNoticeFromAlertsMatchingRuleLabels() {
LocalDateTime matchingCreated = LocalDateTime.of(2026, 7, 30, 10, 0);
LocalDateTime matchingUpdated = LocalDateTime.of(2026, 7, 30, 10, 5);
SingleAlert matchingAlert = SingleAlert.builder()
.fingerprint("matching")
.labels(Map.of("department", "algorithm", "service", "checkout", "severity", "warning"))
.annotations(Map.of("summary", "algorithm summary", "runbook", "shared runbook"))
.content("matching-content")
.status("resolved")
.gmtCreate(matchingCreated)
.gmtUpdate(matchingUpdated)
.build();
SingleAlert unrelatedAlert = SingleAlert.builder()
.fingerprint("unrelated")
.labels(Map.of("department", "infra", "service", "checkout", "severity", "critical"))
.annotations(Map.of("summary", "infra summary", "runbook", "shared runbook"))
.content("unrelated-content")
.status("firing")
.gmtCreate(matchingCreated.minusHours(1))
.gmtUpdate(matchingUpdated.plusHours(1))
.build();
GroupAlert groupedAlert = GroupAlert.builder()
.id(2L)
.groupKey("department:infra,service:checkout")
.status("firing")
.groupLabels(Map.of("department", "infra", "service", "checkout"))
.commonLabels(Map.of("service", "checkout"))
.commonAnnotations(Map.of("runbook", "shared runbook"))
.alertFingerprints(List.of("matching", "unrelated"))
.gmtCreate(matchingCreated.minusHours(1))
.gmtUpdate(matchingUpdated.plusHours(1))
.alerts(List.of(matchingAlert, unrelatedAlert))
.build();
NoticeTemplate template = NoticeTemplate.builder().id(1L).build();
NoticeRule rule = NoticeRule.builder()
.filterAll(false)
.labels(Map.of("department", "algorithm"))
.receiverId(List.of(1L))
.templateId(1L)
.build();
when(alertStoreHandler.store(groupedAlert)).thenReturn(groupedAlert);
when(noticeConfigService.getReceiverFilterRule(groupedAlert)).thenReturn(List.of(rule));
when(noticeConfigService.getReceiverById(1L)).thenReturn(receiver);
when(noticeConfigService.getOneTemplateById(1L)).thenReturn(template);
doAnswer(invocation -> {
Runnable task = invocation.getArgument(1);
task.run();
return null;
}).when(workerPool).executeNotify(anyByte(), any(Runnable.class));
alertNoticeDispatch.dispatchAlarm(groupedAlert);
ArgumentCaptor<GroupAlert> noticeAlert = ArgumentCaptor.forClass(GroupAlert.class);
verify(alertNotifyHandler).send(eq(receiver), eq(template), noticeAlert.capture());
GroupAlert scopedAlert = noticeAlert.getValue();
assertAll(
() -> assertEquals(List.of(matchingAlert), scopedAlert.getAlerts()),
() -> assertEquals(List.of("matching"), scopedAlert.getAlertFingerprints()),
() -> assertEquals("resolved", scopedAlert.getStatus()),
() -> assertEquals(
Map.of("department", "algorithm", "service", "checkout"),
scopedAlert.getGroupLabels()),
() -> assertEquals(
Map.of("department", "algorithm", "service", "checkout", "severity", "warning"),
scopedAlert.getCommonLabels()),
() -> assertEquals(
Map.of("summary", "algorithm summary", "runbook", "shared runbook"),
scopedAlert.getCommonAnnotations()),
() -> assertEquals("department:algorithm,service:checkout", scopedAlert.getGroupKey()),
() -> assertEquals(matchingCreated, scopedAlert.getGmtCreate()),
() -> assertEquals(matchingUpdated, scopedAlert.getGmtUpdate()),
() -> assertEquals(2, groupedAlert.getAlerts().size()),
() -> assertEquals("firing", groupedAlert.getStatus()),
() -> assertEquals(Map.of("service", "checkout"), groupedAlert.getCommonLabels()));
}
@Test
void testDispatchAlarmScopesMultipleRulesForTheSameReceiverIndependently() {
SingleAlert algorithmAlert = SingleAlert.builder()
.fingerprint("algorithm")
.labels(Map.of("department", "algorithm", "service", "checkout"))
.annotations(Map.of("summary", "algorithm firing", "runbook", "algorithm runbook"))
.status("firing")
.build();
SingleAlert algorithmResolvedAlert = SingleAlert.builder()
.fingerprint("algorithm-resolved")
.labels(Map.of("department", "algorithm", "service", "checkout"))
.annotations(Map.of("summary", "algorithm resolved", "runbook", "algorithm runbook"))
.status("resolved")
.build();
SingleAlert infrastructureAlert = SingleAlert.builder()
.fingerprint("infra")
.labels(Map.of("department", "infra", "service", "checkout"))
.annotations(Map.of("summary", "infra summary"))
.status("resolved")
.build();
GroupAlert groupedAlert = GroupAlert.builder()
.status("firing")
.groupLabels(Map.of("service", "checkout"))
.alerts(List.of(algorithmAlert, algorithmResolvedAlert, infrastructureAlert))
.build();
NoticeTemplate algorithmTemplate = NoticeTemplate.builder().id(1L).build();
NoticeTemplate infrastructureTemplate = NoticeTemplate.builder().id(2L).build();
NoticeRule algorithmRule = NoticeRule.builder()
.filterAll(false)
.labels(Map.of("department", "algorithm"))
.receiverId(List.of(1L))
.templateId(1L)
.build();
NoticeRule infrastructureRule = NoticeRule.builder()
.filterAll(false)
.labels(Map.of("department", "infra"))
.receiverId(List.of(1L))
.templateId(2L)
.build();
when(alertStoreHandler.store(groupedAlert)).thenReturn(groupedAlert);
when(noticeConfigService.getReceiverFilterRule(groupedAlert))
.thenReturn(List.of(algorithmRule, infrastructureRule));
when(noticeConfigService.getReceiverById(1L)).thenReturn(receiver);
when(noticeConfigService.getOneTemplateById(1L)).thenReturn(algorithmTemplate);
when(noticeConfigService.getOneTemplateById(2L)).thenReturn(infrastructureTemplate);
doAnswer(invocation -> {
Runnable task = invocation.getArgument(1);
task.run();
return null;
}).when(workerPool).executeNotify(anyByte(), any(Runnable.class));
alertNoticeDispatch.dispatchAlarm(groupedAlert);
ArgumentCaptor<NoticeTemplate> templates = ArgumentCaptor.forClass(NoticeTemplate.class);
ArgumentCaptor<GroupAlert> alerts = ArgumentCaptor.forClass(GroupAlert.class);
verify(alertNotifyHandler, times(2)).send(eq(receiver), templates.capture(), alerts.capture());
assertAll(
() -> assertEquals(List.of(algorithmTemplate, infrastructureTemplate), templates.getAllValues()),
() -> assertEquals(
List.of("algorithm", "algorithm-resolved"),
alerts.getAllValues().get(0).getAlertFingerprints()),
() -> assertEquals(List.of("infra"), alerts.getAllValues().get(1).getAlertFingerprints()),
() -> assertEquals("firing", alerts.getAllValues().get(0).getStatus()),
() -> assertEquals("resolved", alerts.getAllValues().get(1).getStatus()),
() -> assertEquals(
Map.of("runbook", "algorithm runbook"),
alerts.getAllValues().get(0).getCommonAnnotations()),
() -> assertEquals(
Map.of("summary", "infra summary"),
alerts.getAllValues().get(1).getCommonAnnotations()));
}
}
@@ -18,6 +18,8 @@
package org.apache.hertzbeat.alert.notice.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -33,7 +35,9 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Test case for {@link DbAlertStoreHandlerImpl}
@@ -122,4 +126,17 @@ class DbAlertStoreHandlerImplTest {
assertEquals(1L, groupAlert.getId());
}
@Test
void usesBoundedLocksForExternalKeys() {
int stripeCount = DbAlertStoreHandlerImpl.LOCK_STRIPE_COUNT;
Object firstLock = DbAlertStoreHandlerImpl.lockFor("same-key");
assertSame(firstLock, DbAlertStoreHandlerImpl.lockFor("same-key"));
Set<Object> locks = new HashSet<>();
for (int index = 0; index < stripeCount * 4; index++) {
locks.add(DbAlertStoreHandlerImpl.lockFor("external-key-" + index));
}
assertTrue(locks.size() <= stripeCount);
}
}
@@ -37,11 +37,17 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.net.URI;
import java.util.ResourceBundle;
/**
@@ -93,7 +99,7 @@ class WebHookAlertNotifyHandlerImplTest {
ResponseEntity<String> responseEntity =
new ResponseEntity<>("null", HttpStatus.OK);
when(restTemplate.postForEntity(any(String.class), any(), eq(String.class))).thenReturn(responseEntity);
when(restTemplate.postForEntity(any(URI.class), any(), eq(String.class))).thenReturn(responseEntity);
webHookAlertNotifyHandler.send(receiver, template, groupAlert);
}
@@ -103,7 +109,7 @@ class WebHookAlertNotifyHandlerImplTest {
ResponseEntity<String> responseEntity =
new ResponseEntity<>("null", HttpStatus.INTERNAL_SERVER_ERROR);
when(restTemplate.postForEntity(any(String.class), any(), eq(String.class))).thenReturn(responseEntity);
when(restTemplate.postForEntity(any(URI.class), any(), eq(String.class))).thenReturn(responseEntity);
assertThrows(AlertNoticeException.class,
@@ -117,11 +123,27 @@ class WebHookAlertNotifyHandlerImplTest {
ResponseEntity<String> responseEntity =
new ResponseEntity<>("null", HttpStatus.OK);
when(restTemplate.postForEntity(eq(receiver.getHookUrl()), any(), eq(String.class))).thenReturn(responseEntity);
when(restTemplate.postForEntity(eq(URI.create(receiver.getHookUrl())), any(), eq(String.class))).thenReturn(responseEntity);
webHookAlertNotifyHandler.send(receiver, template, groupAlert);
}
@Test
public void testHookUrlWithPercentEncodedQuerySentVerbatim() {
String hookUrl = "https://example.environment.api.powerplatform.com/workflows/wf1/triggers/manual/paths/invoke"
+ "?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=UejZsrJyaZwwAm_jArn7Ze0PIf";
receiver.setHookUrl(hookUrl);
RestTemplate realRestTemplate = new RestTemplate();
MockRestServiceServer mockServer = MockRestServiceServer.createServer(realRestTemplate);
mockServer.expect(requestTo(hookUrl)).andRespond(withSuccess());
ReflectionTestUtils.setField(webHookAlertNotifyHandler, "restTemplate", realRestTemplate);
webHookAlertNotifyHandler.send(receiver, template, groupAlert);
mockServer.verify();
}
@Test
public void testNotifyAlertWithNullOrEmptyUrl() {
// Test null URL
@@ -145,7 +167,7 @@ class WebHookAlertNotifyHandlerImplTest {
ResponseEntity<String> responseEntity =
new ResponseEntity<>("null", HttpStatus.OK);
when(restTemplate.postForEntity(any(String.class), any(), eq(String.class))).thenReturn(responseEntity);
when(restTemplate.postForEntity(any(URI.class), any(), eq(String.class))).thenReturn(responseEntity);
// Test various valid URLs that should work
receiver.setHookUrl("https://hooks.slack.com/services/T123/B456/complete-token");
@@ -170,7 +192,7 @@ class WebHookAlertNotifyHandlerImplTest {
ResponseEntity<String> responseEntity =
new ResponseEntity<>("null", HttpStatus.OK);
when(restTemplate.postForEntity(eq(receiver.getHookUrl()), any(), eq(String.class))).thenReturn(responseEntity);
when(restTemplate.postForEntity(eq(URI.create(receiver.getHookUrl())), any(), eq(String.class))).thenReturn(responseEntity);
webHookAlertNotifyHandler.send(receiver, template, groupAlert);
@@ -17,6 +17,7 @@
package org.apache.hertzbeat.alert.reduce;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -28,6 +29,7 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.config.VirtualThreadProperties;
import org.junit.jupiter.api.AfterEach;
@@ -85,6 +87,28 @@ class AlarmCommonReduceTest {
assertTrue(virtualThread.get());
}
@Test
void durableFingerprintShouldRemainIndependentFromCalculatorCacheKey() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<String> durableFingerprint = new AtomicReference<>();
doAnswer(invocation -> {
durableFingerprint.set(invocation.getArgument(0, SingleAlert.class).getFingerprint());
latch.countDown();
return null;
}).when(alarmGroupReduce).processGroupAlert(any(SingleAlert.class));
SingleAlert alert = SingleAlert.builder()
.labels(new HashMap<>(Map.of(
"instance", "db-1",
"alertname", "disk_full",
"timestamp", "not-part-of-identity")))
.build();
alarmCommonReduce.reduceAndSendAlarm(alert);
assertTrue(latch.await(5, TimeUnit.SECONDS));
assertEquals("alertname:disk_full,instance:db-1", durableFingerprint.get());
}
@Test
void testReduceAndSendAlarmQueuesWhenConcurrencyLimitReached() throws Exception {
VirtualThreadProperties properties = new VirtualThreadProperties(
@@ -17,25 +17,6 @@
* under the License.
*/
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.apache.hertzbeat.alert.reduce;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -17,25 +17,6 @@
* under the License.
*/
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.apache.hertzbeat.alert.reduce;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -69,6 +69,7 @@ public class AlertDefineExcelImExportServiceTest {
row.createCell(6).setCellValue(JsonUtil.toJson(Map.of("key", "value")));
row.createCell(7).setCellValue("template1");
row.createCell(8).setCellValue(true);
row.createCell(9).setCellValue("promql");
ByteArrayInputStream inputStream = new ByteArrayInputStream(toByteArray(initialWorkbook));
@@ -93,6 +94,7 @@ public class AlertDefineExcelImExportServiceTest {
assertEquals(Map.of("key", "value"), alertDefineDTO.getAnnotations());
assertEquals("template1", alertDefineDTO.getTemplate());
assertTrue(alertDefineDTO.getEnable());
assertEquals("promql", alertDefineDTO.getDatasource());
}
}
@@ -111,6 +113,7 @@ public class AlertDefineExcelImExportServiceTest {
alertDefineDTO.setAnnotations(Map.of("key", "value"));
alertDefineDTO.setTemplate("template1");
alertDefineDTO.setEnable(true);
alertDefineDTO.setDatasource("promql");
exportAlertDefineDTO.setAlertDefine(alertDefineDTO);
exportAlertDefineList.add(exportAlertDefineDTO);
@@ -129,6 +132,7 @@ public class AlertDefineExcelImExportServiceTest {
assertEquals("Annotations", headerRow.getCell(6).getStringCellValue());
assertEquals("Template", headerRow.getCell(7).getStringCellValue());
assertEquals("Enable", headerRow.getCell(8).getStringCellValue());
assertEquals("Datasource", headerRow.getCell(9).getStringCellValue());
Row dataRow = resultSheet.getRow(1);
assertEquals("app1", dataRow.getCell(0).getStringCellValue());
@@ -140,6 +144,7 @@ public class AlertDefineExcelImExportServiceTest {
assertEquals(JsonUtil.toJson(Map.of("key", "value")), dataRow.getCell(6).getStringCellValue());
assertEquals("template1", dataRow.getCell(7).getStringCellValue());
assertTrue(dataRow.getCell(8).getBooleanCellValue());
assertEquals("promql", dataRow.getCell(9).getStringCellValue());
}
}
}
@@ -21,6 +21,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
@@ -29,8 +31,10 @@ import java.util.List;
import org.apache.hertzbeat.alert.dto.AlertDefineDTO;
import org.apache.hertzbeat.alert.dto.ExportAlertDefineDTO;
import org.apache.hertzbeat.alert.service.impl.AlertDefineJsonImExportServiceImpl;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
/**
* test case for {@link AlertDefineJsonImExportServiceImpl}
@@ -43,7 +47,7 @@ class AlertDefineJsonImExportServiceTest {
@SuppressWarnings("checkstyle:OperatorWrap")
private static final String JSON_DATA = "[{\"alertDefine\":{\"name\":\"App1\",\"type\":\"realtime\"," +
"\"expr\":\"Expr1\",\"period\":3000,\"times\":3," +
"\"enable\":true,\"template\":\"Template1\"}}]";
"\"enable\":true,\"template\":\"Template1\",\"datasource\":\"promql\"}}]";
private InputStream inputStream;
private List<ExportAlertDefineDTO> alertDefineList;
@@ -77,6 +81,7 @@ class AlertDefineJsonImExportServiceTest {
assertEquals(1, result.size());
assertEquals("App1", result.get(0).getAlertDefine().getName());
assertEquals("realtime", result.get(0).getAlertDefine().getType());
assertEquals("promql", result.get(0).getAlertDefine().getDatasource());
}
@Test
@@ -100,6 +105,29 @@ class AlertDefineJsonImExportServiceTest {
assertTrue(result.contains("realtime"));
}
@Test
void testExportKeepsDatasource() {
AlertDefineService alertDefineService = mock(AlertDefineService.class);
AlertDefine define = AlertDefine.builder()
.name("test")
.type("periodic_metric")
.expr("cpu_usage{instance=\"server1\"} > 80")
.datasource("promql")
.period(300)
.times(3)
.template("test")
.enable(true)
.build();
when(alertDefineService.getAlertDefine(1L)).thenReturn(define);
ReflectionTestUtils.setField(service, "alertDefineService", alertDefineService);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
service.exportConfig(outputStream, List.of(1L));
String result = outputStream.toString(StandardCharsets.UTF_8);
assertTrue(result.contains("promql"), "exported config should keep datasource, but got: " + result);
}
@Test
void testType() {
assertEquals("JSON", service.type());
@@ -64,6 +64,7 @@ class AlertDefineYamlImExportServiceTest {
times: 3
enable: true
template: Template1
datasource: promql
""";
private InputStream inputStream;
@@ -82,6 +83,7 @@ class AlertDefineYamlImExportServiceTest {
alertDefine.setExpr("Expr1");
alertDefine.setEnable(true);
alertDefine.setTemplate("Template1");
alertDefine.setDatasource("promql");
ExportAlertDefineDTO exportAlertDefine = new ExportAlertDefineDTO();
exportAlertDefine.setAlertDefine(alertDefine);
@@ -135,6 +137,7 @@ class AlertDefineYamlImExportServiceTest {
assertTrue(yamlOutput.contains("name: App1"));
assertTrue(yamlOutput.contains("type: realtime"));
assertTrue(yamlOutput.contains("expr: Expr1"));
assertTrue(yamlOutput.contains("datasource: promql"));
}
@Test
@@ -0,0 +1,180 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.alert.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import java.time.Instant;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.impl.AlibabaCloudCmsExternAlertService;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Unit test for {@link AlibabaCloudCmsExternAlertService}.
*/
@ExtendWith(MockitoExtension.class)
class AlibabaCloudCmsExternAlertServiceTest {
private static final long EVENT_TIME = 1785300000123L;
@Mock
private AlarmCommonReduce alarmCommonReduce;
private AlibabaCloudCmsExternAlertService externAlertService;
@BeforeEach
void setUp() {
externAlertService = new AlibabaCloudCmsExternAlertService(alarmCommonReduce);
}
@Test
void shouldConvertTriggeredAlert() {
externAlertService.addExternAlert("""
{
"specversion": "1.0",
"id": "alert-event-1",
"type": "ALERT",
"subtype": "NORMAL_TRIGGER",
"time": "2026-07-29T06:00:00Z",
"timestamp": 1785300000123,
"subject": "ECS CPU usage is high",
"severity": "WARNING",
"status": "OCCURRED",
"userId": "123456",
"ruleId": "rule-1",
"workspace": "default-cms-123456-cn-hangzhou",
"traceId": "trace-1",
"alertMessage": "CPU usage exceeded 80%",
"alertEntityId": "ecs:i-123",
"resource": {
"entity": {
"domain": "ecs",
"entity_type": "instance",
"entity_id": "i-123",
"prop": {
"instanceName": "api-server"
}
},
"tags": {
"regionId": "cn-hangzhou",
"environment": "production"
}
},
"labels": {
"_cms_region": "cn-hangzhou",
"customNumber": 7
},
"annotations": {
"current_value": "92.5"
},
"data": {
"value": 92.5,
"threshold": 80,
"comparisonOperator": ">"
},
"alertEntityFields": {
"privateIp": "10.0.0.1"
},
"alertHistoryUrl": "https://cmsnext.console.aliyun.com/history",
"futureField": "ignored"
}
""");
SingleAlert alert = captureAlert();
assertEquals(CommonConstants.ALERT_STATUS_FIRING, alert.getStatus());
assertEquals(EVENT_TIME, alert.getStartAt());
assertEquals(EVENT_TIME, alert.getActiveAt());
assertNull(alert.getEndAt());
assertEquals("CPU usage exceeded 80%", alert.getContent());
assertEquals("alibabacloud-cms", alert.getLabels().get("__source__"));
assertEquals("ECS CPU usage is high", alert.getLabels().get("alertname"));
assertEquals(CommonConstants.ALERT_SEVERITY_WARNING, alert.getLabels().get("severity"));
assertEquals("instance", alert.getLabels().get("resourceType"));
assertEquals("i-123", alert.getLabels().get("resourceId"));
assertEquals("7", alert.getLabels().get("customNumber"));
assertEquals("api-server", alert.getAnnotations().get("instanceName"));
assertEquals("92.5", alert.getAnnotations().get("value"));
assertEquals("80", alert.getAnnotations().get("threshold"));
assertEquals("10.0.0.1", alert.getAnnotations().get("privateIp"));
}
@Test
void shouldConvertResolvedAlertAndIsoTime() {
externAlertService.addExternAlert("""
{
"subtype": "NORMAL_RESOLVE",
"time": "2026-07-29T06:00:00Z",
"subject": "ECS CPU usage is high",
"severity": "CRITICAL",
"status": "RESOLVED",
"labels": {
"instanceId": "i-123"
}
}
""");
SingleAlert alert = captureAlert();
long expectedTime = Instant.parse("2026-07-29T06:00:00Z").toEpochMilli();
assertEquals(CommonConstants.ALERT_STATUS_RESOLVED, alert.getStatus());
assertEquals(expectedTime, alert.getStartAt());
assertNull(alert.getActiveAt());
assertEquals(expectedTime, alert.getEndAt());
assertEquals("ECS CPU usage is high", alert.getContent());
assertEquals(CommonConstants.ALERT_SEVERITY_CRITICAL, alert.getLabels().get("severity"));
}
@Test
void shouldTreatRecoveredStatusAsResolved() {
externAlertService.addExternAlert("""
{
"timestamp": 1785300000123,
"subject": "Recovered alert",
"status": "RECOVERED"
}
""");
SingleAlert alert = captureAlert();
assertEquals(CommonConstants.ALERT_STATUS_RESOLVED, alert.getStatus());
assertEquals(EVENT_TIME, alert.getEndAt());
}
@Test
void shouldIgnoreInvalidPayload() {
externAlertService.addExternAlert("invalid json");
externAlertService.addExternAlert("{\"subject\":\"missing status\"}");
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
assertEquals("alibabacloud-cms", externAlertService.supportSource());
}
private SingleAlert captureAlert() {
ArgumentCaptor<SingleAlert> captor = ArgumentCaptor.forClass(SingleAlert.class);
verify(alarmCommonReduce).reduceAndSendAlarm(captor.capture());
return captor.getValue();
}
}
@@ -22,6 +22,7 @@ import org.apache.hertzbeat.alert.dao.NoticeRuleDao;
import org.apache.hertzbeat.alert.dao.NoticeTemplateDao;
import org.apache.hertzbeat.alert.notice.AlertNoticeDispatch;
import org.apache.hertzbeat.alert.service.impl.NoticeConfigServiceImpl;
import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil;
import org.apache.hertzbeat.common.cache.CacheFactory;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
@@ -48,12 +49,15 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -211,11 +215,105 @@ class NoticeConfigServiceTest {
@Test
void editReceiver() {
final NoticeReceiver noticeReceiver = mock(NoticeReceiver.class);
final NoticeReceiver noticeReceiver = new NoticeReceiver();
noticeReceiver.setId(5L);
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(noticeReceiver));
noticeConfigService.editReceiver(noticeReceiver);
verify(noticeReceiverDao, times(1)).save(noticeReceiver);
}
@Test
void editReceiverKeepsStoredSecretWhenMasked() {
final NoticeReceiver stored = new NoticeReceiver();
stored.setId(5L);
stored.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM");
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(stored));
final NoticeReceiver incoming = new NoticeReceiver();
incoming.setId(5L);
incoming.setTgBotToken(NoticeReceiverMaskUtil.SECRET_MASK + "voJM");
noticeConfigService.editReceiver(incoming);
assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", incoming.getTgBotToken());
verify(noticeReceiverDao, times(1)).save(incoming);
}
@Test
void editReceiverRejectsUnknownId() {
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.empty());
final NoticeReceiver incoming = new NoticeReceiver();
incoming.setId(5L);
incoming.setTgBotToken(NoticeReceiverMaskUtil.SECRET_MASK + "voJM");
assertThrows(IllegalArgumentException.class, () -> noticeConfigService.editReceiver(incoming));
verify(noticeReceiverDao, never()).save(any());
}
@Test
void sendTestMsgResolvesMaskedSecret() {
final NoticeReceiver stored = new NoticeReceiver();
stored.setId(5L);
stored.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM");
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(stored));
final NoticeReceiver incoming = new NoticeReceiver();
incoming.setId(5L);
incoming.setTgBotToken(NoticeReceiverMaskUtil.SECRET_MASK + "voJM");
noticeConfigService.sendTestMsg(incoming);
assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", incoming.getTgBotToken());
}
@Test
void sendTestMsgRejectsUnknownId() {
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.empty());
final NoticeReceiver incoming = new NoticeReceiver();
incoming.setId(5L);
incoming.setTgBotToken(NoticeReceiverMaskUtil.SECRET_MASK + "voJM");
assertThrows(IllegalArgumentException.class, () -> noticeConfigService.sendTestMsg(incoming));
verify(dispatcherAlarm, never()).sendNoticeMsg(any(), any(), any());
}
@Test
void sendTestMsgRejectsMaskedSecretReplayToChangedWebhookUrl() {
final NoticeReceiver stored = new NoticeReceiver();
stored.setId(5L);
stored.setType((byte) 2);
stored.setHookUrl("https://trusted.example/hook");
stored.setHookAuthToken("hook-auth-token-abcd");
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(stored));
final NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(stored);
incoming.setHookUrl("https://attacker.example/collect");
assertThrows(IllegalArgumentException.class, () -> noticeConfigService.sendTestMsg(incoming));
verify(dispatcherAlarm, never()).sendNoticeMsg(any(), any(), any());
}
@Test
void sendTestMsgRejectsBareMaskReplayToChangedWebhookUrl() {
final NoticeReceiver stored = new NoticeReceiver();
stored.setId(5L);
stored.setType((byte) 2);
stored.setHookUrl("https://trusted.example/hook");
stored.setHookAuthToken("hook-auth-token-abcd");
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(stored));
final NoticeReceiver incoming = new NoticeReceiver();
incoming.setId(5L);
incoming.setType((byte) 2);
incoming.setHookUrl("https://attacker.example/collect");
incoming.setHookAuthToken(NoticeReceiverMaskUtil.SECRET_MASK);
assertThrows(IllegalArgumentException.class, () -> noticeConfigService.sendTestMsg(incoming));
verify(dispatcherAlarm, never()).sendNoticeMsg(any(), any(), any());
}
@Test
void deleteReceiver() {
final Long receiverId = 23342525L;
@@ -288,7 +386,7 @@ class NoticeConfigServiceTest {
@Test
void sendTestMsg() {
final NoticeReceiver noticeReceiver = mock(NoticeReceiver.class);
final NoticeReceiver noticeReceiver = new NoticeReceiver();
final NoticeTemplate noticeTemplate = null;
noticeConfigService.sendTestMsg(noticeReceiver);
verify(dispatcherAlarm, times(1)).sendNoticeMsg(eq(noticeReceiver), eq(noticeTemplate), any(GroupAlert.class));
@@ -0,0 +1,300 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.alert.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.Map;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.dto.sms.AlibabaSmsProperties;
import org.apache.hertzbeat.common.entity.dto.sms.AwsSmsProperties;
import org.apache.hertzbeat.common.entity.dto.sms.SmslocalSmsProperties;
import org.apache.hertzbeat.common.entity.dto.sms.TencentSmsProperties;
import org.apache.hertzbeat.common.entity.dto.sms.TwilioSmsProperties;
import org.apache.hertzbeat.common.entity.dto.sms.UniSmsProperties;
import org.apache.hertzbeat.common.support.exception.SendMessageException;
import org.apache.http.StatusLine;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedStatic;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
/**
* Verifies that SMS clients do not write request credentials or message data to logs.
*/
@ExtendWith(OutputCaptureExtension.class)
class SmsClientLoggingTest {
private static final String ACCESS_KEY = "access-key-log-sentinel";
private static final String PHONE = "15555550123";
private static final String ALERT_CONTENT = "alert-content-log-sentinel";
private static final String PROVIDER_BODY = "provider-body-log-sentinel";
private static final String SIGNED_URL = "https://provider.invalid/send?Signature=signed-url-log-sentinel";
@Test
void requestCredentialsAndMessageDataShouldNotBeLogged(CapturedOutput output) throws Exception {
NoticeReceiver receiver = receiver();
GroupAlert alert = alert();
AwsSmsProperties awsProperties = new AwsSmsProperties();
awsProperties.setAccessKeyId(ACCESS_KEY);
awsProperties.setAccessKeySecret("aws-secret");
awsProperties.setRegion("us-east-1");
withSuccessfulResponse("{\"MessageId\":\"message-id\"}",
() -> new AwsSmsClientImpl(awsProperties).sendMessage(receiver, null, alert));
AlibabaSmsProperties alibabaProperties =
new AlibabaSmsProperties(ACCESS_KEY, "alibaba-secret", "sign", "template");
withSuccessfulResponse("{\"Code\":\"OK\"}",
() -> new AlibabaSmsClientImpl(alibabaProperties).sendMessage(receiver, null, alert));
UniSmsProperties uniProperties =
new UniSmsProperties(ACCESS_KEY, "unisms-secret", "sign", "template", "hmac");
withSuccessfulResponse("{\"code\":\"0\"}",
() -> new UniSmsClientImpl(uniProperties).sendMessage(receiver, null, alert));
withSuccessfulResponse("{\"sid\":\"message-id\"}",
() -> new TwilioSmsClientImpl(twilioProperties()).sendMessage(receiver, null, alert));
withSuccessfulResponse("{\"Response\":{\"SendStatusSet\":[{\"Code\":\"Ok\"}]}}",
() -> new TencentSmsClientImpl(tencentProperties()).sendMessage(receiver, null, alert));
withSuccessfulResponse("[{\"errorCode\":\"200\",\"id\":\"message-id\"}]",
() -> new SmsLocalSmsClientImpl(smslocalProperties()).sendMessage(receiver, null, alert));
String logs = output.getAll();
assertFalse(logs.contains(ACCESS_KEY));
assertFalse(logs.contains(PHONE));
assertFalse(logs.contains(ALERT_CONTENT));
assertFalse(logs.contains("Authorization"));
assertFalse(logs.contains("Signature="));
}
@Test
void failedResponsesExposeOnlyProviderAndHttpStatus(CapturedOutput output) throws Exception {
String body = "{\"message\":\"" + PROVIDER_BODY + "\",\"phone\":\"" + PHONE + "\"}";
SendMessageException awsFailure = withResponse(503, body,
() -> new AwsSmsClientImpl(awsProperties()).sendMessage(receiver(), null, alert()));
SendMessageException alibabaFailure = withResponse(502, body,
() -> new AlibabaSmsClientImpl(alibabaProperties()).sendMessage(receiver(), null, alert()));
SendMessageException uniFailure = withResponse(429, body,
() -> new UniSmsClientImpl(uniProperties()).sendMessage(receiver(), null, alert()));
SendMessageException twilioFailure = withResponse(429, body,
() -> new TwilioSmsClientImpl(twilioProperties()).sendMessage(receiver(), null, alert()));
SendMessageException tencentFailure = withResponse(429, body,
() -> new TencentSmsClientImpl(tencentProperties()).sendMessage(receiver(), null, alert()));
SendMessageException smslocalFailure = withResponse(429, body,
() -> new SmsLocalSmsClientImpl(smslocalProperties()).sendMessage(receiver(), null, alert()));
assertEquals("AWS SMS request failed with HTTP status 503", awsFailure.getMessage());
assertEquals("Alibaba Cloud SMS request failed with HTTP status 502", alibabaFailure.getMessage());
assertEquals("UniSMS request failed with HTTP status 429", uniFailure.getMessage());
assertEquals("Twilio SMS request failed with HTTP status 429", twilioFailure.getMessage());
assertEquals("Tencent Cloud SMS request failed with HTTP status 429", tencentFailure.getMessage());
assertEquals("SMSLocal request failed with HTTP status 429", smslocalFailure.getMessage());
assertNoSensitiveSentinels(output.getAll()
+ awsFailure.getMessage()
+ alibabaFailure.getMessage()
+ uniFailure.getMessage()
+ twilioFailure.getMessage()
+ tencentFailure.getMessage()
+ smslocalFailure.getMessage());
}
@Test
void providerErrorsDoNotExposeProviderMessages(CapturedOutput output) throws Exception {
SendMessageException alibabaFailure = withResponse(
200,
"{\"Code\":\"THROTTLED\",\"Message\":\"" + PROVIDER_BODY + "\"}",
() -> new AlibabaSmsClientImpl(alibabaProperties()).sendMessage(receiver(), null, alert()));
SendMessageException uniFailure = withResponse(
200,
"{\"code\":\"RATE_LIMITED\",\"message\":\"" + PROVIDER_BODY + "\"}",
() -> new UniSmsClientImpl(uniProperties()).sendMessage(receiver(), null, alert()));
SendMessageException awsFailure = withResponse(
200,
"{\"message\":\"" + PROVIDER_BODY + "\"}",
() -> new AwsSmsClientImpl(awsProperties()).sendMessage(receiver(), null, alert()));
SendMessageException twilioFailure = withResponse(
400,
"{\"code\":21608,\"message\":\"" + PROVIDER_BODY + "\"}",
() -> new TwilioSmsClientImpl(twilioProperties()).sendMessage(receiver(), null, alert()));
SendMessageException tencentFailure = withResponse(
200,
"{\"Response\":{\"Error\":{\"Code\":\"THROTTLED\",\"Message\":\""
+ PROVIDER_BODY + "\"}}}",
() -> new TencentSmsClientImpl(tencentProperties()).sendMessage(receiver(), null, alert()));
SendMessageException smslocalFailure = withResponse(
200,
"[{\"errorCode\":\"RATE_LIMITED\",\"id\":\"" + PROVIDER_BODY + "\"}]",
() -> new SmsLocalSmsClientImpl(smslocalProperties()).sendMessage(receiver(), null, alert()));
assertEquals("Alibaba Cloud SMS request failed (code: THROTTLED)", alibabaFailure.getMessage());
assertEquals("UniSMS request failed (code: RATE_LIMITED)", uniFailure.getMessage());
assertEquals("AWS SMS provider returned an invalid response", awsFailure.getMessage());
assertEquals("Twilio SMS request failed (code: 21608)", twilioFailure.getMessage());
assertEquals("Tencent Cloud SMS request failed (code: THROTTLED)", tencentFailure.getMessage());
assertEquals("SMSLocal request failed (code: RATE_LIMITED)", smslocalFailure.getMessage());
assertNoSensitiveSentinels(output.getAll()
+ alibabaFailure.getMessage()
+ uniFailure.getMessage()
+ awsFailure.getMessage()
+ twilioFailure.getMessage()
+ tencentFailure.getMessage()
+ smslocalFailure.getMessage());
}
@Test
void networkExceptionsDoNotExposeSignedUrls(CapturedOutput output) throws Exception {
SendMessageException awsFailure = withNetworkFailure(
() -> new AwsSmsClientImpl(awsProperties()).sendMessage(receiver(), null, alert()));
SendMessageException alibabaFailure = withNetworkFailure(
() -> new AlibabaSmsClientImpl(alibabaProperties()).sendMessage(receiver(), null, alert()));
SendMessageException uniFailure = withNetworkFailure(
() -> new UniSmsClientImpl(uniProperties()).sendMessage(receiver(), null, alert()));
SendMessageException twilioFailure = withNetworkFailure(
() -> new TwilioSmsClientImpl(twilioProperties()).sendMessage(receiver(), null, alert()));
SendMessageException tencentFailure = withNetworkFailure(
() -> new TencentSmsClientImpl(tencentProperties()).sendMessage(receiver(), null, alert()));
SendMessageException smslocalFailure = withNetworkFailure(
() -> new SmsLocalSmsClientImpl(smslocalProperties()).sendMessage(receiver(), null, alert()));
assertEquals("AWS SMS request failed", awsFailure.getMessage());
assertEquals("Alibaba Cloud SMS request failed", alibabaFailure.getMessage());
assertEquals("UniSMS request failed", uniFailure.getMessage());
assertEquals("Twilio SMS request failed", twilioFailure.getMessage());
assertEquals("Tencent Cloud SMS request failed", tencentFailure.getMessage());
assertEquals("SMSLocal request failed", smslocalFailure.getMessage());
assertNoSensitiveSentinels(output.getAll()
+ awsFailure.getMessage()
+ alibabaFailure.getMessage()
+ uniFailure.getMessage()
+ twilioFailure.getMessage()
+ tencentFailure.getMessage()
+ smslocalFailure.getMessage());
}
private void withSuccessfulResponse(String responseBody, Runnable operation) throws Exception {
withResponse(200, responseBody, operation, false);
}
private SendMessageException withResponse(int statusCode, String responseBody, Runnable operation)
throws Exception {
return withResponse(statusCode, responseBody, operation, true);
}
private SendMessageException withResponse(
int statusCode,
String responseBody,
Runnable operation,
boolean expectsFailure) throws Exception {
CloseableHttpClient httpClient = mock(CloseableHttpClient.class);
CloseableHttpResponse response = mock(CloseableHttpResponse.class);
StatusLine statusLine = mock(StatusLine.class);
when(statusLine.getStatusCode()).thenReturn(statusCode);
when(response.getStatusLine()).thenReturn(statusLine);
when(response.getEntity()).thenReturn(new StringEntity(responseBody, ContentType.APPLICATION_JSON));
when(httpClient.execute(any(HttpPost.class))).thenReturn(response);
try (MockedStatic<HttpClients> httpClients = mockStatic(HttpClients.class)) {
httpClients.when(HttpClients::createDefault).thenReturn(httpClient);
if (expectsFailure) {
return assertThrows(SendMessageException.class, operation::run);
}
operation.run();
return null;
}
}
private SendMessageException withNetworkFailure(Runnable operation) throws Exception {
CloseableHttpClient httpClient = mock(CloseableHttpClient.class);
when(httpClient.execute(any(HttpPost.class)))
.thenThrow(new IOException(SIGNED_URL + "&phone=" + PHONE + "&body=" + PROVIDER_BODY));
try (MockedStatic<HttpClients> httpClients = mockStatic(HttpClients.class)) {
httpClients.when(HttpClients::createDefault).thenReturn(httpClient);
return assertThrows(SendMessageException.class, operation::run);
}
}
private NoticeReceiver receiver() {
NoticeReceiver receiver = new NoticeReceiver();
receiver.setPhone(PHONE);
return receiver;
}
private GroupAlert alert() {
GroupAlert alert = new GroupAlert();
alert.setGroupKey("instance");
alert.setCommonLabels(Map.of());
alert.setCommonAnnotations(Map.of("summary", ALERT_CONTENT, "description", ALERT_CONTENT));
return alert;
}
private AwsSmsProperties awsProperties() {
AwsSmsProperties properties = new AwsSmsProperties();
properties.setAccessKeyId(ACCESS_KEY);
properties.setAccessKeySecret("aws-secret");
properties.setRegion("us-east-1");
return properties;
}
private AlibabaSmsProperties alibabaProperties() {
return new AlibabaSmsProperties(ACCESS_KEY, "alibaba-secret", "sign", "template");
}
private UniSmsProperties uniProperties() {
return new UniSmsProperties(ACCESS_KEY, "unisms-secret", "sign", "template", "hmac");
}
private TwilioSmsProperties twilioProperties() {
return new TwilioSmsProperties(ACCESS_KEY, "twilio-secret", "twilio-phone");
}
private TencentSmsProperties tencentProperties() {
return new TencentSmsProperties(ACCESS_KEY, "tencent-secret", "app-id", "sign", "template");
}
private SmslocalSmsProperties smslocalProperties() {
return new SmslocalSmsProperties(ACCESS_KEY);
}
private void assertNoSensitiveSentinels(String text) {
assertFalse(text.contains(ACCESS_KEY));
assertFalse(text.contains(PHONE));
assertFalse(text.contains(ALERT_CONTENT));
assertFalse(text.contains(PROVIDER_BODY));
assertFalse(text.contains(SIGNED_URL));
assertFalse(text.contains("signed-url-log-sentinel"));
}
}
@@ -0,0 +1,58 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.alert.service.impl;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.verify;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Test case for {@link ZabbixExternAlertServiceImpl}.
*/
@ExtendWith(MockitoExtension.class)
class ZabbixExternAlertServiceImplTest {
@Mock
private AlarmCommonReduce alarmCommonReduce;
@InjectMocks
private ZabbixExternAlertServiceImpl externAlertService;
@Test
void ignoresExternalPersistenceIdentity() {
SingleAlert incoming = SingleAlert.builder()
.id(123L)
.fingerprint("zabbix-alert")
.build();
externAlertService.addExternAlert(JsonUtil.toJson(incoming));
ArgumentCaptor<SingleAlert> alertCaptor = ArgumentCaptor.forClass(SingleAlert.class);
verify(alarmCommonReduce).reduceAndSendAlarm(alertCaptor.capture());
assertNull(alertCaptor.getValue().getId());
}
}
@@ -0,0 +1,61 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.alert.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
/**
* Test case for {@link AlertUtil}.
*/
class AlertUtilTest {
@Test
void calculateFingerprintPreservesLabelPairing() {
Map<String, String> first = new LinkedHashMap<>();
first.put("environment", "production");
first.put("team", "payments");
Map<String, String> swapped = new LinkedHashMap<>();
swapped.put("environment", "payments");
swapped.put("team", "production");
assertNotEquals(
AlertUtil.calculateFingerprint(first),
AlertUtil.calculateFingerprint(swapped));
}
@Test
void calculateFingerprintIsIndependentOfMapIterationOrder() {
Map<String, String> first = new LinkedHashMap<>();
first.put("environment", "production");
first.put("team", "payments");
Map<String, String> reversed = new LinkedHashMap<>();
reversed.put("team", "payments");
reversed.put("environment", "production");
assertEquals(
AlertUtil.calculateFingerprint(first),
AlertUtil.calculateFingerprint(reversed));
}
}
@@ -0,0 +1,194 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.alert.util;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.junit.jupiter.api.Test;
import static org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil.SECRET_MASK;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Test case for {@link NoticeReceiverMaskUtil}
*/
class NoticeReceiverMaskUtilTest {
private NoticeReceiver buildReceiverWithSecrets() {
NoticeReceiver receiver = new NoticeReceiver();
receiver.setId(1L);
receiver.setName("tom");
receiver.setEmail("tom@usthe.com");
receiver.setHookUrl("https://example.com/hook");
receiver.setHookAuthToken("hook-auth-token-abcd");
receiver.setAccessToken("c03a568a306f8fd84dab51ff03cf6af6ba676a3be940c904e1df2de34853739d");
receiver.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM");
receiver.setSlackWebHookUrl("https://hooks.slack.com/services/X/Y/Zt0k3n");
receiver.setAppSecret("oUydwn92ey0lnuY02MixNa57eNK-20dJn5NEOG-u2uE");
receiver.setDiscordBotToken("MTA2NTMwMzU0ODY4Mzg4MjUzNw.discord.t0kn");
receiver.setSmnAk("NCVBODJOEYHSW3VNSMAK");
receiver.setSmnSk("nmSNhUJN9MlpPl8lfCsgdA0KvHCL9JSMSK");
receiver.setServerChanToken("SCT193569TSNm6xIabdjqeZPtOGOWcvU1e");
receiver.setGotifyToken("A845h__ZMqDxZlO");
receiver.setNtfyToken("tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2");
return receiver;
}
@Test
void maskKeepsSuffixOfLongSecrets() {
NoticeReceiver receiver = buildReceiverWithSecrets();
NoticeReceiver masked = NoticeReceiverMaskUtil.mask(receiver);
assertEquals(SECRET_MASK + "abcd", masked.getHookAuthToken());
assertEquals(SECRET_MASK + "739d", masked.getAccessToken());
assertEquals(SECRET_MASK + "voJM", masked.getTgBotToken());
assertEquals(SECRET_MASK + "0k3n", masked.getSlackWebHookUrl());
assertEquals(SECRET_MASK + "u2uE", masked.getAppSecret());
assertEquals(SECRET_MASK + "t0kn", masked.getDiscordBotToken());
assertEquals(SECRET_MASK + "SMAK", masked.getSmnAk());
assertEquals(SECRET_MASK + "SMSK", masked.getSmnSk());
assertEquals(SECRET_MASK + "vU1e", masked.getServerChanToken());
assertEquals(SECRET_MASK + "xZlO", masked.getGotifyToken());
assertEquals(SECRET_MASK + "NIz2", masked.getNtfyToken());
assertEquals(receiver.getId(), masked.getId());
assertEquals(receiver.getName(), masked.getName());
assertEquals(receiver.getEmail(), masked.getEmail());
assertEquals(receiver.getHookUrl(), masked.getHookUrl());
}
@Test
void maskHidesShortSecretsEntirely() {
NoticeReceiver receiver = new NoticeReceiver();
receiver.setAccessToken("short-token");
receiver.setGotifyToken("tiny");
NoticeReceiver masked = NoticeReceiverMaskUtil.mask(receiver);
assertEquals(SECRET_MASK, masked.getAccessToken());
assertEquals(SECRET_MASK, masked.getGotifyToken());
}
@Test
void maskDoesNotModifyOriginalAndKeepsEmptySecrets() {
NoticeReceiver receiver = buildReceiverWithSecrets();
NoticeReceiverMaskUtil.mask(receiver);
assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", receiver.getTgBotToken());
assertEquals("nmSNhUJN9MlpPl8lfCsgdA0KvHCL9JSMSK", receiver.getSmnSk());
NoticeReceiver empty = new NoticeReceiver();
NoticeReceiver maskedEmpty = NoticeReceiverMaskUtil.mask(empty);
assertNull(maskedEmpty.getAccessToken());
assertNull(maskedEmpty.getNtfyToken());
assertNull(NoticeReceiverMaskUtil.mask(null));
}
@Test
void resolveMaskRestoresOnlyMaskedFields() {
NoticeReceiver existing = buildReceiverWithSecrets();
NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing);
incoming.setAccessToken("new-access-token-1234");
incoming.setGotifyToken(null);
NoticeReceiverMaskUtil.resolveMask(incoming, existing);
assertEquals("new-access-token-1234", incoming.getAccessToken());
assertNull(incoming.getGotifyToken());
assertEquals("tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", incoming.getNtfyToken());
assertEquals("hook-auth-token-abcd", incoming.getHookAuthToken());
assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", incoming.getTgBotToken());
assertEquals("https://hooks.slack.com/services/X/Y/Zt0k3n", incoming.getSlackWebHookUrl());
assertEquals("oUydwn92ey0lnuY02MixNa57eNK-20dJn5NEOG-u2uE", incoming.getAppSecret());
assertEquals("MTA2NTMwMzU0ODY4Mzg4MjUzNw.discord.t0kn", incoming.getDiscordBotToken());
assertEquals("NCVBODJOEYHSW3VNSMAK", incoming.getSmnAk());
assertEquals("nmSNhUJN9MlpPl8lfCsgdA0KvHCL9JSMSK", incoming.getSmnSk());
assertEquals("SCT193569TSNm6xIabdjqeZPtOGOWcvU1e", incoming.getServerChanToken());
}
@Test
void resolveMaskRejectsMaskWhenNothingIsStored() {
NoticeReceiver existing = new NoticeReceiver();
NoticeReceiver incoming = new NoticeReceiver();
incoming.setAccessToken(SECRET_MASK);
assertThrows(
IllegalArgumentException.class,
() -> NoticeReceiverMaskUtil.resolveMask(incoming, existing));
}
@Test
void resolveMaskRejectsBareMaskAsWildcard() {
NoticeReceiver existing = buildReceiverWithSecrets();
NoticeReceiver incoming = new NoticeReceiver();
incoming.setAccessToken(SECRET_MASK);
assertThrows(
IllegalArgumentException.class,
() -> NoticeReceiverMaskUtil.resolveMask(incoming, existing));
}
@Test
void resolveMaskRestoresBareMaskForShortStoredSecret() {
NoticeReceiver existing = new NoticeReceiver();
existing.setAccessToken("short-token");
NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing);
NoticeReceiverMaskUtil.resolveMask(incoming, existing);
assertEquals("short-token", incoming.getAccessToken());
}
@Test
void resolveMaskForTestRejectsMaskedWebhookSecretForChangedUrl() {
NoticeReceiver existing = buildReceiverWithSecrets();
existing.setType((byte) 2);
NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing);
incoming.setHookUrl("https://attacker.example/collect");
assertThrows(
IllegalArgumentException.class,
() -> NoticeReceiverMaskUtil.resolveMaskForTest(incoming, existing));
assertEquals(SECRET_MASK + "abcd", incoming.getHookAuthToken());
}
@Test
void resolveMaskForTestRejectsMaskedNtfySecretForChangedServer() {
NoticeReceiver existing = buildReceiverWithSecrets();
existing.setType((byte) 15);
existing.setNtfyServerUrl("https://ntfy.example");
NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing);
incoming.setNtfyServerUrl("https://attacker.example");
assertThrows(
IllegalArgumentException.class,
() -> NoticeReceiverMaskUtil.resolveMaskForTest(incoming, existing));
assertEquals(SECRET_MASK + "NIz2", incoming.getNtfyToken());
}
@Test
void resolveMaskForTestRestoresSecretForUnchangedDestination() {
NoticeReceiver existing = buildReceiverWithSecrets();
existing.setType((byte) 2);
NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing);
NoticeReceiverMaskUtil.resolveMaskForTest(incoming, existing);
assertEquals("hook-auth-token-abcd", incoming.getHookAuthToken());
}
}
@@ -151,8 +151,8 @@
<!--Bouncy Castle-->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15on</artifactId>
<version>1.68</version>
<artifactId>bcpkix-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
@@ -0,0 +1,113 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.collector.collect.common;
import java.util.Collections;
import java.util.List;
import org.apache.hertzbeat.collector.constants.CollectorConstants;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.springframework.util.StringUtils;
/**
* Shared one-row response handling for command-based collectors.
*/
public final class OneRowResponseSupport {
/**
* Parse type where each output line maps to one alias field of a single result row.
*/
public static final String PARSE_TYPE_ONE_ROW = "oneRow";
private OneRowResponseSupport() {
}
/**
* Treat blank stdout without an error signal (no stderr, exit status present and &lt;= 1,
* grep-style no match) as valid empty one-row data: append a row of null placeholders so the
* metric stays visible and alertable.
*
* @return true if handled as empty success, false if the caller should report a failure
*/
public static boolean tryAppendEmptyOneRow(String parseType, String stdErr, Integer exitStatus,
List<String> aliasFields, CollectRep.MetricsData.Builder builder,
Long responseTime) {
if (PARSE_TYPE_ONE_ROW.equals(parseType)
&& !StringUtils.hasText(stdErr)
&& exitStatus != null && exitStatus <= 1) {
appendEmptyValues(aliasFields, builder, responseTime);
return true;
}
return false;
}
/**
* Build the failure message for a command that produced no usable stdout: prefer the captured
* stderr, then a non-trivial exit status, otherwise the generic null-data message.
*/
public static String buildBlankFailureMessage(String stdErr, Integer exitStatus,
String exitCodePrefix, String nullMessage) {
if (StringUtils.hasText(stdErr)) {
return stdErr.trim();
}
if (exitStatus != null && exitStatus > 1) {
return exitCodePrefix + exitStatus;
}
return nullMessage;
}
/**
* Map each output line to one alias field of a single row; missing trailing lines become
* NULL_VALUE columns so a partial result keeps its values and the gap stays alertable.
*/
public static void appendResponseValues(String result, List<String> aliasFields,
CollectRep.MetricsData.Builder builder, Long responseTime) {
List<String> safeAliasFields = aliasFields == null ? Collections.emptyList() : aliasFields;
String[] lines = result.split("\n");
CollectRep.ValueRow.Builder valueRowBuilder = CollectRep.ValueRow.newBuilder();
int aliasIndex = 0;
int lineIndex = 0;
while (aliasIndex < safeAliasFields.size()) {
if (CollectorConstants.RESPONSE_TIME.equalsIgnoreCase(safeAliasFields.get(aliasIndex))) {
valueRowBuilder.addColumn(responseTime.toString());
} else {
if (lineIndex < lines.length) {
valueRowBuilder.addColumn(lines[lineIndex].trim());
} else {
valueRowBuilder.addColumn(CommonConstants.NULL_VALUE);
}
lineIndex++;
}
aliasIndex++;
}
builder.addValueRow(valueRowBuilder.build());
}
public static void appendEmptyValues(List<String> aliasFields, CollectRep.MetricsData.Builder builder, Long responseTime) {
List<String> safeAliasFields = aliasFields == null ? Collections.emptyList() : aliasFields;
CollectRep.ValueRow.Builder valueRowBuilder = CollectRep.ValueRow.newBuilder();
for (String aliasField : safeAliasFields) {
if (CollectorConstants.RESPONSE_TIME.equalsIgnoreCase(aliasField)) {
valueRowBuilder.addColumn(responseTime.toString());
} else {
valueRowBuilder.addColumn(CommonConstants.NULL_VALUE);
}
}
builder.addValueRow(valueRowBuilder.build());
}
}
@@ -46,6 +46,7 @@ import org.apache.hertzbeat.common.entity.job.SshTunnel;
import org.apache.hertzbeat.common.entity.job.protocol.JdbcProtocol;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.util.CommonUtil;
import org.apache.hertzbeat.common.util.JdbcUrlSafetyUtil;
import org.apache.sshd.common.SshException;
import org.apache.sshd.common.channel.exception.SshChannelOpenException;
import org.postgresql.util.PSQLException;
@@ -605,28 +606,33 @@ public class JdbcCommonCollect extends AbstractCollect {
return url;
}
assert jdbcProtocol.getPlatform() != null;
return switch (jdbcProtocol.getPlatform()) {
// the database name is concatenated into the url below, so it must not carry url syntax
String database = JdbcUrlSafetyUtil.requireSafeDatabaseName(jdbcProtocol.getDatabase());
String constructedUrl = switch (jdbcProtocol.getPlatform()) {
case "mysql", "mariadb" -> "jdbc:mysql://" + host + ":" + port
+ "/" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase())
+ "/" + database
+ "?useUnicode=true&characterEncoding=utf-8&useSSL=false";
case "xugu" -> "jdbc:xugu://" + host + ":" + port
+ "/" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase());
+ "/" + database;
case "postgresql" -> "jdbc:postgresql://" + host + ":" + port
+ "/" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase());
+ "/" + database;
case "clickhouse" -> "jdbc:clickhouse://" + host + ":" + port
+ "/" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase());
+ "/" + database;
case "sqlserver" -> "jdbc:sqlserver://" + host + ":" + port
+ ";" + (jdbcProtocol.getDatabase() == null ? "" : "DatabaseName=" + jdbcProtocol.getDatabase())
+ ";" + (database.isEmpty() ? "" : "DatabaseName=" + database)
+ ";trustServerCertificate=true;";
case "oracle" -> "jdbc:oracle:thin:@" + host + ":" + port
+ "/" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase());
+ "/" + database;
case "dm" -> "jdbc:dm://" + host + ":" + port;
case "db2" -> "jdbc:db2://" + host + ":" + port
+ "/" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase());
+ "/" + database;
case "testcontainers" -> "jdbc:tc:" + host + ":" + port
+ ":///" + (jdbcProtocol.getDatabase() == null ? "" : jdbcProtocol.getDatabase()) + "?user=root&password=root";
+ ":///" + database + "?user=root&password=root";
default -> throw new IllegalArgumentException("Not support database platform: " + jdbcProtocol.getPlatform());
};
// fail closed if any concatenated value still smuggled a driver property through
JdbcUrlSafetyUtil.requireSafeJdbcUrl(constructedUrl);
return constructedUrl;
}
private static final class ResultSetJdbcQueryRowSet implements JdbcQueryRowSet {
@@ -704,13 +704,11 @@ public class HttpCollectImpl extends AbstractCollect {
valueRowBuilder.addColumn(String.valueOf(value));
} else {
if (alias.startsWith("$.")) {
List<Object> subResults = JsonPathParser.parseContentWithJsonPath(resp, http.getParseScript() + alias.substring(1));
if (subResults != null && subResults.size() > i) {
Object resultValue = subResults.get(i);
valueRowBuilder.addColumn(resultValue == null ? CommonConstants.NULL_VALUE : String.valueOf(resultValue));
} else {
valueRowBuilder.addColumn(CommonConstants.NULL_VALUE);
}
// per-row evaluation, a global "parseScript + alias" query would misalign rows missing the path
List<Object> aliasValues = JsonPathParser.parseRowWithJsonPath(objectValue, alias);
// a wildcard alias matching multiple values is kept whole and rendered as "[v1, v2]"
Object resultValue = aliasValues.size() == 1 ? aliasValues.get(0) : (aliasValues.isEmpty() ? null : aliasValues);
valueRowBuilder.addColumn(resultValue == null ? CommonConstants.NULL_VALUE : String.valueOf(resultValue));
} else {
addColumnForSummary(responseTime, valueRowBuilder, keywordNum, alias);
}
@@ -60,16 +60,37 @@ public class OnlineParser {
}
public static Map<String, MetricFamily> parseMetrics(InputStream inputStream) throws IOException {
Map<String, MetricFamily> metricFamilyMap = new ConcurrentHashMap<>(10);
return parseMetrics(inputStream, Integer.MAX_VALUE);
}
/**
* Parses at most {@code maxSamples} samples from the supplied stream.
*
* @param inputStream The Prometheus text stream
* @param maxSamples The maximum number of samples to materialize
* @return The parsed metric families, or {@code null} when the text format is invalid
* @throws IOException When the stream cannot be read
* @throws SampleLimitExceededException When another sample follows the configured limit
*/
public static Map<String, MetricFamily> parseMetrics(InputStream inputStream, int maxSamples) throws IOException {
if (maxSamples < 0) {
throw new IllegalArgumentException("maxSamples must not be negative");
}
final Map<String, MetricFamily> metricFamilyMap = new ConcurrentHashMap<>(10);
int sampleCount = 0;
try {
int i = getChar(inputStream);
while (i != -1) {
if (i == '#' || i == '\n') {
skipToLineEnd(inputStream).maybeEol().maybeEof().noElse();
} else {
StringBuilder stringBuilder = new StringBuilder();
if (sampleCount >= maxSamples) {
throw new SampleLimitExceededException(maxSamples);
}
final StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append((char) i);
parseMetric(inputStream, metricFamilyMap, stringBuilder);
sampleCount++;
}
i = getChar(inputStream);
// To address the `\n\r` scenario, it is necessary to skip
@@ -84,6 +105,16 @@ public class OnlineParser {
return metricFamilyMap;
}
/**
* Signals that parsing stopped before materializing a sample beyond the configured limit.
*/
public static final class SampleLimitExceededException extends IOException {
public SampleLimitExceededException(int limit) {
super("prometheus payload exceeds the " + limit + " sample limit");
}
}
/**
* Parses Prometheus metrics from the given {@link InputStream}, but only for the specified metric name.
* <p>
@@ -30,6 +30,7 @@ import java.util.Objects;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.collector.collect.AbstractCollect;
import org.apache.hertzbeat.collector.collect.common.OneRowResponseSupport;
import org.apache.hertzbeat.collector.constants.CollectorConstants;
import org.apache.hertzbeat.collector.dispatch.DispatchConstants;
import org.apache.hertzbeat.common.constants.CommonConstants;
@@ -52,7 +53,6 @@ public class ScriptCollectImpl extends AbstractCollect {
private static final String BASH_C = "-c";
private static final String POWERSHELL_C = "-Command";
private static final String POWERSHELL_FILE = "-File";
private static final String PARSE_TYPE_ONE_ROW = "oneRow";
private static final String PARSE_TYPE_MULTI_ROW = "multiRow";
private static final String PARSE_TYPE_NETCAT = "netcat";
private static final String PARSE_TYPE_LOG = "log";
@@ -113,25 +113,48 @@ public class ScriptCollectImpl extends AbstractCollect {
try {
Process process = processBuilder.start();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), Charset.forName(scriptProtocol.getCharset())));
StringBuilder response = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
if (StringUtils.hasText(line)) {
response.append(line).append("\n");
BufferedReader errorReader = new BufferedReader(
new InputStreamReader(process.getErrorStream(), Charset.forName(scriptProtocol.getCharset())));
// drain stderr on its own thread: a full stderr pipe would deadlock the stdout read;
// StringBuffer because the drainer may still be writing when the buffer is read
StringBuffer errorBuffer = new StringBuffer();
Thread errorDrainer = new Thread(() -> {
try {
String errorLine;
while ((errorLine = errorReader.readLine()) != null) {
if (StringUtils.hasText(errorLine)) {
errorBuffer.append(errorLine).append("\n");
}
}
} catch (IOException e) {
log.warn("read script error stream failed: {}", e.getMessage());
}
}
process.waitFor();
});
errorDrainer.setDaemon(true);
errorDrainer.start();
String result = readResponse(reader);
int exitCode = process.waitFor();
// bounded: a lingering grandchild can keep the stderr pipe open
errorDrainer.join(1000);
Long responseTime = System.currentTimeMillis() - startTime;
String result = String.valueOf(response);
String errorResult = errorBuffer.toString();
if (!StringUtils.hasText(result)) {
if (OneRowResponseSupport.tryAppendEmptyOneRow(scriptProtocol.getParseType(), errorResult,
exitCode, metrics.getAliasFields(), builder, responseTime)) {
return;
}
builder.setCode(CollectRep.Code.FAIL);
builder.setMsg("Script response data is null");
builder.setMsg(OneRowResponseSupport.buildBlankFailureMessage(errorResult, exitCode,
"Script exited with code: ", "Script response data is null"));
return;
}
if (StringUtils.hasText(errorResult)) {
log.warn("script command succeeded but wrote to stderr: {}", errorResult.trim());
}
switch (scriptProtocol.getParseType()) {
case PARSE_TYPE_LOG -> parseResponseDataByLog(result, metrics.getAliasFields(), builder, responseTime);
case PARSE_TYPE_NETCAT -> parseResponseDataByNetcat(result, metrics.getAliasFields(), builder, responseTime);
case PARSE_TYPE_ONE_ROW -> parseResponseDataByOne(result, metrics.getAliasFields(), builder, responseTime);
case OneRowResponseSupport.PARSE_TYPE_ONE_ROW -> parseResponseDataByOne(result, metrics.getAliasFields(), builder, responseTime);
case PARSE_TYPE_MULTI_ROW -> parseResponseDataByMulti(result, metrics.getAliasFields(), builder, responseTime);
default -> {
builder.setCode(CollectRep.Code.FAIL);
@@ -207,28 +230,7 @@ public class ScriptCollectImpl extends AbstractCollect {
}
private void parseResponseDataByOne(String result, List<String> aliasFields, CollectRep.MetricsData.Builder builder, Long responseTime) {
String[] lines = result.split("\n");
if (lines.length + 1 < aliasFields.size()) {
log.error("ssh response data not enough: {}", result);
return;
}
CollectRep.ValueRow.Builder valueRowBuilder = CollectRep.ValueRow.newBuilder();
int aliasIndex = 0;
int lineIndex = 0;
while (aliasIndex < aliasFields.size()) {
if (CollectorConstants.RESPONSE_TIME.equalsIgnoreCase(aliasFields.get(aliasIndex))) {
valueRowBuilder.addColumn(responseTime.toString());
} else {
if (lineIndex < lines.length) {
valueRowBuilder.addColumn(lines[lineIndex].trim());
} else {
valueRowBuilder.addColumn(CommonConstants.NULL_VALUE);
}
lineIndex++;
}
aliasIndex++;
}
builder.addValueRow(valueRowBuilder.build());
OneRowResponseSupport.appendResponseValues(result, aliasFields, builder, responseTime);
}
private void parseResponseDataByMulti(String result, List<String> aliasFields,
@@ -261,4 +263,15 @@ public class ScriptCollectImpl extends AbstractCollect {
builder.addValueRow(valueRowBuilder.build());
}
}
private String readResponse(BufferedReader reader) throws IOException {
StringBuilder response = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
if (StringUtils.hasText(line)) {
response.append(line).append("\n");
}
}
return response.toString();
}
}
@@ -21,6 +21,8 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.ConnectException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.net.SocketTimeoutException;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
@@ -33,6 +35,7 @@ import java.util.Objects;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.collector.collect.AbstractCollect;
import org.apache.hertzbeat.collector.collect.common.OneRowResponseSupport;
import org.apache.hertzbeat.collector.collect.common.ssh.CommonSshBlacklist;
import org.apache.hertzbeat.collector.collect.common.ssh.SshHelper;
import org.apache.hertzbeat.collector.constants.CollectorConstants;
@@ -49,7 +52,6 @@ import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.common.SshException;
import org.apache.sshd.common.channel.exception.SshChannelOpenException;
import org.apache.sshd.common.future.CloseFuture;
import org.apache.sshd.common.util.io.output.NoCloseOutputStream;
import org.springframework.util.StringUtils;
/**
@@ -58,7 +60,6 @@ import org.springframework.util.StringUtils;
@Slf4j
public class SshCollectImpl extends AbstractCollect {
private static final String PARSE_TYPE_ONE_ROW = "oneRow";
private static final String PARSE_TYPE_MULTI_ROW = "multiRow";
private static final String PARSE_TYPE_NETCAT = "netcat";
private static final String PARSE_TYPE_LOG = "log";
@@ -93,8 +94,9 @@ public class SshCollectImpl extends AbstractCollect {
}
channel = clientSession.createExecChannel(sshProtocol.getScript());
ByteArrayOutputStream response = new ByteArrayOutputStream();
ByteArrayOutputStream errorResponse = new ByteArrayOutputStream();
channel.setOut(response);
channel.setErr(new NoCloseOutputStream(System.err));
channel.setErr(errorResponse);
channel.open().verify(timeout);
List<ClientChannelEvent> list = new ArrayList<>();
list.add(ClientChannelEvent.CLOSED);
@@ -107,16 +109,28 @@ public class SshCollectImpl extends AbstractCollect {
throw new SocketTimeoutException("Failed to retrieve command result in time: " + sshProtocol.getScript());
}
Long responseTime = System.currentTimeMillis() - startTime;
String result = response.toString();
Charset charset = StringUtils.hasText(sshProtocol.getCharset())
? Charset.forName(sshProtocol.getCharset()) : StandardCharsets.UTF_8;
String result = response.toString(charset);
String errorResult = errorResponse.toString(charset);
Integer exitStatus = channel.getExitStatus();
if (!StringUtils.hasText(result)) {
if (OneRowResponseSupport.tryAppendEmptyOneRow(sshProtocol.getParseType(), errorResult,
exitStatus, metrics.getAliasFields(), builder, responseTime)) {
return;
}
builder.setCode(CollectRep.Code.FAIL);
builder.setMsg("ssh shell response data is null");
builder.setMsg(OneRowResponseSupport.buildBlankFailureMessage(errorResult, exitStatus,
"ssh command exited with code: ", "ssh shell response data is null"));
return;
}
if (StringUtils.hasText(errorResult)) {
log.warn("ssh command succeeded but wrote to stderr: {}", errorResult.trim());
}
switch (sshProtocol.getParseType()) {
case PARSE_TYPE_LOG -> parseResponseDataByLog(result, metrics.getAliasFields(), builder, responseTime);
case PARSE_TYPE_NETCAT -> parseResponseDataByNetcat(result, metrics.getAliasFields(), builder, responseTime);
case PARSE_TYPE_ONE_ROW -> parseResponseDataByOne(result, metrics.getAliasFields(), builder, responseTime);
case OneRowResponseSupport.PARSE_TYPE_ONE_ROW -> parseResponseDataByOne(result, metrics.getAliasFields(), builder, responseTime);
case PARSE_TYPE_MULTI_ROW -> parseResponseDataByMulti(result, metrics.getAliasFields(), builder, responseTime);
default -> {
builder.setCode(CollectRep.Code.FAIL);
@@ -244,28 +258,7 @@ public class SshCollectImpl extends AbstractCollect {
}
private void parseResponseDataByOne(String result, List<String> aliasFields, CollectRep.MetricsData.Builder builder, Long responseTime) {
String[] lines = result.split("\n");
if (lines.length + 1 < aliasFields.size()) {
log.error("ssh response data not enough: {}", result);
return;
}
CollectRep.ValueRow.Builder valueRowBuilder = CollectRep.ValueRow.newBuilder();
int aliasIndex = 0;
int lineIndex = 0;
while (aliasIndex < aliasFields.size()) {
if (CollectorConstants.RESPONSE_TIME.equalsIgnoreCase(aliasFields.get(aliasIndex))) {
valueRowBuilder.addColumn(responseTime.toString());
} else {
if (lineIndex < lines.length) {
valueRowBuilder.addColumn(lines[lineIndex].trim());
} else {
valueRowBuilder.addColumn(CommonConstants.NULL_VALUE);
}
lineIndex++;
}
aliasIndex++;
}
builder.addValueRow(valueRowBuilder.build());
OneRowResponseSupport.appendResponseValues(result, aliasFields, builder, responseTime);
}
private void parseResponseDataByMulti(String result, List<String> aliasFields,
@@ -67,14 +67,20 @@ public class TelnetCollectImpl extends AbstractCollect {
long responseTime = System.currentTimeMillis() - startTime;
List<String> aliasFields = metrics.getAliasFields();
String app = builder.getApp();
Map<String, String> resultMap = execCmdAndParseResult(telnetClient, telnet.getCmd(), app);
resultMap.put(CollectorConstants.RESPONSE_TIME, Long.toString(responseTime));
if (resultMap.size() < aliasFields.size()) {
log.error("telnet response data not enough: {}", resultMap);
CmdResult cmdResult = execCmdAndParseResult(telnetClient, telnet.getCmd(), app);
Map<String, String> resultMap = cmdResult.values();
boolean expectsCmdMetrics = StringUtils.isNotBlank(telnet.getCmd())
&& aliasFields.stream().anyMatch(field -> !CollectorConstants.RESPONSE_TIME.equalsIgnoreCase(field));
boolean hasExpectedMetric = aliasFields.stream().anyMatch(resultMap::containsKey);
if (expectsCmdMetrics && !hasExpectedMetric) {
// e.g. zookeeper refusing a 4lw command not in its 4lw.commands.whitelist
String reply = sanitizeReply(cmdResult.rawResponse());
log.warn("telnet cmd [{}] returned no expected metrics: {}", telnet.getCmd(), reply);
builder.setCode(CollectRep.Code.FAIL);
builder.setMsg("The cmd execution results do not match the expected number of metrics.");
builder.setMsg("Cmd [" + telnet.getCmd() + "] returned no expected metrics. Response: " + reply);
return;
}
resultMap.put(CollectorConstants.RESPONSE_TIME, Long.toString(responseTime));
CollectRep.ValueRow.Builder valueRowBuilder = CollectRep.ValueRow.newBuilder();
for (String field : aliasFields) {
String fieldValue = resultMap.get(field);
@@ -118,30 +124,36 @@ public class TelnetCollectImpl extends AbstractCollect {
return DispatchConstants.PROTOCOL_TELNET;
}
private static Map<String, String> execCmdAndParseResult(TelnetClient telnetClient, String cmd, String app) throws IOException {
record CmdResult(Map<String, String> values, String rawResponse) {
}
private static String sanitizeReply(String raw) {
return StringUtils.abbreviate(raw.trim().replaceAll("[\\p{Cntrl}]+", " "), 300);
}
private static CmdResult execCmdAndParseResult(TelnetClient telnetClient, String cmd, String app) throws IOException {
if (cmd == null || StringUtils.isEmpty(cmd.trim())) {
return new HashMap<>(16);
return new CmdResult(new HashMap<>(16), "");
}
OutputStream outputStream = telnetClient.getOutputStream();
outputStream.write(cmd.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
String result = new String(telnetClient.getInputStream().readAllBytes());
String[] lines = result.split("\n");
if (CollectorConstants.ZOOKEEPER_APP.equals(app) && CollectorConstants.ZOOKEEPER_ENVI_HEAD.equals(lines[0])) {
if (lines.length > 0 && CollectorConstants.ZOOKEEPER_APP.equals(app)
&& CollectorConstants.ZOOKEEPER_ENVI_HEAD.equals(lines[0])) {
lines = Arrays.stream(lines)
.skip(1)
.toArray(String[]::new);
}
boolean contains = lines[0].contains("=");
return Arrays.stream(lines)
.map(item -> {
if (contains) {
return item.split("=");
} else {
return item.split("\t");
}
})
if (lines.length == 0) {
return new CmdResult(new HashMap<>(16), result);
}
String separator = lines[0].contains("=") ? "=" : "\t";
Map<String, String> values = Arrays.stream(lines)
.map(item -> item.split(separator, 2))
.filter(item -> item.length == 2)
.collect(Collectors.toMap(x -> x[0], x -> x[1]));
.collect(Collectors.toMap(x -> x[0], x -> x[1], (first, second) -> first, HashMap::new));
return new CmdResult(values, result);
}
}
@@ -0,0 +1,123 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.collector.collect.common;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.apache.hertzbeat.collector.constants.CollectorConstants;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.junit.jupiter.api.Test;
class OneRowResponseSupportTest {
@Test
void appendResponseValuesShouldMapColumnsInOrder() {
CollectRep.MetricsData.Builder builder = CollectRep.MetricsData.newBuilder();
OneRowResponseSupport.appendResponseValues(
"pod-a\n5\n", List.of("pod", "restart", CollectorConstants.RESPONSE_TIME), builder, 18L);
assertEquals(1, builder.getValuesCount());
assertEquals("pod-a", builder.getValues(0).getColumns(0));
assertEquals("5", builder.getValues(0).getColumns(1));
assertEquals("18", builder.getValues(0).getColumns(2));
}
@Test
void appendResponseValuesShouldPadMissingTrailingLines() {
CollectRep.MetricsData.Builder builder = CollectRep.MetricsData.newBuilder();
OneRowResponseSupport.appendResponseValues(
"52\n35.8033\n5%",
List.of("cpu", "memory", "disk", "nfs_mount", CollectorConstants.RESPONSE_TIME), builder, 18L);
assertEquals(1, builder.getValuesCount());
assertEquals("52", builder.getValues(0).getColumns(0));
assertEquals("35.8033", builder.getValues(0).getColumns(1));
assertEquals("5%", builder.getValues(0).getColumns(2));
assertEquals(CommonConstants.NULL_VALUE, builder.getValues(0).getColumns(3));
assertEquals("18", builder.getValues(0).getColumns(4));
}
@Test
void appendEmptyValuesShouldFillNullPlaceholders() {
CollectRep.MetricsData.Builder builder = CollectRep.MetricsData.newBuilder();
OneRowResponseSupport.appendEmptyValues(
List.of("nfs_mount", CollectorConstants.RESPONSE_TIME), builder, 12L);
assertEquals(1, builder.getValuesCount());
assertEquals(CommonConstants.NULL_VALUE, builder.getValues(0).getColumns(0));
assertEquals("12", builder.getValues(0).getColumns(1));
}
@Test
void tryAppendEmptyOneRowShouldAcceptGrepNoMatchExitOne() {
CollectRep.MetricsData.Builder builder = CollectRep.MetricsData.newBuilder();
// grep with no match exits 1 and writes nothing: treat as valid empty data, not a failure
boolean handled = OneRowResponseSupport.tryAppendEmptyOneRow(
OneRowResponseSupport.PARSE_TYPE_ONE_ROW, "", 1,
List.of("nfs_mount", CollectorConstants.RESPONSE_TIME), builder, 9L);
assertTrue(handled);
assertEquals(1, builder.getValuesCount());
assertEquals(CommonConstants.NULL_VALUE, builder.getValues(0).getColumns(0));
}
@Test
void tryAppendEmptyOneRowShouldRejectNullExitStatus() {
CollectRep.MetricsData.Builder builder = CollectRep.MetricsData.newBuilder();
// an absent exit status (e.g. dropped ssh channel) must be treated as a failure
boolean handled = OneRowResponseSupport.tryAppendEmptyOneRow(
OneRowResponseSupport.PARSE_TYPE_ONE_ROW, "", null,
List.of("nfs_mount"), builder, 9L);
assertFalse(handled);
assertEquals(0, builder.getValuesCount());
}
@Test
void tryAppendEmptyOneRowShouldRejectNonEmptyStderr() {
CollectRep.MetricsData.Builder builder = CollectRep.MetricsData.newBuilder();
boolean handled = OneRowResponseSupport.tryAppendEmptyOneRow(
OneRowResponseSupport.PARSE_TYPE_ONE_ROW, "permission denied", 1,
List.of("nfs_mount"), builder, 9L);
assertFalse(handled);
assertEquals(0, builder.getValuesCount());
}
@Test
void buildBlankFailureMessageShouldPreferStderrThenExitCode() {
assertEquals("permission denied", OneRowResponseSupport.buildBlankFailureMessage(
"permission denied\n", 2, "cmd exited with code: ", "null data"));
assertEquals("cmd exited with code: 2", OneRowResponseSupport.buildBlankFailureMessage(
"", 2, "cmd exited with code: ", "null data"));
assertEquals("null data", OneRowResponseSupport.buildBlankFailureMessage(
"", 1, "cmd exited with code: ", "null data"));
assertEquals("null data", OneRowResponseSupport.buildBlankFailureMessage(
"", null, "cmd exited with code: ", "null data"));
}
}
@@ -185,6 +185,29 @@ class JdbcCommonCollectTest {
assertEquals("Not support database platform: invalid", exception.getMessage());
}
/**
* The url blacklist only guards a user supplied url. Driver properties smuggled through the
* database name reach the very same connection, so they have to be rejected too.
*/
@Test
void testConstructDatabaseUrlRejectsDriverPropertiesInDatabaseName() {
String[] payloads = {
"test?allowLoadLocalInfile=true&z=",
"test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&z=",
"test&useSSL=false",
};
for (String payload : payloads) {
JdbcProtocol jdbcProtocol = JdbcProtocol.builder()
.platform("mysql")
.database(payload)
.build();
assertThrows(IllegalArgumentException.class,
() -> constructDatabaseUrl(jdbcCommonCollect, jdbcProtocol, "localhost", "3306"),
"database name should be rejected: " + payload);
}
}
@Test
void testCloseConnectionWhenCreateStatementFails() throws Exception {
String url = "jdbc:postgresql://localhost:5432/hertzbeat";
@@ -20,6 +20,7 @@ package org.apache.hertzbeat.collector.collect.http;
import com.google.common.collect.Lists;
import com.sun.net.httpserver.HttpServer;
import org.apache.hertzbeat.collector.dispatch.DispatchConstants;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.job.Metrics;
import org.apache.hertzbeat.common.entity.job.protocol.HttpProtocol;
import org.apache.hertzbeat.common.entity.message.CollectRep;
@@ -383,6 +384,48 @@ class HttpCollectImplTest {
assertEquals("0.268751364291017", firstRow.getColumns(0));
}
@Test
void parseResponseByJsonPathKeepsRowAlignmentWhenAliasPathMissing() throws Exception {
String jsonResponse = "{\"items\": ["
+ "{\"metadata\": {\"name\": \"pod-a\"}, \"status\": {\"phase\": \"Running\","
+ " \"containerStatuses\": [{\"name\": \"c1\", \"ready\": true, \"restartCount\": 5}]}},"
+ "{\"metadata\": {\"name\": \"pod-b-pending\"}, \"status\": {\"phase\": \"Pending\"}},"
+ "{\"metadata\": {\"name\": \"pod-c\"}, \"status\": {\"phase\": \"Running\","
+ " \"containerStatuses\": [{\"name\": \"c3\", \"ready\": true, \"restartCount\": 2}]}}"
+ "]}";
HttpProtocol http = HttpProtocol.builder()
.parseType(DispatchConstants.PARSE_JSON_PATH)
.parseScript("$.items.*")
.build();
List<CollectRep.ValueRow> capturedRows = new ArrayList<>();
CollectRep.MetricsData.Builder builder = new CollectRep.MetricsData.Builder() {
@Override
public CollectRep.MetricsData.Builder addValueRow(CollectRep.ValueRow valueRow) {
capturedRows.add(valueRow);
return super.addValueRow(valueRow);
}
};
Method parseMethod = HttpCollectImpl.class.getDeclaredMethod(
"parseResponseByJsonPath",
String.class,
List.class,
HttpProtocol.class,
CollectRep.MetricsData.Builder.class,
Long.class);
parseMethod.setAccessible(true);
parseMethod.invoke(httpCollectImpl, jsonResponse,
Lists.newArrayList("$.metadata.name", "$.status.containerStatuses[0].restartCount"), http, builder, 100L);
assertEquals(3, capturedRows.size());
assertEquals("pod-a", capturedRows.get(0).getColumns(0));
assertEquals("5", capturedRows.get(0).getColumns(1));
assertEquals("pod-b-pending", capturedRows.get(1).getColumns(0));
assertEquals(CommonConstants.NULL_VALUE, capturedRows.get(1).getColumns(1));
assertEquals("pod-c", capturedRows.get(2).getColumns(0));
assertEquals("2", capturedRows.get(2).getColumns(1));
}
@Test
void testParsePromQlLabelValue() throws Exception {
// Create Prometheus format test data
@@ -0,0 +1,88 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.hertzbeat.collector.collect.mqtt;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.StringWriter;
import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.cert.X509Certificate;
import java.util.Date;
import org.apache.hertzbeat.common.entity.job.protocol.MqttProtocol;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.openssl.jcajce.JcaPKCS8Generator;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class MqttSslFactoryTest {
private static String certPem;
private static String pkcs1KeyPem;
private static String pkcs8KeyPem;
@BeforeAll
static void generateCertAndKeys() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair keyPair = generator.generateKeyPair();
X500Name subject = new X500Name("CN=hb-3540-mqtt");
JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
subject, BigInteger.ONE,
new Date(System.currentTimeMillis() - 60_000),
new Date(System.currentTimeMillis() + 3_600_000),
subject, keyPair.getPublic());
X509Certificate cert = new JcaX509CertificateConverter()
.getCertificate(certBuilder.build(new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate())));
certPem = writePem(cert);
pkcs1KeyPem = writePem(keyPair.getPrivate());
pkcs8KeyPem = writePem(new JcaPKCS8Generator(keyPair.getPrivate(), null));
}
@Test
void parsesPkcs1ClientKey() {
assertNotNull(MqttSslFactory.getMslSocketFactory(mqttProtocol(pkcs1KeyPem), true));
}
@Test
void parsesPkcs8ClientKey() {
assertNotNull(MqttSslFactory.getMslSocketFactory(mqttProtocol(pkcs8KeyPem), true));
}
private static MqttProtocol mqttProtocol(String clientKey) {
return MqttProtocol.builder()
.tlsVersion("TLSv1.2")
.clientCert(certPem)
.clientKey(clientKey)
.build();
}
private static String writePem(Object object) throws Exception {
StringWriter out = new StringWriter();
try (JcaPEMWriter writer = new JcaPEMWriter(out)) {
writer.writeObject(object);
}
return out.toString();
}
}
@@ -30,6 +30,8 @@ import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
class OnlineParserTest {
@@ -459,4 +461,27 @@ class OnlineParserTest {
assertEquals("run_as", metricFamily.getMetricList().get(0).getLabels().get(3).getName());
assertEquals("NT AUTHORITY\nLocalService", metricFamily.getMetricList().get(0).getLabels().get(3).getValue());
}
@Test
void testParseMetricsStopsBeforeSampleBeyondLimit() {
final String metrics = "metric_a 1\nmetric_b 2\nmetric_c 3\nmetric_d 4\n";
final ByteArrayInputStream inputStream =
new ByteArrayInputStream(metrics.getBytes(StandardCharsets.UTF_8));
assertThrows(OnlineParser.SampleLimitExceededException.class,
() -> OnlineParser.parseMetrics(inputStream, 2));
assertTrue(inputStream.available() > 0, "samples after the limit should remain unread");
}
@Test
void testParseMetricsAllowsExactlyTheSampleLimit() throws Exception {
final String metrics = "metric_a 1\nmetric_b 2\n";
final InputStream inputStream = new ByteArrayInputStream(metrics.getBytes(StandardCharsets.UTF_8));
final Map<String, MetricFamily> metricFamilyMap = OnlineParser.parseMetrics(inputStream, 2);
assertNotNull(metricFamilyMap);
assertEquals(2, metricFamilyMap.size());
}
}
@@ -19,9 +19,12 @@ package org.apache.hertzbeat.collector.collect.script;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.List;
import org.apache.hertzbeat.collector.dispatch.DispatchConstants;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.job.Metrics;
import org.apache.hertzbeat.common.entity.job.protocol.ScriptProtocol;
import org.apache.hertzbeat.common.entity.message.CollectRep;
@@ -138,6 +141,85 @@ public class ScriptCollectImplTest {
scriptCollect.collect(builder, metrics);
assertEquals(CollectRep.Code.FAIL, builder.getCode());
});
// empty stdout without stderr should be treated as empty one-row data
assertDoesNotThrow(() -> {
ScriptProtocol scriptProtocol = ScriptProtocol.builder()
.charset("utf-8")
.parseType("oneRow")
.scriptTool("bash")
.scriptCommand("grep -o 'centos-hermitlv' /dev/null")
.build();
Metrics metrics = new Metrics();
metrics.setScript(scriptProtocol);
metrics.setAliasFields(List.of("nfs_mount"));
builder = CollectRep.MetricsData.newBuilder();
scriptCollect.collect(builder, metrics);
assertEquals(CollectRep.Code.SUCCESS, builder.getCode());
assertEquals(1, builder.getValuesCount());
assertEquals(CommonConstants.NULL_VALUE, builder.getValues(0).getColumns(0));
});
// partial output missing more than one trailing field: the old length check
// (lines + 1 < aliases) dropped the whole row here, losing the collected values
assertDoesNotThrow(() -> {
ScriptProtocol scriptProtocol = ScriptProtocol.builder()
.charset("utf-8")
.parseType("oneRow")
.scriptTool("bash")
.scriptCommand("echo 52; echo 35.8033; grep -o 'centos-hermitlv' /dev/null")
.build();
Metrics metrics = new Metrics();
metrics.setScript(scriptProtocol);
metrics.setAliasFields(List.of("cpu", "memory", "disk", "nfs_mount"));
builder = CollectRep.MetricsData.newBuilder();
scriptCollect.collect(builder, metrics);
assertEquals(CollectRep.Code.SUCCESS, builder.getCode());
assertEquals(1, builder.getValuesCount());
assertEquals("52", builder.getValues(0).getColumns(0));
assertEquals("35.8033", builder.getValues(0).getColumns(1));
assertEquals(CommonConstants.NULL_VALUE, builder.getValues(0).getColumns(2));
assertEquals(CommonConstants.NULL_VALUE, builder.getValues(0).getColumns(3));
});
// a command that silently exits 1 with no output is indistinguishable from a
// grep no-match, so it is deliberately accepted as an empty success
assertDoesNotThrow(() -> {
ScriptProtocol scriptProtocol = ScriptProtocol.builder()
.charset("utf-8")
.parseType("oneRow")
.scriptTool("bash")
.scriptCommand("exit 1")
.build();
Metrics metrics = new Metrics();
metrics.setScript(scriptProtocol);
metrics.setAliasFields(List.of("nfs_mount"));
builder = CollectRep.MetricsData.newBuilder();
scriptCollect.collect(builder, metrics);
assertEquals(CollectRep.Code.SUCCESS, builder.getCode());
assertEquals(CommonConstants.NULL_VALUE, builder.getValues(0).getColumns(0));
});
// non-empty exit code without stderr should still fail when it is not the grep-style no-match case
assertDoesNotThrow(() -> {
ScriptProtocol scriptProtocol = ScriptProtocol.builder()
.charset("utf-8")
.parseType("oneRow")
.scriptTool("bash")
.scriptCommand("exit 2")
.build();
Metrics metrics = new Metrics();
metrics.setScript(scriptProtocol);
metrics.setAliasFields(List.of("nfs_mount"));
builder = CollectRep.MetricsData.newBuilder();
scriptCollect.collect(builder, metrics);
assertEquals(CollectRep.Code.FAIL, builder.getCode());
assertTrue(builder.getMsg().contains("code: 2"));
});
}
@Test

Some files were not shown because too many files have changed in this diff Show More