From ef67dec26302af0ed8bcb1f78e71b25d65468779 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 19 Jul 2026 11:42:40 +0200 Subject: [PATCH] fix(backend): system-health URLs post EU-migration + GA endpoint visibility - Agent Binaries check + ops watcher now probe the canonical download alias (download.autopilotmonitor.com) AND the legacy blob keepalive; alias failure is unhealthy, legacy failure a warning - McpServerBaseUrl fallback moved from the stale kindwave WEU ACA FQDN to the stable custom domain mcp.autopilotmonitor.com (CNAME to the new GWC instance) - health/detailed and health/mcp include the probed endpoint URLs in check details for Global Admins only (storage table endpoint, WEBSITE_HOSTNAME, alias/legacy download URLs, MCP server URL) Co-Authored-By: Claude Fable 5 --- .../HealthCheckServiceAgentBinariesTests.cs | 107 +++++++++++++++ .../HealthCheckServiceMcpServerTests.cs | 23 ++++ .../Infrastructure/HealthCheckFunction.cs | 24 ++-- .../Services/HealthCheckService.cs | 125 +++++++++++++----- .../MaintenanceService.Aggregation.cs | 49 +++---- .../AutopilotMonitor.Shared/Constants.cs | 25 +++- 6 files changed, 283 insertions(+), 70 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceAgentBinariesTests.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceAgentBinariesTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceAgentBinariesTests.cs new file mode 100644 index 00000000..c25da4ba --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceAgentBinariesTests.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Tests.GraphResolution; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Service-level tests for the Agent Binaries HealthCheck card. The check probes the +/// canonical download alias (download.autopilotmonitor.com) AND the legacy blob keepalive +/// account: alias failure is unhealthy (new installs break), legacy failure is only a +/// warning (existing customers break until they migrate to the alias). +/// +public class HealthCheckServiceAgentBinariesTests +{ + private const string AliasHost = "download.autopilotmonitor.com"; + private const string LegacyHost = "autopilotmonitor.blob.core.windows.net"; + + [Fact] + public async Task Check_AllEndpointsAvailable_ReportsHealthyWithoutUrlDetails() + { + var handler = new StubHttpMessageHandler() + .When(AliasHost, HttpStatusCode.OK, "") + .When(LegacyHost, HttpStatusCode.OK, ""); + + var check = await BuildService(handler).CheckAgentBinariesAsync(includeEndpointUrls: false); + + Assert.Equal("Agent Binaries", check.Name); + Assert.Equal("healthy", check.Status); + Assert.Contains("download alias", check.Message); + Assert.Null(check.Details); + // Both hosts must actually have been probed (ZIP + PS1 each). + Assert.Equal(2, handler.Requests.FindAll(u => u.Contains(AliasHost)).Count); + Assert.Equal(2, handler.Requests.FindAll(u => u.Contains(LegacyHost)).Count); + } + + [Fact] + public async Task Check_WithEndpointUrls_SurfacesAliasAndLegacyUrlsInDetails() + { + var handler = new StubHttpMessageHandler() + .When(AliasHost, HttpStatusCode.OK, "") + .When(LegacyHost, HttpStatusCode.OK, ""); + + var check = await BuildService(handler).CheckAgentBinariesAsync(includeEndpointUrls: true); + + Assert.NotNull(check.Details); + Assert.Equal("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip", check.Details!["Agent ZIP"]); + Assert.Equal("https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1", check.Details["Bootstrap script"]); + Assert.Equal("https://autopilotmonitor.blob.core.windows.net/agent", check.Details["Legacy blob (keepalive)"]); + } + + [Fact] + public async Task Check_LegacyBlobMissing_ReportsWarningNamingTheLegacyEndpoint() + { + var handler = new StubHttpMessageHandler() + .When(AliasHost, HttpStatusCode.OK, "") + .When(LegacyHost, HttpStatusCode.NotFound, ""); + + var check = await BuildService(handler).CheckAgentBinariesAsync(includeEndpointUrls: false); + + Assert.Equal("warning", check.Status); + Assert.Contains("legacy", check.Message, System.StringComparison.OrdinalIgnoreCase); + Assert.Contains("404", check.Message); + } + + [Fact] + public async Task Check_AliasMissing_ReportsUnhealthy() + { + var handler = new StubHttpMessageHandler() + .When(AliasHost, HttpStatusCode.NotFound, "") + .When(LegacyHost, HttpStatusCode.OK, ""); + + var check = await BuildService(handler).CheckAgentBinariesAsync(includeEndpointUrls: false); + + Assert.Equal("unhealthy", check.Status); + Assert.Contains("download alias", check.Message); + } + + [Fact] + public async Task Check_EndpointsUnreachable_ReportsUnhealthy() + { + // Un-scripted URLs make the stub throw — models a connection failure. + var check = await BuildService(new StubHttpMessageHandler()).CheckAgentBinariesAsync(includeEndpointUrls: false); + + Assert.Equal("unhealthy", check.Status); + Assert.Contains("unreachable", check.Message); + } + + private static HealthCheckService BuildService(StubHttpMessageHandler handler) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + + return new HealthCheckService( + NullLogger.Instance, + adminConfigService: null!, + httpClientFactory: new StubHttpClientFactory(handler), + metricsReader: null!, + poisonQueueProbe: null!, + configuration: config); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceMcpServerTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceMcpServerTests.cs index 38467893..41577c7d 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceMcpServerTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceMcpServerTests.cs @@ -53,6 +53,29 @@ public class HealthCheckServiceMcpServerTests Assert.DoesNotContain("http", check.Message, System.StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Check_WithEndpointUrl_SurfacesServerUrlAlongsideVersion() + { + var svc = BuildService(HttpStatusCode.OK, "{\"status\":\"healthy\",\"version\":\"1.4.0\"}"); + + var check = await svc.CheckMcpServerAsync(includeEndpointUrl: true); + + // The Version detail must merge into — not replace — the Server URL detail. + Assert.Equal("https://mcp.example.test", check.Details!["Server URL"]); + Assert.Equal("1.4.0", check.Details["Version"]); + } + + [Fact] + public async Task Check_WithEndpointUrl_SurfacesServerUrlEvenWhenUnhealthy() + { + var svc = BuildService(HttpStatusCode.ServiceUnavailable, ""); + + var check = await svc.CheckMcpServerAsync(includeEndpointUrl: true); + + Assert.Equal("unhealthy", check.Status); + Assert.Equal("https://mcp.example.test", check.Details!["Server URL"]); + } + [Fact] public async Task Check_TimeoutOrConnectionFailure_ReportsWarningForColdStart() { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/HealthCheckFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/HealthCheckFunction.cs index 9a086d71..093e1142 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/HealthCheckFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/HealthCheckFunction.cs @@ -102,17 +102,19 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure // Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware - // Perform comprehensive health checks - var healthCheckResult = await _healthCheckService.PerformAllChecksAsync(); - - // SignalR Quota check exposes the subscription/resource-group path of the - // backing SignalR resource, and Poison Queues exposes internal async-worker - // queue topology — restrict both to Global Admins. Non-GA callers - // (Tenant Admins, Operators) get the rest of the report unchanged. + // SignalR Quota exposes the subscription/resource-group path of the backing + // SignalR resource, Poison Queues exposes internal async-worker queue topology, + // and endpoint URLs in the check details expose infrastructure hostnames — + // restrict all of that to Global Admins. Non-GA callers (Tenant Admins, + // Operators) get the rest of the report unchanged. // The route is registered as AuthenticatedUser, so PolicyEnforcementMiddleware // does NOT compute IsGlobalAdmin; resolve it directly via GlobalAdminService. var requestCtx = context.GetRequestContext(); var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(requestCtx.UserPrincipalName); + + // Perform comprehensive health checks (endpoint URLs only for Global Admins) + var healthCheckResult = await _healthCheckService.PerformAllChecksAsync(includeEndpointUrls: isGlobalAdmin); + var visibleChecks = isGlobalAdmin ? healthCheckResult.Checks : healthCheckResult.Checks @@ -146,10 +148,14 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure /// [Function("McpHealthCheck")] public async Task GetMcpHealthCheck( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health/mcp")] HttpRequestData req) + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health/mcp")] HttpRequestData req, + FunctionContext context) { // Authentication enforced by PolicyEnforcementMiddleware (AuthenticatedUser). - var check = await _healthCheckService.CheckMcpServerAsync(); + // The MCP server URL is infrastructure topology — include it only for Global Admins. + var requestCtx = context.GetRequestContext(); + var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(requestCtx.UserPrincipalName); + var check = await _healthCheckService.CheckMcpServerAsync(includeEndpointUrl: isGlobalAdmin); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs b/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs index 5633fe3e..8aa9f6dc 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs @@ -34,9 +34,12 @@ public class HealthCheckService } /// - /// Performs all health checks and returns the results + /// Performs all health checks and returns the results. + /// adds the probed/backing endpoint URLs to the + /// per-check details — infrastructure topology, so callers must only enable it for + /// Global Admins. /// - public async Task PerformAllChecksAsync() + public async Task PerformAllChecksAsync(bool includeEndpointUrls = false) { var result = new HealthCheckResult { @@ -50,9 +53,9 @@ public class HealthCheckService // one slow check. It is exposed via its own endpoint (GET health/mcp) that the // frontend fetches independently and folds into the card grid incrementally. var checks = await Task.WhenAll( - CheckStorageBackendAsync(), - CheckProcessingBackendAsync(), - CheckAgentBinariesAsync(), + CheckStorageBackendAsync(includeEndpointUrls), + CheckProcessingBackendAsync(includeEndpointUrls), + CheckAgentBinariesAsync(includeEndpointUrls), CheckSignalRQuotaAsync(), CheckPoisonQueuesAsync() ); @@ -73,7 +76,7 @@ public class HealthCheckService /// /// Checks Azure Table Storage connectivity by querying the admin configuration table /// - private async Task CheckStorageBackendAsync() + private async Task CheckStorageBackendAsync(bool includeEndpointUrls) { var check = new HealthCheck { @@ -97,29 +100,62 @@ public class HealthCheckService _logger.LogError(ex, "Storage backend health check failed"); } + if (includeEndpointUrls) + { + // Same resolution order as TableStorageService: account name (Managed Identity) + // is the production path, connection string is the local-dev fallback. + var accountName = _configuration["AzureStorageAccountName"]; + check.Details = new Dictionary + { + ["Table endpoint"] = string.IsNullOrWhiteSpace(accountName) + ? "connection string (local dev)" + : $"https://{accountName}.table.core.windows.net" + }; + } + return check; } /// /// Checks that the Azure Functions host is running /// - private static Task CheckProcessingBackendAsync() + private static Task CheckProcessingBackendAsync(bool includeEndpointUrls) { var uptime = DateTime.UtcNow - System.Diagnostics.Process.GetCurrentProcess().StartTime.ToUniversalTime(); - return Task.FromResult(new HealthCheck + var check = new HealthCheck { Name = "Processing Backend", Description = "Application host process", Status = "healthy", Message = $"Host running (uptime: {(int)uptime.TotalMinutes}m {uptime.Seconds}s)" - }); + }; + + if (includeEndpointUrls) + { + // WEBSITE_HOSTNAME is stamped by App Service and reflects the host actually + // serving this process — no hardcoded URL to go stale after a migration. + var hostname = Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME"); + if (!string.IsNullOrWhiteSpace(hostname)) + { + check.Details = new Dictionary + { + ["API endpoint"] = $"https://{hostname}" + }; + } + } + + return Task.FromResult(check); } /// - /// Checks that the agent binaries (ZIP) and bootstrap script (PS1) are reachable in blob storage + /// Checks that the agent binaries (ZIP) and bootstrap script (PS1) are reachable on the + /// canonical download alias (Front Door → current blob origin) AND on the legacy blob + /// keepalive account that already-deployed customer bootstrap scripts still use. + /// Alias failure is unhealthy (new installs break); legacy failure is a warning + /// (existing customers break until they migrate to the alias). /// - private async Task CheckAgentBinariesAsync() + internal async Task CheckAgentBinariesAsync(bool includeEndpointUrls) { var check = new HealthCheck { @@ -127,8 +163,20 @@ public class HealthCheckService Description = "Agent download package availability" }; - var zipUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentZipFileName}"; - var ps1Url = $"{Constants.AgentBlobBaseUrl}/Install-AutopilotMonitor.ps1"; + var aliasZipUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentZipFileName}"; + var aliasPs1Url = $"{Constants.AgentDownloadBaseUrl}/{Constants.BootstrapScriptName}"; + var legacyZipUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentZipFileName}"; + var legacyPs1Url = $"{Constants.AgentBlobBaseUrl}/{Constants.BootstrapScriptName}"; + + if (includeEndpointUrls) + { + check.Details = new Dictionary + { + ["Agent ZIP"] = aliasZipUrl, + ["Bootstrap script"] = aliasPs1Url, + ["Legacy blob (keepalive)"] = Constants.AgentBlobBaseUrl + }; + } try { @@ -138,40 +186,48 @@ public class HealthCheckService var sw = System.Diagnostics.Stopwatch.StartNew(); // HEAD requests to check availability without downloading - var zipRequest = new HttpRequestMessage(HttpMethod.Head, zipUrl); - var ps1Request = new HttpRequestMessage(HttpMethod.Head, ps1Url); - var results = await Task.WhenAll( - client.SendAsync(zipRequest), - client.SendAsync(ps1Request) + client.SendAsync(new HttpRequestMessage(HttpMethod.Head, aliasZipUrl)), + client.SendAsync(new HttpRequestMessage(HttpMethod.Head, aliasPs1Url)), + client.SendAsync(new HttpRequestMessage(HttpMethod.Head, legacyZipUrl)), + client.SendAsync(new HttpRequestMessage(HttpMethod.Head, legacyPs1Url)) ); sw.Stop(); - var zipResponse = results[0]; - var ps1Response = results[1]; + var aliasZipOk = results[0].IsSuccessStatusCode; + var aliasPs1Ok = results[1].IsSuccessStatusCode; + var legacyZipOk = results[2].IsSuccessStatusCode; + var legacyPs1Ok = results[3].IsSuccessStatusCode; - var zipOk = zipResponse.IsSuccessStatusCode; - var ps1Ok = ps1Response.IsSuccessStatusCode; + var issues = new List(); + if (!aliasZipOk) issues.Add($"Agent ZIP (download alias): {(int)results[0].StatusCode}"); + if (!aliasPs1Ok) issues.Add($"Bootstrap script (download alias): {(int)results[1].StatusCode}"); + if (!legacyZipOk) issues.Add($"Agent ZIP (legacy blob): {(int)results[2].StatusCode}"); + if (!legacyPs1Ok) issues.Add($"Bootstrap script (legacy blob): {(int)results[3].StatusCode}"); - if (zipOk && ps1Ok) + if (issues.Count == 0) { check.Status = "healthy"; - check.Message = $"Agent package and bootstrap script available ({sw.ElapsedMilliseconds}ms)"; + check.Message = $"Agent package and bootstrap script available via download alias + legacy blob ({sw.ElapsedMilliseconds}ms)"; + } + else if (aliasZipOk && aliasPs1Ok) + { + // Alias intact, legacy keepalive degraded — existing customer bootstrap + // scripts still download from the legacy account until they migrate. + check.Status = "warning"; + check.Message = $"Download alias OK, but legacy keepalive degraded — {string.Join(", ", issues)}"; } else { check.Status = "unhealthy"; - var issues = new List(); - if (!zipOk) issues.Add($"Agent ZIP: {(int)zipResponse.StatusCode}"); - if (!ps1Ok) issues.Add($"Bootstrap script: {(int)ps1Response.StatusCode}"); check.Message = $"Missing: {string.Join(", ", issues)}"; } } catch (Exception ex) { check.Status = "unhealthy"; - check.Message = $"Blob storage unreachable: {ex.Message}"; + check.Message = $"Agent download endpoints unreachable: {ex.Message}"; _logger.LogError(ex, "Agent binaries health check failed"); } @@ -182,7 +238,8 @@ public class HealthCheckService /// Probes the MCP server's public /health endpoint (which sits in front of the /// /mcp access guard, so no auth is required) and surfaces reachability + the /// deployed MCP build version. Visible to all authenticated users, so the server URL is - /// deliberately NOT included in the message or details. + /// only included in the details when is set — + /// callers must only enable it for Global Admins. /// /// The Container App runs with minReplicas=0 and scales to zero on idle. Azure Container /// Apps holds an inbound request while it activates a cold replica, so this probe doubles @@ -193,7 +250,7 @@ public class HealthCheckService /// the blocking all-checks batch, so the dashboard never waits on the wake. /// /// - internal async Task CheckMcpServerAsync() + internal async Task CheckMcpServerAsync(bool includeEndpointUrl = false) { var check = new HealthCheck { @@ -203,6 +260,11 @@ public class HealthCheckService var baseUrl = (_configuration["McpServerUrl"] ?? Constants.McpServerBaseUrl).TrimEnd('/'); var healthUrl = $"{baseUrl}/health"; + + if (includeEndpointUrl) + { + check.Details = new Dictionary { ["Server URL"] = baseUrl }; + } var timeoutSeconds = ParsePositiveInt("McpServerHealthTimeoutSeconds", 30); // A cold start that takes longer than this is treated as a successful wake rather @@ -233,7 +295,8 @@ public class HealthCheckService : $"MCP server reachable ({sw.ElapsedMilliseconds}ms)"; if (!string.IsNullOrWhiteSpace(version)) { - check.Details = new Dictionary { ["Version"] = version }; + check.Details ??= new Dictionary(); + check.Details["Version"] = version; } } catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException or HttpRequestException) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs index b3a92113..4444bc67 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -554,52 +554,53 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Verifies that agent binaries and bootstrap script are available on blob storage. + /// Verifies that agent binaries and bootstrap script are available on the canonical + /// download alias (Front Door → current blob origin) AND on the legacy blob keepalive + /// account that already-deployed customer bootstrap scripts still use. /// Records an OpsEvent for each missing item so Global Admins see it in the dashboard. /// private async Task CheckAgentBlobStorageAsync() { - _logger.LogInformation("Checking agent blob storage availability..."); + _logger.LogInformation("Checking agent download endpoint availability..."); - var zipUrl = $"{AutopilotMonitor.Shared.Constants.AgentBlobBaseUrl}/{AutopilotMonitor.Shared.Constants.AgentZipFileName}"; - var ps1Url = $"{AutopilotMonitor.Shared.Constants.AgentBlobBaseUrl}/Install-AutopilotMonitor.ps1"; + var probes = new (string Label, string Url)[] + { + ($"Agent ZIP ({AutopilotMonitor.Shared.Constants.AgentZipFileName}, download alias)", + $"{AutopilotMonitor.Shared.Constants.AgentDownloadBaseUrl}/{AutopilotMonitor.Shared.Constants.AgentZipFileName}"), + ($"Bootstrap script ({AutopilotMonitor.Shared.Constants.BootstrapScriptName}, download alias)", + $"{AutopilotMonitor.Shared.Constants.AgentDownloadBaseUrl}/{AutopilotMonitor.Shared.Constants.BootstrapScriptName}"), + ($"Agent ZIP ({AutopilotMonitor.Shared.Constants.AgentZipFileName}, legacy blob keepalive)", + $"{AutopilotMonitor.Shared.Constants.AgentBlobBaseUrl}/{AutopilotMonitor.Shared.Constants.AgentZipFileName}"), + ($"Bootstrap script ({AutopilotMonitor.Shared.Constants.BootstrapScriptName}, legacy blob keepalive)", + $"{AutopilotMonitor.Shared.Constants.AgentBlobBaseUrl}/{AutopilotMonitor.Shared.Constants.BootstrapScriptName}") + }; try { var client = _httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(15); - var zipRequest = new HttpRequestMessage(HttpMethod.Head, zipUrl); - var ps1Request = new HttpRequestMessage(HttpMethod.Head, ps1Url); - var results = await Task.WhenAll( - client.SendAsync(zipRequest), - client.SendAsync(ps1Request) + probes.Select(p => client.SendAsync(new HttpRequestMessage(HttpMethod.Head, p.Url))) ); - var zipResponse = results[0]; - var ps1Response = results[1]; - - if (!zipResponse.IsSuccessStatusCode) + var allOk = true; + for (var i = 0; i < probes.Length; i++) { - _logger.LogError("Agent ZIP not available on blob storage: HTTP {StatusCode}", (int)zipResponse.StatusCode); - await _opsEventService.RecordBlobStorageMissingAsync("Agent ZIP (AutopilotMonitor-Agent.zip)", (int)zipResponse.StatusCode); + if (results[i].IsSuccessStatusCode) continue; + allOk = false; + _logger.LogError("{Item} not available: HTTP {StatusCode}", probes[i].Label, (int)results[i].StatusCode); + await _opsEventService.RecordBlobStorageMissingAsync(probes[i].Label, (int)results[i].StatusCode); } - if (!ps1Response.IsSuccessStatusCode) + if (allOk) { - _logger.LogError("Bootstrap script not available on blob storage: HTTP {StatusCode}", (int)ps1Response.StatusCode); - await _opsEventService.RecordBlobStorageMissingAsync("Bootstrap script (Install-AutopilotMonitor.ps1)", (int)ps1Response.StatusCode); - } - - if (zipResponse.IsSuccessStatusCode && ps1Response.IsSuccessStatusCode) - { - _logger.LogInformation("Agent blob storage check passed: all binaries available"); + _logger.LogInformation("Agent download endpoint check passed: all binaries available on alias + legacy blob"); } } catch (Exception ex) { - _logger.LogError(ex, "Agent blob storage check failed — storage unreachable"); + _logger.LogError(ex, "Agent download endpoint check failed — endpoints unreachable"); await _opsEventService.RecordBlobStorageUnreachableAsync(ex.Message); } } diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 84d37fef..a55c5d8f 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -73,19 +73,32 @@ namespace AutopilotMonitor.Shared public const string ApiBaseUrl = "https://autopilotmonitor-api-eu.azurewebsites.net"; /// - /// Public base URL of the MCP server Container App (no trailing slash, no /mcp suffix). - /// Used by the backend health check to probe {McpServerBaseUrl}/health. The - /// Container App runs with minReplicas=0, so the first probe after idle may incur a - /// cold start. Overridable via the McpServerUrl app setting (e.g. for dev). + /// Public base URL of the MCP server (no trailing slash, no /mcp suffix). Stable custom + /// domain in front of the Container App — survives region/environment moves, unlike the + /// generated *.azurecontainerapps.io FQDN. Used by the backend health check to probe + /// {McpServerBaseUrl}/health. The Container App runs with minReplicas=0, so the + /// first probe after idle may incur a cold start. Overridable via the + /// McpServerUrl app setting (e.g. for dev). /// - public const string McpServerBaseUrl = "https://autopilotmonitor-mcp.kindwave-58b4b547.westeurope.azurecontainerapps.io"; + public const string McpServerBaseUrl = "https://mcp.autopilotmonitor.com"; // ----------------------------------------------------------------------- // Agent self-update // ----------------------------------------------------------------------- /// - /// Base URL for agent binaries in Azure Blob Storage (public read) + /// Canonical public base URL for agent binaries: Front Door alias in front of the + /// current blob origin (autopilotmonitoreu, container "agent"). Storage-account moves + /// happen behind this name without touching customers. New bootstrap scripts and all + /// availability probes use this URL. + /// + public const string AgentDownloadBaseUrl = "https://download.autopilotmonitor.com/agent"; + + /// + /// LEGACY base URL for agent binaries: the pre-migration storage account, kept alive + /// (dual-push on every release) because bootstrap scripts already deployed in customer + /// Intune tenants still download from it. Remove only after the customer migration to + /// is complete and the legacy account is torn down. /// public const string AgentBlobBaseUrl = "https://autopilotmonitor.blob.core.windows.net/agent";