This is an automated email from the ASF dual-hosted git repository.
tomsun28 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/master by this push:
new 70ef12d086 [monitoring-template] feat(sqlserver): add database account
expiry metrics (#3737) (#4141)
70ef12d086 is described below
commit 70ef12d0866fa20f9ef21407b885c5dad2d1751b
Author: neon-hippo <[email protected]>
AuthorDate: Sat May 23 21:31:48 2026 -0500
[monitoring-template] feat(sqlserver): add database account expiry metrics
(#3737) (#4141)
Co-authored-by: Duansg <[email protected]>
Co-authored-by: Tomsun28 <[email protected]>
---
.../hertzbeat-collector-collector/pom.xml | 6 +
.../SqlServerJdbcTemplateIntegrationTest.java | 435 +++++++++++++++++++++
.../src/main/resources/define/app-sqlserver.yml | 55 +++
3 files changed, 496 insertions(+)
diff --git a/hertzbeat-collector/hertzbeat-collector-collector/pom.xml
b/hertzbeat-collector/hertzbeat-collector-collector/pom.xml
index 5fcca5107d..2e0a08d216 100644
--- a/hertzbeat-collector/hertzbeat-collector-collector/pom.xml
+++ b/hertzbeat-collector/hertzbeat-collector-collector/pom.xml
@@ -122,6 +122,12 @@
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>com.microsoft.sqlserver</groupId>
+ <artifactId>mssql-jdbc</artifactId>
+ <scope>test</scope>
+ </dependency>
+
</dependencies>
<build>
diff --git
a/hertzbeat-collector/hertzbeat-collector-collector/src/test/java/org/apache/hertzbeat/collector/collect/database/sqlserver/SqlServerJdbcTemplateIntegrationTest.java
b/hertzbeat-collector/hertzbeat-collector-collector/src/test/java/org/apache/hertzbeat/collector/collect/database/sqlserver/SqlServerJdbcTemplateIntegrationTest.java
new file mode 100644
index 0000000000..954c27e4e6
--- /dev/null
+++
b/hertzbeat-collector/hertzbeat-collector-collector/src/test/java/org/apache/hertzbeat/collector/collect/database/sqlserver/SqlServerJdbcTemplateIntegrationTest.java
@@ -0,0 +1,435 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.collector.collect.database.sqlserver;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.Reader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.apache.hertzbeat.collector.collect.strategy.CollectStrategyFactory;
+import org.apache.hertzbeat.collector.dispatch.CollectDataDispatch;
+import org.apache.hertzbeat.collector.dispatch.MetricsCollect;
+import org.apache.hertzbeat.collector.timer.WheelTimerTask;
+import org.apache.hertzbeat.common.entity.job.Job;
+import org.apache.hertzbeat.common.entity.job.Metrics;
+import org.apache.hertzbeat.common.entity.job.protocol.JdbcProtocol;
+import org.apache.hertzbeat.common.entity.message.CollectRep;
+import org.apache.hertzbeat.common.timer.Timeout;
+import org.apache.hertzbeat.common.util.JsonUtil;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DynamicContainer;
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestFactory;
+import org.junit.jupiter.api.TestInstance;
+import org.testcontainers.DockerClientFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+import org.yaml.snakeyaml.Yaml;
+
+/**
+ * Integration test for app-sqlserver.yml against all supported SQL Server
+ * versions. Waits for container readiness via JDBC (same mechanism the
+ * collector uses) so timing is independent of the machine.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class SqlServerJdbcTemplateIntegrationTest {
+
+ private static final String SA_PASSWORD = "HertzBeat!2026";
+ private static final String TEST_LOGIN = "test_login";
+ private static final String TEST_PASSWORD = "TestLogin!2026";
+
+ private static final List<String> SUPPORTED_VERSIONS = List.of(
+ "2017-latest",
+ "2019-latest",
+ "2022-latest",
+ "2025-latest"
+ );
+
+ private record VersionedContainer(
+ String versionTag, GenericContainer<?> container) {
+ }
+
+ private List<VersionedContainer> containers;
+ private List<Metrics> templateMetrics;
+
+ @BeforeAll
+ void setUp() throws Exception {
+ Assumptions.assumeTrue(
+ DockerClientFactory.instance().isDockerAvailable(),
+ "Docker is required for integration tests");
+ new CollectStrategyFactory().run();
+ templateMetrics = loadTemplate().getMetrics();
+
+ containers = new ArrayList<>();
+ for (String tag : SUPPORTED_VERSIONS) {
+ GenericContainer<?> c = startContainer(tag);
+ containers.add(new VersionedContainer(tag, c));
+ }
+ }
+
+ @AfterAll
+ void tearDown() {
+ if (containers != null) {
+ containers.forEach(vc -> vc.container().stop());
+ }
+ }
+
+ // ── Contract: account_expiry must exist ──
+
+ @Test
+ @DisplayName("account_expiry metric group must be defined")
+ void accountExpiryShouldBeDefined() {
+ assertTrue(templateMetrics.stream()
+ .anyMatch(m -> "account_expiry".equals(m.getName())),
+ "app-sqlserver.yml must define an account_expiry metric
group");
+ }
+
+ @Test
+ @DisplayName("account_expiry field types must match schema")
+ void accountExpiryFieldTypesShouldMatch() {
+ Metrics expiry = templateMetrics.stream()
+ .filter(m -> "account_expiry".equals(m.getName()))
+ .findFirst().orElseThrow();
+ for (Metrics.Field f : expiry.getFields()) {
+ switch (f.getField()) {
+ case "login_name", "password_expired", "is_disabled" ->
+ assertEquals(1, f.getType(),
+ f.getField() + " must be type 1 (string)");
+ case "days_left" ->
+ assertEquals(0, f.getType(),
+ "days_left must be type 0 (number)");
+ default ->
+ throw new IllegalStateException(
+ "Unexpected field: " + f.getField());
+ }
+ }
+ }
+
+ // ── Every metric group must collect on every version ──
+
+ @TestFactory
+ Stream<DynamicContainer> shouldCollectOnAllSupportedVersions() {
+ return containers.stream()
+ .map(vc -> DynamicContainer.dynamicContainer(vc.versionTag(),
+ templateMetrics.stream()
+ .map(m -> DynamicTest.dynamicTest(m.getName(),
+ () -> verifyMetric(m, vc)))));
+ }
+
+ private void verifyMetric(Metrics tmpl, VersionedContainer vc)
+ throws Exception {
+ Metrics metric = materialize(tmpl, vc);
+ CollectRep.MetricsData data = collect(metric);
+
+ assertEquals(CollectRep.Code.SUCCESS, data.getCode(),
+ () -> vc.versionTag() + " " + metric.getName()
+ + " failed: " + data.getMsg());
+ assertEquals(metric.getFields().size(), data.getFieldsCount(),
+ () -> vc.versionTag() + " " + metric.getName()
+ + " field count mismatch");
+
+ if ("columns".equals(metric.getJdbc().getQueryType())) {
+ assertEquals(1, data.getValuesCount(),
+ () -> vc.versionTag() + " " + metric.getName()
+ + " should be single-row");
+ }
+ if ("basic".equals(metric.getName())) {
+ assertTrue(data.getValuesCount() > 0);
+ assertNotNull(data.getValues().getFirst().getColumns(0),
+ vc.versionTag() + " basic.version should be non-null");
+ }
+ if ("account_expiry".equals(metric.getName())) {
+ assertTrue(data.getValuesCount() > 0,
+ vc.versionTag()
+ + " account_expiry must return at least one row");
+ }
+ }
+
+ // ── Edge-case assertions on account_expiry data ──
+
+ @TestFactory
+ @DisplayName("account_expiry edge cases across all versions")
+ Stream<DynamicTest> accountExpiryEdgeCases() {
+ return containers.stream()
+ .flatMap(vc -> Stream.of(
+ DynamicTest.dynamicTest(
+ vc.versionTag() + " sa must appear",
+ () -> assertSaLoginAppears(vc)),
+ DynamicTest.dynamicTest(
+ vc.versionTag() + " days_left must be integer",
+ () -> assertDaysLeftIsInteger(vc)),
+ DynamicTest.dynamicTest(
+ vc.versionTag()
+ + " password_expired must be 0 or 1",
+ () -> assertPasswordExpiredIsBinary(vc)),
+ DynamicTest.dynamicTest(
+ vc.versionTag()
+ + " is_disabled must be 0 or 1",
+ () -> assertIsDisabledIsBinary(vc))));
+ }
+
+ private void assertSaLoginAppears(VersionedContainer vc) throws Exception {
+ CollectRep.MetricsData data = collect(expiryMetric(vc));
+ boolean found = false;
+ for (int i = 0; i < data.getValuesCount(); i++) {
+ if ("sa".equalsIgnoreCase(
+ data.getValues().get(i).getColumns(0))) {
+ found = true;
+ break;
+ }
+ }
+ assertTrue(found,
+ vc.versionTag() + " sa login must appear in account_expiry");
+ }
+
+ private void assertDaysLeftIsInteger(VersionedContainer vc)
+ throws Exception {
+ CollectRep.MetricsData data = collect(expiryMetric(vc));
+ for (int i = 0; i < data.getValuesCount(); i++) {
+ String v = data.getValues().get(i).getColumns(1);
+ assertNotNull(v,
+ vc.versionTag() + " days_left must not be null");
+ try {
+ Integer.parseInt(v);
+ } catch (NumberFormatException e) {
+ throw new AssertionError(
+ vc.versionTag() + " days_left not an integer: " + v);
+ }
+ }
+ }
+
+ private void assertPasswordExpiredIsBinary(VersionedContainer vc)
+ throws Exception {
+ CollectRep.MetricsData data = collect(expiryMetric(vc));
+ for (int i = 0; i < data.getValuesCount(); i++) {
+ String v = data.getValues().get(i).getColumns(2);
+ assertNotNull(v,
+ vc.versionTag() + " password_expired must not be null");
+ assertTrue("Y".equals(v) || "N".equals(v),
+ vc.versionTag() + " password_expired must be Y/N: " + v);
+ }
+ }
+
+ private void assertIsDisabledIsBinary(VersionedContainer vc)
+ throws Exception {
+ CollectRep.MetricsData data = collect(expiryMetric(vc));
+ for (int i = 0; i < data.getValuesCount(); i++) {
+ String v = data.getValues().get(i).getColumns(3);
+ assertNotNull(v,
+ vc.versionTag() + " is_disabled must not be null");
+ assertTrue("Y".equals(v) || "N".equals(v),
+ vc.versionTag() + " is_disabled must be Y/N: " + v);
+ }
+ }
+
+ private Metrics expiryMetric(VersionedContainer vc) {
+ return materialize(
+ templateMetrics.stream()
+ .filter(m -> "account_expiry".equals(m.getName()))
+ .findFirst().orElseThrow(),
+ vc);
+ }
+
+ // ── Container lifecycle ──
+
+ private static GenericContainer<?> startContainer(String versionTag)
+ throws Exception {
+ GenericContainer<?> container = new GenericContainer<>(
+ DockerImageName.parse(
+ "mcr.microsoft.com/mssql/server:" + versionTag))
+ .withExposedPorts(1433)
+ .withEnv("ACCEPT_EULA", "Y")
+ .withEnv("MSSQL_SA_PASSWORD", SA_PASSWORD)
+ .waitingFor(Wait.forLogMessage(
+ ".*SQL Server is now ready for client connections.*",
+ 1));
+ container.start();
+ awaitJdbc(container, versionTag);
+
+ // Create a test login with CHECK_EXPIRATION=ON
+ String jdbcUrl = "jdbc:sqlserver://"
+ + container.getHost() + ":"
+ + container.getMappedPort(1433)
+ + ";encrypt=false;trustServerCertificate=true";
+ try (Connection c = DriverManager.getConnection(
+ jdbcUrl, "sa", SA_PASSWORD);
+ var stmt = c.createStatement()) {
+ stmt.execute("CREATE LOGIN [" + TEST_LOGIN
+ + "] WITH PASSWORD = '" + TEST_PASSWORD
+ + "', CHECK_POLICY = ON, CHECK_EXPIRATION = ON");
+ }
+ return container;
+ }
+
+ /**
+ * Poll the container via JDBC until a login succeeds.
+ * Uses a generous timeout because SQL Server startup time varies
+ * significantly across versions and hardware.
+ */
+ private static void awaitJdbc(GenericContainer<?> container,
+ String versionTag) throws Exception {
+ String jdbcUrl = "jdbc:sqlserver://"
+ + container.getHost() + ":"
+ + container.getMappedPort(1433)
+ + ";encrypt=false;trustServerCertificate=true;loginTimeout=5";
+ long deadline = System.currentTimeMillis()
+ + Duration.ofMinutes(3).toMillis();
+ while (System.currentTimeMillis() < deadline) {
+ try {
+ try (Connection c = DriverManager.getConnection(
+ jdbcUrl, "sa", SA_PASSWORD)) {
+ if (c.isValid(3)) {
+ return;
+ }
+ }
+ } catch (Exception ignored) {
+ // SQL Server not accepting logins yet
+ }
+ Thread.sleep(5000);
+ }
+ throw new IllegalStateException(
+ "Timed out waiting for " + versionTag + " JDBC connectivity");
+ }
+
+ // ── Collection helpers (same pattern as Mysql test) ──
+
+ private CollectRep.MetricsData collect(Metrics metric) {
+ Job job = Job.builder()
+ .monitorId(1L).tenantId(1L).app("sqlserver")
+ .defaultInterval(600L)
+ .metadata(new HashMap<>(0))
+ .labels(new HashMap<>(0))
+ .annotations(new HashMap<>(0))
+ .configmap(new ArrayList<>(0))
+ .metrics(new ArrayList<>(List.of(metric)))
+ .build();
+ WheelTimerTask timerTask = new WheelTimerTask(job, timeout -> {
+ });
+ var dispatch = new CapturingCollectDataDispatch();
+ var collector = new MetricsCollect(
+ metric, new StubTimeout(timerTask), dispatch,
+ "collector-test", List.of());
+ collector.run();
+ assertNotNull(dispatch.metricsData,
+ metric.getName() + " should dispatch metrics data");
+ return dispatch.metricsData;
+ }
+
+ private Metrics materialize(Metrics templateMetric,
+ VersionedContainer vc) {
+ Metrics m = JsonUtil.fromJson(
+ JsonUtil.toJson(templateMetric), Metrics.class);
+ JdbcProtocol jdbc = m.getJdbc();
+ jdbc.setHost(vc.container().getHost());
+ jdbc.setPort(
+ String.valueOf(vc.container().getMappedPort(1433)));
+ jdbc.setUsername("sa");
+ jdbc.setPassword(SA_PASSWORD);
+ jdbc.setTimeout(
+ String.valueOf(Duration.ofSeconds(15).toMillis()));
+ jdbc.setReuseConnection("false");
+ jdbc.setUrl(null);
+ jdbc.setSshTunnel(null);
+ if (jdbc.getDatabase() == null
+ || jdbc.getDatabase().contains("^_^")) {
+ jdbc.setDatabase("master");
+ }
+ if (m.getAliasFields() == null
+ || m.getAliasFields().isEmpty()) {
+ m.setAliasFields(
+ m.getFields().stream()
+ .map(Metrics.Field::getField)
+ .collect(Collectors.toList()));
+ }
+ return m;
+ }
+
+ private Job loadTemplate() throws Exception {
+ Path path = Path.of(
+ "..", "..", "hertzbeat-manager", "src", "main",
+ "resources", "define", "app-sqlserver.yml")
+ .toAbsolutePath().normalize();
+ try (Reader r = Files.newBufferedReader(path)) {
+ return new Yaml().loadAs(r, Job.class);
+ }
+ }
+
+ // ── inner types ──
+
+ private static final class CapturingCollectDataDispatch
+ implements CollectDataDispatch {
+ private CollectRep.MetricsData metricsData;
+
+ @Override
+ public void dispatchCollectData(Timeout timeout, Metrics metrics,
+ CollectRep.MetricsData data) {
+ this.metricsData = data;
+ }
+
+ @Override
+ public void dispatchCollectData(Timeout timeout, Metrics metrics,
+ List<CollectRep.MetricsData> list) {
+ if (list != null && !list.isEmpty()) {
+ this.metricsData = list.getFirst();
+ }
+ }
+ }
+
+ private record StubTimeout(WheelTimerTask wheelTimerTask) implements
Timeout {
+ @Override
+ public org.apache.hertzbeat.common.timer.Timer timer() {
+ return null;
+ }
+
+ @Override
+ public org.apache.hertzbeat.common.timer.TimerTask task() {
+ return wheelTimerTask;
+ }
+
+ @Override
+ public boolean isExpired() {
+ return false;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return false;
+ }
+
+ @Override
+ public boolean cancel() {
+ return false;
+ }
+ }
+}
diff --git a/hertzbeat-manager/src/main/resources/define/app-sqlserver.yml
b/hertzbeat-manager/src/main/resources/define/app-sqlserver.yml
index f69f7eb00e..9c6ec1ec1f 100644
--- a/hertzbeat-manager/src/main/resources/define/app-sqlserver.yml
+++ b/hertzbeat-manager/src/main/resources/define/app-sqlserver.yml
@@ -300,3 +300,58 @@ metrics:
queryType: oneRow
sql: SELECT cntr_value as user_connection FROM
sys.dm_os_performance_counters WHERE object_name = 'SQLServer:General
Statistics' AND counter_name = 'User Connections';
url: ^_^url^_^
+
+ - name: account_expiry
+ priority: 100
+ i18n:
+ zh-CN: 数据库账号有效期
+ en-US: Account Expiry
+ ja-JP: アカウント有効期限
+ fields:
+ - field: login_name
+ type: 1
+ label: true
+ i18n:
+ zh-CN: 登录名
+ en-US: Login
+ ja-JP: ログイン
+ - field: days_left
+ type: 0
+ i18n:
+ zh-CN: 剩余天数
+ en-US: Days Left
+ ja-JP: 残り日数
+ - field: password_expired
+ type: 1
+ i18n:
+ zh-CN: 是否已过期
+ en-US: Password Expired
+ ja-JP: 期限切れ
+ - field: is_disabled
+ type: 1
+ i18n:
+ zh-CN: 是否禁用
+ en-US: Is Disabled
+ ja-JP: 無効
+ protocol: jdbc
+ jdbc:
+ host: ^_^host^_^
+ port: ^_^port^_^
+ platform: sqlserver
+ username: ^_^username^_^
+ password: ^_^password^_^
+ database: master
+ timeout: ^_^timeout^_^
+ queryType: multiRow
+ sql: |
+ SELECT name AS login_name,
+ ISNULL(LOGINPROPERTY(name, 'DaysUntilExpiration'), -1) AS days_left,
+ CASE LOGINPROPERTY(name, 'IsExpired')
+ WHEN 1 THEN 'Y' ELSE 'N'
+ END AS password_expired,
+ CASE is_disabled
+ WHEN 1 THEN 'Y' ELSE 'N'
+ END AS is_disabled
+ FROM sys.sql_logins
+ WHERE type_desc = 'SQL_LOGIN';
+ url: ^_^url^_^
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]