diff --git a/.github/workflows/api-e2e-mssql-multitenant.yml b/.github/workflows/api-e2e-mssql-multitenant.yml
index a5c8109d5..d1f93475a 100644
--- a/.github/workflows/api-e2e-mssql-multitenant.yml
+++ b/.github/workflows/api-e2e-mssql-multitenant.yml
@@ -46,6 +46,9 @@ jobs:
- name: Copy admin api common folder to docker context
run: cp -r ../EdFi.Ods.AdminApi.Common ../../Docker/Application
+ - name: Copy health check service folder to docker context
+ run: cp -r ../EdFi.Ods.AdminApi.HealthCheck ../../Docker/Application
+
- name: Copy nuget config to docker context
run: cp ../NuGet.Config ../../Docker/Application
diff --git a/.github/workflows/api-e2e-mssql-singletenant.yml b/.github/workflows/api-e2e-mssql-singletenant.yml
index 3964ceed0..2e06e8ff8 100644
--- a/.github/workflows/api-e2e-mssql-singletenant.yml
+++ b/.github/workflows/api-e2e-mssql-singletenant.yml
@@ -46,6 +46,9 @@ jobs:
- name: Copy admin api common folder to docker context
run: cp -r ../EdFi.Ods.AdminApi.Common ../../Docker/Application
+ - name: Copy health check service folder to docker context
+ run: cp -r ../EdFi.Ods.AdminApi.HealthCheck ../../Docker/Application
+
- name: Copy nuget config to docker context
run: cp ../NuGet.Config ../../Docker/Application
diff --git a/.github/workflows/api-e2e-pgsql-multitenant.yml b/.github/workflows/api-e2e-pgsql-multitenant.yml
index fadc070a5..ccf512bae 100644
--- a/.github/workflows/api-e2e-pgsql-multitenant.yml
+++ b/.github/workflows/api-e2e-pgsql-multitenant.yml
@@ -46,6 +46,9 @@ jobs:
- name: Copy admin api common folder to docker context
run: cp -r ../EdFi.Ods.AdminApi.Common ../../Docker/Application
+ - name: Copy health check service folder to docker context
+ run: cp -r ../EdFi.Ods.AdminApi.HealthCheck ../../Docker/Application
+
- name: Copy nuget config to docker context
run: cp ../NuGet.Config ../../Docker/Application
diff --git a/.github/workflows/api-e2e-pgsql-singletenant.yml b/.github/workflows/api-e2e-pgsql-singletenant.yml
index 18c2b0588..4ce890573 100644
--- a/.github/workflows/api-e2e-pgsql-singletenant.yml
+++ b/.github/workflows/api-e2e-pgsql-singletenant.yml
@@ -41,6 +41,7 @@ jobs:
cp -r ../EdFi.Ods.AdminApi ../../Docker/Application
cp -r ../EdFi.Ods.AdminApi.AdminConsole ../../Docker/Application
cp -r ../EdFi.Ods.AdminApi.Common ../../Docker/Application
+ cp -r ../EdFi.Ods.AdminApi.HealthCheck ../../Docker/Application
- name: Copy nuget config to docker context
run: cp ../NuGet.Config ../../Docker/Application
diff --git a/Application/Directory.Packages.props b/Application/Directory.Packages.props
index 513a86f14..4ae594879 100644
--- a/Application/Directory.Packages.props
+++ b/Application/Directory.Packages.props
@@ -79,5 +79,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Application/Ed-Fi-ODS-AdminApi.sln b/Application/Ed-Fi-ODS-AdminApi.sln
index 975da9156..d22977502 100644
--- a/Application/Ed-Fi-ODS-AdminApi.sln
+++ b/Application/Ed-Fi-ODS-AdminApi.sln
@@ -30,6 +30,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EdFi.Ods.AdminApi.Common.Un
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EdFi.Ods.AdminApi.AdminConsole.UnitTests", "EdFi.Ods.AdminApi.AdminConsole.UnitTests\EdFi.Ods.AdminApi.AdminConsole.UnitTests.csproj", "{7C919128-B651-4756-8625-A8F7882FA9A4}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EdFi.Ods.AdminApi.HealthCheck", "EdFi.Ods.AdminApi.HealthCheck\EdFi.Ods.AdminApi.HealthCheck.csproj", "{243D1BB9-7E56-7439-D4DF-438EF9A8692F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EdFi.Ods.AdminApi.HealthCheck.UnitTests", "EdFi.Ods.AdminApi.HealthCheck.UnitTests\EdFi.Ods.AdminApi.HealthCheck.UnitTests.csproj", "{9CF7B5C5-92F6-A980-A213-71B14729F0A0}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -102,6 +106,22 @@ Global
{7C919128-B651-4756-8625-A8F7882FA9A4}.Release|Any CPU.Build.0 = Release|Any CPU
{7C919128-B651-4756-8625-A8F7882FA9A4}.Release|x64.ActiveCfg = Release|Any CPU
{7C919128-B651-4756-8625-A8F7882FA9A4}.Release|x64.Build.0 = Release|Any CPU
+ {243D1BB9-7E56-7439-D4DF-438EF9A8692F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {243D1BB9-7E56-7439-D4DF-438EF9A8692F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {243D1BB9-7E56-7439-D4DF-438EF9A8692F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {243D1BB9-7E56-7439-D4DF-438EF9A8692F}.Debug|x64.Build.0 = Debug|Any CPU
+ {243D1BB9-7E56-7439-D4DF-438EF9A8692F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {243D1BB9-7E56-7439-D4DF-438EF9A8692F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {243D1BB9-7E56-7439-D4DF-438EF9A8692F}.Release|x64.ActiveCfg = Release|Any CPU
+ {243D1BB9-7E56-7439-D4DF-438EF9A8692F}.Release|x64.Build.0 = Release|Any CPU
+ {9CF7B5C5-92F6-A980-A213-71B14729F0A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9CF7B5C5-92F6-A980-A213-71B14729F0A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9CF7B5C5-92F6-A980-A213-71B14729F0A0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9CF7B5C5-92F6-A980-A213-71B14729F0A0}.Debug|x64.Build.0 = Debug|Any CPU
+ {9CF7B5C5-92F6-A980-A213-71B14729F0A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9CF7B5C5-92F6-A980-A213-71B14729F0A0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9CF7B5C5-92F6-A980-A213-71B14729F0A0}.Release|x64.ActiveCfg = Release|Any CPU
+ {9CF7B5C5-92F6-A980-A213-71B14729F0A0}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Application/Ed-Fi-ODS-AdminApi.sln.DotSettings b/Application/Ed-Fi-ODS-AdminApi.sln.DotSettings
index 2bdd201f0..aa0b9430a 100644
--- a/Application/Ed-Fi-ODS-AdminApi.sln.DotSettings
+++ b/Application/Ed-Fi-ODS-AdminApi.sln.DotSettings
@@ -130,6 +130,10 @@ See the LICENSE and NOTICES files in the project root for more information.<Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" />
<Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" />
<Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" />
+ <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"><ElementKinds><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /></Policy>
+ <Policy><Descriptor Staticness="Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Instance fields (not private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy>
+ <Policy><Descriptor Staticness="Static" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Static fields (not private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy>
+ <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Local constants"><ElementKinds><Kind Name="LOCAL_CONSTANT" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy>
<Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" />
<Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" />
<Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" />
@@ -188,6 +192,7 @@ See the LICENSE and NOTICES files in the project root for more information.True
True
True
+ True
True
True
True
diff --git a/Application/EdFi.Ods.AdminApi.AdminConsole/Infrastructure/Services/Instances/Models/IInstanceRequestModel.cs b/Application/EdFi.Ods.AdminApi.AdminConsole/Infrastructure/Services/Instances/Models/IInstanceRequestModel.cs
index c79aa39b2..de7b68254 100644
--- a/Application/EdFi.Ods.AdminApi.AdminConsole/Infrastructure/Services/Instances/Models/IInstanceRequestModel.cs
+++ b/Application/EdFi.Ods.AdminApi.AdminConsole/Infrastructure/Services/Instances/Models/IInstanceRequestModel.cs
@@ -17,7 +17,7 @@ public interface IInstanceRequestModel
ICollection? OdsInstanceDerivatives { get; }
byte[]? Credentials { get; }
- public string? Status { get; set; }
+ string? Status { get; set; }
}
public class OdsInstanceContextModel
diff --git a/Application/EdFi.Ods.AdminApi.AdminConsole/Infrastructure/Services/Tenants/TenantService.cs b/Application/EdFi.Ods.AdminApi.AdminConsole/Infrastructure/Services/Tenants/TenantService.cs
index 4b122bfa8..177f96bf6 100644
--- a/Application/EdFi.Ods.AdminApi.AdminConsole/Infrastructure/Services/Tenants/TenantService.cs
+++ b/Application/EdFi.Ods.AdminApi.AdminConsole/Infrastructure/Services/Tenants/TenantService.cs
@@ -21,11 +21,11 @@ public interface IAdminConsoleTenantsService
Task GetTenantByTenantIdAsync(int tenantId);
}
-public class TenantService(IOptionsSnapshot options,
- IMemoryCache memoryCache) : IAdminConsoleTenantsService
+public class TenantService(IOptionsMonitor options, IMemoryCache memoryCache)
+ : IAdminConsoleTenantsService
{
private const string ADMIN_DB_KEY = "EdFi_Admin";
- protected AppSettingsFile _appSettings = options.Value;
+ protected AppSettingsFile _appSettings = options.CurrentValue;
private readonly IMemoryCache _memoryCache = memoryCache;
private static readonly ILog _log = LogManager.GetLogger(typeof(TenantService));
diff --git a/Application/EdFi.Ods.AdminApi.Common/Infrastructure/AdminApiEndpointBuilder.cs b/Application/EdFi.Ods.AdminApi.Common/Infrastructure/AdminApiEndpointBuilder.cs
index ceb0386c9..745f32ad4 100644
--- a/Application/EdFi.Ods.AdminApi.Common/Infrastructure/AdminApiEndpointBuilder.cs
+++ b/Application/EdFi.Ods.AdminApi.Common/Infrastructure/AdminApiEndpointBuilder.cs
@@ -145,7 +145,7 @@ public void BuildForVersions(string authorizationPolicy, params AdminApiVersions
{
builder.WithResponseCode(400, FeatureCommonConstants.BadRequestResponseDescription);
}
-
+
foreach (var action in _routeOptions)
{
action(builder);
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/EdFi.Ods.AdminApi.HealthCheck.UnitTests.csproj b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/EdFi.Ods.AdminApi.HealthCheck.UnitTests.csproj
new file mode 100644
index 000000000..dd9f8ec2c
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/EdFi.Ods.AdminApi.HealthCheck.UnitTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Features/OdsApi/OdsApiCallerTests.cs b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Features/OdsApi/OdsApiCallerTests.cs
new file mode 100644
index 000000000..734ab3fa0
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Features/OdsApi/OdsApiCallerTests.cs
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using System.Net;
+using EdFi.Ods.AdminApi.HealthCheck.Helpers;
+using EdFi.Ods.AdminApi.HealthCheck.Infrastructure;
+using EdFi.Ods.AdminApi.HealthCheck.Features.OdsApi;
+using FakeItEasy;
+using NUnit.Framework;
+using Shouldly;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.UnitTests.Features.OdsApi;
+
+public class Given_an_ods_api
+{
+ [TestFixture]
+ public class When_HealthCheckData_is_returned_from_api : Given_an_ods_api
+ {
+ private IOdsApiClient _odsApiClient;
+ private OdsApiCaller _odsApiCaller;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _odsApiClient = A.Fake();
+
+ var adminApiInstance = Testing.AdminApiInstances.First();
+
+ var httpResponse1 = new HttpResponseMessage(HttpStatusCode.OK);
+ httpResponse1.Headers.Add(Constants.TotalCountHeader, "3");
+
+ var httpResponse2 = new HttpResponseMessage(HttpStatusCode.OK);
+ httpResponse2.Headers.Add(Constants.TotalCountHeader, "8");
+
+ var httpResponse3 = new HttpResponseMessage(HttpStatusCode.OK);
+ httpResponse3.Headers.Add(Constants.TotalCountHeader, "5");
+
+ A.CallTo(() => _odsApiClient.OdsApiGet(A.Ignored, A.Ignored, A.Ignored, "http://www.myserver.com/data/v3/ed-fi/firstEndPoint?offset=0&limit=0&totalCount=true"))
+ .Returns(new ApiResponse(HttpStatusCode.OK, string.Empty, httpResponse1.Headers));
+
+ A.CallTo(() => _odsApiClient.OdsApiGet(A.Ignored, A.Ignored, A.Ignored, "http://www.myserver.com/data/v3/ed-fi/secondEndpoint?offset=0&limit=0&totalCount=true"))
+ .Returns(new ApiResponse(HttpStatusCode.OK, string.Empty, httpResponse2.Headers));
+
+ A.CallTo(() => _odsApiClient.OdsApiGet(A.Ignored, A.Ignored, A.Ignored, "http://www.myserver.com/data/v3/ed-fi/thirdEndPoint?offset=0&limit=0&totalCount=true"))
+ .Returns(new ApiResponse(HttpStatusCode.OK, string.Empty, httpResponse3.Headers));
+
+ _odsApiCaller = new OdsApiCaller(_odsApiClient, new AppSettingsOdsApiEndpoints(Testing.GetOdsApiSettings()));
+ }
+
+ [Test]
+ public async Task should_return_stronglytyped_healthCheck_data()
+ {
+ var healthCheckData = await _odsApiCaller.GetHealthCheckDataAsync(Testing.AdminApiInstances.First());
+ healthCheckData.ShouldBeEquivalentTo(Testing.HealthCheckData);
+ }
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Features/OdsApi/OdsApiClientTests.cs b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Features/OdsApi/OdsApiClientTests.cs
new file mode 100644
index 000000000..27c65e8d3
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Features/OdsApi/OdsApiClientTests.cs
@@ -0,0 +1,84 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using EdFi.Ods.AdminApi.HealthCheck.Features.OdsApi;
+using EdFi.Ods.AdminApi.HealthCheck.Helpers;
+using EdFi.Ods.AdminApi.HealthCheck.Infrastructure;
+using FakeItEasy;
+using Microsoft.Extensions.Logging;
+using NUnit.Framework;
+using Shouldly;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.UnitTests.Features.OdsApi;
+
+public class Given_an_ods_environment_with_single_tenant
+{
+ private ILogger _logger;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _logger = A.Fake>();
+ }
+
+ public class When_HealthCheck_data_is_requested : Given_an_ods_environment_with_single_tenant
+ {
+ [Test]
+ public async Task should_return_successfully()
+ {
+ var httpClient = A.Fake();
+ var adminApiInstance = Testing.AdminApiInstances.First();
+ var encodedKeySecret = Encoding.ASCII.GetBytes($"{adminApiInstance.ClientId}:{adminApiInstance.ClientSecret}");
+ var headers = new HttpResponseMessage().Headers;
+ headers.Add(Constants.TotalCountHeader, "5");
+
+ A.CallTo(() => httpClient.SendAsync(
+ adminApiInstance.OauthUrl, HttpMethod.Post, A.Ignored, new AuthenticationHeaderValue("Basic", Convert.ToBase64String(encodedKeySecret))))
+ .Returns(new ApiResponse(HttpStatusCode.OK, "{ \"access_token\": \"123\"}"));
+
+ A.CallTo(() => httpClient.SendAsync(adminApiInstance.ResourceUrl, HttpMethod.Get, null as StringContent, new AuthenticationHeaderValue("bearer", "123")))
+ .Returns(new ApiResponse(HttpStatusCode.OK, string.Empty, headers));
+
+ var odsApiClient = new OdsApiClient(httpClient, _logger, Testing.GetAppSettings());
+
+ var response = await odsApiClient.OdsApiGet(
+ adminApiInstance.OauthUrl, adminApiInstance.ClientId, adminApiInstance.ClientSecret, adminApiInstance.ResourceUrl);
+
+ response.Headers.ShouldNotBeNull();
+ response.Headers.Any(o => o.Key == Constants.TotalCountHeader).ShouldBe(true);
+ response.Headers.GetValues(Constants.TotalCountHeader).First().ShouldBe("5");
+ }
+ }
+
+ public class When_HealthCheck_data_is_requested_without_token : Given_an_ods_environment_with_single_tenant
+ {
+ [Test]
+ public async Task InternalServerError_is_returned()
+ {
+ var httpClient = A.Fake();
+ var adminApiInstance = Testing.AdminApiInstances.First();
+ var encodedKeySecret = Encoding.ASCII.GetBytes($"{adminApiInstance.ClientId}:{adminApiInstance.ClientSecret}");
+
+ var headers = new HttpResponseMessage().Headers;
+ headers.Add(Constants.TotalCountHeader, "5");
+
+ A.CallTo(() => httpClient.SendAsync(
+ adminApiInstance.OauthUrl, HttpMethod.Post, A.Ignored, new AuthenticationHeaderValue("Basic", Convert.ToBase64String(encodedKeySecret))))
+ .Returns(new ApiResponse(HttpStatusCode.InternalServerError, string.Empty));
+
+ A.CallTo(() => httpClient.SendAsync(adminApiInstance.ResourceUrl, HttpMethod.Get, null as StringContent, new AuthenticationHeaderValue("bearer", "123")))
+ .Returns(new ApiResponse(HttpStatusCode.OK, string.Empty, headers));
+
+ var odsApiClient = new OdsApiClient(httpClient, _logger, Testing.GetAppSettings());
+
+ var response = await odsApiClient.OdsApiGet(adminApiInstance.OauthUrl, adminApiInstance.ClientId, adminApiInstance.ClientSecret, adminApiInstance.ResourceUrl);
+
+ response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError);
+ }
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/HealthCheckServiceTests.cs b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/HealthCheckServiceTests.cs
new file mode 100644
index 000000000..6b5e27999
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/HealthCheckServiceTests.cs
@@ -0,0 +1,604 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using System.Dynamic;
+using System.Text;
+using EdFi.Ods.AdminApi.AdminConsole.Features.Tenants;
+using EdFi.Ods.AdminApi.AdminConsole.Features.WorkerInstances;
+using EdFi.Ods.AdminApi.AdminConsole.Infrastructure.DataAccess.Models;
+using EdFi.Ods.AdminApi.AdminConsole.Infrastructure.Services.HealthChecks.Commands;
+using EdFi.Ods.AdminApi.AdminConsole.Infrastructure.Services.Instances.Queries;
+using EdFi.Ods.AdminApi.AdminConsole.Infrastructure.Services.Tenants;
+using FakeItEasy;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+using NUnit.Framework;
+using Shouldly;
+using EdFi.Ods.AdminApi.HealthCheck.Features.AdminApi;
+using EdFi.Ods.AdminApi.HealthCheck.Features.OdsApi;
+using EdFi.Ods.AdminApi.HealthCheck.UnitTests.Helpers;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.UnitTests;
+
+public class Given_a_health_check_service
+{
+ public static List CreateTestTenants()
+ {
+ var tenant = new TenantModel { TenantId = 1 };
+ var expandoObject = new ExpandoObject();
+ var dict = (IDictionary)expandoObject;
+ dict["name"] = "TestTenant";
+ tenant.Document = expandoObject;
+ return [tenant];
+ }
+
+ [TestFixture]
+ public class When_running_health_check_with_valid_tenants_and_instances : Given_a_health_check_service
+ {
+ private ILogger _logger;
+ private IAdminConsoleTenantsService _adminConsoleTenantsService;
+ private IGetInstancesQuery _getInstancesQuery;
+ private IAddHealthCheckCommand _addHealthCheckCommand;
+ private IOdsApiCaller _odsApiCaller;
+ private HealthCheckService _healthCheckService;
+ private CancellationToken _cancellationToken;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _logger = A.Fake>();
+ _adminConsoleTenantsService = A.Fake();
+ _getInstancesQuery = A.Fake();
+ _addHealthCheckCommand = A.Fake();
+ _odsApiCaller = A.Fake();
+ _cancellationToken = CancellationToken.None;
+
+ // Setup test data
+ var tenants = CreateTestTenants();
+ var instances = CreateTestInstances();
+ var healthCheckData = CreateTestHealthCheckData();
+
+ var savedHealthCheck = new AdminConsole.Infrastructure.DataAccess.Models.HealthCheck
+ {
+ DocId = 1,
+ InstanceId = 1,
+ EdOrgId = 255,
+ TenantId = 1,
+ Document = JsonSerializer.Serialize(healthCheckData).ToString()
+ };
+
+ A.CallTo(() => _adminConsoleTenantsService.GetTenantsAsync(true))
+ .Returns(tenants);
+
+ A.CallTo(() => _getInstancesQuery.Execute("TestTenant", "Completed"))
+ .Returns(instances);
+
+ A.CallTo(() => _odsApiCaller.GetHealthCheckDataAsync(A.That.Matches(i => i.Id == 1)))
+ .Returns(Task.FromResult(healthCheckData));
+
+ A.CallTo(() => _addHealthCheckCommand.Execute(A.Ignored))
+ .Returns(savedHealthCheck);
+
+ _healthCheckService = new HealthCheckService(
+ _logger,
+ _adminConsoleTenantsService,
+ _getInstancesQuery,
+ _addHealthCheckCommand,
+ _odsApiCaller);
+ }
+
+ [Test]
+ public async Task Should_call_get_tenants_service()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ A.CallTo(() => _adminConsoleTenantsService.GetTenantsAsync(true))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Test]
+ public async Task Should_call_get_instances_query_for_each_tenant()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ A.CallTo(() => _getInstancesQuery.Execute("TestTenant", "Completed"))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Test]
+ public async Task Should_call_ods_api_caller_for_valid_instances()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ A.CallTo(() => _odsApiCaller.GetHealthCheckDataAsync(A.That.Matches(i => i.Id == 1)))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Test]
+ public async Task Should_call_add_health_check_command_with_correct_data()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ A.CallTo(() => _addHealthCheckCommand.Execute(A.That.Matches(cmd =>
+ cmd.TenantId == 1 &&
+ cmd.InstanceId == 1 &&
+ !string.IsNullOrEmpty(cmd.Document))))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ private static List CreateTestInstances()
+ {
+ var credentials = new InstanceWorkerModelDto
+ {
+ ClientId = "test-client-id",
+ Secret = "test-secret"
+ };
+
+ return
+ [
+ new Instance
+ {
+ Id = 1,
+ TenantId = 1,
+ InstanceName = "TestInstance",
+ ResourceUrl = "http://test-resource-url.com",
+ OAuthUrl = "http://test-oauth-url.com",
+ Credentials = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(credentials))
+ }
+ ];
+ }
+
+ private static List CreateTestHealthCheckData()
+ {
+ return
+ [
+ new OdsApiEndpointNameCount { OdsApiEndpointName="students", OdsApiEndpointCount=100 },
+ new OdsApiEndpointNameCount { OdsApiEndpointName="schools", OdsApiEndpointCount=10 },
+ new OdsApiEndpointNameCount { OdsApiEndpointName="staff", OdsApiEndpointCount=50 }
+ ];
+ }
+ }
+
+ [TestFixture]
+ public class When_running_health_check_with_no_tenants : Given_a_health_check_service
+ {
+ private IAdminConsoleTenantsService _adminConsoleTenantsService;
+ private IGetInstancesQuery _getInstancesQuery;
+ private IAddHealthCheckCommand _addHealthCheckCommand;
+ private IOdsApiCaller _odsApiCaller;
+ private HealthCheckService _healthCheckService;
+ private CancellationToken _cancellationToken;
+ public TestLoggerProvider _testLoggerProvider;
+ public ILogger _logger;
+
+ [TearDown]
+ public void TearDown()
+ {
+ _testLoggerProvider?.Dispose();
+ }
+
+ [SetUp]
+ public void SetUp()
+ {
+ _adminConsoleTenantsService = A.Fake();
+ _getInstancesQuery = A.Fake();
+ _addHealthCheckCommand = A.Fake();
+ _odsApiCaller = A.Fake();
+ _cancellationToken = CancellationToken.None;
+
+ _testLoggerProvider = new TestLoggerProvider();
+ using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(_testLoggerProvider));
+ _logger = loggerFactory.CreateLogger();
+
+ A.CallTo(() => _adminConsoleTenantsService.GetTenantsAsync(true))
+ .Returns([]);
+
+ _healthCheckService = new HealthCheckService(
+ _logger,
+ _adminConsoleTenantsService,
+ _getInstancesQuery,
+ _addHealthCheckCommand,
+ _odsApiCaller);
+ }
+
+ [Test]
+ public async Task Should_log_no_tenants_message()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ var information = _testLoggerProvider.Entries.FirstOrDefault(e =>
+ e.LogLevel == LogLevel.Information &&
+ e.Message != null && e.Message.Contains("No tenants returned from Admin Api"));
+
+ information.ShouldNotBeNull();
+ }
+
+ [Test]
+ public async Task Should_not_call_get_instances_query()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ A.CallTo(() => _getInstancesQuery.Execute(A.Ignored, A.Ignored))
+ .MustNotHaveHappened();
+ }
+
+ [Test]
+ public async Task Should_not_call_ods_api_caller()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ A.CallTo(() => _odsApiCaller.GetHealthCheckDataAsync(A.Ignored))
+ .MustNotHaveHappened();
+ }
+
+ [Test]
+ public async Task Should_not_call_add_health_check_command()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ A.CallTo(() => _addHealthCheckCommand.Execute(A.Ignored))
+ .MustNotHaveHappened();
+ }
+ }
+
+ [TestFixture]
+ public class When_running_health_check_with_no_instances : Given_a_health_check_service
+ {
+ private IAdminConsoleTenantsService _adminConsoleTenantsService;
+ private IGetInstancesQuery _getInstancesQuery;
+ private IAddHealthCheckCommand _addHealthCheckCommand;
+ private IOdsApiCaller _odsApiCaller;
+ private HealthCheckService _healthCheckService;
+ private CancellationToken _cancellationToken;
+ public TestLoggerProvider _testLoggerProvider;
+ public ILogger _logger;
+
+ [TearDown]
+ public void TearDown()
+ {
+ _testLoggerProvider?.Dispose();
+ }
+
+ [SetUp]
+ public void SetUp()
+ {
+ _logger = A.Fake>();
+ _adminConsoleTenantsService = A.Fake();
+ _getInstancesQuery = A.Fake();
+ _addHealthCheckCommand = A.Fake();
+ _odsApiCaller = A.Fake();
+ _cancellationToken = CancellationToken.None;
+
+ _testLoggerProvider = new TestLoggerProvider();
+ using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(_testLoggerProvider));
+ _logger = loggerFactory.CreateLogger();
+
+ var tenants = CreateTestTenants();
+
+ A.CallTo(() => _adminConsoleTenantsService.GetTenantsAsync(true))
+ .Returns(tenants);
+
+ A.CallTo(() => _getInstancesQuery.Execute("TestTenant", "Completed"))
+ .Returns([]);
+
+ _healthCheckService = new HealthCheckService(
+ _logger,
+ _adminConsoleTenantsService,
+ _getInstancesQuery,
+ _addHealthCheckCommand,
+ _odsApiCaller);
+ }
+
+ [Test]
+ public async Task Should_log_no_instances_message()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ var information = _testLoggerProvider.Entries.FirstOrDefault(e =>
+ e.LogLevel == LogLevel.Information &&
+ e.Message != null && e.Message.Contains("No instances found on Admin Api"));
+
+ information.ShouldNotBeNull();
+ }
+
+ [Test]
+ public async Task Should_not_call_ods_api_caller()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ A.CallTo(() => _odsApiCaller.GetHealthCheckDataAsync(A.Ignored))
+ .MustNotHaveHappened();
+ }
+
+ [Test]
+ public async Task Should_not_call_add_health_check_command()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ A.CallTo(() => _addHealthCheckCommand.Execute(A.Ignored))
+ .MustNotHaveHappened();
+ }
+ }
+
+ [TestFixture]
+ public class When_running_health_check_with_invalid_instance : Given_a_health_check_service
+ {
+ private ILogger _logger;
+ private IAdminConsoleTenantsService _adminConsoleTenantsService;
+ private IGetInstancesQuery _getInstancesQuery;
+ private IAddHealthCheckCommand _addHealthCheckCommand;
+ private IOdsApiCaller _odsApiCaller;
+ private HealthCheckService _healthCheckService;
+ private CancellationToken _cancellationToken;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _logger = A.Fake>();
+ _adminConsoleTenantsService = A.Fake();
+ _getInstancesQuery = A.Fake();
+ _addHealthCheckCommand = A.Fake();
+ _odsApiCaller = A.Fake();
+ _cancellationToken = CancellationToken.None;
+
+ var tenants = CreateTestTenants();
+ var invalidInstances = CreateInvalidTestInstances();
+
+ A.CallTo(() => _adminConsoleTenantsService.GetTenantsAsync(true))
+ .Returns(tenants);
+
+ A.CallTo(() => _getInstancesQuery.Execute("TestTenant", "Completed"))
+ .Returns(invalidInstances);
+
+ _healthCheckService = new HealthCheckService(
+ _logger,
+ _adminConsoleTenantsService,
+ _getInstancesQuery,
+ _addHealthCheckCommand,
+ _odsApiCaller);
+ }
+
+ [Test]
+ public async Task Should_not_call_ods_api_caller_for_invalid_instance()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ A.CallTo(() => _odsApiCaller.GetHealthCheckDataAsync(A.Ignored))
+ .MustNotHaveHappened();
+ }
+
+ [Test]
+ public async Task Should_not_call_add_health_check_command_for_invalid_instance()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ A.CallTo(() => _addHealthCheckCommand.Execute(A.Ignored))
+ .MustNotHaveHappened();
+ }
+
+ private static List CreateInvalidTestInstances()
+ {
+ return
+ [
+ new Instance
+ {
+ Id = 1,
+ TenantId = 1,
+ InstanceName = "InvalidInstance",
+ ResourceUrl = "", // Invalid - empty resource URL
+ OAuthUrl = "", // Invalid - empty OAuth URL
+ Credentials = null // Invalid - no credentials
+ }
+ ];
+ }
+ }
+
+ [TestFixture]
+ public class When_running_health_check_with_no_health_check_data : Given_a_health_check_service
+ {
+ private ILogger _logger;
+ private IAdminConsoleTenantsService _adminConsoleTenantsService;
+ private IGetInstancesQuery _getInstancesQuery;
+ private IAddHealthCheckCommand _addHealthCheckCommand;
+ private IOdsApiCaller _odsApiCaller;
+ private HealthCheckService _healthCheckService;
+ private CancellationToken _cancellationToken;
+ private TestLoggerProvider _testLoggerProvider;
+
+ [TearDown]
+ public void TearDown()
+ {
+ _testLoggerProvider?.Dispose();
+ }
+
+ [SetUp]
+ public void SetUp()
+ {
+ _adminConsoleTenantsService = A.Fake();
+ _getInstancesQuery = A.Fake();
+ _addHealthCheckCommand = A.Fake();
+ _odsApiCaller = A.Fake();
+ _cancellationToken = CancellationToken.None;
+
+ _testLoggerProvider = new TestLoggerProvider();
+ using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(_testLoggerProvider));
+ _logger = loggerFactory.CreateLogger();
+
+ var tenants = CreateTestTenants();
+ var instances = CreateTestInstances();
+
+ A.CallTo(() => _adminConsoleTenantsService.GetTenantsAsync(true))
+ .Returns(tenants);
+
+ A.CallTo(() => _getInstancesQuery.Execute("TestTenant", "Completed"))
+ .Returns(instances);
+
+ A.CallTo(() => _odsApiCaller.GetHealthCheckDataAsync(A.Ignored))
+ .Returns([]); // Empty health check data
+
+ _healthCheckService = new HealthCheckService(
+ _logger,
+ _adminConsoleTenantsService,
+ _getInstancesQuery,
+ _addHealthCheckCommand,
+ _odsApiCaller);
+ }
+
+ [Test]
+ public async Task Should_log_no_health_check_data_message()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ var information = _testLoggerProvider.Entries.FirstOrDefault(e =>
+ e.LogLevel == LogLevel.Information &&
+ e.Message != null && e.Message.Contains("No HealthCheck data has been collected"));
+
+ information.ShouldNotBeNull();
+ }
+
+ [Test]
+ public async Task Should_not_call_add_health_check_command()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ A.CallTo(() => _addHealthCheckCommand.Execute(A.Ignored))
+ .MustNotHaveHappened();
+ }
+
+ private static List CreateTestInstances()
+ {
+ var credentials = new InstanceWorkerModelDto
+ {
+ ClientId = "test-client-id",
+ Secret = "test-secret"
+ };
+
+ return
+ [
+ new Instance
+ {
+ Id = 1,
+ TenantId = 1,
+ InstanceName = "TestInstance",
+ ResourceUrl = "http://test-resource-url.com",
+ OAuthUrl = "http://test-oauth-url.com",
+ Credentials = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(credentials))
+ }
+ ];
+ }
+ }
+
+ [TestFixture]
+ public class When_running_health_check_with_exception : Given_a_health_check_service
+ {
+ private ILogger _logger;
+ private IAdminConsoleTenantsService _adminConsoleTenantsService;
+ private IGetInstancesQuery _getInstancesQuery;
+ private IAddHealthCheckCommand _addHealthCheckCommand;
+ private IOdsApiCaller _odsApiCaller;
+ private HealthCheckService _healthCheckService;
+ private CancellationToken _cancellationToken;
+ private TestLoggerProvider _testLoggerProvider;
+
+ [TearDown]
+ public void TearDown()
+ {
+ _testLoggerProvider?.Dispose();
+ }
+
+ [SetUp]
+ public void SetUp()
+ {
+ _adminConsoleTenantsService = A.Fake();
+ _getInstancesQuery = A.Fake();
+ _addHealthCheckCommand = A.Fake();
+ _odsApiCaller = A.Fake();
+ _cancellationToken = CancellationToken.None;
+
+ _testLoggerProvider = new TestLoggerProvider();
+ using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(_testLoggerProvider));
+ _logger = loggerFactory.CreateLogger();
+
+ A.CallTo(() => _adminConsoleTenantsService.GetTenantsAsync(true))
+ .Throws(new Exception("Test exception"));
+
+ _healthCheckService = new HealthCheckService(
+ _logger,
+ _adminConsoleTenantsService,
+ _getInstancesQuery,
+ _addHealthCheckCommand,
+ _odsApiCaller);
+ }
+
+ [Test]
+ public async Task Should_log_error_when_exception_occurs()
+ {
+ await _healthCheckService.RunAsync(_cancellationToken);
+
+ var error = _testLoggerProvider.Entries.FirstOrDefault(e =>
+ e.LogLevel == LogLevel.Error &&
+ e.Message != null && e.Message.Contains("An error occurred while running the HealthCheck Service.")
+ && e.Exception != null && e.Exception.Message == "Test exception");
+
+ error.ShouldNotBeNull();
+ }
+
+ [Test]
+ public async Task Should_not_throw_exception()
+ {
+ Should.NotThrow(async () => await _healthCheckService.RunAsync(_cancellationToken));
+ }
+ }
+}
+
+public class Given_a_health_check_command_model
+{
+ [TestFixture]
+ public class When_creating_health_check_command_model : Given_a_health_check_command_model
+ {
+ [Test]
+ public void Should_set_properties_correctly()
+ {
+ const int TenantId = 1;
+ const int InstanceId = 2;
+ const string Document = "test document";
+
+ var model = new HealthCheckCommandModel(TenantId, InstanceId, Document);
+
+ model.TenantId.ShouldBe(TenantId);
+ model.InstanceId.ShouldBe(InstanceId);
+ model.Document.ShouldBe(Document);
+ model.DocId.ShouldBe(0); // Default value
+ model.EdOrgId.ShouldBe(0); // Default value
+ }
+
+ [Test]
+ public void Should_implement_IAddHealthCheckModel()
+ {
+ var model = new HealthCheckCommandModel(1, 2, "document");
+
+ model.ShouldBeAssignableTo();
+ }
+
+ [Test]
+ public void Should_allow_property_modification()
+ {
+ var model = new HealthCheckCommandModel(1, 2, "document")
+ {
+ TenantId = 10,
+ InstanceId = 20,
+ Document = "new document",
+ DocId = 30,
+ EdOrgId = 40
+ };
+
+ model.TenantId.ShouldBe(10);
+ model.InstanceId.ShouldBe(20);
+ model.Document.ShouldBe("new document");
+ model.DocId.ShouldBe(30);
+ model.EdOrgId.ShouldBe(40);
+ }
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Helpers/InstanceValidatorTests.cs b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Helpers/InstanceValidatorTests.cs
new file mode 100644
index 000000000..8f3123fa6
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Helpers/InstanceValidatorTests.cs
@@ -0,0 +1,90 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using EdFi.Ods.AdminApi.HealthCheck.Helpers;
+using EdFi.Ods.AdminApi.HealthCheck.Features.AdminApi;
+using FakeItEasy;
+using Microsoft.Extensions.Logging;
+using NUnit.Framework;
+using Shouldly;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.UnitTests.Helpers;
+
+public class Given_an_instance_returned_from_AdminApi
+{
+ private AdminConsoleInstance _instance = new AdminConsoleInstance();
+ private ILogger _logger;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _logger = A.Fake>();
+
+ _instance.OauthUrl = "Some url";
+ _instance.ResourceUrl = "Some url";
+ _instance.ClientId = "Some url";
+ _instance.ClientSecret = "Some url";
+ _instance.Id = 1;
+ _instance.TenantId = 1;
+ _instance.InstanceName = "Some url";
+ }
+
+ [TestFixture]
+ public class When_it_has_all_required_fields : Given_an_instance_returned_from_AdminApi
+ {
+ [Test]
+ public void should_be_valid()
+ {
+ InstanceValidator.IsInstanceValid(_logger, _instance).ShouldBeTrue();
+ }
+ }
+
+ [TestFixture]
+ public class When_it_does_not_have_AuthenticationUrl : Given_an_instance_returned_from_AdminApi
+ {
+ [Test]
+ public void should_be_invalid()
+ {
+ _instance.OauthUrl = string.Empty;
+ InstanceValidator.IsInstanceValid(_logger, _instance).ShouldBeFalse();
+ }
+ }
+
+ [TestFixture]
+ public class When_it_does_not_have_ResourceUrl : Given_an_instance_returned_from_AdminApi
+ {
+
+ [Test]
+ public void should_be_invalid()
+ {
+ _instance.ResourceUrl = string.Empty;
+ InstanceValidator.IsInstanceValid(_logger, _instance).ShouldBeFalse();
+ }
+ }
+
+
+ [TestFixture]
+ public class When_it_does_not_have_ClientId : Given_an_instance_returned_from_AdminApi
+ {
+ [Test]
+ public void should_be_invalid()
+ {
+ _instance.ClientId = string.Empty;
+ InstanceValidator.IsInstanceValid(_logger, _instance).ShouldBeFalse();
+ }
+ }
+
+ [TestFixture]
+ public class When_it_does_not_have_ClientSecret : Given_an_instance_returned_from_AdminApi
+ {
+
+ [Test]
+ public void should_be_invalid()
+ {
+ _instance.ClientSecret = string.Empty;
+ InstanceValidator.IsInstanceValid(_logger, _instance).ShouldBeFalse();
+ }
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Helpers/JsonBuilderTests.cs b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Helpers/JsonBuilderTests.cs
new file mode 100644
index 000000000..3c17fd37c
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Helpers/JsonBuilderTests.cs
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using EdFi.Ods.AdminApi.HealthCheck.Features.OdsApi;
+using EdFi.Ods.AdminApi.HealthCheck.Helpers;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using Shouldly;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.UnitTests.Helpers;
+
+public class Given_a_set_of_healthCheck_data
+{
+ private List _endpoointCounts = new List();
+
+ [SetUp]
+ public void SetUp()
+ {
+ _endpoointCounts = new List()
+ {
+ new OdsApiEndpointNameCount()
+ {
+ OdsApiEndpointName = "Some endpoint",
+ OdsApiEndpointCount = 2,
+ AnyErrros = false
+ },
+ new OdsApiEndpointNameCount()
+ {
+ OdsApiEndpointName = "Other endpoint",
+ OdsApiEndpointCount = 3,
+ AnyErrros = false
+ }
+ };
+ }
+
+ [TestFixture]
+ public class When_a_json_is_built : Given_a_set_of_healthCheck_data
+ {
+ [Test]
+ public void should_be_valid()
+ {
+ var expectedHealthCheckJsonObjectPayload = "{\"healthy\": true,\"Some endpoint\": 2,\"Other endpoint\": 3}";
+
+ var healthCheckJsonObjectPayload = JsonBuilder.BuildJsonObject(_endpoointCounts);
+
+ JObject.Parse(healthCheckJsonObjectPayload.ToString()).ShouldBeEquivalentTo(JObject.Parse(expectedHealthCheckJsonObjectPayload.ToString()));
+ }
+ }
+
+ [TestFixture]
+ public class When_a_json_is_built_with_errors : Given_a_set_of_healthCheck_data
+ {
+ [Test]
+ public void should_be_invalid()
+ {
+ var expectedHealthCheckJsonObjectPayload = "{\"healthy\": false,\"Some endpoint\": 2,\"Other endpoint\": 3,\"One more endpoint\": 0}";
+
+ var endpoointCountsWithErrors = _endpoointCounts;
+ endpoointCountsWithErrors.Add(new OdsApiEndpointNameCount
+ {
+ OdsApiEndpointName = "One more endpoint",
+ OdsApiEndpointCount = 0,
+ AnyErrros = true
+ });
+
+ var healthCheckJsonObjectPayload = JsonBuilder.BuildJsonObject(endpoointCountsWithErrors);
+
+ JObject.Parse(healthCheckJsonObjectPayload.ToString()).ShouldBeEquivalentTo(JObject.Parse(expectedHealthCheckJsonObjectPayload.ToString()));
+ }
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Helpers/TestLoggerProvider.cs b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Helpers/TestLoggerProvider.cs
new file mode 100644
index 000000000..a7697fd31
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Helpers/TestLoggerProvider.cs
@@ -0,0 +1,61 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using Microsoft.Extensions.Logging;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.UnitTests.Helpers;
+
+public class TestLoggerProvider : ILoggerProvider
+{
+ private readonly List _entries = [];
+ public IReadOnlyList Entries => _entries;
+
+ public ILogger CreateLogger(string categoryName) => new TestLogger(categoryName, _entries);
+
+ public void Dispose() { }
+
+ private class TestLogger(string category, List entries) : ILogger
+ {
+ private readonly List _entries = entries;
+ private readonly string _category = category;
+
+ public IDisposable? BeginScope(TState state) => NullScope.Instance;
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception exception,
+ Func formatter)
+ {
+ _entries.Add(new LogEntry
+ {
+ LogLevel = logLevel,
+ EventId = eventId,
+ Message = formatter(state, exception),
+ Exception = exception,
+ State = state,
+ Category = _category
+ });
+ }
+ }
+
+ private class NullScope : IDisposable
+ {
+ public static NullScope Instance { get; } = new();
+ public void Dispose() { }
+ }
+
+ public record LogEntry
+ {
+ public string? Category { get; init; }
+ public LogLevel LogLevel { get; init; }
+ public EventId EventId { get; init; }
+ public string? Message { get; init; }
+ public Exception? Exception { get; init; }
+ public object? State { get; init; }
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Infrastructure/AppHttpClientTests.cs b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Infrastructure/AppHttpClientTests.cs
new file mode 100644
index 000000000..64267c0f1
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Infrastructure/AppHttpClientTests.cs
@@ -0,0 +1,144 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using System.Net;
+using EdFi.Ods.AdminApi.HealthCheck.Infrastructure;
+using EdFi.Ods.AdminApi.HealthCheck;
+using FakeItEasy;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using NUnit.Framework;
+using Shouldly;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.UnitTests.Infrastructure;
+
+[TestFixture]
+public class AppHttpClientTests
+{
+ private AppSettings _settings;
+ private IOptions _options;
+ private ILogger _logger;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _settings = new AppSettings { MaxRetryAttempts = 2 };
+ _options = Options.Create(_settings);
+ _logger = A.Fake>();
+ }
+
+ private static HttpClient CreateHttpClient(HttpResponseMessage responseMessage, out FakeHttpMessageHandler handler)
+ {
+ handler = new FakeHttpMessageHandler(responseMessage);
+ return new HttpClient(handler);
+ }
+
+ [Test]
+ public async Task SendAsync_StringContent_ReturnsApiResponse_OnSuccess()
+ {
+ // Arrange
+ var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("success")
+ };
+ var httpClient = CreateHttpClient(responseMessage, out _);
+ var sut = new AppHttpClient(httpClient, _logger, _options);
+
+ // Act
+ var result = await sut.SendAsync("http://test", HttpMethod.Get, new StringContent(""), null);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ result.Content.ShouldBe("success");
+ }
+
+ [Test]
+ public async Task SendAsync_StringContent_RetriesOnTransientFailure()
+ {
+ // Arrange
+ int callCount = 0;
+ var handler = new FakeHttpMessageHandler(() =>
+ {
+ callCount++;
+ if (callCount == 1)
+ return new HttpResponseMessage(HttpStatusCode.RequestTimeout);
+ return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("recovered") };
+ });
+
+ var httpClient = new HttpClient(handler);
+ var sut = new AppHttpClient(httpClient, _logger, _options);
+
+ // Act
+ var result = await sut.SendAsync("http://test", HttpMethod.Get, new StringContent(""), null);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ result.Content.ShouldBe("recovered");
+ callCount.ShouldBe(2);
+ }
+
+ [Test]
+ public async Task SendAsync_StringContent_LogsWarning_OnNonOkStatus()
+ {
+ // Arrange
+ var responseMessage = new HttpResponseMessage(HttpStatusCode.BadRequest)
+ {
+ Content = new StringContent("bad request")
+ };
+ var httpClient = CreateHttpClient(responseMessage, out _);
+ var sut = new AppHttpClient(httpClient, _logger, _options);
+
+ // Act
+ var result = await sut.SendAsync("http://test", HttpMethod.Post, new StringContent(""), null);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
+ result.Content.ShouldBe("bad request");
+ }
+
+ [Test]
+ public async Task SendAsync_FormUrlEncodedContent_ReturnsApiResponse_OnSuccess()
+ {
+ // Arrange
+ var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("form success")
+ };
+ var httpClient = CreateHttpClient(responseMessage, out _);
+ var sut = new AppHttpClient(httpClient, _logger, _options);
+
+ // Act
+ var result = await sut.SendAsync("http://test", HttpMethod.Post, new FormUrlEncodedContent([]), null);
+
+ // Assert
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ result.Content.ShouldBe("form success");
+ }
+
+ private class FakeHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly Func? _responseFactory;
+ private readonly HttpResponseMessage? _staticResponse;
+
+ public FakeHttpMessageHandler(HttpResponseMessage staticResponse)
+ {
+ _staticResponse = staticResponse;
+ }
+
+ public FakeHttpMessageHandler(Func? responseFactory)
+ {
+ _responseFactory = responseFactory;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (_responseFactory != null)
+ return Task.FromResult(_responseFactory());
+ if (_staticResponse != null)
+ return Task.FromResult(_staticResponse);
+ throw new InvalidOperationException("No response configured for FakeHttpMessageHandler.");
+ }
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Testing.cs b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Testing.cs
new file mode 100644
index 000000000..e3db80477
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck.UnitTests/Testing.cs
@@ -0,0 +1,137 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using EdFi.Ods.AdminApi.HealthCheck.Features.OdsApi;
+using EdFi.Ods.AdminApi.HealthCheck.Features.AdminApi;
+using Microsoft.Extensions.Options;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.UnitTests;
+
+public class Testing
+{
+ public static IOptions GetAppSettings()
+ {
+ IOptions options = Options.Create(new AppSettings());
+ return options;
+ }
+
+ public static IOptions GetOdsApiSettings()
+ {
+ OdsApiSettings odsApiSettings = new()
+ {
+ Endpoints = Endpoints
+ };
+ IOptions options = Options.Create(odsApiSettings);
+ return options;
+ }
+
+ public static List Endpoints { get { return ["firstEndPoint", "secondEndpoint", "thirdEndPoint"]; } }
+
+ public static List HealthCheckData
+ {
+ get
+ {
+ return
+ [
+ new OdsApiEndpointNameCount()
+ {
+ OdsApiEndpointName = "firstEndPoint",
+ OdsApiEndpointCount = 3,
+ AnyErrros = false
+ },
+ new OdsApiEndpointNameCount()
+ {
+ OdsApiEndpointName = "secondEndpoint",
+ OdsApiEndpointCount = 8,
+ AnyErrros = false
+ },
+ new OdsApiEndpointNameCount()
+ {
+ OdsApiEndpointName = "thirdEndPoint",
+ OdsApiEndpointCount = 5,
+ AnyErrros = false
+ }
+ ];
+ }
+ }
+
+ public const string Tenants =
+ @"[{
+ ""TenantId"": 1,
+ ""Document"":
+ {
+ ""EdfiApiDiscoveryUrl"": ""https://api.ed-fi.org/v7.1/api6/"",
+ ""Name"" : ""tenant1""
+ }
+ },{
+ ""TenantId"": 2,
+ ""Document"":
+ {
+ ""EdfiApiDiscoveryUrl"": ""https://api.ed-fi.org/v7.2/api6/"",
+ ""Name"" : ""tenant2""
+ }
+ }]";
+
+ public static List AdminApiInstances
+ {
+ get
+ {
+ return
+ [
+ new()
+ {
+ Id = 1,
+ OdsInstanceId = 1,
+ TenantId = 1,
+ TenantName = "tenant1",
+ InstanceName = "instance 1",
+ ClientId = "one client",
+ ClientSecret = "one secret",
+ OauthUrl = "http://www.myserver.com/connect/token",
+ ResourceUrl = "http://www.myserver.com/data/v3/",
+ Status = "Completed",
+ },
+ new()
+ {
+ Id = 2,
+ OdsInstanceId = 2,
+ TenantId = 1,
+ TenantName = "tenant1",
+ InstanceName = "instance 2",
+ ClientId = "another client",
+ ClientSecret = "another secret",
+ OauthUrl = "http://www.myserver.com/connect/token",
+ ResourceUrl = "http://www.myserver.com/data/v3/",
+ Status = "Completed",
+ }
+ ];
+ }
+ }
+
+ public const string Instances =
+ @"[{
+ ""id"": 1,
+ ""odsInstanceId"": 1,
+ ""tenantId"": 1,
+ ""tenantName"": ""tenant1"",
+ ""instanceName"": ""instance 1"",
+ ""clientId"": ""one client"",
+ ""clientSecret"": ""one secret"",
+ ""resourceUrl"": ""http://www.myserver.com/data/v3/"",
+ ""oauthUrl"": ""http://www.myserver.com/connect/token"",
+ ""status"": ""Completed""
+ },{
+ ""id"": 2,
+ ""odsInstanceId"": 2,
+ ""tenantId"": 1,
+ ""tenantName"": ""tenant1"",
+ ""instanceName"": ""instance 2"",
+ ""clientId"": ""another client"",
+ ""clientSecret"": ""another secret"",
+ ""resourceUrl"": ""http://www.myserver.com/data/v3/"",
+ ""oauthUrl"": ""http://www.myserver.com/connect/token"",
+ ""status"": ""Completed""
+ }]";
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/AppSettings.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/AppSettings.cs
new file mode 100644
index 000000000..d02870003
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/AppSettings.cs
@@ -0,0 +1,11 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+namespace EdFi.Ods.AdminApi.HealthCheck;
+
+public sealed class AppSettings
+{
+ public int MaxRetryAttempts { get; set; }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/EdFi.AdminConsole.HealthCheckService.nuspec b/Application/EdFi.Ods.AdminApi.HealthCheck/EdFi.AdminConsole.HealthCheckService.nuspec
new file mode 100644
index 000000000..944097d97
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/EdFi.AdminConsole.HealthCheckService.nuspec
@@ -0,0 +1,17 @@
+?xml version="1.0"?>
+
+
+
+ 1.0.0
+ Ed-Fi Admin Console Health Check Worker Process
+ Ed-Fi Alliance
+ https://github.com/Ed-Fi-Alliance-OSS/Ed-Fi-Admin-Console-Health-Check-Worker-Process
+ Copyright @ $year$ Ed-Fi Alliance, LLC and Contributors
+ Ed-Fi Admin Health Check Worker Process
+ Ed-Fi AdminConsoleHealthCheckWorkerProcess
+ Apache-2.0
+
+
+
+
+
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/EdFi.Ods.AdminApi.HealthCheck.csproj b/Application/EdFi.Ods.AdminApi.HealthCheck/EdFi.Ods.AdminApi.HealthCheck.csproj
new file mode 100644
index 000000000..4fa494056
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/EdFi.Ods.AdminApi.HealthCheck.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/Extensions/HttpStatusCode.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/Extensions/HttpStatusCode.cs
new file mode 100644
index 000000000..53f0af5d6
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/Extensions/HttpStatusCode.cs
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using System.Net;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.Extensions;
+
+public static class HttpStatusCodeExtensions
+{
+ public static bool IsPotentiallyTransientFailure(this HttpStatusCode httpStatusCode)
+ {
+ switch (httpStatusCode)
+ {
+ case HttpStatusCode.InternalServerError:
+ case HttpStatusCode.GatewayTimeout:
+ case HttpStatusCode.ServiceUnavailable:
+ case HttpStatusCode.RequestTimeout:
+ return true;
+ default:
+ return false;
+ }
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/Features/AdminApi/AdminConsoleInstance.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/Features/AdminApi/AdminConsoleInstance.cs
new file mode 100644
index 000000000..984081551
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/Features/AdminApi/AdminConsoleInstance.cs
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+namespace EdFi.Ods.AdminApi.HealthCheck.Features.AdminApi;
+
+public class AdminConsoleInstance
+{
+ public int TenantId { get; set; } = 0;
+
+ public string TenantName { get; set; } = string.Empty;
+
+ public int Id { get; set; } = 0;
+
+ public int OdsInstanceId { get; set; } = 0;
+
+ public string InstanceName { get; set; } = string.Empty;
+
+ public string ResourceUrl { get; set; } = string.Empty;
+
+ public string OauthUrl { get; set; } = string.Empty;
+
+ public string ClientId { get; set; } = string.Empty;
+
+ public string ClientSecret { get; set; } = string.Empty;
+
+ public string Status { get; set; } = string.Empty;
+}
+
+public enum InstanceStatus
+{
+ Pending,
+ Completed,
+ InProgress,
+ Pending_Delete,
+ Deleted,
+ Delete_Failed,
+ Error
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/Features/OdsApi/AppSettingsOdsApiEndpoints.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/Features/OdsApi/AppSettingsOdsApiEndpoints.cs
new file mode 100644
index 000000000..7940c4a22
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/Features/OdsApi/AppSettingsOdsApiEndpoints.cs
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using Microsoft.Extensions.Options;
+using System.Collections;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.Features.OdsApi;
+
+public interface IAppSettingsOdsApiEndpoints : IEnumerable
+{
+
+}
+
+public class AppSettingsOdsApiEndpoints : IAppSettingsOdsApiEndpoints
+{
+ private readonly List endpoints;
+
+ public AppSettingsOdsApiEndpoints(IOptions odsApiOptions)
+ {
+ endpoints = new List();
+ endpoints.AddRange(odsApiOptions.Value.Endpoints);
+ }
+
+ public IEnumerator GetEnumerator()
+ {
+ return endpoints.GetEnumerator();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/Features/OdsApi/OdsApiCaller.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/Features/OdsApi/OdsApiCaller.cs
new file mode 100644
index 000000000..150c00ebc
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/Features/OdsApi/OdsApiCaller.cs
@@ -0,0 +1,51 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using EdFi.Ods.AdminApi.HealthCheck.Helpers;
+using EdFi.Ods.AdminApi.HealthCheck.Features.AdminApi;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.Features.OdsApi;
+
+public interface IOdsApiCaller
+{
+ Task> GetHealthCheckDataAsync(AdminConsoleInstance instance);
+}
+
+public class OdsApiCaller(IOdsApiClient odsApiClient, IAppSettingsOdsApiEndpoints appSettingsOdsApiEndpoints) : IOdsApiCaller
+{
+ private readonly IOdsApiClient _odsApiClient = odsApiClient;
+ private readonly IAppSettingsOdsApiEndpoints _appSettingsOdsApiEndpoints = appSettingsOdsApiEndpoints;
+
+ public async Task> GetHealthCheckDataAsync(AdminConsoleInstance instance)
+ {
+ var tasks = new List();
+
+ foreach (var appSettingsOdsApiEndpoint in _appSettingsOdsApiEndpoints)
+ {
+ var odsResourceEndpointUrl = $"{instance.ResourceUrl}{Constants.EdFiUri}/{appSettingsOdsApiEndpoint}{Constants.OdsApiQueryParams}";
+
+ tasks.Add(await GetCountPerEndpointAsync(
+ appSettingsOdsApiEndpoint, instance.OauthUrl, instance.ClientId, instance.ClientSecret, odsResourceEndpointUrl));
+ }
+
+ return tasks;
+ }
+
+ protected async Task GetCountPerEndpointAsync(string odsApiEndpoint, string authUrl, string clientId, string clientSecret, string odsEndpointUrl)
+ {
+ var result = new OdsApiEndpointNameCount()
+ {
+ OdsApiEndpointName = odsApiEndpoint,
+ };
+ var response = await _odsApiClient.OdsApiGet(authUrl, clientId, clientSecret, odsEndpointUrl);
+
+ if (response != null && response.StatusCode == System.Net.HttpStatusCode.OK && response.Headers != null && response.Headers.Contains(Constants.TotalCountHeader))
+ result.OdsApiEndpointCount = int.Parse(response.Headers.GetValues(Constants.TotalCountHeader).First());
+ else
+ result.AnyErrros = true;
+
+ return result;
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/Features/OdsApi/OdsApiClient.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/Features/OdsApi/OdsApiClient.cs
new file mode 100644
index 000000000..250556122
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/Features/OdsApi/OdsApiClient.cs
@@ -0,0 +1,96 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using EdFi.Ods.AdminApi.HealthCheck.Infrastructure;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Newtonsoft.Json.Linq;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.Features.OdsApi;
+
+public interface IOdsApiClient
+{
+ Task OdsApiGet(string authenticationUrl, string clientId, string clientSecret, string odsEndpointUrl);
+}
+
+public class OdsApiClient : IOdsApiClient
+{
+ private readonly IAppHttpClient _appHttpClient;
+ protected readonly ILogger _logger;
+ protected readonly AppSettings _options;
+
+ private string _accessToken;
+
+ public OdsApiClient(
+ IAppHttpClient appHttpClient,
+ ILogger logger,
+ IOptions options
+ )
+ {
+ _appHttpClient = appHttpClient;
+ _logger = logger;
+ _options = options.Value;
+ _accessToken = string.Empty;
+ }
+
+ public async Task OdsApiGet(
+ string authenticationUrl,
+ string clientId,
+ string clientSecret,
+ string odsEndpointUrl
+ )
+ {
+ ApiResponse response = new ApiResponse(HttpStatusCode.InternalServerError, string.Empty);
+ await GetAccessToken(authenticationUrl, clientId, clientSecret);
+
+ if (!string.IsNullOrEmpty(_accessToken))
+ {
+ response = await _appHttpClient.SendAsync(odsEndpointUrl,
+ HttpMethod.Get,
+ null as StringContent,
+ new AuthenticationHeaderValue("bearer", _accessToken)
+ );
+ }
+
+ return response;
+ }
+
+ protected async Task GetAccessToken(string accessTokenUrl, string clientId, string clientSecret)
+ {
+ if (string.IsNullOrEmpty(_accessToken))
+ {
+ FormUrlEncodedContent content;
+
+ content = new FormUrlEncodedContent(
+ new List>
+ {
+ new KeyValuePair("Grant_type", "client_credentials"),
+ }
+ );
+
+ var encodedKeySecret = Encoding.ASCII.GetBytes($"{clientId}:{clientSecret}");
+
+ var apiResponse = await _appHttpClient.SendAsync(
+ accessTokenUrl,
+ HttpMethod.Post,
+ content,
+ new AuthenticationHeaderValue("Basic", Convert.ToBase64String(encodedKeySecret))
+ );
+
+ if (apiResponse.StatusCode == HttpStatusCode.OK)
+ {
+ dynamic jsonToken = JToken.Parse(apiResponse.Content);
+ _accessToken = jsonToken["access_token"].ToString();
+ }
+ else
+ {
+ _logger.LogError("Not able to get Ods Api Access Token");
+ }
+ }
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/Features/OdsApi/OdsApiEndpointNameCount.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/Features/OdsApi/OdsApiEndpointNameCount.cs
new file mode 100644
index 000000000..6680986e3
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/Features/OdsApi/OdsApiEndpointNameCount.cs
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+namespace EdFi.Ods.AdminApi.HealthCheck.Features.OdsApi;
+
+public class OdsApiEndpointNameCount
+{
+ public string OdsApiEndpointName { get; set; } = string.Empty;
+
+ public int OdsApiEndpointCount { get; set; } = 0;
+
+ public bool AnyErrros { get; set; } = false;
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/HealthCheckService.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/HealthCheckService.cs
new file mode 100644
index 000000000..04a9bf706
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/HealthCheckService.cs
@@ -0,0 +1,157 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using EdFi.Ods.AdminApi.HealthCheck.Helpers;
+using Microsoft.Extensions.Logging;
+using EdFi.Ods.AdminApi.AdminConsole.Infrastructure.Services.Instances.Queries;
+using EdFi.Ods.AdminApi.AdminConsole.Infrastructure.Services.Tenants;
+using System.Dynamic;
+using EdFi.Ods.AdminApi.AdminConsole.Features.Tenants;
+using EdFi.Ods.AdminApi.AdminConsole.Infrastructure.DataAccess.Models;
+using EdFi.Ods.AdminApi.AdminConsole.Features.WorkerInstances;
+using Newtonsoft.Json;
+using System.Text;
+using EdFi.Ods.AdminApi.AdminConsole.Infrastructure.Services.HealthChecks.Commands;
+using EdFi.Ods.AdminApi.HealthCheck.Features.AdminApi;
+using EdFi.Ods.AdminApi.HealthCheck.Features.OdsApi;
+
+namespace EdFi.Ods.AdminApi.HealthCheck;
+
+public interface IHealthCheckService
+{
+ Task RunAsync(CancellationToken cancellationToken);
+}
+
+public class HealthCheckService(ILogger logger,
+ IAdminConsoleTenantsService adminConsoleTenantsService,
+ IGetInstancesQuery getInstancesQuery,
+ IAddHealthCheckCommand addHealthCheckCommand,
+ IOdsApiCaller odsApiCaller)
+ : IHealthCheckService
+{
+ private readonly ILogger _logger = logger;
+ private readonly IAdminConsoleTenantsService _adminConsoleTenantsService = adminConsoleTenantsService;
+ private readonly IGetInstancesQuery _getInstancesQuery = getInstancesQuery;
+ private readonly IOdsApiCaller _odsApiCaller = odsApiCaller;
+ private readonly IAddHealthCheckCommand _addHealthCheckCommand = addHealthCheckCommand;
+
+ public async Task RunAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ /// Step 1. Get tenants data from Admin API - Admin Console extension.
+ _logger.LogInformation("Starting HealthCheck Service...");
+ _logger.LogInformation("Get tenants on Admin Api.");
+ var tenants = await _adminConsoleTenantsService.GetTenantsAsync(true);
+
+ if (tenants.Count == 0)
+ _logger.LogInformation("No tenants returned from Admin Api.");
+ else
+ {
+ foreach (var tenantName in tenants.Select(GetTenantName))
+ {
+ _logger.LogInformation("TenantName:{TenantName}", tenantName);
+
+ /// Step 2. Get instances data from Admin API - Admin Console extension.
+ var instances = await _getInstancesQuery.Execute(tenantName, "Completed");
+
+ if (instances == null || !instances.Any())
+ {
+ _logger.LogInformation("No instances found on Admin Api.");
+ }
+ else
+ {
+ foreach (var instance in instances)
+ {
+ /// Step 3. For each instance, Get the HealthCheck data from ODS API
+ _logger.LogInformation(
+ "Processing instance with name: {InstanceName}",
+ instance.InstanceName ?? ""
+ );
+ var adminConsoleInstance = ConvertToAdminConsoleInstance(instance);
+ if (InstanceValidator.IsInstanceValid(_logger, adminConsoleInstance))
+ {
+ var healthCheckData = await _odsApiCaller.GetHealthCheckDataAsync(adminConsoleInstance);
+
+ if (healthCheckData != null && healthCheckData.Count > 0)
+ {
+ _logger.LogInformation("HealCheck data obtained.");
+
+ var healthCheckDocument = JsonBuilder.BuildJsonObject(healthCheckData);
+
+ /// Step 4. Post the HealthCheck data to the Admin API
+ HealthCheckCommandModel healthCheckCommandModel = new(
+ instance.TenantId,
+ instance.Id,
+ healthCheckDocument.ToString()
+ );
+ _logger.LogInformation("Posting HealthCheck data to Admin Api.");
+
+ await _addHealthCheckCommand.Execute(healthCheckCommandModel);
+ }
+ else
+ {
+ _logger.LogInformation(
+ "No HealthCheck data has been collected for instance with name: {InstanceName}",
+ instance.InstanceName
+ );
+ }
+ }
+ }
+ }
+ }
+
+ _logger.LogInformation("Process completed.");
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "An error occurred while running the HealthCheck Service.");
+ }
+
+ static string GetTenantName(TenantModel tenant)
+ {
+ if (tenant.Document is ExpandoObject expandoObject &&
+ expandoObject is IDictionary dict &&
+ dict.TryGetValue("name", out var nameValue) &&
+ nameValue is string name)
+ {
+ return name;
+ }
+ return string.Empty;
+ }
+
+ static AdminConsoleInstance ConvertToAdminConsoleInstance(Instance instance)
+ {
+ string? ClientId = null;
+ string? ClientSecret = null;
+
+ if (instance.Credentials != null)
+ {
+ var credentials = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(instance.Credentials));
+ ClientId = credentials?.ClientId;
+ ClientSecret = credentials?.Secret;
+ }
+
+ return new AdminConsoleInstance
+ {
+ Id = instance.Id,
+ ResourceUrl = instance.ResourceUrl ?? string.Empty,
+ OauthUrl = instance.OAuthUrl ?? string.Empty,
+ ClientId = ClientId ?? string.Empty,
+ ClientSecret = ClientSecret ?? string.Empty
+ };
+ }
+ }
+}
+
+public class HealthCheckCommandModel(int tenantId, int instanceId, string document) : IAddHealthCheckModel
+{
+ public int TenantId { get; set; } = tenantId;
+ public int InstanceId { get; set; } = instanceId;
+ public string Document { get; set; } = document;
+ public int DocId { get; set; }
+ public int EdOrgId { get; set; }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/Helpers/Constants.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/Helpers/Constants.cs
new file mode 100644
index 000000000..5173ec24e
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/Helpers/Constants.cs
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+namespace EdFi.Ods.AdminApi.HealthCheck.Helpers
+{
+ public struct Constants
+ {
+ public const string OdsApiQueryParams = "?offset=0&limit=0&totalCount=true";
+
+ public const string TotalCountHeader = "total-count";
+
+ public const string TenantHeader = "tenant";
+
+ public const string CompletedInstances = "?status=Completed";
+
+ public const string EdFiUri = "ed-fi";
+
+ public const int RetryStartingDelayMilliseconds = 500;
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/Helpers/InstanceValidator.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/Helpers/InstanceValidator.cs
new file mode 100644
index 000000000..9225fb244
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/Helpers/InstanceValidator.cs
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using EdFi.Ods.AdminApi.HealthCheck.Features.AdminApi;
+using Microsoft.Extensions.Logging;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.Helpers;
+
+public static class InstanceValidator
+{
+ public static bool IsInstanceValid(ILogger logger, AdminConsoleInstance instance)
+ {
+ var messages = new List();
+
+ if (instance == null)
+ messages.Add("instance cannot be empty.");
+ else
+ {
+ if (string.IsNullOrEmpty(instance.OauthUrl))
+ messages.Add("AuthenticationUrl is required.");
+
+ if (string.IsNullOrEmpty(instance.ResourceUrl))
+ messages.Add("ResourceUrl is required.");
+
+ if (string.IsNullOrEmpty(instance.ClientId))
+ messages.Add("ClientId is required.");
+
+ if (string.IsNullOrEmpty(instance.ClientSecret))
+ messages.Add("ClientSecret is required.");
+ }
+
+ if (messages != null && messages.Count > 0)
+ {
+ string concatenatedMessages = string.Concat(messages);
+ logger.LogWarning("The instance {Name} obtained from Admin API is not properly formed. {Messages}", instance?.InstanceName, concatenatedMessages);
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/Helpers/JsonBuilder.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/Helpers/JsonBuilder.cs
new file mode 100644
index 000000000..d62bb3938
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/Helpers/JsonBuilder.cs
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using EdFi.Ods.AdminApi.HealthCheck.Features.OdsApi;
+using System.Text.Json.Nodes;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.Helpers;
+
+public static class JsonBuilder
+{
+ public static JsonObject BuildJsonObject(IEnumerable healthCheckData)
+ {
+ JsonObject healthCheckDocument = new();
+
+ if (healthCheckData != null)
+ {
+ healthCheckDocument.Add(new KeyValuePair("healthy", !healthCheckData.Any(r => r.AnyErrros)));
+ foreach (var countPerEndpoint in healthCheckData)
+ {
+ healthCheckDocument.Add(new KeyValuePair(countPerEndpoint.OdsApiEndpointName, countPerEndpoint.OdsApiEndpointCount));
+ }
+ }
+
+ return healthCheckDocument;
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/Infrastructure/ApiResponse.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/Infrastructure/ApiResponse.cs
new file mode 100644
index 000000000..d2186ea87
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/Infrastructure/ApiResponse.cs
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using System.Net;
+using System.Net.Http.Headers;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.Infrastructure;
+public class ApiResponse
+{
+ public HttpStatusCode StatusCode { get; }
+ public string Content { get; }
+ public HttpResponseHeaders? Headers { get; }
+
+ public ApiResponse(HttpStatusCode statusCode, string content)
+ {
+ StatusCode = statusCode;
+ Content = content;
+ Headers = null;
+ }
+ public ApiResponse(HttpStatusCode statusCode, string content, HttpResponseHeaders headers) : this(statusCode, content)
+ {
+ Headers = headers;
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/Infrastructure/AppHttpClient.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/Infrastructure/AppHttpClient.cs
new file mode 100644
index 000000000..7626bfaf7
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/Infrastructure/AppHttpClient.cs
@@ -0,0 +1,143 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using System.Net;
+using System.Net.Http.Headers;
+using EdFi.Ods.AdminApi.HealthCheck.Extensions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Polly;
+using Polly.Contrib.WaitAndRetry;
+using Constants = EdFi.Ods.AdminApi.HealthCheck.Helpers.Constants;
+
+namespace EdFi.Ods.AdminApi.HealthCheck.Infrastructure;
+
+public interface IAppHttpClient
+{
+ Task SendAsync(string uriString, HttpMethod method, StringContent? content, AuthenticationHeaderValue? authenticationHeaderValue);
+
+ Task SendAsync(string uriString, HttpMethod method, FormUrlEncodedContent content, AuthenticationHeaderValue? authenticationHeaderValue);
+}
+
+public class AppHttpClient(HttpClient httpClient, ILogger logger, IOptions options) : IAppHttpClient
+{
+ private readonly HttpClient _httpClient = httpClient;
+ protected readonly ILogger _logger = logger;
+ protected readonly AppSettings _options = options.Value;
+
+ public async Task SendAsync(string uriString, HttpMethod method, StringContent? content, AuthenticationHeaderValue? authenticationHeaderValue)
+ {
+ var delay = Backoff.ExponentialBackoff(
+ TimeSpan.FromMilliseconds(Constants.RetryStartingDelayMilliseconds),
+ _options.MaxRetryAttempts);
+
+ int attempts = 0;
+
+ var retryPolicy = Policy
+ .HandleResult(r => r.StatusCode.IsPotentiallyTransientFailure())
+ .WaitAndRetryAsync(
+ delay,
+ (result, ts, retryAttempt, ctx) =>
+ {
+ _logger.LogWarning("Retrying {HttpMethod} for resource '{UriString}'. Failed with status '{StatusCode}'. Retrying... (retry #{RetryAttempt} of {MaxRetryAttempts} with {TotalSeconds:N1}s delay)",
+ method, uriString, result.Result.StatusCode, retryAttempt, _options.MaxRetryAttempts, ts.TotalSeconds);
+ });
+
+ var response = await retryPolicy.ExecuteAsync(
+ async (ctx, ct) =>
+ {
+ attempts++;
+
+ if (attempts > 1)
+ {
+ _logger.LogDebug("{HttpMethod} for resource '{UriString}'. Attempt #{Attempts}.",
+ method, uriString, attempts);
+ }
+
+ var requestMessage = new HttpRequestMessage(method, uriString)
+ {
+ Content = content
+ };
+
+ if (authenticationHeaderValue != null)
+ {
+ requestMessage.Headers.Authorization = authenticationHeaderValue;
+ }
+
+ return await _httpClient.SendAsync(requestMessage, ct);
+ },
+ [],
+ CancellationToken.None);
+
+ string responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ if (response.StatusCode != HttpStatusCode.OK)
+ {
+ var message = $"{method} request for '{uriString}' reference failed with status '{response.StatusCode}': {responseContent}";
+ _logger.LogWarning(message);
+ }
+
+ return new ApiResponse(response.StatusCode, responseContent, response.Headers);
+ }
+
+ /// Access Token
+ public async Task SendAsync(string uriString, HttpMethod method, FormUrlEncodedContent content, AuthenticationHeaderValue? authenticationHeaderValue)
+ {
+ var delay = Backoff.ExponentialBackoff(
+ TimeSpan.FromMilliseconds(Constants.RetryStartingDelayMilliseconds),
+ _options.MaxRetryAttempts);
+
+ int attempts = 0;
+
+ var retryPolicy = Policy
+ .HandleResult(r => r.StatusCode.IsPotentiallyTransientFailure())
+ .WaitAndRetryAsync(
+ delay,
+ (result, ts, retryAttempt, ctx) =>
+ {
+ _logger.LogWarning("Retrying {HttpMethod} for resource '{UriString}'. Failed with status '{StatusCode}'. Retrying... (retry #{RetryAttempt} of {MaxRetryAttempts} with {TotalSeconds:N1}s delay)",
+ method, uriString, result.Result.StatusCode, retryAttempt, _options.MaxRetryAttempts, ts.TotalSeconds);
+ });
+
+ using var requestMessage = new HttpRequestMessage(method, uriString)
+ {
+ Content = content
+ };
+
+ if (authenticationHeaderValue != null)
+ {
+ _httpClient.DefaultRequestHeaders.Authorization = authenticationHeaderValue;
+ }
+
+ var response = await retryPolicy.ExecuteAsync(
+ async (ctx, ct) =>
+ {
+ attempts++;
+
+ if (attempts > 1)
+ {
+ _logger.LogDebug("{HttpMethod} for resource '{UriString}'. Attempt #{Attempts}.",
+ method, uriString, attempts);
+ }
+
+ var requestMessage = new HttpRequestMessage(method, uriString)
+ {
+ Content = content
+ };
+
+ if (authenticationHeaderValue != null)
+ {
+ requestMessage.Headers.Authorization = authenticationHeaderValue;
+ }
+
+ return await _httpClient.SendAsync(requestMessage, ct);
+ },
+ [],
+ CancellationToken.None);
+
+ var responseContent = await response.Content.ReadAsStringAsync();
+ return new ApiResponse(response.StatusCode, responseContent, response.Headers);
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/Infrastructure/HttpRequestMessageBuilder.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/Infrastructure/HttpRequestMessageBuilder.cs
new file mode 100644
index 000000000..0fec31c58
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/Infrastructure/HttpRequestMessageBuilder.cs
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+namespace EdFi.Ods.AdminApi.HealthCheck.Infrastructure;
+
+public interface IHttpRequestMessageBuilder
+{
+ HttpRequestMessage GetHttpRequestMessage(string uriString, HttpMethod method, StringContent? content);
+
+ HttpRequestMessage GetHttpRequestMessage(string uriString, HttpMethod method, FormUrlEncodedContent? content);
+}
+
+public class HttpRequestMessageBuilder : IHttpRequestMessageBuilder
+{
+ public HttpRequestMessage GetHttpRequestMessage(string uriString, HttpMethod method, StringContent? content)
+ {
+ var request = new HttpRequestMessage()
+ {
+ RequestUri = new Uri(uriString),
+ Method = method,
+ Content = content
+ };
+
+ return request;
+ }
+
+ public HttpRequestMessage GetHttpRequestMessage(string uriString, HttpMethod method, FormUrlEncodedContent? content)
+ {
+ var request = new HttpRequestMessage()
+ {
+ RequestUri = new Uri(uriString),
+ Method = method,
+ Content = content
+ };
+
+ return request;
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi.HealthCheck/OdsApiSettings.cs b/Application/EdFi.Ods.AdminApi.HealthCheck/OdsApiSettings.cs
new file mode 100644
index 000000000..58ce1c15a
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi.HealthCheck/OdsApiSettings.cs
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+namespace EdFi.Ods.AdminApi.HealthCheck;
+
+public interface IOdsApiSettings
+{
+ IEnumerable Endpoints { get; set; }
+}
+
+public class OdsApiSettings : IOdsApiSettings
+{
+ public IEnumerable Endpoints { get; set; } = new List();
+}
diff --git a/Application/EdFi.Ods.AdminApi/EdFi.Ods.AdminApi.csproj b/Application/EdFi.Ods.AdminApi/EdFi.Ods.AdminApi.csproj
index 944336b95..1148b96a2 100644
--- a/Application/EdFi.Ods.AdminApi/EdFi.Ods.AdminApi.csproj
+++ b/Application/EdFi.Ods.AdminApi/EdFi.Ods.AdminApi.csproj
@@ -1,4 +1,4 @@
-
+
net8.0
enable
@@ -39,6 +39,9 @@
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
@@ -53,5 +56,6 @@
+
diff --git a/Application/EdFi.Ods.AdminApi/Features/ClaimSets/ClaimSetModel.cs b/Application/EdFi.Ods.AdminApi/Features/ClaimSets/ClaimSetModel.cs
index 7b60e9dd1..f93c661f9 100644
--- a/Application/EdFi.Ods.AdminApi/Features/ClaimSets/ClaimSetModel.cs
+++ b/Application/EdFi.Ods.AdminApi/Features/ClaimSets/ClaimSetModel.cs
@@ -104,5 +104,5 @@ public interface IResourceClaimOnClaimSetRequest
{
int ClaimSetId { get; }
int ResourceClaimId { get; }
- public List? ResourceClaimActions { get; }
+ List? ResourceClaimActions { get; }
}
diff --git a/Application/EdFi.Ods.AdminApi/Features/HealthCheck/HealthCheckTrigger.cs b/Application/EdFi.Ods.AdminApi/Features/HealthCheck/HealthCheckTrigger.cs
new file mode 100644
index 000000000..9870f9475
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi/Features/HealthCheck/HealthCheckTrigger.cs
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using EdFi.Ods.AdminApi.Common.Features;
+using EdFi.Ods.AdminApi.Common.Infrastructure;
+using EdFi.Ods.AdminApi.Infrastructure.BackgroundJobs;
+using Quartz;
+
+namespace EdFi.Ods.AdminApi.Features.HealthCheck;
+
+public class HealthCheckTrigger : IFeature
+{
+ public void MapEndpoints(IEndpointRouteBuilder endpoints)
+ {
+ var config = endpoints.ServiceProvider.GetRequiredService();
+ bool enabled = config.GetValue("AppSettings:EnableAdminConsoleAPI");
+ if (enabled)
+ {
+ AdminApiEndpointBuilder.MapPost(endpoints, "/healthcheck/trigger", TriggerHealthCheck)
+ .WithRouteOptions(b => b.WithResponseCode(202))
+ .AllowAnonymous()
+ .BuildForVersions(AdminApiVersions.AdminConsole);
+ }
+ }
+
+ internal static async Task TriggerHealthCheck(ISchedulerFactory schedulerFactory)
+ {
+ var scheduler = await schedulerFactory.GetScheduler();
+ var jobKey = new JobKey("HealthCheckJob");
+
+ if (!await scheduler.CheckExists(jobKey))
+ {
+ var jobDetail = JobBuilder.Create()
+ .WithIdentity(jobKey)
+ .Build();
+ await scheduler.AddJob(jobDetail, replace: true);
+ }
+
+ // Schedule a one-time immediate trigger
+ var trigger = TriggerBuilder.Create()
+ .ForJob(jobKey)
+ .WithIdentity($"ImmediateTrigger-{Guid.NewGuid()}")
+ .StartNow()
+ .Build();
+
+ await scheduler.ScheduleJob(trigger);
+
+ // Return accepted immediately without waiting for execution
+ return Results.Accepted("/healthcheck/trigger", new { Title = "Health check process accepted and triggered. The latest results will be available shortly, and processing details will be logged for your reference.", Status = 202 });
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi/Features/Profiles/ProfileValidator.cs b/Application/EdFi.Ods.AdminApi/Features/Profiles/ProfileValidator.cs
index 5d58ef0e3..b517b354c 100644
--- a/Application/EdFi.Ods.AdminApi/Features/Profiles/ProfileValidator.cs
+++ b/Application/EdFi.Ods.AdminApi/Features/Profiles/ProfileValidator.cs
@@ -37,11 +37,11 @@ void EventHandler(object? sender, ValidationEventArgs e)
if (profile != null && !string.IsNullOrEmpty(name))
{
var profileName = profile.GetAttribute("name");
- if(!profileName.Equals(name, StringComparison.InvariantCultureIgnoreCase))
+ if (!profileName.Equals(name, StringComparison.InvariantCultureIgnoreCase))
{
- context.AddFailure(propertyName, $"Profile name attribute value should match with {name}." );
+ context.AddFailure(propertyName, $"Profile name attribute value should match with {name}.");
}
- }
+ }
}
catch (Exception ex)
{
diff --git a/Application/EdFi.Ods.AdminApi/Infrastructure/BackgroundJobs/HealthCheckJob.cs b/Application/EdFi.Ods.AdminApi/Infrastructure/BackgroundJobs/HealthCheckJob.cs
new file mode 100644
index 000000000..15954384b
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi/Infrastructure/BackgroundJobs/HealthCheckJob.cs
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using EdFi.Ods.AdminApi.HealthCheck;
+using Quartz;
+
+namespace EdFi.Ods.AdminApi.Infrastructure.BackgroundJobs;
+
+[DisallowConcurrentExecution]
+public class HealthCheckJob(IHealthCheckService healthCheckService, ILogger logger) : IJob
+{
+ private readonly IHealthCheckService _healthCheckService = healthCheckService;
+ private readonly ILogger _logger = logger;
+
+ public async Task Execute(IJobExecutionContext context)
+ {
+ _logger.LogInformation("Running scheduled health check...");
+ await _healthCheckService.RunAsync(context.CancellationToken);
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi/Infrastructure/BackgroundJobs/HealthCheckServiceExtension.cs b/Application/EdFi.Ods.AdminApi/Infrastructure/BackgroundJobs/HealthCheckServiceExtension.cs
new file mode 100644
index 000000000..7c9c6fb55
--- /dev/null
+++ b/Application/EdFi.Ods.AdminApi/Infrastructure/BackgroundJobs/HealthCheckServiceExtension.cs
@@ -0,0 +1,74 @@
+// SPDX-License-Identifier: Apache-2.0
+// Licensed to the Ed-Fi Alliance under one or more agreements.
+// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
+// See the LICENSE and NOTICES files in the project root for more information.
+
+using EdFi.Ods.AdminApi.AdminConsole.Infrastructure.Services.HealthChecks.Commands;
+using EdFi.Ods.AdminApi.HealthCheck;
+using EdFi.Ods.AdminApi.HealthCheck.Features.OdsApi;
+using EdFi.Ods.AdminApi.HealthCheck.Infrastructure;
+
+namespace EdFi.Ods.AdminApi.Infrastructure.BackgroundJobs;
+
+public static class HealthCheckServiceExtension
+{
+ public static void ConfigureHealthCheckServices(
+ this WebApplicationBuilder builder,
+ IConfiguration configuration
+ )
+ {
+ builder.Services.AddOptions();
+ builder.Services.Configure(configuration.GetSection("AppSettings"));
+ builder.Services.Configure(configuration.GetSection("HealthCheck:OdsApiSettings"));
+
+ builder.Services.AddSingleton();
+ builder.Services.AddScoped();
+
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+
+ builder.Services
+ .AddHttpClient(
+ "AppHttpClient",
+ x =>
+ {
+ x.Timeout = TimeSpan.FromSeconds(500);
+ }
+ )
+ .ConfigurePrimaryHttpMessageHandler(() =>
+ {
+ var handler = new HttpClientHandler();
+ if (
+ configuration?.GetSection("AppSettings")?["IgnoresCertificateErrors"]?.ToLower() == "true"
+ )
+ {
+ return IgnoresCertificateErrorsHandler();
+ }
+ return handler;
+ });
+ }
+
+ private static HttpClientHandler IgnoresCertificateErrorsHandler()
+ {
+ var handler = new HttpClientHandler
+ {
+ ClientCertificateOptions = ClientCertificateOption.Manual,
+#pragma warning disable S4830 // Server certificates should be verified during SSL/TLS connections
+ ServerCertificateCustomValidationCallback = (
+ httpRequestMessage,
+ cert,
+ cetChain,
+ policyErrors
+ ) =>
+ {
+ return true;
+ }
+ };
+#pragma warning restore S4830
+
+ return handler;
+ }
+}
diff --git a/Application/EdFi.Ods.AdminApi/Infrastructure/Services/SimpleGetRequest.cs b/Application/EdFi.Ods.AdminApi/Infrastructure/Services/SimpleGetRequest.cs
index 53e04d051..ddaf2cb66 100644
--- a/Application/EdFi.Ods.AdminApi/Infrastructure/Services/SimpleGetRequest.cs
+++ b/Application/EdFi.Ods.AdminApi/Infrastructure/Services/SimpleGetRequest.cs
@@ -9,7 +9,7 @@ namespace EdFi.Ods.AdminApi.Infrastructure.Services;
public interface ISimpleGetRequest
{
- public Task DownloadString(string address);
+ Task DownloadString(string address);
}
public class SimpleGetRequest : ISimpleGetRequest
diff --git a/Application/EdFi.Ods.AdminApi/Infrastructure/WebApplicationBuilderExtensions.cs b/Application/EdFi.Ods.AdminApi/Infrastructure/WebApplicationBuilderExtensions.cs
index fdaa4999f..65d92f1a7 100644
--- a/Application/EdFi.Ods.AdminApi/Infrastructure/WebApplicationBuilderExtensions.cs
+++ b/Application/EdFi.Ods.AdminApi/Infrastructure/WebApplicationBuilderExtensions.cs
@@ -30,6 +30,8 @@
using Microsoft.Net.Http.Headers;
using Microsoft.OpenApi.Models;
using EdFi.Ods.AdminApi.Infrastructure.Database.Queries;
+using Quartz;
+using EdFi.Ods.AdminApi.Infrastructure.BackgroundJobs;
namespace EdFi.Ods.AdminApi.Infrastructure;
@@ -215,6 +217,29 @@ public static void AddServices(this WebApplicationBuilder webApplicationBuilder)
webApplicationBuilder.Services.AddHttpClient();
webApplicationBuilder.Services.AddTransient();
webApplicationBuilder.Services.AddTransient();
+
+ var adminConsoleIsEnabled = webApplicationBuilder.Configuration.GetValue("AppSettings:EnableAdminConsoleAPI");
+ var healthCheckFrequency = webApplicationBuilder.Configuration.GetValue("HealthCheck:HealthCheckFrequencyInMinutes");
+
+ // Schedule the health check job if the Admin Console API is enabled and
+ // the health check frequency is greater than zero.
+ if (adminConsoleIsEnabled && healthCheckFrequency > 0)
+ {
+ // Quartz.NET back end service
+ webApplicationBuilder.Services.AddQuartz(q =>
+ {
+ var jobKey = new JobKey("HealthCheckJob");
+ q.AddJob(opts => opts.WithIdentity(jobKey));
+
+ // Create a trigger that fires every 10 minutes
+ q.AddTrigger(opts =>
+ opts.ForJob(jobKey)
+ .WithIdentity("HealthCheckJob-trigger")
+ .WithSimpleSchedule(x => x.WithInterval(TimeSpan.FromMinutes(healthCheckFrequency)).RepeatForever())
+ );
+ });
+ webApplicationBuilder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = false);
+ }
}
private static void EnableMultiTenancySupport(this WebApplicationBuilder webApplicationBuilder)
@@ -427,7 +452,12 @@ public static void ConfigureRateLimiting(WebApplicationBuilder builder)
var parts = rule.Endpoint.Split(':');
// Only support fixed window for now, parse period (e.g., "1m")
var window = rule.Period.EndsWith('m') ? TimeSpan.FromMinutes(int.Parse(rule.Period.TrimEnd('m'))) : TimeSpan.FromMinutes(1);
- if (path != null && parts.Length == 2 && method.Equals(parts[0], StringComparison.OrdinalIgnoreCase) && path.Equals(parts[1], StringComparison.OrdinalIgnoreCase))
+ if (
+ path != null
+ && parts.Length == 2
+ && method.Equals(parts[0], StringComparison.OrdinalIgnoreCase)
+ && path.Equals(parts[1], StringComparison.OrdinalIgnoreCase)
+ )
{
return RateLimitPartition.GetFixedWindowLimiter(rule.Endpoint, _ => new FixedWindowRateLimiterOptions
{
diff --git a/Application/EdFi.Ods.AdminApi/Program.cs b/Application/EdFi.Ods.AdminApi/Program.cs
index f33fd09dd..1bc4919b3 100644
--- a/Application/EdFi.Ods.AdminApi/Program.cs
+++ b/Application/EdFi.Ods.AdminApi/Program.cs
@@ -9,6 +9,7 @@
using EdFi.Ods.AdminApi.Common.Infrastructure.MultiTenancy;
using EdFi.Ods.AdminApi.Features;
using EdFi.Ods.AdminApi.Infrastructure;
+using EdFi.Ods.AdminApi.Infrastructure.BackgroundJobs;
using log4net;
var builder = WebApplication.CreateBuilder(args);
@@ -25,7 +26,10 @@
builder.AddServices();
if (adminConsoleIsEnabled)
+{
builder.RegisterAdminConsoleDependencies();
+ builder.ConfigureHealthCheckServices(builder.Configuration);
+}
var app = builder.Build();
diff --git a/Application/EdFi.Ods.AdminApi/appsettings.Development.json b/Application/EdFi.Ods.AdminApi/appsettings.Development.json
index 728ebf299..5e956238a 100644
--- a/Application/EdFi.Ods.AdminApi/appsettings.Development.json
+++ b/Application/EdFi.Ods.AdminApi/appsettings.Development.json
@@ -2,7 +2,7 @@
"AppSettings": {
"MultiTenancy": false,
"EnableAdminConsoleAPI": true,
- "DatabaseEngine": "SqlServer",
+ "DatabaseEngine": "PostgreSql",
"IgnoresCertificateErrors": true
},
"AdminConsoleSettings": {
@@ -22,8 +22,8 @@
"AllowRegistration": true
},
"ConnectionStrings": {
- "EdFi_Admin": "Data Source=.\\;Initial Catalog=EdFi_Admin;Integrated Security=True;Encrypt=false",
- "EdFi_Security": "Data Source=.\\;Initial Catalog=EdFi_Security;Integrated Security=True;Encrypt=false"
+ "EdFi_Admin": "host=localhost;port=5401;username=postgres;password=postgres;database=EdFi_Admin;pooling=false",
+ "EdFi_Security": "host=localhost;port=5401;username=postgres;password=postgres;database=EdFi_Security;pooling=false"
},
"SwaggerSettings": {
"EnableSwagger": true,
diff --git a/Application/EdFi.Ods.AdminApi/appsettings.json b/Application/EdFi.Ods.AdminApi/appsettings.json
index c17dadb7d..f43dc58e3 100644
--- a/Application/EdFi.Ods.AdminApi/appsettings.json
+++ b/Application/EdFi.Ods.AdminApi/appsettings.json
@@ -8,8 +8,24 @@
"MultiTenancy": false,
"PreventDuplicateApplications": false,
"EnableAdminConsoleAPI": false,
- "IgnoresCertificateErrors": false,
- "EnableApplicationResetEndpoint": true
+ "EnableApplicationResetEndpoint": true,
+ "IgnoresCertificateErrors": false
+ },
+ "HealthCheck": {
+ "OdsApiSettings": {
+ "Endpoints": [
+ "studentSpecialEducationProgramAssociations",
+ "studentDisciplineIncidentBehaviorAssociations",
+ "studentSchoolAssociations",
+ "studentSchoolAttendanceEvents",
+ "studentSectionAssociations",
+ "staffEducationOrganizationAssignmentAssociations",
+ "staffSectionAssociations",
+ "courseTranscripts",
+ "sections"
+ ]
+ },
+ "HealthCheckFrequencyInMinutes": 0
},
"AdminConsoleSettings": {
"ApplicationName": "Ed-Fi Health Check",
@@ -34,13 +50,13 @@
"AllowRegistration": true
},
"SwaggerSettings": {
- "EnableSwagger": false,
- "DefaultTenant": ""
+ "EnableSwagger": true,
+ "DefaultTenant": "tenant1"
},
"EnableDockerEnvironment": false,
"ConnectionStrings": {
- "EdFi_Admin": "Data Source=.\\;Initial Catalog=EdFi_Admin;Integrated Security=True",
- "EdFi_Security": "Data Source=.\\;Initial Catalog=EdFi_Security;Integrated Security=True"
+ "EdFi_Admin": "host=localhost;port=5401;username=postgres;password=postgres;database=EdFi_Admin;pooling=false",
+ "EdFi_Security": "host=localhost;port=5401;username=postgres;password=postgres;database=EdFi_Security;pooling=false"
},
"EdFiApiDiscoveryUrl": "https://api.ed-fi.org/v7.2/api/",
"Log4NetCore": {
@@ -57,15 +73,15 @@
"Tenants": {
"tenant1": {
"ConnectionStrings": {
- "EdFi_Security": "Data Source=.\\;Initial Catalog=EdFi_Security_Tenant1;Integrated Security=True",
- "EdFi_Admin": "Data Source=.\\;Initial Catalog=EdFi_Admin_Tenant1;Integrated Security=True"
+ "EdFi_Admin": "host=localhost;port=5401;username=postgres;password=postgres;database=EdFi_Admin;pooling=false",
+ "EdFi_Security": "host=localhost;port=5401;username=postgres;password=postgres;database=EdFi_Security;pooling=false"
},
"EdFiApiDiscoveryUrl": "https://api.ed-fi.org/v7.2/api6/"
},
"tenant2": {
"ConnectionStrings": {
- "EdFi_Security": "Data Source=.\\;Initial Catalog=EdFi_Security_Tenant2;Integrated Security=True",
- "EdFi_Admin": "Data Source=.\\;Initial Catalog=EdFi_Admin_Tenant2;Integrated Security=True"
+ "EdFi_Admin": "host=localhost;port=5402;username=postgres;password=postgres;database=EdFi_Admin;pooling=false",
+ "EdFi_Security": "host=localhost;port=5402;username=postgres;password=postgres;database=EdFi_Security;pooling=false"
},
"EdFiApiDiscoveryUrl": "https://api.ed-fi.org/v7.2/api4/"
}
diff --git a/Application/EdFi.Ods.AdminConsole.DBTests/Testing.cs b/Application/EdFi.Ods.AdminConsole.DBTests/Testing.cs
index 47c4f7e7a..e2327cecf 100644
--- a/Application/EdFi.Ods.AdminConsole.DBTests/Testing.cs
+++ b/Application/EdFi.Ods.AdminConsole.DBTests/Testing.cs
@@ -46,7 +46,7 @@ public static IOptions GetAppSettings()
return Options.Create(appSettings);
}
- public static IOptionsSnapshot GetOptionsSnapshot()
+ public static IOptionsMonitor GetOptionsSnapshot()
{
var appSettingsFile = new AppSettingsFile
{
@@ -100,8 +100,8 @@ public static IOptionsSnapshot GetOptionsSnapshot()
}
};
- var optionsSnapshot = A.Fake>();
- A.CallTo(() => optionsSnapshot.Value).Returns(appSettingsFile);
+ var optionsSnapshot = A.Fake>();
+ A.CallTo(() => optionsSnapshot.CurrentValue).Returns(appSettingsFile);
return optionsSnapshot;
}
diff --git a/Docker/Compose/pgsql/MultiTenant/compose-build-ods-multi-tenant.yml b/Docker/Compose/pgsql/MultiTenant/compose-build-ods-multi-tenant.yml
index 4c35197b5..0ec9f6063 100644
--- a/Docker/Compose/pgsql/MultiTenant/compose-build-ods-multi-tenant.yml
+++ b/Docker/Compose/pgsql/MultiTenant/compose-build-ods-multi-tenant.yml
@@ -85,6 +85,8 @@ services:
PGBOUNCER_SET_DATABASE_USER: "yes"
PGBOUNCER_SET_DATABASE_PASSWORD: "yes"
restart: always
+ ports:
+ - "6401:6432"
container_name: ed-fi-pb-ods-tenant1
depends_on:
- db-ods-tenant1
@@ -102,6 +104,8 @@ services:
PGBOUNCER_SET_DATABASE_PASSWORD: "yes"
restart: always
container_name: ed-fi-pb-ods-tenant2
+ ports:
+ - "6402:6432"
depends_on:
- db-ods-tenant2
diff --git a/Docker/dev.mssql.Dockerfile b/Docker/dev.mssql.Dockerfile
index 27e78554a..faa1ed5c5 100644
--- a/Docker/dev.mssql.Dockerfile
+++ b/Docker/dev.mssql.Dockerfile
@@ -24,6 +24,9 @@ COPY --from=assets ./Application/EdFi.Ods.AdminApi.AdminConsole EdFi.Ods.AdminAp
COPY --from=assets ./Application/NuGet.Config EdFi.Ods.AdminApi.Common/
COPY --from=assets ./Application/EdFi.Ods.AdminApi.Common EdFi.Ods.AdminApi.Common/
+COPY --from=assets ./Application/NuGet.Config EdFi.Ods.AdminApi.HealthCheck/
+COPY --from=assets ./Application/EdFi.Ods.AdminApi.HealthCheck EdFi.Ods.AdminApi.HealthCheck/
+
WORKDIR /source/EdFi.Ods.AdminApi
RUN export ASPNETCORE_ENVIRONMENT=$ASPNETCORE_ENVIRONMENT
RUN dotnet restore && dotnet build -c Release
diff --git a/Docker/dev.pgsql.Dockerfile b/Docker/dev.pgsql.Dockerfile
index 34daa1f41..74275831d 100644
--- a/Docker/dev.pgsql.Dockerfile
+++ b/Docker/dev.pgsql.Dockerfile
@@ -24,6 +24,9 @@ COPY --from=assets ./Application/EdFi.Ods.AdminApi.AdminConsole EdFi.Ods.AdminAp
COPY --from=assets ./Application/NuGet.Config EdFi.Ods.AdminApi.Common/
COPY --from=assets ./Application/EdFi.Ods.AdminApi.Common EdFi.Ods.AdminApi.Common/
+COPY --from=assets ./Application/NuGet.Config EdFi.Ods.AdminApi.HealthCheck/
+COPY --from=assets ./Application/EdFi.Ods.AdminApi.HealthCheck EdFi.Ods.AdminApi.HealthCheck/
+
WORKDIR /source/EdFi.Ods.AdminApi
RUN export ASPNETCORE_ENVIRONMENT=$ASPNETCORE_ENVIRONMENT
RUN dotnet restore && dotnet build -c Release
diff --git a/docs/design/INTEGRATE-HEALTHCHECK-SERVICE b/docs/design/INTEGRATE-HEALTHCHECK-SERVICE
new file mode 100644
index 000000000..5e4c4c917
--- /dev/null
+++ b/docs/design/INTEGRATE-HEALTHCHECK-SERVICE
@@ -0,0 +1,56 @@
+# Integrating EdFi.AdminConsole.HealthCheckService into EdFi.Ods.AdminApi with Quartz.NET
+
+## Overview
+
+This document describes the design and process for integrating the `EdFi.AdminConsole.HealthCheckService` into the `EdFi.Ods.AdminApi` application, leveraging Quartz.NET for scheduled and on-demand execution of health checks.
+
+---
+
+## Goals
+
+* Enable scheduled health checks of ODS API instances via Quartz.NET.
+* Allow on-demand triggering of health checks via an API endpoint.
+* Ensure only one health check job runs at a time to prevent data conflicts.
+* Centralize health check logic in `EdFi.Ods.AdminApi.HealthCheck`.
+
+---
+
+## Architecture
+
+### Components
+
+* **HealthCheckService**: Service class that performs health checks across tenants and instances.
+* **HealthCheckJob**: Quartz.NET job that invokes `HealthCheckService.Run()`.
+* **Quartz.NET Scheduler**: Manages scheduled and ad-hoc job execution.
+* **HealthCheckTrigger Endpoint**: API endpoint to trigger health checks on demand.
+
+---
+
+## Process Flow
+
+### 1. Service Registration
+
+* Register `HealthCheckService` and its dependencies in the DI container (typically as `scoped` or `transient`).
+* Register `HealthCheckJob` with Quartz.NET using `AddQuartz` and `AddQuartzHostedService`.
+
+### 2. Scheduling with Quartz.NET
+
+* Configure Quartz.NET to schedule `HealthCheckJob` at a configurable interval (e.g., every 10 minutes, using `HealthCheckFrequencyInMinutes` from configuration).
+* Use the `[DisallowConcurrentExecution]` attribute on `HealthCheckJob` to prevent overlapping executions.
+
+### 3. On-Demand Triggering
+
+* Implement an API endpoint (e.g., `/healthcheck/trigger`) in `EdFi.Ods.AdminApi`. Note: Grouped with `adminconsole` endpoints for consistency.
+* The endpoint uses `ISchedulerFactory` to schedule an immediate, one-time execution of `HealthCheckJob`.
+
+### 4. Concurrency Control
+
+* `[DisallowConcurrentExecution]` ensures only one instance of `HealthCheckJob` runs at a time, regardless of trigger source (scheduled or on-demand).
+
+---
+
+## Configuration
+
+* **appsettings.json**:
+ * `HealthCheck:HealthCheckFrequencyInMinutes`: Controls the schedule interval.
+ * `AppSettings:EnableAdminConsoleAPI`: Enables or disables the health check API endpoint.
diff --git a/docs/docker.md b/docs/docker.md
index 9d3cc5c39..9aa9fede9 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -184,6 +184,57 @@ For local development and testing with keycloak, use `MultiTenant/compose-build-
For testing pre-built binaries, use `MultiTenant/compose-build-binaries-multi-tenant.yml`.
For testing pre-built binaries with keycloak, use `MultiTenant/compose-build-idp-binaries-multi-tenant.yml`.
+### Multi-Tenant PowerShell Setup Script
+
+The project includes a PowerShell script to simplify multi-tenant Docker environment setup. The script automatically handles multiple compose files and provides comprehensive environment management.
+
+**Location:** `\eng\setup-local-multi-tenants-docker.ps1`
+
+**Features:**
+
+* Automatically runs multiple compose files together
+* Validates prerequisites (Docker, compose files, environment)
+* Provides health checks and status monitoring
+* Supports build, start, stop, and log operations
+* Uses the `.env` file for configuration
+
+**Usage Examples:**
+
+1. **Start multi-tenant environment:**
+
+ ```powershell
+ .\eng\setup-local-multi-tenants-docker.ps1
+ ```
+
+2. **Build and start containers:**
+
+ ```powershell
+ .\eng\setup-local-multi-tenants-docker.ps1 -Build
+ ```
+
+3. **Start with log monitoring:**
+
+ ```powershell
+ .\eng\setup-local-multi-tenants-docker.ps1 -Logs
+ ```
+
+4. **Stop all containers:**
+
+ ```powershell
+ .\eng\setup-local-multi-tenants-docker.ps1 -Down
+ ```
+
+5. **Use custom environment file:**
+
+ ```powershell
+ .\eng\setup-local-multi-tenants-docker.ps1 -EnvFile "custom.env"
+ ```
+
+**Script combines these compose files:**
+
+* `MultiTenant/compose-build-dev-multi-tenant.yml`
+* `MultiTenant/compose-build-ods-multi-tenant.yml`
+
## Admin Api and Ed-Fi ODS / API docker containers
Please refer [DOCKER DEPLOYMENT](https://techdocs.ed-fi.org/display/EDFITOOLS/Docker+Deployment) for
diff --git a/eng/setup-local-multi-tenants-docker.ps1 b/eng/setup-local-multi-tenants-docker.ps1
new file mode 100644
index 000000000..2d9b9822f
--- /dev/null
+++ b/eng/setup-local-multi-tenants-docker.ps1
@@ -0,0 +1,179 @@
+<#
+.SYNOPSIS
+ Sets up Ed-Fi AdminAPI multi-tenant Docker environment
+.DESCRIPTION
+ This script runs the Docker Compose files to set up a multi-tenant Ed-Fi environment
+ with ODS databases and AdminAPI containers.
+.PARAMETER EnvFile
+ Path to the environment file (default: .env)
+.PARAMETER Down
+ Switch to bring down the containers instead of starting them
+.PARAMETER Build
+ Switch to force rebuild of containers
+.PARAMETER Logs
+ Switch to show logs after starting containers
+#>
+
+param(
+ [string]$EnvFile = "..\Docker\Compose\pgsql\.env",
+ [switch]$Down,
+ [switch]$Build,
+ [switch]$Logs
+)
+
+$ErrorActionPreference = "Stop"
+
+$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$ComposeDir = Join-Path $ScriptDir "..\Docker\Compose\pgsql\MultiTenant"
+$OdsComposeFile = Join-Path $ComposeDir "compose-build-ods-multi-tenant.yml"
+$DevComposeFile = Join-Path $ComposeDir "compose-build-dev-multi-tenant.yml"
+
+function Test-DockerRunning {
+ try {
+ docker info | Out-Null
+ return $true
+ }
+ catch {
+ return $false
+ }
+}
+
+function Test-FileExists {
+ param([string]$FilePath, [string]$Description)
+
+ if (-not (Test-Path $FilePath)) {
+ Write-Error "$Description not found at: $FilePath"
+ exit 1
+ }
+ Write-Host "✓ Found $Description" -ForegroundColor Green
+}
+
+function Invoke-MultiDockerCompose {
+ param(
+ [string[]]$ComposeFiles,
+ [string]$Command,
+ [string]$Description
+ )
+
+ Write-Host "$Description..." -ForegroundColor Cyan
+
+ # Build the docker-compose command with multiple -f flags
+ $cmd = "docker-compose"
+
+ # Add multiple -f flags for each compose file
+ foreach ($file in $ComposeFiles) {
+ $cmd += " -f `"$file`""
+ Write-Host "Using compose file: $file" -ForegroundColor Gray
+ }
+ if (Test-Path $EnvFile) {
+ $cmd += " --env-file `"$EnvFile`""
+ Write-Host "Using environment file: $EnvFile" -ForegroundColor Gray
+ }
+
+ $cmd += " $Command"
+
+ Write-Host "Executing: $cmd" -ForegroundColor Gray
+
+ try {
+ Invoke-Expression $cmd
+ if ($LASTEXITCODE -eq 0) {
+ Write-Host "$Description completed successfully" -ForegroundColor Green
+ } else {
+ Write-Error "$Description failed with exit code: $LASTEXITCODE"
+ }
+ }
+ catch {
+ Write-Error "Failed to execute $Description`: $_"
+ exit 1
+ }
+}
+
+try {
+ Write-Host "=== Ed-Fi AdminAPI Multi-Tenant Docker Setup ===" -ForegroundColor Yellow
+ Write-Host "Timestamp: $(Get-Date)" -ForegroundColor Gray
+
+ Write-Host "Checking prerequisites..." -ForegroundColor Cyan
+
+ if (-not (Test-DockerRunning)) {
+ Write-Error "Docker is not running. Please start Docker Desktop and try again."
+ exit 1
+ }
+ Write-Host "Docker is running" -ForegroundColor Green
+
+ try {
+ docker-compose --version | Out-Null
+ Write-Host "Docker Compose is available" -ForegroundColor Green
+ }
+ catch {
+ Write-Error "Docker Compose is not available. Please install Docker Compose."
+ exit 1
+ }
+
+ Test-FileExists -FilePath $DevComposeFile -Description "Dev compose file"
+ Test-FileExists -FilePath $OdsComposeFile -Description "ODS compose file"
+
+ if (Test-Path $EnvFile) {
+ Write-Host "Using environment file: $EnvFile" -ForegroundColor Green
+ } else {
+ Write-Warning "Environment file not found: $EnvFile"
+ Write-Host "Continuing with default Docker environment variables..." -ForegroundColor Yellow
+ }
+
+ # Define compose files array (order matters - Dev first, then ODS)
+ $ComposeFiles = @($DevComposeFile, $OdsComposeFile)
+
+ if ($Down) {
+ Write-Host "Bringing down containers..." -ForegroundColor Red
+
+ Invoke-MultiDockerCompose -ComposeFiles $ComposeFiles -Command "down --remove-orphans --volumes" -Description "Stopping all containers and removing volumes"
+
+ Write-Host "All containers have been stopped and removed" -ForegroundColor Green
+ }
+ else {
+ Write-Host "Starting multi-tenant environment..." -ForegroundColor Green
+ $upCommand = "up -d"
+ if ($Build) {
+ $upCommand += " --build"
+ Write-Host "Building containers..." -ForegroundColor Yellow
+ }
+
+ Invoke-MultiDockerCompose -ComposeFiles $ComposeFiles -Command $upCommand -Description "Starting multi-tenant containers"
+
+ Write-Host "Waiting for all services to be ready..." -ForegroundColor Yellow
+ Start-Sleep -Seconds 20
+
+ Write-Host "Container Status:" -ForegroundColor Cyan
+ docker ps --filter "name=ed-fi" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
+
+ Write-Host "Health Check:" -ForegroundColor Cyan
+ $unhealthyContainers = docker ps --filter "name=ed-fi" --filter "health=unhealthy" --format "{{.Names}}"
+ if ($unhealthyContainers) {
+ Write-Warning "Some containers are unhealthy: $unhealthyContainers"
+ } else {
+ Write-Host "All containers appear healthy" -ForegroundColor Green
+ }
+
+ if ($Logs) {
+ Write-Host "Container Logs:" -ForegroundColor Cyan
+ Write-Host "Press Ctrl+C to stop following logs..." -ForegroundColor Gray
+
+ # Use the same compose files for logs
+ $logCmd = "docker-compose"
+ foreach ($file in $ComposeFiles) {
+ $logCmd += " -f `"$file`""
+ }
+ if (Test-Path $EnvFile) {
+ $logCmd += " --env-file `"$EnvFile`""
+ }
+ $logCmd += " logs -f"
+
+ Invoke-Expression $logCmd
+ }
+
+ Write-Host "Multi-tenant environment setup completed!" -ForegroundColor Green
+ }
+}
+catch {
+ Write-Error "Script execution failed: $_"
+ exit 1
+}