diff --git a/Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs b/Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs index 07893a914..f477bca73 100644 --- a/Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs +++ b/Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs @@ -137,32 +137,57 @@ await File.WriteAllTextAsync(configPath, $$""" public async Task CheckSettingsAsync_ManagedStoreWithAStaleBlock_ReturnsStaleSettingsExitCode_Gated() { var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); - var runtimeRoot = Environment.GetEnvironmentVariable("DARLING_TEST_PGRUNTIME"); Assert.SkipWhen(string.IsNullOrEmpty(connectionString), "Set DARLING_TEST_PG to run the live --check-settings exit-code tests."); - Assert.SkipWhen(string.IsNullOrWhiteSpace(runtimeRoot), - "Set DARLING_TEST_PGRUNTIME to the rig's runtime root — this drives --check-settings through a " - + "REAL managed data directory, which DARLING_TEST_PG alone does not locate on disk."); - - var dataDirectory = Path.Combine(runtimeRoot!, "data"); - var confPath = Path.Combine(dataDirectory, "postgresql.conf"); - Assert.SkipUnless(File.Exists(confPath), $"DARLING_TEST_PGRUNTIME={runtimeRoot} has no data\\postgresql.conf."); + var ct = TestContext.Current.CancellationToken; var builder = new NpgsqlConnectionStringBuilder(connectionString); + + /* The "managed" connect path (DarlingManagedPostgres.TryBuildConnectionStringFromStoredCredential) + always targets DarlingManagedPostgres.DatabaseName ("darling") — hardcoded, not read from config — + so this test provisions that database itself rather than assuming a hand-built rig already has it. + A rig built per the lane's own setup only carries the suite's own database and a scratch database + (never "darling"), and asking --check-settings to connect against a database that does not exist + fails at StoreUnreachable before it ever reaches a settings verdict — the defect this rewrite fixes + (it used to point straight at DARLING_TEST_PGRUNTIME's own data directory and assume "darling" + already existed there, which is true of a bootstrapped product install and false of a bare rig). */ + var adminBuilder = new NpgsqlConnectionStringBuilder(connectionString) { Database = "postgres" }; + var createdDarlingDatabase = false; + await using (var admin = new NpgsqlConnection(adminBuilder.ConnectionString)) + { + await admin.OpenAsync(ct); + await using var probe = new NpgsqlCommand( + "SELECT 1 FROM pg_database WHERE datname = 'darling'", admin); + if (await probe.ExecuteScalarAsync(ct) is null) + { + await using var create = new NpgsqlCommand("CREATE DATABASE darling", admin); + await create.ExecuteNonQueryAsync(ct); + createdDarlingDatabase = true; + } + } + + /* A THROWAWAY data directory, never the rig's real one (ruling 7: a live test here must not depend on + — or mutate — the rig's own postgresql.conf). AttributeManagedSetting reads the conf file straight + off disk; it has no dependency on the directory actually being what PostgreSQL was started from, so + a directory holding nothing but a hand-built postgresql.conf is exactly as real an input to it as + the rig's own. */ var root = Directory.CreateTempSubdirectory("darling-checksettings-stale-"); - var originalConf = File.ReadAllText(confPath); try { + var dataDirectory = Path.Combine(root.FullName, "data"); + Directory.CreateDirectory(dataDirectory); + /* max_connections = 100 inside a managed (v4) block: PostgreSQL's own untouched default already disagrees with today's derivation (DarlingManagedPostgres.TargetMaxConnections = 200), and ClassifyVerdict compares the LIVE value against TODAY's derivation — no reload needed. */ - File.AppendAllText(confPath, "\n" + DarlingManagedPostgres.ConfMarkerV4 + "\nmax_connections = 100\n"); + await File.WriteAllTextAsync( + Path.Combine(dataDirectory, "postgresql.conf"), + "\n" + DarlingManagedPostgres.ConfMarkerV4 + "\nmax_connections = 100\n", ct); - /* The owner credential the "managed" connect path reads (DarlingManagedPostgres. - TryBuildConnectionStringFromStoredCredential) — the rig trusts any password (initdb -A trust), - so the protected value's content does not have to be the rig's real (nonexistent) password. */ + /* The owner credential the "managed" connect path reads — the rig trusts any password (initdb + -A trust), so the protected value's content does not have to be the rig's real password. */ var credentialPath = DarlingManagedPostgres.CredentialPathFor(dataDirectory); - await File.WriteAllTextAsync(credentialPath, DarlingSecrets.Protect("trust-auth-ignores-this")); + await File.WriteAllTextAsync(credentialPath, DarlingSecrets.Protect("trust-auth-ignores-this"), ct); var configPath = Path.Combine(root.FullName, "darling.json"); await File.WriteAllTextAsync(configPath, $$""" @@ -170,19 +195,29 @@ await File.WriteAllTextAsync(configPath, $$""" "postgres": { "managed": true, "port": {{builder.Port}}, "dataDirectory": {{JsonSerializer.Serialize(dataDirectory)}} }, "servers": [ { "name": "SQL2022", "host": "SQL2022" } ] } - """); + """, ct); var output = new StringWriter(); var error = new StringWriter(); - var exit = await DarlingCliCommands.CheckSettingsAsync(configPath, false, output, error, CancellationToken.None); + var exit = await DarlingCliCommands.CheckSettingsAsync(configPath, false, output, error, ct); Assert.Equal(DarlingCliCommands.CheckSettingsExitCode.StaleSettings, exit); Assert.Contains("stale-after-hardware-change", output.ToString(), StringComparison.Ordinal); } finally { - File.WriteAllText(confPath, originalConf); Directory.Delete(root.FullName, recursive: true); + + if (createdDarlingDatabase) + { + await using var admin = new NpgsqlConnection(adminBuilder.ConnectionString); + await admin.OpenAsync(ct); + await using var terminate = new NpgsqlCommand( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'darling' AND pid <> pg_backend_pid()", admin); + await terminate.ExecuteNonQueryAsync(ct); + await using var drop = new NpgsqlCommand("DROP DATABASE IF EXISTS darling", admin); + await drop.ExecuteNonQueryAsync(ct); + } } } diff --git a/Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs b/Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs index 99795bbf0..87621482f 100644 --- a/Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs @@ -249,7 +249,9 @@ public void TheMirrorsLoggerSeat_TakesTheCapturedServiceLogger_NotAHardcodedNull /* The entry passes the captured logger, and MapAll is the caller that supplies it. */ Assert.Contains("DarlingMcpFleetSweepTools.GetSweepReports(pg, logger,", source, StringComparison.Ordinal); - Assert.Contains("BuildReadDispatch(logger)", source, StringComparison.Ordinal); + /* #4214 part 2: BuildReadDispatch also threads postgresConfig (get_store_host's own config seat), + so the literal grew a second argument; still the same logger-by-closure call MapAll makes. */ + Assert.Contains("BuildReadDispatch(logger, postgresConfig)", source, StringComparison.Ordinal); /* The delegate itself did NOT grow a seat — the ~100-entry table stays three-parameter, which is the whole reason the logger rides by closure. */ diff --git a/Darling/Darling.Tests/DarlingMcpStoreHostBudgetLiveTests.cs b/Darling/Darling.Tests/DarlingMcpStoreHostBudgetLiveTests.cs new file mode 100644 index 000000000..ef2fc1cab --- /dev/null +++ b/Darling/Darling.Tests/DarlingMcpStoreHostBudgetLiveTests.cs @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Text; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4214 part 2 / #4198: get_store_host's own response-budget pin. Unlike the row-scaling #4198 tools +/// (get_query_store_regressions, get_blocking, ...), this tool's payload is a FIXED shape - platform/ram/ +/// data_volume/store facts plus exactly one row per sizing-relevant setting (eight today) - so there is no +/// "plant N rows" scale knob; a single live call against the shared rig is the whole proof. +/// +/// Measured against the not_managed (bring-your-own) shape deliberately: every setting's +/// source text is the longest of the four verdicts' strings ("not-managed (bring-your-own store; +/// pg_settings.source = ...)"), so it is the more conservative of the two shapes for a byte count, not the +/// managed one a sized store would actually return. +/// +[Collection("live-postgres")] +public sealed class DarlingMcpStoreHostBudgetLiveTests +{ + private readonly ITestOutputHelper _output; + + public DarlingMcpStoreHostBudgetLiveTests(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task GetStoreHost_BringYourOwnShape_StaysUnderResponseBudget() + { + var cs = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to run the get_store_host budget test."); + + var ct = TestContext.Current.CancellationToken; + await using (var connection = new NpgsqlConnection(cs)) + { + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + } + + await using var postgres = NpgsqlDataSource.Create(cs!); + /* A fresh cache per test (round-1 review, Medium 2), not the production Shared singleton — this + test's budget measurement must always hit the live gather, never a hit left warm by another test + in the same process. */ + var cache = new StoreHostProfileCache(TimeSpan.FromMinutes(5)); + var json = await DarlingMcpStoreHostTools.GetStoreHost(postgres, new PostgresConfig { Managed = false }, cache); + + var bytes = Encoding.UTF8.GetByteCount(json); + _output.WriteLine($"get_store_host (not_managed) call: {bytes:N0} bytes (budget {McpResponseBudget.DefaultBytes:N0})."); + Assert.True(bytes < McpResponseBudget.DefaultBytes, + $"get_store_host is {bytes:N0} bytes, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget."); + } +} diff --git a/Darling/Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs b/Darling/Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs new file mode 100644 index 000000000..7be0915ec --- /dev/null +++ b/Darling/Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// get_store_host (#4214 part 2) read through the SAME least-privilege roles it runs under in +/// production — mcp (DarlingMcpHostService's own connection role) and viewer (the web +/// host's /api/read connection role, see TryBuildViewerConnectionStringFromStoredCredential) +/// — never the rig's owner/superuser every other #4214 part 2 test (including this file's siblings) connects +/// as. Proves all four verdicts (matches, stale_after_hardware_change, operator_override, +/// not_managed) are reachable end to end under EACH role, so a missing grant would surface as a failed +/// row assertion here rather than as a silent gap nobody caught before a real least-privilege deployment hit +/// it. +/// +/// Uses distinct host_mcp_test/host_viewer_test roles (the +/// DarlingSecuritySplitLiveTests pattern) rather than the literal mcp/viewer names +/// DarlingManagedRoles.BuildProvisioningSql renders, so this test can run against a shared rig without +/// colliding with a real provisioning run. Granted the schema-level surface that provisioning gives the real +/// roles (USAGE + SELECT on collect, load-bearing for timescaledb_information.chunks +/// visibility — a role with no SELECT on a hypertable sees none of its chunks in that view). The other three +/// reads GatherStoreFactsAsync/GatherSettingProfilesAsync make — pg_extension, +/// pg_stat_database, pg_settings — carry no Darling-authored GRANT anywhere in +/// DarlingManagedRoles; PostgreSQL's own PUBLIC defaults are what let a least-privilege role read them +/// at all, and this test is what actually proves that assumption live rather than trusting it. +/// +[Collection("live-postgres")] +public sealed class DarlingMcpStoreHostToolsLiveTests +{ + private const string McpRole = "host_mcp_test"; + private const string ViewerRole = "host_viewer_test"; + + /// Round-1 review, Low 6: a hardcoded password here is public in this repository, and each of + /// these two LOGIN roles gets SELECT on every table in collect. Cleanup runs in finally, but a + /// cleanup failure after a failed body is swallowed (LiveStoreCleanup), and a killed run skips + /// cleanup outright — generating a fresh password per run means a role that outlives this test is not a + /// known, reusable credential. Hex output is safe to interpolate directly into the DDL string below (no + /// quoting characters). DarlingSecuritySplitLiveTests uses the same hardcoded-password pattern but is + /// explicitly out of scope here. + private static readonly string RolePassword = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); + + [Fact] + public async Task GetStoreHost_AsMcpAndViewerRoles_SurfacesAllFourVerdicts_Live() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to run the get_store_host least-privilege live tests."); + + var ct = TestContext.Current.CancellationToken; + await using var owner = new NpgsqlConnection(connectionString); + await owner.OpenAsync(ct); + await PgMigrations.MigrateAsync(owner, ct); + await CreateTestRolesAsync(owner, ct); + + var bodySucceeded = false; + var root = Directory.CreateTempSubdirectory("darling-storehost-roles-"); + try + { + var managedDir = Path.Combine(root.FullName, "managed"); + Directory.CreateDirectory(managedDir); + + /* shared_buffers is PGC_POSTMASTER (restart-only) and max_wal_size/max_connections' LIVE value + comes from the REAL rig server this connects to, never from this fake data directory — the fake + conf only drives AttributeManagedSetting's FILE attribution (which marker/block a name falls + in), not the live comparison value. So "matches" is built the one way a live value CAN be set + for a fresh session with no restart and no ALTER SYSTEM (which would misattribute to + operator_override via postgresql.auto.conf): work_mem is PGC_USERSET, so a per-ROLE default + (ALTER ROLE ... SET) applies at the next session start with no reload at all. max_connections + (PGC_POSTMASTER, TargetMaxConnections = 200 - a host-independent constant) is left at whatever + this rig already booted with, which DarlingCliCommandsHostCheckTests already proves live is + never 200 -> stale_after_hardware_change. maintenance_work_mem is left unassigned in the fake + conf entirely -> ClassifyVerdict's default arm, operator_override. One conf, three verdicts. */ + var (_, _, _, memory) = DarlingStoreHostProfile.GatherHostFacts(); + var derivedWorkMemMb = DarlingManagedPostgres.DeriveMemorySettings( + DarlingManagedPostgres.QuantizeRam(memory.EffectiveBytes)).WorkMemMb; + + File.WriteAllText(Path.Combine(managedDir, "postgresql.conf"), + DarlingManagedPostgres.ConfMarkerV4 + "\n" + + "work_mem = 999MB\n" + + "max_connections = 100\n"); + + var managedConfig = new PostgresConfig { Managed = true, DataDirectory = managedDir }; + var byoConfig = new PostgresConfig { Managed = false }; + + foreach (var role in new[] { McpRole, ViewerRole }) + { + /* A per-role default, applied at the NEXT session's start — must run before this role's data + source opens its first physical connection, which is what makes the freshly-derived value + visible to GetStoreHost's own live pg_settings read below. */ + await using (var setDefault = new NpgsqlCommand( + $"ALTER ROLE {role} SET work_mem = '{derivedWorkMemMb}MB'", owner)) + { + await setDefault.ExecuteNonQueryAsync(ct); + } + + await using var dataSource = NpgsqlDataSource.Create(RoleConnectionString(connectionString!, role)); + + /* A fresh cache per call, not one shared across this loop (round-1 review, Medium 2): the + cache holds a single un-keyed entry (production only ever has one store to profile), so + sharing one instance across the managed and BYO calls below would serve the FIRST call's + cached profile back for the second, regardless of which PostgresConfig was passed. */ + var managedJson = await DarlingMcpStoreHostTools.GetStoreHost( + dataSource, managedConfig, new StoreHostProfileCache(TimeSpan.FromMinutes(5))); + using var managed = JsonDocument.Parse(managedJson); + AssertVerdictFor(role, managed, "work_mem", "matches"); + AssertVerdictFor(role, managed, "max_connections", "stale_after_hardware_change"); + AssertVerdictFor(role, managed, "maintenance_work_mem", "operator_override"); + Assert.True(managed.RootElement.GetProperty("any_stale").GetBoolean(), + $"{role}: any_stale should be true with a stale_after_hardware_change row present."); + + /* store facts, under the least-privilege role (item 5): size_bytes and timescale_version + both come off PostgreSQL/TimescaleDB reads PUBLIC can run with no Darling-authored GRANT + (this file's own class doc comment) — a regression there is a silent "unavailable" null, + not a thrown exception, so only an explicit not-null assertion catches it. */ + var store = managed.RootElement.GetProperty("store"); + Assert.NotEqual(JsonValueKind.Null, store.GetProperty("size_bytes").ValueKind); + Assert.NotEqual(JsonValueKind.Null, store.GetProperty("timescale_version").ValueKind); + + /* uncompressed_chunk_count against a fresh OWNER-side read of the exact same query + (DarlingStoreHostProfile.UncompressedChunkSizeSql, ruling 1 — one formula, not a second + copy here) taken immediately after the tool call, under the role that actually granted + the schema surface (this file's whole point) rather than trusting the row count alone. */ + long ownerUncompressedChunkCount; + await using (var chunkCmd = new NpgsqlCommand(DarlingStoreHostProfile.UncompressedChunkSizeSql, owner)) + await using (var chunkReader = await chunkCmd.ExecuteReaderAsync(ct)) + { + await chunkReader.ReadAsync(ct); + ownerUncompressedChunkCount = chunkReader.GetInt32(1); + } + + Assert.Equal(ownerUncompressedChunkCount, store.GetProperty("uncompressed_chunk_count").GetInt32()); + + var byoJson = await DarlingMcpStoreHostTools.GetStoreHost( + dataSource, byoConfig, new StoreHostProfileCache(TimeSpan.FromMinutes(5))); + using var byo = JsonDocument.Parse(byoJson); + AssertVerdictFor(role, byo, "work_mem", "not_managed"); + AssertVerdictFor(role, byo, "max_connections", "not_managed"); + Assert.False(byo.RootElement.GetProperty("any_stale").GetBoolean(), + $"{role}: a bring-your-own gather must never report any_stale."); + } + + bodySucceeded = true; + } + finally + { + Directory.Delete(root.FullName, recursive: true); + await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) => + { + await DropTestRolesAsync(cleanup, cleanupCt); + }); + } + } + + private static void AssertVerdictFor(string role, JsonDocument doc, string settingName, string expectedVerdict) + { + foreach (var row in doc.RootElement.GetProperty("settings").EnumerateArray()) + { + if (row.GetProperty("name").GetString() == settingName) + { + Assert.True(expectedVerdict == row.GetProperty("verdict").GetString(), + $"{role}/{settingName}: expected verdict '{expectedVerdict}' but get_store_host returned " + + $"'{row.GetProperty("verdict").GetString()}' (source: {row.GetProperty("source").GetString()})."); + return; + } + } + + Assert.Fail($"{role}: get_store_host returned no '{settingName}' row."); + } + + /// Mirrors DarlingSecuritySplitLiveTests.CreateTestRolesAndGrantsAsync's shape, narrowed to + /// what get_store_host actually reads: no config-schema grant, no write grant — this tool only ever + /// SELECTs. + private static async Task CreateTestRolesAsync(NpgsqlConnection owner, System.Threading.CancellationToken ct) + { + var ddl = $@" +DO $do$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{McpRole}') THEN + CREATE ROLE {McpRole} LOGIN NOSUPERUSER PASSWORD '{RolePassword}'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{ViewerRole}') THEN + CREATE ROLE {ViewerRole} LOGIN NOSUPERUSER PASSWORD '{RolePassword}'; + END IF; +END $do$; +GRANT USAGE ON SCHEMA collect TO {McpRole}, {ViewerRole}; +GRANT SELECT ON ALL TABLES IN SCHEMA collect TO {McpRole}, {ViewerRole}; +ALTER DEFAULT PRIVILEGES FOR ROLE {OwnerRoleOf(owner)} IN SCHEMA collect GRANT SELECT ON TABLES TO {McpRole}, {ViewerRole};"; + await using var cmd = new NpgsqlCommand(ddl, owner); + await cmd.ExecuteNonQueryAsync(ct); + } + + /// ALTER DEFAULT PRIVILEGES keys on the role that CREATEs the object — here the connected owner. + private static string OwnerRoleOf(NpgsqlConnection owner) + => new NpgsqlConnectionStringBuilder(owner.ConnectionString).Username ?? "darling"; + + private static async Task DropTestRolesAsync(NpgsqlConnection owner, System.Threading.CancellationToken ct) + => await new LiveCleanupBatch(owner).DropRolesAsync( + $@" +DROP OWNED BY {McpRole}, {ViewerRole}; +DROP ROLE IF EXISTS {McpRole}; +DROP ROLE IF EXISTS {ViewerRole};", + [McpRole, ViewerRole], + ct); + + private static string RoleConnectionString(string baseConnectionString, string role) + => new NpgsqlConnectionStringBuilder(baseConnectionString) + { + Username = role, + Password = RolePassword, + SearchPath = "collect,public", + }.ConnectionString; +} diff --git a/Darling/Darling.Tests/DarlingStoreHostProfileTests.cs b/Darling/Darling.Tests/DarlingStoreHostProfileTests.cs index 1d7ff1583..0b0f05c87 100644 --- a/Darling/Darling.Tests/DarlingStoreHostProfileTests.cs +++ b/Darling/Darling.Tests/DarlingStoreHostProfileTests.cs @@ -7,6 +7,7 @@ */ using System; +using System.IO; using PerformanceMonitor.Darling.Service; using Xunit; @@ -195,6 +196,91 @@ public void ClassifyVerdict_UnsetFallsBackToOperatorOverride() Assert.Equal(HostSettingVerdict.OperatorOverride, verdict); } + /* ------------------------------------- FormatSourceForMcp (round-1 review, Medium 1) ------------------------------------- */ + /* Three shapes: a file inside the managed data directory redacts to a directory-relative path, a file + outside it (an include elsewhere) redacts to the bare file name only, and anything with no SourceFile + (not-managed / unreadable / not-visible) passes SourceDescription through unchanged — it is already a + kind word, never a path. */ + + [Fact] + public void FormatSourceForMcp_FileInsideDataDirectory_ReturnsRelativePathNoParentNoRoot() + { + var dataDirectory = Path.Combine(Path.GetTempPath(), "pmdarling-test-pgdata"); + var confFile = Path.Combine(dataDirectory, "postgresql.conf"); + var setting = new HostSettingProfile( + "shared_buffers", "1024MB", 1024, "unused-when-sourcefile-set", "1024MB", 1024, + HostSettingVerdict.Matches, confFile, 42); + + var source = DarlingStoreHostProfile.FormatSourceForMcp(setting, dataDirectory); + + Assert.Equal("managed block (postgresql.conf:42)", source); + Assert.DoesNotContain("..", source, StringComparison.Ordinal); + Assert.DoesNotContain(dataDirectory, source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void FormatSourceForMcp_FileOutsideDataDirectory_ReturnsBareFileNameOnly() + { + var dataDirectory = Path.Combine(Path.GetTempPath(), "pmdarling-test-pgdata"); + var includedFile = Path.Combine(Path.GetTempPath(), "pmdarling-test-elsewhere", "included.conf"); + var setting = new HostSettingProfile( + "work_mem", "4MB", 4, "unused-when-sourcefile-set", "4MB", 4, + HostSettingVerdict.OperatorOverride, includedFile, 7); + + var source = DarlingStoreHostProfile.FormatSourceForMcp(setting, dataDirectory); + + Assert.Equal("operator override (included.conf:7)", source); + Assert.DoesNotContain(dataDirectory, source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(Path.DirectorySeparatorChar.ToString(), source, StringComparison.Ordinal); + Assert.DoesNotContain(Path.AltDirectorySeparatorChar.ToString(), source, StringComparison.Ordinal); + } + + [Theory] + [InlineData(HostSettingVerdict.NotManaged, "not-managed (bring-your-own store; pg_settings.source = configuration file)")] + [InlineData(HostSettingVerdict.NotManaged, "unreadable — this connection's role, or this build of TimescaleDB, does not expose it")] + public void FormatSourceForMcp_NoSourceFile_ReturnsSourceDescriptionVerbatim(HostSettingVerdict verdict, string sourceDescription) + { + var setting = new HostSettingProfile( + "max_connections", "100", 100, sourceDescription, "100", 100, verdict); + + var source = DarlingStoreHostProfile.FormatSourceForMcp(setting, dataDirectory: null); + + Assert.Equal(sourceDescription, source); + } + + /* --------------------------------------- TryResolveProfileDataDirectory / UNC refusal (round-1 review, Low 4) --------------------------------------- */ + /* A UNC-configured managed data directory is refused rather than reached over the network as the service's + own identity (the NTLM-relay vector Low 4 describes) — mirrors DarlingManagedPostgres.TryResolveConfPath's + own UNC refusal for an include directive. */ + + [Fact] + public void TryResolveProfileDataDirectory_UncDataDirectory_ReturnsNull() + { + var postgres = new PostgresConfig { Managed = true, DataDirectory = @"\\host\share\pg" }; + + Assert.Null(DarlingStoreHostProfile.TryResolveProfileDataDirectory(postgres)); + } + + [Fact] + public void TryResolveProfileDataDirectory_LocalDataDirectory_ReturnsResolvedPath() + { + var local = Path.Combine(Path.GetTempPath(), "pmdarling-test-pgdata"); + var postgres = new PostgresConfig { Managed = true, DataDirectory = local }; + + var resolved = DarlingStoreHostProfile.TryResolveProfileDataDirectory(postgres); + + Assert.NotNull(resolved); + Assert.Equal(Path.GetFullPath(local), resolved); + } + + [Fact] + public void ResolveVolumeAnchor_ManagedUncDataDirectory_FallsBackToServiceBaseDirectory() + { + var postgres = new PostgresConfig { Managed = true, DataDirectory = @"\\host\share\pg" }; + + Assert.Equal(AppContext.BaseDirectory, DarlingStoreHostProfile.ResolveVolumeAnchor(postgres)); + } + /* --------------------------------------------- pg_settings unit normalization --------------------------------------------- */ [Theory] diff --git a/Darling/Darling.Tests/FleetSweepWebFeedTests.cs b/Darling/Darling.Tests/FleetSweepWebFeedTests.cs index d00d350b7..c21b649a7 100644 --- a/Darling/Darling.Tests/FleetSweepWebFeedTests.cs +++ b/Darling/Darling.Tests/FleetSweepWebFeedTests.cs @@ -558,6 +558,25 @@ public void TheSweepPage_ReadsTheCadence_AndWritesNothing() Assert.DoesNotContain("update_alert_settings", src, StringComparison.Ordinal); } + /// + /// The Store host section (#4214 part 2): the sweeps page's own "is the STORE sized right" panel, below + /// Watch items. Reads get_store_host through /api/read (never a raw fetch, never a second + /// implementation of the verdict math #4214 part 1 already owns) and highlights every stale-* verdict — + /// source pins, the file's own convention for a branch there is no JS runner to execute. + /// + [Fact] + public void TheSweepPage_HasAStoreHostSection_AndHighlightsEveryStaleVerdict() + { + var src = FrontendSource("js/pages/sweeps.js"); + + Assert.Contains("el(\"h3\", { class: \"section-title\", text: \"Store host\" })", src, StringComparison.Ordinal); + Assert.Contains("readTool(\"get_store_host\", {})", src, StringComparison.Ordinal); + + /* Every stale-* verdict highlighted, not just the exact stale_after_hardware_change string — a + future fifth verdict named "stale_something_else" must not silently fall through unhighlighted. */ + Assert.Contains("v.startsWith(\"stale\")", src, StringComparison.Ordinal); + } + /// The page is reachable: the shell carries the nav entry and the router routes the hash /// to the renderer — the two wiring points a new page can silently miss. [Fact] @@ -567,7 +586,9 @@ public void TheSweepPage_IsWiredIntoTheShellAndTheRouter() var app = FrontendSource("js/app.js"); Assert.Contains("import { renderSweeps } from \"./pages/sweeps.js\";", app, StringComparison.Ordinal); - Assert.Contains("renderSweeps(main)", app, StringComparison.Ordinal); + /* renderSweeps(main, opts) (#4214 round-1 review), not renderSweeps(main) — opts carries the poll + tick's { poll: true } through to the store host card, so it replays instead of re-fetching. */ + Assert.Contains("renderSweeps(main, opts)", app, StringComparison.Ordinal); Assert.Contains("#/sweeps", app, StringComparison.Ordinal); } @@ -606,7 +627,7 @@ public void TheSweepRoutes_LogThroughTheServiceLogger_NotTheProviderlessAppFacto "Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingWebHostService.cs"); Assert.Contains("builder.Logging.ClearProviders();", host, StringComparison.Ordinal); Assert.Contains( - "DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache);", + "DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache, postgresConfig);", host, StringComparison.Ordinal); } diff --git a/Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs b/Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs new file mode 100644 index 000000000..25fcec9be --- /dev/null +++ b/Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using ModelContextProtocol.Server; +using PerformanceMonitor.Common; +using Xunit; + +namespace Darling.Tests; + +/// +/// Round-1 review (#4214), Low 4: every DI-service-typed parameter a registered [McpServerTool] method +/// declares must be registered somewhere in DarlingMcpHostService.cs's own source text via +/// AddSingleton<T> or AddSingleton(new T. If a registration is ever dropped (or put behind a +/// condition), the SDK stops resolving that parameter from DI and instead serves it as a CLIENT argument — for +/// PostgresConfig specifically, a remote MCP caller could then set managed: true with a UNC +/// dataDirectory and make the service open a conf file on a remote share as its own account (the NTLM-relay +/// vector Low 4 in the round-1 review describes). Reuses +/// 's registered-tool-class regex rather than re-deriving +/// it, and for the same service-vs-model-supplied split the +/// budget census already uses. +/// +/// Coupled to item 3 by design: before item 3 trimmed the MCP host's DI seat to +/// AddSingleton(new PostgresConfig { Managed = ..., DataDirectory = ... }), the old +/// AddSingleton(config.Postgres) line did not name PostgresConfig anywhere in source text, so this +/// test only starts passing once item 3 lands. +/// +public sealed class McpServiceParameterDiSeatCensusTests +{ + [Fact] + public void EveryServiceParameterType_HasAnAddSingletonSeatInHostServiceSource() + { + var (_, types, _) = McpToolsListBudgetTests.BuildServedTools(); + var hostServiceSource = File.ReadAllText( + RepoPath("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpHostService.cs")); + + var serviceParameterTypeNames = types + .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) + .Where(m => m.GetCustomAttribute() is not null) + .SelectMany(m => m.GetParameters()) + .Select(p => p.ParameterType) + .Where(McpServedSchema.IsServiceParameter) + /* McpSchemaCompat registers McpToolGuideCatalog itself (get_tool_guide's own seat), never through + DarlingMcpHostService.cs's builder.Services calls — out of scope for this census. */ + .Where(t => t != typeof(McpToolGuideCatalog)) + .Select(t => (Nullable.GetUnderlyingType(t) ?? t).Name) + .Distinct(StringComparer.Ordinal) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + + var missing = serviceParameterTypeNames + .Where(name => !hostServiceSource.Contains($"AddSingleton<{name}>", StringComparison.Ordinal) + && !hostServiceSource.Contains($"AddSingleton(new {name}", StringComparison.Ordinal)) + .ToList(); + + Assert.True( + missing.Count == 0, + "DarlingMcpHostService.cs has no AddSingleton/AddSingleton(new T seat for: " + + string.Join(", ", missing) + + ". A DI-service-typed [McpServerTool] parameter with no registration is served as a client " + + "argument instead of resolved from DI."); + + /* A change worth knowing about even when nothing is missing: today's distinct complex service types are + exactly these five (NpgsqlDataSource, PostgresConfig, DarlingAnalysisService, ILogger, + StoreHostProfileCache) — pin the set so a sixth type appearing here is a deliberate, reviewed + addition rather than a silent one. StoreHostProfileCache added deliberately (#4214 round-1 review, + Medium 2): get_store_host's 5-minute shared cache, registered via the typed-generic + AddSingleton overload this test's own Contains check requires. */ + Assert.Equal( + new List { "DarlingAnalysisService", "ILogger", "NpgsqlDataSource", "PostgresConfig", "StoreHostProfileCache" }, + serviceParameterTypeNames); + } + + private static string RepoPath(params string[] segments) => Path.Combine(new[] { RepoRoot() }.Concat(segments).ToArray()); + + private static string RepoRoot([CallerFilePath] string thisFile = "") + => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "..")); +} diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpStoreHostTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpStoreHostTools.txt new file mode 100644 index 000000000..34211801e --- /dev/null +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpStoreHostTools.txt @@ -0,0 +1,2 @@ +# DarlingMcpStoreHostTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks. +tool get_store_host 510 diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs index 6a3fdc7df..5b2c84d3d 100644 --- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs +++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs @@ -167,6 +167,11 @@ combined total with blocking (#4267) changes on top. */ shared 32 KB budget and is not a served description either. */ /* #4198 (qs-top, merge): re-measured after merging origin/dev (dev now includes #4261+#4258+#4265+#4267+#4264+#4266+#4268+#4272); combined total with qs-top (#4273) changes on top. */ + /* #4214 part 2: +616 bytes for the new get_store_host tool (no parameters - a store-level snapshot, like + get_store_metrics), pinned at McpToolsListBudget/DarlingMcpStoreHostTools.txt. Merged with origin/dev's + own analysis-findings/collection-health/custom-view-catalog bump above (174,236); the constant below is + that base plus this PR's own +616, not the two deltas added by hand. */ + /* #4214 part 2 (merge after #4273): re-measured on the merged tree, dev (#4272+#4273) plus get_store_host. */ /* #4231 (merge): re-measured after merging origin/dev (dev now includes #4267+#4264 on top of the base #4231 branched from); combined total unchanged from the merge base since #4231 added no head bytes. */ /* #4231 (merge after #4273): re-measured after merging origin/dev (dev now includes #4272+#4273); #4231 @@ -175,7 +180,11 @@ shared 32 KB budget and is not a served description either. */ same disclosure. get_query_store_top's head drops the old Darling/Lite split for the shared sentence (422 -> 351, banking 71 bytes); get_top_queries_by_cpu and get_top_procedures_by_cpu each gain that same sentence (474 -> 600 and 406 -> 532, +126 bytes apiece). Net +181, matching Lite's twin change exactly. */ - private const int TotalCeilingBytes = 174_554; + /* #4214/#4282 (merge with #4279): re-measured on the tree combining both independent #4273-based branches - + this PR's own get_store_host (+616) and dev's #4279 Lite-parity head change (+181). Constant set to the + value McpToolsListBudgetTests itself measured on the merged tree, not the two deltas added by hand. */ + private const int TotalCeilingBytes = 175_170; + private const int ConvertedHeadCap = 1_000; diff --git a/Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs b/Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs index b42d457d7..6d6190aba 100644 --- a/Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs +++ b/Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs @@ -106,7 +106,8 @@ Its only caller is DarlingCliCommands.CheckSettingsAsync (--check-settings), a u + "one-shot, under ServiceCommandDeadlines.CliStoreReadSeconds (10s) rather than the serial " + "loop's 5s bound, and never called from the collection loop or at service startup: ruling 9 " + "keeps every store fact that scales with the store out of the startup profile log, so " - + "--check-settings is this regime's only caller"), + + "GatherStoreFactsAsync's only TWO callers are --check-settings and the get_store_host MCP " + + "read below, neither of them the collection loop"), }; /// diff --git a/Darling/Darling.Tests/SharedBaselineCacheTests.cs b/Darling/Darling.Tests/SharedBaselineCacheTests.cs index c43ff3035..fc7597e70 100644 --- a/Darling/Darling.Tests/SharedBaselineCacheTests.cs +++ b/Darling/Darling.Tests/SharedBaselineCacheTests.cs @@ -142,7 +142,7 @@ public void TheService_HandsOneBaselineCacheToEveryHostsAnalysis() Assert.Contains("new DarlingAnalysisService(postgres, planFetcher, _logger, _baselineCache)", mcp, StringComparison.Ordinal); var web = Code("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingWebHostService.cs"); - Assert.Contains("DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache);", web, StringComparison.Ordinal); + Assert.Contains("DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache, postgresConfig);", web, StringComparison.Ordinal); var endpoints = Code("Darling", "PerformanceMonitor.Darling.Service", "DarlingWebEndpoints.cs"); Assert.Contains("new DarlingAnalysisService(postgres, baselineCache: baselineCache)", endpoints, StringComparison.Ordinal); diff --git a/Darling/Darling.Tests/StoreHostProfileCacheTests.cs b/Darling/Darling.Tests/StoreHostProfileCacheTests.cs new file mode 100644 index 000000000..d9df266c3 --- /dev/null +++ b/Darling/Darling.Tests/StoreHostProfileCacheTests.cs @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Darling.Service; +using Xunit; + +namespace Darling.Tests; + +/// +/// get_store_host's 5-minute shared cache (#4214 round-1 review, Medium 2): pins the four promises the +/// design makes — one live gather however many callers race it, nothing gathered again inside the TTL, a +/// fresh gather once the TTL elapses, and a failed gather never cached. Exercised through +/// directly with a counting gather delegate and the injected clock — no +/// real source (a store connection, a disk read) is needed for any of it. +/// +public sealed class StoreHostProfileCacheTests +{ + private static readonly DateTime T0 = new(2026, 9, 25, 12, 0, 0, DateTimeKind.Utc); + + /// A minimal, valid — the cache never reads into its fields, only + /// caches/returns the reference, so the values themselves are arbitrary. + private static HostProfile FixtureProfile() => new() + { + Platform = "linux", + IsContainerized = false, + ProcessorCount = 4, + Memory = new HostMemoryProfile(8_589_934_592, null, 8_589_934_592, true, "GlobalMemoryStatusEx"), + DataVolume = new HostDataVolumeProfile(107_374_182_400, 53_687_091_200, "ext4", true), + IsManagedStore = false, + Store = new HostStoreFacts("17.4", "2.99.0", 1_000_000, 99.0, 0, 0, 0), + Settings = Array.Empty(), + }; + + /// Two callers racing a cold cache cost ONE gather. Deterministic, no Task.Delay: call 1's + /// gather signals started after incrementing the counter and before parking on release, so + /// awaiting started proves call 1 already holds the cache's gate; call 2 started against that same + /// held gate then has a synchronous prefix that runs only as far as its own await on the gate + /// (SemaphoreSlim.WaitAsync never completes synchronously when the semaphore is already held), so + /// it is provably parked — not merely fast — before the counter is checked. + [Fact] + public async Task TwoConcurrentCalls_GatherOnce() + { + var counter = 0; + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cache = new StoreHostProfileCache(TimeSpan.FromMinutes(5), () => T0); + + async Task Gather1(CancellationToken token) + { + Interlocked.Increment(ref counter); + started.SetResult(); + await release.Task; + return FixtureProfile(); + } + + Task Gather2(CancellationToken token) + { + Interlocked.Increment(ref counter); + return Task.FromResult(FixtureProfile()); + } + + var call1 = cache.GetOrGatherAsync(Gather1, CancellationToken.None); + await started.Task; + + var call2 = cache.GetOrGatherAsync(Gather2, CancellationToken.None); + + Assert.False(call2.IsCompleted); + Assert.Equal(1, counter); + + release.SetResult(); + var results = await Task.WhenAll(call1, call2); + + Assert.Equal(1, counter); + Assert.Same(results[0].Profile, results[1].Profile); + Assert.Equal(results[0].GatheredAtUtc, results[1].GatheredAtUtc); + } + + /// A call inside the 5-minute TTL is served the cached entry: no second gather, and the same + /// GatheredAtUtc (the first gather's clock reading) comes back both times. + [Fact] + public async Task ACallInsideFiveMinutes_GathersNothing() + { + var counter = 0; + var now = T0; + var cache = new StoreHostProfileCache(TimeSpan.FromMinutes(5), () => now); + + Task Gather(CancellationToken token) + { + Interlocked.Increment(ref counter); + return Task.FromResult(FixtureProfile()); + } + + var first = await cache.GetOrGatherAsync(Gather, CancellationToken.None); + + now = now.AddMinutes(4); + var second = await cache.GetOrGatherAsync(Gather, CancellationToken.None); + + Assert.Equal(1, counter); + Assert.Same(first.Profile, second.Profile); + Assert.Equal(first.GatheredAtUtc, second.GatheredAtUtc); + Assert.Equal(T0, second.GatheredAtUtc); + } + + /// A call after the clock has advanced 5 minutes past the cached entry's GatheredAtUtc + /// gathers again. + [Fact] + public async Task ACallAfterFiveMinutes_GathersAgain() + { + var counter = 0; + var now = T0; + var cache = new StoreHostProfileCache(TimeSpan.FromMinutes(5), () => now); + + Task Gather(CancellationToken token) + { + Interlocked.Increment(ref counter); + return Task.FromResult(FixtureProfile()); + } + + await cache.GetOrGatherAsync(Gather, CancellationToken.None); + + now = now.AddMinutes(5); + var second = await cache.GetOrGatherAsync(Gather, CancellationToken.None); + + Assert.Equal(2, counter); + Assert.Equal(now, second.GatheredAtUtc); + } + + /// A throwing gather is never cached: the exception propagates out of the FIRST call untouched + /// ( wraps nothing), and the gate's finally + /// still releases it, so a SECOND call is free to gather again rather than finding a stuck, faulted + /// cache. + [Fact] + public async Task AFailedGather_IsNotCached() + { + var counter = 0; + var cache = new StoreHostProfileCache(TimeSpan.FromMinutes(5), () => T0); + + Task ThrowingGather(CancellationToken token) + { + Interlocked.Increment(ref counter); + throw new InvalidOperationException("gather failed (test)"); + } + + Task Gather(CancellationToken token) + { + Interlocked.Increment(ref counter); + return Task.FromResult(FixtureProfile()); + } + + await Assert.ThrowsAsync( + () => cache.GetOrGatherAsync(ThrowingGather, CancellationToken.None)); + + var second = await cache.GetOrGatherAsync(Gather, CancellationToken.None); + + Assert.Equal(2, counter); + Assert.NotNull(second.Profile); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs index 8030e1eea..edcb0132f 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs @@ -97,7 +97,13 @@ internal readonly record struct HostStoreFacts( int UncompressedChunkCount); /// One row of the --check-settings table: a setting's live value, where it came from, what -/// this host would derive for it right now, and the verdict those two facts collapse to. +/// this host would derive for it right now, and the verdict those two facts collapse to. +/// / (round-1 review, Medium 1) are the same file/line +/// already embeds as text for the CLI/local-log surfaces; they exist +/// separately so can redact the path for a +/// remote MCP/web caller without touching itself. Default to null/0 — +/// only the managed-attribution branch of +/// ever sets them. internal readonly record struct HostSettingProfile( string Name, string CurrentValueDisplay, @@ -105,7 +111,9 @@ internal readonly record struct HostSettingProfile( string SourceDescription, string DerivedValueDisplay, long DerivedValueNormalized, - HostSettingVerdict Verdict); + HostSettingVerdict Verdict, + string? SourceFile = null, + int SourceLine = 0); /// The whole host/store/settings snapshot one --check-settings run or one service-start log /// line reports (#4214). @@ -309,6 +317,22 @@ internal static HostDataVolumeProfile GatherDataVolume(string anchorPath) return new HostDataVolumeProfile(0, 0, null, false); } + /// + /// The managed data directory, refused when it resolves to a UNC path (round-1 review, Low 4): a remote + /// MCP/web caller has no way to set postgres.dataDirectory today, but if it is ever configured to a + /// UNC path, this process would otherwise reach out to that share AS ITS OWN IDENTITY purely to read a conf + /// file for attribution or to stat the volume — an NTLM-relay vector, not just an unwanted network hop. + /// Deliberately NOT a change to itself — that + /// helper is shared by the writer/provisioning path too, where a UNC data directory is a different, out of + /// scope question. Mirrors DarlingManagedPostgres.TryResolveConfPath's own UNC refusal for an include + /// directive. + /// + internal static string? TryResolveProfileDataDirectory(PostgresConfig postgres) + { + var resolved = DarlingManagedPostgres.ResolveDataDirectory(postgres); + return resolved.StartsWith(@"\\", StringComparison.Ordinal) ? null : resolved; + } + /// /// The BYO fallback anchor for the "data volume" fact (#4214): a bring-your-own store has no data /// directory this process necessarily knows about — postgres.connectionString may point at a @@ -317,10 +341,12 @@ internal static HostDataVolumeProfile GatherDataVolume(string anchorPath) /// service's disk rather than the store's. A co-located BYO deployment (Docker Compose, same VM — the /// common case) gets a real, useful answer; a genuinely remote store gets a labelled figure instead of a /// missing one. Part 2 (get_store_host / the web panel) can revisit this once it has a place to say "this - /// may not be the store's own disk" beyond a CLI comment. + /// may not be the store's own disk" beyond a CLI comment. A managed store whose data directory resolves to + /// a UNC path (Low 4) takes the SAME fallback as BYO — refuses + /// it rather than reaching out to the share. /// internal static string ResolveVolumeAnchor(PostgresConfig postgres) - => postgres.Managed ? DarlingManagedPostgres.ResolveDataDirectory(postgres) : AppContext.BaseDirectory; + => postgres.Managed ? (TryResolveProfileDataDirectory(postgres) ?? AppContext.BaseDirectory) : AppContext.BaseDirectory; /* ======================================== Store facts (SQL) ========================================== */ @@ -549,6 +575,54 @@ internal static (string SourceDescription, HostSettingVerdict Verdict) ClassifyV } } + /// The MCP/web-safe rendering of one setting's source (round-1 review, Medium 1). The CLI/local + /// surfaces (, , the stale-setting + /// warning in DarlingWorker) print verbatim, + /// which for a managed block or an operator override is a full local filesystem path — fine on a shell the + /// operator already has, not fine handed to a remote MCP/web caller. is + /// the SAME managed data directory resolved for this call; pass it + /// once from the caller and reuse it for every row rather than re-resolving per setting. + internal static string FormatSourceForMcp(HostSettingProfile setting, string? dataDirectory) + { + if (setting.SourceFile is null) + { + /* not-managed, unreadable, and bring-your-own sources are already a kind word (pg_settings.source, + or one of the fixed unreadable/not-visible/no-block strings) — never a path, so nothing to + redact. */ + return setting.SourceDescription; + } + + var kind = setting.Verdict is HostSettingVerdict.Matches or HostSettingVerdict.StaleAfterHardwareChange + ? "managed block" + : "operator override"; + + return FormattableString.Invariant($"{kind} ({SanitizeSourcePathForMcp(setting.SourceFile, dataDirectory)}:{setting.SourceLine})"); + } + + /// Redacts a conf file's absolute path to a data-directory-relative path when it lives inside + /// , or to the bare file name when it does not (an include living outside + /// the data directory, or unresolved) — never a raw absolute path, and + /// never a leading ..\/../ from for an outside file, which + /// would still leak the parent directory tree. + private static string SanitizeSourcePathForMcp(string file, string? dataDirectory) + { + var fullFile = Path.GetFullPath(file); + if (dataDirectory is not null) + { + var fullDataDirectory = Path.GetFullPath(dataDirectory); + var withSeparator = fullDataDirectory.EndsWith(Path.DirectorySeparatorChar) + ? fullDataDirectory + : fullDataDirectory + Path.DirectorySeparatorChar; + + if (fullFile.StartsWith(withSeparator, StringComparison.OrdinalIgnoreCase)) + { + return Path.GetRelativePath(fullDataDirectory, fullFile); + } + } + + return Path.GetFileName(fullFile); + } + /// Converts a pg_settings (setting, unit) pair to the normalized unit each row of the /// table compares in: whole MB for a memory/WAL setting, the bare count for a connection/worker setting. /// Null for an unrecognized unit — the caller reports the row as unreadable rather than guessing. @@ -620,7 +694,10 @@ internal static async Task> GatherSettingProfi } } - var dataDirectory = postgres.Managed ? DarlingManagedPostgres.ResolveDataDirectory(postgres) : null; + /* Round-1 review, Low 4: TryResolveProfileDataDirectory refuses a UNC-resolved data directory (null), + which falls through to the dataDirectory-is-null branch below exactly like a bring-your-own store — + every setting reports not-managed, and this method never reads a file off the share. */ + var dataDirectory = postgres.Managed ? TryResolveProfileDataDirectory(postgres) : null; var results = new List(targets.Length); foreach (var (name, derivedValue, unit) in targets) @@ -650,7 +727,9 @@ internal static async Task> GatherSettingProfi var attribution = AttributeManagedSetting(dataDirectory, name); var (sourceDescription, verdict) = ClassifyVerdict(attribution, currentValue ?? long.MinValue, derivedValue); - results.Add(new HostSettingProfile(name, currentDisplay, currentValue ?? 0, sourceDescription, derivedDisplay, derivedValue, verdict)); + results.Add(new HostSettingProfile( + name, currentDisplay, currentValue ?? 0, sourceDescription, derivedDisplay, derivedValue, verdict, + attribution.File, attribution.Line)); } return results; diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs index fb817963d..76f4b2771 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs @@ -247,7 +247,7 @@ on a state it has not reasoned about. */ /// and the MCP host's analysis fill — so compare_analysis' banding here reads a series the store was already asked /// for this analysis hour from memory. Null keeps the analysis service's baselines private to it. /// - public static void MapAll(WebApplication app, NpgsqlDataSource postgres, CollectorRuntimeState collector, ILogger logger, BaselineCache? baselineCache = null) + public static void MapAll(WebApplication app, NpgsqlDataSource postgres, CollectorRuntimeState collector, ILogger logger, BaselineCache? baselineCache = null, PostgresConfig? postgresConfig = null) { /* Liveness AND collection state (#2953). The one health surface that does not read the store, which makes it the only one that can answer when the store IS the problem — so it reports the collector's @@ -299,7 +299,7 @@ its own empty state and the nav gate can read the count off the same response. * }); /* One GET per read-only tool, calling the tool method directly (no SQL/projection re-implementation). */ - foreach (var (name, handler) in BuildReadDispatch(logger)) + foreach (var (name, handler) in BuildReadDispatch(logger, postgresConfig)) { app.MapGet("/api/read/" + name, async (HttpContext context) => { @@ -2044,6 +2044,7 @@ private static CatalogRead R(string category, string description, params Catalog ["get_store_metrics"] = R(CatOverview, "The monitoring store's own size/compression/growth (self-metrics): a summary by default, object_kind to list one kind, an exact object_name for one object's daily series.", PInt("days_back", 30), PText("object_kind"), PText("object_name"), PLimit(DarlingMcpStoreMetricsTools.DefaultLimit)), ["get_store_log"] = R(CatOverview, "What the monitoring store's OWN PostgreSQL server log recorded - a per-class census with the capture denominator beside it, not the lines. Deliberately unbanded.", PHours(24), PLimit(DarlingMcpStoreLogTools.DefaultRetainedLimit), PAsOf()), ["get_store_query_stats"] = R(CatOverview, "The monitoring store's OWN SQL statements ranked by server-side cost (pg_stat_statements), split by the role that ran them (on a managed store: the web viewer, MCP tools, the Darling Viewer, or the service itself).", PText("role"), PText("order_by"), PTop(DarlingMcpStoreQueryStatsTools.DefaultTop), PBool("full_text", false)), + ["get_store_host"] = R(CatOverview, "The monitoring store's own HOST profile: platform/RAM/data volume, PostgreSQL/TimescaleDB facts, and a verdict per sizing-relevant setting against what this host would derive today - is the store sized right. No parameters; a snapshot."), ["get_collector_cost"] = R(CatOverview, "The monitoring tool's OWN per-collector cost on the monitored servers (self-monitoring) - which of our collectors is the most expensive to run. Pass collector_name for that one collector's daily trend instead of the ranked list.", PInt("days_back", 7), PText("collector_name")), ["get_collector_stall_probes"] = R(CatOverview, "The out-of-band server-wide wait samples taken while one of OUR collectors was stalled mid-read - what the monitored instance was doing inside the window the sequential sweep records nothing in. Carries the outcome census beside the samples, deliberately unbanded.", PServer(), PInt("days_back", 7), PLimit(DarlingMcpStallProbeTools.DefaultLimit)), ["get_oversized_plan_backlog"] = R(CatOverview, "The cached plans this tool measured as too large to capture inline, and what the out-of-band sweep has done about each one: per server the three verdict buckets (pending/captured/expired, a strict partition), the attempt figures on still-pending rows, the newest capture and expiry instants, and observed_bytes min/median/max, with the per-collector census beside them. Takes no window - a worklist updated in place, not a series. Pass server_name with include_rows for the claim keys.", PServer(), PBool("include_rows", false), PLimit(DarlingMcpOversizedPlanBacklogTools.DefaultLimit)), @@ -2729,8 +2730,15 @@ private static IResult UnsupportedMediaTypeResult() => /// dispatch WITH the host service's logger; callers with no host behind them (the parity tests, the triage /// section runner — neither maps that entry) build it bare, and there the null is the honest value: no /// service log is wired to receive anything. + /// + /// rides the same way, into the one entry whose tool takes a + /// config seat (get_store_host, #4214 part 2 — + /// needs PostgresConfig to resolve the managed data directory, which no other read on this surface + /// touches). builds the dispatch with the web host's own config; a caller with none + /// gets the tool's own "unavailable" envelope rather than a null-reference throw if that one entry is ever + /// invoked without it. /// - internal static IReadOnlyDictionary BuildReadDispatch(ILogger? logger = null) + internal static IReadOnlyDictionary BuildReadDispatch(ILogger? logger = null, PostgresConfig? postgresConfig = null) { return new Dictionary(StringComparer.Ordinal) { @@ -2939,6 +2947,11 @@ sizing the points itself (the OptionalDouble rule). */ ["get_store_metrics"] = (c, pg, an) => DarlingMcpStoreMetricsTools.GetStoreMetrics(pg, QueryInt(c, "days_back", null, 30), Str(c, "object_kind"), Str(c, "object_name"), Rows(c, "limit", DarlingMcpStoreMetricsTools.DefaultLimit)), ["get_store_log"] = (c, pg, an) => DarlingMcpStoreLogTools.GetStoreLog(pg, Hours(c, 24), Rows(c, "limit", DarlingMcpStoreLogTools.DefaultRetainedLimit), AsOf(c)), ["get_store_query_stats"] = (c, pg, an) => DarlingMcpStoreQueryStatsTools.GetStoreQueryStats(pg, Str(c, "role"), Str(c, "order_by") ?? "total_time", Rows(c, "top", DarlingMcpStoreQueryStatsTools.DefaultTop), QueryBool(c, "full_text", false)), + /* #4214 part 2: postgresConfig rides by closure (this method's own doc comment), the same way + logger does for get_sweep_reports two screens up. StoreHostProfileCache.Shared as a direct + static reference, not a new BuildReadDispatch parameter (round-1 review, Medium 2) — unlike + postgresConfig, the cache instance never varies by caller, so no threading is needed. */ + ["get_store_host"] = (c, pg, an) => DarlingMcpStoreHostTools.GetStoreHost(pg, postgresConfig, StoreHostProfileCache.Shared), ["get_collector_cost"] = (c, pg, an) => DarlingMcpCollectorCostTools.GetCollectorCost(pg, QueryInt(c, "days_back", null, 7), Str(c, "collector_name")), ["get_collector_stall_probes"] = (c, pg, an) => DarlingMcpStallProbeTools.GetCollectorStallProbes(pg, Server(c), QueryInt(c, "days_back", null, 7), Rows(c, "limit", DarlingMcpStallProbeTools.DefaultLimit)), ["get_oversized_plan_backlog"] = (c, pg, an) => DarlingMcpOversizedPlanBacklogTools.GetOversizedPlanBacklog(pg, Server(c), QueryBool(c, "include_rows", false), Rows(c, "limit", DarlingMcpOversizedPlanBacklogTools.DefaultLimit)), diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs index f8d0841bd..727bec9c3 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs @@ -492,6 +492,20 @@ listen value is itself loopback or a wildcard (0.0.0.0/::), which would collide /* Register services that MCP tools need via dependency injection. */ builder.Services.AddSingleton(postgres); + /* #4214 part 2: get_store_host's config seat — the same config this host loaded to reach this + point, so it cannot disagree with what actually connected. Read-only: GatherAsync only ever + reads dataDirectory/Managed off it, never writes. + Trimmed copy, not config.Postgres itself (round-1 review, Low 3): the full PostgresConfig + also carries the owner connection string. Nothing serializes this DI registration today, + but injecting only the two fields GatherAsync reads means a future [McpServerTool] that + takes a PostgresConfig parameter cannot receive the owner secret through this seat. */ + builder.Services.AddSingleton(new PostgresConfig { Managed = config.Postgres.Managed, DataDirectory = config.Postgres.DataDirectory }); + /* #4214 round-1 review, Medium 2: get_store_host's 5-minute shared cache — the process-wide + Shared instance, not a fresh one per request, so every caller (MCP and the direct-call web + path below) actually shares the one cache window. Typed-generic AddSingleton, not the + untyped AddSingleton(instance) overload: McpServiceParameterDiSeatCensusTests greps this + file's source text for AddSingleton specifically. */ + builder.Services.AddSingleton(StoreHostProfileCache.Shared); builder.Services.AddSingleton(new DarlingAnalysisService(postgres, planFetcher, _logger, _baselineCache)); /* The HOST's logger, registered as the bare ILogger a tool method can take as a DI parameter (the postgres pattern one line up — service-typed params are resolved per request and never @@ -716,6 +730,10 @@ a window had a quiet half and a bad half. A STORED read over the same query_stat /* #3021 get_store_log — the store's OWN server-log census, the second self-monitoring surface beside get_store_metrics. */ .WithGeminiCompatibleTools() + /* #4214 part 2 get_store_host — the store HOST's profile (platform/RAM/data volume, store + facts, per-setting verdicts), the read side of part 1's --check-settings verb. Darling-only: + Lite has no managed PostgreSQL store to profile. */ + .WithGeminiCompatibleTools() .WithGeminiCompatibleTools() .WithGeminiCompatibleTools() /* #2880 get_collector_stall_probes - the out-of-band server-wide wait samples taken diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs index 4395cc3fc..665807238 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs @@ -63,7 +63,7 @@ The writes this server performs (analysis mutes, Custom Views authoring, alert t ## Tools - This server exposes 159 tools. 88 are the same names Performance Monitor Lite exposes. The remaining 71 are unique to Darling: thirty-five are the PostgreSQL reads (`get_pg_*`), plus Custom Views, custom-alert-rule, alert-tuning and server-onboarding tools, and central-store-only reads (`get_fleet_overview`, `get_ag_health` — Availability Groups — `get_sweep_reports`, `get_store_metrics`, `get_store_log`). `get_blocking` = Lite's `get_blocked_process_reports`. + This server exposes 160 tools. 88 are the same names Performance Monitor Lite exposes. The remaining 72 are unique to Darling: thirty-five are the PostgreSQL reads (`get_pg_*`), plus Custom Views, custom-alert-rule, alert-tuning and server-onboarding tools, and central-store-only reads (`get_fleet_overview`, `get_ag_health` — Availability Groups — `get_sweep_reports`, `get_store_metrics`, `get_store_log`). `get_blocking` = Lite's `get_blocked_process_reports`. ### Reading an empty result diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreHostTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreHostTools.cs new file mode 100644 index 000000000..bdc5e4935 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreHostTools.cs @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// get_store_host (#4214 part 2): the store host profile over MCP/web — the same model +/// --check-settings prints (part 1, #4271), reached without a shell on the store's own host. Reuses +/// rather than a second copy (ruling 1): host facts (OS, +/// CPUs, RAM with its cgroup/authoritative reading, the data volume), the store facts (PostgreSQL/TimescaleDB +/// versions, size, lifetime buffer hit, lifetime temp bytes, the #4211 uncompressed-chunks-vs-RAM figure), and +/// a verdict per sizing-relevant setting: matches, stale-after-hardware-change, +/// operator-override or not-managed. +/// +/// Deliberately NOT a block on get_store_metrics (ruling 1): that tool is sized to +/// already, and every one of its calls would pay for a read that +/// most questions about the store never need. Lite has no managed PostgreSQL store, so this tool has no Lite +/// twin — see Lite.Tests/CrossAppMcpToolInventoryPinTests.KnownLiteMissingMcpTools. +/// +/// The mcp role reads this exactly as least-privilege as every other MCP read (ruling 2) — +/// nothing here widens it. The settings' FILE attribution is read off local disk by the service process +/// itself (), the same as part 1, needing no +/// PostgreSQL privilege at all; only the LIVE pg_settings values and the store facts travel over the +/// mcp role's connection. +/// +[McpServerToolType] +public sealed class DarlingMcpStoreHostTools +{ + [McpServerTool(Name = "get_store_host"), Description( + "The monitoring STORE's own host/settings profile - is IT sized right, not a monitored server. No " + + "server_name, no window: a snapshot. Reads the managed data directory's conf files off local disk for " + + "settings attribution (needs no elevated PostgreSQL privilege); a bring-your-own store reports every " + + "setting not-managed. stale_after_hardware_change means a managed block still sets a value this host's " + + "CURRENT RAM/disk would size differently today - the #4207/#4211 class of drift. <> Reports the " + + "host PostgreSQL/TimescaleDB runs on and whether the settings in force still match it: platform " + + "(OS, containerized, processor_count), ram (total_bytes, cgroup_limit_bytes if any, effective_bytes, " + + "authoritative, source), data_volume (total_bytes, free_bytes, filesystem, ready; on a bring-your-own " + + "store this is the SERVICE's own disk, not necessarily the store's - data_volume.note says so when " + + "managed is false), managed, and store (postgres_version, timescale_version, size_bytes, " + + "buffer_hit_ratio_percent, temp_bytes, uncompressed_chunk_bytes, uncompressed_chunk_count, " + + "uncompressed_chunk_percent_of_ram - the #4211 metric: how much of the store's raw ingest sits " + + "uncompressed against the RAM budget). settings is one row per sizing-relevant setting (shared_buffers, " + + "work_mem, effective_cache_size, maintenance_work_mem, the TimescaleDB worker counts, max_connections, " + + "max_wal_size): current (the live value), source (which conf block or ALTER SYSTEM set it), derived " + + "(what the sizing function would compute for THIS host right now) and verdict, one of four: matches " + + "(a managed block set it and it still agrees with today's derivation), stale_after_hardware_change (a " + + "managed block set it but re-deriving from the CURRENT host gives a different number - a resize the " + + "settings never caught up with), operator_override (postgresql.auto.conf, or a hand-edited line after " + + "every managed block, is what is actually in force), or not_managed (a bring-your-own store; nothing " + + "here ever wrote a block to compare against, so pg_settings.source is reported as-is). " + + "any_stale is true when one or more settings verdict is stale_after_hardware_change, so a caller can " + + "act on the summary without walking the whole table. gathered_at (UTC) is when this snapshot was " + + "taken; the store/settings facts are cached for up to 5 minutes and shared across callers, so a burst " + + "of calls costs one live read. This tool never writes a setting or a conf file.")] + public static async Task GetStoreHost(NpgsqlDataSource postgres, PostgresConfig? postgresConfig, StoreHostProfileCache cache) + { + if (postgresConfig is null) + { + /* Only reachable off the direct-call web path when a caller builds the dispatch table without a + config in scope (BuildReadDispatch's postgresConfig defaults to null for such callers, mirroring + its logger parameter) — true MCP dispatch always resolves this from DI, registered once at host + start from the same DarlingConfig every other seat on this host shares. */ + return McpHelpers.Status( + "unavailable", "The store host profile is not available on this call path (no postgres configuration in scope)."); + } + + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(ServiceCommandDeadlines.McpStoreHostProfileSeconds)); + + /* Round-1 review, Medium 2: the store/settings facts are cached for up to 5 minutes and shared + across callers, so a burst of calls costs one live read. The connection opens INSIDE the + gather delegate — a cache hit never calls this delegate at all, so a hit opens no connection. */ + var (profile, gatheredAtUtc) = await cache.GetOrGatherAsync( + async token => + { + await using var connection = await postgres.OpenConnectionAsync(token); + return await DarlingStoreHostProfile.GatherAsync(postgresConfig, connection, token); + }, + cts.Token); + + var anyStale = profile.Settings.Any(s => s.Verdict == HostSettingVerdict.StaleAfterHardwareChange); + var ramPercent = profile.Memory.EffectiveBytes > 0 + ? Math.Round(100.0 * profile.Store.UncompressedChunkBytes / profile.Memory.EffectiveBytes, 1) + : (double?)null; + + /* Round-1 review, Medium 1: resolved once, reused for every settings row below rather than + re-resolved per row — same managed data directory GatherSettingProfilesAsync itself resolved + for this call, so FormatSourceForMcp's containment test agrees with what actually attributed + each row. Round-1 review, Medium 1 follow-up: calls TryResolveProfileDataDirectory (not the + raw ResolveDataDirectory) so a UNC-configured managed store agrees with + GatherSettingProfilesAsync's own refusal (Low 4) instead of silently resolving a path + GatherSettingProfilesAsync never used. */ + var mcpDataDirectory = postgresConfig.Managed ? DarlingStoreHostProfile.TryResolveProfileDataDirectory(postgresConfig) : null; + + return JsonSerializer.Serialize(new + { + platform = profile.Platform, + containerized = profile.IsContainerized, + processor_count = profile.ProcessorCount, + ram = new + { + total_bytes = profile.Memory.TotalBytes, + cgroup_limit_bytes = profile.Memory.CgroupLimitBytes, + effective_bytes = profile.Memory.EffectiveBytes, + authoritative = profile.Memory.IsAuthoritative, + source = profile.Memory.SourceDescription, + }, + data_volume = new + { + total_bytes = profile.DataVolume.TotalBytes, + free_bytes = profile.DataVolume.FreeBytes, + filesystem = profile.DataVolume.Filesystem, + ready = profile.DataVolume.IsReady, + /* #4214 part 1's own doc comment on ResolveVolumeAnchor flagged this as part 2's to say: + a bring-your-own store's "data volume" is this SERVICE's own disk, which may not be the + store's disk at all (postgres.connectionString can point anywhere). */ + note = profile.IsManagedStore + ? null + : "This is the service's own disk, not necessarily the store's — a bring-your-own postgres.connectionString may point at a different host entirely.", + }, + managed = profile.IsManagedStore, + store = new + { + postgres_version = profile.Store.PostgresVersion, + timescale_version = profile.Store.TimescaleVersion, + size_bytes = profile.Store.StoreSizeBytes, + buffer_hit_ratio_percent = profile.Store.BufferHitRatioPercent is { } hit ? Math.Round(hit, 1) : (double?)null, + temp_bytes = profile.Store.TempBytes, + uncompressed_chunk_bytes = profile.Store.UncompressedChunkBytes, + uncompressed_chunk_count = profile.Store.UncompressedChunkCount, + uncompressed_chunk_percent_of_ram = ramPercent, + }, + settings = profile.Settings.Select(s => new + { + name = s.Name, + current = s.CurrentValueDisplay, + derived = s.DerivedValueDisplay, + source = DarlingStoreHostProfile.FormatSourceForMcp(s, mcpDataDirectory), + verdict = DarlingStoreHostProfile.DescribeVerdict(s.Verdict).Replace('-', '_'), + }), + any_stale = anyStale, + /* Round-1 review, correcting the original plan: naive-UTC (no trailing Z), matching every + other "_at" timestamp on an MCP payload in this codebase (DarlingFleetReader.GeneratedAt, + DarlingAgReader, DarlingMcpConfigHistoryTools's NaiveUtc helper, DarlingMcpTrendTools) — + not a DateTimeKind.Utc value, which would serialize with a trailing Z instead. */ + gathered_at = DateTime.SpecifyKind(gatheredAtUtc, DateTimeKind.Unspecified), + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_store_host", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs index 3b46c0b8f..685c5e78e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs @@ -792,7 +792,10 @@ this is a method the live-HTTP test calls too rather than a block copied into it /* #4220: web.publicBaseUrl's host, admitted as one extra allowed Host header value — see ConfigurePipeline's publicBaseUrlHost param and TriageLink.TryGetHost. */ var publicBaseUrlHost = TriageLink.TryGetHost(web.PublicBaseUrl); - ConfigurePipeline(_app, postgres, networkMode, networkListenIp, allowedCidr, accessToken, oidcClient, publicBaseUrlHost); + /* #4214 part 2 / round-1 review Low 3: trimmed copy, not config.Postgres itself — see the + matching comment at DarlingMcpHostService.cs's AddSingleton(PostgresConfig) registration. */ + var storeHostPostgresConfig = new PostgresConfig { Managed = config.Postgres.Managed, DataDirectory = config.Postgres.DataDirectory }; + ConfigurePipeline(_app, postgres, networkMode, networkListenIp, allowedCidr, accessToken, oidcClient, publicBaseUrlHost, storeHostPostgresConfig); /* #2389: name the authority for each half of what is being started — enabled/port from whichever plane the supervisor resolved, listen/allowFrom/token always from darling.json. */ @@ -996,7 +999,8 @@ internal void ConfigurePipeline( IPNetwork allowedCidr, string accessToken, DarlingWebOidcClient? oidcClient, - string? publicBaseUrlHost = null) + string? publicBaseUrlHost = null, + PostgresConfig? postgresConfig = null) { /* #2479 item 5: the gates below used to refuse silently. Rate-limited per (gate, source), because this port is LAN-exposed on purpose - see DarlingHttpRefusalLog. Created per @@ -1275,7 +1279,7 @@ it covers every route MapAll adds (including ones added on a parallel branch) wi await next(context); }); - DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache); + DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache, postgresConfig); app.UseDefaultFiles(); /* Static assets carry an ETag/Last-Modified already (the framework default); no-cache (#4188) makes diff --git a/Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs b/Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs index 6b0fd6a22..0ad570ba1 100644 --- a/Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs +++ b/Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs @@ -450,6 +450,34 @@ public static class ServiceCommandDeadlines /// public const int StartupHostProfileSeconds = CliStoreReadSeconds + 5; + /// + /// The get_store_host MCP read's own regime (#4214 part 2): a linked CTS wrapped around + /// DarlingStoreHostProfile.GatherAsync — the SAME orchestration --check-settings calls, + /// store facts included, unlike 's startup profile which never + /// reaches GatherStoreFactsAsync at all (ruling 9). + /// + /// Why the MCP surface needs its own outer bound where the CLI verb needs none. + /// CliStoreReadSeconds IS the bound for --check-settings because nothing waits behind an + /// operator's own console command. An MCP tool call is different: McpCommandDeadlines's own + /// header states that none of the [McpServerTool] methods take a CancellationToken, so a + /// caller that gives up leaves the read running with nothing to cancel it — exactly the gap that sweep + /// closed for the other 124 shipped reads. This read cannot simply take their constant + /// (McpCommandDeadlines.ReadSeconds, 20s): unlike every read that constant covers, this one's + /// component queries scale with the STORE (#3199) rather than the row, so this needed a regime of its + /// own rather than joining one sized for the opposite kind of read. + /// + /// ABOVE the sum of what it wraps, not tied to any one query in it. + /// GatherAsync runs, sequentially, the version/buffer/size/uncompressed-chunk reads + /// (GatherStoreFactsAsync, each individually bounded at ) and + /// then the pg_settings read (GatherSettingProfilesAsync, same per-statement bound) plus + /// eight local conf-file reads (sub-millisecond). Only two of those five statements are store-scaling + /// (StoreSizeSql, UncompressedChunkSizeSql — SerialLoopStoreSizeSourceTests' + /// regex), so the backstop clears TWO store-scaling statements at their own ceiling plus the same 5s + /// margin carries for everything outside the one query it + /// backstops. + /// + public const int McpStoreHostProfileSeconds = (CliStoreReadSeconds * 2) + 5; + /// /// The store<->service COMMAND plane's own bookkeeping — the stale-command reaper, the atomic /// claim, the desired-state store write a claimed command dispatches, the terminal result report, diff --git a/Darling/PerformanceMonitor.Darling.Service/StoreHostProfileCache.cs b/Darling/PerformanceMonitor.Darling.Service/StoreHostProfileCache.cs new file mode 100644 index 000000000..1418ac2b9 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/StoreHostProfileCache.cs @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// The 5-minute shared cache behind get_store_host (#4214 round-1 review, Medium 2): the store/settings +/// facts reads are a live pg_settings query plus a +/// pg_database_size read whose cost scales with the store (measured 3,177 ms on a 225 GiB store — see +/// SerialLoopStoreSizeSourceTests, #3199), so a burst of MCP calls should cost one live read, not one +/// per call. +/// +/// One immutable behind a single volatile reference field, not a bare +/// tuple field read without a lock: a tuple field is not read/written atomically and can tear under +/// concurrent access, where a single reference assignment is atomic and volatile gives the needed +/// cross-thread visibility for the lock-free fast path (a fresh hit never touches the gate). +/// +/// A failed gather is never cached: only assigns +/// after in 's own call returns successfully, and its +/// finally always releases the gate, so a thrown exception leaves the next call free to try again +/// rather than stuck behind a faulted cache. +/// +public sealed class StoreHostProfileCache : IDisposable +{ + /// The production singleton, registered once per host start (DarlingMcpHostService.cs, + /// DarlingWebEndpoints.cs's direct-call dispatch) so every caller shares the same 5-minute window. + /// A test builds its own instance instead, with an injected clock. + public static readonly StoreHostProfileCache Shared = new(TimeSpan.FromMinutes(5)); + + private sealed record CacheEntry(HostProfile Profile, DateTime GatheredAtUtc); + + private readonly TimeSpan _ttl; + private readonly Func _utcNow; + private readonly SemaphoreSlim _gate = new(1, 1); + private volatile CacheEntry? _entry; + + /// Public, not internal (unlike the rest of this file's neighbors, which favor internal): + /// GetStoreHost is a public [McpServerTool] method — the SDK's own convention, matched by + /// every other DI-service-typed parameter it takes (PostgresConfig, DarlingAnalysisService) + /// — and a parameter type may never be less accessible than the method it appears on (CS0051). + public StoreHostProfileCache(TimeSpan ttl, Func? utcNow = null) + { + _ttl = ttl; + _utcNow = utcNow ?? (() => DateTime.UtcNow); + } + + /// Returns the cached profile when it is still fresh; otherwise calls + /// exactly once, even under concurrent callers, and caches the result. The connection needs is the caller's to open, INSIDE the delegate — a cache hit never calls + /// at all, so a hit opens no connection. + /// + /// Internal, not public (unlike the class itself and its constructor): is + /// internal, so this method's signature can be no more accessible than that without CS0051. Every real + /// caller (GetStoreHost) and every test lives in this same assembly or + /// Darling.Tests (InternalsVisibleTo), so internal is not a reach restriction in + /// practice. + internal async Task<(HostProfile Profile, DateTime GatheredAtUtc)> GetOrGatherAsync( + Func> gather, CancellationToken cancellationToken) + { + if (TryGetFresh() is { } fresh) + { + return (fresh.Profile, fresh.GatheredAtUtc); + } + + await _gate.WaitAsync(cancellationToken); + try + { + /* Re-check after winning the gate: a caller that lost the race to whoever gathered while this + call waited should reuse that result rather than gathering a second time. */ + if (TryGetFresh() is { } freshAfterWait) + { + return (freshAfterWait.Profile, freshAfterWait.GatheredAtUtc); + } + + var profile = await gather(cancellationToken); + var entry = new CacheEntry(profile, _utcNow()); + _entry = entry; + return (entry.Profile, entry.GatheredAtUtc); + } + finally + { + _gate.Release(); + } + } + + private CacheEntry? TryGetFresh() + { + var entry = _entry; + return entry is not null && _utcNow() - entry.GatheredAtUtc < _ttl ? entry : null; + } + + /// CA1001 (owns the disposable ) — mirrors DarlingWebOidcClient's own + /// gate disposal. Safe for despite two hosts (DarlingMcpHostService, + /// DarlingWebEndpoints.cs's direct-call dispatch) both holding a reference to it: both register it + /// with the DI INSTANCE overload (AddSingleton<StoreHostProfileCache>(Shared)), and the + /// built-in container never disposes an instance it did not itself construct — so no container shutdown + /// disposes out from under the other host. + public void Dispose() => _gate.Dispose(); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js index 96da32cae..d33a81014 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js @@ -112,8 +112,10 @@ function serverRoute(rest) { /** * @param {object} [opts] — forwarded to renderServer; `{ poll: true }` marks this call as the 60s poll's own - * refresh rather than a hashchange (sub-tab click, deep link) or the first paint (#4190/#4191). Not meaningful - * to any other page today, so every other renderX() call below ignores it. + * refresh rather than a hashchange (sub-tab click, deep link) or the first paint (#4190/#4191). Also forwarded + * to renderSweeps (#4214 round-1 review), which threads it into the store host card so a poll tick replays + * that card's last-fetched payload instead of re-fetching (the profile it reports changes rarely — a hardware + * or version change, never per-tick). Every other renderX() call below still ignores it. */ function route(opts) { /* The session-expired takeover owns the DOM from the moment it fires until the operator signs in again — @@ -126,7 +128,7 @@ function route(opts) { setActiveNav(r); if (r.name === "server") renderServer(main, r.param, r.tab, opts); else if (r.name === "ag") renderAg(main); - else if (r.name === "sweeps") renderSweeps(main); + else if (r.name === "sweeps") renderSweeps(main, opts); else if (r.name === "alerts") renderAlerts(main); else if (r.name === "alertRules") renderAlertRuleList(main); else if (r.name === "alertEditor") renderAlertEditor(main, r.id, r.template); diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js index 4aaeb5beb..9bbc4e94d 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js @@ -24,7 +24,7 @@ */ import { el, mount, apiGet, readTool, loadingStrip, errorStrip, emptyStrip, localTime, relTime, rollupTextId, - fmtInt, bandClass, disclosure } from "../util.js"; + fmtInt, fmtMb, fmtPct, fmtBool, fmtText, bandClass, disclosure } from "../util.js"; /* The watch-item state vocabulary — the label each FleetSweepWatchStateMachine state owes an operator. The KEYS are the machine's own constants; Darling.Tests.FleetSweepWebFeedTests parses THIS OBJECT and compares @@ -54,10 +54,15 @@ let sweepSpanHours = 1; let selectedSweepId = null; let watchStateShown = null; // null = the open + carried default view; "closed" etc. on request -export async function renderSweeps(main) { +/* The store host card's last successfully fetched payload (#4214 round-1 review): a poll tick replays this + instead of re-fetching — see renderStoreHost below for why. null until the first successful fetch. */ +let lastStoreHostPayload = null; + +export async function renderSweeps(main, opts) { const detailBox = el("div", {}); const timelineBox = el("div", {}); const watchBox = el("div", {}); + const storeHostBox = el("div", {}); const cadenceMeta = el("div", { class: "meta", text: "" }); mount(main, [ @@ -72,6 +77,8 @@ export async function renderSweeps(main) { timelineBox, el("h3", { class: "section-title", text: "Watch items" }), watchBox, + el("h3", { class: "section-title", text: "Store host" }), + storeHostBox, ]); /* Independent sections load in parallel; each degrades alone (the triage page's rule). */ @@ -79,6 +86,7 @@ export async function renderSweeps(main) { renderDetail(detailBox); renderTimeline(timelineBox, detailBox); renderWatchItems(watchBox); + renderStoreHost(storeHostBox, opts); } /* ─────────────────────────── the cadence display (read-only) ─────────────────────────── */ @@ -412,6 +420,102 @@ function watchStateSev(state) { return "sev-Healthy"; } +/* ─────────────────────────── the store host profile (#4214 part 2) ─────────────────────────── */ + +/* get_store_host: is the monitoring STORE itself sized right, not a monitored server — a read-only snapshot + (platform/RAM/data volume, PostgreSQL/TimescaleDB facts, one row per sizing-relevant setting). Read-only on + this page like every other section here: the CLI --check-settings verb and the companion sizing issue own + any write. Darling-only (Lite has no managed PostgreSQL store), so this section has no Lite parity to keep. + + #4214 round-1 review: a poll tick (opts.poll — see app.js's route() doc comment) with an already-fetched + payload replays it instead of re-fetching. The tool's own server-side cache already makes a burst of calls + cheap (5 minutes, shared across callers), but this page's 60s poll would still cross the network and pay + the JSON round-trip every tick for a profile that changes only on a hardware or version change — never + per-tick. A hashchange/first-paint/span-change call (opts.poll not true) always fetches fresh. */ +async function renderStoreHost(box, opts) { + const isPoll = !!(opts && opts.poll === true); + if (isPoll && lastStoreHostPayload) { + renderStoreHostPayload(box, lastStoreHostPayload); + return; + } + + mount(box, loadingStrip("Loading store host…")); + const res = await readTool("get_store_host", {}); + if (res.kind === "error") return mount(box, errorStrip(res.message)); + if (res.kind !== "data") return mount(box, emptyStrip(res.message || "Store host profile not available.")); + + lastStoreHostPayload = res.data || {}; + renderStoreHostPayload(box, lastStoreHostPayload); +} + +/** The store host card's actual render, split out from the fetch above (#4214 round-1 review) so a poll tick + can replay a previously fetched payload through the SAME render path a fresh fetch uses. */ +function renderStoreHostPayload(box, p) { + const ram = p.ram || {}; + const vol = p.data_volume || {}; + const store = p.store || {}; + const settings = Array.isArray(p.settings) ? p.settings : []; + + const facts = table(["Host", "Value"], [ + [cellText("Platform"), cellText((p.platform || "—") + (p.containerized ? " (containerized)" : ""))], + [cellText("CPUs"), cellText(fmtInt(p.processor_count))], + [cellText("RAM"), cellText(fmtMb(bytesToMb(ram.effective_bytes)) + " effective of " + fmtMb(bytesToMb(ram.total_bytes)) + " total (" + fmtText(ram.source) + ")")], + [cellText("Data volume"), cellText(fmtMb(bytesToMb(vol.free_bytes)) + " free of " + fmtMb(bytesToMb(vol.total_bytes)) + " (" + fmtText(vol.filesystem) + ")" + (vol.note ? " — " + vol.note : ""))], + [cellText("Managed store"), cellText(fmtBool(p.managed))], + [cellText("PostgreSQL / TimescaleDB"), cellText(fmtText(store.postgres_version) + " / " + fmtText(store.timescale_version))], + [cellText("Store size"), cellText(fmtMb(bytesToMb(store.size_bytes)))], + [cellText("Lifetime buffer hit"), cellText(fmtPct(store.buffer_hit_ratio_percent))], + [cellText("Uncompressed chunks"), cellText(fmtInt(store.uncompressed_chunk_count) + " chunks, " + fmtMb(bytesToMb(store.uncompressed_chunk_bytes)) + " (" + fmtPct(store.uncompressed_chunk_percent_of_ram) + " of RAM)")], + ]); + + const settingsBody = settings.length + ? table( + ["Setting", "Current", "Derived", "Source", "Verdict"], + settings.map((s) => [ + cellText(s.name, "mono"), + cellText(s.current), + cellText(s.derived), + cellText(s.source), + el("td", { class: verdictSev(s.verdict), text: verdictLabel(s.verdict) }), + ])) + : emptyStrip("No sizing-relevant settings reported."); + + mount(box, [ + el("div", { class: "meta", text: p.any_stale + ? "One or more settings are stale after a hardware change." + : "No setting is stale after a hardware change." }), + facts, + el("h4", { class: "section-title", text: "Settings" }), + settingsBody, + ]); +} + +/** get_store_host reports every size in raw bytes; the shared fmtMb helper takes MB, so every field routes + through here rather than re-deriving the /1024/1024 at each call site. Null-safe (fmtMb itself handles it, + but the division would produce NaN first without this). */ +function bytesToMb(bytes) { + return bytes == null ? null : bytes / (1024 * 1024); +} + +/** The four get_store_host verdicts (DarlingStoreHostProfile.HostSettingVerdict), in the label an operator is + owed — the tool already renders the wire form with underscores (DescribeVerdict(...).Replace('-', '_')). */ +function verdictLabel(v) { + return v === "stale_after_hardware_change" ? "Stale after hardware change" + : v === "operator_override" ? "Operator override" + : v === "not_managed" ? "Not managed" + : v === "matches" ? "Matches" + : fmtText(v); +} + +/** Every stale-* verdict highlighted (ruling 5) — the sev- vocabulary the watch-items table above already + uses on this page, not a new one. */ +function verdictSev(v) { + if (typeof v !== "string") return ""; + if (v.startsWith("stale")) return "sev-Warning"; + if (v === "matches") return "sev-Healthy"; + return ""; +} + /* ─────────────────────────── small shared builders ─────────────────────────── */ function table(headers, rows) { diff --git a/Lite.Tests/CrossAppMcpToolInventoryPinTests.cs b/Lite.Tests/CrossAppMcpToolInventoryPinTests.cs index afd2e6122..a7531fc6c 100644 --- a/Lite.Tests/CrossAppMcpToolInventoryPinTests.cs +++ b/Lite.Tests/CrossAppMcpToolInventoryPinTests.cs @@ -180,6 +180,13 @@ Postgres store measuring ITSELF (hypertable sizes/compression, payload dims, who pg_stat_statements to read. A SKU boundary rather than a porting to-do. */ "get_store_query_stats", + /* #4214 part 2: the store HOST profile read (get_store_host) — platform/RAM/data volume, PostgreSQL + and TimescaleDB facts, and a per-setting verdict against the managed sizing this store's host was + derived from. Darling-ONLY by architecture, the get_store_metrics reason: Lite has no managed + PostgreSQL store for a host profile to be OF, and never writes the managed conf blocks the verdict + compares against. A SKU boundary rather than a porting to-do. */ + "get_store_host", + /* #2674: the collector-cost read (get_collector_cost) over collect.collector_cost — the tool measuring its OWN per-collector cost on the monitored servers. Darling-ONLY by architecture, the same as get_store_metrics: it is an internal self-metric over the central store, which Lite has no twin of. */ diff --git a/README.md b/README.md index 14df4f690..5b5c3b084 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ Configuration is a single JSON file with no schedule knobs. See the **[Darling o | Alerts (tray + email + webhooks) | Yes | Email + webhooks (headless) | Yes | | Themes | Dark and light | Dark and light | Dark and light | | Portability | Single executable | Portable service + viewer zip (Windows), service tarball (Linux) | Server-bound | -| MCP server (LLM integration) | Built-in (89 tools) | On request (159 tools) | Built into Dashboard (66 tools) | +| MCP server (LLM integration) | Built-in (89 tools) | On request (160 tools) | Built into Dashboard (66 tools) | --- @@ -365,7 +365,7 @@ claude mcp add --transport http --scope user sql-monitor http://localhost:5151/ ### Available Tools -**Lite** exposes 89 tools; **Darling** exposes 159 (the analysis + data-read surface plus its write tools) on request; the deprecated **Dashboard** exposes 66 (see [deprecated/Dashboard/README.md](deprecated/Dashboard/README.md)). Core tools are shared. +**Lite** exposes 89 tools; **Darling** exposes 160 (the analysis + data-read surface plus its write tools) on request; the deprecated **Dashboard** exposes 66 (see [deprecated/Dashboard/README.md](deprecated/Dashboard/README.md)). Core tools are shared. | Category | Tools | |---|---| diff --git a/llms.txt b/llms.txt index eedd9fb06..cad1ae471 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # SQL Server Performance Monitor -> Free, open-source SQL Server performance monitoring tool by Erik Darling (Darling Data, LLC). 42 SQL Server collectors, real-time alerts, graphical execution plan viewer, and a built-in MCP server with 89-159 tools for AI-powered analysis. Two current editions: Lite (standalone desktop app with DuckDB storage) and Darling (headless 24/7 service with a central PostgreSQL/TimescaleDB store). The older Full/Dashboard edition is deprecated. Replaces expensive commercial tools like SolarWinds SQL Sentry (formerly SentryOne) and SolarWinds DPA. MIT licensed. +> Free, open-source SQL Server performance monitoring tool by Erik Darling (Darling Data, LLC). 42 SQL Server collectors, real-time alerts, graphical execution plan viewer, and a built-in MCP server with 89-160 tools for AI-powered analysis. Two current editions: Lite (standalone desktop app with DuckDB storage) and Darling (headless 24/7 service with a central PostgreSQL/TimescaleDB store). The older Full/Dashboard edition is deprecated. Replaces expensive commercial tools like SolarWinds SQL Sentry (formerly SentryOne) and SolarWinds DPA. MIT licensed. - Supports SQL Server 2016-2025, Azure SQL Managed Instance, AWS RDS for SQL Server, and Azure SQL Database (Lite and Darling) - Darling's service runs on Windows or Linux, and its viewers are a Windows desktop app and a web dashboard