From 7306e7038ac0a436b582afb0d6c0741fc2929c64 Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Wed, 14 Jan 2026 16:49:37 +0100 Subject: [PATCH 1/3] chore(ci): fix a missing `--repo` in the labeling automation (#6735) --- .github/workflows/mark-as-draft-on-requesting-changes.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mark-as-draft-on-requesting-changes.yml b/.github/workflows/mark-as-draft-on-requesting-changes.yml index 99e8384e4..61407184c 100644 --- a/.github/workflows/mark-as-draft-on-requesting-changes.yml +++ b/.github/workflows/mark-as-draft-on-requesting-changes.yml @@ -41,7 +41,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh pr ready "${{ github.event.pull_request.number }}" --undo || true + gh pr ready "${{ github.event.pull_request.number }}" \ + --repo "${{ github.repository }}" \ + --undo || true + # || true to ignore the case where the pr is already a draft ready-for-review: runs-on: ubuntu-latest From e022b5f976ea6d89b42922ecdb22127328dabd24 Mon Sep 17 00:00:00 2001 From: iotux <46082385+iotux@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:13:53 +0700 Subject: [PATCH 2/3] fix: allow for private domains like example.local and others (#6711) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Frank Elsinga --- server/model/domain_expiry.js | 13 +++--- src/lang/en.json | 3 +- test/backend-test/test-domain.js | 74 +++++++++----------------------- 3 files changed, 28 insertions(+), 62 deletions(-) diff --git a/server/model/domain_expiry.js b/server/model/domain_expiry.js index b7992575f..3502a4b08 100644 --- a/server/model/domain_expiry.js +++ b/server/model/domain_expiry.js @@ -159,20 +159,19 @@ class DomainExpiry extends BeanModel { const tld = parseTld(target); // Avoid logging for incomplete/invalid input while editing monitors. - if (!tld.domain) { - throw new TranslatableError("domain_expiry_unsupported_invalid_domain", { hostname: tld.hostname }); - } - if (!tld.publicSuffix) { - throw new TranslatableError("domain_expiry_unsupported_public_suffix", { publicSuffix: tld.publicSuffix }); - } if (tld.isIp) { throw new TranslatableError("domain_expiry_unsupported_is_ip", { hostname: tld.hostname }); } - // No one-letter public suffix exists; treat this as an incomplete/invalid input while typing. if (tld.publicSuffix.length < 2) { throw new TranslatableError("domain_expiry_public_suffix_too_short", { publicSuffix: tld.publicSuffix }); } + if (!tld.isIcann) { + throw new TranslatableError("domain_expiry_unsupported_is_icann", { + domain: tld.domain, + publicSuffix: tld.publicSuffix, + }); + } const rdap = await getRdapServer(tld.publicSuffix); if (!rdap) { diff --git a/src/lang/en.json b/src/lang/en.json index 19d259222..ea1ea35a8 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -1297,9 +1297,8 @@ "domainExpiryDescription": "Trigger notification when domain names expires in:", "domain_expiry_unsupported_monitor_type": "Domain expiry monitoring is not supported for this monitor type", "domain_expiry_unsupported_missing_target": "No valid domain or hostname is configured for this monitor", - "domain_expiry_unsupported_invalid_domain": "The configured value \"{hostname}\" is not a valid domain name", "domain_expiry_public_suffix_too_short": "\".{publicSuffix}\" is too short for a top level domain", - "domain_expiry_unsupported_public_suffix": "The domain \"{domain}\" does not have a valid public suffix", + "domain_expiry_unsupported_is_icann": "The domain \"{domain}\" is not a candidate for domain expiry monitoring, because its public suffix \".{publicSuffix}\" is not ICAN", "domain_expiry_unsupported_is_ip": "\"{hostname}\" is an IP address. Domain expiry monitoring requires a domain name", "domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint": "Domain expiry monitoring is not available for \".{publicSuffix}\" because no RDAP service is listed by IANA", "minimumIntervalWarning": "Intervals below 20 seconds may result in poor performance.", diff --git a/test/backend-test/test-domain.js b/test/backend-test/test-domain.js index e1c95cd5f..c00d94e24 100644 --- a/test/backend-test/test-domain.js +++ b/test/backend-test/test-domain.js @@ -96,58 +96,26 @@ describe("Domain Expiry", () => { }); describe("Domain Parsing", () => { - test("throws error for invalid domain (no domain part)", async () => { + test("throws error for IP address (isIp check)", async () => { const monitor = { type: "http", - url: "https://", + url: "https://127.0.0.1", domainExpiryNotification: true, }; await assert.rejects( async () => await DomainExpiry.checkSupport(monitor), (error) => { assert.strictEqual(error.constructor.name, "TranslatableError"); - assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain"); + assert.strictEqual(error.message, "domain_expiry_unsupported_is_ip"); return true; } ); }); - test("throws error for IPv4 address instead of domain", async () => { + test("throws error for too short suffix(example.a)", async () => { const monitor = { type: "http", - url: "https://192.168.1.1", - domainExpiryNotification: true, - }; - await assert.rejects( - async () => await DomainExpiry.checkSupport(monitor), - (error) => { - assert.strictEqual(error.constructor.name, "TranslatableError"); - assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain"); - return true; - } - ); - }); - - test("throws error for IPv6 address", async () => { - const monitor = { - type: "http", - url: "https://[2001:db8::1]", - domainExpiryNotification: true, - }; - await assert.rejects( - async () => await DomainExpiry.checkSupport(monitor), - (error) => { - assert.strictEqual(error.constructor.name, "TranslatableError"); - assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain"); - return true; - } - ); - }); - - test("throws error for single-letter TLD", async () => { - const monitor = { - type: "http", - url: "https://example.x", + url: "https://example.a", domainExpiryNotification: true, }; await assert.rejects( @@ -159,6 +127,22 @@ describe("Domain Expiry", () => { } ); }); + + test("throws error for non-ICANN TLD (e.g. .local)", async () => { + const monitor = { + type: "http", + url: "https://example.local", + domainExpiryNotification: true, + }; + await assert.rejects( + async () => await DomainExpiry.checkSupport(monitor), + (error) => { + assert.strictEqual(error.constructor.name, "TranslatableError"); + assert.strictEqual(error.message, "domain_expiry_unsupported_is_icann"); + return true; + } + ); + }); }); describe("Edge Cases & RDAP Support", () => { @@ -205,22 +189,6 @@ describe("Domain Expiry", () => { assert.strictEqual(supportInfo.domain, "example.com"); assert.strictEqual(supportInfo.tld, "com"); }); - - test("throws error for unsupported TLD without RDAP endpoint", async () => { - const monitor = { - type: "http", - url: "https://example.localhost", - domainExpiryNotification: true, - }; - await assert.rejects( - async () => await DomainExpiry.checkSupport(monitor), - (error) => { - assert.strictEqual(error.constructor.name, "TranslatableError"); - assert.strictEqual(error.message, "domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint"); - return true; - } - ); - }); }); }); From d7296c66299f59ea716ed1817a39a59b2cdfca7a Mon Sep 17 00:00:00 2001 From: Dalton Pearson <32880838+daltonpearson@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:48:48 -0500 Subject: [PATCH 3/3] feat: added monitoring for postgres query result (#6736) Co-authored-by: Dalton Pearson --- server/monitor-types/postgres.js | 116 +++++++++++- test/backend-test/monitors/test-postgres.js | 198 ++++++++++++++++++++ 2 files changed, 308 insertions(+), 6 deletions(-) diff --git a/server/monitor-types/postgres.js b/server/monitor-types/postgres.js index fb6cc9b0d..c9daf65f0 100644 --- a/server/monitor-types/postgres.js +++ b/server/monitor-types/postgres.js @@ -3,26 +3,61 @@ const { log, UP } = require("../../src/util"); const dayjs = require("dayjs"); const postgresConParse = require("pg-connection-string").parse; const { Client } = require("pg"); +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 PostgresMonitorType extends MonitorType { name = "postgres"; + 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"; } - await this.postgresQuery(monitor.databaseConnectionString, query); - heartbeat.msg = ""; - heartbeat.status = UP; - heartbeat.ping = dayjs().valueOf() - startTime; + const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null; + const hasConditions = conditions && conditions.children && conditions.children.length > 0; + + const startTime = dayjs().valueOf(); + + try { + if (hasConditions) { + // When conditions are enabled, expect a single value result + const result = await this.postgresQuerySingleValue(monitor.databaseConnectionString, query); + heartbeat.ping = dayjs().valueOf() - startTime; + + const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) }); + + if (!conditionsResult) { + throw new Error(`Query result did not meet the specified conditions (${result})`); + } + + heartbeat.status = UP; + heartbeat.msg = "Query did meet specified conditions"; + } else { + // Backwards compatible: just check connection and return row count + const result = await this.postgresQuery(monitor.databaseConnectionString, query); + heartbeat.ping = dayjs().valueOf() - startTime; + heartbeat.status = UP; + heartbeat.msg = result; + } + } catch (error) { + heartbeat.ping = dayjs().valueOf() - startTime; + // Re-throw condition errors as-is, wrap database errors + if (error.message.includes("did not meet the specified conditions")) { + throw error; + } + throw new Error(`Database connection/query failed: ${error.message}`); + } } /** @@ -76,6 +111,75 @@ class PostgresMonitorType extends MonitorType { }); }); } + + /** + * Run a query on Postgres + * @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 + */ + async postgresQuerySingleValue(connectionString, query) { + return new Promise((resolve, reject) => { + const config = postgresConParse(connectionString); + + // Fix #3868, which true/false is not parsed to boolean + if (typeof config.ssl === "string") { + config.ssl = config.ssl === "true"; + } + + if (config.password === "") { + // See https://github.com/brianc/node-postgres/issues/1927 + reject(new Error("Password is undefined.")); + return; + } + const client = new Client(config); + + client.on("error", (error) => { + log.debug(this.name, "Error caught in the error event handler."); + reject(error); + }); + + client.connect((err) => { + if (err) { + reject(err); + client.end(); + } else { + // Connected here + try { + client.query(query, (err, res) => { + if (err) { + reject(err); + } else { + // Check if we have results + if (!res.rows || res.rows.length === 0) { + reject(new Error("Query returned no results")); + return; + } + // Check if we have multiple rows + if (res.rows.length > 1) { + reject(new Error("Multiple values were found, expected only one value")); + return; + } + const firstRow = res.rows[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; + } + resolve(firstRow[columnNames[0]]); + } + client.end(); + }); + } catch (e) { + reject(e); + client.end(); + } + } + }); + }); + } } module.exports = { diff --git a/test/backend-test/monitors/test-postgres.js b/test/backend-test/monitors/test-postgres.js index a633d9806..3a408b5a3 100644 --- a/test/backend-test/monitors/test-postgres.js +++ b/test/backend-test/monitors/test-postgres.js @@ -49,5 +49,203 @@ describe( await assert.rejects(postgresMonitor.check(monitor, heartbeat, {}), regex); }); + + test("check() sets status to UP when custom query returns single value", async () => { + // The default timeout of 30 seconds might not be enough for the container to start + const postgresContainer = await new PostgreSqlContainer("postgres:latest") + .withStartupTimeout(60000) + .start(); + + const postgresMonitor = new PostgresMonitorType(); + const monitor = { + databaseConnectionString: postgresContainer.getConnectionUri(), + databaseQuery: "SELECT 42", + conditions: "[]", + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await postgresMonitor.check(monitor, heartbeat, {}); + assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`); + } finally { + await postgresContainer.stop(); + } + }); + test("check() sets status to UP when custom query result meets condition", async () => { + const postgresContainer = await new PostgreSqlContainer("postgres:latest") + .withStartupTimeout(60000) + .start(); + + const postgresMonitor = new PostgresMonitorType(); + const monitor = { + databaseConnectionString: postgresContainer.getConnectionUri(), + 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 postgresMonitor.check(monitor, heartbeat, {}); + assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`); + } finally { + await postgresContainer.stop(); + } + }); + test("check() rejects when custom query result does not meet condition", async () => { + const postgresContainer = await new PostgreSqlContainer("postgres:latest") + .withStartupTimeout(60000) + .start(); + + const postgresMonitor = new PostgresMonitorType(); + const monitor = { + databaseConnectionString: postgresContainer.getConnectionUri(), + 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( + postgresMonitor.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 postgresContainer.stop(); + } + }); + test("check() rejects when query returns no results with conditions", async () => { + const postgresContainer = await new PostgreSqlContainer("postgres:latest") + .withStartupTimeout(60000) + .start(); + + const postgresMonitor = new PostgresMonitorType(); + const monitor = { + databaseConnectionString: postgresContainer.getConnectionUri(), + databaseQuery: "SELECT 1 WHERE 1 = 0", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "1", + }, + ]), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await assert.rejects( + postgresMonitor.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 postgresContainer.stop(); + } + }); + test("check() rejects when query returns multiple rows with conditions", async () => { + const postgresContainer = await new PostgreSqlContainer("postgres:latest") + .withStartupTimeout(60000) + .start(); + + const postgresMonitor = new PostgresMonitorType(); + const monitor = { + databaseConnectionString: postgresContainer.getConnectionUri(), + databaseQuery: "SELECT 1 UNION ALL SELECT 2", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "1", + }, + ]), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await assert.rejects( + postgresMonitor.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 postgresContainer.stop(); + } + }); + test("check() rejects when query returns multiple columns with conditions", async () => { + const postgresContainer = await new PostgreSqlContainer("postgres:latest") + .withStartupTimeout(60000) + .start(); + + const postgresMonitor = new PostgresMonitorType(); + const monitor = { + databaseConnectionString: postgresContainer.getConnectionUri(), + databaseQuery: "SELECT 1 AS col1, 2 AS col2", + conditions: JSON.stringify([ + { + type: "expression", + andOr: "and", + variable: "result", + operator: "equals", + value: "1", + }, + ]), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await assert.rejects( + postgresMonitor.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 postgresContainer.stop(); + } + }); } );