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 +}