Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ed146a6
Add get_store_host MCP read for #4214 part 2 (checkpoint)
erikdarlingdata Sep 25, 2026
35e0767
#4214: bump README/llms.txt Darling tool census 159 -> 160
erikdarlingdata Sep 25, 2026
68aee17
#4214: live test proves get_store_host under the mcp/viewer least-pri…
erikdarlingdata Sep 25, 2026
9c6409b
#4214: get_store_host tools/list + response-size budget pins
erikdarlingdata Sep 25, 2026
b788b4e
#4214: web Store host panel on the Sweeps page
erikdarlingdata Sep 25, 2026
4382ed7
Merge remote-tracking branch 'origin/dev' into feature/4214-store-hos…
erikdarlingdata Sep 25, 2026
f4965be
#4214: fix two full-suite regressions from the get_store_host change
erikdarlingdata Sep 25, 2026
f71f154
Merge remote-tracking branch 'origin/dev' into feature/4214-store-hos…
erikdarlingdata Sep 25, 2026
722da33
#4214 round-1 fix item 3: trim PostgresConfig at the MCP/web DI seat
erikdarlingdata Sep 25, 2026
7dfd1d4
#4214 round-1 fix item 7: get_store_host description tail fixes
erikdarlingdata Sep 25, 2026
7988c7b
#4214 round-1 fix item 1: redact conf file paths in get_store_host's …
erikdarlingdata Sep 25, 2026
880dbd5
#4214 round-1 fix item 4: DI-seat census + UNC refusal for the manage…
erikdarlingdata Sep 25, 2026
7607187
#4214 round-1 fix item 6: random per-run password for the live test r…
erikdarlingdata Sep 25, 2026
b40531d
#4214 round-1 fix item 1 follow-up: mcpDataDirectory agrees with the …
erikdarlingdata Sep 25, 2026
f4b2e03
#4214 round-1 fix item 2: get_store_host's 5-minute shared cache
erikdarlingdata Sep 25, 2026
5e4eecd
#4214 round-1 fix item 2 follow-up: StoreHostProfileCache implements …
erikdarlingdata Sep 25, 2026
e16d078
#4214 round-1 fix item 3: sweeps.js's store host card skips re-fetchi…
erikdarlingdata Sep 25, 2026
682b465
#4214 round-1 fix item 5: store-fact assertions in the least-privileg…
erikdarlingdata Sep 25, 2026
9e3563d
Merge remote-tracking branch 'origin/dev' into lane-rigpass-4214
erikdarlingdata Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 52 additions & 17 deletions Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,52 +137,87 @@ 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, $$"""
{
"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);
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
64 changes: 64 additions & 0 deletions Darling/Darling.Tests/DarlingMcpStoreHostBudgetLiveTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #4214 part 2 / #4198: <c>get_store_host</c>'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.
///
/// <para>Measured against the <c>not_managed</c> (bring-your-own) shape deliberately: every setting's
/// <c>source</c> 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.</para>
/// </summary>
[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.");
}
}
Loading
Loading