mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<string, string?>())
|
||||
.Build();
|
||||
|
||||
return new HealthCheckService(
|
||||
NullLogger<HealthCheckService>.Instance,
|
||||
adminConfigService: null!,
|
||||
httpClientFactory: new StubHttpClientFactory(handler),
|
||||
metricsReader: null!,
|
||||
poisonQueueProbe: null!,
|
||||
configuration: config);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
+15
-9
@@ -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
|
||||
/// </summary>
|
||||
[Function("McpHealthCheck")]
|
||||
public async Task<HttpResponseData> 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
|
||||
|
||||
@@ -34,9 +34,12 @@ public class HealthCheckService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs all health checks and returns the results
|
||||
/// Performs all health checks and returns the results.
|
||||
/// <paramref name="includeEndpointUrls"/> adds the probed/backing endpoint URLs to the
|
||||
/// per-check details — infrastructure topology, so callers must only enable it for
|
||||
/// Global Admins.
|
||||
/// </summary>
|
||||
public async Task<HealthCheckResult> PerformAllChecksAsync()
|
||||
public async Task<HealthCheckResult> 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
|
||||
/// <summary>
|
||||
/// Checks Azure Table Storage connectivity by querying the admin configuration table
|
||||
/// </summary>
|
||||
private async Task<HealthCheck> CheckStorageBackendAsync()
|
||||
private async Task<HealthCheck> 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<string, object>
|
||||
{
|
||||
["Table endpoint"] = string.IsNullOrWhiteSpace(accountName)
|
||||
? "connection string (local dev)"
|
||||
: $"https://{accountName}.table.core.windows.net"
|
||||
};
|
||||
}
|
||||
|
||||
return check;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the Azure Functions host is running
|
||||
/// </summary>
|
||||
private static Task<HealthCheck> CheckProcessingBackendAsync()
|
||||
private static Task<HealthCheck> 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<string, object>
|
||||
{
|
||||
["API endpoint"] = $"https://{hostname}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(check);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private async Task<HealthCheck> CheckAgentBinariesAsync()
|
||||
internal async Task<HealthCheck> 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<string, object>
|
||||
{
|
||||
["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<string>();
|
||||
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<string>();
|
||||
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 <c>/health</c> endpoint (which sits in front of the
|
||||
/// <c>/mcp</c> 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 <paramref name="includeEndpointUrl"/> is set —
|
||||
/// callers must only enable it for Global Admins.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal async Task<HealthCheck> CheckMcpServerAsync()
|
||||
internal async Task<HealthCheck> 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<string, object> { ["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<string, object> { ["Version"] = version };
|
||||
check.Details ??= new Dictionary<string, object>();
|
||||
check.Details["Version"] = version;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException or HttpRequestException)
|
||||
|
||||
@@ -554,52 +554,53 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,19 +73,32 @@ namespace AutopilotMonitor.Shared
|
||||
public const string ApiBaseUrl = "https://autopilotmonitor-api-eu.azurewebsites.net";
|
||||
|
||||
/// <summary>
|
||||
/// Public base URL of the MCP server Container App (no trailing slash, no /mcp suffix).
|
||||
/// Used by the backend health check to probe <c>{McpServerBaseUrl}/health</c>. The
|
||||
/// Container App runs with minReplicas=0, so the first probe after idle may incur a
|
||||
/// cold start. Overridable via the <c>McpServerUrl</c> 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
|
||||
/// <c>{McpServerBaseUrl}/health</c>. The Container App runs with minReplicas=0, so the
|
||||
/// first probe after idle may incur a cold start. Overridable via the
|
||||
/// <c>McpServerUrl</c> app setting (e.g. for dev).
|
||||
/// </summary>
|
||||
public const string McpServerBaseUrl = "https://autopilotmonitor-mcp.kindwave-58b4b547.westeurope.azurecontainerapps.io";
|
||||
public const string McpServerBaseUrl = "https://mcp.autopilotmonitor.com";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Agent self-update
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const string AgentDownloadBaseUrl = "https://download.autopilotmonitor.com/agent";
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="AgentDownloadBaseUrl"/> is complete and the legacy account is torn down.
|
||||
/// </summary>
|
||||
public const string AgentBlobBaseUrl = "https://autopilotmonitor.blob.core.windows.net/agent";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user