chore: Extract the sqlserver monitor to its own monitor-type. Enable support conditions for a single result value.

This commit is contained in:
Pedro Magno
2025-12-30 11:06:04 +00:00
parent ebf1a5bb6f
commit 534ba5d547
7 changed files with 469 additions and 54 deletions

60
package-lock.json generated
View File

@@ -55,7 +55,7 @@
"mitt": "~3.0.1",
"mongodb": "~4.17.1",
"mqtt": "~4.3.7",
"mssql": "~11.0.0",
"mssql": "~12.0.0",
"mysql2": "~3.11.3",
"nanoid": "~3.3.4",
"net-snmp": "^3.11.2",
@@ -100,6 +100,7 @@
"@playwright/test": "~1.39.0",
"@popperjs/core": "~2.10.2",
"@testcontainers/hivemq": "^10.13.1",
"@testcontainers/mssqlserver": "^10.28.0",
"@testcontainers/postgresql": "^11.9.0",
"@testcontainers/rabbitmq": "^10.13.2",
"@types/bootstrap": "~5.1.9",
@@ -5627,9 +5628,9 @@
}
},
"node_modules/@tediousjs/connection-string": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz",
"integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.6.0.tgz",
"integrity": "sha512-GxlsW354Vi6QqbUgdPyQVcQjI7cZBdGV5vOYVYuCVDTylx2wl3WHR2HlhcxxHTrMigbelpXsdcZso+66uxPfow==",
"license": "MIT"
},
"node_modules/@testcontainers/hivemq": {
@@ -5642,6 +5643,16 @@
"testcontainers": "^10.28.0"
}
},
"node_modules/@testcontainers/mssqlserver": {
"version": "10.28.0",
"resolved": "https://registry.npmjs.org/@testcontainers/mssqlserver/-/mssqlserver-10.28.0.tgz",
"integrity": "sha512-edyXfjU6rK7Jt/2Pei4HI4tt2rgOBJ6MM79YbwAp60fU0XIQHEdaDdlVtpVjArVNfnaS0kMUG3WxGINjtDhOyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"testcontainers": "^10.28.0"
}
},
"node_modules/@testcontainers/postgresql": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.10.0.tgz",
@@ -13825,17 +13836,16 @@
"license": "MIT"
},
"node_modules/mssql": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/mssql/-/mssql-11.0.1.tgz",
"integrity": "sha512-KlGNsugoT90enKlR8/G36H0kTxPthDhmtNUCwEHvgRza5Cjpjoj+P2X6eMpFUDN7pFrJZsKadL4x990G8RBE1w==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/mssql/-/mssql-12.0.0.tgz",
"integrity": "sha512-FcDQ1Gwe4g3Mhw25R1Onr8N+jmqBTWE/pmtcgxYnAUSIf/vBQMvJfMnyMY8ruOICtBch5+Wgbcfd3REDQSlWpA==",
"license": "MIT",
"dependencies": {
"@tediousjs/connection-string": "^0.5.0",
"@tediousjs/connection-string": "^0.6.0",
"commander": "^11.0.0",
"debug": "^4.3.3",
"rfdc": "^1.3.0",
"tarn": "^3.0.2",
"tedious": "^18.2.1"
"tedious": "^19.0.0"
},
"bin": {
"mssql": "bin/mssql"
@@ -17980,24 +17990,24 @@
}
},
"node_modules/tedious": {
"version": "18.6.2",
"resolved": "https://registry.npmjs.org/tedious/-/tedious-18.6.2.tgz",
"integrity": "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg==",
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/tedious/-/tedious-19.2.0.tgz",
"integrity": "sha512-2dDjX0KP54riDvJPiiIozv0WRS/giJb3/JG2lWpa2dgM0Gha7mLAxbTR3ltPkGzfoS6M3oDnhYnWuzeaZibHuQ==",
"license": "MIT",
"dependencies": {
"@azure/core-auth": "^1.7.2",
"@azure/identity": "^4.2.1",
"@azure/keyvault-keys": "^4.4.0",
"@js-joda/core": "^5.6.1",
"@js-joda/core": "^5.6.5",
"@types/node": ">=18",
"bl": "^6.0.11",
"iconv-lite": "^0.6.3",
"bl": "^6.1.4",
"iconv-lite": "^0.7.0",
"js-md4": "^0.3.2",
"native-duplexpair": "^1.0.0",
"sprintf-js": "^1.1.3"
},
"engines": {
"node": ">=18"
"node": ">=18.17"
}
},
"node_modules/tedious/node_modules/bl": {
@@ -18036,6 +18046,22 @@
"ieee754": "^1.2.1"
}
},
"node_modules/tedious/node_modules/iconv-lite": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
"integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/tedious/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",

View File

@@ -116,13 +116,13 @@
"mitt": "~3.0.1",
"mongodb": "~4.17.1",
"mqtt": "~4.3.7",
"mssql": "~11.0.0",
"mssql": "~12.0.0",
"mysql2": "~3.11.3",
"nanoid": "~3.3.4",
"net-snmp": "^3.11.2",
"node-cloudflared-tunnel": "~1.0.9",
"node-radius-utils": "~1.2.0",
"node-fetch-cache": "^5.1.0",
"node-radius-utils": "~1.2.0",
"nodemailer": "~6.9.13",
"nostr-tools": "^2.10.4",
"notp": "~2.0.3",
@@ -161,6 +161,7 @@
"@playwright/test": "~1.39.0",
"@popperjs/core": "~2.10.2",
"@testcontainers/hivemq": "^10.13.1",
"@testcontainers/mssqlserver": "^10.28.0",
"@testcontainers/postgresql": "^11.9.0",
"@testcontainers/rabbitmq": "^10.13.2",
"@types/bootstrap": "~5.1.9",

View File

@@ -8,7 +8,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MI
PING_COUNT_MIN, PING_COUNT_MAX, PING_COUNT_DEFAULT,
PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT
} = require("../../src/util");
const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, mysqlQuery, setSetting, httpNtlm, radius,
const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mysqlQuery, setSetting, httpNtlm, radius,
kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal, checkCertificateHostname
} = require("../util-server");
const { R } = require("redbean-node");
@@ -782,14 +782,6 @@ class Monitor extends BeanModel {
} else {
throw Error("Container State is " + res.data.State.Status);
}
} else if (this.type === "sqlserver") {
let startTime = dayjs().valueOf();
await mssqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1");
bean.msg = "";
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "mysql") {
let startTime = dayjs().valueOf();

View File

@@ -0,0 +1,118 @@
const { MonitorType } = require("./monitor-type");
const { log, UP } = require("../../src/util");
const dayjs = require("dayjs");
const mssql = require("mssql");
const { ConditionVariable } = require("../monitor-conditions/variables");
const { defaultStringOperators } = require("../monitor-conditions/operators");
const {
ConditionExpressionGroup,
} = require("../monitor-conditions/expression");
const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator");
class MssqlMonitorType extends MonitorType {
name = "sqlserver";
supportsConditions = true;
conditionVariables = [
new ConditionVariable("result", defaultStringOperators),
];
/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
let startTime = dayjs().valueOf();
let query = monitor.databaseQuery;
// No query provided by user, use SELECT 1
if (!query || (typeof query === "string" && query.trim() === "")) {
query = "SELECT 1";
}
let result;
try {
result = await this.mssqlQuery(
monitor.databaseConnectionString,
query
);
} catch (error) {
log.error("sqlserver", "Database query failed:", error.message);
throw new Error(
`Database connection/query failed: ${error.message}`
);
} finally {
heartbeat.ping = dayjs().valueOf() - startTime;
}
const conditions = ConditionExpressionGroup.fromMonitor(monitor);
const handleConditions = (data) =>
conditions ? evaluateExpressionGroup(conditions, data) : true;
// Since result is now a single value, pass it directly to conditions
const conditionsResult = handleConditions({ result: String(result) });
if (!conditionsResult) {
throw new Error(
`Query result did not meet the specified conditions (${result})`
);
}
heartbeat.msg = "";
heartbeat.status = UP;
}
/**
* Run a query on MSSQL server
* @param {string} connectionString The database connection string
* @param {string} query The query to validate the database with
* @returns {Promise<any>} Single value from the first column of the first row
*/
async mssqlQuery(connectionString, query) {
let pool;
try {
pool = new mssql.ConnectionPool(connectionString);
await pool.connect();
const result = await pool.request().query(query);
// Check if we have results
if (!result.recordset || result.recordset.length === 0) {
throw new Error("Query returned no results");
}
// Check if we have multiple rows
if (result.recordset.length > 1) {
throw new Error(
"Multiple values were found, expected only one value"
);
}
const firstRow = result.recordset[0];
const columnNames = Object.keys(firstRow);
// Check if we have multiple columns
if (columnNames.length > 1) {
throw new Error(
"Multiple columns were found, expected only one value"
);
}
// Return the single value from the first (and only) column
return firstRow[columnNames[0]];
} catch (err) {
log.debug(
"sqlserver",
"Error caught in the query execution.",
err.message
);
throw err;
} finally {
if (pool) {
await pool.close();
}
}
}
}
module.exports = {
MssqlMonitorType,
};

View File

@@ -124,6 +124,7 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["port"] = new TCPMonitorType();
UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType();
UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType();
UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType();
// Allow all CORS origins (polling) in development
let cors = undefined;
@@ -572,5 +573,6 @@ const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq");
const { TCPMonitorType } = require("./monitor-types/tcp.js");
const { ManualMonitorType } = require("./monitor-types/manual");
const { RedisMonitorType } = require("./monitor-types/redis");
const { MssqlMonitorType } = require("./monitor-types/mssql");
const Monitor = require("./model/monitor");

View File

@@ -10,7 +10,6 @@ const { Resolver } = require("dns");
const iconv = require("iconv-lite");
const chardet = require("chardet");
const chroma = require("chroma-js");
const mssql = require("mssql");
const mysql = require("mysql2");
const { NtlmClient } = require("./modules/axios-ntlm/lib/ntlmClient.js");
const { Settings } = require("./settings");
@@ -322,31 +321,6 @@ exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype) {
});
};
/**
* Run a query on SQL Server
* @param {string} connectionString The database connection string
* @param {string} query The query to validate the database with
* @returns {Promise<(string[] | object[] | object)>} Response from
* server
*/
exports.mssqlQuery = async function (connectionString, query) {
let pool;
try {
pool = new mssql.ConnectionPool(connectionString);
await pool.connect();
if (!query) {
query = "SELECT 1";
}
await pool.request().query(query);
pool.close();
} catch (e) {
if (pool) {
pool.close();
}
throw e;
}
};
/**
* Run a query on MySQL/MariaDB
* @param {string} connectionString The database connection string

View File

@@ -0,0 +1,302 @@
const { describe, test } = require("node:test");
const assert = require("node:assert");
const { MSSQLServerContainer } = require("@testcontainers/mssqlserver");
const { MssqlMonitorType } = require("../../server/monitor-types/mssql");
const { UP, PENDING } = require("../../src/util");
/**
* Helper function to create and start a MSSQL container
* @returns {Promise<MSSQLServerContainer>} The started MSSQL container
*/
async function createAndStartMSSQLContainer() {
return await new MSSQLServerContainer(
"mcr.microsoft.com/mssql/server:2022-latest"
)
.acceptLicense()
// The default timeout of 30 seconds might not be enough for the container to start
.withStartupTimeout(60000)
.start();
}
describe(
"MSSQL Single Node",
{
skip:
!!process.env.CI &&
(process.platform !== "linux" || process.arch !== "x64"),
},
() => {
test("MSSQL is running", async () => {
let mssqlContainer;
try {
mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
await mssqlMonitor.check(monitor, heartbeat, {});
assert.strictEqual(
heartbeat.status,
UP,
`Expected status ${UP} but got ${heartbeat.status}`
);
} catch (error) {
console.error("Test failed with error:", error.message);
console.error("Error stack:", error.stack);
if (mssqlContainer) {
console.error("Container ID:", mssqlContainer.getId());
console.error(
"Container logs:",
await mssqlContainer.logs()
);
}
throw error;
} finally {
if (mssqlContainer) {
console.log("Stopping MSSQL container...");
await mssqlContainer.stop();
}
}
});
test("MSSQL with custom query returning single value", async () => {
const mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
databaseQuery: "SELECT 42",
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await mssqlMonitor.check(monitor, heartbeat, {});
assert.strictEqual(
heartbeat.status,
UP,
`Expected status ${UP} but got ${heartbeat.status}`
);
} finally {
await mssqlContainer.stop();
}
});
test("MSSQL with custom query and condition that passes", async () => {
const mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
databaseQuery: "SELECT 42 as value",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "42",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await mssqlMonitor.check(monitor, heartbeat, {});
assert.strictEqual(
heartbeat.status,
UP,
`Expected status ${UP} but got ${heartbeat.status}`
);
} finally {
await mssqlContainer.stop();
}
});
test("MSSQL with custom query and condition that fails", async () => {
const mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
databaseQuery: "SELECT 99 as value",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "42",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
mssqlMonitor.check(monitor, heartbeat, {}),
new Error(
"Query result did not meet the specified conditions (99)"
)
);
assert.strictEqual(
heartbeat.status,
PENDING,
`Expected status should not be ${heartbeat.status}`
);
} finally {
await mssqlContainer.stop();
}
});
test("MSSQL query returns no results", async () => {
const mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
databaseQuery: "SELECT 1 WHERE 1 = 0",
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
mssqlMonitor.check(monitor, heartbeat, {}),
new Error(
"Database connection/query failed: Query returned no results"
)
);
assert.strictEqual(
heartbeat.status,
PENDING,
`Expected status should not be ${heartbeat.status}`
);
} finally {
await mssqlContainer.stop();
}
});
test("MSSQL query returns multiple rows", async () => {
const mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
databaseQuery: "SELECT 1 UNION ALL SELECT 2",
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
mssqlMonitor.check(monitor, heartbeat, {}),
new Error(
"Database connection/query failed: Multiple values were found, expected only one value"
)
);
assert.strictEqual(
heartbeat.status,
PENDING,
`Expected status should not be ${heartbeat.status}`
);
} finally {
await mssqlContainer.stop();
}
});
test("MSSQL query returns multiple columns", async () => {
const mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
databaseQuery: "SELECT 1 AS col1, 2 AS col2",
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
mssqlMonitor.check(monitor, heartbeat, {}),
new Error(
"Database connection/query failed: Multiple columns were found, expected only one value"
)
);
assert.strictEqual(
heartbeat.status,
PENDING,
`Expected status should not be ${heartbeat.status}`
);
} finally {
await mssqlContainer.stop();
}
});
test("MSSQL is not running", async () => {
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
"Server=localhost,15433;Database=master;User Id=Fail;Password=Fail;Encrypt=false",
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
await assert.rejects(
mssqlMonitor.check(monitor, heartbeat, {}),
new Error(
"Database connection/query failed: Failed to connect to localhost:15433 - Could not connect (sequence)"
)
);
assert.notStrictEqual(
heartbeat.status,
UP,
`Expected status should not be ${heartbeat.status}`
);
});
}
);