diff --git a/.github/workflows/processmanager-client-bundle-publish.yml b/.github/workflows/processmanager-client-bundle-publish.yml index dbaf9b4c6d..63ad13fcb6 100644 --- a/.github/workflows/processmanager-client-bundle-publish.yml +++ b/.github/workflows/processmanager-client-bundle-publish.yml @@ -33,12 +33,23 @@ on: env: # Conditions PUSH_PACKAGES: ${{ github.event_name != 'pull_request' }} + # Necessary to manage Azure resources from automated tests + AZURE_KEYVAULT_URL: ${{ vars.integration_test_azure_keyvault_url }} + # Set value used by 'AzuriteManager' + # Use 'AzuriteBlobFolderPath' for TestCommon version 4.2.0 and lower + AzuriteBlobFolderPath: ${{ github.workspace }}\node_modules\.bin\ + # Use 'AzuriteFolderPath' for TestCommon version 4.3.0 and higher + AzuriteFolderPath: ${{ github.workspace }}\node_modules\.bin\ + # Overrides settings in 'functionhost.settings.json' + FunctionAppHostPath: ${{ github.workspace }}\node_modules\azure-functions-core-tools\bin\func.dll jobs: build_and_publish: runs-on: windows-2022 name: Publish bundle to NuGet.org + environment: AzureAuth + # We need to have permissions here to be able to support manually triggering this workflow for releasing a pre-release. permissions: id-token: write # Needed by 'dotnet-solution-build-and-test' to login to Azure @@ -53,11 +64,17 @@ jobs: - name: Setup dotnet and tools uses: Energinet-DataHub/.github/.github/actions/dotnet-setup-and-tools@v13 + with: + use_azure_functions_tools: "true" + azure_functions_core_tools_version: 4.0.5455 - name: Build and test solution uses: Energinet-DataHub/.github/.github/actions/dotnet-solution-build-and-test@v13 with: solution_file_path: ./source/ProcessManager.Client.Filtered.slnf + azure_tenant_id: ${{ vars.integration_test_azure_tenant_id }} + azure_subscription_id: ${{ vars.integration_test_azure_subscription_id }} + azure_spn_id: ${{ vars.integration_test_azure_spn_id_oidc }} publish_test_report: "true" - name: Pack ProcessManager.Client project diff --git a/docs/ProcessManager.Client/ReleaseNotes/ReleaseNotes.md b/docs/ProcessManager.Client/ReleaseNotes/ReleaseNotes.md index 895b565d93..9d5230873a 100644 --- a/docs/ProcessManager.Client/ReleaseNotes/ReleaseNotes.md +++ b/docs/ProcessManager.Client/ReleaseNotes/ReleaseNotes.md @@ -1,5 +1,9 @@ # ProcessManager.Client Release Notes +## Version 0.9.0 + +- Walking skeleton for working with BRS_023_027. + ## Version 0.0.1 - Empty release. diff --git a/source/Edi.UnitTests/Validators/AggregatedTimeSeriesRequest/PeriodValidatorTests.cs b/source/Edi.UnitTests/Validators/AggregatedTimeSeriesRequest/PeriodValidatorTests.cs index 585586e158..f121655d86 100644 --- a/source/Edi.UnitTests/Validators/AggregatedTimeSeriesRequest/PeriodValidatorTests.cs +++ b/source/Edi.UnitTests/Validators/AggregatedTimeSeriesRequest/PeriodValidatorTests.cs @@ -18,6 +18,7 @@ using Energinet.DataHub.Wholesale.Edi.Validation.Helpers; using FluentAssertions; using NodaTime; +using NodaTime.Extensions; using Xunit; namespace Energinet.DataHub.Wholesale.Edi.UnitTests.Validators.AggregatedTimeSeriesRequest; @@ -27,11 +28,24 @@ public class PeriodValidatorTests private static readonly ValidationError _invalidDateFormat = new("Forkert dato format for {PropertyName}, skal være YYYY-MM-DDT22:00:00Z eller YYYY-MM-DDT23:00:00Z / Wrong date format for {PropertyName}, must be YYYY-MM-DDT22:00:00Z or YYYY-MM-DDT23:00:00Z", "D66"); private static readonly ValidationError _invalidWinterMidnightFormat = new("Forkert dato format for {PropertyName}, skal være YYYY-MM-DDT23:00:00Z / Wrong date format for {PropertyName}, must be YYYY-MM-DDT23:00:00Z", "D66"); private static readonly ValidationError _invalidSummerMidnightFormat = new("Forkert dato format for {PropertyName}, skal være YYYY-MM-DDT22:00:00Z / Wrong date format for {PropertyName}, must be YYYY-MM-DDT22:00:00Z", "D66"); - private static readonly ValidationError _startDateMustBeLessThen3Years = new("Dato må max være 3 år tilbage i tid / Can maximum be 3 years back in time", "E17"); + + private static readonly ValidationError _startDateMustBeLessThen3Years = new( + "Dato må max være 3 år og 6 måneder tilbage i tid / Can maximum be 3 years and 6 months back in time", + "E17"); + private static readonly ValidationError _periodIsGreaterThenAllowedPeriodSize = new("Dato må kun være for 1 måned af gangen / Can maximum be for a 1 month period", "E17"); private static readonly ValidationError _missingStartOrAndEndDate = new("Start og slut dato skal udfyldes / Start and end date must be present in request", "E50"); - private readonly PeriodValidationRule _sut = new(new PeriodValidationHelper(DateTimeZoneProviders.Tzdb.GetZoneOrNull("Europe/Copenhagen")!, SystemClock.Instance)); + private readonly PeriodValidationRule _sut; + private readonly DateTimeZone? _dateTimeZone = DateTimeZoneProviders.Tzdb.GetZoneOrNull("Europe/Copenhagen"); + + private Instant _now; + + public PeriodValidatorTests() + { + _now = Instant.FromUtc(2024, 5, 31, 22, 0, 0); + _sut = new PeriodValidationRule(new PeriodValidationHelper(_dateTimeZone!, new MockClock(() => _now))); + } [Fact] public async Task Validate_WhenRequestIsValid_ReturnsNoValidationErrors() @@ -71,8 +85,8 @@ public async Task Validate_WhenEndDateIsUnspecified_ReturnsExpectedValidationErr public async Task Validate_WhenStartHourIsWrong_ReturnsExpectedValidationError() { // Arrange - var now = SystemClock.Instance.GetCurrentInstant(); - var notWinterTimeMidnight = Instant.FromUtc(now.InUtc().Year, 1, 1, 22, 0, 0).ToString(); + var notWinterTimeMidnight = Instant.FromUtc(_now.InUtc().Year, 1, 1, 22, 0, 0).ToString(); + var message = AggregatedTimeSeriesRequestBuilder .AggregatedTimeSeriesRequest() .WithStartDate(notWinterTimeMidnight) @@ -92,12 +106,11 @@ public async Task Validate_WhenStartHourIsWrong_ReturnsExpectedValidationError() public async Task Validate_WhenEndHourIsWrong_ReturnsExpectedValidationError() { // Arrange - var now = SystemClock.Instance.GetCurrentInstant(); - var notSummerTimeMidnight = Instant.FromUtc(now.InUtc().Year, 7, 1, 23, 0, 0).ToString(); + var notSummerTimeMidnight = Instant.FromUtc(_now.InUtc().Year, 7, 1, 23, 0, 0).ToString(); var message = AggregatedTimeSeriesRequestBuilder .AggregatedTimeSeriesRequest() .WithEndDate(notSummerTimeMidnight) - .WithStartDate(Instant.FromUtc(now.InUtc().Year, 7, 2, 22, 0, 0).ToString()) + .WithStartDate(Instant.FromUtc(_now.InUtc().Year, 7, 2, 22, 0, 0).ToString()) .Build(); // Act @@ -154,8 +167,8 @@ public async Task Validate_WhenStartAndEndDateAreInvalid_ReturnsExpectedValidati public async Task Validate_WhenPeriodSizeIsGreaterThenAllowed_ReturnsExpectedValidationError() { // Arrange - var now = SystemClock.Instance.GetCurrentInstant(); - var winterTimeMidnight = Instant.FromUtc(now.InUtc().Year, 1, 1, 23, 0, 0); + var winterTimeMidnight = Instant.FromUtc(_now.InUtc().Year, 1, 1, 23, 0, 0); + var message = AggregatedTimeSeriesRequestBuilder .AggregatedTimeSeriesRequest() .WithStartDate(winterTimeMidnight.ToString()) @@ -215,9 +228,8 @@ public async Task Validate_WhenPeriodOverlapSummerDaylightSavingTime_ReturnsNoVa public async Task Validate_WhenPeriodOverlapWinterDaylightSavingTime_ReturnsNoValidationErrors() { // Arrange - var now = SystemClock.Instance.GetCurrentInstant(); - var summerTime = Instant.FromUtc(now.InUtc().Year, 9, 29, 22, 0, 0).ToString(); - var winterTime = Instant.FromUtc(now.InUtc().Year, 10, 29, 23, 0, 0).ToString(); + var summerTime = Instant.FromUtc(_now.InUtc().Year, 9, 29, 22, 0, 0).ToString(); + var winterTime = Instant.FromUtc(_now.InUtc().Year, 10, 29, 23, 0, 0).ToString(); var message = AggregatedTimeSeriesRequestBuilder .AggregatedTimeSeriesRequest() .WithStartDate(summerTime) @@ -230,4 +242,94 @@ public async Task Validate_WhenPeriodOverlapWinterDaylightSavingTime_ReturnsNoVa // Assert errors.Should().BeEmpty(); } + + [Fact] + public async Task + Validate_WhenPeriodStartIsMoreThan3YearsAnd6MonthsOldAndPeriodNotPartOfCutOffMonth_ReturnsExpectedValidationError() + { + // Arrange + var dateTimeOffset = _now.ToDateTimeOffset().AddYears(-5); + + var message = AggregatedTimeSeriesRequestBuilder + .AggregatedTimeSeriesRequest() + .WithStartDate(dateTimeOffset.ToInstant().ToString()) + .WithEndDate(dateTimeOffset.AddMonths(1).ToInstant().ToString()) + .Build(); + + // Act + var errors = await _sut.ValidateAsync(message); + + // Assert + errors.Should().ContainSingle().Subject.Should().Be(_startDateMustBeLessThen3Years); + } + + [Fact] + public async Task Validate_WhenPeriodStartIsLessThan3YearsAnd6MonthsOld_ReturnNoValidationError() + { + // Arrange + _now = new LocalDateTime(2024, 6, 1, 0, 0, 0) + .InZoneStrictly(_dateTimeZone!) + .ToInstant(); + + // Using a start date 2 years, 8 months, 14 days, 13 hours, 25 minutes and 37 seconds back in time + var start = new LocalDateTime(2022, 4, 1, 0, 0, 0) + .InZoneStrictly(_dateTimeZone!) + .ToInstant(); + + // Using an end date 2 years, 7 months, 14 days, 13 hours, 25 minutes and 37 seconds back in time + var end = new LocalDateTime(2022, 5, 1, 0, 0, 0) + .InZoneStrictly(_dateTimeZone!) + .ToInstant(); + + var message = AggregatedTimeSeriesRequestBuilder + .AggregatedTimeSeriesRequest() + .WithStartDate(start.ToString()) + .WithEndDate(end.ToString()) + .Build(); + + // Act + var errors = await _sut.ValidateAsync(message); + + // Assert + errors.Should().BeEmpty(); + } + + [Fact] + public async Task + Validate_WhenPeriodStartIsMoreThan3And6MonthsBackInTimeButPartOfCutOffMonth_ReturnsNoValidationError() + { + // Arrange + _now = new LocalDateTime(2024, 12, 15, 13, 25, 37) + .InZoneStrictly(_dateTimeZone!) + .ToInstant(); + + // Using a start date 3 years, 6 months, 14 days, 13 hours, 25 minutes and 37 seconds back in time + var periodStartDate = new LocalDateTime(2021, 6, 1, 0, 0, 0) + .InZoneStrictly(_dateTimeZone!) + .ToInstant(); + + // Using an end date 3 years, 5 months, 14 days, 13 hours, 25 minutes and 37 seconds back in time + var periodEndDate = new LocalDateTime(2021, 7, 1, 0, 0, 0) + .InZoneStrictly(_dateTimeZone!) + .ToInstant(); + + var message = AggregatedTimeSeriesRequestBuilder + .AggregatedTimeSeriesRequest() + .WithStartDate(periodStartDate.ToString()) + .WithEndDate(periodEndDate.ToString()) + .Build(); + + // Act + var errors = await _sut.ValidateAsync(message); + + // Assert + errors.Should().BeEmpty(); + } + + private sealed class MockClock(Func getInstant) : IClock + { + private readonly Func _getInstant = getInstant; + + public Instant GetCurrentInstant() => _getInstant.Invoke(); + } } diff --git a/source/Edi.UnitTests/Validators/WholesaleServicesRequest/PeriodValidationRuleTests.cs b/source/Edi.UnitTests/Validators/WholesaleServicesRequest/PeriodValidationRuleTests.cs index 9f439f7ad1..89d46a418e 100644 --- a/source/Edi.UnitTests/Validators/WholesaleServicesRequest/PeriodValidationRuleTests.cs +++ b/source/Edi.UnitTests/Validators/WholesaleServicesRequest/PeriodValidationRuleTests.cs @@ -33,7 +33,7 @@ public class PeriodValidationRuleTests private static readonly ValidationError _startDateMustBeLessThanOrEqualTo3YearsAnd3Months = new( - "Der kan ikke anmodes om data for 3 år og 3 måneder tilbage i tid / It is not possible to request data 3 years and 3 months back in time", + "Der kan ikke anmodes om data for 3 år og 6 måneder tilbage i tid / It is not possible to request data 3 years and 6 months back in time", "E17"); private static readonly ValidationError _invalidWinterMidnightFormat = @@ -130,7 +130,8 @@ public async Task Validate_WhenPeriodStartAndEndIsInAnInvalidFormat_ReturnsExpec } [Fact] - public async Task Validate_WhenPeriodStartIs5YearsOld_ReturnsExpectedValidationError() + public async Task + Validate_WhenPeriodStartIsMoreThan3YearsAnd6MonthsOldAndPeriodNotPartOfCutOffMonth_ReturnsExpectedValidationError() { // Arrange var dateTimeOffset = _now.ToDateTimeOffset().AddYears(-5); @@ -148,42 +149,20 @@ public async Task Validate_WhenPeriodStartIs5YearsOld_ReturnsExpectedValidationE } [Fact] - public async Task Validate_WhenPeriodStartIsExactly3YearsAnd2MonthsOld_ReturnNoValidationError() - { - // Arrange - var start = new LocalDateTime(2021, 4, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - var end = new LocalDateTime(2024, 5, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - var message = new WholesaleServicesRequestBuilder() - .WithPeriodStart(start.ToString()) - .WithPeriodEnd(end.ToString()) - .Build(); - - // Act - var errors = await _sut.ValidateAsync(message); - - // Assert - errors.Should().BeEmpty(); - } - - [Fact] - public async Task Validate_WhenPeriodStartIsExactly3YearsAnd3MonthsOld_ReturnsExpectedValidationError() + public async Task Validate_WhenPeriodStartIsLessThan3YearsAnd6MonthsOld_ReturnNoValidationError() { // Arrange _now = new LocalDateTime(2024, 6, 1, 0, 0, 0) .InZoneStrictly(_dateTimeZone!) .ToInstant(); - var start = new LocalDateTime(2021, 3, 1, 0, 0, 0) + // Using a start date 2 years, 8 months, 14 days, 13 hours, 25 minutes and 37 seconds back in time + var start = new LocalDateTime(2022, 4, 1, 0, 0, 0) .InZoneStrictly(_dateTimeZone!) .ToInstant(); - var end = new LocalDateTime(2024, 4, 1, 0, 0, 0) + // Using an end date 2 years, 7 months, 14 days, 13 hours, 25 minutes and 37 seconds back in time + var end = new LocalDateTime(2022, 5, 1, 0, 0, 0) .InZoneStrictly(_dateTimeZone!) .ToInstant(); @@ -196,54 +175,25 @@ public async Task Validate_WhenPeriodStartIsExactly3YearsAnd3MonthsOld_ReturnsEx var errors = await _sut.ValidateAsync(message); // Assert - errors.Should().ContainSingle().Subject.Should().Be(_startDateMustBeLessThanOrEqualTo3YearsAnd3Months); - } - - [Fact] - public async Task Validate_WhenPeriodStartIsExactly3Years2MonthsAnd1HourOldDueToDaylightSavingTime_ReturnsNoValidationError() - { - // Arrange - var periodStartDate = new LocalDateTime(2021, 10, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - var periodEndDate = new LocalDateTime(2021, 11, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - _now = new LocalDateTime(2024, 12, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - var message = new WholesaleServicesRequestBuilder() - .WithPeriodStart(periodStartDate.ToString()) - .WithPeriodEnd(periodEndDate.ToString()) - .Build(); - - // Act - var errors = await _sut.ValidateAsync(message); - - // Assert - using var assertionScope = new AssertionScope(); errors.Should().BeEmpty(); - var duration = _now - periodStartDate; - duration.Days.Should().Be(1157); - duration.Hours.Should().Be(1); } [Fact] - public async Task Validate_WhenPeriodStartIsExactly3Years2MonthsMinus1HourOldDueToDaylightSavingTime_ReturnsNoValidationError() + public async Task + Validate_WhenPeriodStartIsMoreThan3And6MonthsBackInTimeButPartOfCutOffMonth_ReturnsNoValidationError() { // Arrange - var periodStartDate = new LocalDateTime(2021, 3, 1, 0, 0, 0) + _now = new LocalDateTime(2024, 12, 15, 13, 25, 37) .InZoneStrictly(_dateTimeZone!) .ToInstant(); - var periodEndDate = new LocalDateTime(2021, 4, 1, 0, 0, 0) + // Using a start date 3 years, 6 months, 14 days, 13 hours, 25 minutes and 37 seconds back in time + var periodStartDate = new LocalDateTime(2021, 6, 1, 0, 0, 0) .InZoneStrictly(_dateTimeZone!) .ToInstant(); - _now = new LocalDateTime(2024, 5, 1, 0, 0, 0) + // Using an end date 3 years, 5 months, 14 days, 13 hours, 25 minutes and 37 seconds back in time + var periodEndDate = new LocalDateTime(2021, 7, 1, 0, 0, 0) .InZoneStrictly(_dateTimeZone!) .ToInstant(); @@ -258,102 +208,6 @@ public async Task Validate_WhenPeriodStartIsExactly3Years2MonthsMinus1HourOldDue // Assert using var assertionScope = new AssertionScope(); errors.Should().BeEmpty(); - var duration = _now - periodStartDate; - duration.Days.Should().Be(1156); - duration.Hours.Should().Be(23); - } - - [Fact] - public async Task Validate_WhenPeriodStartIsExactly3Years3MonthsAnd1HourOldDueToDaylightSavingTime_ReturnsExpectedValidationError() - { - // Arrange - var periodStartDate = new LocalDateTime(2021, 10, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - var periodEndDate = new LocalDateTime(2021, 11, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - _now = new LocalDateTime(2025, 1, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - var message = new WholesaleServicesRequestBuilder() - .WithPeriodStart(periodStartDate.ToString()) - .WithPeriodEnd(periodEndDate.ToString()) - .Build(); - - // Act - var errors = await _sut.ValidateAsync(message); - - // Assert - using var assertionScope = new AssertionScope(); - errors.Should().ContainSingle().Subject.Should().Be(_startDateMustBeLessThanOrEqualTo3YearsAnd3Months); - var duration = _now - periodStartDate; - duration.Days.Should().Be(1188); - duration.Hours.Should().Be(1); - } - - [Fact] - public async Task Validate_WhenPeriodStartIsExactly3Years3MonthsMinus1HourOldDueToDaylightSavingTime_ReturnsExpectedValidationError() - { - // Arrange - var periodStartDate = new LocalDateTime(2021, 3, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - var periodEndDate = new LocalDateTime(2021, 4, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - _now = new LocalDateTime(2024, 6, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - var message = new WholesaleServicesRequestBuilder() - .WithPeriodStart(periodStartDate.ToString()) - .WithPeriodEnd(periodEndDate.ToString()) - .Build(); - - // Act - var errors = await _sut.ValidateAsync(message); - - // Assert - using var assertionScope = new AssertionScope(); - errors.Should().ContainSingle().Subject.Should().Be(_startDateMustBeLessThanOrEqualTo3YearsAnd3Months); - var duration = _now - periodStartDate; - duration.Days.Should().Be(1187); - duration.Hours.Should().Be(23); - } - - [Fact] - public async Task Validate_WhenPeriodStart3Years2Month1DayFromNow_ReturnsNoValidationError() - { - // Arrange - _now = new LocalDateTime(2024, 6, 2, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - var start = new LocalDateTime(2021, 4, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - var end = new LocalDateTime(2021, 5, 1, 0, 0, 0) - .InZoneStrictly(_dateTimeZone!) - .ToInstant(); - - var message = new WholesaleServicesRequestBuilder() - // 1 day too old is the smallest possible period it can be too old - .WithPeriodStart(start.ToString()) - .WithPeriodEnd(end.ToString()) - .Build(); - - // Act - var errors = await _sut.ValidateAsync(message); - - // Assert - errors.Should().BeEmpty(); } [Fact] diff --git a/source/Edi/Validation/AggregatedTimeSeriesRequest/Rules/PeriodValidationRule.cs b/source/Edi/Validation/AggregatedTimeSeriesRequest/Rules/PeriodValidationRule.cs index 46e683f204..07c2a45c10 100644 --- a/source/Edi/Validation/AggregatedTimeSeriesRequest/Rules/PeriodValidationRule.cs +++ b/source/Edi/Validation/AggregatedTimeSeriesRequest/Rules/PeriodValidationRule.cs @@ -21,15 +21,22 @@ namespace Energinet.DataHub.Wholesale.Edi.Validation.AggregatedTimeSeriesRequest public class PeriodValidationRule(PeriodValidationHelper periodValidationHelper) : IValidationRule { + private const int MaxAllowedPeriodSizeInMonths = 1; + private const int AllowedTimeFrameYearsFromNow = 3; + private const int AllowedTimeFrameMonthsFromNow = 6; + private static readonly ValidationError _invalidDateFormat = new("Forkert dato format for {PropertyName}, skal være YYYY-MM-DDT22:00:00Z eller YYYY-MM-DDT23:00:00Z / Wrong date format for {PropertyName}, must be YYYY-MM-DDT22:00:00Z or YYYY-MM-DDT23:00:00Z", "D66"); private static readonly ValidationError _invalidWinterMidnightFormat = new("Forkert dato format for {PropertyName}, skal være YYYY-MM-DDT23:00:00Z / Wrong date format for {PropertyName}, must be YYYY-MM-DDT23:00:00Z", "D66"); private static readonly ValidationError _invalidSummerMidnightFormat = new("Forkert dato format for {PropertyName}, skal være YYYY-MM-DDT22:00:00Z / Wrong date format for {PropertyName}, must be YYYY-MM-DDT22:00:00Z", "D66"); - private static readonly ValidationError _startDateMustBeLessThen3Years = new("Dato må max være 3 år tilbage i tid / Can maximum be 3 years back in time", "E17"); + + private static readonly ValidationError _startDateMustBeLessThen3Years = new( + $"Dato må max være {AllowedTimeFrameYearsFromNow} år og {AllowedTimeFrameMonthsFromNow} måneder tilbage i tid / Can maximum be {AllowedTimeFrameYearsFromNow} years and {AllowedTimeFrameMonthsFromNow} months back in time", + "E17"); + private static readonly ValidationError _periodIsGreaterThenAllowedPeriodSize = new("Dato må kun være for 1 måned af gangen / Can maximum be for a 1 month period", "E17"); private static readonly ValidationError _missingStartOrAndEndDate = new("Start og slut dato skal udfyldes / Start and end date must be present in request", "E50"); - private readonly int _maxAllowedPeriodSizeInMonths = 1; - private readonly int _allowedTimeFrameInYearsFromNow = 3; + private readonly PeriodValidationHelper _periodValidationHelper = periodValidationHelper; public Task> ValidateAsync(DataHub.Edi.Requests.AggregatedTimeSeriesRequest subject) { @@ -71,14 +78,19 @@ private bool MissingDates(string start, string end, IList error private void IntervalMustBeWithinAllowedPeriodSize(Instant start, Instant end, IList errors) { - if (periodValidationHelper.IntervalMustBeLessThanAllowedPeriodSize(start, end, _maxAllowedPeriodSizeInMonths)) + if (_periodValidationHelper.IntervalMustBeLessThanAllowedPeriodSize(start, end, MaxAllowedPeriodSizeInMonths)) errors.Add(_periodIsGreaterThenAllowedPeriodSize); } private void StartDateMustBeGreaterThenAllowedYears(Instant start, IList errors) { - if (periodValidationHelper.IsDateOlderThanAllowed(start, maxYears: _allowedTimeFrameInYearsFromNow, maxMonths: 0)) + if (_periodValidationHelper.IsMonthOfDateOlderThanXYearsAndYMonths( + start, + AllowedTimeFrameYearsFromNow, + AllowedTimeFrameMonthsFromNow)) + { errors.Add(_startDateMustBeLessThen3Years); + } } private Instant? ParseToInstant(string dateTimeString, string propertyName, IList errors) @@ -93,7 +105,7 @@ private void StartDateMustBeGreaterThenAllowedYears(Instant start, IList errors) { - if (periodValidationHelper.IsMidnight(instant, out var zonedDateTime)) + if (_periodValidationHelper.IsMidnight(instant, out var zonedDateTime)) return; errors.Add(zonedDateTime.IsDaylightSavingTime() diff --git a/source/Edi/Validation/Helpers/PeriodValidationHelper.cs b/source/Edi/Validation/Helpers/PeriodValidationHelper.cs index 893998628a..3a974d33f8 100644 --- a/source/Edi/Validation/Helpers/PeriodValidationHelper.cs +++ b/source/Edi/Validation/Helpers/PeriodValidationHelper.cs @@ -18,17 +18,20 @@ namespace Energinet.DataHub.Wholesale.Edi.Validation.Helpers; public class PeriodValidationHelper(DateTimeZone dateTimeZone, IClock clock) { + private readonly DateTimeZone _dateTimeZone = dateTimeZone; + private readonly IClock _clock = clock; + public bool IsMidnight(Instant instant, out ZonedDateTime zonedDateTime) { - zonedDateTime = new ZonedDateTime(instant, dateTimeZone); + zonedDateTime = new ZonedDateTime(instant, _dateTimeZone); return zonedDateTime.TimeOfDay == LocalTime.Midnight; } public bool IsDateOlderThanAllowed(Instant date, int maxYears, int maxMonths) { - var zonedStartDateTime = new ZonedDateTime(date, dateTimeZone); - var zonedCurrentDateTime = new ZonedDateTime(clock.GetCurrentInstant(), dateTimeZone); + var zonedStartDateTime = new ZonedDateTime(date, _dateTimeZone); + var zonedCurrentDateTime = new ZonedDateTime(_clock.GetCurrentInstant(), _dateTimeZone); var latestStartDate = zonedCurrentDateTime.LocalDateTime.PlusYears(-maxYears).PlusMonths(-maxMonths); return zonedStartDateTime.LocalDateTime < latestStartDate; @@ -36,24 +39,26 @@ public bool IsDateOlderThanAllowed(Instant date, int maxYears, int maxMonths) public bool IntervalMustBeLessThanAllowedPeriodSize(Instant start, Instant end, int maxAllowedPeriodSizeInMonths) { - var zonedStartDateTime = new ZonedDateTime(start, dateTimeZone); - var zonedEndDateTime = new ZonedDateTime(end, dateTimeZone); + var zonedStartDateTime = new ZonedDateTime(start, _dateTimeZone); + var zonedEndDateTime = new ZonedDateTime(end, _dateTimeZone); var monthsFromStart = zonedStartDateTime.LocalDateTime.PlusMonths(maxAllowedPeriodSizeInMonths); return zonedEndDateTime.LocalDateTime > monthsFromStart; } - public bool IsMonthOlder3Years2Months(Instant periodStart) + public bool IsMonthOfDateOlderThanXYearsAndYMonths(Instant periodStart, int years, int months) { - var zonedDateTime = new ZonedDateTime(periodStart, dateTimeZone); - var zonedCurrentDataTime = new ZonedDateTime(clock.GetCurrentInstant(), dateTimeZone); - var threeYearsAndTwoMonthsAgo = zonedCurrentDataTime.LocalDateTime.PlusYears(-3).PlusMonths(-2); + var dateInQuestion = periodStart.InZone(_dateTimeZone); + var someYearsAndSomeMonthsAgo = _clock.GetCurrentInstant() + .InZone(_dateTimeZone) + .Date.PlusYears(-years) + .PlusMonths(-months); - if (zonedDateTime.Year > threeYearsAndTwoMonthsAgo.Year) + if (dateInQuestion.Year > someYearsAndSomeMonthsAgo.Year) return false; - if (zonedDateTime.Year == threeYearsAndTwoMonthsAgo.Year) - return zonedDateTime.Month < threeYearsAndTwoMonthsAgo.Month; + if (dateInQuestion.Year == someYearsAndSomeMonthsAgo.Year) + return dateInQuestion.Month < someYearsAndSomeMonthsAgo.Month; return true; } diff --git a/source/Edi/Validation/WholesaleServicesRequest/Rules/PeriodValidationRule.cs b/source/Edi/Validation/WholesaleServicesRequest/Rules/PeriodValidationRule.cs index 6983133beb..6ddcedf53b 100644 --- a/source/Edi/Validation/WholesaleServicesRequest/Rules/PeriodValidationRule.cs +++ b/source/Edi/Validation/WholesaleServicesRequest/Rules/PeriodValidationRule.cs @@ -18,9 +18,14 @@ namespace Energinet.DataHub.Wholesale.Edi.Validation.WholesaleServicesRequest.Rules; -public sealed class PeriodValidationRule(DateTimeZone dateTimeZone, PeriodValidationHelper periodValidationHelper) +public sealed class PeriodValidationRule( + DateTimeZone dateTimeZone, + PeriodValidationHelper periodValidationHelper) : IValidationRule { + private const int AllowedTimeFrameYearsFromNow = 3; + private const int AllowedTimeFrameMonthsFromNow = 6; + private static readonly ValidationError _invalidDateFormat = new( "Forkert dato format for {PropertyName}, skal være YYYY-MM-DDT22:00:00Z eller YYYY-MM-DDT23:00:00Z / Wrong date format for {PropertyName}, must be YYYY-MM-DDT22:00:00Z or YYYY-MM-DDT23:00:00Z", @@ -28,7 +33,7 @@ public sealed class PeriodValidationRule(DateTimeZone dateTimeZone, PeriodValida private static readonly ValidationError _startDateMustBeLessThanOrEqualTo3YearsAnd3Months = new( - "Der kan ikke anmodes om data for 3 år og 3 måneder tilbage i tid / It is not possible to request data 3 years and 3 months back in time", + $"Der kan ikke anmodes om data for {AllowedTimeFrameYearsFromNow} år og {AllowedTimeFrameMonthsFromNow} måneder tilbage i tid / It is not possible to request data {AllowedTimeFrameYearsFromNow} years and {AllowedTimeFrameMonthsFromNow} months back in time", "E17"); private static readonly ValidationError _invalidWinterMidnightFormat = @@ -51,6 +56,9 @@ public sealed class PeriodValidationRule(DateTimeZone dateTimeZone, PeriodValida "Det er kun muligt at anmode om data på for en hel måned i forbindelse med en engrosfiksering eller korrektioner / It is only possible to request data for a full month in relation to wholesalefixing or corrections", "E17"); + private readonly PeriodValidationHelper _periodValidationHelper = periodValidationHelper; + private readonly DateTimeZone _dateTimeZone = dateTimeZone; + public Task> ValidateAsync(DataHub.Edi.Requests.WholesaleServicesRequest subject) { ArgumentNullException.ThrowIfNull(subject); @@ -73,7 +81,7 @@ public Task> ValidateAsync(DataHub.Edi.Requests.Wholesale MustBeMidnight(startInstant.Value, "Period Start", errors); MustBeMidnight(endInstant.Value, "Period End", errors); MustBeAWholeMonth(startInstant.Value, endInstant.Value, errors); - MustNotBe3YearsAnd3MonthsOld(startInstant.Value, errors); + MustNotBeOlderThan3YearsAnd6Months(startInstant.Value, errors); return Task.FromResult>(errors); } @@ -92,9 +100,12 @@ public Task> ValidateAsync(DataHub.Edi.Requests.Wholesale return null; } - private void MustNotBe3YearsAnd3MonthsOld(Instant periodStart, ICollection errors) + private void MustNotBeOlderThan3YearsAnd6Months(Instant periodStart, ICollection errors) { - if (periodValidationHelper.IsMonthOlder3Years2Months(periodStart)) + if (_periodValidationHelper.IsMonthOfDateOlderThanXYearsAndYMonths( + periodStart, + AllowedTimeFrameYearsFromNow, + AllowedTimeFrameMonthsFromNow)) { errors.Add(_startDateMustBeLessThanOrEqualTo3YearsAnd3Months); } @@ -105,8 +116,8 @@ private void MustBeAWholeMonth( Instant periodEnd, ICollection errors) { - var zonedStartDateTime = new ZonedDateTime(periodStart, dateTimeZone); - var zonedEndDateTime = new ZonedDateTime(periodEnd, dateTimeZone); + var zonedStartDateTime = new ZonedDateTime(periodStart, _dateTimeZone); + var zonedEndDateTime = new ZonedDateTime(periodEnd, _dateTimeZone); if (zonedEndDateTime.LocalDateTime.Month > zonedStartDateTime.LocalDateTime.Month && zonedEndDateTime.LocalDateTime.Day > zonedStartDateTime.LocalDateTime.Day) { @@ -127,11 +138,12 @@ private void MustBeAWholeMonth( private void MustBeMidnight(Instant instant, string propertyName, ICollection errors) { - if (periodValidationHelper.IsMidnight(instant, out var zonedDateTime)) + if (_periodValidationHelper.IsMidnight(instant, out var zonedDateTime)) return; - errors.Add(zonedDateTime.IsDaylightSavingTime() - ? _invalidSummerMidnightFormat.WithPropertyName(propertyName) - : _invalidWinterMidnightFormat.WithPropertyName(propertyName)); + errors.Add( + zonedDateTime.IsDaylightSavingTime() + ? _invalidSummerMidnightFormat.WithPropertyName(propertyName) + : _invalidWinterMidnightFormat.WithPropertyName(propertyName)); } } diff --git a/source/ProcessManager.Client.Tests/Fixtures/ProcessManagerClientCollection.cs b/source/ProcessManager.Client.Tests/Fixtures/ProcessManagerClientCollection.cs new file mode 100644 index 0000000000..12e42713e0 --- /dev/null +++ b/source/ProcessManager.Client.Tests/Fixtures/ProcessManagerClientCollection.cs @@ -0,0 +1,30 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Energinet.DataHub.ProcessManager.Tests.Fixtures; + +namespace Energinet.DataHub.ProcessManager.Client.Tests.Fixtures; + +/// +/// A xUnit collection fixture for ensuring tests don't run in parallel. +/// +/// xUnit documentation of collection fixtures: +/// * https://xunit.net/docs/shared-context#collection-fixture +/// +[CollectionDefinition(nameof(ProcessManagerClientCollection))] +public class ProcessManagerClientCollection : + ICollectionFixture, + ICollectionFixture +{ +} diff --git a/source/ProcessManager.Client.Tests/Fixtures/ScenarioAppFixturesConfiguration.cs b/source/ProcessManager.Client.Tests/Fixtures/ScenarioAppFixturesConfiguration.cs new file mode 100644 index 0000000000..95e413bfe3 --- /dev/null +++ b/source/ProcessManager.Client.Tests/Fixtures/ScenarioAppFixturesConfiguration.cs @@ -0,0 +1,51 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Energinet.DataHub.ProcessManager.Core.Tests.Fixtures; + +namespace Energinet.DataHub.ProcessManager.Client.Tests.Fixtures; + +/// +/// Responsible for coordinating the configuration of app fixtures +/// and +/// . +/// +/// The two applications involved must: +/// - Use the same Database +/// - Use the same Task Hub +/// - Run on different ports +/// +public class ScenarioAppFixturesConfiguration +{ + private static readonly Lazy _instance = + new(() => new ScenarioAppFixturesConfiguration()); + + private ScenarioAppFixturesConfiguration() + { + DatabaseManager = new ProcessManagerDatabaseManager("ClientsTest"); + TaskHubName = "ClientsTest01"; + OrchestrationsAppPort = 8101; + ProcessManagerAppPort = 8102; + } + + public static ScenarioAppFixturesConfiguration Instance => _instance.Value; + + public ProcessManagerDatabaseManager DatabaseManager { get; } + + public string TaskHubName { get; } + + public int OrchestrationsAppPort { get; } + + public int ProcessManagerAppPort { get; } +} diff --git a/source/ProcessManager.Client.Tests/Fixtures/ScenarioOrchestrationsAppFixture.cs b/source/ProcessManager.Client.Tests/Fixtures/ScenarioOrchestrationsAppFixture.cs new file mode 100644 index 0000000000..9be0ec57e4 --- /dev/null +++ b/source/ProcessManager.Client.Tests/Fixtures/ScenarioOrchestrationsAppFixture.cs @@ -0,0 +1,37 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Energinet.DataHub.ProcessManager.Orchestrations.Tests.Fixtures; + +namespace Energinet.DataHub.ProcessManager.Client.Tests.Fixtures; + +/// +/// Configure fixture for scenario testing Process Manager Client, +/// which requires both Orchestrations app and Process Manager app +/// to run simultaneously with coordinated configuration. +/// +public class ScenarioOrchestrationsAppFixture + : OrchestrationsAppFixtureBase +{ + /// + /// See details at . + /// + public ScenarioOrchestrationsAppFixture() + : base( + ScenarioAppFixturesConfiguration.Instance.DatabaseManager, + ScenarioAppFixturesConfiguration.Instance.TaskHubName, + ScenarioAppFixturesConfiguration.Instance.OrchestrationsAppPort) + { + } +} diff --git a/source/ProcessManager.Client.Tests/Fixtures/ScenarioProcessManagerAppFixture.cs b/source/ProcessManager.Client.Tests/Fixtures/ScenarioProcessManagerAppFixture.cs new file mode 100644 index 0000000000..33d5dd7aab --- /dev/null +++ b/source/ProcessManager.Client.Tests/Fixtures/ScenarioProcessManagerAppFixture.cs @@ -0,0 +1,37 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Energinet.DataHub.ProcessManager.Tests.Fixtures; + +namespace Energinet.DataHub.ProcessManager.Client.Tests.Fixtures; + +/// +/// Configure fixture for scenario testing Process Manager Client, +/// which requires both Orchestrations app and Process Manager app +/// to run simultaneously with coordinated configuration. +/// +public class ScenarioProcessManagerAppFixture + : ProcessManagerAppFixtureBase +{ + /// + /// See details at . + /// + public ScenarioProcessManagerAppFixture() + : base( + ScenarioAppFixturesConfiguration.Instance.DatabaseManager, + ScenarioAppFixturesConfiguration.Instance.TaskHubName, + ScenarioAppFixturesConfiguration.Instance.ProcessManagerAppPort) + { + } +} diff --git a/source/ProcessManager.Client.Tests/Integration/MonitorCalculationUsingApiScenario.cs b/source/ProcessManager.Client.Tests/Integration/MonitorCalculationUsingApiScenario.cs new file mode 100644 index 0000000000..074f7f2f09 --- /dev/null +++ b/source/ProcessManager.Client.Tests/Integration/MonitorCalculationUsingApiScenario.cs @@ -0,0 +1,124 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Dynamic; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Energinet.DataHub.Core.FunctionApp.TestCommon.FunctionAppHost; +using Energinet.DataHub.Core.TestCommon; +using Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; +using Energinet.DataHub.ProcessManager.Client.Tests.Fixtures; +using FluentAssertions; +using Xunit.Abstractions; + +namespace Energinet.DataHub.ProcessManager.Client.Tests.Integration; + +/// +/// Test case where we verify the Process Manager clients can be used to start a +/// calculation orchestration and monitor its status during its lifetime. +/// +[Collection(nameof(ProcessManagerClientCollection))] +public class MonitorCalculationUsingApiScenario : IAsyncLifetime +{ + public MonitorCalculationUsingApiScenario( + ScenarioProcessManagerAppFixture processManagerAppFixture, + ScenarioOrchestrationsAppFixture orchestrationsAppFixture, + ITestOutputHelper testOutputHelper) + { + ProcessManagerAppFixture = processManagerAppFixture; + ProcessManagerAppFixture.SetTestOutputHelper(testOutputHelper); + + OrchestrationsAppFixture = orchestrationsAppFixture; + OrchestrationsAppFixture.SetTestOutputHelper(testOutputHelper); + } + + private ScenarioProcessManagerAppFixture ProcessManagerAppFixture { get; } + + private ScenarioOrchestrationsAppFixture OrchestrationsAppFixture { get; } + + public Task InitializeAsync() + { + ProcessManagerAppFixture.AppHostManager.ClearHostLog(); + OrchestrationsAppFixture.AppHostManager.ClearHostLog(); + + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + ProcessManagerAppFixture.SetTestOutputHelper(null!); + OrchestrationsAppFixture.SetTestOutputHelper(null!); + + return Task.CompletedTask; + } + + [Fact] + public async Task CalculationBrs023_WhenScheduledUsingClient_CanMonitorLifecycle() + { + // TODO: Move to API test project + dynamic scheduleRequestDto = new ExpandoObject(); + scheduleRequestDto.RunAt = "2024-11-01T06:19:10.0209567+01:00"; + scheduleRequestDto.InputParameter = new ExpandoObject(); + scheduleRequestDto.InputParameter.CalculationType = 0; + scheduleRequestDto.InputParameter.GridAreaCodes = new[] { "543" }; + scheduleRequestDto.InputParameter.PeriodStartDate = "2024-10-29T15:19:10.0151351+01:00"; + scheduleRequestDto.InputParameter.PeriodEndDate = "2024-10-29T16:19:10.0193962+01:00"; + scheduleRequestDto.InputParameter.IsInternalCalculation = true; + + using var scheduleRequest = new HttpRequestMessage( + HttpMethod.Post, + "/api/processmanager/orchestrationinstance/brs_023_027/1"); + scheduleRequest.Content = new StringContent( + JsonSerializer.Serialize(scheduleRequestDto), + Encoding.UTF8, + "application/json"); + + // Step 1: Schedule new calculation orchestration instance + using var scheduleResponse = await OrchestrationsAppFixture.AppHostManager + .HttpClient + .SendAsync(scheduleRequest); + scheduleResponse.EnsureSuccessStatusCode(); + + var calculationId = await scheduleResponse.Content + .ReadFromJsonAsync(); + + // Step 2: Trigger the scheduler to queue the calculation orchestration instance + await ProcessManagerAppFixture.AppHostManager + .TriggerFunctionAsync("StartScheduledOrchestrationInstances"); + + // Step 3: Query until terminated with succeeded + var isTerminated = await Awaiter.TryWaitUntilConditionAsync( + async () => + { + using var queryRequest = new HttpRequestMessage( + HttpMethod.Get, + $"/api/processmanager/orchestrationinstance/{calculationId}"); + + using var queryResponse = await ProcessManagerAppFixture.AppHostManager + .HttpClient + .SendAsync(queryRequest); + queryResponse.EnsureSuccessStatusCode(); + + var orchestrationInstance = await queryResponse.Content + .ReadFromJsonAsync(); + + return orchestrationInstance!.Lifecycle!.State == OrchestrationInstanceLifecycleStates.Terminated; + }, + timeLimit: TimeSpan.FromSeconds(40), + delay: TimeSpan.FromSeconds(2)); + + isTerminated.Should().BeTrue("because we expects the orchestration instance can complete within given wait time"); + } +} diff --git a/source/ProcessManager.Client.Tests/Integration/MonitorCalculationUsingClientsScenario.cs b/source/ProcessManager.Client.Tests/Integration/MonitorCalculationUsingClientsScenario.cs new file mode 100644 index 0000000000..b60fcd71ca --- /dev/null +++ b/source/ProcessManager.Client.Tests/Integration/MonitorCalculationUsingClientsScenario.cs @@ -0,0 +1,129 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// IMPORTANT: +// Since we use shared types (linked files) and the test project needs a reference +// to multiple projects where files are linked, we need to specify which assembly +// we want to use the type from. +// See also https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/cs0433?f1url=%3FappId%3Droslyn%26k%3Dk(CS0433) +extern alias ClientTypes; + +using ClientTypes.Energinet.DataHub.ProcessManager.Client.Extensions.DependencyInjection; +using ClientTypes.Energinet.DataHub.ProcessManager.Client.Extensions.Options; +using ClientTypes.Energinet.DataHub.ProcessManager.Orchestrations.Processes.BRS_023_027.V1.Model; +using Energinet.DataHub.Core.FunctionApp.TestCommon.FunctionAppHost; +using Energinet.DataHub.Core.TestCommon; +using Energinet.DataHub.ProcessManager.Client.Tests.Fixtures; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Energinet.DataHub.ProcessManager.Client.Tests.Integration; + +/// +/// Test case where we verify the Process Manager clients can be used to start a +/// calculation orchestration and monitor its status during its lifetime. +/// +[Collection(nameof(ProcessManagerClientCollection))] +public class MonitorCalculationUsingClientsScenario : IAsyncLifetime +{ + public MonitorCalculationUsingClientsScenario( + ScenarioProcessManagerAppFixture processManagerAppFixture, + ScenarioOrchestrationsAppFixture orchestrationsAppFixture, + ITestOutputHelper testOutputHelper) + { + ProcessManagerAppFixture = processManagerAppFixture; + ProcessManagerAppFixture.SetTestOutputHelper(testOutputHelper); + + OrchestrationsAppFixture = orchestrationsAppFixture; + OrchestrationsAppFixture.SetTestOutputHelper(testOutputHelper); + + var services = new ServiceCollection(); + services.AddScoped(_ => CreateInMemoryConfigurations(new Dictionary() + { + [$"{ProcessManagerClientOptions.SectionName}:{nameof(ProcessManagerClientOptions.GeneralApiBaseAddress)}"] + = ProcessManagerAppFixture.AppHostManager.HttpClient.BaseAddress!.ToString(), + [$"{ProcessManagerClientOptions.SectionName}:{nameof(ProcessManagerClientOptions.OrchestrationsApiBaseAddress)}"] + = OrchestrationsAppFixture.AppHostManager.HttpClient.BaseAddress!.ToString(), + })); + services.AddProcessManagerClients(); + ServiceProvider = services.BuildServiceProvider(); + } + + private ScenarioProcessManagerAppFixture ProcessManagerAppFixture { get; } + + private ScenarioOrchestrationsAppFixture OrchestrationsAppFixture { get; } + + private ServiceProvider ServiceProvider { get; } + + public Task InitializeAsync() + { + ProcessManagerAppFixture.AppHostManager.ClearHostLog(); + OrchestrationsAppFixture.AppHostManager.ClearHostLog(); + + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + ProcessManagerAppFixture.SetTestOutputHelper(null!); + OrchestrationsAppFixture.SetTestOutputHelper(null!); + + await ServiceProvider.DisposeAsync(); + } + + [Fact] + public async Task CalculationBrs023_WhenScheduledUsingClient_CanMonitorLifecycle() + { + var calculationClient = ServiceProvider.GetRequiredService(); + + // Step 1: Schedule new calculation orchestration instance + var orchestrationInstanceId = await calculationClient + .ScheduleNewCalculationAsync( + new ClientTypes.Energinet.DataHub.ProcessManager.Api.Model.ScheduleOrchestrationInstanceDto( + RunAt: DateTimeOffset.Parse("2024-11-01T06:19:10.0209567+01:00"), + InputParameter: new NotifyAggregatedMeasureDataInputV1( + CalculationTypes.BalanceFixing, + GridAreaCodes: new[] { "543" }, + PeriodStartDate: DateTimeOffset.Parse("2024-10-29T15:19:10.0151351+01:00"), + PeriodEndDate: DateTimeOffset.Parse("2024-10-29T16:19:10.0193962+01:00"), + IsInternalCalculation: true)), + CancellationToken.None); + + // Step 2: Trigger the scheduler to queue the calculation orchestration instance + await ProcessManagerAppFixture.AppHostManager + .TriggerFunctionAsync("StartScheduledOrchestrationInstances"); + + // Step 3: Query until terminated with succeeded + var isTerminated = await Awaiter.TryWaitUntilConditionAsync( + async () => + { + var orchestrationInstance = await calculationClient.GetCalculationAsync(orchestrationInstanceId, CancellationToken.None); + + return orchestrationInstance!.Lifecycle!.State == ClientTypes.Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance.OrchestrationInstanceLifecycleStates.Terminated; + }, + timeLimit: TimeSpan.FromSeconds(60), + delay: TimeSpan.FromSeconds(3)); + + isTerminated.Should().BeTrue("because we expects the orchestration instance can complete within given wait time"); + } + + private IConfiguration CreateInMemoryConfigurations(Dictionary configurations) + { + return new ConfigurationBuilder() + .AddInMemoryCollection(configurations) + .Build(); + } +} diff --git a/source/ProcessManager.Client.Tests/ProcessManager.Client.Tests.csproj b/source/ProcessManager.Client.Tests/ProcessManager.Client.Tests.csproj index 63890323c7..9b55d2efe6 100644 --- a/source/ProcessManager.Client.Tests/ProcessManager.Client.Tests.csproj +++ b/source/ProcessManager.Client.Tests/ProcessManager.Client.Tests.csproj @@ -1,4 +1,4 @@ - + Energinet.DataHub.ProcessManager.Client.Tests @@ -7,15 +7,28 @@ true + + + + + + + - + + ClientTypes + + + + + @@ -23,9 +36,16 @@ - - + + + PreserveNewest + + + PreserveNewest + + + diff --git a/source/ProcessManager.Client.Tests/functionapphost.settings.json b/source/ProcessManager.Client.Tests/functionapphost.settings.json new file mode 100644 index 0000000000..64bf8342e8 --- /dev/null +++ b/source/ProcessManager.Client.Tests/functionapphost.settings.json @@ -0,0 +1,12 @@ +{ + // + // See class FunctionAppHostSettings for a description of settings in this file. + // + + // We previously used %ProgramFiles% but experienced problems as it sometimes resolved to "...\Program Files (x86)\" + "DotnetExecutablePath": "C:\\Program Files\\dotnet\\dotnet.exe", + + // We must ensure this tool is installed at the same location on all developer machines. + // Be sure to use 'nvm' to manage Node.js + npm and node modules. + "FunctionAppHostPath": "C:\\Program Files\\nodejs\\node_modules\\azure-functions-core-tools\\bin\\func.dll" +} \ No newline at end of file diff --git a/source/ProcessManager.Client.Tests/integrationtest.local.settings.sample.json b/source/ProcessManager.Client.Tests/integrationtest.local.settings.sample.json new file mode 100644 index 0000000000..d9822a1f51 --- /dev/null +++ b/source/ProcessManager.Client.Tests/integrationtest.local.settings.sample.json @@ -0,0 +1,3 @@ +{ + "AZURE_KEYVAULT_URL": "" +} diff --git a/source/ProcessManager.Client/Extensions/DependencyInjection/ClientExtensions.cs b/source/ProcessManager.Client/Extensions/DependencyInjection/ClientExtensions.cs new file mode 100644 index 0000000000..417eb66b8b --- /dev/null +++ b/source/ProcessManager.Client/Extensions/DependencyInjection/ClientExtensions.cs @@ -0,0 +1,71 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Energinet.DataHub.ProcessManager.Client.Extensions.Options; +using Energinet.DataHub.ProcessManager.Client.Processes.BRS_023_027.V1; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Energinet.DataHub.ProcessManager.Client.Extensions.DependencyInjection; + +/// +/// Extension methods for +/// that allow adding Process Manager clients to an application. +/// +public static class ClientExtensions +{ + /// + /// Register Process Manager clients for use in applications. + /// If is registered we try to retrieve the "Authorization" + /// header value and forward it to the Process Manager API for authentication/authorization. + /// + public static IServiceCollection AddProcessManagerClients(this IServiceCollection services) + { + services + .AddOptions() + .BindConfiguration(ProcessManagerClientOptions.SectionName) + .ValidateDataAnnotations(); + + services.AddHttpClient(HttpClientNames.GeneralApi, (sp, httpClient) => + { + var options = sp.GetRequiredService>().Value; + ConfigureHttpClient(sp, httpClient, options.GeneralApiBaseAddress); + }); + services.AddHttpClient(HttpClientNames.OrchestrationsApi, (sp, httpClient) => + { + var options = sp.GetRequiredService>().Value; + ConfigureHttpClient(sp, httpClient, options.OrchestrationsApiBaseAddress); + }); + + services.AddScoped(); + services.AddScoped(); + + return services; + } + + /// + /// Configure http client base address; and if available then apply + /// the authorization header from the current HTTP context. + /// + private static void ConfigureHttpClient(IServiceProvider sp, HttpClient httpClient, string baseAddress) + { + httpClient.BaseAddress = new Uri(baseAddress); + + var httpContextAccessor = sp.GetService(); + var authorizationHeaderValue = (string?)httpContextAccessor?.HttpContext.Request.Headers["Authorization"]; + if (!string.IsNullOrWhiteSpace(authorizationHeaderValue)) + httpClient.DefaultRequestHeaders.Add("Authorization", authorizationHeaderValue); + } +} diff --git a/source/ProcessManager.Client/Extensions/DependencyInjection/HttpClientNames.cs b/source/ProcessManager.Client/Extensions/DependencyInjection/HttpClientNames.cs new file mode 100644 index 0000000000..bf86479bae --- /dev/null +++ b/source/ProcessManager.Client/Extensions/DependencyInjection/HttpClientNames.cs @@ -0,0 +1,31 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Energinet.DataHub.ProcessManager.Client.Extensions.DependencyInjection; + +/// +/// Constants used for naming instances. +/// +internal static class HttpClientNames +{ + /// + /// Http client for the general Api hosted in Process Manager + /// + public const string GeneralApi = "General"; + + /// + /// Http client for the specific Api hosted in Process Manager Orchestrations. + /// + public const string OrchestrationsApi = "Orchestrations"; +} diff --git a/source/ProcessManager.Client/Extensions/Options/ProcessManagerClientOptions.cs b/source/ProcessManager.Client/Extensions/Options/ProcessManagerClientOptions.cs new file mode 100644 index 0000000000..0b9cb403f5 --- /dev/null +++ b/source/ProcessManager.Client/Extensions/Options/ProcessManagerClientOptions.cs @@ -0,0 +1,37 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.ComponentModel.DataAnnotations; + +namespace Energinet.DataHub.ProcessManager.Client.Extensions.Options; + +/// +/// Options for the configuration of Process Manager clients using the Process Manager API. +/// +public class ProcessManagerClientOptions +{ + public const string SectionName = "ProcessManagerClient"; + + /// + /// Address to the general Api hosted in Process Manager. + /// + [Required] + public string GeneralApiBaseAddress { get; set; } = string.Empty; + + /// + /// Address to the specific Api hosted in Process Manager Orchestrations. + /// + [Required] + public string OrchestrationsApiBaseAddress { get; set; } = string.Empty; +} diff --git a/source/ProcessManager.Client/IProcessManagerClient.cs b/source/ProcessManager.Client/IProcessManagerClient.cs new file mode 100644 index 0000000000..ec26fcb6ba --- /dev/null +++ b/source/ProcessManager.Client/IProcessManagerClient.cs @@ -0,0 +1,50 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; + +namespace Energinet.DataHub.ProcessManager.Client; + +/// +/// Client for using the generic Process Manager API. +/// +public interface IProcessManagerClient +{ + /// + /// Cancel a scheduled orchestration instance. + /// + public Task CancelScheduledOrchestrationInstanceAsync( + Guid id, + CancellationToken cancellationToken); + + /// + /// Get orchestration instance. + /// + public Task GetOrchestrationInstanceAsync( + Guid id, + CancellationToken cancellationToken); + + /// + /// Get all orchestration instances filtered by their related orchestration definition name and version, + /// and their lifecycle / termination states. + /// + public Task> SearchOrchestrationInstancesAsync( + string name, + int? version, + OrchestrationInstanceLifecycleStates? lifecycleState, + OrchestrationInstanceTerminationStates? terminationState, + DateTimeOffset? startedAtOrLater, + DateTimeOffset? terminatedAtOrEarlier, + CancellationToken cancellationToken); +} diff --git a/source/ProcessManager.Client/ProcessManager.Client.csproj b/source/ProcessManager.Client/ProcessManager.Client.csproj index 2fa79e879d..a9aa9b49d9 100644 --- a/source/ProcessManager.Client/ProcessManager.Client.csproj +++ b/source/ProcessManager.Client/ProcessManager.Client.csproj @@ -7,7 +7,7 @@ Energinet.DataHub.ProcessManager.Client - 0.0.1$(VersionSuffix) + 0.9.0$(VersionSuffix) DH3 Process Manager Client library Energinet-DataHub Energinet-DataHub @@ -47,4 +47,31 @@ true + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/source/ProcessManager.Client/ProcessManagerClient.cs b/source/ProcessManager.Client/ProcessManagerClient.cs new file mode 100644 index 0000000000..ba1b285b7d --- /dev/null +++ b/source/ProcessManager.Client/ProcessManagerClient.cs @@ -0,0 +1,126 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Globalization; +using System.Net.Http.Json; +using System.Text; +using Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; +using Energinet.DataHub.ProcessManager.Client.Extensions.DependencyInjection; + +namespace Energinet.DataHub.ProcessManager.Client; + +/// +internal class ProcessManagerClient : IProcessManagerClient +{ + private readonly HttpClient _httpClient; + + public ProcessManagerClient(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient(HttpClientNames.GeneralApi); + } + + /// + public async Task CancelScheduledOrchestrationInstanceAsync( + Guid id, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage( + HttpMethod.Delete, + $"/api/processmanager/orchestrationinstance/{id}"); + + using var actualResponse = await _httpClient + .SendAsync(request, cancellationToken) + .ConfigureAwait(false); + actualResponse.EnsureSuccessStatusCode(); + } + + /// + public async Task GetOrchestrationInstanceAsync( + Guid id, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"/api/processmanager/orchestrationinstance/{id}"); + + using var actualResponse = await _httpClient + .SendAsync(request, cancellationToken) + .ConfigureAwait(false); + actualResponse.EnsureSuccessStatusCode(); + + var orchestrationInstance = await actualResponse.Content + .ReadFromJsonAsync(cancellationToken) + .ConfigureAwait(false); + + return orchestrationInstance!; + } + + /// + public async Task> SearchOrchestrationInstancesAsync( + string name, + int? version, + OrchestrationInstanceLifecycleStates? lifecycleState, + OrchestrationInstanceTerminationStates? terminationState, + DateTimeOffset? startedAtOrLater, + DateTimeOffset? terminatedAtOrEarlier, + CancellationToken cancellationToken) + { + var url = BuildSearchRequestUrl(name, version, lifecycleState, terminationState, startedAtOrLater, terminatedAtOrEarlier); + using var request = new HttpRequestMessage( + HttpMethod.Get, + url); + + using var actualResponse = await _httpClient + .SendAsync(request, cancellationToken) + .ConfigureAwait(false); + actualResponse.EnsureSuccessStatusCode(); + + var orchestrationInstances = await actualResponse.Content + .ReadFromJsonAsync>(cancellationToken) + .ConfigureAwait(false); + + return orchestrationInstances!; + } + + // TODO: Perhaps share with other clients + private static string BuildSearchRequestUrl( + string name, + int? version, + OrchestrationInstanceLifecycleStates? lifecycleState, + OrchestrationInstanceTerminationStates? terminationState, + DateTimeOffset? startedAtOrLater, + DateTimeOffset? terminatedAtOrEarlier) + { + var urlBuilder = new StringBuilder($"/api/processmanager/orchestrationinstances/{name}"); + + if (version.HasValue) + urlBuilder.Append($"/{version}"); + + urlBuilder.Append("?"); + + if (lifecycleState.HasValue) + urlBuilder.Append($"lifecycleState={Uri.EscapeDataString(lifecycleState.ToString() ?? string.Empty)}&"); + + if (terminationState.HasValue) + urlBuilder.Append($"terminationState={Uri.EscapeDataString(terminationState.ToString() ?? string.Empty)}&"); + + if (startedAtOrLater.HasValue) + urlBuilder.Append($"startedAtOrLater={Uri.EscapeDataString(startedAtOrLater?.ToString("o", CultureInfo.InvariantCulture) ?? string.Empty)}&"); + + if (terminatedAtOrEarlier.HasValue) + urlBuilder.Append($"terminatedAtOrEarlier={Uri.EscapeDataString(terminatedAtOrEarlier?.ToString("o", CultureInfo.InvariantCulture) ?? string.Empty)}&"); + + return urlBuilder.ToString(); + } +} diff --git a/source/ProcessManager.Client/Processes/BRS_023_027/V1/INotifyAggregatedMeasureDataClientV1.cs b/source/ProcessManager.Client/Processes/BRS_023_027/V1/INotifyAggregatedMeasureDataClientV1.cs new file mode 100644 index 0000000000..70f18c8ed7 --- /dev/null +++ b/source/ProcessManager.Client/Processes/BRS_023_027/V1/INotifyAggregatedMeasureDataClientV1.cs @@ -0,0 +1,54 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Energinet.DataHub.ProcessManager.Api.Model; +using Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; +using Energinet.DataHub.ProcessManager.Orchestrations.Processes.BRS_023_027.V1.Model; + +namespace Energinet.DataHub.ProcessManager.Client.Processes.BRS_023_027.V1; + +/// +/// Client for using the BRS-023/BRS_027 Process Manager API. +/// +public interface INotifyAggregatedMeasureDataClientV1 +{ + /// + /// Schedule a BRS-023 or BRS-027 calculation and return its id. + /// + public Task ScheduleNewCalculationAsync( + ScheduleOrchestrationInstanceDto requestDto, + CancellationToken cancellationToken); + + /// + /// Get information for BRS-023 or BRS-027 calculation. + /// + public Task> GetCalculationAsync( + Guid id, + CancellationToken cancellationToken); + + /// + /// Get all BRS-023 or BRS-027 calculations filtered by given parameters. + /// + public Task>> SearchCalculationsAsync( + OrchestrationInstanceLifecycleStates? lifecycleState, + OrchestrationInstanceTerminationStates? terminationState, + DateTimeOffset? startedAtOrLater, + DateTimeOffset? terminatedAtOrEarlier, + IReadOnlyCollection? calculationTypes, + IReadOnlyCollection? gridAreaCodes, + DateTimeOffset? periodStartDate, + DateTimeOffset? periodEndDate, + bool? isInternalCalculation, + CancellationToken cancellationToken); +} diff --git a/source/ProcessManager.Client/Processes/BRS_023_027/V1/NotifyAggregatedMeasureDataClientV1.cs b/source/ProcessManager.Client/Processes/BRS_023_027/V1/NotifyAggregatedMeasureDataClientV1.cs new file mode 100644 index 0000000000..f37092a663 --- /dev/null +++ b/source/ProcessManager.Client/Processes/BRS_023_027/V1/NotifyAggregatedMeasureDataClientV1.cs @@ -0,0 +1,157 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Globalization; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Energinet.DataHub.ProcessManager.Api.Model; +using Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; +using Energinet.DataHub.ProcessManager.Client.Extensions.DependencyInjection; +using Energinet.DataHub.ProcessManager.Orchestrations.Processes.BRS_023_027.V1.Model; + +namespace Energinet.DataHub.ProcessManager.Client.Processes.BRS_023_027.V1; + +/// +internal class NotifyAggregatedMeasureDataClientV1 : INotifyAggregatedMeasureDataClientV1 +{ + private readonly HttpClient _generalApiHttpClient; + private readonly HttpClient _orchestrationsApiHttpClient; + + public NotifyAggregatedMeasureDataClientV1(IHttpClientFactory httpClientFactory) + { + _generalApiHttpClient = httpClientFactory.CreateClient(HttpClientNames.GeneralApi); + _orchestrationsApiHttpClient = httpClientFactory.CreateClient(HttpClientNames.OrchestrationsApi); + } + + /// + public async Task ScheduleNewCalculationAsync( + ScheduleOrchestrationInstanceDto requestDto, + CancellationToken cancellationToken) + { + // TODO: + // Same base functionality as the generic code; should be possible to just reuse the generic code + // (but currently we have implemented the endpoint strongly typed). + using var request = new HttpRequestMessage( + HttpMethod.Post, + "/api/processmanager/orchestrationinstance/brs_023_027/1"); + request.Content = new StringContent( + JsonSerializer.Serialize(requestDto), + Encoding.UTF8, + "application/json"); + + using var actualResponse = await _orchestrationsApiHttpClient + .SendAsync(request, cancellationToken) + .ConfigureAwait(false); + actualResponse.EnsureSuccessStatusCode(); + + var calculationId = await actualResponse.Content + .ReadFromJsonAsync(cancellationToken) + .ConfigureAwait(false); + + return calculationId; + } + + /// + public async Task> GetCalculationAsync( + Guid id, + CancellationToken cancellationToken) + { + // TODO: + // Same base functionality as the generic code; should be possible to just reuse + // the "request" generic code and only have specific parsing. + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"/api/processmanager/orchestrationinstance/{id}"); + + using var actualResponse = await _generalApiHttpClient + .SendAsync(request, cancellationToken) + .ConfigureAwait(false); + actualResponse.EnsureSuccessStatusCode(); + + var calculationOrechestrationInstance = await actualResponse.Content + .ReadFromJsonAsync>(cancellationToken) + .ConfigureAwait(false); + + return calculationOrechestrationInstance!; + } + + /// + public async Task>> SearchCalculationsAsync( + OrchestrationInstanceLifecycleStates? lifecycleState, + OrchestrationInstanceTerminationStates? terminationState, + DateTimeOffset? startedAtOrLater, + DateTimeOffset? terminatedAtOrEarlier, + IReadOnlyCollection? calculationTypes, + IReadOnlyCollection? gridAreaCodes, + DateTimeOffset? periodStartDate, + DateTimeOffset? periodEndDate, + bool? isInternalCalculation, + CancellationToken cancellationToken) + { + // TODO: Same base functionality as the generic code, but we perform an + // additional in-memory filtering of specific inputs. + var url = BuildSearchRequestUrl("brs_023_027", 1, lifecycleState, terminationState, startedAtOrLater, terminatedAtOrEarlier); + using var request = new HttpRequestMessage( + HttpMethod.Get, + url); + + using var actualResponse = await _generalApiHttpClient + .SendAsync(request, cancellationToken) + .ConfigureAwait(false); + actualResponse.EnsureSuccessStatusCode(); + + var orchestrationInstances = await actualResponse.Content + .ReadFromJsonAsync>>(cancellationToken) + .ConfigureAwait(false); + + if (orchestrationInstances == null) + return []; + + // TODO: Filter in-memory + + return orchestrationInstances; + } + + // TODO: Perhaps share with other clients + private static string BuildSearchRequestUrl( + string name, + int? version, + OrchestrationInstanceLifecycleStates? lifecycleState, + OrchestrationInstanceTerminationStates? terminationState, + DateTimeOffset? startedAtOrLater, + DateTimeOffset? terminatedAtOrEarlier) + { + var urlBuilder = new StringBuilder($"/api/processmanager/orchestrationinstances/{name}"); + + if (version.HasValue) + urlBuilder.Append($"/{version}"); + + urlBuilder.Append("?"); + + if (lifecycleState.HasValue) + urlBuilder.Append($"lifecycleState={Uri.EscapeDataString(lifecycleState.ToString() ?? string.Empty)}&"); + + if (terminationState.HasValue) + urlBuilder.Append($"terminationState={Uri.EscapeDataString(terminationState.ToString() ?? string.Empty)}&"); + + if (startedAtOrLater.HasValue) + urlBuilder.Append($"startedAtOrLater={Uri.EscapeDataString(startedAtOrLater?.ToString("o", CultureInfo.InvariantCulture) ?? string.Empty)}&"); + + if (terminatedAtOrEarlier.HasValue) + urlBuilder.Append($"terminatedAtOrEarlier={Uri.EscapeDataString(terminatedAtOrEarlier?.ToString("o", CultureInfo.InvariantCulture) ?? string.Empty)}&"); + + return urlBuilder.ToString(); + } +} diff --git a/source/ProcessManager.Core.Tests/Integration/Infrastructure/Orchestration/OrchestrationInstanceRepositoryTests.cs b/source/ProcessManager.Core.Tests/Integration/Infrastructure/Orchestration/OrchestrationInstanceRepositoryTests.cs index 399b403654..463e67cb67 100644 --- a/source/ProcessManager.Core.Tests/Integration/Infrastructure/Orchestration/OrchestrationInstanceRepositoryTests.cs +++ b/source/ProcessManager.Core.Tests/Integration/Infrastructure/Orchestration/OrchestrationInstanceRepositoryTests.cs @@ -21,6 +21,7 @@ using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.SqlServer.NodaTime.Extensions; +using Moq; using NodaTime; namespace Energinet.DataHub.ProcessManager.Core.Tests.Integration.Infrastructure.Orchestration; @@ -137,12 +138,12 @@ public async Task GivenScheduledOrchestrationInstancesInDatabase_WhenGetSchedule var scheduledToRun = CreateOrchestrationInstance( existingOrchestrationDescription, - scheduledToRunAt: SystemClock.Instance.GetCurrentInstant().PlusMinutes(1)); + runAt: SystemClock.Instance.GetCurrentInstant().PlusMinutes(1)); await _sut.AddAsync(scheduledToRun); var scheduledIntoTheFarFuture = CreateOrchestrationInstance( existingOrchestrationDescription, - scheduledToRunAt: SystemClock.Instance.GetCurrentInstant().PlusDays(5)); + runAt: SystemClock.Instance.GetCurrentInstant().PlusDays(5)); await _sut.AddAsync(scheduledIntoTheFarFuture); await _unitOfWork.CommitAsync(); @@ -162,26 +163,28 @@ public async Task GivenScheduledOrchestrationInstancesInDatabase_WhenGetSchedule public async Task GivenOrchestrationInstancesInDatabase_WhenSearchByName_ThenExpectedOrchestrationInstancesAreRetrieved() { // Arrange - var uniqueName = Guid.NewGuid().ToString(); - var existingOrchestrationDescriptionV1 = CreateOrchestrationDescription(uniqueName, version: 1); - await SeedDatabaseWithOrchestrationDescriptionAsync(existingOrchestrationDescriptionV1); + var uniqueName1 = Guid.NewGuid().ToString(); + var existingOrchestrationDescription01 = CreateOrchestrationDescription(uniqueName1, version: 1); + await SeedDatabaseWithOrchestrationDescriptionAsync(existingOrchestrationDescription01); - var notScheduledV1 = CreateOrchestrationInstance(existingOrchestrationDescriptionV1); - await _sut.AddAsync(notScheduledV1); + var uniqueName2 = Guid.NewGuid().ToString(); + var existingOrchestrationDescription02 = CreateOrchestrationDescription(uniqueName2, version: 1); + await SeedDatabaseWithOrchestrationDescriptionAsync(existingOrchestrationDescription02); - var scheduledToRunV1 = CreateOrchestrationInstance( - existingOrchestrationDescriptionV1, - scheduledToRunAt: SystemClock.Instance.GetCurrentInstant().PlusMinutes(1)); - await _sut.AddAsync(scheduledToRunV1); + var basedOn01 = CreateOrchestrationInstance(existingOrchestrationDescription01); + await _sut.AddAsync(basedOn01); + + var basedOn02 = CreateOrchestrationInstance(existingOrchestrationDescription02); + await _sut.AddAsync(basedOn02); await _unitOfWork.CommitAsync(); // Act - var actual = await _sut.SearchAsync(existingOrchestrationDescriptionV1.Name); + var actual = await _sut.SearchAsync(existingOrchestrationDescription01.Name); // Assert actual.Should() - .BeEquivalentTo(new[] { notScheduledV1, scheduledToRunV1 }); + .BeEquivalentTo(new[] { basedOn01 }); } [Fact] @@ -226,7 +229,7 @@ public async Task GivenOrchestrationInstancesInDatabase_WhenSearchByNameAndLifec await _sut.AddAsync(isPendingV1); var isRunningV1 = CreateOrchestrationInstance(existingOrchestrationDescriptionV1); - isRunningV1.Lifecycle.TransitionToStartRequested(SystemClock.Instance); + isRunningV1.Lifecycle.TransitionToQueued(SystemClock.Instance); isRunningV1.Lifecycle.TransitionToRunning(SystemClock.Instance); await _sut.AddAsync(isRunningV1); @@ -234,7 +237,7 @@ public async Task GivenOrchestrationInstancesInDatabase_WhenSearchByNameAndLifec await _sut.AddAsync(isPendingV2); var isRunningV2 = CreateOrchestrationInstance(existingOrchestrationDescriptionV2); - isRunningV2.Lifecycle.TransitionToStartRequested(SystemClock.Instance); + isRunningV2.Lifecycle.TransitionToQueued(SystemClock.Instance); isRunningV2.Lifecycle.TransitionToRunning(SystemClock.Instance); await _sut.AddAsync(isRunningV2); @@ -263,7 +266,7 @@ public async Task GivenOrchestrationInstancesInDatabase_WhenSearchByNameAndTermi await _sut.AddAsync(isPendingV1); var isTerminatedAsSucceededV1 = CreateOrchestrationInstance(existingOrchestrationDescriptionV1); - isTerminatedAsSucceededV1.Lifecycle.TransitionToStartRequested(SystemClock.Instance); + isTerminatedAsSucceededV1.Lifecycle.TransitionToQueued(SystemClock.Instance); isTerminatedAsSucceededV1.Lifecycle.TransitionToRunning(SystemClock.Instance); isTerminatedAsSucceededV1.Lifecycle.TransitionToTerminated(SystemClock.Instance, OrchestrationInstanceTerminationStates.Succeeded); await _sut.AddAsync(isTerminatedAsSucceededV1); @@ -272,7 +275,7 @@ public async Task GivenOrchestrationInstancesInDatabase_WhenSearchByNameAndTermi await _sut.AddAsync(isPendingV2); var isTerminatedAsFailedV2 = CreateOrchestrationInstance(existingOrchestrationDescriptionV2); - isTerminatedAsFailedV2.Lifecycle.TransitionToStartRequested(SystemClock.Instance); + isTerminatedAsFailedV2.Lifecycle.TransitionToQueued(SystemClock.Instance); isTerminatedAsFailedV2.Lifecycle.TransitionToRunning(SystemClock.Instance); isTerminatedAsFailedV2.Lifecycle.TransitionToTerminated(SystemClock.Instance, OrchestrationInstanceTerminationStates.Failed); await _sut.AddAsync(isTerminatedAsFailedV2); @@ -290,6 +293,89 @@ public async Task GivenOrchestrationInstancesInDatabase_WhenSearchByNameAndTermi .BeEquivalentTo(new[] { isTerminatedAsSucceededV1 }); } + [Fact] + public async Task GivenOrchestrationInstancesInDatabase_WhenSearchByNameAndStartedAt_ThenExpectedOrchestrationInstancesAreRetrieved() + { + // Arrange + var startedAt01 = SystemClock.Instance.GetCurrentInstant().PlusDays(1); + var startedAtClockMock01 = new Mock(); + startedAtClockMock01.Setup(m => m.GetCurrentInstant()) + .Returns(startedAt01); + + var uniqueName = Guid.NewGuid().ToString(); + var existingOrchestrationDescriptionV1 = CreateOrchestrationDescription(uniqueName, version: 1); + await SeedDatabaseWithOrchestrationDescriptionAsync(existingOrchestrationDescriptionV1); + + var isPending = CreateOrchestrationInstance(existingOrchestrationDescriptionV1); + await _sut.AddAsync(isPending); + + var isRunning01 = CreateOrchestrationInstance(existingOrchestrationDescriptionV1); + isRunning01.Lifecycle.TransitionToQueued(SystemClock.Instance); + isRunning01.Lifecycle.TransitionToRunning(startedAtClockMock01.Object); + await _sut.AddAsync(isRunning01); + + var isRunning02 = CreateOrchestrationInstance(existingOrchestrationDescriptionV1); + isRunning02.Lifecycle.TransitionToQueued(SystemClock.Instance); + isRunning02.Lifecycle.TransitionToRunning(SystemClock.Instance); + await _sut.AddAsync(isRunning02); + + await _unitOfWork.CommitAsync(); + + // Act + var actual = await _sut.SearchAsync( + existingOrchestrationDescriptionV1.Name, + startedAtOrLater: startedAt01); + + // Assert + actual.Should() + .BeEquivalentTo(new[] { isRunning01 }); + } + + [Fact] + public async Task GivenOrchestrationInstancesInDatabase_WhenSearchByNameAndTerminatedAt_ThenExpectedOrchestrationInstancesAreRetrieved() + { + // Arrange + var terminatedAt01 = SystemClock.Instance.GetCurrentInstant().PlusDays(-1); + var terminatedAtClockMock01 = new Mock(); + terminatedAtClockMock01.Setup(m => m.GetCurrentInstant()) + .Returns(terminatedAt01); + + var uniqueName = Guid.NewGuid().ToString(); + var existingOrchestrationDescriptionV1 = CreateOrchestrationDescription(uniqueName, version: 1); + await SeedDatabaseWithOrchestrationDescriptionAsync(existingOrchestrationDescriptionV1); + + var isPending = CreateOrchestrationInstance(existingOrchestrationDescriptionV1); + await _sut.AddAsync(isPending); + + var isRunning = CreateOrchestrationInstance(existingOrchestrationDescriptionV1); + isRunning.Lifecycle.TransitionToQueued(SystemClock.Instance); + isRunning.Lifecycle.TransitionToRunning(SystemClock.Instance); + await _sut.AddAsync(isRunning); + + var isTerminated01 = CreateOrchestrationInstance(existingOrchestrationDescriptionV1); + isTerminated01.Lifecycle.TransitionToQueued(SystemClock.Instance); + isTerminated01.Lifecycle.TransitionToRunning(SystemClock.Instance); + isTerminated01.Lifecycle.TransitionToTerminated(terminatedAtClockMock01.Object, OrchestrationInstanceTerminationStates.Succeeded); + await _sut.AddAsync(isTerminated01); + + var isTerminated02 = CreateOrchestrationInstance(existingOrchestrationDescriptionV1); + isTerminated02.Lifecycle.TransitionToQueued(SystemClock.Instance); + isTerminated02.Lifecycle.TransitionToRunning(SystemClock.Instance); + isTerminated02.Lifecycle.TransitionToTerminated(SystemClock.Instance, OrchestrationInstanceTerminationStates.Succeeded); + await _sut.AddAsync(isTerminated02); + + await _unitOfWork.CommitAsync(); + + // Act + var actual = await _sut.SearchAsync( + existingOrchestrationDescriptionV1.Name, + terminatedAtOrEarlier: terminatedAt01); + + // Assert + actual.Should() + .BeEquivalentTo(new[] { isTerminated01 }); + } + private static OrchestrationDescription CreateOrchestrationDescription(string? name = default, int? version = default) { var existingOrchestrationDescription = new OrchestrationDescription( @@ -304,12 +390,12 @@ private static OrchestrationDescription CreateOrchestrationDescription(string? n return existingOrchestrationDescription; } - private static OrchestrationInstance CreateOrchestrationInstance(OrchestrationDescription existingOrchestrationDescription, Instant? scheduledToRunAt = default) + private static OrchestrationInstance CreateOrchestrationInstance(OrchestrationDescription existingOrchestrationDescription, Instant? runAt = default) { var existingOrchestrationInstance = new OrchestrationInstance( existingOrchestrationDescription.Id, SystemClock.Instance, - scheduledToRunAt); + runAt); var step1 = new OrchestrationStep( existingOrchestrationInstance.Id, diff --git a/source/ProcessManager.Core.Tests/Unit/Domain/OrchestrationParameterDefinitionTests.cs b/source/ProcessManager.Core.Tests/Unit/Domain/OrchestrationParameterDefinitionTests.cs index 4c9acced6e..a560430ef9 100644 --- a/source/ProcessManager.Core.Tests/Unit/Domain/OrchestrationParameterDefinitionTests.cs +++ b/source/ProcessManager.Core.Tests/Unit/Domain/OrchestrationParameterDefinitionTests.cs @@ -72,14 +72,14 @@ public async Task GivenSetFromType_WhenValidatingInstanceOfAnotherType_ThenIsNot /// DOES NOT work if the parameter use the 'NodaTime.Instant' type. /// public sealed record OrchestrationParameterExample01( - DateTimeOffset ScheduledAt, + DateTimeOffset RunAt, bool IsInternal); /// /// Example orchestration parameter for testing purposes. /// public sealed record OrchestrationParameterExample02( - DateTimeOffset ScheduledAt, + DateTimeOffset RunAt, bool IsInternal); /// diff --git a/source/ProcessManager.Core/Application/IOrchestrationInstanceManager.cs b/source/ProcessManager.Core/Application/IOrchestrationInstanceManager.cs index 7dc84e7301..0bc2e754ca 100644 --- a/source/ProcessManager.Core/Application/IOrchestrationInstanceManager.cs +++ b/source/ProcessManager.Core/Application/IOrchestrationInstanceManager.cs @@ -22,8 +22,11 @@ public interface IOrchestrationInstanceManager /// /// Start a new instance of an orchestration. /// - Task StartNewOrchestrationInstanceAsync(string name, int version, TParameter parameter) - where TParameter : class; + Task StartNewOrchestrationInstanceAsync( + string name, + int version, + TParameter inputParameter) + where TParameter : class; /// /// Schedule a new instance of an orchestration. @@ -31,14 +34,9 @@ Task StartNewOrchestrationInstanceAsync(str Task ScheduleNewOrchestrationInstanceAsync( string name, int version, - TParameter parameter, + TParameter inputParameter, Instant runAt) - where TParameter : class; - - /// - /// Start a scheduled orchestration instance. - /// - Task StartScheduledOrchestrationInstanceAsync(OrchestrationInstanceId id); + where TParameter : class; /// /// Cancel a scheduled orchestration instance. diff --git a/source/ProcessManager.Core/Application/IOrchestrationInstanceRepository.cs b/source/ProcessManager.Core/Application/IOrchestrationInstanceRepository.cs index 5e1f1875d4..53c24150c8 100644 --- a/source/ProcessManager.Core/Application/IOrchestrationInstanceRepository.cs +++ b/source/ProcessManager.Core/Application/IOrchestrationInstanceRepository.cs @@ -13,6 +13,7 @@ // limitations under the License. using Energinet.DataHub.ProcessManagement.Core.Domain.OrchestrationInstance; +using NodaTime; namespace Energinet.DataHub.ProcessManagement.Core.Application; @@ -32,5 +33,7 @@ Task> SearchAsync( string name, int? version, OrchestrationInstanceLifecycleStates? lifecycleState, - OrchestrationInstanceTerminationStates? terminationState); + OrchestrationInstanceTerminationStates? terminationState, + Instant? startedAtOrLater, + Instant? terminatedAtOrEarlier); } diff --git a/source/ProcessManager.Core/Application/IOrchestrationInstanceScheduleManager.cs b/source/ProcessManager.Core/Application/IOrchestrationInstanceScheduleManager.cs new file mode 100644 index 0000000000..c1be747be2 --- /dev/null +++ b/source/ProcessManager.Core/Application/IOrchestrationInstanceScheduleManager.cs @@ -0,0 +1,25 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Energinet.DataHub.ProcessManagement.Core.Domain.OrchestrationInstance; + +namespace Energinet.DataHub.ProcessManagement.Core.Application; + +public interface IOrchestrationInstanceScheduleManager +{ + /// + /// Start a scheduled orchestration instance. + /// + Task StartScheduledOrchestrationInstanceAsync(OrchestrationInstanceId id); +} diff --git a/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstance.cs b/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstance.cs index bb3263b1c1..0a5c15782b 100644 --- a/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstance.cs +++ b/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstance.cs @@ -27,10 +27,10 @@ public class OrchestrationInstance public OrchestrationInstance( OrchestrationDescriptionId orchestrationDescriptionId, IClock clock, - Instant? scheduledToRunAt = default) + Instant? runAt = default) { Id = new OrchestrationInstanceId(Guid.NewGuid()); - Lifecycle = new OrchestrationInstanceLifecycleState(clock, scheduledToRunAt); + Lifecycle = new OrchestrationInstanceLifecycleState(clock, runAt); ParameterValue = new(); Steps = []; CustomState = new OrchestrationInstanceCustomState(string.Empty); @@ -56,7 +56,7 @@ private OrchestrationInstance() public OrchestrationInstanceLifecycleState Lifecycle { get; } /// - /// Defines the Durable Functions orchestration input parameter value. + /// Contains the Durable Functions orchestration input parameter value. /// public OrchestrationInstanceParameterValue ParameterValue { get; } diff --git a/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstanceLifecycleState.cs b/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstanceLifecycleState.cs index 8e17726696..771c6aaf1d 100644 --- a/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstanceLifecycleState.cs +++ b/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstanceLifecycleState.cs @@ -18,10 +18,10 @@ namespace Energinet.DataHub.ProcessManagement.Core.Domain.OrchestrationInstance; public class OrchestrationInstanceLifecycleState { - internal OrchestrationInstanceLifecycleState(IClock clock, Instant? scheduledToRunAt) + internal OrchestrationInstanceLifecycleState(IClock clock, Instant? runAt) { CreatedAt = clock.GetCurrentInstant(); - ScheduledToRunAt = scheduledToRunAt; + ScheduledToRunAt = runAt; State = OrchestrationInstanceLifecycleStates.Pending; } @@ -44,7 +44,7 @@ private OrchestrationInstanceLifecycleState() public Instant? ScheduledToRunAt { get; } - public Instant? StartRequestedAt { get; private set; } + public Instant? QueuedAt { get; private set; } public Instant? StartedAt { get; private set; } @@ -57,18 +57,18 @@ public bool IsPendingForScheduledStart() && ScheduledToRunAt.HasValue; } - public void TransitionToStartRequested(IClock clock) + public void TransitionToQueued(IClock clock) { if (State is not OrchestrationInstanceLifecycleStates.Pending) - ThrowInvalidStateTransitionException(State, OrchestrationInstanceLifecycleStates.StartRequested); + ThrowInvalidStateTransitionException(State, OrchestrationInstanceLifecycleStates.Queued); - State = OrchestrationInstanceLifecycleStates.StartRequested; - StartRequestedAt = clock.GetCurrentInstant(); + State = OrchestrationInstanceLifecycleStates.Queued; + QueuedAt = clock.GetCurrentInstant(); } public void TransitionToRunning(IClock clock) { - if (State is not OrchestrationInstanceLifecycleStates.StartRequested) + if (State is not OrchestrationInstanceLifecycleStates.Queued) ThrowInvalidStateTransitionException(State, OrchestrationInstanceLifecycleStates.Running); State = OrchestrationInstanceLifecycleStates.Running; diff --git a/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstanceLifecycleStates.cs b/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstanceLifecycleStates.cs index 4095e63a97..3127731160 100644 --- a/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstanceLifecycleStates.cs +++ b/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstanceLifecycleStates.cs @@ -27,7 +27,7 @@ public enum OrchestrationInstanceLifecycleStates /// /// The Process Manager has requested the Task Hub to start the Durable Functions orchestration instance. /// - StartRequested = 2, + Queued = 2, /// /// A Durable Functions activity has transitioned the orchestration instance into running. diff --git a/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstanceParameterValue.cs b/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstanceParameterValue.cs index 015a021707..09c97864fb 100644 --- a/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstanceParameterValue.cs +++ b/source/ProcessManager.Core/Domain/OrchestrationInstance/OrchestrationInstanceParameterValue.cs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Dynamic; using System.Text.Json; namespace Energinet.DataHub.ProcessManagement.Core.Domain.OrchestrationInstance; @@ -41,4 +42,9 @@ public void SetFromInstance(TParameter instance) { SerializedParameterValue = JsonSerializer.Serialize(instance); } + + public ExpandoObject? AsExpandoObject() + { + return JsonSerializer.Deserialize(SerializedParameterValue); + } } diff --git a/source/ProcessManager.Core/Infrastructure/Database/OrchestrationInstanceEntityConfiguration.cs b/source/ProcessManager.Core/Infrastructure/Database/OrchestrationInstanceEntityConfiguration.cs index 3c69d706f7..8fd71a259d 100644 --- a/source/ProcessManager.Core/Infrastructure/Database/OrchestrationInstanceEntityConfiguration.cs +++ b/source/ProcessManager.Core/Infrastructure/Database/OrchestrationInstanceEntityConfiguration.cs @@ -41,7 +41,7 @@ public void Configure(EntityTypeBuilder builder) b.Property(pv => pv.CreatedAt); b.Property(pv => pv.ScheduledToRunAt); - b.Property(pv => pv.StartRequestedAt); + b.Property(pv => pv.QueuedAt); b.Property(pv => pv.StartedAt); b.Property(pv => pv.TerminatedAt); }); diff --git a/source/ProcessManager.Core/Infrastructure/Extensions/DependencyInjection/ProcessManagerExtensions.cs b/source/ProcessManager.Core/Infrastructure/Extensions/DependencyInjection/ProcessManagerExtensions.cs index 5e7194f1e2..5487a0d205 100644 --- a/source/ProcessManager.Core/Infrastructure/Extensions/DependencyInjection/ProcessManagerExtensions.cs +++ b/source/ProcessManager.Core/Infrastructure/Extensions/DependencyInjection/ProcessManagerExtensions.cs @@ -66,16 +66,20 @@ public static IServiceCollection AddProcessManagerCore(this IServiceCollection s }); // ProcessManager components using interfaces to restrict access to functionality + // => Scheduler + services.TryAddScoped(); + services.TryAddScoped(); + // => Manager services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); services.TryAddScoped(); return services; } /// - /// Register options and services necessary for integrating Durable Functions orchestrations with the Process Manager functionality. + /// Register options and services necessary for integrating Durable Functions orchestrations with the + /// Process Manager functionality. /// Should be used from host's that contains Durable Functions orchestrations. /// /// @@ -96,13 +100,34 @@ public static IServiceCollection AddProcessManagerForOrchestrations( // Task Hub connected to Durable Functions services.AddTaskHubStorage(); + services + .AddDurableClientFactory() + .TryAddSingleton(sp => + { + // IDurableClientFactory has a singleton lifecycle and caches clients + var clientFactory = sp.GetRequiredService(); + var processManagerOptions = sp.GetRequiredService>().Value; - // Orchestration Descriptions to register - services.TryAddTransient>(sp => enabledDescriptionsFactory()); + var durableClient = clientFactory.CreateClient(new DurableClientOptions + { + ConnectionName = nameof(ProcessManagerTaskHubOptions.ProcessManagerStorageConnectionString), + TaskHub = processManagerOptions.ProcessManagerTaskHubName, + IsExternalClient = true, + }); + + return durableClient; + }); // ProcessManager components using interfaces to restrict access to functionality + // => Orchestration Descriptions registration during startup + services.TryAddTransient>(sp => enabledDescriptionsFactory()); services.TryAddTransient(); + // => Orchestration instances progress services.TryAddScoped(); + // => Manager + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/source/ProcessManager.Core/Infrastructure/Orchestration/OrchestrationInstanceManager.cs b/source/ProcessManager.Core/Infrastructure/Orchestration/OrchestrationInstanceManager.cs index 77f1dd475e..2c59671103 100644 --- a/source/ProcessManager.Core/Infrastructure/Orchestration/OrchestrationInstanceManager.cs +++ b/source/ProcessManager.Core/Infrastructure/Orchestration/OrchestrationInstanceManager.cs @@ -24,7 +24,7 @@ namespace Energinet.DataHub.ProcessManagement.Core.Infrastructure.Orchestration; /// An encapsulation of that allows us to /// provide a "framework" for managing Durable Functions orchestration instances using custom domain types. /// -public class OrchestrationInstanceManager : IOrchestrationInstanceManager +public class OrchestrationInstanceManager : IOrchestrationInstanceManager, IOrchestrationInstanceScheduleManager { private readonly IClock _clock; private readonly IDurableClient _durableClient; @@ -56,12 +56,15 @@ public OrchestrationInstanceManager( } /// - public async Task StartNewOrchestrationInstanceAsync(string name, int version, TParameter parameter) - where TParameter : class + public async Task StartNewOrchestrationInstanceAsync( + string name, + int version, + TParameter inputParameter) + where TParameter : class { - var orchestrationDescription = await GuardMatchingOrchestrationDescriptionAsync(name, version, parameter).ConfigureAwait(false); + var orchestrationDescription = await GuardMatchingOrchestrationDescriptionAsync(name, version, inputParameter).ConfigureAwait(false); - var orchestrationInstance = await CreateOrchestrationInstanceAsync(parameter, orchestrationDescription).ConfigureAwait(false); + var orchestrationInstance = await CreateOrchestrationInstanceAsync(inputParameter, orchestrationDescription).ConfigureAwait(false); await RequestStartOfOrchestrationInstanceAsync(orchestrationDescription, orchestrationInstance).ConfigureAwait(false); return orchestrationInstance.Id; @@ -71,15 +74,15 @@ public async Task StartNewOrchestrationInstanceAsync ScheduleNewOrchestrationInstanceAsync( string name, int version, - TParameter parameter, + TParameter inputParameter, Instant runAt) - where TParameter : class + where TParameter : class { - var orchestrationDescription = await GuardMatchingOrchestrationDescriptionAsync(name, version, parameter).ConfigureAwait(false); + var orchestrationDescription = await GuardMatchingOrchestrationDescriptionAsync(name, version, inputParameter).ConfigureAwait(false); if (orchestrationDescription.CanBeScheduled == false) throw new InvalidOperationException("Orchestration description cannot be scheduled."); - var orchestrationInstance = await CreateScheduledOrchestrationInstanceAsync(parameter, runAt, orchestrationDescription).ConfigureAwait(false); + var orchestrationInstance = await CreateScheduledOrchestrationInstanceAsync(inputParameter, runAt, orchestrationDescription).ConfigureAwait(false); return orchestrationInstance.Id; } @@ -116,8 +119,8 @@ public async Task CancelScheduledOrchestrationInstanceAsync(OrchestrationInstanc private async Task GuardMatchingOrchestrationDescriptionAsync( string name, int version, - TParameter parameter) - where TParameter : class + TParameter inputParameter) + where TParameter : class { var orchestrationDescription = await _orchestrationRegister.GetOrDefaultAsync(name, version, isEnabled: true).ConfigureAwait(false); if (orchestrationDescription == null) @@ -125,21 +128,21 @@ private async Task GuardMatchingOrchestrationDescripti throw new InvalidOperationException($"No enabled orchestration description matches Name='{name}' and Version='{version}'."); } - var isValidParameterValue = await orchestrationDescription.ParameterDefinition.IsValidParameterValueAsync(parameter).ConfigureAwait(false); + var isValidParameterValue = await orchestrationDescription.ParameterDefinition.IsValidParameterValueAsync(inputParameter).ConfigureAwait(false); return isValidParameterValue == false ? throw new InvalidOperationException("Paramater value is not valid compared to registered parameter definition.") : orchestrationDescription; } private async Task CreateOrchestrationInstanceAsync( - TParameter parameter, + TParameter inputParameter, OrchestrationDescription orchestrationDescription) - where TParameter : class + where TParameter : class { var orchestrationInstance = new OrchestrationInstance( orchestrationDescription.Id, _clock); - orchestrationInstance.ParameterValue.SetFromInstance(parameter); + orchestrationInstance.ParameterValue.SetFromInstance(inputParameter); await _orchestrationInstanceRepository.AddAsync(orchestrationInstance).ConfigureAwait(false); await _unitOfWork.CommitAsync().ConfigureAwait(false); @@ -148,16 +151,16 @@ private async Task CreateOrchestrationInstanceAsync CreateScheduledOrchestrationInstanceAsync( - TParameter parameter, + TParameter inputParameter, Instant runAt, OrchestrationDescription orchestrationDescription) - where TParameter : class + where TParameter : class { var orchestrationInstance = new OrchestrationInstance( orchestrationDescription.Id, _clock, runAt); - orchestrationInstance.ParameterValue.SetFromInstance(parameter); + orchestrationInstance.ParameterValue.SetFromInstance(inputParameter); await _orchestrationInstanceRepository.AddAsync(orchestrationInstance).ConfigureAwait(false); await _unitOfWork.CommitAsync().ConfigureAwait(false); @@ -165,7 +168,9 @@ private async Task CreateScheduledOrchestrationInstanceAs return orchestrationInstance; } - private async Task RequestStartOfOrchestrationInstanceAsync(OrchestrationDescription orchestrationDescription, OrchestrationInstance orchestrationInstance) + private async Task RequestStartOfOrchestrationInstanceAsync( + OrchestrationDescription orchestrationDescription, + OrchestrationInstance orchestrationInstance) { await _durableClient .StartNewAsync( @@ -174,7 +179,7 @@ await _durableClient input: orchestrationInstance.ParameterValue.SerializedParameterValue) .ConfigureAwait(false); - orchestrationInstance.Lifecycle.TransitionToStartRequested(_clock); + orchestrationInstance.Lifecycle.TransitionToQueued(_clock); await _unitOfWork.CommitAsync().ConfigureAwait(false); } } diff --git a/source/ProcessManager.Core/Infrastructure/Orchestration/OrchestrationInstanceRepository.cs b/source/ProcessManager.Core/Infrastructure/Orchestration/OrchestrationInstanceRepository.cs index 3f7cfe4b14..72f24e0d5f 100644 --- a/source/ProcessManager.Core/Infrastructure/Orchestration/OrchestrationInstanceRepository.cs +++ b/source/ProcessManager.Core/Infrastructure/Orchestration/OrchestrationInstanceRepository.cs @@ -60,7 +60,9 @@ public async Task> SearchAsync( string name, int? version = default, OrchestrationInstanceLifecycleStates? lifecycleState = default, - OrchestrationInstanceTerminationStates? terminationState = default) + OrchestrationInstanceTerminationStates? terminationState = default, + Instant? startedAtOrLater = default, + Instant? terminatedAtOrEarlier = default) { ArgumentException.ThrowIfNullOrWhiteSpace(name); @@ -74,7 +76,9 @@ public async Task> SearchAsync( instance => instance.OrchestrationDescriptionId, (_, instance) => instance) .Where(x => lifecycleState == null || x.Lifecycle.State == lifecycleState) - .Where(x => terminationState == null || x.Lifecycle.TerminationState == terminationState); + .Where(x => terminationState == null || x.Lifecycle.TerminationState == terminationState) + .Where(x => startedAtOrLater == null || x.Lifecycle.StartedAt >= startedAtOrLater) + .Where(x => terminatedAtOrEarlier == null || x.Lifecycle.TerminatedAt <= terminatedAtOrEarlier); return await query.ToListAsync().ConfigureAwait(false); } diff --git a/source/ProcessManager.DatabaseMigration/Scripts/202410240915 Create OrchestrationInstance table.sql b/source/ProcessManager.DatabaseMigration/Scripts/202410240915 Create OrchestrationInstance table.sql index 933d97163a..a504491de3 100644 --- a/source/ProcessManager.DatabaseMigration/Scripts/202410240915 Create OrchestrationInstance table.sql +++ b/source/ProcessManager.DatabaseMigration/Scripts/202410240915 Create OrchestrationInstance table.sql @@ -7,7 +7,7 @@ [Lifecycle_TerminationState] INT NULL, [Lifecycle_CreatedAt] DATETIME2 NOT NULL, [Lifecycle_ScheduledToRunAt] DATETIME2 NULL, - [Lifecycle_StartRequestedAt] DATETIME2 NULL, + [Lifecycle_QueuedAt] DATETIME2 NULL, [Lifecycle_StartedAt] DATETIME2 NULL, [Lifecycle_TerminatedAt] DATETIME2 NULL, diff --git a/source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppCollectionFixture.cs b/source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppCollection.cs similarity index 84% rename from source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppCollectionFixture.cs rename to source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppCollection.cs index 151fc43d65..09212987b6 100644 --- a/source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppCollectionFixture.cs +++ b/source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppCollection.cs @@ -20,7 +20,7 @@ namespace Energinet.DataHub.ProcessManager.Orchestrations.Tests.Fixtures; /// xUnit documentation of collection fixtures: /// * https://xunit.net/docs/shared-context#collection-fixture /// -[CollectionDefinition(nameof(OrchestrationsAppCollectionFixture))] -public class OrchestrationsAppCollectionFixture : ICollectionFixture +[CollectionDefinition(nameof(OrchestrationsAppCollection))] +public class OrchestrationsAppCollection : ICollectionFixture { } diff --git a/source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppFixture.cs b/source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppFixture.cs index 36543691a0..dae44419fc 100644 --- a/source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppFixture.cs +++ b/source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppFixture.cs @@ -12,196 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using Energinet.DataHub.Core.FunctionApp.TestCommon.Azurite; -using Energinet.DataHub.Core.FunctionApp.TestCommon.Configuration; -using Energinet.DataHub.Core.FunctionApp.TestCommon.FunctionAppHost; -using Energinet.DataHub.Core.TestCommon.Diagnostics; -using Energinet.DataHub.ProcessManagement.Core.Infrastructure.Extensions.Options; using Energinet.DataHub.ProcessManager.Core.Tests.Fixtures; -using Xunit.Abstractions; namespace Energinet.DataHub.ProcessManager.Orchestrations.Tests.Fixtures; /// -/// Support testing ProcessManager app. +/// Support testing Process Manager Orchestrations app using default fixture configuration. /// -public class OrchestrationsAppFixture : IAsyncLifetime +public class OrchestrationsAppFixture + : OrchestrationsAppFixtureBase { - /// - /// Durable Functions Task Hub Name - /// See naming constraints: https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-task-hubs?tabs=csharp#task-hub-names - /// - private const string TaskHubName = "OrchestrationsTest01"; - public OrchestrationsAppFixture() + : base( + databaseManager: new ProcessManagerDatabaseManager("OrchestrationsTest"), + taskHubName: "OrchestrationsTest01", + port: 8000) { - TestLogger = new TestDiagnosticsLogger(); - IntegrationTestConfiguration = new IntegrationTestConfiguration(); - - AzuriteManager = new AzuriteManager(useOAuth: true); - DatabaseManager = new ProcessManagerDatabaseManager("OrchestrationsTest"); - - HostConfigurationBuilder = new FunctionAppHostConfigurationBuilder(); - } - - public ITestDiagnosticsLogger TestLogger { get; } - - public ProcessManagerDatabaseManager DatabaseManager { get; } - - [NotNull] - public FunctionAppHostManager? AppHostManager { get; private set; } - - private IntegrationTestConfiguration IntegrationTestConfiguration { get; } - - private AzuriteManager AzuriteManager { get; } - - private FunctionAppHostConfigurationBuilder HostConfigurationBuilder { get; } - - public async Task InitializeAsync() - { - // Clean up old Azurite storage - CleanupAzuriteStorage(); - - // Storage emulator - AzuriteManager.StartAzurite(); - - // Database - await DatabaseManager.CreateDatabaseAsync(); - - // Prepare host settings - var port = 8000; - var appHostSettings = CreateAppHostSettings("ProcessManager.Orchestrations", ref port); - - // Create and start host - AppHostManager = new FunctionAppHostManager(appHostSettings, TestLogger); - StartHost(AppHostManager); - } - - public async Task DisposeAsync() - { - AppHostManager.Dispose(); - AzuriteManager.Dispose(); - await DatabaseManager.DeleteDatabaseAsync(); - } - - /// - /// Use this method to attach to the host logging pipeline. - /// While attached, any entries written to host log pipeline will also be logged to xUnit test output. - /// It is important that it is only attached while a test i active. Hence, it should be attached in - /// the test class constructor; and detached in the test class Dispose method (using 'null'). - /// - /// If a xUnit test is active, this should be the instance of xUnit's ; - /// otherwise it should be 'null'. - public void SetTestOutputHelper(ITestOutputHelper testOutputHelper) - { - TestLogger.TestOutputHelper = testOutputHelper; - } - - private static void StartHost(FunctionAppHostManager hostManager) - { - IEnumerable hostStartupLog; - - try - { - hostManager.StartHost(); - } - catch (Exception) - { - // Function App Host failed during startup. - // Exception has already been logged by host manager. - hostStartupLog = hostManager.GetHostLogSnapshot(); - - if (Debugger.IsAttached) - Debugger.Break(); - - // Rethrow - throw; - } - - // Function App Host started. - hostStartupLog = hostManager.GetHostLogSnapshot(); - } - - private static string GetBuildConfiguration() - { -#if DEBUG - return "Debug"; -#else - return "Release"; -#endif - } - - private FunctionAppHostSettings CreateAppHostSettings(string csprojName, ref int port) - { - var buildConfiguration = GetBuildConfiguration(); - - var appHostSettings = HostConfigurationBuilder.CreateFunctionAppHostSettings(); - appHostSettings.FunctionApplicationPath = $"..\\..\\..\\..\\{csprojName}\\bin\\{buildConfiguration}\\net8.0"; - appHostSettings.Port = ++port; - - // It seems the host + worker is not ready if we use the default startup log message, so we override it here - appHostSettings.HostStartedEvent = "Host lock lease acquired"; - - appHostSettings.ProcessEnvironmentVariables.Add( - "FUNCTIONS_WORKER_RUNTIME", - "dotnet-isolated"); - appHostSettings.ProcessEnvironmentVariables.Add( - "AzureWebJobsStorage", - AzuriteManager.FullConnectionString); - appHostSettings.ProcessEnvironmentVariables.Add( - "APPLICATIONINSIGHTS_CONNECTION_STRING", - IntegrationTestConfiguration.ApplicationInsightsConnectionString); - - // ProcessManager - // => Task Hub - appHostSettings.ProcessEnvironmentVariables.Add( - nameof(ProcessManagerTaskHubOptions.ProcessManagerStorageConnectionString), - AzuriteManager.FullConnectionString); - appHostSettings.ProcessEnvironmentVariables.Add( - nameof(ProcessManagerTaskHubOptions.ProcessManagerTaskHubName), - TaskHubName); - // => Database - appHostSettings.ProcessEnvironmentVariables.Add( - $"{ProcessManagerOptions.SectionName}__{nameof(ProcessManagerOptions.SqlDatabaseConnectionString)}", - DatabaseManager.ConnectionString); - - return appHostSettings; - } - - /// - /// Cleanup Azurite storage to avoid situations where Durable Functions - /// would otherwise continue working on old orchestrations that e.g. failed in - /// previous runs. - /// - private void CleanupAzuriteStorage() - { - if (Directory.Exists("__blobstorage__")) - Directory.Delete("__blobstorage__", true); - - if (Directory.Exists("__queuestorage__")) - Directory.Delete("__queuestorage__", true); - - if (Directory.Exists("__tablestorage__")) - Directory.Delete("__tablestorage__", true); - - if (File.Exists("__azurite_db_blob__.json")) - File.Delete("__azurite_db_blob__.json"); - - if (File.Exists("__azurite_db_blob_extent__.json")) - File.Delete("__azurite_db_blob_extent__.json"); - - if (File.Exists("__azurite_db_queue__.json")) - File.Delete("__azurite_db_queue__.json"); - - if (File.Exists("__azurite_db_queue_extent__.json")) - File.Delete("__azurite_db_queue_extent__.json"); - - if (File.Exists("__azurite_db_table__.json")) - File.Delete("__azurite_db_table__.json"); - - if (File.Exists("__azurite_db_table_extent__.json")) - File.Delete("__azurite_db_table_extent__.json"); } } diff --git a/source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppFixtureBase.cs b/source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppFixtureBase.cs new file mode 100644 index 0000000000..6f783da360 --- /dev/null +++ b/source/ProcessManager.Orchestrations.Tests/Fixtures/OrchestrationsAppFixtureBase.cs @@ -0,0 +1,218 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Energinet.DataHub.Core.FunctionApp.TestCommon.Azurite; +using Energinet.DataHub.Core.FunctionApp.TestCommon.Configuration; +using Energinet.DataHub.Core.FunctionApp.TestCommon.FunctionAppHost; +using Energinet.DataHub.Core.TestCommon.Diagnostics; +using Energinet.DataHub.ProcessManagement.Core.Infrastructure.Extensions.Options; +using Energinet.DataHub.ProcessManager.Core.Tests.Fixtures; +using Xunit.Abstractions; + +namespace Energinet.DataHub.ProcessManager.Orchestrations.Tests.Fixtures; + +/// +/// Support testing Process Manager Orchestrations app and specifying configuration using inheritance. +/// This allows us to use multiple fixtures and coordinate their configuration. +/// +public abstract class OrchestrationsAppFixtureBase : IAsyncLifetime +{ + public OrchestrationsAppFixtureBase( + ProcessManagerDatabaseManager databaseManager, + string taskHubName, + int port) + { + DatabaseManager = databaseManager + ?? throw new ArgumentNullException(nameof(databaseManager)); + TaskHubName = string.IsNullOrWhiteSpace(taskHubName) + ? throw new ArgumentException("Cannot be null or whitespace.", nameof(taskHubName)) + : taskHubName; + Port = port; + + TestLogger = new TestDiagnosticsLogger(); + IntegrationTestConfiguration = new IntegrationTestConfiguration(); + + AzuriteManager = new AzuriteManager(useOAuth: true); + + HostConfigurationBuilder = new FunctionAppHostConfigurationBuilder(); + } + + public ITestDiagnosticsLogger TestLogger { get; } + + public ProcessManagerDatabaseManager DatabaseManager { get; } + + [NotNull] + public FunctionAppHostManager? AppHostManager { get; private set; } + + /// + /// Durable Functions Task Hub Name + /// See naming constraints: https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-task-hubs?tabs=csharp#task-hub-names + /// + private string TaskHubName { get; } + + private int Port { get; } + + private IntegrationTestConfiguration IntegrationTestConfiguration { get; } + + private AzuriteManager AzuriteManager { get; } + + private FunctionAppHostConfigurationBuilder HostConfigurationBuilder { get; } + + public async Task InitializeAsync() + { + // Clean up old Azurite storage + CleanupAzuriteStorage(); + + // Storage emulator + AzuriteManager.StartAzurite(); + + // Database + await DatabaseManager.CreateDatabaseAsync(); + + // Prepare host settings + var appHostSettings = CreateAppHostSettings("ProcessManager.Orchestrations"); + + // Create and start host + AppHostManager = new FunctionAppHostManager(appHostSettings, TestLogger); + StartHost(AppHostManager); + } + + public async Task DisposeAsync() + { + AppHostManager.Dispose(); + AzuriteManager.Dispose(); + await DatabaseManager.DeleteDatabaseAsync(); + } + + /// + /// Use this method to attach to the host logging pipeline. + /// While attached, any entries written to host log pipeline will also be logged to xUnit test output. + /// It is important that it is only attached while a test i active. Hence, it should be attached in + /// the test class constructor; and detached in the test class Dispose method (using 'null'). + /// + /// If a xUnit test is active, this should be the instance of xUnit's ; + /// otherwise it should be 'null'. + public void SetTestOutputHelper(ITestOutputHelper testOutputHelper) + { + TestLogger.TestOutputHelper = testOutputHelper; + } + + private static void StartHost(FunctionAppHostManager hostManager) + { + IEnumerable hostStartupLog; + + try + { + hostManager.StartHost(); + } + catch (Exception) + { + // Function App Host failed during startup. + // Exception has already been logged by host manager. + hostStartupLog = hostManager.GetHostLogSnapshot(); + + if (Debugger.IsAttached) + Debugger.Break(); + + // Rethrow + throw; + } + + // Function App Host started. + hostStartupLog = hostManager.GetHostLogSnapshot(); + } + + private static string GetBuildConfiguration() + { +#if DEBUG + return "Debug"; +#else + return "Release"; +#endif + } + + private FunctionAppHostSettings CreateAppHostSettings(string csprojName) + { + var buildConfiguration = GetBuildConfiguration(); + + var appHostSettings = HostConfigurationBuilder.CreateFunctionAppHostSettings(); + appHostSettings.FunctionApplicationPath = $"..\\..\\..\\..\\{csprojName}\\bin\\{buildConfiguration}\\net8.0"; + appHostSettings.Port = Port; + + // It seems the host + worker is not ready if we use the default startup log message, so we override it here + appHostSettings.HostStartedEvent = "Host lock lease acquired"; + + appHostSettings.ProcessEnvironmentVariables.Add( + "FUNCTIONS_WORKER_RUNTIME", + "dotnet-isolated"); + appHostSettings.ProcessEnvironmentVariables.Add( + "AzureWebJobsStorage", + AzuriteManager.FullConnectionString); + appHostSettings.ProcessEnvironmentVariables.Add( + "APPLICATIONINSIGHTS_CONNECTION_STRING", + IntegrationTestConfiguration.ApplicationInsightsConnectionString); + + // ProcessManager + // => Task Hub + appHostSettings.ProcessEnvironmentVariables.Add( + nameof(ProcessManagerTaskHubOptions.ProcessManagerStorageConnectionString), + AzuriteManager.FullConnectionString); + appHostSettings.ProcessEnvironmentVariables.Add( + nameof(ProcessManagerTaskHubOptions.ProcessManagerTaskHubName), + TaskHubName); + // => Database + appHostSettings.ProcessEnvironmentVariables.Add( + $"{ProcessManagerOptions.SectionName}__{nameof(ProcessManagerOptions.SqlDatabaseConnectionString)}", + DatabaseManager.ConnectionString); + + return appHostSettings; + } + + /// + /// Cleanup Azurite storage to avoid situations where Durable Functions + /// would otherwise continue working on old orchestrations that e.g. failed in + /// previous runs. + /// + private void CleanupAzuriteStorage() + { + if (Directory.Exists("__blobstorage__")) + Directory.Delete("__blobstorage__", true); + + if (Directory.Exists("__queuestorage__")) + Directory.Delete("__queuestorage__", true); + + if (Directory.Exists("__tablestorage__")) + Directory.Delete("__tablestorage__", true); + + if (File.Exists("__azurite_db_blob__.json")) + File.Delete("__azurite_db_blob__.json"); + + if (File.Exists("__azurite_db_blob_extent__.json")) + File.Delete("__azurite_db_blob_extent__.json"); + + if (File.Exists("__azurite_db_queue__.json")) + File.Delete("__azurite_db_queue__.json"); + + if (File.Exists("__azurite_db_queue_extent__.json")) + File.Delete("__azurite_db_queue_extent__.json"); + + if (File.Exists("__azurite_db_table__.json")) + File.Delete("__azurite_db_table__.json"); + + if (File.Exists("__azurite_db_table_extent__.json")) + File.Delete("__azurite_db_table_extent__.json"); + } +} diff --git a/source/ProcessManager.Orchestrations.Tests/Integration/Monitor/HealthCheckEndpointTests.cs b/source/ProcessManager.Orchestrations.Tests/Integration/Monitor/HealthCheckEndpointTests.cs index 6cc3855a5a..4d6a620119 100644 --- a/source/ProcessManager.Orchestrations.Tests/Integration/Monitor/HealthCheckEndpointTests.cs +++ b/source/ProcessManager.Orchestrations.Tests/Integration/Monitor/HealthCheckEndpointTests.cs @@ -23,7 +23,7 @@ namespace Energinet.DataHub.ProcessManager.Orchestrations.Tests.Integration.Moni /// /// Tests verifying the configuration and behaviour of Health Checks. /// -[Collection(nameof(OrchestrationsAppCollectionFixture))] +[Collection(nameof(OrchestrationsAppCollection))] public class HealthCheckEndpointTests : IAsyncLifetime { public HealthCheckEndpointTests(OrchestrationsAppFixture fixture, ITestOutputHelper testOutputHelper) diff --git a/source/ProcessManager.Orchestrations/ProcessManager.Orchestrations.csproj b/source/ProcessManager.Orchestrations/ProcessManager.Orchestrations.csproj index c7f5219e72..3d188c74ed 100644 --- a/source/ProcessManager.Orchestrations/ProcessManager.Orchestrations.csproj +++ b/source/ProcessManager.Orchestrations/ProcessManager.Orchestrations.csproj @@ -27,8 +27,13 @@ + - - + + + + + + \ No newline at end of file diff --git a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023CalculationStepStartActivityV1.cs b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023CalculationStepStartActivityV1.cs index c230fe7dbf..b6f8dda177 100644 --- a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023CalculationStepStartActivityV1.cs +++ b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023CalculationStepStartActivityV1.cs @@ -41,6 +41,6 @@ public async Task Run( await UnitOfWork.CommitAsync().ConfigureAwait(false); // TODO: For demo purposes; remove when done - await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false); } } diff --git a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023CalculationStepTerminateActivityV1.cs b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023CalculationStepTerminateActivityV1.cs index 989ac34886..05cd3f9481 100644 --- a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023CalculationStepTerminateActivityV1.cs +++ b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023CalculationStepTerminateActivityV1.cs @@ -41,6 +41,6 @@ public async Task Run( await UnitOfWork.CommitAsync().ConfigureAwait(false); // TODO: For demo purposes; remove when done - await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); } } diff --git a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023EnqueueMessagesStepStartActivityV1.cs b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023EnqueueMessagesStepStartActivityV1.cs index 06b1296e53..dd3ce49f2e 100644 --- a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023EnqueueMessagesStepStartActivityV1.cs +++ b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023EnqueueMessagesStepStartActivityV1.cs @@ -41,6 +41,6 @@ public async Task Run( await UnitOfWork.CommitAsync().ConfigureAwait(false); // TODO: For demo purposes; remove when done - await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false); } } diff --git a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023EnqueueMessagesStepTerminateActivityV1.cs b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023EnqueueMessagesStepTerminateActivityV1.cs index 5a9bdbde65..32d74ec419 100644 --- a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023EnqueueMessagesStepTerminateActivityV1.cs +++ b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023EnqueueMessagesStepTerminateActivityV1.cs @@ -41,6 +41,6 @@ public async Task Run( await UnitOfWork.CommitAsync().ConfigureAwait(false); // TODO: For demo purposes; remove when done - await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); } } diff --git a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023OrchestrationInitializeActivityV1.cs b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023OrchestrationInitializeActivityV1.cs index 98795072b4..27b99d42bc 100644 --- a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023OrchestrationInitializeActivityV1.cs +++ b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023OrchestrationInitializeActivityV1.cs @@ -40,24 +40,10 @@ public async Task Run( .GetAsync(new OrchestrationInstanceId(orchestrationInstanceId)) .ConfigureAwait(false); - // TODO: For demo purposes we create the steps here; will be refactored to either: - // - describing the steps as part of the orchestration description - // - describing the steps in the specific BRS handler located in the API - orchestrationInstance.Steps.Add(new OrchestrationStep( - orchestrationInstance.Id, - Clock, - "Beregning", - NotifyAggregatedMeasureDataOrchestrationV1.CalculationStepIndex)); - orchestrationInstance.Steps.Add(new OrchestrationStep( - orchestrationInstance.Id, - Clock, - "Besked dannelse", - NotifyAggregatedMeasureDataOrchestrationV1.EnqueueMessagesStepIndex)); - orchestrationInstance.Lifecycle.TransitionToRunning(Clock); await UnitOfWork.CommitAsync().ConfigureAwait(false); // TODO: For demo purposes; remove when done - await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); } } diff --git a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023OrchestrationTerminateActivityV1.cs b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023OrchestrationTerminateActivityV1.cs index 8240dab1d9..c10402765c 100644 --- a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023OrchestrationTerminateActivityV1.cs +++ b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Activities/Brs023OrchestrationTerminateActivityV1.cs @@ -44,6 +44,6 @@ public async Task Run( await UnitOfWork.CommitAsync().ConfigureAwait(false); // TODO: For demo purposes; remove when done - await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); } } diff --git a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/NotifyAggregatedMeasureDataOrchestrationTriggerV1.cs b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/NotifyAggregatedMeasureDataOrchestrationTriggerV1.cs new file mode 100644 index 0000000000..060a760ffc --- /dev/null +++ b/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/NotifyAggregatedMeasureDataOrchestrationTriggerV1.cs @@ -0,0 +1,83 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Energinet.DataHub.ProcessManagement.Core.Application; +using Energinet.DataHub.ProcessManagement.Core.Domain.OrchestrationInstance; +using Energinet.DataHub.ProcessManager.Api.Model; +using Energinet.DataHub.ProcessManager.Orchestrations.Processes.BRS_023_027.V1.Model; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using NodaTime; +using NodaTime.Extensions; +using FromBodyAttribute = Microsoft.Azure.Functions.Worker.Http.FromBodyAttribute; + +namespace Energinet.DataHub.ProcessManager.Orchestrations.Processes.BRS_023_027.V1; + +internal class NotifyAggregatedMeasureDataOrchestrationTriggerV1( + ILogger logger, + IClock clock, + IOrchestrationInstanceRepository repository, + IUnitOfWork unitOfWork, + IOrchestrationInstanceManager manager) +{ + private readonly ILogger _logger = logger; + private readonly IClock _clock = clock; + private readonly IOrchestrationInstanceRepository _repository = repository; + private readonly IUnitOfWork _unitOfWork = unitOfWork; + private readonly IOrchestrationInstanceManager _manager = manager; + + /// + /// Schedule a BRS-023 or BRS-027 calculation and return its id. + /// + [Function(nameof(NotifyAggregatedMeasureDataOrchestrationTriggerV1))] + public async Task Run( + [HttpTrigger( + AuthorizationLevel.Anonymous, + "post", + Route = "processmanager/orchestrationinstance/brs_023_027/1")] + HttpRequest httpRequest, + [FromBody] + ScheduleOrchestrationInstanceDto dto, + FunctionContext executionContext) + { + // TODO: Server-side validation => Validate "period" is midnight values when given "timezone" + var orchestrationInstanceId = await _manager + .ScheduleNewOrchestrationInstanceAsync( + name: "BRS_023_027", + version: 1, + inputParameter: dto.InputParameter, + runAt: dto.RunAt.ToInstant()) + .ConfigureAwait(false); + + // TODO: + // For demo purposes we create the steps here. + // Will be refactored to describing the steps as part of the orchestration description + var orchestrationInstance = await _repository.GetAsync(orchestrationInstanceId); + orchestrationInstance.Steps.Add(new OrchestrationStep( + orchestrationInstance.Id, + _clock, + "Beregning", + sequence: 0)); + orchestrationInstance.Steps.Add(new OrchestrationStep( + orchestrationInstance.Id, + _clock, + "Besked dannelse", + sequence: 1)); + await _unitOfWork.CommitAsync(); + + return new OkObjectResult(orchestrationInstanceId.Value); + } +} diff --git a/source/ProcessManager.Orchestrations/local.settings.sample.json b/source/ProcessManager.Orchestrations/local.settings.sample.json index f28f501a69..91fc3a005f 100644 --- a/source/ProcessManager.Orchestrations/local.settings.sample.json +++ b/source/ProcessManager.Orchestrations/local.settings.sample.json @@ -7,6 +7,7 @@ // - Settings names must match configuration in 'host.json' // - 'ProcessManager' (host) and 'ProcessManager.Orchestrations' (host) must use the same Task Hub "ProcessManagerStorageConnectionString": "UseDevelopmentStorage=true", - "ProcessManagerTaskHubName": "Orchestrations01" + "ProcessManagerTaskHubName": "Orchestrations01", + "ProcessManager__SqlDatabaseConnectionString": "" } } \ No newline at end of file diff --git a/source/ProcessManager.Tests/Fixtures/ProcessManagerAppCollectionFixture.cs b/source/ProcessManager.Tests/Fixtures/ProcessManagerAppCollection.cs similarity index 84% rename from source/ProcessManager.Tests/Fixtures/ProcessManagerAppCollectionFixture.cs rename to source/ProcessManager.Tests/Fixtures/ProcessManagerAppCollection.cs index 46667a1164..35bc1d056a 100644 --- a/source/ProcessManager.Tests/Fixtures/ProcessManagerAppCollectionFixture.cs +++ b/source/ProcessManager.Tests/Fixtures/ProcessManagerAppCollection.cs @@ -20,7 +20,7 @@ namespace Energinet.DataHub.ProcessManager.Tests.Fixtures; /// xUnit documentation of collection fixtures: /// * https://xunit.net/docs/shared-context#collection-fixture /// -[CollectionDefinition(nameof(ProcessManagerAppCollectionFixture))] -public class ProcessManagerAppCollectionFixture : ICollectionFixture +[CollectionDefinition(nameof(ProcessManagerAppCollection))] +public class ProcessManagerAppCollection : ICollectionFixture { } diff --git a/source/ProcessManager.Tests/Fixtures/ProcessManagerAppFixture.cs b/source/ProcessManager.Tests/Fixtures/ProcessManagerAppFixture.cs index ea6c566878..b91167f1fb 100644 --- a/source/ProcessManager.Tests/Fixtures/ProcessManagerAppFixture.cs +++ b/source/ProcessManager.Tests/Fixtures/ProcessManagerAppFixture.cs @@ -12,202 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using Energinet.DataHub.Core.FunctionApp.TestCommon.Azurite; -using Energinet.DataHub.Core.FunctionApp.TestCommon.Configuration; -using Energinet.DataHub.Core.FunctionApp.TestCommon.FunctionAppHost; -using Energinet.DataHub.Core.TestCommon.Diagnostics; -using Energinet.DataHub.ProcessManagement.Core.Infrastructure.Extensions.Options; using Energinet.DataHub.ProcessManager.Core.Tests.Fixtures; -using Energinet.DataHub.ProcessManager.Scheduler; -using Xunit.Abstractions; namespace Energinet.DataHub.ProcessManager.Tests.Fixtures; /// -/// Support testing ProcessManager app. +/// Support testing Process Manager Orchestrations app using default fixture configuration. /// -public class ProcessManagerAppFixture : IAsyncLifetime +public class ProcessManagerAppFixture + : ProcessManagerAppFixtureBase { - /// - /// Durable Functions Task Hub Name - /// See naming constraints: https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-task-hubs?tabs=csharp#task-hub-names - /// - private const string TaskHubName = "ProcessManagerTest01"; - public ProcessManagerAppFixture() + : base( + databaseManager: new ProcessManagerDatabaseManager("ProcessManagerTest"), + taskHubName: "ProcessManagerTest01", + port: 8000) { - TestLogger = new TestDiagnosticsLogger(); - IntegrationTestConfiguration = new IntegrationTestConfiguration(); - - AzuriteManager = new AzuriteManager(useOAuth: true); - DatabaseManager = new ProcessManagerDatabaseManager("ProcessManagerTest"); - - HostConfigurationBuilder = new FunctionAppHostConfigurationBuilder(); - } - - public ITestDiagnosticsLogger TestLogger { get; } - - public ProcessManagerDatabaseManager DatabaseManager { get; } - - [NotNull] - public FunctionAppHostManager? AppHostManager { get; private set; } - - private IntegrationTestConfiguration IntegrationTestConfiguration { get; } - - private AzuriteManager AzuriteManager { get; } - - private FunctionAppHostConfigurationBuilder HostConfigurationBuilder { get; } - - public async Task InitializeAsync() - { - // Clean up old Azurite storage - CleanupAzuriteStorage(); - - // Storage emulator - AzuriteManager.StartAzurite(); - - // Database - await DatabaseManager.CreateDatabaseAsync(); - - // Prepare host settings - var port = 8000; - var appHostSettings = CreateAppHostSettings("ProcessManager", ref port); - - // Create and start host - AppHostManager = new FunctionAppHostManager(appHostSettings, TestLogger); - StartHost(AppHostManager); - } - - public async Task DisposeAsync() - { - AppHostManager.Dispose(); - AzuriteManager.Dispose(); - await DatabaseManager.DeleteDatabaseAsync(); - } - - /// - /// Use this method to attach to the host logging pipeline. - /// While attached, any entries written to host log pipeline will also be logged to xUnit test output. - /// It is important that it is only attached while a test i active. Hence, it should be attached in - /// the test class constructor; and detached in the test class Dispose method (using 'null'). - /// - /// If a xUnit test is active, this should be the instance of xUnit's ; - /// otherwise it should be 'null'. - public void SetTestOutputHelper(ITestOutputHelper testOutputHelper) - { - TestLogger.TestOutputHelper = testOutputHelper; - } - - private static void StartHost(FunctionAppHostManager hostManager) - { - IEnumerable hostStartupLog; - - try - { - hostManager.StartHost(); - } - catch (Exception) - { - // Function App Host failed during startup. - // Exception has already been logged by host manager. - hostStartupLog = hostManager.GetHostLogSnapshot(); - - if (Debugger.IsAttached) - Debugger.Break(); - - // Rethrow - throw; - } - - // Function App Host started. - hostStartupLog = hostManager.GetHostLogSnapshot(); - } - - private static string GetBuildConfiguration() - { -#if DEBUG - return "Debug"; -#else - return "Release"; -#endif - } - - private FunctionAppHostSettings CreateAppHostSettings(string csprojName, ref int port) - { - var buildConfiguration = GetBuildConfiguration(); - - var appHostSettings = HostConfigurationBuilder.CreateFunctionAppHostSettings(); - appHostSettings.FunctionApplicationPath = $"..\\..\\..\\..\\{csprojName}\\bin\\{buildConfiguration}\\net8.0"; - appHostSettings.Port = ++port; - - // It seems the host + worker is not ready if we use the default startup log message, so we override it here - appHostSettings.HostStartedEvent = "Host lock lease acquired"; - - appHostSettings.ProcessEnvironmentVariables.Add( - "FUNCTIONS_WORKER_RUNTIME", - "dotnet-isolated"); - appHostSettings.ProcessEnvironmentVariables.Add( - "AzureWebJobsStorage", - AzuriteManager.FullConnectionString); - appHostSettings.ProcessEnvironmentVariables.Add( - "APPLICATIONINSIGHTS_CONNECTION_STRING", - IntegrationTestConfiguration.ApplicationInsightsConnectionString); - - // ProcessManager - // => Task Hub - appHostSettings.ProcessEnvironmentVariables.Add( - nameof(ProcessManagerTaskHubOptions.ProcessManagerStorageConnectionString), - AzuriteManager.FullConnectionString); - appHostSettings.ProcessEnvironmentVariables.Add( - nameof(ProcessManagerTaskHubOptions.ProcessManagerTaskHubName), - TaskHubName); - // => Database - appHostSettings.ProcessEnvironmentVariables.Add( - $"{ProcessManagerOptions.SectionName}__{nameof(ProcessManagerOptions.SqlDatabaseConnectionString)}", - DatabaseManager.ConnectionString); - - // Disable timer trigger (should be manually triggered in tests) - appHostSettings.ProcessEnvironmentVariables.Add( - $"AzureWebJobs.{nameof(SchedulerTrigger.StartScheduledOrchestrationInstances)}.Disabled", - "true"); - - return appHostSettings; - } - - /// - /// Cleanup Azurite storage to avoid situations where Durable Functions - /// would otherwise continue working on old orchestrations that e.g. failed in - /// previous runs. - /// - private void CleanupAzuriteStorage() - { - if (Directory.Exists("__blobstorage__")) - Directory.Delete("__blobstorage__", true); - - if (Directory.Exists("__queuestorage__")) - Directory.Delete("__queuestorage__", true); - - if (Directory.Exists("__tablestorage__")) - Directory.Delete("__tablestorage__", true); - - if (File.Exists("__azurite_db_blob__.json")) - File.Delete("__azurite_db_blob__.json"); - - if (File.Exists("__azurite_db_blob_extent__.json")) - File.Delete("__azurite_db_blob_extent__.json"); - - if (File.Exists("__azurite_db_queue__.json")) - File.Delete("__azurite_db_queue__.json"); - - if (File.Exists("__azurite_db_queue_extent__.json")) - File.Delete("__azurite_db_queue_extent__.json"); - - if (File.Exists("__azurite_db_table__.json")) - File.Delete("__azurite_db_table__.json"); - - if (File.Exists("__azurite_db_table_extent__.json")) - File.Delete("__azurite_db_table_extent__.json"); } } diff --git a/source/ProcessManager.Tests/Fixtures/ProcessManagerAppFixtureBase.cs b/source/ProcessManager.Tests/Fixtures/ProcessManagerAppFixtureBase.cs new file mode 100644 index 0000000000..5f15985bcb --- /dev/null +++ b/source/ProcessManager.Tests/Fixtures/ProcessManagerAppFixtureBase.cs @@ -0,0 +1,223 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Energinet.DataHub.Core.FunctionApp.TestCommon.Azurite; +using Energinet.DataHub.Core.FunctionApp.TestCommon.Configuration; +using Energinet.DataHub.Core.FunctionApp.TestCommon.FunctionAppHost; +using Energinet.DataHub.Core.TestCommon.Diagnostics; +using Energinet.DataHub.ProcessManagement.Core.Infrastructure.Extensions.Options; +using Energinet.DataHub.ProcessManager.Core.Tests.Fixtures; +using Xunit.Abstractions; + +namespace Energinet.DataHub.ProcessManager.Tests.Fixtures; + +/// +/// Support testing Process Manager app and specifying configuration using inheritance. +/// This allows us to use multiple fixtures and coordinate their configuration. +/// +public abstract class ProcessManagerAppFixtureBase : IAsyncLifetime +{ + public ProcessManagerAppFixtureBase( + ProcessManagerDatabaseManager databaseManager, + string taskHubName, + int port) + { + DatabaseManager = databaseManager + ?? throw new ArgumentNullException(nameof(databaseManager)); + TaskHubName = string.IsNullOrWhiteSpace(taskHubName) + ? throw new ArgumentException("Cannot be null or whitespace.", nameof(taskHubName)) + : taskHubName; + Port = port; + + TestLogger = new TestDiagnosticsLogger(); + IntegrationTestConfiguration = new IntegrationTestConfiguration(); + + AzuriteManager = new AzuriteManager(useOAuth: true); + + HostConfigurationBuilder = new FunctionAppHostConfigurationBuilder(); + } + + public ITestDiagnosticsLogger TestLogger { get; } + + public ProcessManagerDatabaseManager DatabaseManager { get; } + + [NotNull] + public FunctionAppHostManager? AppHostManager { get; private set; } + + /// + /// Durable Functions Task Hub Name + /// See naming constraints: https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-task-hubs?tabs=csharp#task-hub-names + /// + private string TaskHubName { get; } + + private int Port { get; } + + private IntegrationTestConfiguration IntegrationTestConfiguration { get; } + + private AzuriteManager AzuriteManager { get; } + + private FunctionAppHostConfigurationBuilder HostConfigurationBuilder { get; } + + public async Task InitializeAsync() + { + // Clean up old Azurite storage + CleanupAzuriteStorage(); + + // Storage emulator + AzuriteManager.StartAzurite(); + + // Database + await DatabaseManager.CreateDatabaseAsync(); + + // Prepare host settings + var appHostSettings = CreateAppHostSettings("ProcessManager"); + + // Create and start host + AppHostManager = new FunctionAppHostManager(appHostSettings, TestLogger); + StartHost(AppHostManager); + } + + public async Task DisposeAsync() + { + AppHostManager.Dispose(); + AzuriteManager.Dispose(); + await DatabaseManager.DeleteDatabaseAsync(); + } + + /// + /// Use this method to attach to the host logging pipeline. + /// While attached, any entries written to host log pipeline will also be logged to xUnit test output. + /// It is important that it is only attached while a test i active. Hence, it should be attached in + /// the test class constructor; and detached in the test class Dispose method (using 'null'). + /// + /// If a xUnit test is active, this should be the instance of xUnit's ; + /// otherwise it should be 'null'. + public void SetTestOutputHelper(ITestOutputHelper testOutputHelper) + { + TestLogger.TestOutputHelper = testOutputHelper; + } + + private static void StartHost(FunctionAppHostManager hostManager) + { + IEnumerable hostStartupLog; + + try + { + hostManager.StartHost(); + } + catch (Exception) + { + // Function App Host failed during startup. + // Exception has already been logged by host manager. + hostStartupLog = hostManager.GetHostLogSnapshot(); + + if (Debugger.IsAttached) + Debugger.Break(); + + // Rethrow + throw; + } + + // Function App Host started. + hostStartupLog = hostManager.GetHostLogSnapshot(); + } + + private static string GetBuildConfiguration() + { +#if DEBUG + return "Debug"; +#else + return "Release"; +#endif + } + + private FunctionAppHostSettings CreateAppHostSettings(string csprojName) + { + var buildConfiguration = GetBuildConfiguration(); + + var appHostSettings = HostConfigurationBuilder.CreateFunctionAppHostSettings(); + appHostSettings.FunctionApplicationPath = $"..\\..\\..\\..\\{csprojName}\\bin\\{buildConfiguration}\\net8.0"; + appHostSettings.Port = Port; + + // It seems the host + worker is not ready if we use the default startup log message, so we override it here + appHostSettings.HostStartedEvent = "Host lock lease acquired"; + + appHostSettings.ProcessEnvironmentVariables.Add( + "FUNCTIONS_WORKER_RUNTIME", + "dotnet-isolated"); + appHostSettings.ProcessEnvironmentVariables.Add( + "AzureWebJobsStorage", + AzuriteManager.FullConnectionString); + appHostSettings.ProcessEnvironmentVariables.Add( + "APPLICATIONINSIGHTS_CONNECTION_STRING", + IntegrationTestConfiguration.ApplicationInsightsConnectionString); + + // ProcessManager + // => Task Hub + appHostSettings.ProcessEnvironmentVariables.Add( + nameof(ProcessManagerTaskHubOptions.ProcessManagerStorageConnectionString), + AzuriteManager.FullConnectionString); + appHostSettings.ProcessEnvironmentVariables.Add( + nameof(ProcessManagerTaskHubOptions.ProcessManagerTaskHubName), + TaskHubName); + // => Database + appHostSettings.ProcessEnvironmentVariables.Add( + $"{ProcessManagerOptions.SectionName}__{nameof(ProcessManagerOptions.SqlDatabaseConnectionString)}", + DatabaseManager.ConnectionString); + + // Disable timer trigger (should be manually triggered in tests) + appHostSettings.ProcessEnvironmentVariables.Add( + $"AzureWebJobs.StartScheduledOrchestrationInstances.Disabled", + "true"); + + return appHostSettings; + } + + /// + /// Cleanup Azurite storage to avoid situations where Durable Functions + /// would otherwise continue working on old orchestrations that e.g. failed in + /// previous runs. + /// + private void CleanupAzuriteStorage() + { + if (Directory.Exists("__blobstorage__")) + Directory.Delete("__blobstorage__", true); + + if (Directory.Exists("__queuestorage__")) + Directory.Delete("__queuestorage__", true); + + if (Directory.Exists("__tablestorage__")) + Directory.Delete("__tablestorage__", true); + + if (File.Exists("__azurite_db_blob__.json")) + File.Delete("__azurite_db_blob__.json"); + + if (File.Exists("__azurite_db_blob_extent__.json")) + File.Delete("__azurite_db_blob_extent__.json"); + + if (File.Exists("__azurite_db_queue__.json")) + File.Delete("__azurite_db_queue__.json"); + + if (File.Exists("__azurite_db_queue_extent__.json")) + File.Delete("__azurite_db_queue_extent__.json"); + + if (File.Exists("__azurite_db_table__.json")) + File.Delete("__azurite_db_table__.json"); + + if (File.Exists("__azurite_db_table_extent__.json")) + File.Delete("__azurite_db_table_extent__.json"); + } +} diff --git a/source/ProcessManager.Tests/Integration/Monitor/HealthCheckEndpointTests.cs b/source/ProcessManager.Tests/Integration/Monitor/HealthCheckEndpointTests.cs index 9a3e708866..01d843770b 100644 --- a/source/ProcessManager.Tests/Integration/Monitor/HealthCheckEndpointTests.cs +++ b/source/ProcessManager.Tests/Integration/Monitor/HealthCheckEndpointTests.cs @@ -23,7 +23,7 @@ namespace Energinet.DataHub.ProcessManager.Tests.Integration.Monitor; /// /// Tests verifying the configuration and behaviour of Health Checks. /// -[Collection(nameof(ProcessManagerAppCollectionFixture))] +[Collection(nameof(ProcessManagerAppCollection))] public class HealthCheckEndpointTests : IAsyncLifetime { public HealthCheckEndpointTests(ProcessManagerAppFixture fixture, ITestOutputHelper testOutputHelper) diff --git a/source/ProcessManager.Tests/Unit/Api/Mappers/OrchestrationInstanceMapperExtensionsTests.cs b/source/ProcessManager.Tests/Unit/Api/Mappers/OrchestrationInstanceMapperExtensionsTests.cs new file mode 100644 index 0000000000..6ddae66635 --- /dev/null +++ b/source/ProcessManager.Tests/Unit/Api/Mappers/OrchestrationInstanceMapperExtensionsTests.cs @@ -0,0 +1,93 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json; +using Energinet.DataHub.ProcessManagement.Core.Domain.OrchestrationDescription; +using Energinet.DataHub.ProcessManagement.Core.Domain.OrchestrationInstance; +using Energinet.DataHub.ProcessManager.Api.Mappers; +using Energinet.DataHub.ProcessManager.Api.Model; +using FluentAssertions; +using NodaTime; + +namespace Energinet.DataHub.ProcessManager.Tests.Unit.Api.Mappers; + +public class OrchestrationInstanceMapperExtensionsTests +{ + /// + /// Even the 'ParameterValue' is mapped in a way that allows us to serialize the object + /// and deserialize it to a strongly typed orchestration instance with parmeters. + /// + [Fact] + public void MapToDto_WhenOrchestrationInstance_CreateOrchestrationInstanceDtoThatCanBeFullySerializedToJson() + { + var orchestrationInstance = CreateOrchestrationInstance(); + + // Act + var actualDto = orchestrationInstance.MapToDto(); + var dtoAsJson = JsonSerializer.Serialize(actualDto); + + // Assert + var typedDto = JsonSerializer.Deserialize>(dtoAsJson); + typedDto!.ParameterValue!.TestString.Should().NotBeNull(); + typedDto!.ParameterValue!.TestInt.Should().NotBeNull(); + } + + private static OrchestrationInstance CreateOrchestrationInstance() + { + var orchestrationDescriptionId = new OrchestrationDescriptionId(Guid.NewGuid()); + + var existingOrchestrationInstance = new OrchestrationInstance( + orchestrationDescriptionId, + SystemClock.Instance); + + var step1 = new OrchestrationStep( + existingOrchestrationInstance.Id, + SystemClock.Instance, + "Test step 1", + 0); + + var step2 = new OrchestrationStep( + existingOrchestrationInstance.Id, + SystemClock.Instance, + "Test step 2", + 1, + step1.Id); + + var step3 = new OrchestrationStep( + existingOrchestrationInstance.Id, + SystemClock.Instance, + "Test step 3", + 2, + step2.Id); + + existingOrchestrationInstance.Steps.Add(step1); + existingOrchestrationInstance.Steps.Add(step2); + existingOrchestrationInstance.Steps.Add(step3); + + existingOrchestrationInstance.ParameterValue.SetFromInstance(new TestOrchestrationParameter + { + TestString = "Test string", + TestInt = 42, + }); + + return existingOrchestrationInstance; + } + + private class TestOrchestrationParameter + { + public string? TestString { get; set; } + + public int? TestInt { get; set; } + } +} diff --git a/source/ProcessManager/Api/CancelScheduledOrchestrationInstanceTrigger.cs b/source/ProcessManager/Api/CancelScheduledOrchestrationInstanceTrigger.cs new file mode 100644 index 0000000000..6f306e7b5c --- /dev/null +++ b/source/ProcessManager/Api/CancelScheduledOrchestrationInstanceTrigger.cs @@ -0,0 +1,50 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Energinet.DataHub.ProcessManagement.Core.Application; +using Energinet.DataHub.ProcessManagement.Core.Domain.OrchestrationInstance; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace Energinet.DataHub.ProcessManager.Api; + +internal class CancelScheduledOrchestrationInstanceTrigger( + ILogger logger, + IOrchestrationInstanceManager manager) +{ + private readonly ILogger _logger = logger; + private readonly IOrchestrationInstanceManager _manager = manager; + + /// + /// Cancel a scheduled orchestration instance. + /// + [Function(nameof(CancelScheduledOrchestrationInstanceTrigger))] + public async Task Run( + [HttpTrigger( + AuthorizationLevel.Anonymous, + "delete", + Route = "processmanager/orchestrationinstance/{id:guid}")] + HttpRequest httpRequest, + Guid id, + FunctionContext executionContext) + { + await _manager + .CancelScheduledOrchestrationInstanceAsync(new OrchestrationInstanceId(id)) + .ConfigureAwait(false); + + return new OkResult(); + } +} diff --git a/source/ProcessManager/Api/GetOrchestrationInstanceTrigger.cs b/source/ProcessManager/Api/GetOrchestrationInstanceTrigger.cs new file mode 100644 index 0000000000..f0850b2adc --- /dev/null +++ b/source/ProcessManager/Api/GetOrchestrationInstanceTrigger.cs @@ -0,0 +1,52 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Energinet.DataHub.ProcessManagement.Core.Application; +using Energinet.DataHub.ProcessManagement.Core.Domain.OrchestrationInstance; +using Energinet.DataHub.ProcessManager.Api.Mappers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace Energinet.DataHub.ProcessManager.Api; + +internal class GetOrchestrationInstanceTrigger( + ILogger logger, + IOrchestrationInstanceRepository repository) +{ + private readonly ILogger _logger = logger; + private readonly IOrchestrationInstanceRepository _repository = repository; + + /// + /// Get orchestration instance. + /// + [Function(nameof(GetOrchestrationInstanceTrigger))] + public async Task Run( + [HttpTrigger( + AuthorizationLevel.Anonymous, + "get", + Route = "processmanager/orchestrationinstance/{id:guid}")] + HttpRequest httpRequest, + Guid id, + FunctionContext executionContext) + { + var orchestrationInstance = await _repository + .GetAsync(new OrchestrationInstanceId(id)) + .ConfigureAwait(false); + + var dto = orchestrationInstance.MapToDto(); + return new OkObjectResult(dto); + } +} diff --git a/source/ProcessManager/Api/Mappers/OrchestrationInstanceMapperExtensions.cs b/source/ProcessManager/Api/Mappers/OrchestrationInstanceMapperExtensions.cs new file mode 100644 index 0000000000..4fcd64929d --- /dev/null +++ b/source/ProcessManager/Api/Mappers/OrchestrationInstanceMapperExtensions.cs @@ -0,0 +1,108 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using ApiModel = Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; +using DomainModel = Energinet.DataHub.ProcessManagement.Core.Domain.OrchestrationInstance; + +namespace Energinet.DataHub.ProcessManager.Api.Mappers; + +internal static class OrchestrationInstanceMapperExtensions +{ + public static ApiModel.OrchestrationInstanceDto MapToDto( + this DomainModel.OrchestrationInstance entity) + { + return new ApiModel.OrchestrationInstanceDto + { + Id = entity.Id.Value, + Lifecycle = entity.Lifecycle.MapToDto(), + ParameterValue = entity.ParameterValue.AsExpandoObject(), + Steps = entity.Steps.Select(step => step.MapToDto()).ToList(), + CustomState = entity.CustomState.Value, + }; + } + + public static ApiModel.OrchestrationInstanceLifecycleStatesDto MapToDto( + this DomainModel.OrchestrationInstanceLifecycleState entity) + { + return new ApiModel.OrchestrationInstanceLifecycleStatesDto + { + State = Enum + .TryParse( + entity.State.ToString(), + ignoreCase: true, + out var lifecycleStateResult) + ? lifecycleStateResult + : null, + TerminationState = Enum + .TryParse( + entity.TerminationState.ToString(), + ignoreCase: true, + out var terminationStateResult) + ? terminationStateResult + : null, + CreatedAt = entity.CreatedAt.ToDateTimeOffset(), + ScheduledToRunAt = entity.ScheduledToRunAt?.ToDateTimeOffset(), + QueuedAt = entity.QueuedAt?.ToDateTimeOffset(), + StartedAt = entity.StartedAt?.ToDateTimeOffset(), + TerminatedAt = entity.TerminatedAt?.ToDateTimeOffset(), + }; + } + + public static ApiModel.OrchestrationStepDto MapToDto( + this DomainModel.OrchestrationStep entity) + { + return new ApiModel.OrchestrationStepDto + { + Id = entity.Id.Value, + Lifecycle = entity.Lifecycle.MapToDto(), + Description = entity.Description, + Sequence = entity.Sequence, + DependsOn = entity.DependsOn?.Value, + CustomState = entity.CustomState.Value, + }; + } + + public static ApiModel.OrchestrationStepLifecycleStateDto MapToDto( + this DomainModel.OrchestrationStepLifecycleState entity) + { + return new ApiModel.OrchestrationStepLifecycleStateDto + { + State = Enum + .TryParse( + entity.State.ToString(), + ignoreCase: true, + out var lifecycleStateResult) + ? lifecycleStateResult + : null, + TerminationState = Enum + .TryParse( + entity.TerminationState.ToString(), + ignoreCase: true, + out var terminationStateResult) + ? terminationStateResult + : null, + CreatedAt = entity.CreatedAt.ToDateTimeOffset(), + StartedAt = entity.StartedAt?.ToDateTimeOffset(), + TerminatedAt = entity.TerminatedAt?.ToDateTimeOffset(), + }; + } + + public static IReadOnlyCollection MapToDto( + this IReadOnlyCollection entities) + { + return entities + .Select(instance => instance.MapToDto()) + .ToList(); + } +} diff --git a/source/ProcessManager/Api/SearchOrchestrationInstancesTrigger.cs b/source/ProcessManager/Api/SearchOrchestrationInstancesTrigger.cs new file mode 100644 index 0000000000..329fc634f2 --- /dev/null +++ b/source/ProcessManager/Api/SearchOrchestrationInstancesTrigger.cs @@ -0,0 +1,72 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Globalization; +using Energinet.DataHub.ProcessManagement.Core.Application; +using Energinet.DataHub.ProcessManagement.Core.Domain.OrchestrationInstance; +using Energinet.DataHub.ProcessManager.Api.Mappers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using NodaTime; + +namespace Energinet.DataHub.ProcessManager.Api; + +internal class SearchOrchestrationInstancesTrigger( + ILogger logger, + IOrchestrationInstanceRepository repository) +{ + private readonly ILogger _logger = logger; + private readonly IOrchestrationInstanceRepository _repository = repository; + + [Function(nameof(SearchOrchestrationInstancesTrigger))] + public async Task Run( + [HttpTrigger( + AuthorizationLevel.Anonymous, + "get", + Route = "processmanager/orchestrationinstances/{name}/{version:int?}")] + HttpRequest httpRequest, + string name, + int? version, + FunctionContext executionContext) + { + var lifecycleState = + Enum.TryParse(httpRequest.Query["lifecycleState"], ignoreCase: true, out var lifecycleStateResult) + ? lifecycleStateResult + : (OrchestrationInstanceLifecycleStates?)null; + var terminationState = + Enum.TryParse(httpRequest.Query["terminationState"], ignoreCase: true, out var terminationStateResult) + ? terminationStateResult + : (OrchestrationInstanceTerminationStates?)null; + + // DateTimeOffset values must be in "round-trip" ("o"/"O") format to be parsed correctly + // See https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings#the-round-trip-o-o-format-specifier + var startedAtOrLater = + DateTimeOffset.TryParse(httpRequest.Query["startedAtOrLater"], CultureInfo.InvariantCulture, out var startedAtOrLaterResult) + ? Instant.FromDateTimeOffset(startedAtOrLaterResult) + : (Instant?)null; + var terminatedAtOrEarlier = + DateTimeOffset.TryParse(httpRequest.Query["terminatedAtOrEarlier"], CultureInfo.InvariantCulture, out var terminatedAtOrEarlierResult) + ? Instant.FromDateTimeOffset(terminatedAtOrEarlierResult) + : (Instant?)null; + + var orchestrationInstances = await _repository + .SearchAsync(name, version, lifecycleState, terminationState, startedAtOrLater, terminatedAtOrEarlier) + .ConfigureAwait(false); + + var dto = orchestrationInstances.MapToDto(); + return new OkObjectResult(dto); + } +} diff --git a/source/ProcessManager/ProcessManager.csproj b/source/ProcessManager/ProcessManager.csproj index a6e0333d2f..28a1e63eff 100644 --- a/source/ProcessManager/ProcessManager.csproj +++ b/source/ProcessManager/ProcessManager.csproj @@ -7,6 +7,7 @@ + @@ -26,13 +27,17 @@ - - - - + + + + + + + + \ No newline at end of file diff --git a/source/ProcessManager/Program.cs b/source/ProcessManager/Program.cs index 9c5519cac2..eff54f677e 100644 --- a/source/ProcessManager/Program.cs +++ b/source/ProcessManager/Program.cs @@ -12,19 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Dynamic; using Energinet.DataHub.Core.App.Common.Extensions.DependencyInjection; using Energinet.DataHub.Core.App.FunctionApp.Extensions.Builder; using Energinet.DataHub.Core.App.FunctionApp.Extensions.DependencyInjection; -using Energinet.DataHub.ProcessManagement.Core.Application; using Energinet.DataHub.ProcessManagement.Core.Infrastructure.Extensions.DependencyInjection; using Energinet.DataHub.ProcessManagement.Core.Infrastructure.Telemetry; -using Energinet.DataHub.ProcessManager.Orchestrations.Processes.BRS_023_027.V1.Model; using Energinet.DataHub.ProcessManager.Scheduler; -using Microsoft.EntityFrameworkCore.SqlServer.NodaTime.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using NodaTime; var host = new HostBuilder() .ConfigureFunctionsWebApplication() @@ -47,31 +42,4 @@ }) .Build(); -// TODO: For demo purposes; remove when done -var runDemo = false; -if (runDemo) -{ - // Wait to allow orchestartion description to be registered. - // This is only necessary if the database is empty and hence the orchestration description was not registered previously. - await Task.Delay(TimeSpan.FromSeconds(30)).ConfigureAwait(false); - - // Must match the parameter definition registered, with regards to properties - dynamic parameter = new ExpandoObject(); - parameter.CalculationType = CalculationTypes.BalanceFixing; - parameter.GridAreaCodes = new[] { "543" }; - parameter.StartDate = DateTimeOffset.Now; - parameter.EndDate = DateTimeOffset.Now.AddHours(1); - parameter.ScheduledAt = DateTimeOffset.Now; - parameter.IsInternalCalculation = true; - - var manager = host.Services.GetRequiredService(); - var clock = host.Services.GetRequiredService(); - await manager.ScheduleNewOrchestrationInstanceAsync( - name: "BRS_023_027", - version: 1, - parameter: parameter, - runAt: clock.GetCurrentInstant().PlusSeconds(20)) - .ConfigureAwait(false); -} - host.Run(); diff --git a/source/ProcessManager/Scheduler/SchedulerHandler.cs b/source/ProcessManager/Scheduler/SchedulerHandler.cs index 20f48bef48..1a7d94e130 100644 --- a/source/ProcessManager/Scheduler/SchedulerHandler.cs +++ b/source/ProcessManager/Scheduler/SchedulerHandler.cs @@ -22,12 +22,12 @@ public class SchedulerHandler( ILogger logger, IClock clock, IQueryScheduledOrchestrationInstancesByInstant query, - IOrchestrationInstanceManager orchestrationInstanceManager) + IOrchestrationInstanceScheduleManager manager) { private readonly ILogger _logger = logger; private readonly IClock _clock = clock; private readonly IQueryScheduledOrchestrationInstancesByInstant _query = query; - private readonly IOrchestrationInstanceManager _orchestrationInstanceManager = orchestrationInstanceManager; + private readonly IOrchestrationInstanceScheduleManager _manager = manager; public async Task StartScheduledOrchestrationInstancesAsync() { @@ -40,7 +40,7 @@ public async Task StartScheduledOrchestrationInstancesAsync() { try { - await _orchestrationInstanceManager + await _manager .StartScheduledOrchestrationInstanceAsync(orchestrationInstance.Id) .ConfigureAwait(false); } diff --git a/source/ProcessManager/local.settings.sample.json b/source/ProcessManager/local.settings.sample.json index 408170f399..87118cd564 100644 --- a/source/ProcessManager/local.settings.sample.json +++ b/source/ProcessManager/local.settings.sample.json @@ -6,6 +6,7 @@ // Process Manager: // - 'ProcessManager' (host) and 'ProcessManager.Orchestrations' (host) must use the same Task Hub "ProcessManagerStorageConnectionString": "UseDevelopmentStorage=true", - "ProcessManagerTaskHubName": "Orchestrations01" + "ProcessManagerTaskHubName": "Orchestrations01", + "ProcessManager__SqlDatabaseConnectionString": "" } } \ No newline at end of file diff --git a/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationInstanceDto.cs b/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationInstanceDto.cs new file mode 100644 index 0000000000..9c4d1706d8 --- /dev/null +++ b/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationInstanceDto.cs @@ -0,0 +1,42 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Dynamic; + +namespace Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; + +public class OrchestrationInstanceDto +{ + public Guid Id { get; set; } + + /// + /// The high-level lifecycle states that all orchestration instances can go through. + /// + public OrchestrationInstanceLifecycleStatesDto? Lifecycle { get; set; } + + /// + /// Contains the Durable Functions orchestration input parameter value. + /// + public ExpandoObject? ParameterValue { get; set; } + + /// + /// Workflow steps the orchestration instance is going through. + /// + public IReadOnlyCollection? Steps { get; set; } + + /// + /// Any custom state of the orchestration instance. + /// + public string? CustomState { get; set; } +} diff --git a/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationInstanceLifecycleStates.cs b/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationInstanceLifecycleStates.cs new file mode 100644 index 0000000000..969675e926 --- /dev/null +++ b/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationInstanceLifecycleStates.cs @@ -0,0 +1,54 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; + +/// +/// High-level lifecycle states that all orchestration instances can go through. +/// +public enum OrchestrationInstanceLifecycleStates +{ + /// + /// Created and waiting to be started. + /// + Pending = 1, + + /// + /// The Process Manager has requested the Task Hub to start the Durable Functions orchestration instance. + /// + Queued = 2, + + /// + /// A Durable Functions activity has transitioned the orchestration instance into running. + /// + Running = 3, + + /// + /// A Durable Functions activity has transitioned the orchestration instance into terminated. + /// See for details. + /// + Terminated = 4, +} + +public enum OrchestrationInstanceTerminationStates +{ + Succeeded = 1, + + Failed = 2, + + /// + /// A user canceled the orchestration instance. + /// + UserCanceled = 3, +} diff --git a/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationInstanceLifecycleStatesDto.cs b/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationInstanceLifecycleStatesDto.cs new file mode 100644 index 0000000000..913e7f0191 --- /dev/null +++ b/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationInstanceLifecycleStatesDto.cs @@ -0,0 +1,32 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; + +public class OrchestrationInstanceLifecycleStatesDto +{ + public OrchestrationInstanceLifecycleStates? State { get; set; } + + public OrchestrationInstanceTerminationStates? TerminationState { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? ScheduledToRunAt { get; set; } + + public DateTimeOffset? QueuedAt { get; set; } + + public DateTimeOffset? StartedAt { get; set; } + + public DateTimeOffset? TerminatedAt { get; set; } +} diff --git a/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationStepDto.cs b/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationStepDto.cs new file mode 100644 index 0000000000..b989a4c6ae --- /dev/null +++ b/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationStepDto.cs @@ -0,0 +1,41 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; + +/// +/// Represents the instance of a workflow (orchestration) step. +/// It contains state information about the step, and is linked +/// to the orchestration instance that it is part of. +/// +public class OrchestrationStepDto +{ + public Guid Id { get; set; } + + /// + /// The high-level lifecycle states that all orchestration steps can go through. + /// + public OrchestrationStepLifecycleStateDto? Lifecycle { get; set; } + + public string? Description { get; set; } + + public int Sequence { get; set; } + + public Guid? DependsOn { get; set; } + + /// + /// Any custom state of the step. + /// + public string? CustomState { get; set; } +} diff --git a/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationStepLifecycleStateDto.cs b/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationStepLifecycleStateDto.cs new file mode 100644 index 0000000000..110f610d11 --- /dev/null +++ b/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationStepLifecycleStateDto.cs @@ -0,0 +1,28 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; + +public class OrchestrationStepLifecycleStateDto +{ + public OrchestrationStepLifecycleStates? State { get; set; } + + public OrchestrationStepTerminationStates? TerminationState { get; set; } + + public DateTimeOffset? CreatedAt { get; set; } + + public DateTimeOffset? StartedAt { get; set; } + + public DateTimeOffset? TerminatedAt { get; set; } +} diff --git a/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationStepLifecycleStates.cs b/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationStepLifecycleStates.cs new file mode 100644 index 0000000000..45cb5f4168 --- /dev/null +++ b/source/Shared/ProcessManager/Api/Model/OrchestrationInstance/OrchestrationStepLifecycleStates.cs @@ -0,0 +1,44 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; + +/// +/// High-level lifecycle states that all orchestration steps can go through. +/// +public enum OrchestrationStepLifecycleStates +{ + /// + /// Created and waiting to be started. + /// + Pending = 1, + + /// + /// A Durable Functions activity has transitioned the orchestration step into running. + /// + Running = 2, + + /// + /// A Durable Functions activity has transitioned the orchestration step into terminated. + /// See for details. + /// + Terminated = 3, +} + +public enum OrchestrationStepTerminationStates +{ + Succeeded = 1, + + Failed = 2, +} diff --git a/source/Shared/ProcessManager/Api/Model/OrchestrationInstanceTypedDto.cs b/source/Shared/ProcessManager/Api/Model/OrchestrationInstanceTypedDto.cs new file mode 100644 index 0000000000..2305b515bf --- /dev/null +++ b/source/Shared/ProcessManager/Api/Model/OrchestrationInstanceTypedDto.cs @@ -0,0 +1,48 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Energinet.DataHub.ProcessManager.Api.Model.OrchestrationInstance; + +namespace Energinet.DataHub.ProcessManager.Api.Model; + +/// +/// Contains information about an orchestration instance including +/// specific input parameter values. +/// +/// Must be a JSON serializable type. +public class OrchestrationInstanceTypedDto + where TParameterDto : class +{ + public Guid Id { get; set; } + + /// + /// The high-level lifecycle states that all orchestration instances can go through. + /// + public OrchestrationInstanceLifecycleStatesDto? Lifecycle { get; set; } + + /// + /// Contains the Durable Functions orchestration input parameter value. + /// + public TParameterDto? ParameterValue { get; set; } + + /// + /// Workflow steps the orchestration instance is going through. + /// + public IReadOnlyCollection? Steps { get; set; } + + /// + /// Any custom state of the orchestration instance. + /// + public string? CustomState { get; set; } +} diff --git a/source/Shared/ProcessManager/Api/Model/ScheduleOrchestrationInstanceDto.cs b/source/Shared/ProcessManager/Api/Model/ScheduleOrchestrationInstanceDto.cs new file mode 100644 index 0000000000..0eb3cc8693 --- /dev/null +++ b/source/Shared/ProcessManager/Api/Model/ScheduleOrchestrationInstanceDto.cs @@ -0,0 +1,20 @@ +// Copyright 2020 Energinet DataHub A/S +// +// Licensed under the Apache License, Version 2.0 (the "License2"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Energinet.DataHub.ProcessManager.Api.Model; + +public sealed record ScheduleOrchestrationInstanceDto( + DateTimeOffset RunAt, + TParameter InputParameter) + where TParameter : class; diff --git a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Model/CalculationTypes.cs b/source/Shared/ProcessManager/Orchestrations/Processes/BRS_023_027/V1/Model/CalculationTypes.cs similarity index 100% rename from source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Model/CalculationTypes.cs rename to source/Shared/ProcessManager/Orchestrations/Processes/BRS_023_027/V1/Model/CalculationTypes.cs diff --git a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Model/NotifyAggregatedMeasureDataInputV1.cs b/source/Shared/ProcessManager/Orchestrations/Processes/BRS_023_027/V1/Model/NotifyAggregatedMeasureDataInputV1.cs similarity index 82% rename from source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Model/NotifyAggregatedMeasureDataInputV1.cs rename to source/Shared/ProcessManager/Orchestrations/Processes/BRS_023_027/V1/Model/NotifyAggregatedMeasureDataInputV1.cs index 26b12d1584..f5013045ce 100644 --- a/source/ProcessManager.Orchestrations/Processes/BRS_023_027/V1/Model/NotifyAggregatedMeasureDataInputV1.cs +++ b/source/Shared/ProcessManager/Orchestrations/Processes/BRS_023_027/V1/Model/NotifyAggregatedMeasureDataInputV1.cs @@ -15,12 +15,11 @@ namespace Energinet.DataHub.ProcessManager.Orchestrations.Processes.BRS_023_027.V1.Model; /// -/// An immutable input to start the . +/// An immutable input to start the orchestration instance for "BRS_023_027" V1. /// public sealed record NotifyAggregatedMeasureDataInputV1( CalculationTypes CalculationType, IReadOnlyCollection GridAreaCodes, - DateTimeOffset StartDate, - DateTimeOffset EndDate, - DateTimeOffset ScheduledAt, + DateTimeOffset PeriodStartDate, + DateTimeOffset PeriodEndDate, bool IsInternalCalculation);