From 534ba5d547267bed9440bfc263e16805957bf7cc Mon Sep 17 00:00:00 2001 From: Pedro Magno Date: Tue, 30 Dec 2025 11:06:04 +0000 Subject: [PATCH] chore: Extract the sqlserver monitor to its own monitor-type. Enable support conditions for a single result value. --- package-lock.json | 60 +++++-- package.json | 5 +- server/model/monitor.js | 10 +- server/monitor-types/mssql.js | 118 +++++++++++++ server/uptime-kuma-server.js | 2 + server/util-server.js | 26 --- test/backend-test/test-mssql.js | 302 ++++++++++++++++++++++++++++++++ 7 files changed, 469 insertions(+), 54 deletions(-) create mode 100644 server/monitor-types/mssql.js create mode 100644 test/backend-test/test-mssql.js diff --git a/package-lock.json b/package-lock.json index 1df648a9c..7a9a5aead 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 04247d031..95e30049d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/model/monitor.js b/server/model/monitor.js index 1c9a112cf..b2462bd03 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -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(); diff --git a/server/monitor-types/mssql.js b/server/monitor-types/mssql.js new file mode 100644 index 000000000..40385351f --- /dev/null +++ b/server/monitor-types/mssql.js @@ -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} 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, +}; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index b528cc3b3..7f7cfd0c4 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -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"); diff --git a/server/util-server.js b/server/util-server.js index 9ab324402..552ae6a21 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -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 diff --git a/test/backend-test/test-mssql.js b/test/backend-test/test-mssql.js new file mode 100644 index 000000000..afdd1faf8 --- /dev/null +++ b/test/backend-test/test-mssql.js @@ -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} 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}` + ); + }); + } +);