feat: extract MySQL/MariaDB monitor to its own monitor-type and enable conditions support
This commit is contained in:
parent
1d500bb88f
commit
d825352410
@ -162,6 +162,7 @@
|
|||||||
"@playwright/test": "~1.39.0",
|
"@playwright/test": "~1.39.0",
|
||||||
"@popperjs/core": "~2.10.2",
|
"@popperjs/core": "~2.10.2",
|
||||||
"@testcontainers/hivemq": "^10.13.1",
|
"@testcontainers/hivemq": "^10.13.1",
|
||||||
|
"@testcontainers/mariadb": "^10.13.0",
|
||||||
"@testcontainers/mssqlserver": "^10.28.0",
|
"@testcontainers/mssqlserver": "^10.28.0",
|
||||||
"@testcontainers/postgresql": "^11.9.0",
|
"@testcontainers/postgresql": "^11.9.0",
|
||||||
"@testcontainers/rabbitmq": "^10.13.2",
|
"@testcontainers/rabbitmq": "^10.13.2",
|
||||||
|
|||||||
@ -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_COUNT_MIN, PING_COUNT_MAX, PING_COUNT_DEFAULT,
|
||||||
PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT
|
PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT
|
||||||
} = require("../../src/util");
|
} = require("../../src/util");
|
||||||
const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mysqlQuery, setSetting, httpNtlm, radius,
|
const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, setSetting, httpNtlm, radius,
|
||||||
kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal, checkCertificateHostname
|
kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal, checkCertificateHostname
|
||||||
} = require("../util-server");
|
} = require("../util-server");
|
||||||
const { R } = require("redbean-node");
|
const { R } = require("redbean-node");
|
||||||
@ -781,16 +781,6 @@ class Monitor extends BeanModel {
|
|||||||
bean.status = UP;
|
bean.status = UP;
|
||||||
bean.msg = `Container has not reported health and is currently ${res.data.State.Status}. As it is running, it is considered UP. Consider adding a health check for better service visibility`;
|
bean.msg = `Container has not reported health and is currently ${res.data.State.Status}. As it is running, it is considered UP. Consider adding a health check for better service visibility`;
|
||||||
}
|
}
|
||||||
} else if (this.type === "mysql") {
|
|
||||||
let startTime = dayjs().valueOf();
|
|
||||||
|
|
||||||
// Use `radius_password` as `password` field, since there are too many unnecessary fields
|
|
||||||
// TODO: rename `radius_password` to `password` later for general use
|
|
||||||
let mysqlPassword = this.radiusPassword;
|
|
||||||
|
|
||||||
bean.msg = await mysqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1", mysqlPassword);
|
|
||||||
bean.status = UP;
|
|
||||||
bean.ping = dayjs().valueOf() - startTime;
|
|
||||||
} else if (this.type === "radius") {
|
} else if (this.type === "radius") {
|
||||||
let startTime = dayjs().valueOf();
|
let startTime = dayjs().valueOf();
|
||||||
|
|
||||||
|
|||||||
118
server/monitor-types/mysql.js
Normal file
118
server/monitor-types/mysql.js
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
const { MonitorType } = require("./monitor-type");
|
||||||
|
const { log, UP } = require("../../src/util");
|
||||||
|
const dayjs = require("dayjs");
|
||||||
|
const mysql = require("mysql2");
|
||||||
|
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 MysqlMonitorType extends MonitorType {
|
||||||
|
name = "mysql";
|
||||||
|
|
||||||
|
supportsConditions = true;
|
||||||
|
conditionVariables = [
|
||||||
|
new ConditionVariable("result", defaultStringOperators),
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
async check(monitor, heartbeat, _server) {
|
||||||
|
let startTime = dayjs().valueOf();
|
||||||
|
|
||||||
|
let query = monitor.databaseQuery;
|
||||||
|
if (!query || (typeof query === "string" && query.trim() === "")) {
|
||||||
|
query = "SELECT 1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use `radius_password` as `password` field for backwards compatibility
|
||||||
|
// TODO: rename `radius_password` to `password` later for general use
|
||||||
|
const password = monitor.radiusPassword;
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await this.mysqlQuery(monitor.databaseConnectionString, query, password);
|
||||||
|
} catch (error) {
|
||||||
|
log.error("mysql", "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 MySQL/MariaDB
|
||||||
|
* @param {string} connectionString The database connection string
|
||||||
|
* @param {string} query The query to execute
|
||||||
|
* @param {string} password Optional password override
|
||||||
|
* @returns {Promise<any>} Single value from the first column of the first row
|
||||||
|
*/
|
||||||
|
mysqlQuery(connectionString, query, password = undefined) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const connection = mysql.createConnection({
|
||||||
|
uri: connectionString,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on("error", (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.query(query, (err, res) => {
|
||||||
|
try {
|
||||||
|
connection.end();
|
||||||
|
} catch (_) {
|
||||||
|
connection.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have results
|
||||||
|
if (!Array.isArray(res) || res.length === 0) {
|
||||||
|
reject(new Error("Query returned no results"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have multiple rows
|
||||||
|
if (res.length > 1) {
|
||||||
|
reject(new Error("Multiple values were found, expected only one value"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRow = res[0];
|
||||||
|
const columnNames = Object.keys(firstRow);
|
||||||
|
|
||||||
|
// Check if we have multiple columns
|
||||||
|
if (columnNames.length > 1) {
|
||||||
|
reject(new Error("Multiple columns were found, expected only one value"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the single value from the first (and only) column
|
||||||
|
resolve(firstRow[columnNames[0]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MysqlMonitorType,
|
||||||
|
};
|
||||||
@ -128,6 +128,7 @@ class UptimeKumaServer {
|
|||||||
UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType();
|
UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType();
|
||||||
UptimeKumaServer.monitorTypeList["system-service"] = new SystemServiceMonitorType();
|
UptimeKumaServer.monitorTypeList["system-service"] = new SystemServiceMonitorType();
|
||||||
UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType();
|
UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType();
|
||||||
|
UptimeKumaServer.monitorTypeList["mysql"] = new MysqlMonitorType();
|
||||||
|
|
||||||
// Allow all CORS origins (polling) in development
|
// Allow all CORS origins (polling) in development
|
||||||
let cors = undefined;
|
let cors = undefined;
|
||||||
@ -580,5 +581,6 @@ const { ManualMonitorType } = require("./monitor-types/manual");
|
|||||||
const { RedisMonitorType } = require("./monitor-types/redis");
|
const { RedisMonitorType } = require("./monitor-types/redis");
|
||||||
const { SystemServiceMonitorType } = require("./monitor-types/system-service");
|
const { SystemServiceMonitorType } = require("./monitor-types/system-service");
|
||||||
const { MssqlMonitorType } = require("./monitor-types/mssql");
|
const { MssqlMonitorType } = require("./monitor-types/mssql");
|
||||||
|
const { MysqlMonitorType } = require("./monitor-types/mysql");
|
||||||
const Monitor = require("./model/monitor");
|
const Monitor = require("./model/monitor");
|
||||||
|
|
||||||
|
|||||||
156
test/backend-test/monitors/test-mysql.js
Normal file
156
test/backend-test/monitors/test-mysql.js
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
const { describe, test } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { MariaDbContainer } = require("@testcontainers/mariadb");
|
||||||
|
const { MysqlMonitorType } = require("../../../server/monitor-types/mysql");
|
||||||
|
const { UP, PENDING } = require("../../../src/util");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create and start a MariaDB container
|
||||||
|
* @returns {Promise<MariaDbContainer>} The started MariaDB container
|
||||||
|
*/
|
||||||
|
async function createAndStartMariaDBContainer() {
|
||||||
|
return await new MariaDbContainer("mariadb:10.11")
|
||||||
|
.withStartupTimeout(90000)
|
||||||
|
.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(
|
||||||
|
"MySQL/MariaDB Monitor",
|
||||||
|
{
|
||||||
|
skip:
|
||||||
|
!!process.env.CI &&
|
||||||
|
(process.platform !== "linux" || process.arch !== "x64"),
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
test("check() sets status to UP when MariaDB server is reachable", async () => {
|
||||||
|
const mariadbContainer = await createAndStartMariaDBContainer();
|
||||||
|
|
||||||
|
const mysqlMonitor = new MysqlMonitorType();
|
||||||
|
const monitor = {
|
||||||
|
databaseConnectionString: `mysql://${mariadbContainer.getUsername()}:${mariadbContainer.getUserPassword()}@${mariadbContainer.getHost()}:${mariadbContainer.getPort()}/${mariadbContainer.getDatabase()}`,
|
||||||
|
conditions: "[]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const heartbeat = {
|
||||||
|
msg: "",
|
||||||
|
status: PENDING,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mysqlMonitor.check(monitor, heartbeat, {});
|
||||||
|
assert.strictEqual(
|
||||||
|
heartbeat.status,
|
||||||
|
UP,
|
||||||
|
`Expected status ${UP} but got ${heartbeat.status}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await mariadbContainer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("check() rejects when MariaDB server is not reachable", async () => {
|
||||||
|
const mysqlMonitor = new MysqlMonitorType();
|
||||||
|
const monitor = {
|
||||||
|
databaseConnectionString:
|
||||||
|
"mysql://invalid:invalid@localhost:13306/test",
|
||||||
|
conditions: "[]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const heartbeat = {
|
||||||
|
msg: "",
|
||||||
|
status: PENDING,
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
mysqlMonitor.check(monitor, heartbeat, {}),
|
||||||
|
(err) => {
|
||||||
|
assert.ok(
|
||||||
|
err.message.includes("Database connection/query failed"),
|
||||||
|
`Expected error message to include "Database connection/query failed" but got: ${err.message}`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.notStrictEqual(
|
||||||
|
heartbeat.status,
|
||||||
|
UP,
|
||||||
|
`Expected status should not be ${UP}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("check() sets status to UP when custom query result meets condition", async () => {
|
||||||
|
const mariadbContainer = await createAndStartMariaDBContainer();
|
||||||
|
|
||||||
|
const mysqlMonitor = new MysqlMonitorType();
|
||||||
|
const monitor = {
|
||||||
|
databaseConnectionString: `mysql://${mariadbContainer.getUsername()}:${mariadbContainer.getUserPassword()}@${mariadbContainer.getHost()}:${mariadbContainer.getPort()}/${mariadbContainer.getDatabase()}`,
|
||||||
|
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 mysqlMonitor.check(monitor, heartbeat, {});
|
||||||
|
assert.strictEqual(
|
||||||
|
heartbeat.status,
|
||||||
|
UP,
|
||||||
|
`Expected status ${UP} but got ${heartbeat.status}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await mariadbContainer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("check() rejects when custom query result does not meet condition", async () => {
|
||||||
|
const mariadbContainer = await createAndStartMariaDBContainer();
|
||||||
|
|
||||||
|
const mysqlMonitor = new MysqlMonitorType();
|
||||||
|
const monitor = {
|
||||||
|
databaseConnectionString: `mysql://${mariadbContainer.getUsername()}:${mariadbContainer.getUserPassword()}@${mariadbContainer.getHost()}:${mariadbContainer.getPort()}/${mariadbContainer.getDatabase()}`,
|
||||||
|
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(
|
||||||
|
mysqlMonitor.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 mariadbContainer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user