mirror of
https://github.com/dromara/hertzbeat.git
synced 2026-09-17 09:40:58 +00:00
Integrate hybrid collector backend
This commit is contained in:
@@ -21,10 +21,15 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/hybrid-collector-release.yml'
|
||||
- '.github/workflows/nightly-build.yml'
|
||||
- 'pom.xml'
|
||||
- 'hertzbeat-collector/**'
|
||||
- 'hertzbeat-common-core/**'
|
||||
- 'hertzbeat-manager/**'
|
||||
- 'hertzbeat-observability/**'
|
||||
- 'hertzbeat-otel/**'
|
||||
- 'hertzbeat-otel-runtime/**'
|
||||
- 'hertzbeat-startup/**'
|
||||
- 'script/assembly/collector/**'
|
||||
- 'script/ci/*hybrid-collector*'
|
||||
- 'script/ci/*otel-runtime*'
|
||||
@@ -57,6 +62,8 @@ jobs:
|
||||
- name: Verify source and dependency contracts
|
||||
run: |
|
||||
python3 script/ci/test_verify_hybrid_collector_release_content.py -v
|
||||
python3 script/ci/test_verify_hybrid_collector_jvm_package.py -v
|
||||
python3 script/ci/test_verify_otel_runtime_sbom_platform.py -v
|
||||
python3 script/ci/test_prepare_hybrid_collector_native_container_context.py -v
|
||||
python3 script/ci/test_hybrid_collector_systemd_install.py -v
|
||||
mkdir -p target/release-policy
|
||||
@@ -73,11 +80,15 @@ jobs:
|
||||
- name: Verify Java runtime and Spring AOT
|
||||
run: |
|
||||
./script/ci/verify-hybrid-collector-java.sh
|
||||
make -C hertzbeat-otel-runtime release-assets
|
||||
./mvnw -pl hertzbeat-collector/hertzbeat-collector-collector -am \
|
||||
-Pcluster -DskipTests package
|
||||
archive=$(find dist -maxdepth 1 -type f -name 'apache-hertzbeat-collector-*-bin.tar.gz' -print -quit)
|
||||
test -n "$archive"
|
||||
sh ./script/ci/verify-hybrid-collector-jvm-package.sh "$archive" generic
|
||||
-Pruntime -DskipTests package
|
||||
set -- dist/apache-hertzbeat-collector-*-bin.tar.gz
|
||||
test "$#" -eq 1 && test -f "$1"
|
||||
sh ./script/ci/verify-hybrid-collector-jvm-package.sh "$1" generic
|
||||
set -- dist/apache-hertzbeat-collector-*-bin-linux_amd64.tar.gz
|
||||
test "$#" -eq 1 && test -f "$1"
|
||||
sh ./script/ci/verify-hybrid-collector-jvm-package.sh "$1" linux-amd64
|
||||
|
||||
source-policy:
|
||||
strategy:
|
||||
@@ -188,6 +199,10 @@ jobs:
|
||||
push: false
|
||||
outputs: type=oci,dest=/tmp/hybrid-collector.oci.tar
|
||||
tags: apache/hertzbeat-collector:native-test
|
||||
- name: Verify multi-platform native image layers
|
||||
run: |
|
||||
python3 script/ci/verify-hybrid-collector-release-content.py \
|
||||
--container-image /tmp/hybrid-collector.oci.tar
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hybrid-collector-native-oci
|
||||
|
||||
@@ -42,6 +42,14 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: hertzbeat-otel-runtime/go.mod
|
||||
cache-dependency-path: hertzbeat-otel-runtime/go.mod
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: zulu
|
||||
java-version: 25
|
||||
- name: Build the Frontend
|
||||
run: |
|
||||
corepack enable
|
||||
@@ -49,13 +57,33 @@ jobs:
|
||||
cd web-app
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
|
||||
- name: Build and verify managed Collector runtimes
|
||||
run: |
|
||||
make -C hertzbeat-otel-runtime release-assets
|
||||
./script/ci/verify-otel-runtime-package-layout.sh
|
||||
|
||||
- name: Build the Backend
|
||||
run: |
|
||||
mvn clean install
|
||||
mvn clean package -Prelease -DskipTests
|
||||
cd hertzbeat-collector
|
||||
mvn clean package -Pcluster -DskipTests
|
||||
./mvnw -pl hertzbeat-collector/hertzbeat-collector-collector -am \
|
||||
-Pruntime -DskipTests package
|
||||
|
||||
- name: Verify Linux Collector release content
|
||||
run: |
|
||||
set -- dist/apache-hertzbeat-collector-*-bin-linux_amd64.tar.gz
|
||||
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
|
||||
echo "expected exactly one generated linux-amd64 Hybrid Collector archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
./script/ci/verify-hybrid-collector-jvm-package.sh "$1" linux-amd64
|
||||
set -- dist/apache-hertzbeat-collector-*-bin-linux_arm64.tar.gz
|
||||
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
|
||||
echo "expected exactly one generated linux-arm64 Hybrid Collector archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
./script/ci/verify-hybrid-collector-jvm-package.sh "$1" linux-arm64
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
@@ -74,11 +102,22 @@ jobs:
|
||||
push: true
|
||||
tags: apache/hertzbeat:nightly
|
||||
|
||||
- name: Build and Push Collector
|
||||
- name: Build Collector image for verification
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./dist
|
||||
file: ./script/docker/collector/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: apache/hertzbeat-collector:nightly
|
||||
outputs: type=oci,dest=/tmp/hybrid-collector-nightly.oci.tar
|
||||
|
||||
- name: Verify final Collector image content
|
||||
run: python3 script/ci/verify-hybrid-collector-release-content.py --container-image /tmp/hybrid-collector-nightly.oci.tar
|
||||
|
||||
- name: Push verified Collector image
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y skopeo
|
||||
skopeo copy --all --authfile "$HOME/.docker/config.json" \
|
||||
oci-archive:/tmp/hybrid-collector-nightly.oci.tar \
|
||||
docker://apache/hertzbeat-collector:nightly
|
||||
|
||||
@@ -34,8 +34,9 @@ filter. Such a request is rejected instead of silently applying ambiguous constr
|
||||
- Metrics console request and returned context.
|
||||
- Historical log list, surrounding context, overview, trace coverage, trend, and group-by queries.
|
||||
- Live log SSE filtering.
|
||||
- Trace list, overview, and group-by queries. Trace detail and span endpoints already address an
|
||||
exact trace identifier and do not add these discovery filters.
|
||||
- Trace list, overview, group-by, detail, and span queries. Detail and span requests retain the
|
||||
exact trace identifier while also applying the supplied discovery context; an optional `spanId`
|
||||
further narrows the row set.
|
||||
|
||||
Storage access remains behind the existing query services and storage adapters. Controllers only
|
||||
bind the HTTP parameters and construct the storage-neutral context; they contain no database query
|
||||
|
||||
+61
-14
@@ -30,8 +30,11 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* SSE manager for alert
|
||||
@@ -39,50 +42,94 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AlertSseManager implements ApplicationListener<ContextClosedEvent> {
|
||||
|
||||
private static final long RECONNECT_TIME_MILLIS = 3_000L;
|
||||
|
||||
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
|
||||
private final Object lifecycleMonitor = new Object();
|
||||
private final AtomicLong eventSequence = new AtomicLong(System.currentTimeMillis());
|
||||
private final Supplier<SseEmitter> emitterFactory;
|
||||
private boolean closing;
|
||||
|
||||
public AlertSseManager() {
|
||||
this(() -> new SseEmitter(Long.MAX_VALUE));
|
||||
}
|
||||
|
||||
AlertSseManager(Supplier<SseEmitter> emitterFactory) {
|
||||
this.emitterFactory = Objects.requireNonNull(emitterFactory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a reconnectable stream. The ready event is a convergence trigger:
|
||||
* clients must reread canonical alert state rather than expect replay from
|
||||
* this in-memory stream.
|
||||
*/
|
||||
public SseEmitter createEmitter(Long clientId) {
|
||||
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
|
||||
emitter.onCompletion(() -> removeEmitter(clientId));
|
||||
emitter.onTimeout(() -> removeEmitter(clientId));
|
||||
emitter.onError((ex) -> removeEmitter(clientId));
|
||||
SseEmitter emitter = emitterFactory.get();
|
||||
emitter.onCompletion(() -> removeEmitter(clientId, emitter));
|
||||
emitter.onTimeout(() -> removeEmitter(clientId, emitter));
|
||||
emitter.onError((ex) -> removeEmitter(clientId, emitter));
|
||||
SseEmitter replacedEmitter;
|
||||
synchronized (lifecycleMonitor) {
|
||||
if (closing) {
|
||||
emitter.complete();
|
||||
tryComplete(emitter);
|
||||
return emitter;
|
||||
}
|
||||
emitters.put(clientId, emitter);
|
||||
replacedEmitter = emitters.put(clientId, emitter);
|
||||
}
|
||||
if (replacedEmitter != null && replacedEmitter != emitter) {
|
||||
tryCompleteAndClean(clientId, replacedEmitter);
|
||||
}
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("ALERT_STREAM_READY")
|
||||
.data("{}")
|
||||
.reconnectTime(RECONNECT_TIME_MILLIS));
|
||||
} catch (IOException | IllegalStateException exception) {
|
||||
tryCompleteAndClean(clientId, emitter);
|
||||
}
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@Async
|
||||
public void broadcast(String data) {
|
||||
broadcast(data, "ALERT_EVENT");
|
||||
}
|
||||
|
||||
@Async
|
||||
public void broadcastGroupMutation(String data) {
|
||||
broadcast(data, "ALERT_GROUP_MUTATION");
|
||||
}
|
||||
|
||||
private void broadcast(String data, String eventName) {
|
||||
String eventId = String.valueOf(eventSequence.incrementAndGet());
|
||||
emitters.forEach((clientId, emitter) -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.id(String.valueOf(System.currentTimeMillis()))
|
||||
.name("ALERT_EVENT")
|
||||
.id(eventId)
|
||||
.name(eventName)
|
||||
.data(data));
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
tryCompleteAndClean(clientId, emitter);
|
||||
} catch (Exception exception) {
|
||||
log.error("Failed to broadcast alert data to client: {}", exception.getMessage());
|
||||
log.error("Failed to broadcast alert data to client: {}",
|
||||
exception.getClass().getSimpleName());
|
||||
tryCompleteAndClean(clientId, emitter);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void tryCompleteAndClean(Long clientId, SseEmitter emitter) {
|
||||
tryComplete(emitter);
|
||||
removeEmitter(clientId, emitter);
|
||||
}
|
||||
|
||||
private void tryComplete(SseEmitter emitter) {
|
||||
try {
|
||||
Optional.ofNullable(emitter).ifPresent(ResponseBodyEmitter::complete);
|
||||
} catch (Throwable e) {
|
||||
log.debug("Failed to complete emitter for client {}: {}", clientId, e.getMessage());
|
||||
log.debug("Failed to complete alert emitter: {}", e.getClass().getSimpleName());
|
||||
}
|
||||
// execute clear
|
||||
removeEmitter(clientId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -98,7 +145,7 @@ public class AlertSseManager implements ApplicationListener<ContextClosedEvent>
|
||||
activeEmitters.forEach(this::tryCompleteAndClean);
|
||||
}
|
||||
|
||||
private void removeEmitter(Long clientId) {
|
||||
emitters.remove(clientId);
|
||||
private void removeEmitter(Long clientId, SseEmitter emitter) {
|
||||
emitters.remove(clientId, emitter);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-18
@@ -17,15 +17,13 @@
|
||||
|
||||
package org.apache.hertzbeat.alert.controller;
|
||||
|
||||
import static org.apache.hertzbeat.common.constants.CommonConstants.MONITOR_NOT_EXIST_CODE;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.Objects;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitResponse;
|
||||
import org.apache.hertzbeat.alert.service.AlertInhibitService;
|
||||
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
|
||||
import org.apache.hertzbeat.common.entity.dto.Message;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -50,30 +48,24 @@ public class AlertInhibitController {
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "New Alarm Inhibit", description = "Added an alarm Inhibit")
|
||||
public ResponseEntity<Message<Void>> addNewAlertInhibit(@Valid @RequestBody AlertInhibit alertInhibit) {
|
||||
alertInhibitService.validate(alertInhibit, false);
|
||||
alertInhibitService.addAlertInhibit(alertInhibit);
|
||||
return ResponseEntity.ok(Message.success("Add success"));
|
||||
public ResponseEntity<Message<AlertInhibitResponse>> addNewAlertInhibit(
|
||||
@RequestBody AlertInhibitRequest request) {
|
||||
return ResponseEntity.ok(Message.success(alertInhibitService.create(request)));
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
@Operation(summary = "Modifying an Alarm Inhibit", description = "Modify an existing alarm Inhibit")
|
||||
public ResponseEntity<Message<Void>> modifyAlertInhibit(@Valid @RequestBody AlertInhibit alertInhibit) {
|
||||
alertInhibitService.validate(alertInhibit, true);
|
||||
alertInhibitService.modifyAlertInhibit(alertInhibit);
|
||||
return ResponseEntity.ok(Message.success("Modify success"));
|
||||
public ResponseEntity<Message<AlertInhibitResponse>> modifyAlertInhibit(
|
||||
@RequestBody AlertInhibitRequest request) {
|
||||
return ResponseEntity.ok(Message.success(alertInhibitService.update(request)));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/{id}")
|
||||
@Operation(summary = "Querying Alarm Inhibit",
|
||||
description = "You can obtain alarm Inhibit information based on the alarm Inhibit ID")
|
||||
public ResponseEntity<Message<AlertInhibit>> getAlertInhibit(
|
||||
public ResponseEntity<Message<AlertInhibitResponse>> getAlertInhibit(
|
||||
@Parameter(description = "Alarm Inhibit ID", example = "6565463543") @PathVariable("id") long id) {
|
||||
AlertInhibit alertInhibit = alertInhibitService.getAlertInhibit(id);
|
||||
|
||||
return Objects.isNull(alertInhibit)
|
||||
? ResponseEntity.ok(Message.fail(MONITOR_NOT_EXIST_CODE, "AlertInhibit not exist."))
|
||||
: ResponseEntity.ok(Message.success(alertInhibit));
|
||||
return ResponseEntity.ok(Message.success(alertInhibitService.get(id)));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.controller;
|
||||
|
||||
import static org.apache.hertzbeat.common.constants.CommonConstants.FAIL_CODE;
|
||||
import static org.apache.hertzbeat.common.constants.CommonConstants.MONITOR_NOT_EXIST_CODE;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hertzbeat.alert.service.AlertInhibitNotFoundException;
|
||||
import org.apache.hertzbeat.common.entity.dto.Message;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/** Safe and non-reflective failure mapping for the two existing alert-inhibit controllers. */
|
||||
@Slf4j
|
||||
@RestControllerAdvice(assignableTypes = {AlertInhibitController.class, AlertInhibitsController.class})
|
||||
public class AlertInhibitControllerAdvice {
|
||||
|
||||
@ExceptionHandler(AlertInhibitNotFoundException.class)
|
||||
public ResponseEntity<Message<Void>> missing() {
|
||||
return ResponseEntity.ok(Message.fail(MONITOR_NOT_EXIST_CODE, "AlertInhibit not exist."));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataAccessException.class)
|
||||
public ResponseEntity<Message<Void>> unavailable(DataAccessException exception) {
|
||||
log.error("Alert inhibit storage unavailable: {}", exception.getClass().getSimpleName());
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Alert inhibit storage unavailable"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Message<Void>> invalid() {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Message.fail(FAIL_CODE, "Invalid alert inhibit request"));
|
||||
}
|
||||
|
||||
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentNotValidException.class})
|
||||
public ResponseEntity<Message<Void>> malformed() {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Message.fail(FAIL_CODE, "Invalid alert inhibit request"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Message<Void>> error(Exception exception) {
|
||||
log.error("Alert inhibit operation error: {}", exception.getClass().getSimpleName());
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Alert inhibit operation error"));
|
||||
}
|
||||
}
|
||||
+8
-11
@@ -23,11 +23,11 @@ import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitDeleteResponse;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitPageResponse;
|
||||
import org.apache.hertzbeat.alert.service.AlertInhibitService;
|
||||
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
|
||||
import org.apache.hertzbeat.common.entity.dto.Message;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -49,28 +49,25 @@ public class AlertInhibitsController {
|
||||
@GetMapping
|
||||
@Operation(summary = "Query the alarm inhibit list",
|
||||
description = "You can obtain the list of alarm inhibit by querying filter items")
|
||||
public ResponseEntity<Message<Page<AlertInhibit>>> getAlertInhibits(
|
||||
public ResponseEntity<Message<AlertInhibitPageResponse>> getAlertInhibits(
|
||||
@Parameter(description = "Alarm Inhibit ID", example = "6565463543") @RequestParam(required = false) List<Long> ids,
|
||||
@Parameter(description = "Search Name", example = "x") @RequestParam(required = false) String search,
|
||||
@Parameter(description = "Sort field, default id", example = "id") @RequestParam(defaultValue = "id") String sort,
|
||||
@Parameter(description = "Sort mode: asc: ascending, desc: descending", example = "desc") @RequestParam(defaultValue = "desc") String order,
|
||||
@Parameter(description = "List current page", example = "0") @RequestParam(defaultValue = "0") int pageIndex,
|
||||
@Parameter(description = "Number of list pages", example = "8") @RequestParam(defaultValue = "8") int pageSize) {
|
||||
Page<AlertInhibit> alertInhibitPage = alertInhibitService.getAlertInhibits(ids, search, sort, order, pageIndex, pageSize);
|
||||
return ResponseEntity.ok(Message.success(alertInhibitPage));
|
||||
return ResponseEntity.ok(Message.success(
|
||||
alertInhibitService.list(ids, search, sort, order, pageIndex, pageSize)));
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(summary = "Delete alarm inhibit in batches",
|
||||
description = "Delete alarm inhibit in batches based on the alarm inhibit ID list")
|
||||
public ResponseEntity<Message<Void>> deleteAlertDefines(
|
||||
public ResponseEntity<Message<AlertInhibitDeleteResponse>> deleteAlertDefines(
|
||||
@Parameter(description = "Alarm Inhibit IDs", example = "6565463543") @RequestParam(required = false) List<Long> ids
|
||||
) {
|
||||
if (ids != null && !ids.isEmpty()) {
|
||||
alertInhibitService.deleteAlertInhibits(new HashSet<>(ids));
|
||||
}
|
||||
Message<Void> message = Message.success();
|
||||
return ResponseEntity.ok(message);
|
||||
return ResponseEntity.ok(Message.success(
|
||||
alertInhibitService.delete(ids == null ? null : new HashSet<>(ids))));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.CatalogResponse;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.IntegrationGuide;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationRequestException;
|
||||
import org.apache.hertzbeat.alert.integration.service.AlertIntegrationCatalogService;
|
||||
import org.apache.hertzbeat.common.constants.CommonConstants;
|
||||
import org.apache.hertzbeat.common.entity.dto.Message;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Read-only catalog for external alert integrations.
|
||||
*/
|
||||
@Tag(name = "External Alert Integration Catalog API")
|
||||
@RestController
|
||||
@RequestMapping("/api/alerts/integrations")
|
||||
public class AlertIntegrationCatalogController {
|
||||
|
||||
private final AlertIntegrationCatalogService service;
|
||||
|
||||
public AlertIntegrationCatalogController(AlertIntegrationCatalogService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List external alert integrations")
|
||||
public ResponseEntity<Message<CatalogResponse>> catalog() {
|
||||
return ResponseEntity.ok(Message.success(service.catalog()));
|
||||
}
|
||||
|
||||
@GetMapping("/{source}")
|
||||
@Operation(summary = "Render one external alert integration")
|
||||
public ResponseEntity<Message<IntegrationGuide>> render(@PathVariable String source) {
|
||||
return ResponseEntity.ok(Message.success(service.render(source)));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AlertIntegrationRequestException.class)
|
||||
public ResponseEntity<Message<Void>> handleRequestFailure(AlertIntegrationRequestException exception) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Message.fail(CommonConstants.FAIL_CODE, exception.errorCode().code()));
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -49,7 +49,8 @@ public class AlertSilenceControllerAdvice {
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Message<Void>> invalid() {
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Invalid alert silence request"));
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Message.fail(FAIL_CODE, "Invalid alert silence request"));
|
||||
}
|
||||
|
||||
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentNotValidException.class})
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.controller;
|
||||
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.hertzbeat.alert.dto.AlertSummary;
|
||||
import org.apache.hertzbeat.alert.service.AlertService;
|
||||
import org.apache.hertzbeat.common.entity.dto.Message;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Dashboard alert summary read API.
|
||||
*/
|
||||
@Tag(name = "Alarm Summary API")
|
||||
@RestController
|
||||
@RequestMapping(path = "/api/alerts/summary", produces = {APPLICATION_JSON_VALUE})
|
||||
@RequiredArgsConstructor
|
||||
public class AlertSummaryController {
|
||||
|
||||
private final AlertService alertService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Get alarm statistics", description = "Get alarm statistics information")
|
||||
public ResponseEntity<Message<AlertSummary>> getAlertsSummary() {
|
||||
return ResponseEntity.ok(Message.success(alertService.getAlertsSummary()));
|
||||
}
|
||||
}
|
||||
+52
-17
@@ -17,13 +17,18 @@
|
||||
|
||||
package org.apache.hertzbeat.alert.controller;
|
||||
|
||||
import static org.apache.hertzbeat.common.constants.CommonConstants.FAIL_CODE;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import org.apache.hertzbeat.alert.dto.AlertSummary;
|
||||
import org.apache.hertzbeat.alert.dto.AlertGroupEvidence;
|
||||
import org.apache.hertzbeat.alert.service.AlertGroupEvidenceRequestException;
|
||||
import org.apache.hertzbeat.alert.service.AlertGroupEvidenceService;
|
||||
import org.apache.hertzbeat.alert.service.AlertGroupNotFoundException;
|
||||
import org.apache.hertzbeat.alert.service.AlertGroupStatusNotSupportedException;
|
||||
import org.apache.hertzbeat.alert.service.AlertService;
|
||||
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
|
||||
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
|
||||
@@ -47,9 +52,21 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping(path = "/api/alerts", produces = {APPLICATION_JSON_VALUE})
|
||||
public class AlertsController {
|
||||
|
||||
private static final String ALERT_GROUP_NOT_FOUND_MESSAGE = "Alert group was not found.";
|
||||
private static final String ALERT_GROUP_DELETE_FAILED_MESSAGE = "Alert group delete failed.";
|
||||
private static final String ALERT_GROUP_STATUS_NOT_SUPPORTED_MESSAGE = "Alert group status is not supported.";
|
||||
private static final String ALERT_GROUP_STATUS_UPDATE_FAILED_MESSAGE = "Alert group status update failed.";
|
||||
private static final String INVALID_ALERT_GROUP_EVIDENCE_REQUEST_MESSAGE =
|
||||
"Invalid alert group evidence request.";
|
||||
private static final String ALERT_GROUP_EVIDENCE_QUERY_FAILED_MESSAGE =
|
||||
"Alert group evidence query failed.";
|
||||
|
||||
@Autowired
|
||||
private AlertService alertService;
|
||||
|
||||
@Autowired
|
||||
private AlertGroupEvidenceService alertGroupEvidenceService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Query Alarms")
|
||||
public ResponseEntity<Message<Page<SingleAlert>>> getAlerts(
|
||||
@@ -81,15 +98,34 @@ public class AlertsController {
|
||||
return ResponseEntity.ok(Message.success(alertPage));
|
||||
}
|
||||
|
||||
@GetMapping("/group/evidence")
|
||||
@Operation(summary = "Query canonical alert group evidence by ID")
|
||||
public ResponseEntity<Message<AlertGroupEvidence>> getGroupAlertEvidence(
|
||||
@Parameter(description = "Alert group ID list", example = "6565463543")
|
||||
@RequestParam(required = false) List<String> ids) {
|
||||
try {
|
||||
return ResponseEntity.ok(Message.success(alertGroupEvidenceService.getEvidence(ids)));
|
||||
} catch (AlertGroupEvidenceRequestException exception) {
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, INVALID_ALERT_GROUP_EVIDENCE_REQUEST_MESSAGE));
|
||||
} catch (Exception exception) {
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, ALERT_GROUP_EVIDENCE_QUERY_FAILED_MESSAGE));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/group")
|
||||
@Operation(summary = "Delete group alarms in batches", description = "according to the alarm ID list to delete the alarm information in batches")
|
||||
public ResponseEntity<Message<Void>> deleteAlerts(
|
||||
@Parameter(description = "Alarm List ID", example = "6565463543") @RequestParam(required = false) List<Long> ids) {
|
||||
if (ids != null && !ids.isEmpty()) {
|
||||
alertService.deleteGroupAlerts(new HashSet<>(ids));
|
||||
try {
|
||||
if (ids != null && !ids.isEmpty()) {
|
||||
alertService.deleteGroupAlerts(new HashSet<>(ids));
|
||||
}
|
||||
return ResponseEntity.ok(Message.success());
|
||||
} catch (AlertGroupNotFoundException exception) {
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, ALERT_GROUP_NOT_FOUND_MESSAGE));
|
||||
} catch (Exception exception) {
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, ALERT_GROUP_DELETE_FAILED_MESSAGE));
|
||||
}
|
||||
Message<Void> message = Message.success();
|
||||
return ResponseEntity.ok(message);
|
||||
}
|
||||
|
||||
@PutMapping(path = "/group/status/{status}")
|
||||
@@ -98,11 +134,18 @@ public class AlertsController {
|
||||
public ResponseEntity<Message<Void>> applyAlertDefinesStatus(
|
||||
@Parameter(description = "Alarm status value", example = "acknowledged") @PathVariable String status,
|
||||
@Parameter(description = "Alarm List IDS", example = "6565463543") @RequestParam(required = false) List<Long> ids) {
|
||||
if (ids != null && status != null && !ids.isEmpty()) {
|
||||
alertService.editGroupAlertStatus(status, ids);
|
||||
try {
|
||||
if (ids != null && status != null && !ids.isEmpty()) {
|
||||
alertService.editGroupAlertStatus(status, ids);
|
||||
}
|
||||
return ResponseEntity.ok(Message.success());
|
||||
} catch (AlertGroupStatusNotSupportedException exception) {
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, ALERT_GROUP_STATUS_NOT_SUPPORTED_MESSAGE));
|
||||
} catch (AlertGroupNotFoundException exception) {
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, ALERT_GROUP_NOT_FOUND_MESSAGE));
|
||||
} catch (Exception exception) {
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, ALERT_GROUP_STATUS_UPDATE_FAILED_MESSAGE));
|
||||
}
|
||||
Message<Void> message = Message.success();
|
||||
return ResponseEntity.ok(message);
|
||||
}
|
||||
|
||||
@PutMapping(path = "/status/{status}")
|
||||
@@ -118,12 +161,4 @@ public class AlertsController {
|
||||
return ResponseEntity.ok(message);
|
||||
}
|
||||
|
||||
@GetMapping(path = "/summary")
|
||||
@Operation(summary = "Get alarm statistics", description = "Get alarm statistics information")
|
||||
public ResponseEntity<Message<AlertSummary>> getAlertsSummary() {
|
||||
AlertSummary alertSummary = alertService.getAlertsSummary();
|
||||
Message<AlertSummary> message = Message.success(alertSummary);
|
||||
return ResponseEntity.ok(message);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+80
-17
@@ -31,6 +31,7 @@ import org.apache.hertzbeat.alert.dto.NoticeReceiverOptionResponse;
|
||||
import org.apache.hertzbeat.alert.dto.NoticeReceiverRequest;
|
||||
import org.apache.hertzbeat.alert.dto.NoticeReceiverResponse;
|
||||
import org.apache.hertzbeat.alert.service.NoticeReceiverContractService;
|
||||
import org.apache.hertzbeat.alert.service.NoticeTemplateMutationException;
|
||||
import org.apache.hertzbeat.common.entity.dto.Message;
|
||||
import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
|
||||
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
|
||||
@@ -58,6 +59,12 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@Slf4j
|
||||
public class NoticeConfigController {
|
||||
|
||||
private static final String NOTICE_TEMPLATE_INVALID_MESSAGE = "Notice template request is invalid.";
|
||||
private static final String NOTICE_TEMPLATE_NOT_FOUND_MESSAGE = "Notice template was not found.";
|
||||
private static final String NOTICE_TEMPLATE_READ_ONLY_MESSAGE = "Preset notice templates are read-only.";
|
||||
private static final String NOTICE_TEMPLATE_STORAGE_UNAVAILABLE_MESSAGE = "Notice template storage is unavailable.";
|
||||
private static final String NOTICE_TEMPLATE_OPERATION_FAILED_MESSAGE = "Notice template operation failed.";
|
||||
|
||||
@Autowired
|
||||
private NoticeConfigService noticeConfigService;
|
||||
|
||||
@@ -202,28 +209,47 @@ public class NoticeConfigController {
|
||||
@PostMapping(path = "/template")
|
||||
@Operation(summary = "Add a notification template", description = "Add a notification template")
|
||||
public ResponseEntity<Message<Void>> addNewNoticeTemplate(@Valid @RequestBody NoticeTemplate noticeTemplate) {
|
||||
noticeConfigService.addNoticeTemplate(noticeTemplate);
|
||||
return ResponseEntity.ok(Message.success("Add success"));
|
||||
try {
|
||||
noticeConfigService.addNoticeTemplate(noticeTemplate);
|
||||
return ResponseEntity.ok(Message.success("Add success"));
|
||||
} catch (NoticeTemplateMutationException exception) {
|
||||
return templateMutationFailure(exception);
|
||||
} catch (DataAccessException exception) {
|
||||
return templateStorageUnavailable("create", exception);
|
||||
} catch (Exception exception) {
|
||||
return templateOperationFailed("create", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping(path = "/template")
|
||||
@Operation(summary = "Modify existing notification template information", description = "Modify existing notification template information")
|
||||
public ResponseEntity<Message<Void>> editNoticeTemplate(@Valid @RequestBody NoticeTemplate noticeTemplate) {
|
||||
noticeConfigService.editNoticeTemplate(noticeTemplate);
|
||||
return ResponseEntity.ok(Message.success("Edit success"));
|
||||
try {
|
||||
noticeConfigService.editNoticeTemplate(noticeTemplate);
|
||||
return ResponseEntity.ok(Message.success("Edit success"));
|
||||
} catch (NoticeTemplateMutationException exception) {
|
||||
return templateMutationFailure(exception);
|
||||
} catch (DataAccessException exception) {
|
||||
return templateStorageUnavailable("update", exception);
|
||||
} catch (Exception exception) {
|
||||
return templateOperationFailed("update", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping(path = "/template/{id}")
|
||||
@Operation(summary = "Delete existing notification template information", description = "Delete existing notification template information")
|
||||
public ResponseEntity<Message<Void>> deleteNoticeTemplate(
|
||||
@Parameter(description = "en: Notification template ID", example = "6565463543") @PathVariable("id") final Long templateId) {
|
||||
// Returns success if it does not exist or if the deletion is successful
|
||||
Optional<NoticeTemplate> noticeTemplate = noticeConfigService.getNoticeTemplatesById(templateId);
|
||||
if (noticeTemplate.isEmpty()) {
|
||||
return ResponseEntity.ok(Message.success("The specified notification template could not be queried, please check whether the parameters are correct"));
|
||||
try {
|
||||
noticeConfigService.deleteNoticeTemplate(templateId);
|
||||
return ResponseEntity.ok(Message.success("Delete success"));
|
||||
} catch (NoticeTemplateMutationException exception) {
|
||||
return templateMutationFailure(exception);
|
||||
} catch (DataAccessException exception) {
|
||||
return templateStorageUnavailable("delete", exception);
|
||||
} catch (Exception exception) {
|
||||
return templateOperationFailed("delete", exception);
|
||||
}
|
||||
noticeConfigService.deleteNoticeTemplate(templateId);
|
||||
return ResponseEntity.ok(Message.success("Delete success"));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/templates")
|
||||
@@ -234,15 +260,27 @@ public class NoticeConfigController {
|
||||
@Parameter(description = "Whether it is a preset template", example = "true") @RequestParam(defaultValue = "true") final boolean preset,
|
||||
@Parameter(description = "List current page", example = "0") @RequestParam(defaultValue = "0") final int pageIndex,
|
||||
@Parameter(description = "Number of list pages", example = "8") @RequestParam(defaultValue = "8") final int pageSize) {
|
||||
Page<NoticeTemplate> templatePage = noticeConfigService.getNoticeTemplates(name, preset, pageIndex, pageSize);
|
||||
return ResponseEntity.ok(Message.success(templatePage));
|
||||
try {
|
||||
Page<NoticeTemplate> templatePage = noticeConfigService.getNoticeTemplates(name, preset, pageIndex, pageSize);
|
||||
return ResponseEntity.ok(Message.success(templatePage));
|
||||
} catch (DataAccessException exception) {
|
||||
return templateStorageUnavailable("list", exception);
|
||||
} catch (Exception exception) {
|
||||
return templateOperationFailed("list", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping(path = "/templates/all")
|
||||
@Operation(summary = "Get a list of all message notification templates",
|
||||
description = "Get a list of all message notification templates")
|
||||
public ResponseEntity<Message<List<NoticeTemplate>>> getAllTemplates() {
|
||||
return ResponseEntity.ok(Message.success(noticeConfigService.getAllNoticeTemplates()));
|
||||
try {
|
||||
return ResponseEntity.ok(Message.success(noticeConfigService.getAllNoticeTemplates()));
|
||||
} catch (DataAccessException exception) {
|
||||
return templateStorageUnavailable("list all", exception);
|
||||
} catch (Exception exception) {
|
||||
return templateOperationFailed("list all", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping(path = "/template/{id}")
|
||||
@@ -250,11 +288,17 @@ public class NoticeConfigController {
|
||||
description = "Get the notification template information based on the template ID")
|
||||
public ResponseEntity<Message<NoticeTemplate>> getTemplateById(
|
||||
@Parameter(description = "en: Notification template ID", example = "6565463543") @PathVariable("id") final Long templateId) {
|
||||
Optional<NoticeTemplate> noticeTemplate = noticeConfigService.getNoticeTemplatesById(templateId);
|
||||
if (noticeTemplate.isEmpty()) {
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, "The specified notification template could not be queried, please check whether the parameters are correct or refresh the page"));
|
||||
try {
|
||||
Optional<NoticeTemplate> noticeTemplate = noticeConfigService.getNoticeTemplatesById(templateId);
|
||||
if (noticeTemplate.isEmpty()) {
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, NOTICE_TEMPLATE_NOT_FOUND_MESSAGE));
|
||||
}
|
||||
return ResponseEntity.ok(Message.success(noticeTemplate.get()));
|
||||
} catch (DataAccessException exception) {
|
||||
return templateStorageUnavailable("detail", exception);
|
||||
} catch (Exception exception) {
|
||||
return templateOperationFailed("detail", exception);
|
||||
}
|
||||
return ResponseEntity.ok(Message.success(noticeTemplate.get()));
|
||||
}
|
||||
|
||||
@PostMapping(path = "/receiver/send-test-msg")
|
||||
@@ -273,6 +317,25 @@ public class NoticeConfigController {
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<Message<Void>> templateMutationFailure(NoticeTemplateMutationException exception) {
|
||||
String message = switch (exception.getReason()) {
|
||||
case INVALID_REQUEST -> NOTICE_TEMPLATE_INVALID_MESSAGE;
|
||||
case NOT_FOUND -> NOTICE_TEMPLATE_NOT_FOUND_MESSAGE;
|
||||
case READ_ONLY -> NOTICE_TEMPLATE_READ_ONLY_MESSAGE;
|
||||
};
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, message));
|
||||
}
|
||||
|
||||
private <T> ResponseEntity<Message<T>> templateStorageUnavailable(String operation, Exception exception) {
|
||||
log.error("notice template {} storage unavailable: {}", operation, exception.getClass().getSimpleName());
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, NOTICE_TEMPLATE_STORAGE_UNAVAILABLE_MESSAGE));
|
||||
}
|
||||
|
||||
private <T> ResponseEntity<Message<T>> templateOperationFailed(String operation, Exception exception) {
|
||||
log.error("notice template {} failed: {}", operation, exception.getClass().getSimpleName());
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, NOTICE_TEMPLATE_OPERATION_FAILED_MESSAGE));
|
||||
}
|
||||
|
||||
private <T> ResponseEntity<Message<T>> receiverStorageUnavailable(String operation, Exception exception) {
|
||||
log.error("receiver {} storage unavailable: {}", operation, exception.getClass().getSimpleName());
|
||||
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Receiver storage unavailable"));
|
||||
|
||||
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.alert.dao;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import org.apache.hertzbeat.alert.dto.AlertGroupStatusEvidence;
|
||||
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
@@ -60,4 +61,13 @@ public interface GroupAlertDao extends JpaRepository<GroupAlert, Long>, JpaSpeci
|
||||
* @return group alerts
|
||||
*/
|
||||
List<GroupAlert> findGroupAlertsByIdIn(HashSet<Long> ids);
|
||||
|
||||
/**
|
||||
* Find only persisted status evidence for the requested group IDs.
|
||||
* @param ids requested group IDs
|
||||
* @return minimal ID/status projections
|
||||
*/
|
||||
@Query("select new org.apache.hertzbeat.alert.dto.AlertGroupStatusEvidence(alert.id, alert.status) "
|
||||
+ "from GroupAlert alert where alert.id in :ids")
|
||||
List<AlertGroupStatusEvidence> findStatusEvidenceByIdIn(@Param("ids") List<Long> ids);
|
||||
}
|
||||
|
||||
@@ -17,13 +17,21 @@
|
||||
|
||||
package org.apache.hertzbeat.alert.dao;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* Query all enabled notification policies
|
||||
*/
|
||||
public interface NoticeTemplateDao extends JpaRepository<NoticeTemplate, Long>, JpaSpecificationExecutor<NoticeTemplate> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select template from NoticeTemplate template where template.id = :id")
|
||||
Optional<NoticeTemplate> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ public class AlertDefineDTO {
|
||||
private String name;
|
||||
@Excel(name = "Type")
|
||||
private String type;
|
||||
@Excel(name = "Datasource")
|
||||
private String datasource;
|
||||
@Excel(name = "Expr")
|
||||
private String expr;
|
||||
@Excel(name = "Period")
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 java.util.List;
|
||||
|
||||
/**
|
||||
* Canonical group-alert evidence for a bounded set of requested IDs.
|
||||
*/
|
||||
public record AlertGroupEvidence(
|
||||
List<AlertGroupStatusEvidence> groups,
|
||||
List<Long> missingIds,
|
||||
long observedAt) {
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Minimal persisted state for one existing alert group.
|
||||
*/
|
||||
public record AlertGroupStatusEvidence(Long id, String status) {
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 java.util.Set;
|
||||
|
||||
/** Authoritative batch-delete outcome. */
|
||||
public record AlertInhibitDeleteResponse(String status, Set<Long> deletedIds, Set<Long> missingIds) {
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 java.util.List;
|
||||
|
||||
/** Stable pagination response independent of the persistence entity. */
|
||||
public record AlertInhibitPageResponse(
|
||||
List<AlertInhibitResponse> content,
|
||||
long totalElements,
|
||||
int totalPages,
|
||||
int number,
|
||||
int size) {
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.JsonAnySetter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.Data;
|
||||
|
||||
/** Persistence-safe create/update input for an alert inhibit rule. */
|
||||
@Data
|
||||
public class AlertInhibitRequest {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private Map<String, String> sourceLabels;
|
||||
private Map<String, String> targetLabels;
|
||||
private List<String> equalLabels;
|
||||
private Boolean enable;
|
||||
|
||||
@JsonAnySetter
|
||||
public void rejectUnknownField(String name, Object value) {
|
||||
throw new IllegalArgumentException("Unsupported alert inhibit field: " + name);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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 java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Explicit read model for an alert inhibit rule. */
|
||||
public record AlertInhibitResponse(
|
||||
Long id,
|
||||
String name,
|
||||
Map<String, String> sourceLabels,
|
||||
Map<String, String> targetLabels,
|
||||
List<String> equalLabels,
|
||||
boolean enable,
|
||||
String creator,
|
||||
String modifier,
|
||||
LocalDateTime gmtCreate,
|
||||
LocalDateTime gmtUpdate) {
|
||||
}
|
||||
+11
-6
@@ -27,6 +27,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -39,11 +40,15 @@ public class AlertExpressionEvalVisitor extends AlertExpressionBaseVisitor<List<
|
||||
private static final String VALUE = "__value__";
|
||||
private static final String TIMESTAMP = "__timestamp__";
|
||||
|
||||
private final QueryExecutor executor;
|
||||
private final Function<String, List<Map<String, Object>>> executeQuery;
|
||||
private final CommonTokenStream tokens;
|
||||
|
||||
public AlertExpressionEvalVisitor(QueryExecutor executor, CommonTokenStream tokens) {
|
||||
this.executor = executor;
|
||||
this(executor, tokens, false);
|
||||
}
|
||||
|
||||
public AlertExpressionEvalVisitor(QueryExecutor executor, CommonTokenStream tokens, boolean preview) {
|
||||
this.executeQuery = preview ? executor::executePreview : executor::execute;
|
||||
this.tokens = tokens;
|
||||
}
|
||||
|
||||
@@ -248,13 +253,13 @@ public class AlertExpressionEvalVisitor extends AlertExpressionBaseVisitor<List<
|
||||
@Override
|
||||
public List<Map<String, Object>> visitPromqlExpr(AlertExpressionParser.PromqlExprContext ctx) {
|
||||
String rawPromql = tokens.getText(ctx.promql());
|
||||
return executor.execute(rawPromql);
|
||||
return executeQuery.apply(rawPromql);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> visitSqlExpr(AlertExpressionParser.SqlExprContext ctx) {
|
||||
String rawSql = tokens.getText(ctx.selectSql());
|
||||
return executor.execute(rawSql);
|
||||
return executeQuery.apply(rawSql);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -269,7 +274,7 @@ public class AlertExpressionEvalVisitor extends AlertExpressionBaseVisitor<List<
|
||||
|
||||
private List<Map<String, Object>> callSqlOrPromql(String text) {
|
||||
String script = text.substring(1, text.length() - 1);
|
||||
return executor.execute(script);
|
||||
return executeQuery.apply(script);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,4 +324,4 @@ public class AlertExpressionEvalVisitor extends AlertExpressionBaseVisitor<List<
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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.integration.api;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Structured read contract for external alert integrations.
|
||||
*/
|
||||
public final class AlertIntegrationApiContract {
|
||||
|
||||
private AlertIntegrationApiContract() {
|
||||
}
|
||||
|
||||
/**
|
||||
* How honestly the current guide can describe a runnable integration.
|
||||
*/
|
||||
public enum Readiness {
|
||||
@JsonProperty("ready")
|
||||
READY,
|
||||
@JsonProperty("configuration_required")
|
||||
CONFIGURATION_REQUIRED,
|
||||
@JsonProperty("guide_blocked")
|
||||
GUIDE_BLOCKED
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe public error codes.
|
||||
*/
|
||||
public enum RequestErrorCode {
|
||||
SOURCE_UNSUPPORTED("external_alert_source_unsupported"),
|
||||
GUIDE_UNAVAILABLE("external_alert_guide_unavailable");
|
||||
|
||||
private final String code;
|
||||
|
||||
RequestErrorCode(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog response.
|
||||
*/
|
||||
public record CatalogResponse(List<CatalogItem> items) {
|
||||
public CatalogResponse {
|
||||
items = List.copyOf(items);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight catalog row.
|
||||
*/
|
||||
public record CatalogItem(
|
||||
String source,
|
||||
String displayNameKey,
|
||||
String iconKey,
|
||||
Readiness readiness,
|
||||
List<String> limitations) {
|
||||
public CatalogItem {
|
||||
limitations = List.copyOf(limitations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One rendered, token-free integration guide.
|
||||
*/
|
||||
public record IntegrationGuide(
|
||||
String source,
|
||||
String displayNameKey,
|
||||
String iconKey,
|
||||
String method,
|
||||
String ingressPath,
|
||||
String payloadShape,
|
||||
Map<String, String> requiredHeaders,
|
||||
List<String> requiredFields,
|
||||
List<String> steps,
|
||||
List<String> snippets,
|
||||
String acknowledgement,
|
||||
Readiness readiness,
|
||||
List<String> limitations) {
|
||||
public IntegrationGuide {
|
||||
requiredHeaders = Map.copyOf(requiredHeaders);
|
||||
requiredFields = List.copyOf(requiredFields);
|
||||
steps = List.copyOf(steps);
|
||||
snippets = List.copyOf(snippets);
|
||||
limitations = List.copyOf(limitations);
|
||||
}
|
||||
|
||||
public CatalogItem toCatalogItem() {
|
||||
return new CatalogItem(source, displayNameKey, iconKey, readiness, limitations);
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.integration.api;
|
||||
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.RequestErrorCode;
|
||||
|
||||
/**
|
||||
* Cause-free request failure with a stable safe public code.
|
||||
*/
|
||||
public class AlertIntegrationRequestException extends IllegalArgumentException {
|
||||
|
||||
private final RequestErrorCode errorCode;
|
||||
|
||||
private AlertIntegrationRequestException(RequestErrorCode errorCode) {
|
||||
super(errorCode.code());
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public static AlertIntegrationRequestException sourceUnsupported() {
|
||||
return new AlertIntegrationRequestException(RequestErrorCode.SOURCE_UNSUPPORTED);
|
||||
}
|
||||
|
||||
public static AlertIntegrationRequestException guideUnavailable() {
|
||||
return new AlertIntegrationRequestException(RequestErrorCode.GUIDE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
public RequestErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.integration.guide;
|
||||
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.IntegrationGuide;
|
||||
|
||||
/**
|
||||
* Maps one registered ingress bean source to its public integration guide.
|
||||
*/
|
||||
public record AlertIntegrationDescriptor(String ingressSource, IntegrationGuide guide) {
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* 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.integration.guide;
|
||||
|
||||
import static org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.CONFIGURATION_REQUIRED;
|
||||
import static org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.GUIDE_BLOCKED;
|
||||
import static org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.READY;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.IntegrationGuide;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Frozen structured descriptors for the currently registered alert ingress adapters.
|
||||
*/
|
||||
@Component
|
||||
public class AlertIntegrationDescriptorRegistry {
|
||||
|
||||
private static final String ACKNOWLEDGEMENT = "alert.integration.ack.accepted_for_processing";
|
||||
private static final String AUTHORIZATION_REQUIRED =
|
||||
"alert.integration.limit.bearer_configuration_required";
|
||||
|
||||
private final List<AlertIntegrationDescriptor> descriptors;
|
||||
private final Set<String> ingressSources;
|
||||
private final Map<String, AlertIntegrationDescriptor> byPublicSource;
|
||||
|
||||
public AlertIntegrationDescriptorRegistry() {
|
||||
this(officialDescriptors());
|
||||
}
|
||||
|
||||
AlertIntegrationDescriptorRegistry(List<AlertIntegrationDescriptor> descriptors) {
|
||||
LinkedHashMap<String, AlertIntegrationDescriptor> ingressIndex = new LinkedHashMap<>();
|
||||
LinkedHashMap<String, AlertIntegrationDescriptor> publicIndex = new LinkedHashMap<>();
|
||||
for (AlertIntegrationDescriptor descriptor : descriptors) {
|
||||
if (descriptor == null || descriptor.guide() == null
|
||||
|| ingressIndex.put(descriptor.ingressSource(), descriptor) != null
|
||||
|| publicIndex.put(descriptor.guide().source(), descriptor) != null) {
|
||||
throw new IllegalStateException("Duplicate or invalid alert integration descriptor");
|
||||
}
|
||||
}
|
||||
this.descriptors = List.copyOf(descriptors);
|
||||
this.ingressSources = Set.copyOf(ingressIndex.keySet());
|
||||
this.byPublicSource = Map.copyOf(publicIndex);
|
||||
}
|
||||
|
||||
public List<AlertIntegrationDescriptor> descriptors() {
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
public Set<String> ingressSources() {
|
||||
return ingressSources;
|
||||
}
|
||||
|
||||
public AlertIntegrationDescriptor findByPublicSource(String source) {
|
||||
return byPublicSource.get(source);
|
||||
}
|
||||
|
||||
public static AlertIntegrationDescriptorRegistry official() {
|
||||
return new AlertIntegrationDescriptorRegistry();
|
||||
}
|
||||
|
||||
private static List<AlertIntegrationDescriptor> officialDescriptors() {
|
||||
return List.of(
|
||||
descriptor("default", guide(
|
||||
"webhook",
|
||||
"hertzbeat",
|
||||
"/api/alerts/report",
|
||||
"single_alert",
|
||||
List.of("labels", "content", "status", "startAt"),
|
||||
List.of(
|
||||
"alert.integration.webhook.step.create_token",
|
||||
"alert.integration.webhook.step.configure_request",
|
||||
"alert.integration.webhook.step.verify_alert"),
|
||||
List.of("""
|
||||
{
|
||||
"labels": {"alertname": "HighCPUUsage", "instance": "server-1"},
|
||||
"annotations": {"summary": "High CPU usage"},
|
||||
"content": "CPU usage exceeded the configured threshold.",
|
||||
"status": "firing",
|
||||
"triggerTimes": 1,
|
||||
"startAt": 1736580031832,
|
||||
"activeAt": 1736580031832,
|
||||
"endAt": null
|
||||
}"""),
|
||||
READY,
|
||||
List.of())),
|
||||
descriptor("prometheus", guide(
|
||||
"prometheus",
|
||||
"prometheus",
|
||||
"/api/v2/alerts",
|
||||
"prometheus_alert_array",
|
||||
List.of("[].labels", "[].annotations", "[].startsAt", "[].endsAt"),
|
||||
List.of(
|
||||
"alert.integration.prometheus.step.create_token",
|
||||
"alert.integration.prometheus.step.configure_alertmanager_target",
|
||||
"alert.integration.prometheus.step.verify_alert"),
|
||||
List.of("""
|
||||
[
|
||||
{
|
||||
"labels": {"alertname": "HighCPUUsage", "instance": "server-1"},
|
||||
"annotations": {"summary": "High CPU usage"},
|
||||
"startsAt": "2026-01-01T00:00:00Z",
|
||||
"endsAt": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
]"""),
|
||||
READY,
|
||||
List.of())),
|
||||
descriptor("alertmanager", guide(
|
||||
"alertmanager",
|
||||
"prometheus",
|
||||
"/api/alerts/report/alertmanager",
|
||||
"alertmanager_webhook",
|
||||
List.of("alerts", "alerts[].labels", "alerts[].startsAt", "alerts[].endsAt"),
|
||||
List.of(
|
||||
"alert.integration.alertmanager.step.create_token",
|
||||
"alert.integration.alertmanager.step.configure_webhook",
|
||||
"alert.integration.alertmanager.step.verify_alert"),
|
||||
List.of("""
|
||||
{
|
||||
"status": "firing",
|
||||
"alerts": [
|
||||
{
|
||||
"labels": {"alertname": "HighCPUUsage", "instance": "server-1"},
|
||||
"annotations": {"summary": "High CPU usage"},
|
||||
"startsAt": "2026-01-01T00:00:00Z",
|
||||
"endsAt": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}"""),
|
||||
READY,
|
||||
List.of())),
|
||||
descriptor("skywalking", constrainedGuide(
|
||||
"skywalking",
|
||||
"skywalking",
|
||||
"skywalking_alert_array",
|
||||
List.of("[].alarmMessage", "[].startTime", "[].tags"),
|
||||
CONFIGURATION_REQUIRED)),
|
||||
descriptor("uptime-kuma", constrainedGuide(
|
||||
"uptime-kuma",
|
||||
"uptime-kuma",
|
||||
"uptime_kuma_webhook",
|
||||
List.of("heartbeat.status", "heartbeat.time", "monitor.id", "monitor.name"),
|
||||
CONFIGURATION_REQUIRED)),
|
||||
descriptor("zabbix", guide(
|
||||
"zabbix",
|
||||
"zabbix",
|
||||
"/api/alerts/report/zabbix",
|
||||
"single_alert",
|
||||
List.of("labels", "content", "status", "startAt"),
|
||||
List.of("alert.integration.zabbix.step.correct_guide_required"),
|
||||
List.of(),
|
||||
GUIDE_BLOCKED,
|
||||
List.of(
|
||||
"alert.integration.limit.zabbix.authorization_missing",
|
||||
"alert.integration.limit.zabbix.response_contract_mismatch",
|
||||
"alert.integration.limit.zabbix.recovery_time_semantics"))),
|
||||
descriptor("tencent", constrainedGuide(
|
||||
"tencent",
|
||||
"tencent",
|
||||
"tencent_cloud_webhook",
|
||||
List.of(
|
||||
"alarmStatus",
|
||||
"alarmType",
|
||||
"firstOccurTime",
|
||||
"alarmObjInfo",
|
||||
"alarmPolicyInfo.conditions"),
|
||||
CONFIGURATION_REQUIRED)),
|
||||
descriptor("alibabacloud-sls", constrainedGuide(
|
||||
"alibabacloud-sls",
|
||||
"alibabacloud",
|
||||
"alibaba_cloud_sls_webhook",
|
||||
List.of("alert_name", "status", "fire_time", "alert_time", "region", "project"),
|
||||
CONFIGURATION_REQUIRED)),
|
||||
descriptor("huaweicloud-ces", constrainedGuide(
|
||||
"huaweicloud-ces",
|
||||
"huaweicloud",
|
||||
"huawei_cloud_smn_webhook",
|
||||
List.of(
|
||||
"signature",
|
||||
"signing_cert_url",
|
||||
"type",
|
||||
"message",
|
||||
"timestamp",
|
||||
"topic_urn"),
|
||||
CONFIGURATION_REQUIRED)),
|
||||
descriptor("volcengine", constrainedGuide(
|
||||
"volcengine",
|
||||
"volcengine",
|
||||
"volcengine_webhook",
|
||||
List.of("Type"),
|
||||
CONFIGURATION_REQUIRED)));
|
||||
}
|
||||
|
||||
private static AlertIntegrationDescriptor descriptor(String ingressSource, IntegrationGuide guide) {
|
||||
return new AlertIntegrationDescriptor(ingressSource, guide);
|
||||
}
|
||||
|
||||
private static IntegrationGuide constrainedGuide(
|
||||
String source,
|
||||
String iconKey,
|
||||
String payloadShape,
|
||||
List<String> requiredFields,
|
||||
Readiness readiness) {
|
||||
return guide(
|
||||
source,
|
||||
iconKey,
|
||||
"/api/alerts/report/" + source,
|
||||
payloadShape,
|
||||
requiredFields,
|
||||
List.of("alert.integration.step.configure_bearer_capable_callback"),
|
||||
List.of(),
|
||||
readiness,
|
||||
List.of(AUTHORIZATION_REQUIRED));
|
||||
}
|
||||
|
||||
private static IntegrationGuide guide(
|
||||
String source,
|
||||
String iconKey,
|
||||
String ingressPath,
|
||||
String payloadShape,
|
||||
List<String> requiredFields,
|
||||
List<String> steps,
|
||||
List<String> snippets,
|
||||
Readiness readiness,
|
||||
List<String> limitations) {
|
||||
return new IntegrationGuide(
|
||||
source,
|
||||
"alert.integration.source." + source,
|
||||
iconKey,
|
||||
"POST",
|
||||
ingressPath,
|
||||
payloadShape,
|
||||
requiredHeaders(),
|
||||
requiredFields,
|
||||
steps,
|
||||
snippets,
|
||||
ACKNOWLEDGEMENT,
|
||||
readiness,
|
||||
limitations);
|
||||
}
|
||||
|
||||
private static Map<String, String> requiredHeaders() {
|
||||
LinkedHashMap<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("Authorization", "Bearer {token}");
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.integration.service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.CatalogResponse;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.IntegrationGuide;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationRequestException;
|
||||
import org.apache.hertzbeat.alert.integration.guide.AlertIntegrationDescriptor;
|
||||
import org.apache.hertzbeat.alert.integration.guide.AlertIntegrationDescriptorRegistry;
|
||||
import org.apache.hertzbeat.alert.service.ExternAlertService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Builds alert integration read models from the registered ingress adapters.
|
||||
*/
|
||||
@Service
|
||||
public class AlertIntegrationCatalogService {
|
||||
|
||||
private final List<ExternAlertService> externAlertServices;
|
||||
private final AlertIntegrationDescriptorRegistry descriptorRegistry;
|
||||
|
||||
public AlertIntegrationCatalogService(
|
||||
List<ExternAlertService> externAlertServices,
|
||||
AlertIntegrationDescriptorRegistry descriptorRegistry) {
|
||||
this.externAlertServices = List.copyOf(externAlertServices);
|
||||
this.descriptorRegistry = descriptorRegistry;
|
||||
}
|
||||
|
||||
public CatalogResponse catalog() {
|
||||
requireAlignedDescriptors();
|
||||
return new CatalogResponse(descriptorRegistry.descriptors().stream()
|
||||
.map(AlertIntegrationDescriptor::guide)
|
||||
.map(IntegrationGuide::toCatalogItem)
|
||||
.toList());
|
||||
}
|
||||
|
||||
public IntegrationGuide render(String source) {
|
||||
if (!StringUtils.hasText(source)) {
|
||||
throw AlertIntegrationRequestException.sourceUnsupported();
|
||||
}
|
||||
String normalizedSource = source.trim();
|
||||
AlertIntegrationDescriptor descriptor = descriptorRegistry.findByPublicSource(normalizedSource);
|
||||
if (descriptor == null) {
|
||||
throw AlertIntegrationRequestException.sourceUnsupported();
|
||||
}
|
||||
requireAlignedDescriptors();
|
||||
return descriptor.guide();
|
||||
}
|
||||
|
||||
private void requireAlignedDescriptors() {
|
||||
Map<String, ExternAlertService> servicesBySource = new LinkedHashMap<>();
|
||||
for (ExternAlertService service : externAlertServices) {
|
||||
if (service == null || !StringUtils.hasText(service.supportSource())
|
||||
|| servicesBySource.put(service.supportSource(), service) != null) {
|
||||
throw AlertIntegrationRequestException.guideUnavailable();
|
||||
}
|
||||
}
|
||||
if (!descriptorRegistry.ingressSources().equals(servicesBySource.keySet())) {
|
||||
throw AlertIntegrationRequestException.guideUnavailable();
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Indicates that an alert-group evidence request violates its bounded ID contract.
|
||||
*/
|
||||
public class AlertGroupEvidenceRequestException extends RuntimeException {
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.apache.hertzbeat.alert.dao.GroupAlertDao;
|
||||
import org.apache.hertzbeat.alert.dto.AlertGroupEvidence;
|
||||
import org.apache.hertzbeat.alert.dto.AlertGroupStatusEvidence;
|
||||
import org.apache.hertzbeat.common.constants.CommonConstants;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Bounded canonical evidence query for persisted alert groups.
|
||||
*/
|
||||
@Service
|
||||
public class AlertGroupEvidenceService {
|
||||
|
||||
private static final int MAX_RAW_IDS = 100;
|
||||
private static final Set<String> SUPPORTED_STATUSES = Set.of(
|
||||
CommonConstants.ALERT_STATUS_FIRING,
|
||||
CommonConstants.ALERT_STATUS_PENDING,
|
||||
CommonConstants.ALERT_STATUS_ACKNOWLEDGED,
|
||||
CommonConstants.ALERT_STATUS_RESOLVED);
|
||||
|
||||
private final GroupAlertDao groupAlertDao;
|
||||
|
||||
public AlertGroupEvidenceService(GroupAlertDao groupAlertDao) {
|
||||
this.groupAlertDao = groupAlertDao;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AlertGroupEvidence getEvidence(List<String> ids) {
|
||||
List<Long> requestedIds = normalizeIds(ids);
|
||||
Map<Long, String> foundStatuses = new HashMap<>();
|
||||
for (AlertGroupStatusEvidence evidence : groupAlertDao.findStatusEvidenceByIdIn(requestedIds)) {
|
||||
requireSupportedStatus(evidence.status());
|
||||
if (evidence.id() == null || foundStatuses.putIfAbsent(evidence.id(), evidence.status()) != null) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
List<AlertGroupStatusEvidence> groups = requestedIds.stream()
|
||||
.filter(foundStatuses::containsKey)
|
||||
.map(id -> new AlertGroupStatusEvidence(id, foundStatuses.get(id)))
|
||||
.toList();
|
||||
List<Long> missingIds = requestedIds.stream()
|
||||
.filter(id -> !foundStatuses.containsKey(id))
|
||||
.toList();
|
||||
return new AlertGroupEvidence(groups, missingIds, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
private static List<Long> normalizeIds(List<String> ids) {
|
||||
if (ids == null || ids.isEmpty() || ids.size() > MAX_RAW_IDS) {
|
||||
throw new AlertGroupEvidenceRequestException();
|
||||
}
|
||||
try {
|
||||
List<Long> normalizedIds = ids.stream()
|
||||
.map(id -> {
|
||||
if (!StringUtils.hasText(id)) {
|
||||
throw new AlertGroupEvidenceRequestException();
|
||||
}
|
||||
return Long.parseLong(id.trim());
|
||||
})
|
||||
.toList();
|
||||
if (normalizedIds.stream().anyMatch(id -> id <= 0)) {
|
||||
throw new AlertGroupEvidenceRequestException();
|
||||
}
|
||||
return normalizedIds.stream().distinct().sorted().toList();
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new AlertGroupEvidenceRequestException();
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSupportedStatus(String status) {
|
||||
if (status == null || !SUPPORTED_STATUSES.contains(status)) {
|
||||
throw new AlertGroupStatusNotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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 java.util.Collection;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hertzbeat.alert.config.AlertSseManager;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* Publishes safe alert group mutation events after authoritative state commits.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AlertGroupMutationPublisher {
|
||||
|
||||
private final AlertSseManager alertSseManager;
|
||||
|
||||
public AlertGroupMutationPublisher(AlertSseManager alertSseManager) {
|
||||
this.alertSseManager = alertSseManager;
|
||||
}
|
||||
|
||||
public void publishStatusChanged(Collection<Long> ids, String status) {
|
||||
publishAfterCommit(ids, status, GroupMutation.STATUS_CHANGED);
|
||||
}
|
||||
|
||||
public void publishDeleted(Collection<Long> ids) {
|
||||
publishAfterCommit(ids, null, GroupMutation.DELETED);
|
||||
}
|
||||
|
||||
private void publishAfterCommit(Collection<Long> ids, String status, GroupMutation mutation) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> sortedIds = ids.stream().distinct().sorted().toList();
|
||||
AlertGroupMutationEvent event =
|
||||
new AlertGroupMutationEvent(sortedIds.get(0), sortedIds, status, mutation.eventName);
|
||||
String payload = JsonUtil.toJson(event);
|
||||
Runnable publication = () -> safelyBroadcast(payload);
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
// AlertService is transactional in production. Immediate publication is the explicit
|
||||
// boundary for direct non-transactional calls such as maintenance tools and unit tests.
|
||||
publication.run();
|
||||
return;
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
publication.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void safelyBroadcast(String payload) {
|
||||
try {
|
||||
alertSseManager.broadcastGroupMutation(payload);
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("Failed to broadcast committed alert mutation: {}",
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
private enum GroupMutation {
|
||||
STATUS_CHANGED("GROUP_STATUS_CHANGED"),
|
||||
DELETED("GROUP_DELETED");
|
||||
|
||||
private final String eventName;
|
||||
|
||||
GroupMutation(String eventName) {
|
||||
this.eventName = eventName;
|
||||
}
|
||||
}
|
||||
|
||||
private record AlertGroupMutationEvent(Long id, List<Long> ids, String status, String mutation) {
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Indicates that at least one requested alert group does not exist.
|
||||
*/
|
||||
public class AlertGroupNotFoundException extends RuntimeException {
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Indicates that a requested group-alert status transition is not supported.
|
||||
*/
|
||||
public class AlertGroupStatusNotSupportedException extends RuntimeException {
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitResponse;
|
||||
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Validates the public contract and maps it to the persistence model. */
|
||||
@Component
|
||||
public class AlertInhibitContractMapper {
|
||||
|
||||
private static final int MAX_MATCHERS = 32;
|
||||
private static final int MAX_EQUAL_LABELS = 32;
|
||||
private static final int MAX_LABEL_KEY_LENGTH = 64;
|
||||
private static final int MAX_LABEL_VALUE_LENGTH = 256;
|
||||
private static final int MAX_LABEL_CONTENT_LENGTH = 1800;
|
||||
|
||||
public AlertInhibit toNewEntity(AlertInhibitRequest request) {
|
||||
if (request == null || request.getId() != null) {
|
||||
throw new IllegalArgumentException("id is not allowed when creating an alert inhibit");
|
||||
}
|
||||
return apply(request, new AlertInhibit());
|
||||
}
|
||||
|
||||
public AlertInhibit toExistingEntity(AlertInhibitRequest request, AlertInhibit existing) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Alert inhibit request is required");
|
||||
}
|
||||
Long id = requirePositiveId(request.getId());
|
||||
if (!id.equals(existing.getId())) {
|
||||
throw new IllegalArgumentException("Alert inhibit identity does not match");
|
||||
}
|
||||
AlertInhibit target = new AlertInhibit();
|
||||
target.setId(existing.getId());
|
||||
target.setCreator(existing.getCreator());
|
||||
target.setModifier(existing.getModifier());
|
||||
target.setGmtCreate(existing.getGmtCreate());
|
||||
target.setGmtUpdate(existing.getGmtUpdate());
|
||||
return apply(request, target);
|
||||
}
|
||||
|
||||
public AlertInhibitResponse toResponse(AlertInhibit entity) {
|
||||
return new AlertInhibitResponse(entity.getId(), entity.getName(),
|
||||
copyMap(entity.getSourceLabels()), copyMap(entity.getTargetLabels()),
|
||||
entity.getEqualLabels() == null ? List.of() : List.copyOf(entity.getEqualLabels()),
|
||||
Boolean.TRUE.equals(entity.getEnable()), entity.getCreator(), entity.getModifier(),
|
||||
entity.getGmtCreate(), entity.getGmtUpdate());
|
||||
}
|
||||
|
||||
public Long requirePositiveId(Long id) {
|
||||
if (id == null || id <= 0) {
|
||||
throw new IllegalArgumentException("A positive alert inhibit id is required");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private AlertInhibit apply(AlertInhibitRequest request, AlertInhibit target) {
|
||||
String name = StringUtils.trimToNull(request.getName());
|
||||
if (name == null || name.length() > 100) {
|
||||
throw new IllegalArgumentException("Alert inhibit name is invalid");
|
||||
}
|
||||
if (request.getEnable() == null) {
|
||||
throw new IllegalArgumentException("Alert inhibit enable is required");
|
||||
}
|
||||
target.setName(name);
|
||||
target.setEnable(request.getEnable());
|
||||
target.setSourceLabels(validateMatchers(request.getSourceLabels()));
|
||||
target.setTargetLabels(validateMatchers(request.getTargetLabels()));
|
||||
target.setEqualLabels(validateEqualLabels(request.getEqualLabels()));
|
||||
return target;
|
||||
}
|
||||
|
||||
private Map<String, String> validateMatchers(Map<String, String> matchers) {
|
||||
if (matchers == null || matchers.isEmpty() || matchers.size() > MAX_MATCHERS) {
|
||||
throw new IllegalArgumentException("Alert inhibit matchers are invalid");
|
||||
}
|
||||
Map<String, String> normalized = new LinkedHashMap<>();
|
||||
int contentLength = 0;
|
||||
for (Map.Entry<String, String> entry : matchers.entrySet()) {
|
||||
String key = StringUtils.trimToNull(entry.getKey());
|
||||
String value = StringUtils.trimToNull(entry.getValue());
|
||||
if (key == null || key.length() > MAX_LABEL_KEY_LENGTH
|
||||
|| value == null || value.length() > MAX_LABEL_VALUE_LENGTH) {
|
||||
throw new IllegalArgumentException("Alert inhibit matcher is invalid");
|
||||
}
|
||||
contentLength += key.length() + value.length();
|
||||
normalized.put(key, value);
|
||||
}
|
||||
if (contentLength > MAX_LABEL_CONTENT_LENGTH) {
|
||||
throw new IllegalArgumentException("Alert inhibit matchers are too large");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private List<String> validateEqualLabels(List<String> equalLabels) {
|
||||
if (equalLabels == null || equalLabels.isEmpty() || equalLabels.size() > MAX_EQUAL_LABELS) {
|
||||
throw new IllegalArgumentException("Alert inhibit equal labels are invalid");
|
||||
}
|
||||
Set<String> normalized = new LinkedHashSet<>();
|
||||
for (String candidate : equalLabels) {
|
||||
String label = StringUtils.trimToNull(candidate);
|
||||
if (label == null || label.length() > MAX_LABEL_KEY_LENGTH || !normalized.add(label)) {
|
||||
throw new IllegalArgumentException("Alert inhibit equal label is invalid");
|
||||
}
|
||||
}
|
||||
return List.copyOf(normalized);
|
||||
}
|
||||
|
||||
private Map<String, String> copyMap(Map<String, String> labels) {
|
||||
return labels == null ? Map.of() : Map.copyOf(labels);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/** Named missing-record boundary for alert inhibit operations. */
|
||||
public class AlertInhibitNotFoundException extends RuntimeException {
|
||||
|
||||
public AlertInhibitNotFoundException() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/** Named uncertain-write boundary for alert inhibit operations. */
|
||||
public class AlertInhibitOperationException extends RuntimeException {
|
||||
|
||||
public AlertInhibitOperationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+10
-46
@@ -19,60 +19,24 @@ package org.apache.hertzbeat.alert.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitDeleteResponse;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitPageResponse;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitResponse;
|
||||
|
||||
/**
|
||||
* management interface service for alert inhibit
|
||||
*/
|
||||
public interface AlertInhibitService {
|
||||
/**
|
||||
* Verify the correctness of the request data parameters
|
||||
* @param alertInhibit AlertInhibit
|
||||
* @param isModify whether modify
|
||||
* @throws IllegalArgumentException A checksum parameter error is thrown
|
||||
*/
|
||||
void validate(AlertInhibit alertInhibit, boolean isModify) throws IllegalArgumentException;
|
||||
|
||||
/**
|
||||
* New AlertInhibit
|
||||
* @param alertInhibit AlertInhibit Entity
|
||||
* @throws RuntimeException Added procedure exception throwing
|
||||
*/
|
||||
void addAlertInhibit(AlertInhibit alertInhibit) throws RuntimeException;
|
||||
AlertInhibitResponse create(AlertInhibitRequest request);
|
||||
|
||||
/**
|
||||
* Modifying an AlertInhibit
|
||||
* @param alertInhibit Alarm definition Entity
|
||||
* @throws RuntimeException Exception thrown during modification
|
||||
*/
|
||||
void modifyAlertInhibit(AlertInhibit alertInhibit) throws RuntimeException;
|
||||
AlertInhibitResponse update(AlertInhibitRequest request);
|
||||
|
||||
/**
|
||||
* Obtain AlertInhibit information
|
||||
* @param inhibitId AlertInhibit ID
|
||||
* @return AlertInhibit
|
||||
* @throws RuntimeException An exception was thrown during the query
|
||||
*/
|
||||
AlertInhibit getAlertInhibit(long inhibitId) throws RuntimeException;
|
||||
AlertInhibitResponse get(long inhibitId);
|
||||
|
||||
AlertInhibitDeleteResponse delete(Set<Long> inhibitIds);
|
||||
|
||||
/**
|
||||
* Delete AlertInhibit in batches
|
||||
* @param inhibitIds AlertInhibit IDs
|
||||
* @throws RuntimeException Exception thrown during deletion
|
||||
*/
|
||||
void deleteAlertInhibits(Set<Long> inhibitIds) throws RuntimeException;
|
||||
|
||||
/**
|
||||
* Dynamic conditional query
|
||||
* @param inhibitIds Alarm Silence ID
|
||||
* @param search Search Name
|
||||
* @param sort Sort field
|
||||
* @param order Sort mode: asc: ascending, desc: descending
|
||||
* @param pageIndex List current page
|
||||
* @param pageSize Number of list pages
|
||||
* @return The query results
|
||||
*/
|
||||
Page<AlertInhibit> getAlertInhibits(List<Long> inhibitIds, String search, String sort, String order, int pageIndex, int pageSize);
|
||||
AlertInhibitPageResponse list(List<Long> inhibitIds, String search, String sort, String order,
|
||||
int pageIndex, int pageSize);
|
||||
}
|
||||
|
||||
+6
@@ -39,6 +39,9 @@ public class AlertSilenceContractMapper {
|
||||
private static final int MAX_MATCHER_CONTENT_LENGTH = 1800;
|
||||
|
||||
public AlertSilence toNewEntity(AlertSilenceRequest request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Alert silence request is required");
|
||||
}
|
||||
if (request.getId() != null) {
|
||||
throw new IllegalArgumentException("id is not allowed when creating an alert silence");
|
||||
}
|
||||
@@ -46,6 +49,9 @@ public class AlertSilenceContractMapper {
|
||||
}
|
||||
|
||||
public AlertSilence toExistingEntity(AlertSilenceRequest request, AlertSilence existing) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Alert silence request is required");
|
||||
}
|
||||
Long id = requirePositiveId(request.getId());
|
||||
if (!id.equals(existing.getId())) {
|
||||
throw new IllegalArgumentException("Alert silence identity does not match");
|
||||
|
||||
+17
@@ -33,6 +33,14 @@ public interface DataSourceService {
|
||||
*/
|
||||
List<Map<String, Object>> calculate(String datasource, String expr);
|
||||
|
||||
/**
|
||||
* Execute a preview calculation while preserving query execution failures.
|
||||
* @param datasource datasource
|
||||
* @param expr query expr
|
||||
* @return preview result
|
||||
*/
|
||||
List<Map<String, Object>> calculatePreview(String datasource, String expr);
|
||||
|
||||
/**
|
||||
* query result set from db
|
||||
* @param datasource sql or promql
|
||||
@@ -50,6 +58,15 @@ public interface DataSourceService {
|
||||
*/
|
||||
List<Map<String, Object>> query(String datasource, String expr, String alertType);
|
||||
|
||||
/**
|
||||
* Execute an alert preview query without exposing executor failure details.
|
||||
* @param datasource sql or promql
|
||||
* @param expr query expr
|
||||
* @param alertType alert rule type
|
||||
* @return preview rows
|
||||
*/
|
||||
List<Map<String, Object>> queryPreview(String datasource, String expr, String alertType);
|
||||
|
||||
/**
|
||||
* Get available datasource executors status
|
||||
* @return map containing available executors by type (promql, sql)
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 java.util.Objects;
|
||||
|
||||
/**
|
||||
* Classifies a rejected notice-template mutation without carrying request data.
|
||||
*/
|
||||
public class NoticeTemplateMutationException extends RuntimeException {
|
||||
|
||||
private final Reason reason;
|
||||
|
||||
public NoticeTemplateMutationException(Reason reason) {
|
||||
this.reason = Objects.requireNonNull(reason);
|
||||
}
|
||||
|
||||
public Reason getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable mutation rejection reasons.
|
||||
*/
|
||||
public enum Reason {
|
||||
INVALID_REQUEST,
|
||||
NOT_FOUND,
|
||||
READ_ONLY
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -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,9 @@ 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 +230,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();
|
||||
|
||||
+16
-6
@@ -284,22 +284,32 @@ public class AlertDefineServiceImpl implements AlertDefineService {
|
||||
if (!StringUtils.hasText(expr) || !StringUtils.hasText(datasource) || !StringUtils.hasText(type)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Map<String, Object>> preview;
|
||||
switch (type) {
|
||||
case CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_PERIODIC:
|
||||
return dataSourceService.calculate(datasource, expr);
|
||||
preview = dataSourceService.calculatePreview(datasource, expr);
|
||||
break;
|
||||
case CommonConstants.LOG_ALERT_THRESHOLD_TYPE_PERIODIC:
|
||||
// todo support alert expr preview
|
||||
return dataSourceService.query(datasource, expr, type);
|
||||
preview = dataSourceService.queryPreview(datasource, expr, type);
|
||||
break;
|
||||
case CommonConstants.TRACE_ALERT_THRESHOLD_TYPE_PERIODIC:
|
||||
List<Map<String, Object>> tracePreview = dataSourceService.query(datasource, expr, type);
|
||||
List<Map<String, Object>> tracePreview = dataSourceService.queryPreview(datasource, expr, type);
|
||||
validateTracePreview(tracePreview);
|
||||
return tracePreview;
|
||||
preview = tracePreview;
|
||||
break;
|
||||
case CommonConstants.LOG_ALERT_THRESHOLD_TYPE_REALTIME:
|
||||
return validateRealtimeExpressionPreview(type, expr);
|
||||
preview = validateRealtimeExpressionPreview(type, expr);
|
||||
break;
|
||||
default:
|
||||
log.error("Get define preview unsupported type: {}", type);
|
||||
return Collections.emptyList();
|
||||
preview = Collections.emptyList();
|
||||
break;
|
||||
}
|
||||
if (preview == null || preview.size() <= CommonConstants.ALERT_PREVIEW_RESULT_LIMIT) {
|
||||
return preview == null ? Collections.emptyList() : preview;
|
||||
}
|
||||
return List.copyOf(preview.subList(0, CommonConstants.ALERT_PREVIEW_RESULT_LIMIT));
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> validateRealtimeExpressionPreview(String type, String expr) {
|
||||
|
||||
+56
-4
@@ -19,14 +19,22 @@ package org.apache.hertzbeat.alert.service.impl;
|
||||
|
||||
import static org.apache.hertzbeat.common.constants.ExportFileConstants.YamlFile.FILE_SUFFIX;
|
||||
import static org.apache.hertzbeat.common.constants.ExportFileConstants.YamlFile.TYPE;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hertzbeat.alert.dto.AlertDefineDTO;
|
||||
import org.apache.hertzbeat.alert.dto.ExportAlertDefineDTO;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.apache.hertzbeat.common.util.export.YamlExportUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
import org.yaml.snakeyaml.nodes.Tag;
|
||||
|
||||
/**
|
||||
* Configure the import and export Yaml format.
|
||||
@@ -36,6 +44,14 @@ import org.yaml.snakeyaml.Yaml;
|
||||
@Service
|
||||
public class AlertDefineYamlImExportServiceImpl extends AlertDefineAbstractImExportServiceImpl {
|
||||
|
||||
private static final int MAX_CODE_POINTS = 1_048_576;
|
||||
private static final int MAX_NESTING_DEPTH = 50;
|
||||
private static final int MAX_ALIASES_FOR_COLLECTIONS = 20;
|
||||
private static final Tag LEGACY_EXPORT_ALERT_DEFINE_TAG = new Tag(ExportAlertDefineDTO.class);
|
||||
private static final Tag LEGACY_ALERT_DEFINE_TAG = new Tag(AlertDefineDTO.class);
|
||||
private static final Set<Tag> ALLOWED_LEGACY_TAGS =
|
||||
Set.of(LEGACY_EXPORT_ALERT_DEFINE_TAG, LEGACY_ALERT_DEFINE_TAG);
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
@@ -48,14 +64,50 @@ public class AlertDefineYamlImExportServiceImpl extends AlertDefineAbstractImExp
|
||||
|
||||
@Override
|
||||
public List<ExportAlertDefineDTO> parseImport(InputStream is) {
|
||||
Yaml yaml = new Yaml();
|
||||
return yaml.load(is);
|
||||
LoaderOptions loaderOptions = new LoaderOptions();
|
||||
loaderOptions.setCodePointLimit(MAX_CODE_POINTS);
|
||||
loaderOptions.setNestingDepthLimit(MAX_NESTING_DEPTH);
|
||||
loaderOptions.setMaxAliasesForCollections(MAX_ALIASES_FOR_COLLECTIONS);
|
||||
loaderOptions.setTagInspector(ALLOWED_LEGACY_TAGS::contains);
|
||||
Yaml yaml = new Yaml(new LegacyAlertDefineSafeConstructor(loaderOptions));
|
||||
Object payload = yaml.load(is);
|
||||
if (!(payload instanceof List<?> records)) {
|
||||
throw new IllegalArgumentException("Alert define YAML must contain a list");
|
||||
}
|
||||
return records.stream().map(AlertDefineYamlImExportServiceImpl::toExportAlertDefine).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeOs(List<ExportAlertDefineDTO> exportAlertDefineList, OutputStream os) {
|
||||
List<Object> portableRecords = exportAlertDefineList.stream()
|
||||
.map(AlertDefineYamlImExportServiceImpl::toPortableRecord)
|
||||
.toList();
|
||||
YamlExportUtils.exportWriteOs(portableRecords, os);
|
||||
}
|
||||
|
||||
YamlExportUtils.exportWriteOs(exportAlertDefineList, os);
|
||||
private static Object toPortableRecord(ExportAlertDefineDTO record) {
|
||||
Map<?, ?> portableRecord = JsonUtil.convertValueQuietly(record, Map.class);
|
||||
if (portableRecord == null) {
|
||||
throw new IllegalArgumentException("Alert define YAML contains an invalid export record");
|
||||
}
|
||||
return portableRecord;
|
||||
}
|
||||
|
||||
private static ExportAlertDefineDTO toExportAlertDefine(Object record) {
|
||||
ExportAlertDefineDTO alertDefine = JsonUtil.convertValueQuietly(record, ExportAlertDefineDTO.class);
|
||||
if (alertDefine == null || alertDefine.getAlertDefine() == null) {
|
||||
throw new IllegalArgumentException("Alert define YAML contains an invalid record");
|
||||
}
|
||||
return alertDefine;
|
||||
}
|
||||
|
||||
private static final class LegacyAlertDefineSafeConstructor extends SafeConstructor {
|
||||
|
||||
private LegacyAlertDefineSafeConstructor(LoaderOptions loaderOptions) {
|
||||
super(loaderOptions);
|
||||
yamlConstructors.put(LEGACY_EXPORT_ALERT_DEFINE_TAG, new ConstructYamlMap());
|
||||
yamlConstructors.put(LEGACY_ALERT_DEFINE_TAG, new ConstructYamlMap());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+95
-26
@@ -20,66 +20,122 @@ package org.apache.hertzbeat.alert.service.impl;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hertzbeat.alert.dao.AlertInhibitDao;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitDeleteResponse;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitPageResponse;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitResponse;
|
||||
import org.apache.hertzbeat.alert.reduce.AlarmInhibitReduce;
|
||||
import org.apache.hertzbeat.alert.service.AlertInhibitContractMapper;
|
||||
import org.apache.hertzbeat.alert.service.AlertInhibitNotFoundException;
|
||||
import org.apache.hertzbeat.alert.service.AlertInhibitOperationException;
|
||||
import org.apache.hertzbeat.alert.service.AlertInhibitService;
|
||||
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* management interface service implement for alert inhibit
|
||||
*/
|
||||
@Service
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@Slf4j
|
||||
public class AlertInhibitServiceImpl implements AlertInhibitService {
|
||||
|
||||
@Autowired
|
||||
private AlertInhibitDao alertInhibitDao;
|
||||
|
||||
@Autowired
|
||||
private AlarmInhibitReduce alarmInhibitReduce;
|
||||
private static final Set<String> SORT_FIELDS = Set.of("id", "name", "enable", "gmtCreate", "gmtUpdate");
|
||||
private static final int MAX_PAGE_SIZE = 100;
|
||||
|
||||
@Override
|
||||
public void validate(AlertInhibit alertInhibit, boolean isModify) throws IllegalArgumentException {
|
||||
// todo
|
||||
private final AlertInhibitDao alertInhibitDao;
|
||||
private final AlarmInhibitReduce alarmInhibitReduce;
|
||||
private final AlertInhibitContractMapper mapper;
|
||||
|
||||
public AlertInhibitServiceImpl(AlertInhibitDao alertInhibitDao, AlarmInhibitReduce alarmInhibitReduce,
|
||||
AlertInhibitContractMapper mapper) {
|
||||
this.alertInhibitDao = alertInhibitDao;
|
||||
this.alarmInhibitReduce = alarmInhibitReduce;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addAlertInhibit(AlertInhibit alertInhibit) throws RuntimeException {
|
||||
alertInhibitDao.save(alertInhibit);
|
||||
public AlertInhibitResponse create(AlertInhibitRequest request) {
|
||||
AlertInhibit saved = alertInhibitDao.save(mapper.toNewEntity(request));
|
||||
if (saved == null || saved.getId() == null) {
|
||||
throw new AlertInhibitOperationException("Alert inhibit create did not return an identity");
|
||||
}
|
||||
AlertInhibit authoritative = alertInhibitDao.findById(saved.getId())
|
||||
.orElseThrow(() -> new AlertInhibitOperationException("Alert inhibit create did not converge"));
|
||||
refreshAlertInhibitsCache();
|
||||
return mapper.toResponse(authoritative);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void modifyAlertInhibit(AlertInhibit alertInhibit) throws RuntimeException {
|
||||
alertInhibitDao.save(alertInhibit);
|
||||
public AlertInhibitResponse update(AlertInhibitRequest request) {
|
||||
Long id = mapper.requirePositiveId(request == null ? null : request.getId());
|
||||
AlertInhibit existing = alertInhibitDao.findById(id).orElseThrow(AlertInhibitNotFoundException::new);
|
||||
alertInhibitDao.save(mapper.toExistingEntity(request, existing));
|
||||
AlertInhibit authoritative = alertInhibitDao.findById(id)
|
||||
.orElseThrow(() -> new AlertInhibitOperationException("Alert inhibit update did not converge"));
|
||||
refreshAlertInhibitsCache();
|
||||
return mapper.toResponse(authoritative);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AlertInhibit getAlertInhibit(long inhibitId) throws RuntimeException {
|
||||
return alertInhibitDao.findById(inhibitId).orElse(null);
|
||||
@Transactional(readOnly = true)
|
||||
public AlertInhibitResponse get(long inhibitId) {
|
||||
mapper.requirePositiveId(inhibitId);
|
||||
return alertInhibitDao.findById(inhibitId).map(mapper::toResponse)
|
||||
.orElseThrow(AlertInhibitNotFoundException::new);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAlertInhibits(Set<Long> inhibitIds) throws RuntimeException {
|
||||
alertInhibitDao.deleteAlertInhibitsByIdIn(inhibitIds);
|
||||
public AlertInhibitDeleteResponse delete(Set<Long> inhibitIds) {
|
||||
Set<Long> requested = validateIds(inhibitIds);
|
||||
Set<Long> existing = ids(alertInhibitDao.findAllById(requested));
|
||||
Set<Long> missing = new LinkedHashSet<>(requested);
|
||||
missing.removeAll(existing);
|
||||
if (!existing.isEmpty()) {
|
||||
alertInhibitDao.deleteAlertInhibitsByIdIn(existing);
|
||||
}
|
||||
Set<Long> remaining = ids(alertInhibitDao.findAllById(requested));
|
||||
if (!remaining.isEmpty()) {
|
||||
throw new AlertInhibitOperationException("Alert inhibit delete did not converge");
|
||||
}
|
||||
refreshAlertInhibitsCache();
|
||||
String status = existing.isEmpty() ? "missing" : missing.isEmpty() ? "deleted" : "partial";
|
||||
return new AlertInhibitDeleteResponse(status, Set.copyOf(existing), Set.copyOf(missing));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<AlertInhibit> getAlertInhibits(List<Long> inhibitIds, String search, String sort, String order, int pageIndex, int pageSize) {
|
||||
@Transactional(readOnly = true)
|
||||
public AlertInhibitPageResponse list(List<Long> inhibitIds, String search, String sort, String order,
|
||||
int pageIndex, int pageSize) {
|
||||
List<Long> ids = inhibitIds == null ? null : List.copyOf(validateIds(new LinkedHashSet<>(inhibitIds)));
|
||||
String query = StringUtils.trimToNull(search);
|
||||
if (query != null && query.length() > 100) {
|
||||
throw new IllegalArgumentException("Alert inhibit search is too long");
|
||||
}
|
||||
if (!SORT_FIELDS.contains(sort) || !("asc".equalsIgnoreCase(order) || "desc".equalsIgnoreCase(order))) {
|
||||
throw new IllegalArgumentException("Alert inhibit sort is invalid");
|
||||
}
|
||||
if (pageIndex < 0 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
|
||||
throw new IllegalArgumentException("Alert inhibit page is invalid");
|
||||
}
|
||||
Page<AlertInhibit> page = alertInhibitDao.findAll(specification(ids, query),
|
||||
PageRequest.of(pageIndex, pageSize, Sort.by(Sort.Direction.fromString(order), sort)));
|
||||
List<AlertInhibitResponse> content = page.getContent().stream().map(mapper::toResponse).toList();
|
||||
return new AlertInhibitPageResponse(content, page.getTotalElements(), page.getTotalPages(),
|
||||
page.getNumber(), page.getSize());
|
||||
}
|
||||
|
||||
private Specification<AlertInhibit> specification(List<Long> inhibitIds, String search) {
|
||||
Specification<AlertInhibit> specification = (root, query, criteriaBuilder) -> {
|
||||
List<Predicate> andList = new ArrayList<>();
|
||||
if (inhibitIds != null && !inhibitIds.isEmpty()) {
|
||||
@@ -89,11 +145,11 @@ public class AlertInhibitServiceImpl implements AlertInhibitService {
|
||||
}
|
||||
andList.add(inPredicate);
|
||||
}
|
||||
if (StringUtils.hasText(search)) {
|
||||
if (search != null) {
|
||||
Predicate predicate = criteriaBuilder.or(
|
||||
criteriaBuilder.like(
|
||||
criteriaBuilder.lower(root.get("name")),
|
||||
"%" + search.toLowerCase() + "%"
|
||||
"%" + search.toLowerCase(Locale.ROOT) + "%"
|
||||
)
|
||||
);
|
||||
andList.add(predicate);
|
||||
@@ -101,9 +157,22 @@ public class AlertInhibitServiceImpl implements AlertInhibitService {
|
||||
Predicate[] predicates = new Predicate[andList.size()];
|
||||
return criteriaBuilder.and(andList.toArray(predicates));
|
||||
};
|
||||
Sort sortExp = Sort.by(new Sort.Order(Sort.Direction.fromString(order), sort));
|
||||
PageRequest pageRequest = PageRequest.of(pageIndex, pageSize, sortExp);
|
||||
return alertInhibitDao.findAll(specification, pageRequest);
|
||||
return specification;
|
||||
}
|
||||
|
||||
private Set<Long> validateIds(Set<Long> inhibitIds) {
|
||||
if (inhibitIds == null || inhibitIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("Alert inhibit ids are required");
|
||||
}
|
||||
LinkedHashSet<Long> result = new LinkedHashSet<>();
|
||||
inhibitIds.forEach(id -> result.add(mapper.requirePositiveId(id)));
|
||||
return result;
|
||||
}
|
||||
|
||||
private Set<Long> ids(Iterable<AlertInhibit> inhibits) {
|
||||
Set<Long> ids = new LinkedHashSet<>();
|
||||
inhibits.forEach(inhibit -> ids.add(inhibit.getId()));
|
||||
return ids;
|
||||
}
|
||||
|
||||
private void refreshAlertInhibitsCache() {
|
||||
|
||||
+42
-50
@@ -18,10 +18,8 @@
|
||||
package org.apache.hertzbeat.alert.service.impl;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hertzbeat.alert.dto.AlertManagerExternAlert;
|
||||
import org.apache.hertzbeat.alert.dto.PrometheusExternAlert;
|
||||
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
|
||||
@@ -36,7 +34,6 @@ import org.springframework.util.StringUtils;
|
||||
/**
|
||||
* Alertmanager external alarm service impl
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AlertManagerExternAlertService implements ExternAlertService {
|
||||
|
||||
@@ -47,55 +44,50 @@ public class AlertManagerExternAlertService implements ExternAlertService {
|
||||
@Override
|
||||
public void addExternAlert(String content) {
|
||||
|
||||
AlertManagerExternAlert alert = JsonUtil.fromJsonQuietly(content, AlertManagerExternAlert.class);
|
||||
if (alert == null) {
|
||||
log.warn("Parse alertmanager external alert content failed");
|
||||
return;
|
||||
}
|
||||
List<PrometheusExternAlert> alerts = alert.getAlerts();
|
||||
if (alerts == null || alerts.isEmpty()) {
|
||||
log.warn("Received alertmanager external alert without alerts");
|
||||
return;
|
||||
}
|
||||
for (PrometheusExternAlert prometheusAlert : alerts) {
|
||||
Map<String, String> annotations = prometheusAlert.getAnnotations();
|
||||
if (annotations == null) {
|
||||
annotations = new HashMap<>(8);
|
||||
}
|
||||
if (StringUtils.hasText(prometheusAlert.getGeneratorURL())) {
|
||||
annotations.put("generatorURL", prometheusAlert.getGeneratorURL());
|
||||
}
|
||||
String description = annotations.get("description");
|
||||
if (description == null) {
|
||||
description = annotations.get("summary");
|
||||
}
|
||||
if (description == null) {
|
||||
description = annotations.values().stream().findFirst().orElse("");
|
||||
}
|
||||
Map<String, String> labels = prometheusAlert.getLabels();
|
||||
if (labels == null) {
|
||||
labels = new HashMap<>(8);
|
||||
}
|
||||
labels.put("__source__", "alertmanager");
|
||||
String status = CommonConstants.ALERT_STATUS_FIRING;
|
||||
Instant now = Instant.now();
|
||||
Instant endsAt = prometheusAlert.getEndsAt();
|
||||
if (endsAt != null && endsAt.getEpochSecond() > 0 && endsAt.isBefore(now)) {
|
||||
status = CommonConstants.ALERT_STATUS_RESOLVED;
|
||||
}
|
||||
SingleAlert singleAlert = SingleAlert.builder()
|
||||
.content(description)
|
||||
.status(status)
|
||||
.activeAt(CommonConstants.ALERT_STATUS_FIRING.equals(status) ? Instant.now().toEpochMilli() : null)
|
||||
.startAt(prometheusAlert.getStartsAt() != null ? prometheusAlert.getStartsAt().toEpochMilli() : Instant.now().toEpochMilli())
|
||||
.endAt(CommonConstants.ALERT_STATUS_RESOLVED.equals(status) ? prometheusAlert.getEndsAt().toEpochMilli() : null)
|
||||
.labels(labels)
|
||||
.annotations(prometheusAlert.getAnnotations())
|
||||
.triggerTimes(1)
|
||||
.build();
|
||||
AlertManagerExternAlert alert = ExternalAlertIngressValidator.requirePresent(
|
||||
JsonUtil.fromJsonQuietly(content, AlertManagerExternAlert.class));
|
||||
List<PrometheusExternAlert> alerts =
|
||||
ExternalAlertIngressValidator.requireBatch(alert.getAlerts());
|
||||
List<SingleAlert> singleAlerts = alerts.stream()
|
||||
.map(this::toSingleAlert)
|
||||
.toList();
|
||||
singleAlerts.forEach(alarmCommonReduce::reduceAndSendAlarm);
|
||||
}
|
||||
|
||||
alarmCommonReduce.reduceAndSendAlarm(singleAlert);
|
||||
private SingleAlert toSingleAlert(PrometheusExternAlert prometheusAlert) {
|
||||
Map<String, String> annotations =
|
||||
ExternalAlertIngressValidator.normalizeAnnotations(prometheusAlert.getAnnotations());
|
||||
if (StringUtils.hasText(prometheusAlert.getGeneratorURL())) {
|
||||
annotations.put("generatorURL", prometheusAlert.getGeneratorURL());
|
||||
}
|
||||
String description = annotations.get("description");
|
||||
if (description == null) {
|
||||
description = annotations.get("summary");
|
||||
}
|
||||
if (description == null) {
|
||||
description = annotations.values().stream().findFirst().orElse("");
|
||||
}
|
||||
Map<String, String> labels =
|
||||
ExternalAlertIngressValidator.requireBusinessLabels(prometheusAlert.getLabels());
|
||||
labels.put("__source__", "alertmanager");
|
||||
String status = CommonConstants.ALERT_STATUS_FIRING;
|
||||
Instant now = Instant.now();
|
||||
Instant endsAt = prometheusAlert.getEndsAt();
|
||||
if (endsAt != null && endsAt.getEpochSecond() > 0 && endsAt.isBefore(now)) {
|
||||
status = CommonConstants.ALERT_STATUS_RESOLVED;
|
||||
}
|
||||
return ExternalAlertIngressValidator.normalize(SingleAlert.builder()
|
||||
.content(description)
|
||||
.status(status)
|
||||
.activeAt(CommonConstants.ALERT_STATUS_FIRING.equals(status) ? Instant.now().toEpochMilli() : null)
|
||||
.startAt(prometheusAlert.getStartsAt() != null
|
||||
? prometheusAlert.getStartsAt().toEpochMilli() : Instant.now().toEpochMilli())
|
||||
.endAt(CommonConstants.ALERT_STATUS_RESOLVED.equals(status)
|
||||
? prometheusAlert.getEndsAt().toEpochMilli() : null)
|
||||
.labels(labels)
|
||||
.annotations(annotations)
|
||||
.triggerTimes(1)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+35
-3
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -32,6 +33,9 @@ import org.apache.hertzbeat.alert.dao.GroupAlertDao;
|
||||
import org.apache.hertzbeat.alert.dao.SingleAlertDao;
|
||||
import org.apache.hertzbeat.alert.dto.AlertSummary;
|
||||
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
|
||||
import org.apache.hertzbeat.alert.service.AlertGroupMutationPublisher;
|
||||
import org.apache.hertzbeat.alert.service.AlertGroupNotFoundException;
|
||||
import org.apache.hertzbeat.alert.service.AlertGroupStatusNotSupportedException;
|
||||
import org.apache.hertzbeat.alert.service.AlertService;
|
||||
import org.apache.hertzbeat.common.constants.CommonConstants;
|
||||
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
|
||||
@@ -63,6 +67,9 @@ public class AlertServiceImpl implements AlertService {
|
||||
@Autowired
|
||||
private AlarmCommonReduce alarmCommonReduce;
|
||||
|
||||
@Autowired
|
||||
private AlertGroupMutationPublisher alertGroupMutationPublisher;
|
||||
|
||||
@Override
|
||||
public Page<SingleAlert> getSingleAlerts(String status, String search, String sort, String order, int pageIndex, int pageSize) {
|
||||
Specification<SingleAlert> specification = (root, query, criteriaBuilder) -> {
|
||||
@@ -166,12 +173,17 @@ public class AlertServiceImpl implements AlertService {
|
||||
|
||||
@Override
|
||||
public void deleteGroupAlerts(HashSet<Long> ids) {
|
||||
if (ids.contains(null)) {
|
||||
throw new AlertGroupNotFoundException();
|
||||
}
|
||||
List<GroupAlert> groupAlerts = groupAlertDao.findGroupAlertsByIdIn(ids);
|
||||
requireExactGroupAlertTargets(ids, groupAlerts);
|
||||
for (GroupAlert groupAlert : groupAlerts) {
|
||||
List<String> firingAlerts = groupAlert.getAlertFingerprints();
|
||||
singleAlertDao.deleteSingleAlertsByFingerprintIn(firingAlerts);
|
||||
}
|
||||
groupAlertDao.deleteGroupAlertsByIdIn(ids);
|
||||
alertGroupMutationPublisher.publishDeleted(ids);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -184,10 +196,13 @@ public class AlertServiceImpl implements AlertService {
|
||||
if (!StringUtils.hasText(status) || ids == null || ids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<GroupAlert> groupAlerts = groupAlertDao.findAllById(ids);
|
||||
if (groupAlerts.isEmpty()) {
|
||||
return;
|
||||
requireSupportedGroupAlertStatus(status);
|
||||
List<Long> requestedIds = ids.stream().distinct().toList();
|
||||
if (requestedIds.contains(null)) {
|
||||
throw new AlertGroupNotFoundException();
|
||||
}
|
||||
List<GroupAlert> groupAlerts = groupAlertDao.findAllById(requestedIds);
|
||||
requireExactGroupAlertTargets(requestedIds, groupAlerts);
|
||||
long now = Instant.now().toEpochMilli();
|
||||
List<String> fingerprints = groupAlerts.stream()
|
||||
.map(GroupAlert::getAlertFingerprints)
|
||||
@@ -217,6 +232,23 @@ public class AlertServiceImpl implements AlertService {
|
||||
if (!singleAlerts.isEmpty()) {
|
||||
singleAlertDao.saveAll(singleAlerts);
|
||||
}
|
||||
alertGroupMutationPublisher.publishStatusChanged(requestedIds, status);
|
||||
}
|
||||
|
||||
private static void requireSupportedGroupAlertStatus(String status) {
|
||||
if (!CommonConstants.ALERT_STATUS_FIRING.equals(status)
|
||||
&& !CommonConstants.ALERT_STATUS_ACKNOWLEDGED.equals(status)
|
||||
&& !CommonConstants.ALERT_STATUS_RESOLVED.equals(status)) {
|
||||
throw new AlertGroupStatusNotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireExactGroupAlertTargets(Collection<Long> requestedIds, List<GroupAlert> groupAlerts) {
|
||||
List<Long> foundIds = groupAlerts.stream().map(GroupAlert::getId).distinct().toList();
|
||||
// Batch mutations are all-or-nothing so clients cannot treat a partial update as authoritative success.
|
||||
if (foundIds.size() != requestedIds.size() || !foundIds.containsAll(requestedIds)) {
|
||||
throw new AlertGroupNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ public class AlertSilenceServiceImpl implements AlertSilenceService {
|
||||
|
||||
@Override
|
||||
public AlertSilenceResponse update(AlertSilenceRequest request) {
|
||||
Long id = mapper.requirePositiveId(request.getId());
|
||||
Long id = mapper.requirePositiveId(request == null ? null : request.getId());
|
||||
AlertSilence existing = alertSilenceDao.findById(id).orElseThrow(AlertSilenceNotFoundException::new);
|
||||
alertSilenceDao.save(mapper.toExistingEntity(request, existing));
|
||||
AlertSilence authoritative = alertSilenceDao.findById(id)
|
||||
|
||||
+46
-4
@@ -61,6 +61,8 @@ public class DataSourceServiceImpl implements DataSourceService {
|
||||
|
||||
private static final List<String> TRACE_ALLOWED_TABLES = List.of("hertzbeat_apm_red_1m", "hzb_traces");
|
||||
|
||||
private static final String PREVIEW_QUERY_EXECUTION_FAILED = "Preview query execution failed";
|
||||
|
||||
protected ResourceBundle bundle = ResourceBundleUtil.getBundle("alerter");
|
||||
|
||||
@Setter
|
||||
@@ -85,6 +87,15 @@ public class DataSourceServiceImpl implements DataSourceService {
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> calculate(String datasource, String expr) {
|
||||
return calculate(datasource, expr, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> calculatePreview(String datasource, String expr) {
|
||||
return calculate(datasource, expr, true);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> calculate(String datasource, String expr, boolean strict) {
|
||||
if (!StringUtils.hasText(expr)) {
|
||||
throw new IllegalArgumentException("Empty expression");
|
||||
}
|
||||
@@ -99,11 +110,19 @@ public class DataSourceServiceImpl implements DataSourceService {
|
||||
// replace all white space
|
||||
expr = expr.replaceAll("\\s+", " ");
|
||||
try {
|
||||
return evaluate(expr, executor);
|
||||
return evaluate(expr, executor, strict);
|
||||
} catch (AlertExpressionException ae) {
|
||||
if (strict) {
|
||||
log.warn("Alert preview calculation rejected for datasource {}", datasource);
|
||||
throw new AlertExpressionException(PREVIEW_QUERY_EXECUTION_FAILED);
|
||||
}
|
||||
log.error("Calculate query parse error, datasource: {}, expr: {}, msg: {}", datasource, expr, ae.getMessage(), ae);
|
||||
throw ae;
|
||||
} catch (Exception e) {
|
||||
if (strict) {
|
||||
log.warn("Alert preview calculation execution failed for datasource {}", datasource);
|
||||
throw new AlertExpressionException(PREVIEW_QUERY_EXECUTION_FAILED);
|
||||
}
|
||||
log.error("Error executing query on datasource {}: {}", datasource, e.getMessage());
|
||||
throw new RuntimeException("Query execution failed", e);
|
||||
}
|
||||
@@ -116,6 +135,15 @@ public class DataSourceServiceImpl implements DataSourceService {
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> query(String datasource, String expr, String alertType) {
|
||||
return query(datasource, expr, alertType, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> queryPreview(String datasource, String expr, String alertType) {
|
||||
return query(datasource, expr, alertType, true);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> query(String datasource, String expr, String alertType, boolean strict) {
|
||||
if (!StringUtils.hasText(expr)) {
|
||||
throw new IllegalArgumentException("Empty expression");
|
||||
}
|
||||
@@ -136,13 +164,27 @@ public class DataSourceServiceImpl implements DataSourceService {
|
||||
}
|
||||
|
||||
try {
|
||||
return executor.execute(expr);
|
||||
String executableExpression = strict && isSqlDatasource(datasource) ? limitPreviewSql(expr) : expr;
|
||||
return strict ? executor.executePreview(executableExpression) : executor.execute(executableExpression);
|
||||
} catch (Exception e) {
|
||||
if (strict) {
|
||||
log.warn("Alert preview query execution failed for datasource {}", datasource);
|
||||
throw new AlertExpressionException(PREVIEW_QUERY_EXECUTION_FAILED);
|
||||
}
|
||||
log.error("Error executing query on datasource {}: {}", datasource, e.getMessage());
|
||||
throw new AlertExpressionException(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String limitPreviewSql(String sql) {
|
||||
String boundedSql = sql.stripTrailing();
|
||||
if (boundedSql.endsWith(";")) {
|
||||
boundedSql = boundedSql.substring(0, boundedSql.length() - 1).stripTrailing();
|
||||
}
|
||||
return "SELECT * FROM (" + boundedSql + ") AS hertzbeat_preview LIMIT "
|
||||
+ CommonConstants.ALERT_PREVIEW_RESULT_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the datasource is SQL-based
|
||||
*/
|
||||
@@ -169,7 +211,7 @@ public class DataSourceServiceImpl implements DataSourceService {
|
||||
return logSqlSecurityValidator;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> evaluate(String expr, QueryExecutor executor) {
|
||||
private List<Map<String, Object>> evaluate(String expr, QueryExecutor executor, boolean strict) {
|
||||
CommonTokenStream tokens = createTokenStream(expr);
|
||||
AlertExpressionParser parser = new AlertExpressionParser(tokens);
|
||||
ParseTree tree = expressionCache.get(expr, e -> parser.expr());
|
||||
@@ -178,7 +220,7 @@ public class DataSourceServiceImpl implements DataSourceService {
|
||||
if (tokens.index() > 0 && tokens.LA(1) != Token.EOF) {
|
||||
throw new AlertExpressionException(bundle.getString("alerter.calculate.parse.error"));
|
||||
}
|
||||
AlertExpressionEvalVisitor visitor = new AlertExpressionEvalVisitor(executor, tokens);
|
||||
AlertExpressionEvalVisitor visitor = new AlertExpressionEvalVisitor(executor, tokens, strict);
|
||||
return visitor.visit(tree);
|
||||
}
|
||||
|
||||
|
||||
+2
-7
@@ -18,7 +18,6 @@
|
||||
package org.apache.hertzbeat.alert.service.impl;
|
||||
|
||||
import java.time.Instant;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
|
||||
import org.apache.hertzbeat.alert.service.ExternAlertService;
|
||||
import org.apache.hertzbeat.common.constants.CommonConstants;
|
||||
@@ -30,7 +29,6 @@ import org.springframework.stereotype.Service;
|
||||
/**
|
||||
* Default external alarm service impl
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class DefaultExternAlertService implements ExternAlertService {
|
||||
|
||||
@@ -39,11 +37,8 @@ public class DefaultExternAlertService implements ExternAlertService {
|
||||
|
||||
@Override
|
||||
public void addExternAlert(String content) {
|
||||
SingleAlert alert = JsonUtil.fromJsonQuietly(content, SingleAlert.class);
|
||||
if (alert == null) {
|
||||
log.warn("Failed to parse default external alert content");
|
||||
throw new IllegalArgumentException("parse extern alert content failed!");
|
||||
}
|
||||
SingleAlert alert = ExternalAlertIngressValidator.normalize(
|
||||
JsonUtil.fromJsonQuietly(content, SingleAlert.class));
|
||||
alert.setId(null);
|
||||
String status = alert.getStatus();
|
||||
if (status == null) {
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
|
||||
|
||||
/**
|
||||
* Validates and normalizes the synchronous boundary before an external alert
|
||||
* is accepted for asynchronous processing.
|
||||
*/
|
||||
final class ExternalAlertIngressValidator {
|
||||
|
||||
private static final String ALERT_REJECTED = "external_alert_rejected";
|
||||
|
||||
private ExternalAlertIngressValidator() {
|
||||
}
|
||||
|
||||
static <T> T requirePresent(T value) {
|
||||
if (value == null) {
|
||||
throw rejected();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static <T> List<T> requireBatch(List<T> values) {
|
||||
if (values == null || values.isEmpty() || values.stream().anyMatch(value -> value == null)) {
|
||||
throw rejected();
|
||||
}
|
||||
return List.copyOf(values);
|
||||
}
|
||||
|
||||
static SingleAlert normalize(SingleAlert alert) {
|
||||
requirePresent(alert);
|
||||
alert.setLabels(requireBusinessLabels(alert.getLabels()));
|
||||
alert.setAnnotations(normalizeAnnotations(alert.getAnnotations()));
|
||||
return alert;
|
||||
}
|
||||
|
||||
static Map<String, String> requireBusinessLabels(Map<String, String> labels) {
|
||||
if (labels == null || labels.isEmpty()) {
|
||||
throw rejected();
|
||||
}
|
||||
return new HashMap<>(labels);
|
||||
}
|
||||
|
||||
static Map<String, String> normalizeAnnotations(Map<String, String> annotations) {
|
||||
return annotations == null ? new HashMap<>(8) : new HashMap<>(annotations);
|
||||
}
|
||||
|
||||
private static IllegalArgumentException rejected() {
|
||||
return new IllegalArgumentException(ALERT_REJECTED);
|
||||
}
|
||||
}
|
||||
+36
-5
@@ -30,6 +30,7 @@ import org.apache.hertzbeat.alert.notice.AlertNoticeDispatch;
|
||||
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.NoticeTemplateMutationException;
|
||||
import org.apache.hertzbeat.alert.service.NoticeConfigService;
|
||||
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -131,12 +132,12 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine
|
||||
} else {
|
||||
// Query custom templates
|
||||
Specification<NoticeTemplate> specification = (root, query, criteriaBuilder) -> {
|
||||
Predicate predicate = criteriaBuilder.conjunction();
|
||||
Predicate predicate = criteriaBuilder.equal(root.get("preset"), false);
|
||||
if (StringUtils.isNotBlank(name)) {
|
||||
Predicate predicateName = criteriaBuilder.like(
|
||||
criteriaBuilder.lower(root.get("name")), "%" + name.toLowerCase() + "%"
|
||||
);
|
||||
predicate = criteriaBuilder.and(predicateName);
|
||||
predicate = criteriaBuilder.and(predicate, predicateName);
|
||||
}
|
||||
return predicate;
|
||||
};
|
||||
@@ -272,19 +273,49 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine
|
||||
|
||||
@Override
|
||||
public void addNoticeTemplate(NoticeTemplate noticeTemplate) {
|
||||
noticeTemplateDao.save(noticeTemplate);
|
||||
if (noticeTemplate.getId() != null || noticeTemplate.isPreset()) {
|
||||
throw new NoticeTemplateMutationException(NoticeTemplateMutationException.Reason.INVALID_REQUEST);
|
||||
}
|
||||
NoticeTemplate customTemplate = NoticeTemplate.builder()
|
||||
.name(noticeTemplate.getName())
|
||||
.type(noticeTemplate.getType())
|
||||
.preset(false)
|
||||
.content(noticeTemplate.getContent())
|
||||
.build();
|
||||
noticeTemplateDao.save(customTemplate);
|
||||
clearNoticeRulesCache();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editNoticeTemplate(NoticeTemplate noticeTemplate) {
|
||||
noticeTemplateDao.save(noticeTemplate);
|
||||
if (noticeTemplate.getId() == null) {
|
||||
throw new NoticeTemplateMutationException(NoticeTemplateMutationException.Reason.INVALID_REQUEST);
|
||||
}
|
||||
NoticeTemplate persisted = noticeTemplateDao.findByIdForUpdate(noticeTemplate.getId())
|
||||
.orElseThrow(() -> new NoticeTemplateMutationException(
|
||||
NoticeTemplateMutationException.Reason.NOT_FOUND));
|
||||
if (persisted.isPreset() || noticeTemplate.isPreset()) {
|
||||
throw new NoticeTemplateMutationException(NoticeTemplateMutationException.Reason.READ_ONLY);
|
||||
}
|
||||
persisted.setName(noticeTemplate.getName());
|
||||
persisted.setType(noticeTemplate.getType());
|
||||
persisted.setContent(noticeTemplate.getContent());
|
||||
noticeTemplateDao.save(persisted);
|
||||
clearNoticeRulesCache();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteNoticeTemplate(Long templateId) {
|
||||
noticeTemplateDao.deleteById(templateId);
|
||||
if (templateId == null) {
|
||||
throw new NoticeTemplateMutationException(NoticeTemplateMutationException.Reason.INVALID_REQUEST);
|
||||
}
|
||||
NoticeTemplate persisted = noticeTemplateDao.findByIdForUpdate(templateId)
|
||||
.orElseThrow(() -> new NoticeTemplateMutationException(
|
||||
NoticeTemplateMutationException.Reason.NOT_FOUND));
|
||||
if (persisted.isPreset()) {
|
||||
throw new NoticeTemplateMutationException(NoticeTemplateMutationException.Reason.READ_ONLY);
|
||||
}
|
||||
noticeTemplateDao.delete(persisted);
|
||||
clearNoticeRulesCache();
|
||||
}
|
||||
|
||||
|
||||
+36
-43
@@ -19,10 +19,8 @@ package org.apache.hertzbeat.alert.service.impl;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hertzbeat.alert.dto.PrometheusExternAlert;
|
||||
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
|
||||
import org.apache.hertzbeat.alert.service.ExternAlertService;
|
||||
@@ -36,7 +34,6 @@ import org.springframework.util.StringUtils;
|
||||
/**
|
||||
* Prometheus external alarm service impl
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PrometheusExternAlertService implements ExternAlertService {
|
||||
|
||||
@@ -48,48 +45,44 @@ public class PrometheusExternAlertService implements ExternAlertService {
|
||||
public void addExternAlert(String content) {
|
||||
|
||||
TypeReference<List<PrometheusExternAlert>> typeReference = new TypeReference<>() {};
|
||||
List<PrometheusExternAlert> alerts = JsonUtil.fromJsonQuietly(content, typeReference);
|
||||
if (alerts == null || alerts.isEmpty()) {
|
||||
log.warn("Failed to parse Prometheus external alert content");
|
||||
return;
|
||||
}
|
||||
for (PrometheusExternAlert alert : alerts) {
|
||||
Map<String, String> annotations = alert.getAnnotations();
|
||||
if (annotations == null) {
|
||||
annotations = new HashMap<>(8);
|
||||
}
|
||||
if (StringUtils.hasText(alert.getGeneratorURL())) {
|
||||
annotations.put("generatorURL", alert.getGeneratorURL());
|
||||
}
|
||||
String description = annotations.get("description");
|
||||
if (description == null) {
|
||||
description = annotations.get("summary");
|
||||
}
|
||||
if (description == null) {
|
||||
description = annotations.values().stream().findFirst().orElse("");
|
||||
}
|
||||
Map<String, String> labels = alert.getLabels();
|
||||
if (labels == null) {
|
||||
labels = new HashMap<>(8);
|
||||
}
|
||||
labels.put("__source__", "prometheus");
|
||||
String status = CommonConstants.ALERT_STATUS_FIRING;
|
||||
if (alert.getEndsAt() != null && alert.getEndsAt().isBefore(Instant.now())) {
|
||||
status = CommonConstants.ALERT_STATUS_RESOLVED;
|
||||
}
|
||||
SingleAlert singleAlert = SingleAlert.builder()
|
||||
.content(description)
|
||||
.status(status)
|
||||
.activeAt(CommonConstants.ALERT_STATUS_FIRING.equals(status) ? Instant.now().toEpochMilli() : null)
|
||||
.startAt(alert.getStartsAt() != null ? alert.getStartsAt().toEpochMilli() : Instant.now().toEpochMilli())
|
||||
.endAt(CommonConstants.ALERT_STATUS_RESOLVED.equals(status) ? alert.getEndsAt().toEpochMilli() : null)
|
||||
.labels(labels)
|
||||
.annotations(alert.getAnnotations())
|
||||
.triggerTimes(1)
|
||||
.build();
|
||||
List<PrometheusExternAlert> alerts = ExternalAlertIngressValidator.requireBatch(
|
||||
JsonUtil.fromJsonQuietly(content, typeReference));
|
||||
List<SingleAlert> singleAlerts = alerts.stream()
|
||||
.map(this::toSingleAlert)
|
||||
.toList();
|
||||
singleAlerts.forEach(alarmCommonReduce::reduceAndSendAlarm);
|
||||
}
|
||||
|
||||
alarmCommonReduce.reduceAndSendAlarm(singleAlert);
|
||||
private SingleAlert toSingleAlert(PrometheusExternAlert alert) {
|
||||
Map<String, String> annotations =
|
||||
ExternalAlertIngressValidator.normalizeAnnotations(alert.getAnnotations());
|
||||
if (StringUtils.hasText(alert.getGeneratorURL())) {
|
||||
annotations.put("generatorURL", alert.getGeneratorURL());
|
||||
}
|
||||
String description = annotations.get("description");
|
||||
if (description == null) {
|
||||
description = annotations.get("summary");
|
||||
}
|
||||
if (description == null) {
|
||||
description = annotations.values().stream().findFirst().orElse("");
|
||||
}
|
||||
Map<String, String> labels =
|
||||
ExternalAlertIngressValidator.requireBusinessLabels(alert.getLabels());
|
||||
labels.put("__source__", "prometheus");
|
||||
String status = CommonConstants.ALERT_STATUS_FIRING;
|
||||
if (alert.getEndsAt() != null && alert.getEndsAt().isBefore(Instant.now())) {
|
||||
status = CommonConstants.ALERT_STATUS_RESOLVED;
|
||||
}
|
||||
return ExternalAlertIngressValidator.normalize(SingleAlert.builder()
|
||||
.content(description)
|
||||
.status(status)
|
||||
.activeAt(CommonConstants.ALERT_STATUS_FIRING.equals(status) ? Instant.now().toEpochMilli() : null)
|
||||
.startAt(alert.getStartsAt() != null ? alert.getStartsAt().toEpochMilli() : Instant.now().toEpochMilli())
|
||||
.endAt(CommonConstants.ALERT_STATUS_RESOLVED.equals(status) ? alert.getEndsAt().toEpochMilli() : null)
|
||||
.labels(labels)
|
||||
.annotations(annotations)
|
||||
.triggerTimes(1)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+2
-7
@@ -17,7 +17,6 @@
|
||||
|
||||
package org.apache.hertzbeat.alert.service.impl;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
|
||||
import org.apache.hertzbeat.alert.service.ExternAlertService;
|
||||
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
|
||||
@@ -28,7 +27,6 @@ import org.springframework.stereotype.Service;
|
||||
/**
|
||||
* zabbix external alarm service impl
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ZabbixExternAlertServiceImpl implements ExternAlertService {
|
||||
|
||||
@@ -37,11 +35,8 @@ public class ZabbixExternAlertServiceImpl implements ExternAlertService {
|
||||
|
||||
@Override
|
||||
public void addExternAlert(String content) {
|
||||
SingleAlert alert = JsonUtil.fromJsonQuietly(content, SingleAlert.class);
|
||||
if (alert == null) {
|
||||
log.warn("Failed to parse Zabbix external alert content");
|
||||
return;
|
||||
}
|
||||
SingleAlert alert = ExternalAlertIngressValidator.normalize(
|
||||
JsonUtil.fromJsonQuietly(content, SingleAlert.class));
|
||||
alarmCommonReduce.reduceAndSendAlarm(alert);
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -33,6 +33,7 @@ import org.junit.jupiter.api.function.Executable;
|
||||
class AlertIntegrationRouteAuthorizationConfigTest {
|
||||
|
||||
private static final String SOURCE_POST_RULE = " - /api/alerts/**===post===[admin,user]";
|
||||
private static final String ALERT_READ_RULE = " - /api/alerts/**===get===[admin,user,guest]";
|
||||
private static final String PROMETHEUS_POST_RULE = " - /api/v2/alerts===post===[admin,user]";
|
||||
private static final List<String> SURENESS_CONFIGS = List.of(
|
||||
"hertzbeat-startup/src/main/resources/sureness.yml",
|
||||
@@ -56,6 +57,7 @@ class AlertIntegrationRouteAuthorizationConfigTest {
|
||||
|
||||
private static void assertRules(String config) throws IOException {
|
||||
List<String> lines = Files.readAllLines(repoRoot().resolve(config));
|
||||
assertTrue(lines.contains(ALERT_READ_RULE), () -> config + " must protect alert reads");
|
||||
assertTrue(lines.contains(SOURCE_POST_RULE), () -> config + " must protect source ingestion");
|
||||
assertTrue(lines.contains(PROMETHEUS_POST_RULE), () -> config + " must protect Prometheus ingestion");
|
||||
assertFalse(lines.stream().anyMatch(line -> line.contains("/api/alerts/report") && line.endsWith("===*")),
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.function.Executable;
|
||||
|
||||
class AlertNoiseRouteAuthorizationConfigTest {
|
||||
|
||||
private static final String GET_RULE = " - /api/alert/**===get===[admin,user,guest]";
|
||||
private static final String ALERT_PREVIEW_GET_RULE =
|
||||
" - /api/alert/define/preview/**===get===[admin,user]";
|
||||
private static final String POST_RULE = " - /api/alert/**===post===[admin,user]";
|
||||
private static final String PUT_RULE = " - /api/alert/**===put===[admin,user]";
|
||||
private static final String DELETE_RULE = " - /api/alert/**===delete===[admin]";
|
||||
private static final List<String> SURENESS_CONFIGS = List.of(
|
||||
"hertzbeat-startup/src/main/resources/sureness.yml",
|
||||
"hertzbeat-manager/src/test/resources/sureness.yml",
|
||||
"hertzbeat-e2e/hertzbeat-observability-e2e/src/test/resources/sureness.yml",
|
||||
"script/sureness.yml",
|
||||
"script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml",
|
||||
"script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml",
|
||||
"script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml",
|
||||
"script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml",
|
||||
"script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml");
|
||||
|
||||
@Test
|
||||
void everyNoiseManagementRouteUsesTheExpectedRoleMatrix() {
|
||||
List<Executable> checks = new ArrayList<>();
|
||||
for (String config : SURENESS_CONFIGS) {
|
||||
checks.add(() -> assertRules(config));
|
||||
}
|
||||
assertAll(checks);
|
||||
}
|
||||
|
||||
private static void assertRules(String config) throws IOException {
|
||||
List<String> lines = Files.readAllLines(repoRoot().resolve(config));
|
||||
assertTrue(lines.contains(ALERT_PREVIEW_GET_RULE),
|
||||
() -> config + " must restrict alert preview telemetry to authors");
|
||||
assertTrue(lines.contains(GET_RULE), () -> config + " must allow authenticated alert reads");
|
||||
assertTrue(lines.indexOf(ALERT_PREVIEW_GET_RULE) < lines.indexOf(GET_RULE),
|
||||
() -> config + " must match the alert preview rule before the wildcard read");
|
||||
assertTrue(lines.contains(POST_RULE), () -> config + " must protect alert creates");
|
||||
assertTrue(lines.contains(PUT_RULE), () -> config + " must protect alert updates");
|
||||
assertTrue(lines.contains(DELETE_RULE), () -> config + " must restrict alert deletes");
|
||||
assertFalse(lines.stream().anyMatch(line -> line.contains("/api/alert/inhibit") && line.endsWith("===*")),
|
||||
() -> config + " must not bypass inhibit authentication");
|
||||
assertFalse(lines.stream().anyMatch(line -> line.contains("/api/alert/silence") && line.endsWith("===*")),
|
||||
() -> config + " must not bypass silence authentication");
|
||||
}
|
||||
|
||||
private static Path repoRoot() {
|
||||
Path current = Paths.get("").toAbsolutePath();
|
||||
while (current != null && !Files.exists(current.resolve("hertzbeat-alerter/pom.xml"))) {
|
||||
current = current.getParent();
|
||||
}
|
||||
if (current == null) {
|
||||
throw new IllegalStateException("Cannot locate HertzBeat repository root");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
+190
-50
@@ -17,77 +17,217 @@
|
||||
|
||||
package org.apache.hertzbeat.alert.config;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.event.ContextClosedEvent;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* alert sse manager test
|
||||
* Alert SSE delivery and reconnection contract tests.
|
||||
*/
|
||||
public class AlertSseManagerTest {
|
||||
class AlertSseManagerTest {
|
||||
|
||||
private AlertSseManager alertSseManager;
|
||||
@Test
|
||||
void newSubscriberReceivesImmediateReconnectContract() {
|
||||
RecordingSseEmitter emitter = new RecordingSseEmitter();
|
||||
AlertSseManager manager = new AlertSseManager(() -> emitter);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
alertSseManager = new AlertSseManager();
|
||||
assertEquals(emitter, manager.createEmitter(1L));
|
||||
assertEquals(1, emitter.events.size());
|
||||
String event = eventText(emitter.events.get(0));
|
||||
assertTrue(event.contains("event:ALERT_STREAM_READY"));
|
||||
assertTrue(event.contains("retry:3000"));
|
||||
assertTrue(event.contains("data:{}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCompleteThrowsException() throws Exception {
|
||||
SseEmitter emitter = alertSseManager.createEmitter(1L);
|
||||
assertNotNull(emitter);
|
||||
void broadcastDeliversNamedEventsWithDistinctIds() {
|
||||
RecordingSseEmitter emitter = new RecordingSseEmitter();
|
||||
AlertSseManager manager = new AlertSseManager(() -> emitter);
|
||||
manager.createEmitter(1L);
|
||||
|
||||
Map<Long, SseEmitter> emitters = new HashMap<>();
|
||||
SseEmitter spyEmitter = mock(SseEmitter.class);
|
||||
|
||||
doThrow(new IllegalStateException("Simulated output stream error")).when(spyEmitter).send(any(SseEmitter.SseEventBuilder.class));
|
||||
doThrow(new RuntimeException("Complete failed")).when(spyEmitter).complete();
|
||||
|
||||
emitters.put(1L, spyEmitter);
|
||||
manager.broadcast("{\"id\":7,\"status\":\"firing\"}");
|
||||
manager.broadcast("{\"id\":7,\"status\":\"acknowledged\"}");
|
||||
|
||||
Field emittersField = AlertSseManager.class.getDeclaredField("emitters");
|
||||
emittersField.setAccessible(true);
|
||||
emittersField.set(alertSseManager, emitters);
|
||||
|
||||
assertThrows(RuntimeException.class, () -> alertSseManager.broadcast("{\"id\":1,\"content\":\"Test alert\"}"));
|
||||
Map<Long, SseEmitter> currentEmitters = (Map<Long, SseEmitter>) emittersField.get(alertSseManager);
|
||||
assertFalse(currentEmitters.containsKey(1L), "Emitter must be removed even when complete() throws");
|
||||
assertEquals(3, emitter.events.size());
|
||||
String first = eventText(emitter.events.get(1));
|
||||
String second = eventText(emitter.events.get(2));
|
||||
assertTrue(first.contains("event:ALERT_EVENT"));
|
||||
assertTrue(first.contains("{\"id\":7,\"status\":\"firing\"}"));
|
||||
assertTrue(second.contains("event:ALERT_EVENT"));
|
||||
assertTrue(second.contains("{\"id\":7,\"status\":\"acknowledged\"}"));
|
||||
assertNotEquals(eventId(first), eventId(second));
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesActiveEmittersBeforeApplicationShutdown() throws Exception {
|
||||
SseEmitter emitter = mock(SseEmitter.class);
|
||||
Map<Long, SseEmitter> emitters = emitters(alertSseManager);
|
||||
emitters.put(1L, emitter);
|
||||
void groupMutationUsesExplicitEventNameAndSharedLogicalEventId() {
|
||||
RecordingSseEmitter firstEmitter = new RecordingSseEmitter();
|
||||
RecordingSseEmitter secondEmitter = new RecordingSseEmitter();
|
||||
Queue<RecordingSseEmitter> emitters = new ArrayDeque<>(List.of(firstEmitter, secondEmitter));
|
||||
AlertSseManager manager = new AlertSseManager(emitters::remove);
|
||||
manager.createEmitter(1L);
|
||||
manager.createEmitter(2L);
|
||||
|
||||
alertSseManager.onApplicationEvent(new ContextClosedEvent(mock(ConfigurableApplicationContext.class)));
|
||||
alertSseManager.createEmitter(2L);
|
||||
manager.broadcastGroupMutation("{\"id\":7,\"mutation\":\"GROUP_DELETED\"}");
|
||||
|
||||
verify(emitter).complete();
|
||||
assertFalse(emitters.containsKey(1L));
|
||||
assertFalse(emitters.containsKey(2L), "Shutdown must not retain a concurrent late subscriber");
|
||||
String first = eventText(firstEmitter.events.get(1));
|
||||
String second = eventText(secondEmitter.events.get(1));
|
||||
assertTrue(first.contains("event:ALERT_GROUP_MUTATION"));
|
||||
assertTrue(second.contains("event:ALERT_GROUP_MUTATION"));
|
||||
assertEquals(eventId(first), eventId(second));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<Long, SseEmitter> emitters(AlertSseManager manager) throws Exception {
|
||||
Field emittersField = AlertSseManager.class.getDeclaredField("emitters");
|
||||
emittersField.setAccessible(true);
|
||||
return (Map<Long, SseEmitter>) emittersField.get(manager);
|
||||
@Test
|
||||
void failedConnectionCanReconnectAndReceiveLaterAlerts() {
|
||||
RecordingSseEmitter failedEmitter = new RecordingSseEmitter();
|
||||
RecordingSseEmitter reconnectedEmitter = new RecordingSseEmitter();
|
||||
AtomicReference<RecordingSseEmitter> current = new AtomicReference<>(failedEmitter);
|
||||
AlertSseManager manager = new AlertSseManager(current::get);
|
||||
manager.createEmitter(1L);
|
||||
failedEmitter.failSends = true;
|
||||
|
||||
manager.broadcast("{\"id\":7,\"status\":\"firing\"}");
|
||||
|
||||
assertTrue(failedEmitter.completed);
|
||||
current.set(reconnectedEmitter);
|
||||
manager.createEmitter(1L);
|
||||
manager.broadcast("{\"id\":7,\"status\":\"resolved\"}");
|
||||
|
||||
assertEquals(2, reconnectedEmitter.events.size());
|
||||
assertTrue(eventText(reconnectedEmitter.events.get(1)).contains("\"status\":\"resolved\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replacedConnectionCannotRemoveNewSameClientOwner() {
|
||||
RecordingSseEmitter oldEmitter = new RecordingSseEmitter();
|
||||
RecordingSseEmitter newEmitter = new RecordingSseEmitter();
|
||||
Queue<RecordingSseEmitter> emitters = new ArrayDeque<>(List.of(oldEmitter, newEmitter));
|
||||
AlertSseManager manager = new AlertSseManager(emitters::remove);
|
||||
|
||||
manager.createEmitter(1L);
|
||||
manager.createEmitter(1L);
|
||||
oldEmitter.signalCompletion();
|
||||
manager.broadcast("{\"id\":7,\"status\":\"resolved\"}");
|
||||
|
||||
assertTrue(oldEmitter.completed);
|
||||
assertEquals(2, newEmitter.events.size());
|
||||
assertTrue(eventText(newEmitter.events.get(1)).contains("\"status\":\"resolved\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unexpectedSendFailureDoesNotLogExceptionOrAlertBody() {
|
||||
String privateDetail = "private-exception-and-alert-body";
|
||||
RecordingSseEmitter emitter = new RecordingSseEmitter();
|
||||
AlertSseManager manager = new AlertSseManager(() -> emitter);
|
||||
Logger logger = (Logger) LoggerFactory.getLogger(AlertSseManager.class);
|
||||
Level originalLevel = logger.getLevel();
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
logger.setLevel(Level.DEBUG);
|
||||
try {
|
||||
manager.createEmitter(1L);
|
||||
emitter.runtimeFailure = new UnsupportedOperationException(privateDetail);
|
||||
emitter.completeFailure = new IllegalArgumentException(privateDetail);
|
||||
|
||||
manager.broadcast("{\"content\":\"" + privateDetail + "\"}");
|
||||
|
||||
String logs = appender.list.stream()
|
||||
.map(ILoggingEvent::getFormattedMessage)
|
||||
.reduce("", String::concat);
|
||||
assertFalse(logs.contains(privateDetail));
|
||||
assertTrue(logs.contains(UnsupportedOperationException.class.getSimpleName()));
|
||||
assertTrue(logs.contains(IllegalArgumentException.class.getSimpleName()));
|
||||
} finally {
|
||||
logger.setLevel(originalLevel);
|
||||
logger.detachAppender(appender);
|
||||
appender.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesActiveEmittersAndRejectsLateSubscribersDuringShutdown() {
|
||||
RecordingSseEmitter activeEmitter = new RecordingSseEmitter();
|
||||
RecordingSseEmitter lateEmitter = new RecordingSseEmitter();
|
||||
Queue<RecordingSseEmitter> emitters = new ArrayDeque<>(List.of(activeEmitter, lateEmitter));
|
||||
AlertSseManager manager = new AlertSseManager(emitters::remove);
|
||||
manager.createEmitter(1L);
|
||||
|
||||
manager.onApplicationEvent(new ContextClosedEvent(mock(ConfigurableApplicationContext.class)));
|
||||
manager.createEmitter(2L);
|
||||
manager.broadcast("{\"id\":7,\"status\":\"resolved\"}");
|
||||
|
||||
assertTrue(activeEmitter.completed);
|
||||
assertTrue(lateEmitter.completed);
|
||||
assertEquals(1, activeEmitter.events.size());
|
||||
assertTrue(lateEmitter.events.isEmpty());
|
||||
}
|
||||
|
||||
private static String eventText(SseEmitter.SseEventBuilder event) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
event.build().forEach(part -> text.append(part.getData()));
|
||||
return text.toString();
|
||||
}
|
||||
|
||||
private static String eventId(String event) {
|
||||
return event.lines()
|
||||
.filter(line -> line.startsWith("id:"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
|
||||
private static final class RecordingSseEmitter extends SseEmitter {
|
||||
|
||||
private final List<SseEventBuilder> events = new ArrayList<>();
|
||||
private boolean failSends;
|
||||
private boolean completed;
|
||||
private RuntimeException runtimeFailure;
|
||||
private RuntimeException completeFailure;
|
||||
private Runnable completionCallback;
|
||||
|
||||
@Override
|
||||
public void send(SseEventBuilder builder) throws IOException {
|
||||
if (runtimeFailure != null) {
|
||||
throw runtimeFailure;
|
||||
}
|
||||
if (failSends) {
|
||||
throw new IOException("private alert payload");
|
||||
}
|
||||
events.add(builder);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void complete() {
|
||||
if (completeFailure != null) {
|
||||
throw completeFailure;
|
||||
}
|
||||
completed = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompletion(Runnable callback) {
|
||||
completionCallback = callback;
|
||||
}
|
||||
|
||||
private void signalCompletion() {
|
||||
completionCallback.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+79
-77
@@ -1,10 +1,10 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* 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
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
@@ -18,116 +18,118 @@
|
||||
package org.apache.hertzbeat.alert.controller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitResponse;
|
||||
import org.apache.hertzbeat.alert.service.AlertInhibitNotFoundException;
|
||||
import org.apache.hertzbeat.alert.service.AlertInhibitOperationException;
|
||||
import org.apache.hertzbeat.alert.service.AlertInhibitService;
|
||||
import org.apache.hertzbeat.common.constants.CommonConstants;
|
||||
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.dao.DataAccessResourceFailureException;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
||||
/**
|
||||
* test case for {@link AlertInhibitControllerTest}
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class AlertInhibitControllerTest {
|
||||
class AlertInhibitControllerTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Mock
|
||||
private AlertInhibitService alertInhibitService;
|
||||
|
||||
private AlertInhibitService service;
|
||||
@InjectMocks
|
||||
private AlertInhibitController alertInhibitController;
|
||||
|
||||
private AlertInhibit alertInhibit;
|
||||
private AlertInhibitController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
this.mockMvc = standaloneSetup(alertInhibitController).build();
|
||||
|
||||
HashMap<String, String> sourceLabels = new HashMap<>();
|
||||
HashMap<String, String> targetLabels = new HashMap<>();
|
||||
|
||||
alertInhibit = AlertInhibit.builder()
|
||||
.id(1L)
|
||||
.name("test")
|
||||
.sourceLabels(sourceLabels)
|
||||
.targetLabels(targetLabels)
|
||||
.creator("test")
|
||||
.build();
|
||||
mockMvc = standaloneSetup(controller).setControllerAdvice(new AlertInhibitControllerAdvice()).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddNewAlertInhibit() throws Exception {
|
||||
void createAndUpdateReturnAuthoritativeExplicitRecords() throws Exception {
|
||||
AlertInhibitResponse response = response();
|
||||
when(service.create(any(AlertInhibitRequest.class))).thenReturn(response);
|
||||
when(service.update(any(AlertInhibitRequest.class))).thenReturn(response);
|
||||
|
||||
doNothing().when(alertInhibitService).validate(any(AlertInhibit.class), eq(false));
|
||||
doNothing().when(alertInhibitService).addAlertInhibit(any(AlertInhibit.class));
|
||||
mockMvc.perform(post("/api/alert/inhibit").contentType(MediaType.APPLICATION_JSON).content(createBody()))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
|
||||
.andExpect(jsonPath("$.data.id").value(7));
|
||||
mockMvc.perform(put("/api/alert/inhibit").contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createBody().replaceFirst("\\{", "{\"id\":7,")))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
|
||||
.andExpect(jsonPath("$.data.name").value("Host suppression"));
|
||||
}
|
||||
|
||||
mockMvc.perform(post("/api/alert/inhibit")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(JsonUtil.toJson(alertInhibit)))
|
||||
.andExpect(status().isOk())
|
||||
@Test
|
||||
void createRejectsAuditFieldsWithoutReflectingValues() throws Exception {
|
||||
String body = """
|
||||
{"name":"Host suppression","enable":true,
|
||||
"sourceLabels":{"severity":"critical"},"targetLabels":{"severity":"warning"},
|
||||
"equalLabels":["instance"],"creator":"attacker"}
|
||||
""";
|
||||
mockMvc.perform(post("/api/alert/inhibit").contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$").value(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("attacker"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailDistinguishesMissingUnavailableAndErrorWithoutReflectingMessages() throws Exception {
|
||||
when(service.get(1L)).thenThrow(new AlertInhibitNotFoundException());
|
||||
when(service.get(2L)).thenThrow(new DataAccessResourceFailureException("matcher-sentinel"));
|
||||
when(service.get(3L)).thenThrow(new IllegalStateException("body-sentinel"));
|
||||
when(service.get(7L)).thenReturn(response());
|
||||
|
||||
mockMvc.perform(get("/api/alert/inhibit/7")).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Add success"));
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void testModifyAlertInhibit() throws Exception {
|
||||
|
||||
doNothing().when(alertInhibitService).validate(any(AlertInhibit.class), eq(true));
|
||||
doNothing().when(alertInhibitService).modifyAlertInhibit(any(AlertInhibit.class));
|
||||
|
||||
mockMvc.perform(put("/api/alert/inhibit")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(JsonUtil.toJson(alertInhibit)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Modify success"));
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAlertInhibitExists() throws Exception {
|
||||
|
||||
when(alertInhibitService.getAlertInhibit(1L)).thenReturn(alertInhibit);
|
||||
|
||||
mockMvc.perform(get("/api/alert/inhibit/{id}", 1L)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(alertInhibit.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAlertInhibitNotExists() throws Exception {
|
||||
|
||||
when(alertInhibitService.getAlertInhibit(1L)).thenReturn(null);
|
||||
|
||||
mockMvc.perform(get("/api/alert/inhibit/{id}", 1L)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(7));
|
||||
mockMvc.perform(get("/api/alert/inhibit/1")).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.MONITOR_NOT_EXIST_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("AlertInhibit not exist."));
|
||||
mockMvc.perform(get("/api/alert/inhibit/2")).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.msg").value("Alert inhibit storage unavailable"));
|
||||
mockMvc.perform(get("/api/alert/inhibit/3")).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.msg").value("Alert inhibit operation error"))
|
||||
.andExpect(jsonPath("$").value(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("body-sentinel"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uncertainCreateReturnsSafeNonValidationOutcome() throws Exception {
|
||||
when(service.create(any(AlertInhibitRequest.class)))
|
||||
.thenThrow(new AlertInhibitOperationException("write-sentinel"));
|
||||
|
||||
mockMvc.perform(post("/api/alert/inhibit").contentType(MediaType.APPLICATION_JSON).content(createBody()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Alert inhibit operation error"))
|
||||
.andExpect(jsonPath("$").value(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("write-sentinel"))));
|
||||
}
|
||||
|
||||
private String createBody() {
|
||||
return """
|
||||
{"name":"Host suppression","enable":true,
|
||||
"sourceLabels":{"severity":"critical"},"targetLabels":{"severity":"warning"},
|
||||
"equalLabels":["instance"]}
|
||||
""";
|
||||
}
|
||||
|
||||
private AlertInhibitResponse response() {
|
||||
return new AlertInhibitResponse(7L, "Host suppression", Map.of("severity", "critical"),
|
||||
Map.of("severity", "warning"), List.of("instance"), true, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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.controller;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitDeleteResponse;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitPageResponse;
|
||||
import org.apache.hertzbeat.alert.service.AlertInhibitService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.test.web.servlet.MockMvc;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AlertInhibitsControllerTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
@Mock
|
||||
private AlertInhibitService service;
|
||||
@InjectMocks
|
||||
private AlertInhibitsController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = standaloneSetup(controller).setControllerAdvice(new AlertInhibitControllerAdvice()).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAndDeleteReturnExplicitContracts() throws Exception {
|
||||
when(service.list(null, null, "id", "desc", 0, 8))
|
||||
.thenReturn(new AlertInhibitPageResponse(List.of(), 0, 0, 0, 8));
|
||||
when(service.delete(Set.of(7L, 8L)))
|
||||
.thenReturn(new AlertInhibitDeleteResponse("partial", Set.of(7L), Set.of(8L)));
|
||||
|
||||
mockMvc.perform(get("/api/alert/inhibits"))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.totalElements").value(0));
|
||||
mockMvc.perform(delete("/api/alert/inhibits?ids=7&ids=8"))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("partial"))
|
||||
.andExpect(jsonPath("$.data.deletedIds[0]").value(7))
|
||||
.andExpect(jsonPath("$.data.missingIds[0]").value(8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyDeleteReturnsStableValidationFailure() throws Exception {
|
||||
when(service.delete(null)).thenThrow(new IllegalArgumentException("ids-sentinel"));
|
||||
|
||||
mockMvc.perform(delete("/api/alert/inhibits")).andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.msg").value("Invalid alert inhibit request"))
|
||||
.andExpect(jsonPath("$").value(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("ids-sentinel"))));
|
||||
}
|
||||
}
|
||||
+113
@@ -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.alert.controller;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.CatalogItem;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.CatalogResponse;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.IntegrationGuide;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationRequestException;
|
||||
import org.apache.hertzbeat.alert.integration.service.AlertIntegrationCatalogService;
|
||||
import org.apache.hertzbeat.common.constants.CommonConstants;
|
||||
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;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AlertIntegrationCatalogControllerTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Mock
|
||||
private AlertIntegrationCatalogService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders
|
||||
.standaloneSetup(new AlertIntegrationCatalogController(service))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesTheUnversionedCatalogEndpoint() throws Exception {
|
||||
when(service.catalog()).thenReturn(new CatalogResponse(List.of(
|
||||
new CatalogItem(
|
||||
"webhook",
|
||||
"alert.integration.source.webhook",
|
||||
"hertzbeat",
|
||||
org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.READY,
|
||||
List.of()))));
|
||||
|
||||
mockMvc.perform(MockMvcRequestBuilders.get("/api/alerts/integrations"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
|
||||
.andExpect(jsonPath("$.data.items[0].source").value("webhook"))
|
||||
.andExpect(jsonPath("$.data.items[0].readiness").value("ready"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesTheUnversionedRenderEndpoint() throws Exception {
|
||||
when(service.render("webhook")).thenReturn(new IntegrationGuide(
|
||||
"webhook",
|
||||
"alert.integration.source.webhook",
|
||||
"hertzbeat",
|
||||
"POST",
|
||||
"/api/alerts/report",
|
||||
"single_alert",
|
||||
Map.of("Authorization", "Bearer {token}"),
|
||||
List.of("labels"),
|
||||
List.of("alert.integration.webhook.step.configure_request"),
|
||||
List.of("{\"labels\":{\"alertname\":\"HighCPUUsage\"}}"),
|
||||
"alert.integration.ack.accepted_for_processing",
|
||||
Readiness.READY,
|
||||
List.of()));
|
||||
|
||||
mockMvc.perform(MockMvcRequestBuilders.get("/api/alerts/integrations/webhook"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
|
||||
.andExpect(jsonPath("$.data.source").value("webhook"))
|
||||
.andExpect(jsonPath("$.data.requiredHeaders.Authorization").value("Bearer {token}"))
|
||||
.andExpect(jsonPath("$.data.readiness").value("ready"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSourcesReturnSafeStableErrors() throws Exception {
|
||||
String privateSource = "Bearer-private-source";
|
||||
when(service.render(privateSource)).thenThrow(
|
||||
AlertIntegrationRequestException.sourceUnsupported());
|
||||
|
||||
mockMvc.perform(MockMvcRequestBuilders.get("/api/alerts/integrations/{source}", privateSource))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("external_alert_source_unsupported"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString(privateSource))));
|
||||
}
|
||||
}
|
||||
+61
@@ -19,13 +19,19 @@ package org.apache.hertzbeat.alert.controller;
|
||||
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
|
||||
import org.apache.hertzbeat.alert.service.ExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.AlertManagerExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.DefaultExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.PrometheusExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.ZabbixExternAlertServiceImpl;
|
||||
import org.apache.hertzbeat.common.constants.CommonConstants;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -34,8 +40,10 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.RequestBuilder;
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
/**
|
||||
* Unit contract for {@link AlertReportController}.
|
||||
@@ -50,6 +58,9 @@ class AlertReportControllerTest {
|
||||
@Mock
|
||||
private ExternAlertService externAlertService;
|
||||
|
||||
@Mock
|
||||
private AlarmCommonReduce alarmCommonReduce;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders
|
||||
@@ -135,6 +146,37 @@ class AlertReportControllerTest {
|
||||
.andExpect(jsonPath("$.msg").value("external_alert_rejected"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void realIngressRejectsMalformedEmptyAndUnprocessablePayloadsSafely() throws Exception {
|
||||
MockMvc realIngress = MockMvcBuilders
|
||||
.standaloneSetup(new AlertReportController(realIngressServices()))
|
||||
.build();
|
||||
String privateBody = "Bearer-private-token private-test-source /secret/private-body";
|
||||
List<RequestBuilder> rejectedRequests = List.of(
|
||||
post("/api/alerts/report", privateBody),
|
||||
post("/api/alerts/report", "{}"),
|
||||
post("/api/v2/alerts", privateBody),
|
||||
post("/api/v2/alerts", "[]"),
|
||||
post("/api/alerts/report/alertmanager", privateBody),
|
||||
post("/api/alerts/report/alertmanager", "{\"alerts\":[]}"),
|
||||
post("/api/alerts/report/zabbix", privateBody),
|
||||
post("/api/alerts/report/zabbix", "{}"));
|
||||
|
||||
for (RequestBuilder request : rejectedRequests) {
|
||||
realIngress.perform(request)
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("external_alert_rejected"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("Bearer-private-token"))))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("private-test-source"))))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("/secret/private-body"))));
|
||||
}
|
||||
verifyNoInteractions(alarmCommonReduce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedOrUnavailableSourceReturnsStableFailureEnvelope() throws Exception {
|
||||
String privateSource = "Bearer-private-source";
|
||||
@@ -170,4 +212,23 @@ class AlertReportControllerTest {
|
||||
mockMvc.perform(MockMvcRequestBuilders.delete("/api/alerts/report"))
|
||||
.andExpect(status().isMethodNotAllowed());
|
||||
}
|
||||
|
||||
private List<ExternAlertService> realIngressServices() {
|
||||
return List.of(
|
||||
withReducer(new DefaultExternAlertService()),
|
||||
withReducer(new PrometheusExternAlertService()),
|
||||
withReducer(new AlertManagerExternAlertService()),
|
||||
withReducer(new ZabbixExternAlertServiceImpl()));
|
||||
}
|
||||
|
||||
private <T extends ExternAlertService> T withReducer(T service) {
|
||||
ReflectionTestUtils.setField(service, "alarmCommonReduce", alarmCommonReduce);
|
||||
return service;
|
||||
}
|
||||
|
||||
private static RequestBuilder post(String path, String body) {
|
||||
return MockMvcRequestBuilders.post(path)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body);
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -63,4 +63,14 @@ class AlertSilencesControllerTest {
|
||||
mockMvc.perform(delete("/api/alert/silences?ids=7&ids=8"))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("partial"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidDeleteReturnsExplicitClientRejection() throws Exception {
|
||||
when(service.delete(null)).thenThrow(new IllegalArgumentException("ids-sentinel"));
|
||||
|
||||
mockMvc.perform(delete("/api/alert/silences")).andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.msg").value("Invalid alert silence request"))
|
||||
.andExpect(jsonPath("$").value(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("ids-sentinel"))));
|
||||
}
|
||||
}
|
||||
|
||||
+158
-1
@@ -17,14 +17,23 @@
|
||||
|
||||
package org.apache.hertzbeat.alert.controller;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.LongStream;
|
||||
import org.apache.hertzbeat.alert.dto.AlertGroupEvidence;
|
||||
import org.apache.hertzbeat.alert.dto.AlertGroupStatusEvidence;
|
||||
import org.apache.hertzbeat.alert.dto.AlertSummary;
|
||||
import org.apache.hertzbeat.alert.service.AlertGroupEvidenceRequestException;
|
||||
import org.apache.hertzbeat.alert.service.AlertGroupEvidenceService;
|
||||
import org.apache.hertzbeat.alert.service.AlertGroupNotFoundException;
|
||||
import org.apache.hertzbeat.alert.service.AlertGroupStatusNotSupportedException;
|
||||
import org.apache.hertzbeat.alert.service.AlertService;
|
||||
import org.apache.hertzbeat.common.constants.CommonConstants;
|
||||
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
|
||||
@@ -55,15 +64,21 @@ class AlertsControllerTest {
|
||||
@InjectMocks
|
||||
private AlertsController alertsController;
|
||||
|
||||
@InjectMocks
|
||||
private AlertSummaryController alertSummaryController;
|
||||
|
||||
@Mock
|
||||
private AlertService alertService;
|
||||
|
||||
@Mock
|
||||
private AlertGroupEvidenceService alertGroupEvidenceService;
|
||||
|
||||
private List<Long> ids;
|
||||
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
this.mockMvc = MockMvcBuilders.standaloneSetup(alertsController).build();
|
||||
this.mockMvc = MockMvcBuilders.standaloneSetup(alertsController, alertSummaryController).build();
|
||||
ids = LongStream.rangeClosed(1, 10).boxed().collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@@ -108,6 +123,60 @@ class AlertsControllerTest {
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGroupAlertEvidenceReturnsFrozenSchema() throws Exception {
|
||||
List<String> requestedIds = List.of("2", "1", "3", "1");
|
||||
AlertGroupEvidence evidence = new AlertGroupEvidence(
|
||||
List.of(
|
||||
new AlertGroupStatusEvidence(1L, CommonConstants.ALERT_STATUS_FIRING),
|
||||
new AlertGroupStatusEvidence(2L, CommonConstants.ALERT_STATUS_PENDING)),
|
||||
List.of(3L),
|
||||
123456789L);
|
||||
Mockito.when(alertGroupEvidenceService.getEvidence(requestedIds)).thenReturn(evidence);
|
||||
|
||||
mockMvc.perform(MockMvcRequestBuilders.get("/api/alerts/group/evidence")
|
||||
.param("ids", "2", "1", "3", "1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
|
||||
.andExpect(jsonPath("$.data.groups.length()").value(2))
|
||||
.andExpect(jsonPath("$.data.groups[0].id").value(1))
|
||||
.andExpect(jsonPath("$.data.groups[0].status").value("firing"))
|
||||
.andExpect(jsonPath("$.data.groups[1].id").value(2))
|
||||
.andExpect(jsonPath("$.data.groups[1].status").value("pending"))
|
||||
.andExpect(jsonPath("$.data.missingIds[0]").value(3))
|
||||
.andExpect(jsonPath("$.data.observedAt").value(123456789L));
|
||||
|
||||
Mockito.verify(alertGroupEvidenceService).getEvidence(requestedIds);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGroupAlertEvidenceInvalidRequestReturnsStableSafeFailure() throws Exception {
|
||||
Mockito.when(alertGroupEvidenceService.getEvidence(List.of("-6565463543")))
|
||||
.thenThrow(new AlertGroupEvidenceRequestException());
|
||||
|
||||
mockMvc.perform(MockMvcRequestBuilders.get("/api/alerts/group/evidence")
|
||||
.param("ids", "-6565463543"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Invalid alert group evidence request."))
|
||||
.andExpect(content().string(not(containsString("6565463543"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGroupAlertEvidenceFailureDoesNotExposeExceptionDetails() throws Exception {
|
||||
Mockito.when(alertGroupEvidenceService.getEvidence(List.of("7")))
|
||||
.thenThrow(new IllegalStateException(
|
||||
"token=private-evidence-token payload=private-alert-payload"));
|
||||
|
||||
mockMvc.perform(MockMvcRequestBuilders.get("/api/alerts/group/evidence")
|
||||
.param("ids", "7"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Alert group evidence query failed."))
|
||||
.andExpect(content().string(not(containsString("private-evidence-token"))))
|
||||
.andExpect(content().string(not(containsString("private-alert-payload"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteGroupAlerts() throws Exception {
|
||||
mockMvc.perform(
|
||||
@@ -121,6 +190,38 @@ class AlertsControllerTest {
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteGroupAlertsMissingTargetReturnsStableSafeFailure() throws Exception {
|
||||
HashSet<Long> missingIds = new HashSet<>(List.of(6565463543L));
|
||||
Mockito.doThrow(new AlertGroupNotFoundException())
|
||||
.when(alertService).deleteGroupAlerts(missingIds);
|
||||
|
||||
mockMvc.perform(MockMvcRequestBuilders
|
||||
.delete("/api/alerts/group")
|
||||
.param("ids", "6565463543"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Alert group was not found."))
|
||||
.andExpect(content().string(not(containsString("6565463543"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteGroupAlertsGenericFailureDoesNotExposeExceptionDetails() throws Exception {
|
||||
HashSet<Long> ids = new HashSet<>(List.of(7L));
|
||||
Mockito.doThrow(new IllegalStateException(
|
||||
"token=private-delete-token payload=private-alert-payload"))
|
||||
.when(alertService).deleteGroupAlerts(ids);
|
||||
|
||||
mockMvc.perform(MockMvcRequestBuilders
|
||||
.delete("/api/alerts/group")
|
||||
.param("ids", "7"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Alert group delete failed."))
|
||||
.andExpect(content().string(not(containsString("private-delete-token"))))
|
||||
.andExpect(content().string(not(containsString("private-alert-payload"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyGroupAlertStatus() throws Exception {
|
||||
mockMvc.perform(
|
||||
@@ -147,6 +248,62 @@ class AlertsControllerTest {
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyGroupAlertStatusMissingTargetReturnsStableSafeFailure() throws Exception {
|
||||
Mockito.doThrow(new AlertGroupNotFoundException())
|
||||
.when(alertService).editGroupAlertStatus("acknowledged", List.of(6565463543L));
|
||||
|
||||
mockMvc.perform(MockMvcRequestBuilders
|
||||
.put("/api/alerts/group/status/acknowledged")
|
||||
.param("ids", "6565463543"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Alert group was not found."))
|
||||
.andExpect(content().string(not(containsString("6565463543"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyGroupAlertStatusGenericFailureDoesNotExposeExceptionDetails() throws Exception {
|
||||
Mockito.doThrow(new IllegalStateException(
|
||||
"token=private-alert-token payload=private-alert-payload"))
|
||||
.when(alertService).editGroupAlertStatus("resolved", List.of(7L));
|
||||
|
||||
mockMvc.perform(MockMvcRequestBuilders
|
||||
.put("/api/alerts/group/status/resolved")
|
||||
.param("ids", "7"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Alert group status update failed."))
|
||||
.andExpect(content().string(not(containsString("private-alert-token"))))
|
||||
.andExpect(content().string(not(containsString("private-alert-payload"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyGroupAlertStatusRejectsUnsupportedPathValueWithStableSafeFailure() throws Exception {
|
||||
Mockito.doThrow(new AlertGroupStatusNotSupportedException())
|
||||
.when(alertService).editGroupAlertStatus("private-arbitrary-status", List.of(6565463543L));
|
||||
|
||||
mockMvc.perform(MockMvcRequestBuilders
|
||||
.put("/api/alerts/group/status/private-arbitrary-status")
|
||||
.param("ids", "6565463543"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Alert group status is not supported."))
|
||||
.andExpect(content().string(not(containsString("private-arbitrary-status"))))
|
||||
.andExpect(content().string(not(containsString("6565463543"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyGroupAlertStatusKeepsEmptyIdsAsLegacySuccessNoOp() throws Exception {
|
||||
mockMvc.perform(MockMvcRequestBuilders
|
||||
.put("/api/alerts/group/status/private-arbitrary-status"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
|
||||
.andExpect(content().json("{\"data\":null,\"msg\":null,\"code\":0}"));
|
||||
|
||||
Mockito.verifyNoInteractions(alertService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applySingleAlertAcknowledgedStatus() throws Exception {
|
||||
mockMvc.perform(
|
||||
|
||||
+69
-6
@@ -42,6 +42,7 @@ import org.apache.hertzbeat.alert.dto.NoticeReceiverRequest;
|
||||
import org.apache.hertzbeat.alert.dto.NoticeReceiverResponse;
|
||||
import org.apache.hertzbeat.alert.service.NoticeReceiverContractMapper;
|
||||
import org.apache.hertzbeat.alert.service.NoticeReceiverContractService;
|
||||
import org.apache.hertzbeat.alert.service.NoticeTemplateMutationException;
|
||||
import org.apache.hertzbeat.alert.service.impl.NoticeConfigServiceImpl;
|
||||
import org.apache.hertzbeat.common.constants.CommonConstants;
|
||||
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
|
||||
@@ -431,6 +432,24 @@ class NoticeConfigControllerTest {
|
||||
verify(noticeConfigService).addNoticeTemplate(noticeTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addNewNoticeTemplateReturnsStableSafeInvalidRequestFailure() throws Exception {
|
||||
NoticeTemplate noticeTemplate = getNoticeTemplate();
|
||||
noticeTemplate.setName("private-template-payload");
|
||||
Mockito.doThrow(new NoticeTemplateMutationException(
|
||||
NoticeTemplateMutationException.Reason.INVALID_REQUEST))
|
||||
.when(noticeConfigService).addNoticeTemplate(noticeTemplate);
|
||||
|
||||
this.mockMvc.perform(post("/api/notice/template")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(JsonUtil.toJson(noticeTemplate)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Notice template request is invalid."))
|
||||
.andExpect(content().string(not(containsString("private-template-payload"))))
|
||||
.andExpect(content().string(not(containsString("87584674384"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void editNoticeTemplate() throws Exception {
|
||||
NoticeTemplate noticeTemplate = getNoticeTemplate();
|
||||
@@ -447,10 +466,26 @@ class NoticeConfigControllerTest {
|
||||
verify(noticeConfigService).editNoticeTemplate(noticeTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editNoticeTemplateReturnsStableSafeNotFoundFailure() throws Exception {
|
||||
NoticeTemplate noticeTemplate = getNoticeTemplate();
|
||||
noticeTemplate.setId(87584674384L);
|
||||
Mockito.doThrow(new NoticeTemplateMutationException(
|
||||
NoticeTemplateMutationException.Reason.NOT_FOUND))
|
||||
.when(noticeConfigService).editNoticeTemplate(noticeTemplate);
|
||||
|
||||
this.mockMvc.perform(put("/api/notice/template")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(JsonUtil.toJson(noticeTemplate)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Notice template was not found."))
|
||||
.andExpect(content().string(not(containsString("87584674384"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteNoticeTemplate_Success() throws Exception {
|
||||
Long templateId = 1L;
|
||||
when(noticeConfigService.getNoticeTemplatesById(templateId)).thenReturn(Optional.of(new NoticeTemplate()));
|
||||
|
||||
mockMvc.perform(delete("/api/notice/template/{id}", templateId))
|
||||
.andExpect(status().isOk())
|
||||
@@ -463,14 +498,30 @@ class NoticeConfigControllerTest {
|
||||
@Test
|
||||
void deleteNoticeTemplate_NotFound() throws Exception {
|
||||
Long templateId = 1L;
|
||||
when(noticeConfigService.getNoticeTemplatesById(templateId)).thenReturn(Optional.empty());
|
||||
Mockito.doThrow(new NoticeTemplateMutationException(
|
||||
NoticeTemplateMutationException.Reason.NOT_FOUND))
|
||||
.when(noticeConfigService).deleteNoticeTemplate(templateId);
|
||||
|
||||
mockMvc.perform(delete("/api/notice/template/{id}", templateId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("The specified notification template could not be queried, please check whether the parameters are correct"));
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Notice template was not found."));
|
||||
|
||||
Mockito.verify(noticeConfigService, Mockito.never()).deleteNoticeTemplate(templateId);
|
||||
Mockito.verify(noticeConfigService).deleteNoticeTemplate(templateId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteNoticeTemplateReturnsStableSafeReadOnlyFailure() throws Exception {
|
||||
long templateId = 87584674384L;
|
||||
Mockito.doThrow(new NoticeTemplateMutationException(
|
||||
NoticeTemplateMutationException.Reason.READ_ONLY))
|
||||
.when(noticeConfigService).deleteNoticeTemplate(templateId);
|
||||
|
||||
mockMvc.perform(delete("/api/notice/template/{id}", templateId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Preset notice templates are read-only."))
|
||||
.andExpect(content().string(not(containsString("87584674384"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -510,6 +561,18 @@ class NoticeConfigControllerTest {
|
||||
.andExpect(jsonPath("$.data.number").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTemplatesReturnsStableSafeStorageFailure() throws Exception {
|
||||
when(noticeConfigService.getNoticeTemplates(null, true, 0, 8))
|
||||
.thenThrow(new DataAccessResourceFailureException("private-storage-token"));
|
||||
|
||||
this.mockMvc.perform(get("/api/notice/templates"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("Notice template storage is unavailable."))
|
||||
.andExpect(content().string(not(containsString("private-storage-token"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetTemplatesById() throws Exception {
|
||||
// Mock the service response
|
||||
@@ -525,7 +588,7 @@ class NoticeConfigControllerTest {
|
||||
this.mockMvc.perform(get("/api/notice/template/{id}", 25857585858L))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
|
||||
.andExpect(jsonPath("$.msg").value("The specified notification template could not be queried, please check whether the parameters are correct or refresh the page"));
|
||||
.andExpect(jsonPath("$.msg").value("Notice template was not found."));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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.integration.service;
|
||||
|
||||
import static org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.CONFIGURATION_REQUIRED;
|
||||
import static org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.GUIDE_BLOCKED;
|
||||
import static org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.READY;
|
||||
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 static org.mockito.Mockito.mock;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.IntegrationGuide;
|
||||
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationRequestException;
|
||||
import org.apache.hertzbeat.alert.integration.guide.AlertIntegrationDescriptorRegistry;
|
||||
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
|
||||
import org.apache.hertzbeat.alert.service.ExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.AlertManagerExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.AlibabaCloudSlsExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.DefaultExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.HuaweiCloudExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.PrometheusExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.SkyWalkingExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.TencentExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.UptimeKumaExternAlertServiceImpl;
|
||||
import org.apache.hertzbeat.alert.service.impl.VolcEngineExternAlertService;
|
||||
import org.apache.hertzbeat.alert.service.impl.ZabbixExternAlertServiceImpl;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AlertIntegrationCatalogServiceTest {
|
||||
|
||||
private static final List<String> PUBLIC_SOURCE_ORDER = List.of(
|
||||
"webhook", "prometheus", "alertmanager", "skywalking", "uptime-kuma", "zabbix", "tencent",
|
||||
"alibabacloud-sls", "huaweicloud-ces", "volcengine");
|
||||
private static final String PRIVATE_SOURCE = "private-test-source";
|
||||
private static final String PRIVATE_TOKEN = "private-test-token";
|
||||
|
||||
@Test
|
||||
void derivesStablePublicCatalogFromRegisteredIngressBeans() {
|
||||
AlertIntegrationCatalogService service = service(services());
|
||||
|
||||
assertEquals(PUBLIC_SOURCE_ORDER, service.catalog().items().stream()
|
||||
.map(item -> item.source())
|
||||
.toList());
|
||||
assertFalse(service.catalog().items().stream().anyMatch(item -> "default".equals(item.source())));
|
||||
assertFalse(service.catalog().toString().contains(PRIVATE_SOURCE));
|
||||
assertFalse(service.catalog().toString().contains(PRIVATE_TOKEN));
|
||||
for (String source : PUBLIC_SOURCE_ORDER) {
|
||||
IntegrationGuide guide = service.render(source);
|
||||
assertEquals(source, guide.source());
|
||||
assertEquals(Map.of("Authorization", "Bearer {token}"), guide.requiredHeaders());
|
||||
assertFalse(guide.toString().contains(PRIVATE_SOURCE));
|
||||
assertFalse(guide.toString().contains(PRIVATE_TOKEN));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersHonestReadyAndBlockedSourceContracts() {
|
||||
AlertIntegrationCatalogService service = service(services());
|
||||
|
||||
IntegrationGuide webhook = service.render("webhook");
|
||||
assertEquals(READY, webhook.readiness());
|
||||
assertEquals("POST", webhook.method());
|
||||
assertEquals("/api/alerts/report", webhook.ingressPath());
|
||||
assertEquals("single_alert", webhook.payloadShape());
|
||||
assertEquals("Bearer {token}", webhook.requiredHeaders().get("Authorization"));
|
||||
assertTrue(webhook.requiredFields().contains("labels"));
|
||||
assertTrue(webhook.snippets().stream().anyMatch(snippet -> snippet.contains("\"labels\"")));
|
||||
|
||||
IntegrationGuide prometheus = service.render("prometheus");
|
||||
assertEquals(READY, prometheus.readiness());
|
||||
assertEquals("/api/v2/alerts", prometheus.ingressPath());
|
||||
assertEquals("Bearer {token}", prometheus.requiredHeaders().get("Authorization"));
|
||||
assertTrue(prometheus.snippets().stream().anyMatch(snippet -> snippet.trim().startsWith("[")));
|
||||
|
||||
IntegrationGuide alertmanager = service.render("alertmanager");
|
||||
assertEquals(READY, alertmanager.readiness());
|
||||
assertEquals("/api/alerts/report/alertmanager", alertmanager.ingressPath());
|
||||
assertEquals("Bearer {token}", alertmanager.requiredHeaders().get("Authorization"));
|
||||
assertTrue(alertmanager.snippets().stream().anyMatch(snippet -> snippet.contains("\"alerts\"")));
|
||||
|
||||
assertEquals(GUIDE_BLOCKED, service.render("zabbix").readiness());
|
||||
assertTrue(service.render("zabbix").limitations().contains(
|
||||
"alert.integration.limit.zabbix.response_contract_mismatch"));
|
||||
assertEquals(CONFIGURATION_REQUIRED, service.render("skywalking").readiness());
|
||||
assertEquals(CONFIGURATION_REQUIRED, service.render("huaweicloud-ces").readiness());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRegistryAndBeanDriftWithSafeStableErrors() {
|
||||
List<ExternAlertService> missingBean = new ArrayList<>(services());
|
||||
missingBean.removeLast();
|
||||
AlertIntegrationRequestException missingFailure = assertThrows(
|
||||
AlertIntegrationRequestException.class, () -> service(missingBean).catalog());
|
||||
assertEquals("external_alert_guide_unavailable", missingFailure.getMessage());
|
||||
|
||||
List<ExternAlertService> extraBean = new ArrayList<>(services());
|
||||
ExternAlertService unsupported = mock(ExternAlertService.class);
|
||||
org.mockito.Mockito.when(unsupported.supportSource()).thenReturn("private-source-name");
|
||||
extraBean.add(unsupported);
|
||||
AlertIntegrationRequestException extraFailure = assertThrows(
|
||||
AlertIntegrationRequestException.class, () -> service(extraBean).catalog());
|
||||
assertEquals("external_alert_guide_unavailable", extraFailure.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSourcesUseSafeStableErrors() {
|
||||
AlertIntegrationRequestException failure = assertThrows(
|
||||
AlertIntegrationRequestException.class,
|
||||
() -> service(services()).render(PRIVATE_SOURCE));
|
||||
|
||||
assertEquals("external_alert_source_unsupported", failure.getMessage());
|
||||
assertFalse(failure.getMessage().contains(PRIVATE_SOURCE));
|
||||
assertFalse(failure.getMessage().contains(PRIVATE_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankSourcesUseSafeStableErrors() {
|
||||
AlertIntegrationCatalogService service = service(services());
|
||||
|
||||
for (String source : List.of("", " ", "\t")) {
|
||||
AlertIntegrationRequestException failure = assertThrows(
|
||||
AlertIntegrationRequestException.class, () -> service.render(source));
|
||||
assertEquals("external_alert_source_unsupported", failure.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static AlertIntegrationCatalogService service(List<ExternAlertService> services) {
|
||||
return new AlertIntegrationCatalogService(services, AlertIntegrationDescriptorRegistry.official());
|
||||
}
|
||||
|
||||
private static List<ExternAlertService> services() {
|
||||
AlarmCommonReduce reducer = mock(AlarmCommonReduce.class);
|
||||
return List.of(
|
||||
new DefaultExternAlertService(),
|
||||
new AlertManagerExternAlertService(),
|
||||
new PrometheusExternAlertService(),
|
||||
new SkyWalkingExternAlertService(),
|
||||
new UptimeKumaExternAlertServiceImpl(),
|
||||
new ZabbixExternAlertServiceImpl(),
|
||||
new TencentExternAlertService(),
|
||||
new AlibabaCloudSlsExternAlertService(reducer),
|
||||
new HuaweiCloudExternAlertService(reducer),
|
||||
new VolcEngineExternAlertService(reducer));
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -18,6 +18,7 @@
|
||||
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.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
@@ -93,6 +94,7 @@ public class AlertDefineExcelImExportServiceTest {
|
||||
assertEquals(Map.of("key", "value"), alertDefineDTO.getAnnotations());
|
||||
assertEquals("template1", alertDefineDTO.getTemplate());
|
||||
assertTrue(alertDefineDTO.getEnable());
|
||||
assertNull(alertDefineDTO.getDatasource());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +106,7 @@ public class AlertDefineExcelImExportServiceTest {
|
||||
AlertDefineDTO alertDefineDTO = new AlertDefineDTO();
|
||||
alertDefineDTO.setName("app1");
|
||||
alertDefineDTO.setType("metric1");
|
||||
alertDefineDTO.setDatasource("lifecycle-promql");
|
||||
alertDefineDTO.setExpr("expr1");
|
||||
alertDefineDTO.setPeriod(10);
|
||||
alertDefineDTO.setTimes(1);
|
||||
@@ -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,11 @@ 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("lifecycle-promql", dataRow.getCell(9).getStringCellValue());
|
||||
|
||||
List<ExportAlertDefineDTO> parsed = alertDefineExcelImExportService.parseImport(
|
||||
new ByteArrayInputStream(outputStream.toByteArray()));
|
||||
assertEquals("lifecycle-promql", parsed.getFirst().getAlertDefine().getDatasource());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,4 +161,4 @@ public class AlertDefineExcelImExportServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -57,6 +57,7 @@ class AlertDefineJsonImExportServiceTest {
|
||||
AlertDefineDTO alertDefine = new AlertDefineDTO();
|
||||
alertDefine.setName("App1");
|
||||
alertDefine.setType("realtime");
|
||||
alertDefine.setDatasource("lifecycle-promql");
|
||||
alertDefine.setExpr("Expr1");
|
||||
alertDefine.setPeriod(3000);
|
||||
alertDefine.setTimes(3);
|
||||
@@ -77,6 +78,7 @@ class AlertDefineJsonImExportServiceTest {
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("App1", result.get(0).getAlertDefine().getName());
|
||||
assertEquals("realtime", result.get(0).getAlertDefine().getType());
|
||||
assertNull(result.get(0).getAlertDefine().getDatasource());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -98,6 +100,10 @@ class AlertDefineJsonImExportServiceTest {
|
||||
assertNotNull(result);
|
||||
assertTrue(result.contains("App1"));
|
||||
assertTrue(result.contains("realtime"));
|
||||
assertTrue(result.contains("\"datasource\":\"lifecycle-promql\""));
|
||||
List<ExportAlertDefineDTO> parsed = service.parseImport(
|
||||
new ByteArrayInputStream(outputStream.toByteArray()));
|
||||
assertEquals("lifecycle-promql", parsed.getFirst().getAlertDefine().getDatasource());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+43
-3
@@ -183,7 +183,7 @@ class AlertDefineServiceTest {
|
||||
put("status", "200");
|
||||
}
|
||||
};
|
||||
when(dataSourceService.calculate(eq("promql"), eq(expr))).thenReturn(Lists.newArrayList(countValue1));
|
||||
when(dataSourceService.calculatePreview(eq("promql"), eq(expr))).thenReturn(Lists.newArrayList(countValue1));
|
||||
List<Map<String, Object>> result = alertDefineService.getDefinePreview("promql", METRIC_ALERT_THRESHOLD_TYPE_PERIODIC, expr);
|
||||
assertNotNull(result);
|
||||
assertEquals(1307, result.get(0).get("__value__"));
|
||||
@@ -195,11 +195,35 @@ class AlertDefineServiceTest {
|
||||
assertEquals(0, result.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDefinePreviewPreservesDatasourceExecutionFailure() {
|
||||
String expr = "up > 0";
|
||||
when(dataSourceService.calculatePreview("promql", expr))
|
||||
.thenThrow(new AlertExpressionException("Preview query execution failed"));
|
||||
|
||||
assertThrows(AlertExpressionException.class,
|
||||
() -> alertDefineService.getDefinePreview("promql", METRIC_ALERT_THRESHOLD_TYPE_PERIODIC, expr));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDefinePreviewCapsReturnedTelemetryRows() {
|
||||
String expr = "up > 0";
|
||||
List<Map<String, Object>> rows = java.util.stream.IntStream.range(0, 101)
|
||||
.mapToObj(index -> Map.<String, Object>of("__value__", index))
|
||||
.toList();
|
||||
when(dataSourceService.calculatePreview("promql", expr)).thenReturn(rows);
|
||||
|
||||
List<Map<String, Object>> result =
|
||||
alertDefineService.getDefinePreview("promql", METRIC_ALERT_THRESHOLD_TYPE_PERIODIC, expr);
|
||||
|
||||
assertEquals(100, result.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDefinePreviewSupportsPeriodicTraceSql() {
|
||||
String expr = "SELECT service_name, 0.2 AS __value__ FROM hertzbeat_apm_red_1m";
|
||||
Map<String, Object> row = Map.of("service_name", "checkout", "__value__", 0.2D);
|
||||
when(dataSourceService.query(eq("sql"), eq(expr), eq(TRACE_ALERT_THRESHOLD_TYPE_PERIODIC)))
|
||||
when(dataSourceService.queryPreview(eq("sql"), eq(expr), eq(TRACE_ALERT_THRESHOLD_TYPE_PERIODIC)))
|
||||
.thenReturn(List.of(row));
|
||||
|
||||
List<Map<String, Object>> result =
|
||||
@@ -209,6 +233,22 @@ class AlertDefineServiceTest {
|
||||
assertEquals(0.2D, result.get(0).get("__value__"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDefinePreviewPreservesPeriodicLogAndTraceExecutionFailures() {
|
||||
String logExpr = "SELECT * FROM hertzbeat_logs";
|
||||
String traceExpr = "SELECT __value__ FROM hertzbeat_apm_red_1m";
|
||||
AlertExpressionException safeFailure = new AlertExpressionException("Preview query execution failed");
|
||||
when(dataSourceService.queryPreview("sql", logExpr, LOG_ALERT_THRESHOLD_TYPE_PERIODIC))
|
||||
.thenThrow(safeFailure);
|
||||
when(dataSourceService.queryPreview("sql", traceExpr, TRACE_ALERT_THRESHOLD_TYPE_PERIODIC))
|
||||
.thenThrow(safeFailure);
|
||||
|
||||
assertThrows(AlertExpressionException.class,
|
||||
() -> alertDefineService.getDefinePreview("sql", LOG_ALERT_THRESHOLD_TYPE_PERIODIC, logExpr));
|
||||
assertThrows(AlertExpressionException.class,
|
||||
() -> alertDefineService.getDefinePreview("sql", TRACE_ALERT_THRESHOLD_TYPE_PERIODIC, traceExpr));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDefinePreviewValidatesRealtimeLogExpression() {
|
||||
HistoryDataReader historyDataReader = Mockito.mock(HistoryDataReader.class);
|
||||
@@ -257,7 +297,7 @@ class AlertDefineServiceTest {
|
||||
@Test
|
||||
void getDefinePreviewRejectsPeriodicTraceSqlWithoutValueColumn() {
|
||||
String expr = "SELECT service_name FROM hertzbeat_apm_red_1m";
|
||||
when(dataSourceService.query(eq("sql"), eq(expr), eq(TRACE_ALERT_THRESHOLD_TYPE_PERIODIC)))
|
||||
when(dataSourceService.queryPreview(eq("sql"), eq(expr), eq(TRACE_ALERT_THRESHOLD_TYPE_PERIODIC)))
|
||||
.thenReturn(List.of(Map.of("service_name", "checkout")));
|
||||
|
||||
AlertExpressionException exception = assertThrows(AlertExpressionException.class,
|
||||
|
||||
+42
-8
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.alert.service;
|
||||
|
||||
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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
@@ -37,13 +38,11 @@ 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.AlertDefineYamlImExportServiceImpl;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/**
|
||||
* test case for {@link AlertDefineYamlImExportServiceImpl}
|
||||
@@ -77,6 +76,7 @@ class AlertDefineYamlImExportServiceTest {
|
||||
AlertDefineDTO alertDefine = new AlertDefineDTO();
|
||||
alertDefine.setName("App1");
|
||||
alertDefine.setType("realtime");
|
||||
alertDefine.setDatasource("lifecycle-promql");
|
||||
alertDefine.setPeriod(3000);
|
||||
alertDefine.setTimes(3);
|
||||
alertDefine.setExpr("Expr1");
|
||||
@@ -96,12 +96,9 @@ class AlertDefineYamlImExportServiceTest {
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.size());
|
||||
|
||||
InputStream inputStream = new ByteArrayInputStream(JsonUtil.toJson(alertDefineList)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
Yaml yaml = new Yaml();
|
||||
|
||||
assertEquals(yaml.load(inputStream), result);
|
||||
assertEquals("App1", result.getFirst().getAlertDefine().getName());
|
||||
assertEquals("realtime", result.getFirst().getAlertDefine().getType());
|
||||
assertNull(result.getFirst().getAlertDefine().getDatasource());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -125,6 +122,39 @@ class AlertDefineYamlImExportServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParseImportRejectsUnknownJavaGlobalTags() {
|
||||
InputStream taggedInput = new ByteArrayInputStream(
|
||||
"- !!java.net.URL {}\n"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThrows(RuntimeException.class, () -> service.parseImport(taggedInput));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParseImportSupportsLegacyHertzBeatDtoTags() {
|
||||
String legacyYaml =
|
||||
"""
|
||||
- !!org.apache.hertzbeat.alert.dto.ExportAlertDefineDTO
|
||||
alertDefine: !!org.apache.hertzbeat.alert.dto.AlertDefineDTO
|
||||
name: Legacy
|
||||
type: periodic
|
||||
datasource: legacy-promql
|
||||
expr: up == 0
|
||||
period: 60
|
||||
times: 2
|
||||
enable: false
|
||||
template: Legacy template
|
||||
""";
|
||||
|
||||
List<ExportAlertDefineDTO> result = service.parseImport(
|
||||
new ByteArrayInputStream(legacyYaml.getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("Legacy", result.getFirst().getAlertDefine().getName());
|
||||
assertEquals("legacy-promql", result.getFirst().getAlertDefine().getDatasource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testWriteOs() {
|
||||
|
||||
@@ -135,6 +165,10 @@ class AlertDefineYamlImExportServiceTest {
|
||||
assertTrue(yamlOutput.contains("name: App1"));
|
||||
assertTrue(yamlOutput.contains("type: realtime"));
|
||||
assertTrue(yamlOutput.contains("expr: Expr1"));
|
||||
assertTrue(yamlOutput.contains("datasource: lifecycle-promql"));
|
||||
List<ExportAlertDefineDTO> parsed = service.parseImport(
|
||||
new ByteArrayInputStream(outputStream.toByteArray()));
|
||||
assertEquals("lifecycle-promql", parsed.getFirst().getAlertDefine().getDatasource());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+125
@@ -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.alert.service;
|
||||
|
||||
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.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import org.apache.hertzbeat.alert.dao.GroupAlertDao;
|
||||
import org.apache.hertzbeat.alert.dto.AlertGroupEvidence;
|
||||
import org.apache.hertzbeat.alert.dto.AlertGroupStatusEvidence;
|
||||
import org.apache.hertzbeat.common.constants.CommonConstants;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Bounded canonical alert-group evidence query contracts.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AlertGroupEvidenceServiceTest {
|
||||
|
||||
@Mock
|
||||
private GroupAlertDao groupAlertDao;
|
||||
|
||||
@InjectMocks
|
||||
private AlertGroupEvidenceService evidenceService;
|
||||
|
||||
@Test
|
||||
void sortsGroupsAndMissingIdsWithoutChildHydration() {
|
||||
List<String> rawIds = List.of("4", "1", "5", "2", "3", "1");
|
||||
List<Long> normalizedIds = List.of(1L, 2L, 3L, 4L, 5L);
|
||||
when(groupAlertDao.findStatusEvidenceByIdIn(normalizedIds)).thenReturn(List.of(
|
||||
new AlertGroupStatusEvidence(4L, CommonConstants.ALERT_STATUS_RESOLVED),
|
||||
new AlertGroupStatusEvidence(2L, CommonConstants.ALERT_STATUS_PENDING),
|
||||
new AlertGroupStatusEvidence(1L, CommonConstants.ALERT_STATUS_FIRING),
|
||||
new AlertGroupStatusEvidence(3L, CommonConstants.ALERT_STATUS_ACKNOWLEDGED)));
|
||||
long before = System.currentTimeMillis();
|
||||
|
||||
AlertGroupEvidence result = evidenceService.getEvidence(rawIds);
|
||||
|
||||
long after = System.currentTimeMillis();
|
||||
assertEquals(List.of(
|
||||
new AlertGroupStatusEvidence(1L, CommonConstants.ALERT_STATUS_FIRING),
|
||||
new AlertGroupStatusEvidence(2L, CommonConstants.ALERT_STATUS_PENDING),
|
||||
new AlertGroupStatusEvidence(3L, CommonConstants.ALERT_STATUS_ACKNOWLEDGED),
|
||||
new AlertGroupStatusEvidence(4L, CommonConstants.ALERT_STATUS_RESOLVED)), result.groups());
|
||||
assertEquals(List.of(5L), result.missingIds());
|
||||
assertTrue(result.observedAt() >= before);
|
||||
assertTrue(result.observedAt() <= after);
|
||||
verify(groupAlertDao).findStatusEvidenceByIdIn(normalizedIds);
|
||||
verify(groupAlertDao, never()).findAllById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidIdsBeforeQuery() {
|
||||
List<List<String>> invalidRequests = new ArrayList<>();
|
||||
invalidRequests.add(null);
|
||||
invalidRequests.add(List.of());
|
||||
invalidRequests.add(List.of(""));
|
||||
invalidRequests.add(List.of("0"));
|
||||
invalidRequests.add(List.of("-1"));
|
||||
invalidRequests.add(List.of("not-a-number"));
|
||||
|
||||
for (List<String> invalidRequest : invalidRequests) {
|
||||
assertThrows(AlertGroupEvidenceRequestException.class,
|
||||
() -> evidenceService.getEvidence(invalidRequest));
|
||||
}
|
||||
verifyNoInteractions(groupAlertDao);
|
||||
}
|
||||
|
||||
@Test
|
||||
void limitsRawEntriesBeforeDeduplication() {
|
||||
List<String> repeatedIds = Collections.nCopies(101, "1");
|
||||
|
||||
assertThrows(AlertGroupEvidenceRequestException.class,
|
||||
() -> evidenceService.getEvidence(repeatedIds));
|
||||
|
||||
verifyNoInteractions(groupAlertDao);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownPersistedStatus() {
|
||||
when(groupAlertDao.findStatusEvidenceByIdIn(List.of(1L))).thenReturn(List.of(
|
||||
new AlertGroupStatusEvidence(1L, "private-unknown-status")));
|
||||
|
||||
assertThrows(AlertGroupStatusNotSupportedException.class,
|
||||
() -> evidenceService.getEvidence(List.of("1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotHideDuplicateDaoRows() {
|
||||
when(groupAlertDao.findStatusEvidenceByIdIn(List.of(1L))).thenReturn(List.of(
|
||||
new AlertGroupStatusEvidence(1L, CommonConstants.ALERT_STATUS_FIRING),
|
||||
new AlertGroupStatusEvidence(1L, CommonConstants.ALERT_STATUS_RESOLVED)));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> evidenceService.getEvidence(List.of("1")));
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.alert.config.AlertSseManager;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mockito;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
|
||||
/**
|
||||
* Transaction and safe-payload contracts for alert group mutation publication.
|
||||
*/
|
||||
class AlertGroupMutationPublisherTest {
|
||||
|
||||
@Test
|
||||
void statusRefreshPublishesSafeSortedPayloadOnlyAfterCommit() {
|
||||
AlertSseManager manager = Mockito.mock(AlertSseManager.class);
|
||||
AlertGroupMutationPublisher publisher = new AlertGroupMutationPublisher(manager);
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
publisher.publishStatusChanged(List.of(2L, 1L, 2L), "acknowledged");
|
||||
|
||||
verify(manager, never()).broadcastGroupMutation(anyString());
|
||||
TransactionSynchronizationManager.getSynchronizations()
|
||||
.forEach(TransactionSynchronization::afterCommit);
|
||||
|
||||
assertMutationEvent(manager, List.of(1L, 2L), "acknowledged", "GROUP_STATUS_CHANGED");
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteTombstonePublishesOnlyAfterCommit() {
|
||||
AlertSseManager manager = Mockito.mock(AlertSseManager.class);
|
||||
AlertGroupMutationPublisher publisher = new AlertGroupMutationPublisher(manager);
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
publisher.publishDeleted(List.of(2L, 1L));
|
||||
|
||||
verify(manager, never()).broadcastGroupMutation(anyString());
|
||||
TransactionSynchronizationManager.getSynchronizations()
|
||||
.forEach(TransactionSynchronization::afterCommit);
|
||||
|
||||
assertMutationEvent(manager, List.of(1L, 2L), null, "GROUP_DELETED");
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledBackTransactionDoesNotBroadcast() {
|
||||
AlertSseManager manager = Mockito.mock(AlertSseManager.class);
|
||||
AlertGroupMutationPublisher publisher = new AlertGroupMutationPublisher(manager);
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
publisher.publishDeleted(List.of(1L));
|
||||
|
||||
TransactionSynchronizationManager.getSynchronizations()
|
||||
.forEach(synchronization ->
|
||||
synchronization.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
|
||||
|
||||
verify(manager, never()).broadcastGroupMutation(anyString());
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyOrNullTargetsDoNotRegisterOrBroadcast() {
|
||||
AlertSseManager manager = Mockito.mock(AlertSseManager.class);
|
||||
AlertGroupMutationPublisher publisher = new AlertGroupMutationPublisher(manager);
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
publisher.publishDeleted(List.of());
|
||||
publisher.publishStatusChanged(null, "acknowledged");
|
||||
|
||||
assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty());
|
||||
verify(manager, never()).broadcastGroupMutation(anyString());
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonTransactionalBroadcastFailureDoesNotLeakPayloadOrExceptionMessage() {
|
||||
String privateDetail = "private-mutation-body-and-exception";
|
||||
AlertSseManager manager = Mockito.mock(AlertSseManager.class);
|
||||
doThrow(new IllegalStateException(privateDetail))
|
||||
.when(manager).broadcastGroupMutation(anyString());
|
||||
AlertGroupMutationPublisher publisher = new AlertGroupMutationPublisher(manager);
|
||||
Logger logger = (Logger) LoggerFactory.getLogger(AlertGroupMutationPublisher.class);
|
||||
Level originalLevel = logger.getLevel();
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
logger.setLevel(Level.DEBUG);
|
||||
try {
|
||||
publisher.publishDeleted(List.of(1L));
|
||||
|
||||
String logs = appender.list.stream()
|
||||
.map(ILoggingEvent::getFormattedMessage)
|
||||
.reduce("", String::concat);
|
||||
assertFalse(logs.contains(privateDetail));
|
||||
assertTrue(logs.contains(IllegalStateException.class.getSimpleName()));
|
||||
} finally {
|
||||
logger.setLevel(originalLevel);
|
||||
logger.detachAppender(appender);
|
||||
appender.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertMutationEvent(
|
||||
AlertSseManager manager, List<Long> ids, String status, String mutation) {
|
||||
ArgumentCaptor<String> payload = ArgumentCaptor.forClass(String.class);
|
||||
verify(manager).broadcastGroupMutation(payload.capture());
|
||||
Map<String, Object> event = JsonUtil.fromJson(payload.getValue(), new TypeReference<>() {
|
||||
});
|
||||
assertEquals(ids.get(0).longValue(), ((Number) event.get("id")).longValue());
|
||||
List<Long> eventIds = ((List<?>) event.get("ids")).stream()
|
||||
.map(Number.class::cast)
|
||||
.map(Number::longValue)
|
||||
.toList();
|
||||
assertEquals(ids, eventIds);
|
||||
assertEquals(status, event.get("status"));
|
||||
assertEquals(mutation, event.get("mutation"));
|
||||
assertNull(event.get("content"));
|
||||
assertNull(event.get("annotations"));
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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.assertThrows;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AlertInhibitContractMapperTest {
|
||||
|
||||
private final AlertInhibitContractMapper mapper = new AlertInhibitContractMapper();
|
||||
|
||||
@Test
|
||||
void createRejectsIdentityAndRequiresOperationalMatchers() {
|
||||
AlertInhibitRequest request = request();
|
||||
request.setId(7L);
|
||||
assertThrows(IllegalArgumentException.class, () -> mapper.toNewEntity(request));
|
||||
|
||||
request.setId(null);
|
||||
request.setSourceLabels(Map.of());
|
||||
assertThrows(IllegalArgumentException.class, () -> mapper.toNewEntity(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizesSafeExplicitRequest() {
|
||||
AlertInhibitRequest request = request();
|
||||
request.setName(" Host severity suppression ");
|
||||
|
||||
var entity = mapper.toNewEntity(request);
|
||||
|
||||
assertEquals("Host severity suppression", entity.getName());
|
||||
assertEquals(Map.of("severity", "critical"), entity.getSourceLabels());
|
||||
assertEquals(Map.of("severity", "warning"), entity.getTargetLabels());
|
||||
assertEquals(List.of("instance"), entity.getEqualLabels());
|
||||
}
|
||||
|
||||
private AlertInhibitRequest request() {
|
||||
AlertInhibitRequest request = new AlertInhibitRequest();
|
||||
request.setName("Host severity suppression");
|
||||
request.setEnable(true);
|
||||
request.setSourceLabels(Map.of("severity", "critical"));
|
||||
request.setTargetLabels(Map.of("severity", "warning"));
|
||||
request.setEqualLabels(List.of("instance"));
|
||||
return request;
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.apache.hertzbeat.alert.dao.AlertInhibitDao;
|
||||
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
|
||||
import org.apache.hertzbeat.alert.reduce.AlarmInhibitReduce;
|
||||
import org.apache.hertzbeat.alert.service.impl.AlertInhibitServiceImpl;
|
||||
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AlertInhibitServiceTest {
|
||||
|
||||
@Mock
|
||||
private AlertInhibitDao dao;
|
||||
@Mock
|
||||
private AlarmInhibitReduce reducer;
|
||||
private AlertInhibitServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new AlertInhibitServiceImpl(dao, reducer, new AlertInhibitContractMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAndUpdateUseAuthoritativeRereads() {
|
||||
AlertInhibit saved = entity(7L, "draft");
|
||||
AlertInhibit authoritative = entity(7L, "authoritative");
|
||||
when(dao.save(any(AlertInhibit.class))).thenReturn(saved);
|
||||
when(dao.findById(7L)).thenReturn(Optional.of(authoritative));
|
||||
when(dao.findAlertInhibitsByEnableIsTrue()).thenReturn(List.of(authoritative));
|
||||
|
||||
assertEquals("authoritative", service.create(request(null)).name());
|
||||
|
||||
AlertInhibit existing = entity(7L, "existing");
|
||||
AlertInhibit updated = entity(7L, "updated");
|
||||
when(dao.findById(7L)).thenReturn(Optional.of(existing), Optional.of(updated));
|
||||
assertEquals("updated", service.update(request(7L)).name());
|
||||
verify(reducer, org.mockito.Mockito.times(2)).refreshInhibitRules(List.of(authoritative));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingUpdateDoesNotInsert() {
|
||||
when(dao.findById(7L)).thenReturn(Optional.empty());
|
||||
assertThrows(AlertInhibitNotFoundException.class, () -> service.update(request(7L)));
|
||||
verify(dao, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRereadsAndReportsMissingIds() {
|
||||
when(dao.findAllById(Set.of(7L, 8L))).thenReturn(List.of(entity(7L, "existing")), List.of());
|
||||
when(dao.findAlertInhibitsByEnableIsTrue()).thenReturn(List.of());
|
||||
|
||||
var result = service.delete(Set.of(7L, 8L));
|
||||
|
||||
assertEquals("partial", result.status());
|
||||
assertEquals(Set.of(7L), result.deletedIds());
|
||||
assertEquals(Set.of(8L), result.missingIds());
|
||||
InOrder order = inOrder(dao);
|
||||
order.verify(dao).findAllById(Set.of(7L, 8L));
|
||||
order.verify(dao).deleteAlertInhibitsByIdIn(Set.of(7L));
|
||||
order.verify(dao).findAllById(Set.of(7L, 8L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uncertainDeleteLeavesCacheUnpublished() {
|
||||
when(dao.findAllById(Set.of(7L))).thenReturn(List.of(entity(7L, "existing")),
|
||||
List.of(entity(7L, "remaining")));
|
||||
|
||||
assertThrows(AlertInhibitOperationException.class, () -> service.delete(Set.of(7L)));
|
||||
verify(reducer, never()).refreshInhibitRules(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMapsExplicitPageAndRejectsUnsafeControls() {
|
||||
AlertInhibit listed = entity(7L, "listed");
|
||||
when(dao.findAll(any(Specification.class), any(PageRequest.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(listed), PageRequest.of(0, 8), 1));
|
||||
|
||||
var result = service.list(null, " listed ", "id", "desc", 0, 8);
|
||||
|
||||
assertEquals(1, result.totalElements());
|
||||
assertEquals("listed", result.content().getFirst().name());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.list(null, null, "sourceLabels", "desc", 0, 8));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.list(null, null, "id", "desc", -1, 8));
|
||||
}
|
||||
|
||||
private AlertInhibitRequest request(Long id) {
|
||||
AlertInhibitRequest request = new AlertInhibitRequest();
|
||||
request.setId(id);
|
||||
request.setName("Host suppression");
|
||||
request.setEnable(true);
|
||||
request.setSourceLabels(Map.of("severity", "critical"));
|
||||
request.setTargetLabels(Map.of("severity", "warning"));
|
||||
request.setEqualLabels(List.of("instance"));
|
||||
return request;
|
||||
}
|
||||
|
||||
private AlertInhibit entity(Long id, String name) {
|
||||
return AlertInhibit.builder().id(id).name(name).enable(true)
|
||||
.sourceLabels(Map.of("severity", "critical"))
|
||||
.targetLabels(Map.of("severity", "warning"))
|
||||
.equalLabels(List.of("instance")).build();
|
||||
}
|
||||
}
|
||||
+124
-2
@@ -21,8 +21,13 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.never;
|
||||
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.Collections;
|
||||
import java.util.HashSet;
|
||||
@@ -46,6 +51,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* Test case for {@link AlertService}
|
||||
@@ -61,6 +67,9 @@ class AlertServiceTest {
|
||||
@Mock
|
||||
private AlarmCommonReduce alarmCommonReduce;
|
||||
|
||||
@Mock
|
||||
private AlertGroupMutationPublisher alertGroupMutationPublisher;
|
||||
|
||||
@InjectMocks
|
||||
private AlertServiceImpl alertService;
|
||||
|
||||
@@ -73,10 +82,49 @@ class AlertServiceTest {
|
||||
HashSet<Long> ids = new HashSet<>();
|
||||
ids.add(1L);
|
||||
ids.add(2L);
|
||||
List<GroupAlert> groupAlerts = List.of(
|
||||
GroupAlert.builder().id(1L).alertFingerprints(List.of()).build(),
|
||||
GroupAlert.builder().id(2L).alertFingerprints(List.of()).build());
|
||||
when(groupAlertDao.findGroupAlertsByIdIn(ids)).thenReturn(groupAlerts);
|
||||
|
||||
assertDoesNotThrow(() -> alertService.deleteGroupAlerts(ids));
|
||||
|
||||
verify(groupAlertDao, times(1)).deleteGroupAlertsByIdIn(ids);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteGroupAlertsRejectsPartialMissingTargetsBeforeDeletes() {
|
||||
HashSet<Long> ids = new HashSet<>(List.of(1L, 2L));
|
||||
GroupAlert existingAlert = GroupAlert.builder()
|
||||
.id(1L)
|
||||
.alertFingerprints(List.of("private-alert-fingerprint"))
|
||||
.build();
|
||||
when(groupAlertDao.findGroupAlertsByIdIn(ids)).thenReturn(List.of(existingAlert));
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
assertThrows(AlertGroupNotFoundException.class, () -> alertService.deleteGroupAlerts(ids));
|
||||
|
||||
assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty());
|
||||
verify(groupAlertDao, never()).deleteGroupAlertsByIdIn(ids);
|
||||
verifyNoInteractions(singleAlertDao);
|
||||
verifyNoInteractions(alertGroupMutationPublisher);
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteGroupAlertsRequestsTombstoneAfterExactDelete() {
|
||||
HashSet<Long> ids = new HashSet<>(List.of(2L, 1L));
|
||||
List<GroupAlert> groupAlerts = List.of(
|
||||
GroupAlert.builder().id(1L).alertFingerprints(List.of()).build(),
|
||||
GroupAlert.builder().id(2L).alertFingerprints(List.of()).build());
|
||||
when(groupAlertDao.findGroupAlertsByIdIn(ids)).thenReturn(groupAlerts);
|
||||
alertService.deleteGroupAlerts(ids);
|
||||
|
||||
verify(groupAlertDao).deleteGroupAlertsByIdIn(ids);
|
||||
verify(alertGroupMutationPublisher).publishDeleted(ids);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editGroupAlertStatus() {
|
||||
@@ -87,20 +135,31 @@ class AlertServiceTest {
|
||||
.status(CommonConstants.ALERT_STATUS_RESOLVED)
|
||||
.alertFingerprints(List.of("fingerprint-1"))
|
||||
.build();
|
||||
GroupAlert secondGroupAlert = GroupAlert.builder()
|
||||
.id(2L)
|
||||
.status(CommonConstants.ALERT_STATUS_RESOLVED)
|
||||
.alertFingerprints(List.of())
|
||||
.build();
|
||||
GroupAlert thirdGroupAlert = GroupAlert.builder()
|
||||
.id(3L)
|
||||
.status(CommonConstants.ALERT_STATUS_RESOLVED)
|
||||
.alertFingerprints(List.of())
|
||||
.build();
|
||||
List<GroupAlert> groupAlerts = List.of(groupAlert, secondGroupAlert, thirdGroupAlert);
|
||||
SingleAlert singleAlert = SingleAlert.builder()
|
||||
.id(1L)
|
||||
.fingerprint("fingerprint-1")
|
||||
.status(CommonConstants.ALERT_STATUS_RESOLVED)
|
||||
.endAt(1L)
|
||||
.build();
|
||||
when(groupAlertDao.findAllById(ids)).thenReturn(List.of(groupAlert));
|
||||
when(groupAlertDao.findAllById(ids)).thenReturn(groupAlerts);
|
||||
when(singleAlertDao.findSingleAlertsByFingerprintIn(List.of("fingerprint-1"))).thenReturn(List.of(singleAlert));
|
||||
|
||||
assertDoesNotThrow(() -> alertService.editGroupAlertStatus(status, ids));
|
||||
assertEquals(CommonConstants.ALERT_STATUS_FIRING, groupAlert.getStatus());
|
||||
assertEquals(CommonConstants.ALERT_STATUS_FIRING, singleAlert.getStatus());
|
||||
assertNull(singleAlert.getEndAt());
|
||||
verify(groupAlertDao, times(1)).saveAll(List.of(groupAlert));
|
||||
verify(groupAlertDao, times(1)).saveAll(groupAlerts);
|
||||
verify(singleAlertDao, times(1)).saveAll(List.of(singleAlert));
|
||||
}
|
||||
|
||||
@@ -157,6 +216,68 @@ class AlertServiceTest {
|
||||
verify(singleAlertDao, times(1)).saveAll(List.of(singleAlert));
|
||||
}
|
||||
|
||||
@Test
|
||||
void editGroupAlertStatusRejectsPartialMissingTargetsBeforeWrites() {
|
||||
List<Long> ids = List.of(1L, 2L);
|
||||
GroupAlert existingAlert = GroupAlert.builder()
|
||||
.id(1L)
|
||||
.status(CommonConstants.ALERT_STATUS_FIRING)
|
||||
.alertFingerprints(List.of("fingerprint-1"))
|
||||
.build();
|
||||
when(groupAlertDao.findAllById(ids)).thenReturn(List.of(existingAlert));
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
assertThrows(AlertGroupNotFoundException.class,
|
||||
() -> alertService.editGroupAlertStatus(CommonConstants.ALERT_STATUS_RESOLVED, ids));
|
||||
|
||||
assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty());
|
||||
verify(groupAlertDao, never()).saveAll(anyList());
|
||||
verifyNoInteractions(singleAlertDao);
|
||||
verifyNoInteractions(alertGroupMutationPublisher);
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void editGroupAlertStatusRequestsRefreshAfterExactWrites() {
|
||||
List<Long> ids = List.of(2L, 1L, 2L);
|
||||
List<GroupAlert> groupAlerts = List.of(
|
||||
GroupAlert.builder().id(1L).alertFingerprints(List.of()).build(),
|
||||
GroupAlert.builder().id(2L).alertFingerprints(List.of()).build());
|
||||
when(groupAlertDao.findAllById(List.of(2L, 1L))).thenReturn(groupAlerts);
|
||||
alertService.editGroupAlertStatus(CommonConstants.ALERT_STATUS_ACKNOWLEDGED, ids);
|
||||
|
||||
verify(groupAlertDao).saveAll(groupAlerts);
|
||||
verify(alertGroupMutationPublisher).publishStatusChanged(
|
||||
List.of(2L, 1L), CommonConstants.ALERT_STATUS_ACKNOWLEDGED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editGroupAlertStatusRemainsIdempotentWhenStatusAlreadyApplied() {
|
||||
List<Long> ids = List.of(1L);
|
||||
GroupAlert groupAlert = GroupAlert.builder()
|
||||
.id(1L)
|
||||
.status(CommonConstants.ALERT_STATUS_ACKNOWLEDGED)
|
||||
.alertFingerprints(List.of())
|
||||
.build();
|
||||
when(groupAlertDao.findAllById(ids)).thenReturn(List.of(groupAlert));
|
||||
|
||||
assertDoesNotThrow(() -> alertService.editGroupAlertStatus(CommonConstants.ALERT_STATUS_ACKNOWLEDGED, ids));
|
||||
|
||||
assertEquals(CommonConstants.ALERT_STATUS_ACKNOWLEDGED, groupAlert.getStatus());
|
||||
verify(groupAlertDao).saveAll(List.of(groupAlert));
|
||||
verifyNoInteractions(singleAlertDao);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editGroupAlertStatusRejectsUnsupportedStatusBeforeQueriesOrWrites() {
|
||||
assertThrows(AlertGroupStatusNotSupportedException.class,
|
||||
() -> alertService.editGroupAlertStatus("private-arbitrary-status", List.of(1L)));
|
||||
|
||||
verifyNoInteractions(groupAlertDao, singleAlertDao);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGroupAlertsFiltersByServiceNamespaceAndEnvironmentLabels() {
|
||||
GroupAlert matching = GroupAlert.builder()
|
||||
@@ -227,4 +348,5 @@ class AlertServiceTest {
|
||||
verify(singleAlertDao, times(1)).querySingleAlertsByStatus(CommonConstants.ALERT_STATUS_FIRING);
|
||||
verify(singleAlertDao, times(1)).count();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+8
@@ -82,6 +82,14 @@ class AlertSilenceContractMapperTest {
|
||||
assertEquals("creator", updated.getCreator());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullRequestFailsValidation() {
|
||||
var existing = org.apache.hertzbeat.common.entity.alerter.AlertSilence.builder().id(7L).build();
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> mapper.toNewEntity(null));
|
||||
assertThrows(IllegalArgumentException.class, () -> mapper.toExistingEntity(null, existing));
|
||||
}
|
||||
|
||||
private AlertSilenceRequest request(byte type) {
|
||||
AlertSilenceRequest request = new AlertSilenceRequest();
|
||||
request.setName("Maintenance");
|
||||
|
||||
+5
@@ -87,6 +87,11 @@ class AlertSilenceServiceTest {
|
||||
assertThrows(AlertSilenceNotFoundException.class, () -> service.get(7L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullUpdateIsRejectedAsValidation() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.update(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRereadsAndReportsMissingIds() {
|
||||
when(dao.findAllById(Set.of(7L, 8L))).thenReturn(List.of(entity(7L, "existing")), List.of());
|
||||
|
||||
+72
@@ -73,6 +73,78 @@ class DataSourceServiceTest {
|
||||
assertEquals(200.0, result.get(1).get("__value__"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculatePreviewPreservesExecutorFailure() {
|
||||
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
|
||||
when(mockExecutor.support("promql")).thenReturn(true);
|
||||
when(mockExecutor.execute(anyString())).thenReturn(List.of());
|
||||
when(mockExecutor.executePreview(anyString())).thenThrow(new IllegalStateException("preview backend unavailable"));
|
||||
dataSourceService.setExecutors(List.of(mockExecutor));
|
||||
|
||||
AlertExpressionException exception = assertThrows(AlertExpressionException.class,
|
||||
() -> dataSourceService.calculatePreview("promql", "node_cpu_seconds_total > 80"));
|
||||
assertEquals("Preview query execution failed", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryPreviewReturnsSafeFailureWithoutExecutorDetails() {
|
||||
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
|
||||
when(mockExecutor.support("sql")).thenReturn(true);
|
||||
when(mockExecutor.execute(anyString())).thenReturn(List.of());
|
||||
when(mockExecutor.executePreview(anyString()))
|
||||
.thenThrow(new IllegalStateException("private backend host and query details"));
|
||||
dataSourceService.setExecutors(List.of(mockExecutor));
|
||||
|
||||
AlertExpressionException exception = assertThrows(AlertExpressionException.class,
|
||||
() -> dataSourceService.queryPreview(
|
||||
"sql", "SELECT * FROM hertzbeat_logs", LOG_ALERT_THRESHOLD_TYPE_PERIODIC));
|
||||
|
||||
assertEquals("Preview query execution failed", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryPreviewWrapsValidatedSqlWithAnOuterLimit() {
|
||||
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
|
||||
when(mockExecutor.support("sql")).thenReturn(true);
|
||||
when(mockExecutor.executePreview(anyString())).thenReturn(List.of());
|
||||
dataSourceService.setExecutors(List.of(mockExecutor));
|
||||
|
||||
dataSourceService.queryPreview(
|
||||
"sql", "SELECT * FROM hertzbeat_logs LIMIT 10; ", LOG_ALERT_THRESHOLD_TYPE_PERIODIC);
|
||||
|
||||
verify(mockExecutor).executePreview(
|
||||
"SELECT * FROM (SELECT * FROM hertzbeat_logs LIMIT 10) AS hertzbeat_preview LIMIT 100");
|
||||
verify(mockExecutor, never()).execute(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryPreviewRejectsUnsafeSqlBeforeStrictExecution() {
|
||||
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
|
||||
when(mockExecutor.support("sql")).thenReturn(true);
|
||||
dataSourceService.setExecutors(List.of(mockExecutor));
|
||||
|
||||
assertThrows(AlertExpressionException.class,
|
||||
() -> dataSourceService.queryPreview(
|
||||
"sql", "DROP TABLE hertzbeat_logs", LOG_ALERT_THRESHOLD_TYPE_PERIODIC));
|
||||
|
||||
verify(mockExecutor, never()).executePreview(anyString());
|
||||
verify(mockExecutor, never()).execute(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryLeavesRegularSqlUnwrapped() {
|
||||
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
|
||||
when(mockExecutor.support("sql")).thenReturn(true);
|
||||
when(mockExecutor.execute(anyString())).thenReturn(List.of());
|
||||
dataSourceService.setExecutors(List.of(mockExecutor));
|
||||
|
||||
dataSourceService.query(
|
||||
"sql", "SELECT * FROM hertzbeat_logs LIMIT 10;", LOG_ALERT_THRESHOLD_TYPE_PERIODIC);
|
||||
|
||||
verify(mockExecutor).execute("SELECT * FROM hertzbeat_logs LIMIT 10;");
|
||||
verify(mockExecutor, never()).executePreview(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculate2() {
|
||||
List<Map<String, Object>> prometheusData = List.of(
|
||||
|
||||
+172
-5
@@ -17,10 +17,17 @@
|
||||
|
||||
package org.apache.hertzbeat.alert.service;
|
||||
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Expression;
|
||||
import jakarta.persistence.criteria.Path;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
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.notice.AlertNoticeDispatch;
|
||||
import org.apache.hertzbeat.alert.service.NoticeTemplateMutationException.Reason;
|
||||
import org.apache.hertzbeat.alert.service.impl.NoticeConfigServiceImpl;
|
||||
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
|
||||
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
|
||||
@@ -29,6 +36,7 @@ import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
|
||||
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.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
@@ -42,11 +50,15 @@ import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
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.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
|
||||
@@ -158,6 +170,38 @@ class NoticeConfigServiceTest {
|
||||
verify(noticeTemplateDao, times(1)).findAll(any(Specification.class), any(PageRequest.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void getCustomNoticeTemplatesCombinesPresetAndNameFilters() {
|
||||
ArgumentCaptor<Specification<NoticeTemplate>> specificationCaptor =
|
||||
ArgumentCaptor.forClass(Specification.class);
|
||||
when(noticeTemplateDao.findAll(any(Specification.class), any(PageRequest.class)))
|
||||
.thenReturn(Page.empty());
|
||||
|
||||
noticeConfigService.getNoticeTemplates("Template", false, 0, 8);
|
||||
|
||||
verify(noticeTemplateDao).findAll(specificationCaptor.capture(), any(PageRequest.class));
|
||||
Root<NoticeTemplate> root = mock(Root.class);
|
||||
CriteriaQuery<?> query = mock(CriteriaQuery.class);
|
||||
CriteriaBuilder criteriaBuilder = mock(CriteriaBuilder.class);
|
||||
Path<Boolean> presetPath = mock(Path.class);
|
||||
Path<String> namePath = mock(Path.class);
|
||||
Expression<String> loweredName = mock(Expression.class);
|
||||
Predicate customPredicate = mock(Predicate.class);
|
||||
Predicate namePredicate = mock(Predicate.class);
|
||||
Predicate combinedPredicate = mock(Predicate.class);
|
||||
when(root.<Boolean>get("preset")).thenReturn(presetPath);
|
||||
when(root.<String>get("name")).thenReturn(namePath);
|
||||
when(criteriaBuilder.equal(presetPath, false)).thenReturn(customPredicate);
|
||||
when(criteriaBuilder.lower(namePath)).thenReturn(loweredName);
|
||||
when(criteriaBuilder.like(loweredName, "%template%")).thenReturn(namePredicate);
|
||||
when(criteriaBuilder.and(customPredicate, namePredicate)).thenReturn(combinedPredicate);
|
||||
|
||||
Predicate result = specificationCaptor.getValue().toPredicate(root, query, criteriaBuilder);
|
||||
|
||||
assertSame(combinedPredicate, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAllNoticeTemplates() {
|
||||
when(noticeTemplateDao.findAll()).thenReturn(Arrays.asList(template1, template2));
|
||||
@@ -216,23 +260,146 @@ class NoticeConfigServiceTest {
|
||||
|
||||
@Test
|
||||
void addTemplate() {
|
||||
final NoticeTemplate noticeTemplate = mock(NoticeTemplate.class);
|
||||
final NoticeTemplate noticeTemplate = NoticeTemplate.builder()
|
||||
.name("custom")
|
||||
.type((byte) 1)
|
||||
.content("content")
|
||||
.build();
|
||||
noticeConfigService.addNoticeTemplate(noticeTemplate);
|
||||
verify(noticeTemplateDao, times(1)).save(noticeTemplate);
|
||||
verify(noticeTemplateDao, times(1)).save(any(NoticeTemplate.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addNoticeTemplateRejectsCallerOwnedIdentityAndPresetState() {
|
||||
NoticeTemplate noticeTemplate = NoticeTemplate.builder()
|
||||
.id(87584674384L)
|
||||
.name("private-template")
|
||||
.type((byte) 1)
|
||||
.preset(true)
|
||||
.content("private-template-payload")
|
||||
.build();
|
||||
|
||||
NoticeTemplateMutationException exception = assertThrows(NoticeTemplateMutationException.class,
|
||||
() -> noticeConfigService.addNoticeTemplate(noticeTemplate));
|
||||
|
||||
assertEquals(Reason.INVALID_REQUEST, exception.getReason());
|
||||
verifyNoInteractions(noticeTemplateDao);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editTemplate() {
|
||||
final NoticeTemplate noticeTemplate = mock(NoticeTemplate.class);
|
||||
final NoticeTemplate noticeTemplate = NoticeTemplate.builder()
|
||||
.id(23342525L)
|
||||
.name("updated")
|
||||
.type((byte) 1)
|
||||
.content("updated content")
|
||||
.build();
|
||||
NoticeTemplate persisted = NoticeTemplate.builder().id(23342525L).preset(false).build();
|
||||
when(noticeTemplateDao.findByIdForUpdate(23342525L)).thenReturn(java.util.Optional.of(persisted));
|
||||
noticeConfigService.editNoticeTemplate(noticeTemplate);
|
||||
verify(noticeTemplateDao, times(1)).save(noticeTemplate);
|
||||
verify(noticeTemplateDao, times(1)).save(persisted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editNoticeTemplateRejectsMissingTargetBeforeWrite() {
|
||||
NoticeTemplate noticeTemplate = NoticeTemplate.builder()
|
||||
.id(87584674384L)
|
||||
.name("updated")
|
||||
.type((byte) 1)
|
||||
.content("updated content")
|
||||
.build();
|
||||
when(noticeTemplateDao.findByIdForUpdate(87584674384L)).thenReturn(java.util.Optional.empty());
|
||||
|
||||
NoticeTemplateMutationException exception = assertThrows(NoticeTemplateMutationException.class,
|
||||
() -> noticeConfigService.editNoticeTemplate(noticeTemplate));
|
||||
|
||||
assertEquals(Reason.NOT_FOUND, exception.getReason());
|
||||
verify(noticeTemplateDao, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editNoticeTemplateUpdatesOnlyTheLockedExactCustomTarget() {
|
||||
NoticeTemplate persisted = NoticeTemplate.builder()
|
||||
.id(87584674384L)
|
||||
.name("existing")
|
||||
.type((byte) 2)
|
||||
.content("existing content")
|
||||
.creator("trusted-creator")
|
||||
.preset(false)
|
||||
.build();
|
||||
NoticeTemplate request = NoticeTemplate.builder()
|
||||
.id(87584674384L)
|
||||
.name("updated")
|
||||
.type((byte) 1)
|
||||
.content("updated content")
|
||||
.creator("private-spoofed-creator")
|
||||
.preset(false)
|
||||
.build();
|
||||
when(noticeTemplateDao.findByIdForUpdate(87584674384L)).thenReturn(java.util.Optional.of(persisted));
|
||||
|
||||
noticeConfigService.editNoticeTemplate(request);
|
||||
|
||||
assertEquals("updated", persisted.getName());
|
||||
assertEquals((byte) 1, persisted.getType());
|
||||
assertEquals("updated content", persisted.getContent());
|
||||
assertEquals("trusted-creator", persisted.getCreator());
|
||||
verify(noticeTemplateDao).save(persisted);
|
||||
verify(noticeTemplateDao, never()).save(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editNoticeTemplateRejectsPresetMutationBeforeWrite() {
|
||||
NoticeTemplate persisted = NoticeTemplate.builder()
|
||||
.id(87584674384L)
|
||||
.preset(false)
|
||||
.build();
|
||||
NoticeTemplate request = NoticeTemplate.builder()
|
||||
.id(87584674384L)
|
||||
.preset(true)
|
||||
.build();
|
||||
when(noticeTemplateDao.findByIdForUpdate(87584674384L)).thenReturn(java.util.Optional.of(persisted));
|
||||
|
||||
NoticeTemplateMutationException exception = assertThrows(NoticeTemplateMutationException.class,
|
||||
() -> noticeConfigService.editNoticeTemplate(request));
|
||||
|
||||
assertEquals(Reason.READ_ONLY, exception.getReason());
|
||||
verify(noticeTemplateDao, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteTemplate() {
|
||||
final Long templateId = 23342525L;
|
||||
NoticeTemplate persisted = NoticeTemplate.builder().id(templateId).preset(false).build();
|
||||
when(noticeTemplateDao.findByIdForUpdate(templateId)).thenReturn(java.util.Optional.of(persisted));
|
||||
noticeConfigService.deleteNoticeTemplate(templateId);
|
||||
verify(noticeTemplateDao, times(1)).deleteById(templateId);
|
||||
verify(noticeTemplateDao, times(1)).delete(persisted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteNoticeTemplateRejectsMissingTargetBeforeWrite() {
|
||||
long templateId = 87584674384L;
|
||||
when(noticeTemplateDao.findByIdForUpdate(templateId)).thenReturn(java.util.Optional.empty());
|
||||
|
||||
NoticeTemplateMutationException exception = assertThrows(NoticeTemplateMutationException.class,
|
||||
() -> noticeConfigService.deleteNoticeTemplate(templateId));
|
||||
|
||||
assertEquals(Reason.NOT_FOUND, exception.getReason());
|
||||
verify(noticeTemplateDao, never()).delete(any(NoticeTemplate.class));
|
||||
verify(noticeTemplateDao, never()).deleteById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteNoticeTemplateRejectsLockedPresetTargetBeforeWrite() {
|
||||
long templateId = 87584674384L;
|
||||
NoticeTemplate persisted = NoticeTemplate.builder().id(templateId).preset(true).build();
|
||||
when(noticeTemplateDao.findByIdForUpdate(templateId)).thenReturn(java.util.Optional.of(persisted));
|
||||
|
||||
NoticeTemplateMutationException exception = assertThrows(NoticeTemplateMutationException.class,
|
||||
() -> noticeConfigService.deleteNoticeTemplate(templateId));
|
||||
|
||||
assertEquals(Reason.READ_ONLY, exception.getReason());
|
||||
verify(noticeTemplateDao, never()).delete(any(NoticeTemplate.class));
|
||||
verify(noticeTemplateDao, never()).deleteById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+7
-15
@@ -20,6 +20,7 @@ package org.apache.hertzbeat.alert.service.impl;
|
||||
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.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -183,7 +184,7 @@ public class AlertManagerExternAlertServiceTest {
|
||||
@Test
|
||||
void testAddExternAlertWithInvalidContent() {
|
||||
String invalidContent = "invalid json content";
|
||||
externAlertService.addExternAlert(invalidContent);
|
||||
assertThrows(IllegalArgumentException.class, () -> externAlertService.addExternAlert(invalidContent));
|
||||
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
|
||||
}
|
||||
|
||||
@@ -193,7 +194,8 @@ public class AlertManagerExternAlertServiceTest {
|
||||
.groupKey("test-group-key")
|
||||
.alerts(List.of()) // Empty alerts list
|
||||
.build();
|
||||
externAlertService.addExternAlert(JsonUtil.toJson(alertManagerAlert));
|
||||
String content = JsonUtil.toJson(alertManagerAlert);
|
||||
assertThrows(IllegalArgumentException.class, () -> externAlertService.addExternAlert(content));
|
||||
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
|
||||
}
|
||||
|
||||
@@ -211,19 +213,9 @@ public class AlertManagerExternAlertServiceTest {
|
||||
.alerts(List.of(prometheusAlert))
|
||||
.build();
|
||||
|
||||
final SingleAlert[] capturedAlert = new SingleAlert[1];
|
||||
doAnswer(invocation -> {
|
||||
capturedAlert[0] = invocation.getArgument(0);
|
||||
return null;
|
||||
}).when(alarmCommonReduce).reduceAndSendAlarm(any(SingleAlert.class));
|
||||
|
||||
externAlertService.addExternAlert(JsonUtil.toJson(alertManagerAlert));
|
||||
|
||||
verify(alarmCommonReduce, times(1)).reduceAndSendAlarm(any(SingleAlert.class));
|
||||
|
||||
assertNotNull(capturedAlert[0]);
|
||||
assertNotNull(capturedAlert[0].getLabels());
|
||||
assertEquals("alertmanager", capturedAlert[0].getLabels().get("__source__"));
|
||||
String content = JsonUtil.toJson(alertManagerAlert);
|
||||
assertThrows(IllegalArgumentException.class, () -> externAlertService.addExternAlert(content));
|
||||
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* 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.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
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 java.util.List;
|
||||
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
|
||||
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;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ExternalAlertIngressValidationTest {
|
||||
|
||||
@Mock
|
||||
private AlarmCommonReduce alarmCommonReduce;
|
||||
|
||||
private DefaultExternAlertService defaultService;
|
||||
private PrometheusExternAlertService prometheusService;
|
||||
private AlertManagerExternAlertService alertManagerService;
|
||||
private ZabbixExternAlertServiceImpl zabbixService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
defaultService = withReducer(new DefaultExternAlertService());
|
||||
prometheusService = withReducer(new PrometheusExternAlertService());
|
||||
alertManagerService = withReducer(new AlertManagerExternAlertService());
|
||||
zabbixService = withReducer(new ZabbixExternAlertServiceImpl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedAndEmptyPayloadsBeforeAsyncSubmission() {
|
||||
assertThrows(IllegalArgumentException.class, () -> defaultService.addExternAlert("not-json"));
|
||||
assertThrows(IllegalArgumentException.class, () -> prometheusService.addExternAlert("not-json"));
|
||||
assertThrows(IllegalArgumentException.class, () -> prometheusService.addExternAlert("null"));
|
||||
assertThrows(IllegalArgumentException.class, () -> prometheusService.addExternAlert("[]"));
|
||||
assertThrows(IllegalArgumentException.class, () -> alertManagerService.addExternAlert("not-json"));
|
||||
assertThrows(IllegalArgumentException.class, () -> alertManagerService.addExternAlert("null"));
|
||||
assertThrows(IllegalArgumentException.class, () -> alertManagerService.addExternAlert("{}"));
|
||||
assertThrows(IllegalArgumentException.class, () -> alertManagerService.addExternAlert("{\"alerts\":[]}"));
|
||||
assertThrows(IllegalArgumentException.class, () -> zabbixService.addExternAlert("not-json"));
|
||||
|
||||
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingOrEmptyBusinessLabelsBeforeAddingSyntheticSource() {
|
||||
assertThrows(IllegalArgumentException.class, () -> defaultService.addExternAlert("{}"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> defaultService.addExternAlert("{\"labels\":{}}"));
|
||||
assertThrows(IllegalArgumentException.class, () -> prometheusService.addExternAlert("[{}]"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> prometheusService.addExternAlert("[{\"labels\":{}}]"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> alertManagerService.addExternAlert("{\"alerts\":[{}]}"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> alertManagerService.addExternAlert("{\"alerts\":[{\"labels\":{}}]}"));
|
||||
assertThrows(IllegalArgumentException.class, () -> zabbixService.addExternAlert("{}"));
|
||||
assertThrows(IllegalArgumentException.class, () -> zabbixService.addExternAlert("{\"labels\":{}}"));
|
||||
|
||||
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesCompleteBatchBeforeSubmittingAnyElement() {
|
||||
String prometheusBatch = """
|
||||
[
|
||||
{"labels":{"alertname":"first"}},
|
||||
null
|
||||
]""";
|
||||
String alertManagerBatch = """
|
||||
{
|
||||
"alerts":[
|
||||
{"labels":{"alertname":"first"}},
|
||||
null
|
||||
]
|
||||
}""";
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> prometheusService.addExternAlert(prometheusBatch));
|
||||
assertThrows(IllegalArgumentException.class, () -> alertManagerService.addExternAlert(alertManagerBatch));
|
||||
|
||||
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizesAnnotationsAndSubmitsProcessableAlerts() {
|
||||
defaultService.addExternAlert("{\"labels\":{\"alertname\":\"default\"}}");
|
||||
prometheusService.addExternAlert("[{\"labels\":{\"alertname\":\"prometheus\"}}]");
|
||||
alertManagerService.addExternAlert(
|
||||
"{\"alerts\":[{\"labels\":{\"alertname\":\"alertmanager\"}}]}");
|
||||
zabbixService.addExternAlert("{\"labels\":{\"alertname\":\"zabbix\"}}");
|
||||
|
||||
ArgumentCaptor<SingleAlert> captor = ArgumentCaptor.forClass(SingleAlert.class);
|
||||
verify(alarmCommonReduce, times(4)).reduceAndSendAlarm(captor.capture());
|
||||
List<SingleAlert> submittedAlerts = captor.getAllValues();
|
||||
for (SingleAlert alert : submittedAlerts) {
|
||||
assertFalse(alert.getLabels().isEmpty());
|
||||
assertNotNull(alert.getAnnotations());
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T withReducer(T service) {
|
||||
ReflectionTestUtils.setField(service, "alarmCommonReduce", alarmCommonReduce);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,14 @@
|
||||
|
||||
package org.apache.hertzbeat.base.dao;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.apache.hertzbeat.common.entity.manager.GeneralConfig;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
@@ -28,7 +33,7 @@ import org.springframework.stereotype.Component;
|
||||
* <p>This interface inherits the two interfaces JpaRepository and JpaSpecificationExecutor, providing basic CRUD operations and specification query capabilities.</p>
|
||||
*/
|
||||
@Component
|
||||
public interface GeneralConfigDao extends JpaRepository<GeneralConfig, Long>, JpaSpecificationExecutor<GeneralConfig> {
|
||||
public interface GeneralConfigDao extends JpaRepository<GeneralConfig, String>, JpaSpecificationExecutor<GeneralConfig> {
|
||||
|
||||
/**
|
||||
* Query by type
|
||||
@@ -36,4 +41,22 @@ public interface GeneralConfigDao extends JpaRepository<GeneralConfig, Long>, Jp
|
||||
* @return Return the queried configuration information
|
||||
*/
|
||||
GeneralConfig findByType(String type);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select config from GeneralConfig config where config.type = :type")
|
||||
GeneralConfig findByTypeForUpdate(@Param("type") String type);
|
||||
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query("""
|
||||
update GeneralConfig config
|
||||
set config.content = :content,
|
||||
config.revision = :nextRevision,
|
||||
config.gmtUpdate = CURRENT_TIMESTAMP
|
||||
where config.type = :type
|
||||
and config.revision = :expectedRevision
|
||||
""")
|
||||
int updateContentIfRevision(@Param("type") String type,
|
||||
@Param("content") String content,
|
||||
@Param("nextRevision") String nextRevision,
|
||||
@Param("expectedRevision") String expectedRevision);
|
||||
}
|
||||
|
||||
+30
@@ -117,6 +117,9 @@ public class OtelRuntimeConfigRenderer {
|
||||
if (properties.getInternalTelemetryPort() < 1 || properties.getInternalTelemetryPort() > 65535) {
|
||||
throw new IllegalArgumentException("Runtime internal telemetry port is invalid");
|
||||
}
|
||||
long exporterTimeoutSeconds = OtelRuntimeGatewayPolicy.boundedTimeout(
|
||||
properties.getOtlpHttpExporterTimeout(), "HTTP exporter request").toSeconds();
|
||||
long exporterTimeoutMillis = properties.getOtlpHttpExporterTimeout().toMillis();
|
||||
StringBuilder yaml = new StringBuilder("receivers:\n otlp:\n protocols:\n")
|
||||
.append(" grpc:\n")
|
||||
.append(" endpoint: ").append(yamlScalar(gateway.grpcEndpoint())).append('\n')
|
||||
@@ -141,6 +144,7 @@ public class OtelRuntimeConfigRenderer {
|
||||
headers:
|
||||
Authorization: Bearer ${env:HERTZBEAT_OTLP_TOKEN}
|
||||
compression: gzip
|
||||
timeout: %ds
|
||||
retry_on_failure:
|
||||
enabled: true
|
||||
initial_interval: 1s
|
||||
@@ -153,6 +157,8 @@ public class OtelRuntimeConfigRenderer {
|
||||
sizer: requests
|
||||
queue_size: 2048
|
||||
storage: file_storage
|
||||
""".formatted(exporterTimeoutSeconds));
|
||||
yaml.append("""
|
||||
extensions:
|
||||
health_check:
|
||||
endpoint: 127.0.0.1:%d
|
||||
@@ -169,6 +175,16 @@ public class OtelRuntimeConfigRenderer {
|
||||
yaml.append("""
|
||||
service:
|
||||
telemetry:
|
||||
resource:
|
||||
attributes:
|
||||
- name: service.name
|
||||
value: hertzbeat-otel-runtime
|
||||
- name: service.namespace
|
||||
value: hertzbeat
|
||||
- name: hertzbeat.collector.id
|
||||
value: ${env:HERTZBEAT_COLLECTOR_ID}
|
||||
- name: hertzbeat.workspace_id
|
||||
value: ${env:HERTZBEAT_WORKSPACE_ID}
|
||||
metrics:
|
||||
level: basic
|
||||
readers:
|
||||
@@ -179,6 +195,18 @@ public class OtelRuntimeConfigRenderer {
|
||||
port: %d
|
||||
without_type_suffix: true
|
||||
without_units: true
|
||||
- periodic:
|
||||
interval: 10000
|
||||
timeout: %d
|
||||
exporter:
|
||||
otlp:
|
||||
protocol: http/protobuf
|
||||
endpoint: ${env:HERTZBEAT_OTLP_HTTP_ENDPOINT}/v1/metrics
|
||||
headers:
|
||||
- name: Authorization
|
||||
value: Bearer ${env:HERTZBEAT_OTLP_TOKEN}
|
||||
compression: gzip
|
||||
timeout: %d
|
||||
extensions: [%s]
|
||||
pipelines:
|
||||
metrics:
|
||||
@@ -195,6 +223,8 @@ public class OtelRuntimeConfigRenderer {
|
||||
exporters: [otlphttp]
|
||||
""".formatted(
|
||||
properties.getInternalTelemetryPort(),
|
||||
exporterTimeoutMillis,
|
||||
exporterTimeoutMillis,
|
||||
gateway.enabled() ? "health_check, file_storage, bearertokenauth" : "health_check, file_storage",
|
||||
metricsReceivers(desiredConfig.hostMetricsEnabled(), sources.prometheusTargets()),
|
||||
commonProcessors,
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.runtime.otel;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Validates and canonicalizes the shared OTLP/HTTP export base endpoint.
|
||||
*/
|
||||
final class OtelRuntimeExportEndpointPolicy {
|
||||
|
||||
private OtelRuntimeExportEndpointPolicy() {
|
||||
}
|
||||
|
||||
static String canonicalize(URI endpoint) {
|
||||
if (endpoint == null || endpoint.isOpaque() || endpoint.getHost() == null
|
||||
|| endpoint.getRawUserInfo() != null || endpoint.getPort() > 65535) {
|
||||
throw new IllegalArgumentException("OTLP HTTP export endpoint must be an HTTP(S) server URI");
|
||||
}
|
||||
String scheme = endpoint.getScheme() == null ? "" : endpoint.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(scheme) && !"https".equals(scheme)) {
|
||||
throw new IllegalArgumentException("OTLP HTTP export endpoint must use HTTP or HTTPS");
|
||||
}
|
||||
if (endpoint.getRawQuery() != null || endpoint.getRawFragment() != null) {
|
||||
throw new IllegalArgumentException("OTLP HTTP export endpoint must not contain a query or fragment");
|
||||
}
|
||||
String path = endpoint.getRawPath() == null ? "" : endpoint.getRawPath();
|
||||
while (path.endsWith("/")) {
|
||||
path = path.substring(0, path.length() - 1);
|
||||
}
|
||||
return scheme + "://" + endpoint.getRawAuthority() + path;
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -53,9 +53,9 @@ final class OtelRuntimeGatewayPolicy {
|
||||
if (!gatewayEnabled && (!isLoopback(grpcEndpoint) || !isLoopback(httpEndpoint))) {
|
||||
throw new IllegalArgumentException("Non-loopback OTLP listeners require explicit Gateway mode");
|
||||
}
|
||||
Duration readTimeout = timeout(properties.getOtlpReadTimeout(), "read");
|
||||
Duration writeTimeout = timeout(properties.getOtlpWriteTimeout(), "write");
|
||||
Duration idleTimeout = timeout(properties.getOtlpIdleTimeout(), "idle");
|
||||
Duration readTimeout = boundedTimeout(properties.getOtlpReadTimeout(), "read");
|
||||
Duration writeTimeout = boundedTimeout(properties.getOtlpWriteTimeout(), "write");
|
||||
Duration idleTimeout = boundedTimeout(properties.getOtlpIdleTimeout(), "idle");
|
||||
if (!gatewayEnabled) {
|
||||
return new ResolvedGateway(grpcEndpoint, httpEndpoint, false,
|
||||
readTimeout, writeTimeout, idleTimeout, null, null, null, null);
|
||||
@@ -129,7 +129,7 @@ final class OtelRuntimeGatewayPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration timeout(Duration value, String label) {
|
||||
static Duration boundedTimeout(Duration value, String label) {
|
||||
if (value == null || value.compareTo(MINIMUM_TIMEOUT) < 0 || value.compareTo(MAXIMUM_TIMEOUT) > 0
|
||||
|| value.getNano() != 0) {
|
||||
throw new IllegalArgumentException("OTLP " + label + " timeout must be a whole second between 1s and 5m");
|
||||
|
||||
+2
@@ -48,6 +48,8 @@ public class OtelRuntimeProperties {
|
||||
|
||||
private URI exportEndpoint = URI.create("http://127.0.0.1:1157/api/otlp");
|
||||
|
||||
private Duration otlpHttpExporterTimeout = Duration.ofSeconds(5);
|
||||
|
||||
private String token = "";
|
||||
|
||||
private String collectorId = "";
|
||||
|
||||
+2
-1
@@ -317,7 +317,8 @@ public class OtelRuntimeSupervisor implements SmartLifecycle, AutoCloseable, Col
|
||||
Map<String, String> environment = new HashMap<>();
|
||||
environment.put("HERTZBEAT_COLLECTOR_ID", properties.getCollectorId());
|
||||
environment.put("HERTZBEAT_WORKSPACE_ID", properties.getWorkspaceId());
|
||||
environment.put("HERTZBEAT_OTLP_HTTP_ENDPOINT", properties.getExportEndpoint().toString());
|
||||
environment.put("HERTZBEAT_OTLP_HTTP_ENDPOINT",
|
||||
OtelRuntimeExportEndpointPolicy.canonicalize(properties.getExportEndpoint()));
|
||||
environment.put("HERTZBEAT_OTLP_TOKEN", properties.getToken());
|
||||
environment.put("HERTZBEAT_OTEL_HEALTH_PORT", Integer.toString(properties.getHealthPort()));
|
||||
environment.put("HERTZBEAT_OTEL_FILE_STORAGE_DIR", OtelRuntimeConfigRenderer.resolve(
|
||||
|
||||
+6
-1
@@ -34,7 +34,12 @@ import org.apache.hertzbeat.common.entity.dto.ManagedOtelRuntimeStatus.RuntimeTe
|
||||
import org.apache.hertzbeat.common.entity.dto.ManagedOtelRuntimeStatus.SignalCounters;
|
||||
import org.apache.hertzbeat.common.entity.dto.ManagedOtelRuntimeStatus.SignalGauges;
|
||||
|
||||
/** Reads the bounded loopback Prometheus view of the official Runtime's internal telemetry. */
|
||||
/**
|
||||
* Reads the bounded loopback Prometheus view for Java heartbeat/status observation.
|
||||
*
|
||||
* <p>This local scrape does not forward telemetry. The Runtime's official periodic OTLP reader performs direct
|
||||
* internal-metrics export independently.</p>
|
||||
*/
|
||||
public class OtelRuntimeTelemetryClient {
|
||||
|
||||
private static final int MAXIMUM_RESPONSE_BYTES = 1024 * 1024;
|
||||
|
||||
@@ -69,6 +69,7 @@ collector:
|
||||
host-metrics-interval: ${HERTZBEAT_OTEL_HOST_METRICS_INTERVAL:10s}
|
||||
file-storage-directory: ${HERTZBEAT_OTEL_FILE_STORAGE_DIRECTORY:data/otel-runtime}
|
||||
export-endpoint: ${HERTZBEAT_OTLP_HTTP_ENDPOINT:http://127.0.0.1:1157/api/otlp}
|
||||
otlp-http-exporter-timeout: ${HERTZBEAT_OTLP_HTTP_EXPORTER_TIMEOUT:5s}
|
||||
token: ${HERTZBEAT_OTLP_TOKEN:}
|
||||
otlp-grpc-endpoint: ${HERTZBEAT_OTLP_GRPC_LISTEN_ENDPOINT:127.0.0.1:4317}
|
||||
otlp-http-endpoint: ${HERTZBEAT_OTLP_HTTP_LISTEN_ENDPOINT:127.0.0.1:4318}
|
||||
|
||||
+55
@@ -94,6 +94,7 @@ class OtelRuntimeConfigRendererTest {
|
||||
assertTrue(yaml.contains("initial_interval: 1s"));
|
||||
assertTrue(yaml.contains("max_interval: 30s"));
|
||||
assertTrue(yaml.contains("max_elapsed_time: 0s"));
|
||||
assertTrue(yaml.contains("compression: gzip\n timeout: 5s\n retry_on_failure:"));
|
||||
assertTrue(yaml.contains(" file_storage:\n directory: ${env:HERTZBEAT_OTEL_FILE_STORAGE_DIR}"));
|
||||
assertTrue(yaml.contains("timeout: 1s"));
|
||||
assertTrue(yaml.contains("max_size: 67108864"));
|
||||
@@ -130,6 +131,60 @@ class OtelRuntimeConfigRendererTest {
|
||||
assertThrows(IllegalArgumentException.class, () -> new OtelRuntimeConfigRenderer().render(properties));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersOnlyBoundedOtlpHttpExporterRequestTimeout() throws Exception {
|
||||
OtelRuntimeProperties properties = new OtelRuntimeProperties();
|
||||
properties.setHome(tempDir);
|
||||
properties.setConfig(Path.of("conf/runtime.yaml"));
|
||||
properties.setOtlpHttpExporterTimeout(Duration.ofSeconds(12));
|
||||
|
||||
String yaml = Files.readString(new OtelRuntimeConfigRenderer().render(properties));
|
||||
|
||||
assertTrue(yaml.contains("compression: gzip\n timeout: 12s\n retry_on_failure:"));
|
||||
|
||||
properties.setOtlpHttpExporterTimeout(Duration.ZERO);
|
||||
assertThrows(IllegalArgumentException.class, () -> new OtelRuntimeConfigRenderer().render(properties));
|
||||
properties.setOtlpHttpExporterTimeout(Duration.ofMillis(1500));
|
||||
assertThrows(IllegalArgumentException.class, () -> new OtelRuntimeConfigRenderer().render(properties));
|
||||
properties.setOtlpHttpExporterTimeout(Duration.ofMinutes(5).plusSeconds(1));
|
||||
assertThrows(IllegalArgumentException.class, () -> new OtelRuntimeConfigRenderer().render(properties));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersDirectAuthenticatedInternalMetricsOtlpReaderWithoutEmbeddingSecrets() throws Exception {
|
||||
OtelRuntimeProperties properties = new OtelRuntimeProperties();
|
||||
properties.setHome(tempDir);
|
||||
properties.setConfig(Path.of("conf/runtime.yaml"));
|
||||
properties.setToken("internal-metrics-secret-must-stay-in-environment");
|
||||
properties.setOtlpHttpExporterTimeout(Duration.ofSeconds(12));
|
||||
|
||||
String yaml = Files.readString(new OtelRuntimeConfigRenderer().render(properties));
|
||||
|
||||
assertTrue(yaml.contains(" resource:\n"
|
||||
+ " attributes:\n"
|
||||
+ " - name: service.name\n"
|
||||
+ " value: hertzbeat-otel-runtime\n"
|
||||
+ " - name: service.namespace\n"
|
||||
+ " value: hertzbeat\n"
|
||||
+ " - name: hertzbeat.collector.id\n"
|
||||
+ " value: ${env:HERTZBEAT_COLLECTOR_ID}\n"
|
||||
+ " - name: hertzbeat.workspace_id\n"
|
||||
+ " value: ${env:HERTZBEAT_WORKSPACE_ID}\n"));
|
||||
assertTrue(yaml.contains(" - periodic:\n"
|
||||
+ " interval: 10000\n"
|
||||
+ " timeout: 12000\n"
|
||||
+ " exporter:\n"
|
||||
+ " otlp:\n"
|
||||
+ " protocol: http/protobuf\n"
|
||||
+ " endpoint: ${env:HERTZBEAT_OTLP_HTTP_ENDPOINT}/v1/metrics\n"
|
||||
+ " headers:\n"
|
||||
+ " - name: Authorization\n"
|
||||
+ " value: Bearer ${env:HERTZBEAT_OTLP_TOKEN}\n"
|
||||
+ " compression: gzip\n"
|
||||
+ " timeout: 12000\n"));
|
||||
assertFalse(yaml.contains(properties.getToken()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersExplicitEnvironmentCloudDetectionAndFixedNoisePreset() throws Exception {
|
||||
OtelRuntimeProperties properties = new OtelRuntimeProperties();
|
||||
|
||||
+4
@@ -38,6 +38,8 @@ class OtelRuntimeConfigurationTest {
|
||||
assertTrue(context.isRunning());
|
||||
OtelRuntimeSupervisor supervisor = context.getBean(OtelRuntimeSupervisor.class);
|
||||
assertEquals(OtelRuntimeState.STOPPED, supervisor.snapshot().state());
|
||||
assertEquals(Duration.ofSeconds(5),
|
||||
context.getBean(OtelRuntimeProperties.class).getOtlpHttpExporterTimeout());
|
||||
assertTrue(context.getBean(CollectorRuntimeStatusProvider.class) instanceof OtelRuntimeStatusProvider);
|
||||
});
|
||||
}
|
||||
@@ -103,6 +105,7 @@ class OtelRuntimeConfigurationTest {
|
||||
"collector.otel-runtime.otlp-grpc-endpoint=0.0.0.0:4317",
|
||||
"collector.otel-runtime.otlp-http-endpoint=0.0.0.0:4318",
|
||||
"collector.otel-runtime.otlp-max-request-mi-b=8",
|
||||
"collector.otel-runtime.otlp-http-exporter-timeout=12s",
|
||||
"collector.otel-runtime.otlp-read-timeout=20s",
|
||||
"collector.otel-runtime.otlp-write-timeout=25s",
|
||||
"collector.otel-runtime.otlp-idle-timeout=45s",
|
||||
@@ -120,6 +123,7 @@ class OtelRuntimeConfigurationTest {
|
||||
assertTrue(properties.isOtlpGatewayEnabled());
|
||||
assertEquals("0.0.0.0:4317", properties.getOtlpGrpcEndpoint());
|
||||
assertEquals(8, properties.getOtlpMaxRequestMiB());
|
||||
assertEquals(Duration.ofSeconds(12), properties.getOtlpHttpExporterTimeout());
|
||||
assertEquals(Duration.ofSeconds(20), properties.getOtlpReadTimeout());
|
||||
assertEquals(512, properties.getRuntimeMemoryLimitMiB());
|
||||
assertEquals(128, properties.getRuntimeMemorySpikeLimitMiB());
|
||||
|
||||
+27
@@ -18,6 +18,7 @@
|
||||
package org.apache.hertzbeat.collector.runtime.otel;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.ManagedChannel;
|
||||
@@ -74,6 +75,12 @@ class OtelRuntimeProtocolIntegrationTest {
|
||||
supervisor.start();
|
||||
assertEquals(OtelRuntimeState.RUNNING, supervisor.snapshot().state());
|
||||
|
||||
// Prove direct official service.telemetry export before Java performs any loopback status scrape.
|
||||
OtelRuntimeTestSupport.await(
|
||||
() -> capture.bodies("metrics").stream().anyMatch(this::isRuntimeInternalMetricRequest),
|
||||
Duration.ofSeconds(20));
|
||||
assertTrue(capture.hasAuthorization("metrics", "Bearer runtime-direct-token"));
|
||||
|
||||
sendJsonSignals(properties.getOtlpHttpEndpoint());
|
||||
|
||||
SignalRequests protobuf = signalRequests("http-protobuf", 0x31);
|
||||
@@ -130,6 +137,26 @@ class OtelRuntimeProtocolIntegrationTest {
|
||||
return observed.state() == ManagedOtelRuntimeStatus.ValueState.AVAILABLE && observed.value() > 0;
|
||||
}
|
||||
|
||||
private boolean isRuntimeInternalMetricRequest(byte[] body) {
|
||||
try {
|
||||
ExportMetricsServiceRequest request = ExportMetricsServiceRequest.parseFrom(body);
|
||||
return request.getResourceMetricsList().stream().anyMatch(resourceMetrics -> {
|
||||
boolean stableIdentity = resourceMetrics.getResource().getAttributesList().stream()
|
||||
.anyMatch(attribute -> "service.name".equals(attribute.getKey())
|
||||
&& "hertzbeat-otel-runtime".equals(attribute.getValue().getStringValue()))
|
||||
&& resourceMetrics.getResource().getAttributesList().stream()
|
||||
.anyMatch(attribute -> "hertzbeat.collector.id".equals(attribute.getKey())
|
||||
&& "collector-protocol-integration".equals(attribute.getValue().getStringValue()));
|
||||
boolean internalMetric = resourceMetrics.getScopeMetricsList().stream()
|
||||
.flatMap(scopeMetrics -> scopeMetrics.getMetricsList().stream())
|
||||
.anyMatch(metric -> metric.getName().startsWith("otelcol_"));
|
||||
return stableIdentity && internalMetric;
|
||||
});
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendJsonSignals(String endpoint) throws Exception {
|
||||
long now = System.currentTimeMillis() * 1_000_000;
|
||||
sendJson(endpoint, "metrics", """
|
||||
|
||||
+16
-1
@@ -72,7 +72,7 @@ class OtelRuntimeSupervisorTest {
|
||||
properties.setCollectorId("collector-phase0");
|
||||
properties.setWorkspaceId("workspace-phase0");
|
||||
properties.setToken("token-phase0");
|
||||
properties.setExportEndpoint(URI.create("http://127.0.0.1:1157/api/otlp"));
|
||||
properties.setExportEndpoint(URI.create("http://127.0.0.1:1157/api/otlp/"));
|
||||
properties.setRestartDelay(Duration.ZERO);
|
||||
properties.setStartupTimeout(Duration.ofMillis(200));
|
||||
properties.setHealthTimeout(Duration.ofMillis(50));
|
||||
@@ -118,6 +118,8 @@ class OtelRuntimeSupervisorTest {
|
||||
assertEquals("collector-phase0", environment.getValue().get("HERTZBEAT_COLLECTOR_ID"));
|
||||
assertEquals("workspace-phase0", environment.getValue().get("HERTZBEAT_WORKSPACE_ID"));
|
||||
assertEquals("token-phase0", environment.getValue().get("HERTZBEAT_OTLP_TOKEN"));
|
||||
assertEquals("http://127.0.0.1:1157/api/otlp",
|
||||
environment.getValue().get("HERTZBEAT_OTLP_HTTP_ENDPOINT"));
|
||||
assertEquals(tempDir.resolve("data/otel-runtime").toString(),
|
||||
environment.getValue().get("HERTZBEAT_OTEL_FILE_STORAGE_DIR"));
|
||||
InOrder activationOrder = inOrder(launcher, configTransaction);
|
||||
@@ -206,6 +208,19 @@ class OtelRuntimeSupervisorTest {
|
||||
verify(launcher, never()).start(any(), any(), any(), any(), anyMap(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsExportEndpointQueryOrFragmentBeforeLaunchingRuntime() throws Exception {
|
||||
properties.setExportEndpoint(URI.create("http://127.0.0.1:1157/api/otlp?tenant=unsafe#fragment"));
|
||||
properties.setRestartDelay(Duration.ofHours(1));
|
||||
supervisor = new OtelRuntimeSupervisor(properties, resolver, configTransaction, launcher, healthClient);
|
||||
|
||||
supervisor.start();
|
||||
|
||||
assertEquals(OtelRuntimeState.DEGRADED, supervisor.snapshot().state());
|
||||
assertTrue(supervisor.snapshot().lastError().contains("query or fragment"));
|
||||
verify(launcher, never()).start(any(), any(), any(), any(), anyMap(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactsCredentialsBeforeRecordingOrLoggingSupervisorFailure(CapturedOutput output) throws Exception {
|
||||
properties.setRestartDelay(Duration.ofHours(1));
|
||||
|
||||
+11
-2
@@ -156,6 +156,12 @@ final class OtelRuntimeTestSupport {
|
||||
.toList();
|
||||
}
|
||||
|
||||
boolean hasAuthorization(String signal, String authorization) {
|
||||
String path = "/api/otlp/v1/" + signal;
|
||||
return requests.stream().anyMatch(request -> path.equals(request.path())
|
||||
&& authorization.equals(request.authorization()));
|
||||
}
|
||||
|
||||
private void capture(HttpExchange exchange) throws IOException {
|
||||
try (exchange) {
|
||||
byte[] request = exchange.getRequestBody().readAllBytes();
|
||||
@@ -167,7 +173,10 @@ final class OtelRuntimeTestSupport {
|
||||
}
|
||||
if (retainPayloads) {
|
||||
payloads.add(new String(request, StandardCharsets.ISO_8859_1));
|
||||
requests.add(new CapturedRequest(exchange.getRequestURI().getPath(), request.clone()));
|
||||
requests.add(new CapturedRequest(
|
||||
exchange.getRequestURI().getPath(),
|
||||
request.clone(),
|
||||
exchange.getRequestHeaders().getFirst("Authorization")));
|
||||
}
|
||||
exchange.sendResponseHeaders(200, -1);
|
||||
}
|
||||
@@ -180,7 +189,7 @@ final class OtelRuntimeTestSupport {
|
||||
}
|
||||
}
|
||||
|
||||
private record CapturedRequest(String path, byte[] body) {
|
||||
private record CapturedRequest(String path, byte[] body, String authorization) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -177,6 +177,11 @@ public interface CommonConstants {
|
||||
*/
|
||||
String TRACE_ALERT_THRESHOLD_TYPE_PERIODIC = "periodic_trace";
|
||||
|
||||
/**
|
||||
* Maximum number of rows or series returned by an alert rule preview.
|
||||
*/
|
||||
int ALERT_PREVIEW_RESULT_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Alert mode label key
|
||||
*/
|
||||
|
||||
+5
@@ -74,6 +74,11 @@ public interface ExportFileConstants {
|
||||
* Export file suffix.
|
||||
*/
|
||||
String FILE_SUFFIX = ".yaml";
|
||||
|
||||
/**
|
||||
* Alternative YAML file suffix.
|
||||
*/
|
||||
String FILE_SHORT_SUFFIX = ".yml";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -247,4 +247,21 @@ public final class JsonUtil {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value without logging conversion details.
|
||||
* @param fromValue source value
|
||||
* @param toValueType target type
|
||||
* @return converted value or null if conversion fails
|
||||
*/
|
||||
public static <T> T convertValueQuietly(Object fromValue, Class<T> toValueType) {
|
||||
if (fromValue == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.convertValue(fromValue, toValueType);
|
||||
} catch (RuntimeException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -27,6 +27,7 @@ import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -60,6 +61,10 @@ public class GeneralConfig {
|
||||
@Column(length = 8192)
|
||||
private String content;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "config_revision", nullable = false, length = 36)
|
||||
private String revision = UUID.randomUUID().toString();
|
||||
|
||||
@Schema(title = "The creator of this record", example = "tom", accessMode = READ_ONLY)
|
||||
@CreatedBy
|
||||
private String creator;
|
||||
|
||||
+15
@@ -25,6 +25,7 @@ import org.apache.commons.lang3.StringUtils;
|
||||
public final class AuthTokenRequestContext {
|
||||
|
||||
private static final ThreadLocal<String> WORKSPACE_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> AUTHENTICATED_WORKSPACE_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> COLLECTOR_ID = new ThreadLocal<>();
|
||||
|
||||
private AuthTokenRequestContext() {
|
||||
@@ -43,6 +44,19 @@ public final class AuthTokenRequestContext {
|
||||
return WORKSPACE_ID.get();
|
||||
}
|
||||
|
||||
public static void bindAuthenticatedWorkspaceId(String workspaceId) {
|
||||
String normalized = StringUtils.trimToNull(workspaceId);
|
||||
if (normalized == null) {
|
||||
AUTHENTICATED_WORKSPACE_ID.remove();
|
||||
return;
|
||||
}
|
||||
AUTHENTICATED_WORKSPACE_ID.set(AuthTokenScopes.normalizeWorkspaceId(normalized));
|
||||
}
|
||||
|
||||
public static String currentAuthenticatedWorkspaceId() {
|
||||
return AUTHENTICATED_WORKSPACE_ID.get();
|
||||
}
|
||||
|
||||
public static void bindCollectorId(String collectorId) {
|
||||
String normalized = StringUtils.trimToNull(collectorId);
|
||||
if (normalized == null) {
|
||||
@@ -58,6 +72,7 @@ public final class AuthTokenRequestContext {
|
||||
|
||||
public static void clear() {
|
||||
WORKSPACE_ID.remove();
|
||||
AUTHENTICATED_WORKSPACE_ID.remove();
|
||||
COLLECTOR_ID.remove();
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -17,6 +17,7 @@
|
||||
|
||||
package org.apache.hertzbeat.common.util;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.common.constants.ExportFileConstants;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -37,6 +38,7 @@ public final class FileUtil {
|
||||
fileTypes = Map.of(
|
||||
ExportFileConstants.JsonFile.FILE_SUFFIX, ExportFileConstants.JsonFile.TYPE,
|
||||
ExportFileConstants.YamlFile.FILE_SUFFIX, ExportFileConstants.YamlFile.TYPE,
|
||||
ExportFileConstants.YamlFile.FILE_SHORT_SUFFIX, ExportFileConstants.YamlFile.TYPE,
|
||||
ExportFileConstants.ExcelFile.FILE_SUFFIX, ExportFileConstants.ExcelFile.TYPE
|
||||
);
|
||||
}
|
||||
@@ -72,7 +74,7 @@ public final class FileUtil {
|
||||
if (dotIndex == -1 || dotIndex == fileName.length() - 1) {
|
||||
return "";
|
||||
}
|
||||
var fileNameExtension = fileName.substring(dotIndex);
|
||||
var fileNameExtension = fileName.substring(dotIndex).toLowerCase(Locale.ROOT);
|
||||
|
||||
return fileTypes.get(fileNameExtension);
|
||||
}
|
||||
|
||||
+20
@@ -18,6 +18,7 @@
|
||||
package org.apache.hertzbeat.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import org.apache.hertzbeat.common.constants.ExportFileConstants;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -70,4 +71,23 @@ class FileUtilTest {
|
||||
assertEquals("", FileUtil.getFileType(emptyFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFileTypeSupportsYamlAliasAndCaseInsensitiveKnownExtensions() {
|
||||
assertEquals(ExportFileConstants.YamlFile.TYPE, FileUtil.getFileType(file("test.yml")));
|
||||
assertEquals(ExportFileConstants.YamlFile.TYPE, FileUtil.getFileType(file("test.YAML")));
|
||||
assertEquals(ExportFileConstants.JsonFile.TYPE, FileUtil.getFileType(file("test.JSON")));
|
||||
assertEquals(ExportFileConstants.ExcelFile.TYPE, FileUtil.getFileType(file("test.XLSX")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFileTypeRejectsUnknownOrDisguisedExtensions() {
|
||||
assertNull(FileUtil.getFileType(file("test.xls")));
|
||||
assertNull(FileUtil.getFileType(file("test.yaml.txt")));
|
||||
assertEquals("", FileUtil.getFileType(file("test")));
|
||||
}
|
||||
|
||||
private static MockMultipartFile file(String originalFilename) {
|
||||
return new MockMultipartFile("file", originalFilename, null, new byte[0]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* 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.observability.storage;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.common.constants.CommonConstants;
|
||||
import org.apache.hertzbeat.common.entity.manager.Collector;
|
||||
import org.apache.hertzbeat.manager.dao.CollectorDao;
|
||||
import org.apache.hertzbeat.manager.instrumentation.intake.CollectorIntakeAdvertisementCodec;
|
||||
import org.apache.hertzbeat.manager.instrumentation.intake.CollectorIntakeAdvertisementRequest;
|
||||
import org.apache.hertzbeat.manager.pojo.dto.CollectorInstrumentationIntake.Capability;
|
||||
import org.apache.hertzbeat.manager.pojo.dto.CollectorInstrumentationIntake.Gateway;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
/**
|
||||
* Proves the authenticated public HTTP boundary can ingest, detect, and query all three signals in Greptime.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = org.apache.hertzbeat.startup.HertzBeatApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {
|
||||
"hertzbeat.otlp.grpc.enabled=false",
|
||||
"otel.sdk.disabled=true",
|
||||
"scheduler.server.enabled=false",
|
||||
"spring.datasource.url=jdbc:h2:mem:hertzbeat-authenticated-greptime-e2e;MODE=MYSQL;DB_CLOSE_DELAY=-1",
|
||||
"warehouse.store.duckdb.enabled=false",
|
||||
"warehouse.store.greptime.enabled=true",
|
||||
"warehouse.store.greptime.username=",
|
||||
"warehouse.store.greptime.password="
|
||||
})
|
||||
@Testcontainers
|
||||
class AuthenticatedGreptimeThreeSignalPublicApiE2eTest extends GreptimeThreeSignalE2eSupport {
|
||||
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(20);
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(REQUEST_TIMEOUT)
|
||||
.build();
|
||||
|
||||
@LocalServerPort
|
||||
private int serverPort;
|
||||
|
||||
@Autowired
|
||||
private CollectorDao collectorDao;
|
||||
|
||||
@Test
|
||||
void authenticatedPublicApiIngestsDetectsAndQueriesThreeSignalsInGreptime() throws Exception {
|
||||
advertiseCollectorProfile();
|
||||
long startedAt = System.currentTimeMillis() - 1_000;
|
||||
long signalTimeNanos = System.currentTimeMillis() * 1_000_000L;
|
||||
byte[] detectionBody = detectionBody(startedAt);
|
||||
|
||||
assertUnauthenticatedRequestsAreRejected(detectionBody);
|
||||
String token = login();
|
||||
|
||||
postSignal("metrics", metrics(signalTimeNanos).toByteArray(), token);
|
||||
postSignal("logs", logs(signalTimeNanos).toByteArray(), token);
|
||||
postSignal("traces", traces(signalTimeNanos).toByteArray(), token);
|
||||
|
||||
JsonNode queryContext = awaitReceivedDetection(detectionBody, token);
|
||||
awaitPublicQueries(queryContext, token);
|
||||
}
|
||||
|
||||
private void assertUnauthenticatedRequestsAreRejected(byte[] detectionBody) throws Exception {
|
||||
HttpResponse<byte[]> ingest = send(postProtobuf("/api/otlp/v1/metrics", metrics(
|
||||
System.currentTimeMillis() * 1_000_000L).toByteArray(), null));
|
||||
assertThat(ingest.statusCode()).isEqualTo(401);
|
||||
|
||||
HttpResponse<byte[]> detection = send(postJson("/api/instrumentation/detect", detectionBody, null));
|
||||
assertThat(detection.statusCode()).isEqualTo(401);
|
||||
|
||||
HttpResponse<byte[]> query = send(get("/api/ingestion/otlp/metrics/console", null));
|
||||
assertThat(query.statusCode()).isEqualTo(401);
|
||||
}
|
||||
|
||||
private String login() throws Exception {
|
||||
byte[] body = OBJECT_MAPPER.writeValueAsBytes(Map.of(
|
||||
"type", 0,
|
||||
"identifier", "admin",
|
||||
"credential", "hertzbeat"));
|
||||
HttpResponse<byte[]> response = send(postJson("/api/account/auth/form", body, null));
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
JsonNode envelope = OBJECT_MAPPER.readTree(response.body());
|
||||
assertThat(envelope.path("code").asInt()).isZero();
|
||||
String token = envelope.path("data").path("token").asText();
|
||||
assertThat(token).isNotBlank();
|
||||
return token;
|
||||
}
|
||||
|
||||
private void postSignal(String signal, byte[] payload, String token) throws Exception {
|
||||
HttpResponse<byte[]> response = send(postProtobuf("/api/otlp/v1/" + signal, payload, token));
|
||||
assertThat(response.statusCode()).isBetween(200, 299);
|
||||
}
|
||||
|
||||
private JsonNode awaitReceivedDetection(byte[] detectionBody, String token) {
|
||||
JsonNode[] received = new JsonNode[1];
|
||||
await().atMost(Duration.ofSeconds(30)).pollInterval(Duration.ofSeconds(1)).untilAsserted(() -> {
|
||||
HttpResponse<byte[]> response = send(postJson("/api/instrumentation/detect", detectionBody, token));
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
JsonNode envelope = OBJECT_MAPPER.readTree(response.body());
|
||||
assertThat(envelope.path("code").asInt()).isZero();
|
||||
JsonNode data = envelope.path("data");
|
||||
assertThat(data.path("signals").path("metrics").path("status").asText()).isEqualTo("received");
|
||||
assertThat(data.path("signals").path("logs").path("status").asText()).isEqualTo("received");
|
||||
assertThat(data.path("signals").path("traces").path("status").asText()).isEqualTo("received");
|
||||
assertThat(data.path("queryJumps").isArray()).isTrue();
|
||||
assertThat(data.path("queryJumps").size()).isEqualTo(3);
|
||||
assertThat(data.path("queryJumps").findValuesAsText("enabled")).containsOnly("true");
|
||||
received[0] = data.path("queryJumpContext");
|
||||
});
|
||||
return received[0];
|
||||
}
|
||||
|
||||
private void awaitPublicQueries(JsonNode context, String token) {
|
||||
await().atMost(Duration.ofSeconds(30)).pollInterval(Duration.ofSeconds(1)).untilAsserted(() -> {
|
||||
assertMetricsQuery(context, token);
|
||||
assertLogsQuery(context, token);
|
||||
assertTracesQuery(context, token);
|
||||
});
|
||||
}
|
||||
|
||||
private void assertMetricsQuery(JsonNode context, String token) throws Exception {
|
||||
Map<String, String> parameters = commonQueryParameters(context);
|
||||
parameters.put("query", METRIC_QUERY);
|
||||
parameters.put("step", "1s");
|
||||
parameters.put("limit", "20");
|
||||
JsonNode data = authenticatedGet("/api/ingestion/otlp/metrics/console", parameters, token);
|
||||
assertThat(data.path("context").path("collectorId").asText()).isEqualTo(COLLECTOR_ID);
|
||||
assertThat(data.path("context").path("instance").asText()).isEqualTo(INSTANCE_ID);
|
||||
assertThat(data.path("context").path("endpoint").asText()).isEqualTo(ENDPOINT);
|
||||
assertThat(data.path("stats").path("nonEmptySeries").asInt()).isPositive();
|
||||
assertThat(data.path("results").path("frames").isArray()).isTrue();
|
||||
assertThat(data.path("results").path("frames").size()).isPositive();
|
||||
}
|
||||
|
||||
private void assertLogsQuery(JsonNode context, String token) throws Exception {
|
||||
Map<String, String> parameters = commonQueryParameters(context);
|
||||
parameters.put("traceId", TRACE_ID);
|
||||
parameters.put("spanId", SPAN_ID);
|
||||
parameters.put("severityText", "INFO");
|
||||
parameters.put("search", LOG_BODY);
|
||||
parameters.put("pageIndex", "0");
|
||||
parameters.put("pageSize", "20");
|
||||
JsonNode content = authenticatedGet("/api/logs/list", parameters, token).path("content");
|
||||
assertThat(content.isArray()).isTrue();
|
||||
assertThat(content.size()).isEqualTo(1);
|
||||
JsonNode log = content.get(0);
|
||||
assertThat(log.path("body").asText()).isEqualTo(LOG_BODY);
|
||||
assertThat(log.path("traceId").asText()).isEqualTo(TRACE_ID);
|
||||
assertThat(log.path("spanId").asText()).isEqualTo(SPAN_ID);
|
||||
assertThat(log.path("resource").path("hertzbeat.collector.id").asText()).isEqualTo(COLLECTOR_ID);
|
||||
assertThat(log.path("resource").path("service.instance.id").asText()).isEqualTo(INSTANCE_ID);
|
||||
assertThat(log.path("attributes").path("http.route").asText()).isEqualTo(ENDPOINT);
|
||||
}
|
||||
|
||||
private void assertTracesQuery(JsonNode context, String token) throws Exception {
|
||||
Map<String, String> parameters = commonQueryParameters(context);
|
||||
parameters.put("traceId", TRACE_ID);
|
||||
parameters.put("operationName", SPAN_NAME);
|
||||
parameters.put("spanScope", "root");
|
||||
parameters.put("pageIndex", "0");
|
||||
parameters.put("pageSize", "20");
|
||||
JsonNode content = authenticatedGet("/api/traces/list", parameters, token).path("content");
|
||||
assertThat(content.isArray()).isTrue();
|
||||
assertThat(content.size()).isEqualTo(1);
|
||||
JsonNode trace = content.get(0);
|
||||
assertThat(trace.path("traceId").asText()).isEqualTo(TRACE_ID);
|
||||
assertThat(trace.path("rootSpanId").asText()).isEqualTo(SPAN_ID);
|
||||
assertThat(trace.path("serviceName").asText()).isEqualTo(SERVICE_NAME);
|
||||
assertThat(trace.path("resourceAttributes").path("hertzbeat.collector.id").asText())
|
||||
.isEqualTo(COLLECTOR_ID);
|
||||
assertThat(trace.path("resourceAttributes").path("service.instance.id").asText())
|
||||
.isEqualTo(INSTANCE_ID);
|
||||
}
|
||||
|
||||
private JsonNode authenticatedGet(String path, Map<String, String> parameters, String token) throws Exception {
|
||||
HttpResponse<byte[]> response = send(get(path + queryString(parameters), token));
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
JsonNode envelope = OBJECT_MAPPER.readTree(response.body());
|
||||
assertThat(envelope.path("code").asInt()).isZero();
|
||||
return envelope.path("data");
|
||||
}
|
||||
|
||||
private Map<String, String> commonQueryParameters(JsonNode context) {
|
||||
Map<String, String> parameters = new LinkedHashMap<>();
|
||||
parameters.put("start", context.path("startedAt").asText());
|
||||
parameters.put("end", Long.toString(context.path("detectedAt").asLong() + 60_000));
|
||||
parameters.put("serviceName", context.path("serviceName").asText());
|
||||
parameters.put("serviceNamespace", context.path("serviceNamespace").asText());
|
||||
parameters.put("environment", context.path("environment").asText());
|
||||
parameters.put("collectorId", context.path("collectorId").asText());
|
||||
parameters.put("instance", context.path("serviceInstanceId").asText());
|
||||
parameters.put("endpoint", context.path("endpoint").asText());
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private byte[] detectionBody(long startedAt) throws Exception {
|
||||
Map<String, Object> service = Map.of(
|
||||
"name", SERVICE_NAME,
|
||||
"namespace", SERVICE_NAMESPACE,
|
||||
"environment", ENVIRONMENT,
|
||||
"serviceInstanceId", INSTANCE_ID,
|
||||
"endpoint", ENDPOINT);
|
||||
Map<String, Object> request = new LinkedHashMap<>();
|
||||
request.put("schemaVersion", 2);
|
||||
request.put("sourceKind", "quick_start");
|
||||
request.put("recipeId", "opentelemetry_telemetrygen");
|
||||
request.put("environment", "vm");
|
||||
request.put("platform", "linux_amd64");
|
||||
request.put("service", service);
|
||||
request.put("intakeProfileId", "collector:" + COLLECTOR_ID);
|
||||
request.put("startedAt", startedAt);
|
||||
return OBJECT_MAPPER.writeValueAsBytes(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collector persistence is deterministic test setup only. All behavior under proof starts at the public HTTP
|
||||
* boundary; no ingestion, detection, or query service is invoked directly.
|
||||
*/
|
||||
private void advertiseCollectorProfile() {
|
||||
String advertisement = new CollectorIntakeAdvertisementCodec().encode(
|
||||
new CollectorIntakeAdvertisementRequest(
|
||||
1,
|
||||
Gateway.COLLECTOR,
|
||||
java.util.List.of(Capability.OTLP_HTTP_PROTOBUF),
|
||||
"http://127.0.0.1:4318",
|
||||
null));
|
||||
collectorDao.save(Collector.builder()
|
||||
.name(COLLECTOR_ID)
|
||||
.ip("127.0.0.1")
|
||||
.status(CommonConstants.COLLECTOR_STATUS_ONLINE)
|
||||
.instrumentationIntake(advertisement)
|
||||
.build());
|
||||
}
|
||||
|
||||
private HttpRequest postProtobuf(String path, byte[] body, String token) {
|
||||
HttpRequest.Builder builder = request(path)
|
||||
.header("Content-Type", "application/x-protobuf")
|
||||
.header("Accept", "application/x-protobuf")
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return authorize(builder, token).build();
|
||||
}
|
||||
|
||||
private HttpRequest postJson(String path, byte[] body, String token) {
|
||||
HttpRequest.Builder builder = request(path)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return authorize(builder, token).build();
|
||||
}
|
||||
|
||||
private HttpRequest get(String path, String token) {
|
||||
return authorize(request(path).header("Accept", "application/json").GET(), token).build();
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request(String path) {
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + serverPort + path))
|
||||
.timeout(REQUEST_TIMEOUT);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder authorize(HttpRequest.Builder builder, String token) {
|
||||
if (token != null) {
|
||||
builder.header("Authorization", "Bearer " + token);
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
|
||||
private HttpResponse<byte[]> send(HttpRequest request) throws Exception {
|
||||
return httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
}
|
||||
|
||||
private String queryString(Map<String, String> parameters) {
|
||||
StringBuilder query = new StringBuilder("?");
|
||||
parameters.forEach((key, value) -> {
|
||||
if (query.length() > 1) {
|
||||
query.append('&');
|
||||
}
|
||||
query.append(URLEncoder.encode(key, StandardCharsets.UTF_8))
|
||||
.append('=')
|
||||
.append(URLEncoder.encode(value, StandardCharsets.UTF_8));
|
||||
});
|
||||
return query.toString();
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* 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.observability.storage;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest;
|
||||
import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest;
|
||||
import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest;
|
||||
import io.opentelemetry.proto.common.v1.AnyValue;
|
||||
import io.opentelemetry.proto.common.v1.KeyValue;
|
||||
import io.opentelemetry.proto.logs.v1.LogRecord;
|
||||
import io.opentelemetry.proto.logs.v1.ResourceLogs;
|
||||
import io.opentelemetry.proto.logs.v1.ScopeLogs;
|
||||
import io.opentelemetry.proto.metrics.v1.Gauge;
|
||||
import io.opentelemetry.proto.metrics.v1.Metric;
|
||||
import io.opentelemetry.proto.metrics.v1.NumberDataPoint;
|
||||
import io.opentelemetry.proto.metrics.v1.ResourceMetrics;
|
||||
import io.opentelemetry.proto.metrics.v1.ScopeMetrics;
|
||||
import io.opentelemetry.proto.resource.v1.Resource;
|
||||
import io.opentelemetry.proto.trace.v1.ResourceSpans;
|
||||
import io.opentelemetry.proto.trace.v1.ScopeSpans;
|
||||
import io.opentelemetry.proto.trace.v1.Span;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.containers.wait.strategy.Wait;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
/** Shared real-Greptime container and deterministic OTLP payloads for three-signal E2E tests. */
|
||||
abstract class GreptimeThreeSignalE2eSupport {
|
||||
|
||||
static final String SERVICE_NAME = "checkout-api";
|
||||
static final String SERVICE_NAMESPACE = "commerce";
|
||||
static final String ENVIRONMENT = "proof";
|
||||
static final String COLLECTOR_ID = "collector-e2e";
|
||||
static final String SERVER_PROFILE_ID = "server-e2e";
|
||||
static final String INSTANCE_ID = "checkout-e2e-7d9";
|
||||
static final String ENDPOINT = "/checkout";
|
||||
static final String TRACE_ID = "0123456789abcdef0123456789abcdef";
|
||||
static final String SPAN_ID = "0123456789abcdef";
|
||||
static final String METRIC_NAME = "hertzbeat.e2e.requests";
|
||||
static final String METRIC_QUERY = "hertzbeat_e2e_requests";
|
||||
static final String LOG_BODY = "three-signal-e2e";
|
||||
static final String SPAN_NAME = "GET /checkout";
|
||||
|
||||
private static final int GREPTIME_HTTP_PORT = 4000;
|
||||
private static final int GREPTIME_GRPC_PORT = 4001;
|
||||
|
||||
@Container
|
||||
@SuppressWarnings("resource")
|
||||
static final GenericContainer<?> GREPTIME = new GenericContainer<>(
|
||||
DockerImageName.parse("greptime/greptimedb:v1.0.1"))
|
||||
.withExposedPorts(GREPTIME_HTTP_PORT, GREPTIME_GRPC_PORT)
|
||||
.withCommand("standalone", "start",
|
||||
"--http-addr", "0.0.0.0:" + GREPTIME_HTTP_PORT,
|
||||
"--rpc-bind-addr", "0.0.0.0:" + GREPTIME_GRPC_PORT)
|
||||
.waitingFor(Wait.forListeningPorts(GREPTIME_HTTP_PORT, GREPTIME_GRPC_PORT))
|
||||
.withStartupTimeout(Duration.ofSeconds(120));
|
||||
|
||||
@DynamicPropertySource
|
||||
static void greptimeProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("warehouse.store.greptime.http-endpoint", () -> "http://" + GREPTIME.getHost()
|
||||
+ ":" + GREPTIME.getMappedPort(GREPTIME_HTTP_PORT));
|
||||
registry.add("warehouse.store.greptime.grpc-endpoints", () -> GREPTIME.getHost()
|
||||
+ ":" + GREPTIME.getMappedPort(GREPTIME_GRPC_PORT));
|
||||
}
|
||||
|
||||
static ExportMetricsServiceRequest metrics(long timeNanos) {
|
||||
NumberDataPoint point = NumberDataPoint.newBuilder()
|
||||
.setTimeUnixNano(timeNanos)
|
||||
.setAsInt(1)
|
||||
.addAttributes(attribute("http.route", ENDPOINT))
|
||||
.build();
|
||||
Metric metric = Metric.newBuilder()
|
||||
.setName(METRIC_NAME)
|
||||
.setGauge(Gauge.newBuilder().addDataPoints(point))
|
||||
.build();
|
||||
return ExportMetricsServiceRequest.newBuilder()
|
||||
.addResourceMetrics(ResourceMetrics.newBuilder()
|
||||
.setResource(resource())
|
||||
.addScopeMetrics(ScopeMetrics.newBuilder().addMetrics(metric)))
|
||||
.build();
|
||||
}
|
||||
|
||||
static ExportLogsServiceRequest logs(long timeNanos) {
|
||||
LogRecord record = LogRecord.newBuilder()
|
||||
.setTimeUnixNano(timeNanos)
|
||||
.setObservedTimeUnixNano(timeNanos)
|
||||
.setSeverityText("INFO")
|
||||
.setBody(AnyValue.newBuilder().setStringValue(LOG_BODY))
|
||||
.setTraceId(ByteString.copyFrom(HexFormat.of().parseHex(TRACE_ID)))
|
||||
.setSpanId(ByteString.copyFrom(HexFormat.of().parseHex(SPAN_ID)))
|
||||
.addAttributes(attribute("http.route", ENDPOINT))
|
||||
.build();
|
||||
return ExportLogsServiceRequest.newBuilder()
|
||||
.addResourceLogs(ResourceLogs.newBuilder()
|
||||
.setResource(resource())
|
||||
.addScopeLogs(ScopeLogs.newBuilder().addLogRecords(record)))
|
||||
.build();
|
||||
}
|
||||
|
||||
static ExportTraceServiceRequest traces(long timeNanos) {
|
||||
Span span = Span.newBuilder()
|
||||
.setTraceId(ByteString.copyFrom(HexFormat.of().parseHex(TRACE_ID)))
|
||||
.setSpanId(ByteString.copyFrom(HexFormat.of().parseHex(SPAN_ID)))
|
||||
.setName(SPAN_NAME)
|
||||
.setKind(Span.SpanKind.SPAN_KIND_SERVER)
|
||||
.setStartTimeUnixNano(timeNanos)
|
||||
.setEndTimeUnixNano(timeNanos + 10_000_000L)
|
||||
.addAttributes(attribute("http.route", ENDPOINT))
|
||||
.build();
|
||||
return ExportTraceServiceRequest.newBuilder()
|
||||
.addResourceSpans(ResourceSpans.newBuilder()
|
||||
.setResource(resource())
|
||||
.addScopeSpans(ScopeSpans.newBuilder().addSpans(span)))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Resource resource() {
|
||||
return Resource.newBuilder().addAllAttributes(List.of(
|
||||
attribute("service.name", SERVICE_NAME),
|
||||
attribute("service.namespace", SERVICE_NAMESPACE),
|
||||
attribute("deployment.environment.name", ENVIRONMENT),
|
||||
attribute("service.instance.id", INSTANCE_ID),
|
||||
attribute("hertzbeat.collector.id", COLLECTOR_ID))).build();
|
||||
}
|
||||
|
||||
private static KeyValue attribute(String key, String value) {
|
||||
return KeyValue.newBuilder()
|
||||
.setKey(key)
|
||||
.setValue(AnyValue.newBuilder().setStringValue(value))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user