diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index c7b426a..0000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,70 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ main ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ main ] - schedule: - - cron: '39 4 * * 4' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'csharp' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://git.io/codeql-language-support - - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@a35ac6e6798d72df5475948b28efb89edc2e19ca # v4.37.9 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@a35ac6e6798d72df5475948b28efb89edc2e19ca # v4.37.9 - - # ℹ️ Command-line programs to run using the OS shell. - # πŸ“š https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@a35ac6e6798d72df5475948b28efb89edc2e19ca # v4.37.9 diff --git a/AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj b/AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj index ce190a9..cf7a37d 100644 --- a/AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj +++ b/AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/AzureSearchEmulator.Aspire/AzureSearchEmulator.Aspire.csproj b/AzureSearchEmulator.Aspire/AzureSearchEmulator.Aspire.csproj index 4abdfb9..f3fc36e 100644 --- a/AzureSearchEmulator.Aspire/AzureSearchEmulator.Aspire.csproj +++ b/AzureSearchEmulator.Aspire/AzureSearchEmulator.Aspire.csproj @@ -28,7 +28,7 @@ - + diff --git a/AzureSearchEmulator.IntegrationTests/AzureSearchEmulator.IntegrationTests.csproj b/AzureSearchEmulator.IntegrationTests/AzureSearchEmulator.IntegrationTests.csproj index 0fd699f..cc2135b 100644 --- a/AzureSearchEmulator.IntegrationTests/AzureSearchEmulator.IntegrationTests.csproj +++ b/AzureSearchEmulator.IntegrationTests/AzureSearchEmulator.IntegrationTests.csproj @@ -1,4 +1,4 @@ - + net10.0 diff --git a/AzureSearchEmulator.IntegrationTests/DashboardIntegrationTests.cs b/AzureSearchEmulator.IntegrationTests/DashboardIntegrationTests.cs new file mode 100644 index 0000000..0af895f --- /dev/null +++ b/AzureSearchEmulator.IntegrationTests/DashboardIntegrationTests.cs @@ -0,0 +1,114 @@ +using System.Net; +using Azure.Search.Documents.Indexes.Models; +using Xunit; + +namespace AzureSearchEmulator.IntegrationTests; + +/// +/// Integration tests for the dashboard and health endpoint (issue #90), run against a +/// containerized emulator. +/// +/// +/// What these are really guarding is the coexistence. Serving a UI at / meant taking the +/// root away from OData's service document and adding anti-forgery middleware in front of every +/// route, and both of those are the kind of change that works on the developer's machine and +/// breaks the API surface somewhere else. So the assertions come in pairs: the dashboard and +/// /health answer, and the endpoints the Azure SDK depends on still answer as they did. +/// +public class DashboardIntegrationTests(EmulatorFactory factory) + : IClassFixture +{ + [Fact] + public async Task Root_ServesTheDashboard() + { + await factory.WaitUntilServingAsync(); + using var client = factory.CreateHttpClient(); + + var response = await client.GetAsync("/", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + + var html = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + // Prerendered server-side, so the status is in the markup rather than arriving later over + // the circuit β€” which is also what makes it assertable without driving a browser. + Assert.Contains("Azure Search Emulator", html); + Assert.Contains("Health checks", html); + Assert.Contains("Running normally", html); + } + + [Fact] + public async Task Dashboard_StylesheetIsServed() + { + await factory.WaitUntilServingAsync(); + using var client = factory.CreateHttpClient(); + + var response = await client.GetAsync("/css/dashboard.css", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/css", response.Content.Headers.ContentType?.MediaType); + } + + /// + /// The container has a writable indexes volume and no malformed definitions, so a healthy + /// answer here is the real thing rather than a check that cannot fail β€” the unit tests cover + /// the failing directions. + /// + [Fact] + public async Task Health_ReportsHealthy() + { + await factory.WaitUntilServingAsync(); + using var client = factory.CreateHttpClient(); + + var response = await client.GetAsync("/health", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + + /// + /// The dashboard took / from OData's service document, which sits on the same controller + /// as $metadata. Only the former was meant to go. + /// + [Fact] + public async Task Metadata_IsStillServed() + { + await factory.WaitUntilServingAsync(); + using var client = factory.CreateHttpClient(); + + var response = await client.GetAsync("/$metadata", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("SearchIndex", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + + /// + /// Anti-forgery middleware runs ahead of the API routes now. It is supposed to ignore requests + /// that carry no token, but a misconfiguration would reject exactly the unauthenticated writes + /// every SDK client makes β€” so this drives one through the SDK rather than raw HTTP. + /// + [Fact] + public async Task ApiWrites_AreNotBlockedByAntiforgery() + { + var client = factory.CreateSearchIndexClient(); + const string indexName = "test-dashboard-antiforgery"; + + var index = new SearchIndex(indexName) + { + Fields = { new SearchField("id", SearchFieldDataType.String) { IsKey = true } } + }; + + try + { + await client.CreateIndexAsync(index, TestContext.Current.CancellationToken); + + var fetched = await client.GetIndexAsync(indexName, TestContext.Current.CancellationToken); + Assert.Equal(indexName, fetched.Value.Name); + } + finally + { + await client.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken); + } + } +} diff --git a/AzureSearchEmulator.UnitTests/HealthCheckTests.cs b/AzureSearchEmulator.UnitTests/HealthCheckTests.cs new file mode 100644 index 0000000..5086f0c --- /dev/null +++ b/AzureSearchEmulator.UnitTests/HealthCheckTests.cs @@ -0,0 +1,183 @@ +using System.Text.Json; +using AzureSearchEmulator.Health; +using AzureSearchEmulator.Models; +using AzureSearchEmulator.Repositories; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Xunit; + +namespace AzureSearchEmulator.UnitTests; + +/// +/// Tests for the health checks behind the dashboard and /health (issue #90). +/// +/// +/// These run against real directories rather than an abstraction over the filesystem, because the +/// conditions the checks exist to catch β€” a directory that is missing, or present but not writable +/// β€” are filesystem states, and a mocked IFileSystem reporting them would only be testing +/// the mock's own opinion of what a failed write looks like. +/// +public class HealthCheckTests : IDisposable +{ + private readonly string _root = + Path.Join(Path.GetTempPath(), $"azsearchemu-health-{Guid.NewGuid():N}"); + + public void Dispose() + { + if (!Directory.Exists(_root)) + { + return; + } + + // A directory the writability test chmod'ed has to be made writable again or the delete + // fails and leaks the temp folder. Nothing is chmod'ed on Windows, where that test skips. + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(_root, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + Directory.Delete(_root, recursive: true); + } + + private IndexStorageHealthCheck CreateStorageCheck(string directory) => + new(Options.Create(new EmulatorOptions { IndexesDirectory = directory })); + + private static Task Run(IHealthCheck check) => + check.CheckHealthAsync(new HealthCheckContext()); + + [Fact] + public async Task IndexStorage_WithWritableDirectory_IsHealthy() + { + Directory.CreateDirectory(_root); + + var result = await Run(CreateStorageCheck(_root)); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal(_root, result.Data["indexesDirectory"]); + } + + /// + /// The emulator creates the indexes directory on first use, so a fresh install has none. That + /// is the normal state rather than a fault, and reporting it as unhealthy would mean every + /// first run of the tool showed a red dashboard. + /// + [Fact] + public async Task IndexStorage_WhenDirectoryDoesNotYetExist_IsHealthy() + { + Directory.CreateDirectory(_root); + var notYetCreated = Path.Join(_root, "indexes"); + + var result = await Run(CreateStorageCheck(notYetCreated)); + + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task IndexStorage_WhenNeitherDirectoryNorParentExists_IsUnhealthy() + { + var unreachable = Path.Join(_root, "no", "such", "path"); + + var result = await Run(CreateStorageCheck(unreachable)); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("cannot be created", result.Description); + } + + /// + /// The case the check is really for: a Docker volume mounted read-only, or a tool launched from + /// a directory the user cannot write to. Without it the failure first appears as a 500 from + /// whichever indexing request happened to run. + /// + [Fact] + public async Task IndexStorage_WhenDirectoryIsNotWritable_IsUnhealthy() + { + // Written as an if rather than Assert.SkipWhen so the platform analyzer can see that + // SetUnixFileMode is only reached off Windows; Skip throws, which it cannot narrow on. + if (OperatingSystem.IsWindows()) + { + Assert.Skip("Unix file modes are the mechanism being used to make the directory read-only."); + return; + } + + Assert.SkipWhen(Environment.UserName == "root", + "root writes to a read-only directory regardless of its mode."); + + Directory.CreateDirectory(_root); + File.SetUnixFileMode(_root, UnixFileMode.UserRead | UnixFileMode.UserExecute); + + var result = await Run(CreateStorageCheck(_root)); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("not writable", result.Description); + } + + [Fact] + public async Task IndexStorage_LeavesNoProbeFileBehind() + { + Directory.CreateDirectory(_root); + + await Run(CreateStorageCheck(_root)); + + Assert.Empty(Directory.GetFileSystemEntries(_root)); + } + + [Fact] + public async Task IndexDefinitions_WithReadableDefinitions_ReportsTheCount() + { + Directory.CreateDirectory(_root); + WriteIndex("products"); + WriteIndex("people"); + + var result = await Run(new IndexDefinitionsHealthCheck(CreateRepository())); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal(2, result.Data["indexCount"]); + } + + /// + /// Degraded rather than Unhealthy: the emulator still serves every index that parses, and the + /// checks aggregate into one overall status, so failing outright would claim the whole service + /// is down over one hand-edited file. + /// + [Fact] + public async Task IndexDefinitions_WithAMalformedDefinition_IsDegraded() + { + Directory.CreateDirectory(_root); + WriteIndex("products"); + File.WriteAllText(Path.Join(_root, "broken.index.json"), "{ this is not json"); + + var result = await Run(new IndexDefinitionsHealthCheck(CreateRepository())); + + Assert.Equal(HealthStatus.Degraded, result.Status); + } + + [Fact] + public async Task IndexDefinitions_WithNoIndexes_IsHealthy() + { + Directory.CreateDirectory(_root); + + var result = await Run(new IndexDefinitionsHealthCheck(CreateRepository())); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal(0, result.Data["indexCount"]); + } + + private ISearchIndexRepository CreateRepository() => + new FileSearchIndexRepository( + new JsonSerializerOptions(JsonSerializerDefaults.Web), + Options.Create(new EmulatorOptions { IndexesDirectory = _root })); + + private void WriteIndex(string name) + { + var index = new SearchIndex + { + Name = name, + Fields = [new SearchField { Name = "Id", Type = "Edm.String", Key = true }] + }; + + File.WriteAllText( + Path.Join(_root, $"{name}.index.json"), + JsonSerializer.Serialize(index, new JsonSerializerOptions(JsonSerializerDefaults.Web))); + } +} diff --git a/AzureSearchEmulator/Components/App.razor b/AzureSearchEmulator/Components/App.razor new file mode 100644 index 0000000..8ac93a7 --- /dev/null +++ b/AzureSearchEmulator/Components/App.razor @@ -0,0 +1,21 @@ +@* Root component (issue #90). Hand-written rather than taken from the Blazor template: the + template's version pulls in bootstrap, the sample stylesheet and the blazor error UI, none of + which this dashboard uses. *@ + + + + + + + + + + + + + + + + + + diff --git a/AzureSearchEmulator/Components/Layout/MainLayout.razor b/AzureSearchEmulator/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..9bfc2f1 --- /dev/null +++ b/AzureSearchEmulator/Components/Layout/MainLayout.razor @@ -0,0 +1,30 @@ +@inherits LayoutComponentBase + +
+
+ + + + Azure Search Emulator + Local development service + + +
+ +
+ @Body +
+ + +
diff --git a/AzureSearchEmulator/Components/Pages/Dashboard.razor b/AzureSearchEmulator/Components/Pages/Dashboard.razor new file mode 100644 index 0000000..da9ec70 --- /dev/null +++ b/AzureSearchEmulator/Components/Pages/Dashboard.razor @@ -0,0 +1,197 @@ +@page "/" +@rendermode InteractiveServer +@inject EmulatorStatusService StatusService +@implements IDisposable + +Azure Search Emulator + +

Azure Search Emulator status

+ +@if (_status is null) +{ +
+ +

Checking emulator health…

+
+} +else +{ +
+
+ +
+

Emulator status

+

@StatusHeadline(_status.Status)

+
+
+
+
+
Version
+
@_status.Version
+
+
+
Environment
+
@_status.Environment
+
+
+
Uptime
+
@FormatUptime(_status.Uptime)
+
+
+
Checks took
+
@FormatDuration(_status.TotalDuration)
+
+
+
+ +
+
+

Health checks

+ +
+ +
    + @foreach (var entry in _status.Entries) + { +
  • + +
    +

    @Humanize(entry.Name)

    + @if (!string.IsNullOrWhiteSpace(entry.Description)) + { +

    @entry.Description

    + } +
    + @entry.Status +
  • + } +
+
+ +
+

Storage

+

Index definitions and their Lucene data are written here.

+

@_status.IndexesDirectory

+
+ +

+ Last checked @_status.CheckedAt.ToLocalTime().ToString("HH:mm:ss") · refreshes + automatically every @RefreshInterval.TotalSeconds seconds. +

+} + +@code { + // Long enough that an idle dashboard is not re-probing storage every second, short enough + // that a mount going read-only shows up while the user is still looking at the page. + private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(15); + + private EmulatorStatus? _status; + private bool _refreshing; + private PeriodicTimer? _timer; + private CancellationTokenSource? _cts; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(CancellationToken.None); + } + + protected override void OnAfterRender(bool firstRender) + { + // Started after the first render rather than in OnInitializedAsync, which also runs during + // the prerender pass β€” a timer started there would tick against a circuit that no longer + // exists once the interactive one takes over. + if (!firstRender) + { + return; + } + + _cts = new CancellationTokenSource(); + _timer = new PeriodicTimer(RefreshInterval); + _ = PollAsync(_cts.Token); + } + + private async Task PollAsync(CancellationToken cancellationToken) + { + try + { + while (await _timer!.WaitForNextTickAsync(cancellationToken)) + { + await LoadAsync(cancellationToken); + await InvokeAsync(StateHasChanged); + } + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) + { + // The page went away mid-tick: Dispose cancels the token and disposes the timer, and + // either can surface here depending on where the loop happened to be. Neither is a + // fault worth tearing down the circuit over. + } + } + + private async Task RefreshAsync() + { + _refreshing = true; + + try + { + await LoadAsync(_cts?.Token ?? CancellationToken.None); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) + { + // Navigated away while the checks were still running. + } + finally + { + _refreshing = false; + } + } + + private async Task LoadAsync(CancellationToken cancellationToken) + { + _status = await StatusService.GetStatusAsync(cancellationToken); + } + + public void Dispose() + { + _cts?.Cancel(); + _cts?.Dispose(); + _timer?.Dispose(); + } + + private static string StatusClass(HealthStatus status) => status switch + { + HealthStatus.Healthy => "is-healthy", + HealthStatus.Degraded => "is-degraded", + _ => "is-unhealthy" + }; + + private static string StatusHeadline(HealthStatus status) => status switch + { + HealthStatus.Healthy => "Running normally", + HealthStatus.Degraded => "Running with problems", + _ => "Not serving requests correctly" + }; + + private static string FormatDuration(TimeSpan duration) => + duration.TotalMilliseconds < 1000 + ? $"{duration.TotalMilliseconds:0} ms" + : $"{duration.TotalSeconds:0.0} s"; + + private static string FormatUptime(TimeSpan uptime) => uptime switch + { + { TotalMinutes: < 1 } => $"{uptime.TotalSeconds:0} sec", + { TotalHours: < 1 } => $"{uptime.TotalMinutes:0} min", + { TotalDays: < 1 } => $"{uptime.Hours}h {uptime.Minutes}m", + _ => $"{(int)uptime.TotalDays}d {uptime.Hours}h" + }; + + /// Health check names are registered as identifiers; the dashboard shows prose. + private static string Humanize(string name) => name switch + { + "index-storage" => "Index storage", + "index-definitions" => "Index definitions", + _ => name + }; +} diff --git a/AzureSearchEmulator/Components/Routes.razor b/AzureSearchEmulator/Components/Routes.razor new file mode 100644 index 0000000..b28c13f --- /dev/null +++ b/AzureSearchEmulator/Components/Routes.razor @@ -0,0 +1,16 @@ + + + + + + + + Not found +
+

Not found

+

There is nothing at this address.

+

Back to the dashboard

+
+
+
+
diff --git a/AzureSearchEmulator/Components/_Imports.razor b/AzureSearchEmulator/Components/_Imports.razor new file mode 100644 index 0000000..fff5ff8 --- /dev/null +++ b/AzureSearchEmulator/Components/_Imports.razor @@ -0,0 +1,8 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.Extensions.Diagnostics.HealthChecks +@using AzureSearchEmulator.Components +@using AzureSearchEmulator.Components.Layout +@using AzureSearchEmulator.Health diff --git a/AzureSearchEmulator/Health/EmulatorStatus.cs b/AzureSearchEmulator/Health/EmulatorStatus.cs new file mode 100644 index 0000000..19d2c61 --- /dev/null +++ b/AzureSearchEmulator/Health/EmulatorStatus.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace AzureSearchEmulator.Health; + +/// +/// The health report plus the handful of facts about the running emulator the dashboard shows +/// alongside it (issue #90). +/// +public record EmulatorStatus( + HealthStatus Status, + TimeSpan TotalDuration, + IReadOnlyList Entries, + string Version, + string Environment, + string IndexesDirectory, + TimeSpan Uptime, + DateTimeOffset CheckedAt); + +public record EmulatorStatusEntry( + string Name, + HealthStatus Status, + string? Description, + TimeSpan Duration, + IReadOnlyDictionary Data); + +/// +/// Produces an for the dashboard. +/// +/// +/// The dashboard runs in-process, so it calls directly instead of +/// fetching its own /health endpoint over HTTP. Going back out through the network stack +/// would mean the page needed to know its own externally reachable URL β€” which, behind the +/// container's port mapping or an Aspire proxy, it does not β€” to report on checks that are sitting +/// in the same process. +/// +public class EmulatorStatusService( + HealthCheckService healthCheckService, + IHostEnvironment hostEnvironment, + Microsoft.Extensions.Options.IOptions options, + TimeProvider timeProvider) +{ + private static readonly string AssemblyVersion = GetAssemblyVersion(); + + private static readonly long StartedAtTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); + + public async Task GetStatusAsync(CancellationToken cancellationToken = default) + { + var report = await healthCheckService.CheckHealthAsync(cancellationToken); + + var entries = report.Entries + .Select(i => new EmulatorStatusEntry( + i.Key, + i.Value.Status, + // A failing check's exception message is the useful part and the description is + // often null, so fall back to it rather than showing a bare "Unhealthy". + i.Value.Description ?? i.Value.Exception?.Message, + i.Value.Duration, + i.Value.Data)) + .OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new EmulatorStatus( + report.Status, + report.TotalDuration, + entries, + AssemblyVersion, + hostEnvironment.EnvironmentName, + options.Value.IndexesDirectory, + System.Diagnostics.Stopwatch.GetElapsedTime(StartedAtTimestamp), + timeProvider.GetUtcNow()); + } + + private static string GetAssemblyVersion() + { + var assembly = typeof(EmulatorStatusService).Assembly; + + var informational = assembly.GetCustomAttribute() + ?.InformationalVersion; + + if (!string.IsNullOrEmpty(informational)) + { + // The informational version carries a "+" suffix when the build has source + // link enabled; the dashboard wants the version, not the provenance. + var plus = informational.IndexOf('+'); + return plus < 0 ? informational : informational[..plus]; + } + + return assembly.GetName().Version?.ToString() ?? "unknown"; + } +} diff --git a/AzureSearchEmulator/Health/IndexDefinitionsHealthCheck.cs b/AzureSearchEmulator/Health/IndexDefinitionsHealthCheck.cs new file mode 100644 index 0000000..0661439 --- /dev/null +++ b/AzureSearchEmulator/Health/IndexDefinitionsHealthCheck.cs @@ -0,0 +1,50 @@ +using AzureSearchEmulator.Repositories; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace AzureSearchEmulator.Health; + +/// +/// Reports whether every index definition on disk still deserializes (issue #90). +/// +/// +/// The definitions are plain JSON files in a directory users are told about and mount as a volume, +/// so hand-editing one is an expected thing to do. A malformed file does not surface until some +/// request happens to enumerate it, and then it surfaces as a 500 from an unrelated route β€” +/// throws partway through, taking the listing with it. +/// Reporting it here names the file instead. +/// +/// Degraded rather than Unhealthy: the emulator is still serving every index that does parse, and +/// the checks are aggregated into one overall status, so failing outright would claim the service +/// is down when one stray file is the whole of the problem. +/// +/// +public class IndexDefinitionsHealthCheck(ISearchIndexRepository searchIndexRepository) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var count = 0; + + try + { + await foreach (var index in searchIndexRepository.GetAll().WithCancellation(cancellationToken)) + { + _ = index; + count++; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException + or System.Text.Json.JsonException) + { + return HealthCheckResult.Degraded( + $"One or more index definitions could not be read: {ex.Message}", + ex, + new Dictionary { ["indexesRead"] = count }); + } + + return HealthCheckResult.Healthy( + count == 1 ? "1 index definition loaded." : $"{count} index definitions loaded.", + new Dictionary { ["indexCount"] = count }); + } +} diff --git a/AzureSearchEmulator/Health/IndexStorageHealthCheck.cs b/AzureSearchEmulator/Health/IndexStorageHealthCheck.cs new file mode 100644 index 0000000..f5d216f --- /dev/null +++ b/AzureSearchEmulator/Health/IndexStorageHealthCheck.cs @@ -0,0 +1,105 @@ +using AzureSearchEmulator.Repositories; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; + +namespace AzureSearchEmulator.Health; + +/// +/// Reports whether the emulator can actually read and write the directory its indexes live in +/// (issue #90). +/// +/// +/// This is the check worth having. Everything the emulator does is backed by that directory, and +/// the ways it goes wrong are all environmental rather than code faults: a Docker volume mounted +/// read-only, a dotnet tool launched from a directory the user cannot write to, a path +/// configured to somewhere that does not exist. Each produces failures at index-creation or +/// indexing time that read like emulator bugs, so surfacing it as health turns a confusing 500 +/// into a stated cause. +/// +/// Writability is probed rather than inferred from file attributes, because on the platforms the +/// emulator runs on the attributes do not answer the question β€” a POSIX mode bit, an ACL, a +/// read-only bind mount and a full disk all present differently, and only an actual write covers +/// them all. +/// +/// +public class IndexStorageHealthCheck(IOptions options) : IHealthCheck +{ + private readonly EmulatorOptions _options = options.Value; + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var directory = _options.IndexesDirectory; + + var data = new Dictionary + { + ["indexesDirectory"] = directory + }; + + if (!Directory.Exists(directory)) + { + // Not a failure: the directory is created on first use, so its absence on a fresh + // install is the normal state rather than a fault. What matters is whether the + // emulator would be able to create it, which is what the parent's writability says. + var parent = Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(directory)); + + if (parent == null || !Directory.Exists(parent)) + { + return HealthCheckResult.Unhealthy( + $"The indexes directory '{directory}' does not exist and neither does its parent, so it cannot be created.", + data: data); + } + + var parentProbe = await TryWrite(parent, cancellationToken); + + return parentProbe == null + ? HealthCheckResult.Healthy("No indexes have been created yet.", data) + : HealthCheckResult.Unhealthy( + $"The indexes directory '{directory}' does not exist and cannot be created: {parentProbe}", + data: data); + } + + var probe = await TryWrite(directory, cancellationToken); + + if (probe != null) + { + return HealthCheckResult.Unhealthy( + $"The indexes directory '{directory}' is not writable: {probe}", + data: data); + } + + return HealthCheckResult.Healthy("The indexes directory is readable and writable.", data); + } + + /// null when the write succeeded, otherwise a description of why it did not. + private static async Task TryWrite(string directory, CancellationToken cancellationToken) + { + // A uniquely named file, so two emulators sharing an indexes volume β€” or one being probed + // concurrently β€” cannot collide and report each other's probe as a failure. + var path = Path.Join(directory, $".healthcheck-{Guid.NewGuid():N}.tmp"); + + try + { + await File.WriteAllTextAsync(path, string.Empty, cancellationToken); + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return ex.Message; + } + finally + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The probe already answered the question it was asked; a leftover empty file is + // not worth failing health over, and it is excluded from the *.index.json glob + // the repository enumerates. + } + } + } +} diff --git a/AzureSearchEmulator/Program.cs b/AzureSearchEmulator/Program.cs index 8c73365..a33c729 100644 --- a/AzureSearchEmulator/Program.cs +++ b/AzureSearchEmulator/Program.cs @@ -1,9 +1,12 @@ using System.Text.Json; using AzureSearchEmulator; +using AzureSearchEmulator.Components; using AzureSearchEmulator.ErrorHandling; +using AzureSearchEmulator.Health; using AzureSearchEmulator.Indexing; using AzureSearchEmulator.Models; using AzureSearchEmulator.Repositories; +using AzureSearchEmulator.Routing; using AzureSearchEmulator.SearchData; using AzureSearchEmulator.Searching; using Microsoft.AspNetCore.Http.Extensions; @@ -71,6 +74,10 @@ // as a filter rather than applied at each call site so every existing rejection β€” // and any added later β€” carries the shape the SDK reads (issue #40). options.Filters.Add(); + + // Frees "/" for the dashboard; see the convention for why the service document is the + // endpoint that gives way (issue #90). + options.Conventions.Add(new ODataServiceDocumentConvention()); }) .AddJsonOptions(options => { @@ -130,6 +137,23 @@ builder.Services.AddTransient(); builder.Services.AddSingleton(); +// Health checks, surfaced both at /health for a monitor and on the dashboard for a human +// (issue #90). The names are the identifiers the dashboard maps to prose. +builder.Services.AddHealthChecks() + .AddCheck("index-storage") + .AddCheck("index-definitions"); + +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddScoped(); + +// The dashboard (issue #90). Blazor rather than a separate SPA so the whole UI ships inside the +// same assembly as the emulator β€” no second build step, no static bundle to keep in sync, and +// nothing extra for `dotnet tool install` to pull down. The interactive server render mode means +// the status panel refreshes over the existing circuit instead of the page needing to know its +// own externally reachable URL to poll itself. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + var app = builder.Build(); // Outermost, so it also covers the model binding and OData formatter work that runs before a @@ -194,8 +218,28 @@ app.UseRouting(); +// Serves wwwroot, which holds the dashboard's stylesheet and nothing else. Registered after +// UseRouting so these requests still pass through the request logging above rather than being +// short-circuited ahead of it. +app.UseStaticFiles(); + +// Required by MapRazorComponents, which stamps anti-forgery metadata onto its endpoints. It only +// validates tokens on the form posts and interactive requests that carry them, so the emulator's +// own API routes are unaffected β€” a client posting a document batch has no token and is not asked +// for one. +app.UseAntiforgery(); + app.MapControllers(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +// The machine-readable half of the same checks the dashboard renders. Kept separate from +// /servicestats, which Aspire already probes: that route exists to imitate Azure's API surface and +// answers 200 as long as the process is up, whereas this one answers for the emulator's actual +// ability to serve β€” a read-only indexes volume is a 200 there and Unhealthy here. +app.MapHealthChecks("/health"); + await app.RunAsync(); return; diff --git a/AzureSearchEmulator/Routing/ODataServiceDocumentConvention.cs b/AzureSearchEmulator/Routing/ODataServiceDocumentConvention.cs new file mode 100644 index 0000000..f87c73f --- /dev/null +++ b/AzureSearchEmulator/Routing/ODataServiceDocumentConvention.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.OData.Routing.Controllers; + +namespace AzureSearchEmulator.Routing; + +/// +/// Drops OData's service-document action so the dashboard can own / (issue #90). +/// +/// +/// AddRouteComponents("", model) registers OData's built-in +/// at the route prefix, which puts on /. +/// The dashboard's @page "/" lands on the same address, and routing refuses to choose between +/// them β€” the request fails with an AmbiguousMatchException rather than either endpoint winning. +/// +/// The service document is the one to give up. Azure AI Search does not serve one: its root answers +/// 404, so nothing written against the real service can be relying on it, and the Azure SDK never +/// requests it. It exists here only because registering the OData route components at the root +/// prefix brings it along. /$metadata is a separate action on the same controller and stays +/// registered. +/// +/// +public class ODataServiceDocumentConvention : IApplicationModelConvention +{ + public void Apply(ApplicationModel application) + { + foreach (var controller in application.Controllers + .Where(i => i.ControllerType == typeof(MetadataController).GetTypeInfo())) + { + foreach (var action in controller.Actions + .Where(i => i.ActionName == nameof(MetadataController.GetServiceDocument)) + .ToList()) + { + controller.Actions.Remove(action); + } + } + } +} diff --git a/AzureSearchEmulator/wwwroot/css/dashboard.css b/AzureSearchEmulator/wwwroot/css/dashboard.css new file mode 100644 index 0000000..54b1b55 --- /dev/null +++ b/AzureSearchEmulator/wwwroot/css/dashboard.css @@ -0,0 +1,411 @@ +/* Dashboard styling (issue #90). + * + * Written from scratch rather than starting from the Blazor template's bootstrap bundle: the + * dashboard is a handful of cards, and shipping a CSS framework to lay them out would add far + * more to a `dotnet tool` package than it saves. Colours are declared as tokens on :root and + * re-declared under prefers-color-scheme so the page follows the developer's OS theme. */ + +:root { + color-scheme: light dark; + + --bg: #f5f6f8; + --bg-elevated: #ffffff; + --border: #e2e5ea; + --border-strong: #cdd2da; + --text: #16191f; + --text-muted: #5c6470; + --accent: #0b62d0; + + --healthy: #10794a; + --healthy-soft: #e6f4ec; + --degraded: #8a5a00; + --degraded-soft: #fdf2dd; + --unhealthy: #b3261e; + --unhealthy-soft: #fceceb; + + --radius: 14px; + --shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 8px 24px rgb(16 24 40 / 6%); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #101318; + --bg-elevated: #181c23; + --border: #262c36; + --border-strong: #333b47; + --text: #e8eaed; + --text-muted: #9aa3b0; + --accent: #6ba6ff; + + --healthy: #5cd39a; + --healthy-soft: #10251c; + --degraded: #e8b563; + --degraded-soft: #2a2113; + --unhealthy: #f2857e; + --unhealthy-soft: #2b1615; + + --shadow: 0 1px 2px rgb(0 0 0 / 40%), 0 8px 24px rgb(0 0 0 / 30%); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 15px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +h1, h2 { + margin: 0; + letter-spacing: -0.01em; +} + +h2 { + font-size: 1rem; + font-weight: 600; +} + +p { + margin: 0; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +/* Layout ------------------------------------------------------------------ */ + +.shell { + max-width: 880px; + margin: 0 auto; + padding: 0 24px 48px; + min-height: 100vh; + display: flex; + flex-direction: column; + gap: 24px; +} + +.masthead { + padding: 28px 0 4px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 12px; + text-decoration: none; + color: inherit; +} + +.brand-mark { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: 11px; + background: linear-gradient(140deg, #0b62d0, #2aa5e0); + color: #fff; + box-shadow: var(--shadow); +} + +.brand-mark svg { + width: 21px; + height: 21px; +} + +.brand-text { + display: flex; + flex-direction: column; + line-height: 1.25; +} + +.brand-text strong { + font-size: 1.0625rem; + font-weight: 650; +} + +.brand-text small { + color: var(--text-muted); + font-size: 0.8125rem; +} + +.content { + display: flex; + flex-direction: column; + gap: 20px; + flex: 1; +} + +.footwell { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + justify-content: space-between; + padding-top: 20px; + border-top: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.8125rem; +} + +/* Hero -------------------------------------------------------------------- */ + +.hero { + display: flex; + flex-wrap: wrap; + gap: 24px; + justify-content: space-between; + align-items: center; + padding: 24px; + border: 1px solid var(--border); + border-left: 4px solid var(--status-color); + border-radius: var(--radius); + background: var(--bg-elevated); + box-shadow: var(--shadow); +} + +.hero-status { + display: flex; + align-items: center; + gap: 14px; +} + +.hero-eyebrow { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.hero-headline { + font-size: 1.375rem; + font-weight: 650; + letter-spacing: -0.015em; +} + +.hero-facts { + display: flex; + flex-wrap: wrap; + gap: 8px 28px; + margin: 0; +} + +.hero-facts dt { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.hero-facts dd { + margin: 2px 0 0; + font-size: 0.9375rem; + font-variant-numeric: tabular-nums; +} + +/* Panels ------------------------------------------------------------------ */ + +.panel { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px 24px 24px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-elevated); + box-shadow: var(--shadow); +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.panel-loading { + flex-direction: row; + align-items: center; + gap: 12px; +} + +.muted { + color: var(--text-muted); + font-size: 0.875rem; +} + +.path { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--bg); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.8125rem; + overflow-wrap: anywhere; +} + +/* Checks ------------------------------------------------------------------ */ + +.checks { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.check { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 11px; + background: var(--status-soft); +} + +.check-body { + flex: 1; + min-width: 0; +} + +.check-name { + font-weight: 600; +} + +.check-description { + color: var(--text-muted); + font-size: 0.875rem; + overflow-wrap: anywhere; +} + +.badge { + flex-shrink: 0; + padding: 3px 10px; + border-radius: 999px; + background: var(--bg-elevated); + border: 1px solid var(--border-strong); + color: var(--status-color); + font-size: 0.75rem; + font-weight: 650; + letter-spacing: 0.02em; +} + +/* Status colours ---------------------------------------------------------- */ + +.is-healthy { + --status-color: var(--healthy); + --status-soft: var(--healthy-soft); +} + +.is-degraded { + --status-color: var(--degraded); + --status-soft: var(--degraded-soft); +} + +.is-unhealthy { + --status-color: var(--unhealthy); + --status-soft: var(--unhealthy-soft); +} + +.status-dot { + flex-shrink: 0; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--status-color); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--status-color) 22%, transparent); +} + +/* Controls ---------------------------------------------------------------- */ + +.button { + padding: 7px 14px; + border: 1px solid var(--border-strong); + border-radius: 9px; + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease; +} + +.button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.button:disabled { + opacity: 0.6; + cursor: default; +} + +.link { + color: var(--accent); + text-decoration: none; +} + +.link:hover { + text-decoration: underline; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.timestamp { + color: var(--text-muted); + font-size: 0.8125rem; + text-align: center; +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 700ms linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .spinner { + animation-duration: 3s; + } +} + +@media (max-width: 620px) { + .shell { + padding: 0 16px 32px; + } + + .hero { + align-items: flex-start; + } +} diff --git a/README.md b/README.md index 5a8edad..221cd1e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,33 @@ You can also run the emulator [with Docker](#building-and-running-with-docker), 2. Open AzureSearchEmulator.sln in Visual Studio 2026, Rider, or Visual Studio Code with C# Dev Kit and run it, or cd to the `AzureSearchEmulator` folder and run `dotnet run` from the command-line. +## Web UI + +Open the emulator's base address in a browser — http://localhost:5123 by default — for a +dashboard showing whether the emulator is running correctly: the version and environment it is +running as, how long it has been up, where it is storing indexes, and the result of each health +check. It refreshes on its own every few seconds. + +The emulator is a local development tool, so the UI has no authentication. Do not expose it on an +address other machines can reach. + +The same checks are available at `/health`, which answers `Healthy`, `Degraded`, or `Unhealthy` as +plain text with a matching status code, for scripts and container health probes: + +```bash +curl http://localhost:5123/health +``` + +The checks cover the things that go wrong in the environment rather than in the emulator: whether +the indexes directory can actually be written to (a read-only Docker volume, or a directory the +tool was launched from without permission), and whether every index definition on disk still +parses after being hand-edited. A malformed definition reports `Degraded` and names the problem +rather than failing an unrelated request later. + +Note that this replaces the OData service document that used to be served at the base address. +Azure AI Search does not serve one either, and the Azure SDK never requests it; `/$metadata` is +unchanged. + ## Features This project aims to be a nearly complete, API-compatible emulator of Azure Search for your local development environment, @@ -141,6 +168,9 @@ Currently, there is support (to varying degrees) for the following Azure Search * Get service stats (mostly dummy values) * [Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/azureai/azureai-search-document-integration) for example uses servicestats route as a health check endpoint. +Beyond the Azure Search API surface, the emulator also serves a web UI and a health endpoint of its +own (see Web UI above). + ### Suggesters and autocomplete An index may declare `suggesters`, each naming the searchable fields that typeahead queries diff --git a/aspire.config.json b/aspire.config.json new file mode 100644 index 0000000..c3877cb --- /dev/null +++ b/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj" + } +} \ No newline at end of file