diff --git a/src/Aura.Api/Docs/configuration.md b/src/Aura.Api/Docs/configuration.md index e84f6488..e1d8f51b 100644 --- a/src/Aura.Api/Docs/configuration.md +++ b/src/Aura.Api/Docs/configuration.md @@ -431,6 +431,71 @@ Configure file logging in `appsettings.Production.json`: } ``` +## Deployment Configuration + +### Server Start Time + +The server start time is automatically initialized when the application starts. This timestamp is used by the `/health` endpoint to indicate when the server was last restarted, which is useful for: + +- Verifying successful deployments +- Tracking uptime +- Diagnosing issues related to server restarts + +**No configuration required** - the server start time is captured automatically at application startup. + +### Deployment Tag + +The `DEPLOY_TAG` environment variable identifies the specific version or build of the running server. This is useful for: + +- Verifying that the correct version is deployed +- Correlating server behavior with specific builds +- Tracking deployments across environments + +**Setting the deployment tag**: + +**Windows (PowerShell)**: +```powershell +$env:DEPLOY_TAG = "v1.3.1-abc1234" +``` + +**Windows (System-wide)**: +```powershell +[System.Environment]::SetEnvironmentVariable("DEPLOY_TAG", "v1.3.1-abc1234", "Machine") +``` + +**Linux/macOS**: +```bash +export DEPLOY_TAG="v1.3.1-abc1234" +``` + +**Docker/Container**: +```dockerfile +ENV DEPLOY_TAG=v1.3.1-abc1234 +``` + +**Windows Service**: + +When deploying as a Windows Service, set the environment variable at the system level before installing the service: + +```powershell +[System.Environment]::SetEnvironmentVariable("DEPLOY_TAG", "v1.3.1-abc1234", "Machine") +.\scripts\Update-LocalInstall.ps1 +``` + +If not set, the deployment tag will default to `"unknown"`. + +**Health endpoint response**: + +The `/health` endpoint includes both the server start time and deployment tag: + +```json +{ + "status": "healthy", + "startedAt": "2026-02-07T09:12:51Z", + "deployTag": "v1.3.1-abc1234" +} +``` + ## Environment Variables ### Summary Table @@ -438,6 +503,7 @@ Configure file logging in `appsettings.Production.json`: | Variable | Purpose | Example | |----------|---------|---------| | `ASPNETCORE_ENVIRONMENT` | Environment name | `Production` | +| `DEPLOY_TAG` | Deployment version identifier | `v1.3.1-abc1234` | | `AURA_AZUREOPENAI_APIKEY` | Azure OpenAI API key | `` | | `AURA_OPENAI_APIKEY` | OpenAI API key | `` | | `AURA_DB_HOST` | Database host | `localhost` | diff --git a/src/Aura.Api/Endpoints/HealthEndpoints.cs b/src/Aura.Api/Endpoints/HealthEndpoints.cs index 38304532..b302d216 100644 --- a/src/Aura.Api/Endpoints/HealthEndpoints.cs +++ b/src/Aura.Api/Endpoints/HealthEndpoints.cs @@ -18,9 +18,15 @@ public static class HealthEndpoints /// /// Maps all health endpoints to the application. /// - public static WebApplication MapHealthEndpoints(this WebApplication app) + /// The web application. + /// The server start time. + /// The deployment tag. + public static WebApplication MapHealthEndpoints( + this WebApplication app, + DateTime serverStartTime, + string deploymentTag) { - app.MapGet("/health", GetHealth); + app.MapGet("/health", () => GetHealth(serverStartTime, deploymentTag)); app.MapGet("/health/db", GetDatabaseHealth); app.MapGet("/health/rag", GetRagHealth); app.MapGet("/health/ollama", GetLlmHealth); @@ -30,12 +36,11 @@ public static WebApplication MapHealthEndpoints(this WebApplication app) return app; } - private static object GetHealth() => new + private static object GetHealth(DateTime serverStartTime, string deploymentTag) => new { status = "healthy", - healthy = true, - version = "0.1.0", - timestamp = DateTime.UtcNow + startedAt = serverStartTime.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture), + deployTag = deploymentTag }; private static async Task GetDatabaseHealth(AuraDbContext db) diff --git a/src/Aura.Api/Program.cs b/src/Aura.Api/Program.cs index 64992fd2..de35f5c5 100644 --- a/src/Aura.Api/Program.cs +++ b/src/Aura.Api/Program.cs @@ -14,6 +14,10 @@ using Aura.Module.Researcher; using Microsoft.EntityFrameworkCore; +// Server metadata for health endpoint +var ServerStartTime = DateTime.UtcNow; +var DeploymentTag = Environment.GetEnvironmentVariable("DEPLOY_TAG") ?? "unknown"; + var builder = WebApplication.CreateBuilder(args); // Configure as Windows Service when installed as service @@ -161,7 +165,7 @@ app.UseGitHubToken(); // Map all endpoint groups -app.MapHealthEndpoints(); +app.MapHealthEndpoints(ServerStartTime, DeploymentTag); app.MapMcpEndpoints(); app.MapAgentEndpoints(); app.MapRagEndpoints(); diff --git a/tests/Aura.Api.Tests/Endpoints/HealthEndpointsTests.cs b/tests/Aura.Api.Tests/Endpoints/HealthEndpointsTests.cs new file mode 100644 index 00000000..4601611f --- /dev/null +++ b/tests/Aura.Api.Tests/Endpoints/HealthEndpointsTests.cs @@ -0,0 +1,121 @@ +// +// Copyright (c) Aura. All rights reserved. +// + +namespace Aura.Api.Tests.Endpoints; + +using Aura.Api.Endpoints; +using FluentAssertions; +using Xunit; + +public class HealthEndpointsTests +{ + [Fact] + public void GetHealth_ReturnsCorrectJsonStructure() + { + // Arrange + var serverStartTime = new DateTime(2026, 2, 7, 9, 12, 51, DateTimeKind.Utc); + var deploymentTag = "v1.3.1-abc1234"; + + // Act + var result = InvokeGetHealth(serverStartTime, deploymentTag); + + // Assert + result.Should().NotBeNull(); + var status = GetPropertyValue(result, "status"); + var startedAt = GetPropertyValue(result, "startedAt"); + var deployTag = GetPropertyValue(result, "deployTag"); + + status.Should().Be("healthy"); + startedAt.Should().Be("2026-02-07T09:12:51Z"); + deployTag.Should().Be("v1.3.1-abc1234"); + } + + [Fact] + public void GetHealth_WithDifferentServerStartTime_ReturnsCorrectTimestamp() + { + // Arrange + var serverStartTime = new DateTime(2025, 12, 25, 14, 30, 0, DateTimeKind.Utc); + var deploymentTag = "v2.0.0"; + + // Act + var result = InvokeGetHealth(serverStartTime, deploymentTag); + + // Assert + var startedAt = GetPropertyValue(result, "startedAt"); + startedAt.Should().Be("2025-12-25T14:30:00Z"); + } + + [Fact] + public void GetHealth_WithEmptyDeployTag_ReturnsEmptyString() + { + // Arrange + var serverStartTime = DateTime.UtcNow; + var deploymentTag = string.Empty; + + // Act + var result = InvokeGetHealth(serverStartTime, deploymentTag); + + // Assert + var deployTag = GetPropertyValue(result, "deployTag"); + deployTag.Should().Be(string.Empty); + } + + [Fact] + public void GetHealth_StatusAlwaysHealthy() + { + // Arrange + var serverStartTime = DateTime.UtcNow; + var deploymentTag = "test-tag"; + + // Act + var result = InvokeGetHealth(serverStartTime, deploymentTag); + + // Assert + var status = GetPropertyValue(result, "status"); + status.Should().Be("healthy"); + } + + [Fact] + public void GetHealth_TimestampFormattedAsISO8601() + { + // Arrange + var serverStartTime = new DateTime(2026, 1, 15, 8, 45, 33, DateTimeKind.Utc); + var deploymentTag = "v1.0.0"; + + // Act + var result = InvokeGetHealth(serverStartTime, deploymentTag); + + // Assert + var startedAt = GetPropertyValue(result, "startedAt"); + var timestampString = startedAt.ToString(); + timestampString.Should().MatchRegex(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$"); + } + + private static object InvokeGetHealth(DateTime serverStartTime, string deploymentTag) + { + var method = typeof(HealthEndpoints).GetMethod( + "GetHealth", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + if (method == null) + { + throw new InvalidOperationException("GetHealth method not found"); + } + + var result = method.Invoke(null, new object[] { serverStartTime, deploymentTag }); + return result ?? throw new InvalidOperationException("GetHealth returned null"); + } + + private static object GetPropertyValue(object obj, string propertyName) + { + var type = obj.GetType(); + var property = type.GetProperty(propertyName); + if (property == null) + { + throw new InvalidOperationException($"Property {propertyName} not found"); + } + + return property.GetValue(obj) ?? throw new InvalidOperationException($"Property {propertyName} is null"); + } +}