diff --git a/build.ps1 b/build.ps1 index e461e105d..af7bb4a29 100644 --- a/build.ps1 +++ b/build.ps1 @@ -9,7 +9,7 @@ param ( [string]$SemanticVersion = '1.0.0-zlocal', [string]$Branch = 'zlocal', [string]$CommitSHA, - [string]$BuildBranch = 'c35bbc228717720bdbc610f3285259391635e90e' + [string]$BuildBranch = '80b8f1b4a1cfe57367881e59fca063866dcaaa42' ) $msBuildVersion = 15; diff --git a/src/Gallery.Maintenance/DeleteExpiredVerificationKeysTask.cs b/src/Gallery.Maintenance/DeleteExpiredVerificationKeysTask.cs index 8b0e91721..d071f95fa 100644 --- a/src/Gallery.Maintenance/DeleteExpiredVerificationKeysTask.cs +++ b/src/Gallery.Maintenance/DeleteExpiredVerificationKeysTask.cs @@ -61,19 +61,21 @@ public override async Task RunAsync(Job job) if (expectedRowCount > 0) { using (var connection = await job.OpenSqlConnectionAsync()) + using (var transaction = connection.BeginTransaction()) + using (var command = connection.CreateCommand()) { - using (var transaction = connection.BeginTransaction()) - { - var numKeys = 0; - var parameters = credentialKeys.Select(c => new SqlParameter("@Key" + numKeys++, SqlDbType.Int) { Value = c }).ToArray(); - - rowCount = await connection.ExecuteAsync( - string.Format(DeleteQuery, string.Join(",", parameters.Select(p => p.ParameterName))), - parameters, - transaction, _commandTimeout); - - transaction.Commit(); - } + var numKeys = 0; + var parameters = credentialKeys.Select(c => new SqlParameter("@Key" + numKeys++, SqlDbType.Int) { Value = c }).ToArray(); + command.Parameters.AddRange(parameters); + + command.CommandText = string.Format(DeleteQuery, string.Join(",", parameters.Select(p => p.ParameterName))); + command.CommandType = CommandType.Text; + command.CommandTimeout = (int)_commandTimeout.TotalSeconds; + command.Transaction = transaction; + + rowCount = await command.ExecuteNonQueryAsync(); + + transaction.Commit(); } } diff --git a/src/NuGet.Jobs.Common/Extensions/DapperExtensions.cs b/src/NuGet.Jobs.Common/Extensions/DapperExtensions.cs index 08c0c12c4..3690c3050 100644 --- a/src/NuGet.Jobs.Common/Extensions/DapperExtensions.cs +++ b/src/NuGet.Jobs.Common/Extensions/DapperExtensions.cs @@ -10,30 +10,6 @@ namespace System.Data.SqlClient { public static class DapperExtensions { - public static Task ExecuteAsync(this SqlConnection connection, string sql, SqlParameter[] parameters = null, SqlTransaction transaction = null, TimeSpan? commandTimeout = null) - { - SqlCommand cmd = connection.CreateCommand(); - cmd.CommandText = sql; - cmd.CommandType = CommandType.Text; - - if (parameters != null) - { - cmd.Parameters.AddRange(parameters); - } - - if (commandTimeout.HasValue) - { - cmd.CommandTimeout = (int)commandTimeout.Value.TotalSeconds; - } - - if (transaction != null) - { - cmd.Transaction = transaction; - } - - return cmd.ExecuteNonQueryAsync(); - } - public static async Task> QueryWithRetryAsync( this SqlConnection connection, string sql, diff --git a/src/NuGet.Jobs.Common/NuGet.Jobs.Common.csproj b/src/NuGet.Jobs.Common/NuGet.Jobs.Common.csproj index f79ffb264..647f69162 100644 --- a/src/NuGet.Jobs.Common/NuGet.Jobs.Common.csproj +++ b/src/NuGet.Jobs.Common/NuGet.Jobs.Common.csproj @@ -94,13 +94,13 @@ 2.27.0 - 2.28.0 + 2.29.0 2.27.0 - 2.28.0 + 2.29.0 4.3.3 diff --git a/src/NuGet.Services.Revalidate/NuGet.Services.Revalidate.csproj b/src/NuGet.Services.Revalidate/NuGet.Services.Revalidate.csproj index b069f681a..efbc9b0f4 100644 --- a/src/NuGet.Services.Revalidate/NuGet.Services.Revalidate.csproj +++ b/src/NuGet.Services.Revalidate/NuGet.Services.Revalidate.csproj @@ -117,10 +117,10 @@ - 2.27.0 + 2.29.0 - 2.27.0 + 2.29.0 diff --git a/src/NuGet.Services.Validation.Orchestrator/Job.cs b/src/NuGet.Services.Validation.Orchestrator/Job.cs index 603ccfd26..919d2ec0c 100644 --- a/src/NuGet.Services.Validation.Orchestrator/Job.cs +++ b/src/NuGet.Services.Validation.Orchestrator/Job.cs @@ -599,6 +599,7 @@ private static void ConfigureOrchestratorSymbolTypes(IServiceCollection services services.AddTransient, ValidationSetProvider>(); services.AddTransient, SymbolsPackageMessageService>(); services.AddTransient, SymbolsValidatorMessageSerializer>(); + services.AddTransient, SymbolsIngesterMessageSerializer>(); services.AddTransient(); } @@ -653,18 +654,18 @@ private static void ConfigureSymbolsIngester(ContainerBuilder builder) .Keyed(SymbolsIngesterBindingKey); builder - .RegisterType() + .RegisterType() .WithKeyedParameter(typeof(ITopicClient), SymbolsIngesterBindingKey) .WithParameter( (pi, ctx) => pi.ParameterType == typeof(TimeSpan?), (pi, ctx) => ctx.Resolve>().Value.MessageDelay) - .Keyed(SymbolsIngesterBindingKey) - .As(); + .Keyed(SymbolsIngesterBindingKey) + .As(); builder .RegisterType() .WithKeyedParameter(typeof(IValidatorStateService), SymbolsIngesterBindingKey) - .WithKeyedParameter(typeof(ISymbolsMessageEnqueuer), SymbolsIngesterBindingKey) + .WithKeyedParameter(typeof(ISymbolsIngesterMessageEnqueuer), SymbolsIngesterBindingKey) .AsSelf(); } @@ -672,6 +673,5 @@ private T GetRequiredService() { return _serviceProvider.GetRequiredService(); } - } } diff --git a/src/NuGet.Services.Validation.Orchestrator/NuGet.Services.Validation.Orchestrator.csproj b/src/NuGet.Services.Validation.Orchestrator/NuGet.Services.Validation.Orchestrator.csproj index ee95d80b4..39e1a907e 100644 --- a/src/NuGet.Services.Validation.Orchestrator/NuGet.Services.Validation.Orchestrator.csproj +++ b/src/NuGet.Services.Validation.Orchestrator/NuGet.Services.Validation.Orchestrator.csproj @@ -53,6 +53,7 @@ + @@ -62,6 +63,7 @@ + diff --git a/src/NuGet.Services.Validation.Orchestrator/Symbols/ISymbolsIngesterMessageEnqueuer.cs b/src/NuGet.Services.Validation.Orchestrator/Symbols/ISymbolsIngesterMessageEnqueuer.cs new file mode 100644 index 000000000..df5c689b0 --- /dev/null +++ b/src/NuGet.Services.Validation.Orchestrator/Symbols/ISymbolsIngesterMessageEnqueuer.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using NuGet.Jobs.Validation.Symbols.Core; + +namespace NuGet.Services.Validation.Symbols +{ + public interface ISymbolsIngesterMessageEnqueuer + { + /// + /// Enqueues a message to one of the topics used by the Symbol Ingester. + /// + /// The validation request. + /// A that will be completed when the execution is completed. + Task EnqueueSymbolsIngestionMessageAsync(IValidationRequest request); + } +} diff --git a/src/NuGet.Services.Validation.Orchestrator/Symbols/SymbolsIngester.cs b/src/NuGet.Services.Validation.Orchestrator/Symbols/SymbolsIngester.cs index cf04940ad..4d8378c27 100644 --- a/src/NuGet.Services.Validation.Orchestrator/Symbols/SymbolsIngester.cs +++ b/src/NuGet.Services.Validation.Orchestrator/Symbols/SymbolsIngester.cs @@ -15,13 +15,13 @@ namespace NuGet.Services.Validation.Symbols public class SymbolsIngester : BaseValidator, IValidator { private readonly ISymbolsValidationEntitiesService _symbolsValidationEntitiesService; - private readonly ISymbolsMessageEnqueuer _symbolMessageEnqueuer; + private readonly ISymbolsIngesterMessageEnqueuer _symbolMessageEnqueuer; private readonly ITelemetryService _telemetryService; private readonly ILogger _logger; public SymbolsIngester( ISymbolsValidationEntitiesService symbolsValidationEntitiesService, - ISymbolsMessageEnqueuer symbolMessageEnqueuer, + ISymbolsIngesterMessageEnqueuer symbolMessageEnqueuer, ITelemetryService telemetryService, ILogger logger) { @@ -40,11 +40,12 @@ public async Task GetResultAsync(IValidationRequest request) var result = SymbolsValidationEntitiesService.ConvertToIValidationResult(await _symbolsValidationEntitiesService.GetSymbolsServerRequestAsync(request)); _logger.LogInformation( - "Symbols status {Status} for PackageId: {PackageId}, PackageNormalizedVersion {PackageNormalizedVersion}, SymbolsPackageKey {SymbolsPackageKey} ", + "Symbols status {Status} for PackageId: {PackageId}, PackageNormalizedVersion {PackageNormalizedVersion}, SymbolsPackageKey {SymbolsPackageKey} ValidationId {ValidationId}", result.Status, request.PackageId, request.PackageVersion, - request.PackageKey); + request.PackageKey, + request.ValidationId); return result; } @@ -78,9 +79,9 @@ public async Task StartAsync(IValidationRequest request) } _telemetryService.TrackSymbolsMessageEnqueued(ValidatorName.SymbolsIngester, request.ValidationId); - await _symbolMessageEnqueuer.EnqueueSymbolsValidationMessageAsync(request); + var message = await _symbolMessageEnqueuer.EnqueueSymbolsIngestionMessageAsync(request); - var newSymbolsRequest = SymbolsValidationEntitiesService.CreateFromValidationRequest(request, SymbolsPackageIngestRequestStatus.Ingesting); + var newSymbolsRequest = SymbolsValidationEntitiesService.CreateFromValidationRequest(request, SymbolsPackageIngestRequestStatus.Ingesting, message.RequestName); var savedSymbolRequest = await _symbolsValidationEntitiesService.AddSymbolsServerRequestAsync(newSymbolsRequest); if(savedSymbolRequest.RequestStatusKey != SymbolsPackageIngestRequestStatus.Ingesting) diff --git a/src/NuGet.Services.Validation.Orchestrator/Symbols/SymbolsIngesterMessageEnqueuer.cs b/src/NuGet.Services.Validation.Orchestrator/Symbols/SymbolsIngesterMessageEnqueuer.cs new file mode 100644 index 000000000..fdcc51bea --- /dev/null +++ b/src/NuGet.Services.Validation.Orchestrator/Symbols/SymbolsIngesterMessageEnqueuer.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using NuGet.Jobs.Validation.Symbols.Core; +using NuGet.Services.ServiceBus; + +namespace NuGet.Services.Validation.Symbols +{ + public class SymbolsIngesterMessageEnqueuer : ISymbolsIngesterMessageEnqueuer + { + private readonly ITopicClient _topicClient; + private readonly TimeSpan? _messageDelay; + private readonly IBrokeredMessageSerializer _serializer; + + public SymbolsIngesterMessageEnqueuer( + ITopicClient topicClient, + IBrokeredMessageSerializer serializer, + TimeSpan? messageDelay) + { + _topicClient = topicClient ?? throw new ArgumentNullException(nameof(topicClient)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + _messageDelay = messageDelay; + } + + public async Task EnqueueSymbolsIngestionMessageAsync(IValidationRequest request) + { + var message = new SymbolsIngesterMessage(validationId: request.ValidationId, + symbolPackageKey: request.PackageKey, + packageId: request.PackageId, + packageNormalizedVersion: request.PackageVersion, + snupkgUrl: request.NupkgUrl, + requestName : SymbolsValidationEntitiesService.CreateSymbolServerRequestNameFromValidationRequest(request)); + var brokeredMessage = _serializer.Serialize(message); + + var visibleAt = DateTimeOffset.UtcNow + (_messageDelay ?? TimeSpan.Zero); + brokeredMessage.ScheduledEnqueueTimeUtc = visibleAt; + + await _topicClient.SendAsync(brokeredMessage); + return message; + } + } +} diff --git a/src/NuGet.SupportRequests.Notifications/Tasks/SupportRequestsNotificationScheduledTask.cs b/src/NuGet.SupportRequests.Notifications/Tasks/SupportRequestsNotificationScheduledTask.cs index 8f0d0312d..89573f048 100644 --- a/src/NuGet.SupportRequests.Notifications/Tasks/SupportRequestsNotificationScheduledTask.cs +++ b/src/NuGet.SupportRequests.Notifications/Tasks/SupportRequestsNotificationScheduledTask.cs @@ -15,8 +15,6 @@ internal abstract class SupportRequestsNotificationScheduledTask : IScheduledTask where TNotification : INotification { - private InitializationConfiguration _configuration; - private readonly SupportRequestRepository _supportRequestRepository; private readonly MessagingService _messagingService; diff --git a/src/NuGetCDNRedirect/GlobalSuppressions.cs b/src/NuGetCDNRedirect/GlobalSuppressions.cs new file mode 100644 index 000000000..cda20da70 --- /dev/null +++ b/src/NuGetCDNRedirect/GlobalSuppressions.cs @@ -0,0 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Diagnostics.CodeAnalysis; + +[module: SuppressMessage("Microsoft.Security.Web.Configuration", "CA3103:EnableFormsRequireSSL", Justification = "Forms authentication is not used, and this project is obsolete (Engineering#1737).")] \ No newline at end of file diff --git a/src/NuGetCDNRedirect/NuGetCDNRedirect.csproj b/src/NuGetCDNRedirect/NuGetCDNRedirect.csproj index 33d955612..af8674db5 100644 --- a/src/NuGetCDNRedirect/NuGetCDNRedirect.csproj +++ b/src/NuGetCDNRedirect/NuGetCDNRedirect.csproj @@ -183,6 +183,7 @@ Global.asax + diff --git a/src/Search.GenerateAuxiliaryData/NestedJArrayExporter.cs b/src/Search.GenerateAuxiliaryData/NestedJArrayExporter.cs index a7946d393..8d2b8232b 100644 --- a/src/Search.GenerateAuxiliaryData/NestedJArrayExporter.cs +++ b/src/Search.GenerateAuxiliaryData/NestedJArrayExporter.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Data; using System.Data.SqlClient; using System.Linq; using System.Threading.Tasks; @@ -34,8 +33,7 @@ public NestedJArrayExporter( protected override JContainer GetResultOfQuery(SqlConnection connection) { - var command = new SqlCommand(GetEmbeddedSqlScript(SqlScript), connection); - command.CommandType = CommandType.Text; + var command = GetEmbeddedSqlCommand(connection, SqlScript); return SqlDataReaderToNestedJArrays(command.ExecuteReader(), Col0, Col1); } diff --git a/src/Search.GenerateAuxiliaryData/RankingsExporter.cs b/src/Search.GenerateAuxiliaryData/RankingsExporter.cs index 3b4db07a2..eb418c944 100644 --- a/src/Search.GenerateAuxiliaryData/RankingsExporter.cs +++ b/src/Search.GenerateAuxiliaryData/RankingsExporter.cs @@ -32,8 +32,7 @@ public RankingsExporter( protected override JContainer GetResultOfQuery(SqlConnection connection) { - var rankingsTotalCommand = new SqlCommand(GetEmbeddedSqlScript(_rankingsTotalScript), connection); - rankingsTotalCommand.CommandType = CommandType.Text; + var rankingsTotalCommand = GetEmbeddedSqlCommand(connection, _rankingsTotalScript); rankingsTotalCommand.Parameters.AddWithValue(_rankingCountParameterName, _rankingCount); rankingsTotalCommand.CommandTimeout = 60; diff --git a/src/Search.GenerateAuxiliaryData/SqlExporter.cs b/src/Search.GenerateAuxiliaryData/SqlExporter.cs index ace4960c6..1a0b77b56 100644 --- a/src/Search.GenerateAuxiliaryData/SqlExporter.cs +++ b/src/Search.GenerateAuxiliaryData/SqlExporter.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Data; using System.Data.SqlClient; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Reflection; using System.Threading.Tasks; @@ -34,10 +35,18 @@ public SqlExporter( OpenSqlConnectionAsync = openSqlConnectionAsync; } - protected static string GetEmbeddedSqlScript(string resourceName) + [SuppressMessage("Microsoft.Security", "CA2100", Justification = "Query string comes from embedded resource, not user input.")] + protected static SqlCommand GetEmbeddedSqlCommand(SqlConnection connection, string resourceName) { - var stream = _executingAssembly.GetManifestResourceStream(_assemblyName + "." + resourceName); - return new StreamReader(stream).ReadToEnd(); + using (var reader = new StreamReader(_executingAssembly.GetManifestResourceStream(_assemblyName + "." + resourceName))) + { + var commandText = reader.ReadToEnd(); + + return new SqlCommand(commandText, connection) + { + CommandType = CommandType.Text + }; + } } public override async Task ExportAsync() diff --git a/src/Search.GenerateAuxiliaryData/VerifiedPackagesExporter.cs b/src/Search.GenerateAuxiliaryData/VerifiedPackagesExporter.cs index dc7ee4e08..95c4091bd 100644 --- a/src/Search.GenerateAuxiliaryData/VerifiedPackagesExporter.cs +++ b/src/Search.GenerateAuxiliaryData/VerifiedPackagesExporter.cs @@ -29,8 +29,7 @@ public VerifiedPackagesExporter( protected override JContainer GetResultOfQuery(SqlConnection connection) { - var verifiedPackagesCommand = new SqlCommand(GetEmbeddedSqlScript(_verifiedPackagesScript), connection); - verifiedPackagesCommand.CommandType = CommandType.Text; + var verifiedPackagesCommand = GetEmbeddedSqlCommand(connection, _verifiedPackagesScript); verifiedPackagesCommand.CommandTimeout = 60; SqlDataReader reader = null; diff --git a/src/Stats.AggregateCdnDownloadsInGallery/AggregateCdnDownloadsJob.cs b/src/Stats.AggregateCdnDownloadsInGallery/AggregateCdnDownloadsJob.cs index 60eb18e2f..74e63ea98 100644 --- a/src/Stats.AggregateCdnDownloadsInGallery/AggregateCdnDownloadsJob.cs +++ b/src/Stats.AggregateCdnDownloadsInGallery/AggregateCdnDownloadsJob.cs @@ -148,7 +148,14 @@ private async Task ProcessBatch(List batch, SqlConnection desti { // Create a temporary table Logger.LogDebug("Creating temporary table..."); - await destinationDatabase.ExecuteAsync(_createTempTable); + + using (var cmd = destinationDatabase.CreateCommand()) + { + cmd.CommandText = _createTempTable; + cmd.CommandType = CommandType.Text; + + await cmd.ExecuteNonQueryAsync(); + } // Load temporary table var aggregateCdnDownloadsInGalleryTable = new DataTable(); @@ -206,8 +213,14 @@ private async Task ProcessBatch(List batch, SqlConnection desti Logger.LogInformation("Updating destination database Download Counts... ({RecordCount} package registrations to process).", batch.Count()); stopwatch.Restart(); - await destinationDatabase.ExecuteAsync(_updateFromTempTable, - commandTimeout: TimeSpan.FromMinutes(30)); + using (var cmd = destinationDatabase.CreateCommand()) + { + cmd.CommandText = _updateFromTempTable; + cmd.CommandType = CommandType.Text; + cmd.CommandTimeout = (int)TimeSpan.FromMinutes(30).TotalSeconds; + + await cmd.ExecuteNonQueryAsync(); + } Logger.LogInformation( "Updated destination database Download Counts (took {DurationSeconds} seconds).", diff --git a/src/Stats.RefreshClientDimension/Stats.RefreshClientDimension.csproj b/src/Stats.RefreshClientDimension/Stats.RefreshClientDimension.csproj index f79edf412..60d87b17c 100644 --- a/src/Stats.RefreshClientDimension/Stats.RefreshClientDimension.csproj +++ b/src/Stats.RefreshClientDimension/Stats.RefreshClientDimension.csproj @@ -84,7 +84,7 @@ 9.0.1 - 2.28.0 + 2.29.0 1.2.0 diff --git a/src/StatusAggregator/Cursor.cs b/src/StatusAggregator/Collector/Cursor.cs similarity index 88% rename from src/StatusAggregator/Cursor.cs rename to src/StatusAggregator/Collector/Cursor.cs index 79ec0f28f..999cfc7e7 100644 --- a/src/StatusAggregator/Cursor.cs +++ b/src/StatusAggregator/Collector/Cursor.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -8,10 +8,13 @@ using NuGet.Services.Status.Table; using StatusAggregator.Table; -namespace StatusAggregator +namespace StatusAggregator.Collector { public class Cursor : ICursor { + private readonly ITableWrapper _table; + private readonly ILogger _logger; + public Cursor( ITableWrapper table, ILogger logger) @@ -20,16 +23,13 @@ public Cursor( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - private readonly ITableWrapper _table; - - private readonly ILogger _logger; - public async Task Get(string name) { + name = name ?? throw new ArgumentNullException(nameof(name)); + using (_logger.Scope("Fetching cursor with name {CursorName}.", name)) { - var cursor = await _table.RetrieveAsync( - CursorEntity.DefaultPartitionKey, name); + var cursor = await _table.RetrieveAsync(name); DateTime value; if (cursor == null) @@ -50,6 +50,8 @@ public async Task Get(string name) public Task Set(string name, DateTime value) { + name = name ?? throw new ArgumentNullException(nameof(name)); + using (_logger.Scope("Updating cursor with name {CursorName} to {CursorValue}.", name, value)) { var cursorEntity = new CursorEntity(name, value); @@ -57,4 +59,4 @@ public Task Set(string name, DateTime value) } } } -} +} \ No newline at end of file diff --git a/src/StatusAggregator/Collector/EntityCollector.cs b/src/StatusAggregator/Collector/EntityCollector.cs new file mode 100644 index 000000000..64903fae8 --- /dev/null +++ b/src/StatusAggregator/Collector/EntityCollector.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; + +namespace StatusAggregator.Collector +{ + public class EntityCollector : IEntityCollector + { + private readonly ICursor _cursor; + private readonly IEntityCollectorProcessor _processor; + + public EntityCollector( + ICursor cursor, + IEntityCollectorProcessor processor) + { + _cursor = cursor ?? throw new ArgumentNullException(nameof(cursor)); + _processor = processor ?? throw new ArgumentNullException(nameof(processor)); + } + + public string Name => _processor.Name; + + public async Task FetchLatest() + { + var lastCursor = await _cursor.Get(Name); + var nextCursor = await _processor.FetchSince(lastCursor); + if (nextCursor.HasValue) + { + await _cursor.Set(Name, nextCursor.Value); + } + + return nextCursor ?? lastCursor; + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Collector/ICursor.cs b/src/StatusAggregator/Collector/ICursor.cs new file mode 100644 index 000000000..4176b9042 --- /dev/null +++ b/src/StatusAggregator/Collector/ICursor.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; + +namespace StatusAggregator.Collector +{ + /// + /// Persists a that represents that job's current progress by a string. + /// + public interface ICursor + { + /// + /// Gets the current progress of the job by . + /// + Task Get(string name); + + /// + /// Saves as the current progress of the job by . + /// + Task Set(string name, DateTime value); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Collector/IEntityCollector.cs b/src/StatusAggregator/Collector/IEntityCollector.cs new file mode 100644 index 000000000..dc8bb04de --- /dev/null +++ b/src/StatusAggregator/Collector/IEntityCollector.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; + +namespace StatusAggregator.Collector +{ + /// + /// Collects new entities from a source. + /// + public interface IEntityCollector + { + /// + /// A unique name for this collector. + /// + string Name { get; } + + /// + /// Fetches all new entities from the source and returns the newest timestamp found. + /// + Task FetchLatest(); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Collector/IEntityCollectorProcessor.cs b/src/StatusAggregator/Collector/IEntityCollectorProcessor.cs new file mode 100644 index 000000000..36e76144d --- /dev/null +++ b/src/StatusAggregator/Collector/IEntityCollectorProcessor.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; + +namespace StatusAggregator.Collector +{ + /// + /// Used by to fetch the latest entities from a source. + /// + public interface IEntityCollectorProcessor + { + /// + /// A unique name for this processor. + /// + string Name { get; } + + /// + /// Fetches all entities from the source newer than and returns the latest timestamp found. + /// + Task FetchSince(DateTime cursor); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/IncidentUpdater.cs b/src/StatusAggregator/Collector/IncidentEntityCollectorProcessor.cs similarity index 56% rename from src/StatusAggregator/IncidentUpdater.cs rename to src/StatusAggregator/Collector/IncidentEntityCollectorProcessor.cs index 3cae1697e..915799089 100644 --- a/src/StatusAggregator/IncidentUpdater.cs +++ b/src/StatusAggregator/Collector/IncidentEntityCollectorProcessor.cs @@ -1,37 +1,39 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.Extensions.Logging; using NuGet.Jobs.Extensions; using NuGet.Services.Incidents; using NuGet.Services.Status.Table; +using StatusAggregator.Factory; using StatusAggregator.Parse; -using StatusAggregator.Table; using System; using System.Linq; using System.Threading.Tasks; -namespace StatusAggregator +namespace StatusAggregator.Collector { - public class IncidentUpdater : IIncidentUpdater + /// + /// Fetches new s using an . + /// + public class IncidentEntityCollectorProcessor : IEntityCollectorProcessor { - private readonly ITableWrapper _table; + public const string IncidentsCollectorName = "incidents"; + private readonly IAggregateIncidentParser _aggregateIncidentParser; private readonly IIncidentApiClient _incidentApiClient; - private readonly IIncidentFactory _incidentFactory; - private readonly ILogger _logger; + private readonly IComponentAffectingEntityFactory _incidentFactory; + private readonly ILogger _logger; private readonly string _incidentApiTeamId; - public IncidentUpdater( - ITableWrapper table, + public IncidentEntityCollectorProcessor( IIncidentApiClient incidentApiClient, IAggregateIncidentParser aggregateIncidentParser, - IIncidentFactory incidentFactory, + IComponentAffectingEntityFactory incidentFactory, StatusAggregatorConfiguration configuration, - ILogger logger) + ILogger logger) { - _table = table ?? throw new ArgumentNullException(nameof(table)); _incidentApiClient = incidentApiClient ?? throw new ArgumentNullException(nameof(incidentApiClient)); _aggregateIncidentParser = aggregateIncidentParser ?? throw new ArgumentNullException(nameof(aggregateIncidentParser)); _incidentFactory = incidentFactory ?? throw new ArgumentNullException(nameof(incidentFactory)); @@ -39,30 +41,9 @@ public IncidentUpdater( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task RefreshActiveIncidents() - { - using (_logger.Scope("Refreshing active incidents.")) - { - var activeIncidentEntities = _table - .CreateQuery() - .Where(i => i.PartitionKey == IncidentEntity.DefaultPartitionKey && i.IsActive) - .ToList(); - - _logger.LogInformation("Refreshing {ActiveIncidentsCount} active incidents.", activeIncidentEntities.Count()); - foreach (var activeIncidentEntity in activeIncidentEntities) - { - using (_logger.Scope("Refreshing active incident '{IncidentRowKey}'.", activeIncidentEntity.RowKey)) - { - var activeIncident = await _incidentApiClient.GetIncident(activeIncidentEntity.IncidentApiId); - activeIncidentEntity.MitigationTime = activeIncident.MitigationData?.Date; - _logger.LogInformation("Updated mitigation time of active incident to {MitigationTime}", activeIncidentEntity.MitigationTime); - await _table.InsertOrReplaceAsync(activeIncidentEntity); - } - } - } - } + public string Name => IncidentsCollectorName; - public async Task FetchNewIncidents(DateTime cursor) + public async Task FetchSince(DateTime cursor) { using (_logger.Scope("Fetching all new incidents since {Cursor}.", cursor)) { @@ -73,12 +54,19 @@ public async Task RefreshActiveIncidents() .Where(i => i.CreateDate > cursor) .ToList(); + _logger.LogInformation("Found {IncidentCount} incidents to parse.", incidents.Count); var parsedIncidents = incidents - .SelectMany(i => _aggregateIncidentParser.ParseIncident(i)) + .SelectMany(_aggregateIncidentParser.ParseIncident) .ToList(); - foreach (var parsedIncident in parsedIncidents.OrderBy(i => i.CreationTime)) + + _logger.LogInformation("Parsed {ParsedIncidentCount} incidents.", parsedIncidents.Count); + foreach (var parsedIncident in parsedIncidents.OrderBy(i => i.StartTime)) { - await _incidentFactory.CreateIncident(parsedIncident); + using (_logger.Scope("Creating incident for parsed incident with ID {ParsedIncidentID} affecting {ParsedIncidentPath} at {ParsedIncidentStartTime} with status {ParsedIncidentStatus}.", + parsedIncident.Id, parsedIncident.AffectedComponentPath, parsedIncident.StartTime, parsedIncident.AffectedComponentStatus)) + { + await _incidentFactory.CreateAsync(parsedIncident); + } } return incidents.Any() ? incidents.Max(i => i.CreateDate) : (DateTime?)null; @@ -97,4 +85,4 @@ private string GetRecentIncidentsQuery(DateTime cursor) return query; } } -} +} \ No newline at end of file diff --git a/src/StatusAggregator/Manual/ManualStatusChangeUpdater.cs b/src/StatusAggregator/Collector/ManualStatusChangeCollectorProcessor.cs similarity index 66% rename from src/StatusAggregator/Manual/ManualStatusChangeUpdater.cs rename to src/StatusAggregator/Collector/ManualStatusChangeCollectorProcessor.cs index ac0bd8819..e719e79b1 100644 --- a/src/StatusAggregator/Manual/ManualStatusChangeUpdater.cs +++ b/src/StatusAggregator/Collector/ManualStatusChangeCollectorProcessor.cs @@ -1,29 +1,36 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.Extensions.Logging; using NuGet.Jobs.Extensions; using NuGet.Services.Status.Table.Manual; +using StatusAggregator.Manual; using StatusAggregator.Table; using System; using System.Linq; using System.Threading.Tasks; -namespace StatusAggregator.Manual +namespace StatusAggregator.Collector { - public class ManualStatusChangeUpdater : IManualStatusChangeUpdater + /// + /// Fetches new s using an . + /// + public class ManualStatusChangeCollectorProcessor : IEntityCollectorProcessor { + public const string ManualCollectorNamePrefix = "manual"; + private readonly ITableWrapper _table; private readonly IManualStatusChangeHandler _handler; - private readonly ILogger _logger; + private readonly ILogger _logger; - public ManualStatusChangeUpdater( + public ManualStatusChangeCollectorProcessor( string name, ITableWrapper table, IManualStatusChangeHandler handler, - ILogger logger) + ILogger logger) { - Name = name; + Name = ManualCollectorNamePrefix + + (name ?? throw new ArgumentNullException(nameof(name))); _table = table ?? throw new ArgumentNullException(nameof(table)); _handler = handler ?? throw new ArgumentNullException(nameof(handler)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -31,13 +38,12 @@ public ManualStatusChangeUpdater( public string Name { get; } - public async Task ProcessNewManualChanges(DateTime cursor) + public async Task FetchSince(DateTime cursor) { using (_logger.Scope("Processing manual status changes.")) { var manualChangesQuery = _table - .CreateQuery() - .Where(c => c.PartitionKey == ManualStatusChangeEntity.DefaultPartitionKey); + .CreateQuery(); // Table storage throws on queries with DateTime values that are too low. // If we are fetching manual changes for the first time, don't filter on the timestamp. @@ -49,7 +55,7 @@ public ManualStatusChangeUpdater( var manualChanges = manualChangesQuery.ToList(); _logger.LogInformation("Processing {ManualChangesCount} manual status changes.", manualChanges.Count()); - foreach (var manualChange in manualChanges) + foreach (var manualChange in manualChanges.OrderBy(m => m.Timestamp)) { await _handler.Handle(_table, manualChange); } @@ -58,4 +64,4 @@ public ManualStatusChangeUpdater( } } } -} +} \ No newline at end of file diff --git a/src/StatusAggregator/Container/ContainerWrapper.cs b/src/StatusAggregator/Container/ContainerWrapper.cs new file mode 100644 index 000000000..332a20273 --- /dev/null +++ b/src/StatusAggregator/Container/ContainerWrapper.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.WindowsAzure.Storage.Blob; + +namespace StatusAggregator.Container +{ + public class ContainerWrapper : IContainerWrapper + { + private readonly CloudBlobContainer _container; + + public ContainerWrapper(CloudBlobContainer container) + { + _container = container; + } + + public Task CreateIfNotExistsAsync() + { + return _container.CreateIfNotExistsAsync(); + } + + public Task SaveBlobAsync(string name, string contents) + { + var blob = _container.GetBlockBlobReference(name); + return blob.UploadTextAsync(contents); + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Container/IContainerWrapper.cs b/src/StatusAggregator/Container/IContainerWrapper.cs new file mode 100644 index 000000000..68f097cb7 --- /dev/null +++ b/src/StatusAggregator/Container/IContainerWrapper.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.WindowsAzure.Storage.Blob; + +namespace StatusAggregator.Container +{ + /// + /// Simple wrapper for that exists for unit-testing. + /// + public interface IContainerWrapper + { + Task CreateIfNotExistsAsync(); + + Task SaveBlobAsync(string name, string contents); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/EventUpdater.cs b/src/StatusAggregator/EventUpdater.cs deleted file mode 100644 index 83e7cb22b..000000000 --- a/src/StatusAggregator/EventUpdater.cs +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using NuGet.Jobs.Extensions; -using NuGet.Services.Status.Table; -using StatusAggregator.Table; - -namespace StatusAggregator -{ - public class EventUpdater : IEventUpdater - { - public readonly TimeSpan _eventEndDelay; - - private readonly ITableWrapper _table; - private readonly IMessageUpdater _messageUpdater; - - private readonly ILogger _logger; - - public EventUpdater( - ITableWrapper table, - IMessageUpdater messageUpdater, - StatusAggregatorConfiguration configuration, - ILogger logger) - { - _table = table ?? throw new ArgumentNullException(nameof(table)); - _messageUpdater = messageUpdater ?? throw new ArgumentNullException(nameof(messageUpdater)); - _eventEndDelay = TimeSpan.FromMinutes(configuration?.EventEndDelayMinutes ?? throw new ArgumentNullException(nameof(configuration))); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task UpdateActiveEvents(DateTime cursor) - { - using (_logger.Scope("Updating active events.")) - { - var activeEvents = _table.GetActiveEvents().ToList(); - _logger.LogInformation("Updating {ActiveEventsCount} active events.", activeEvents.Count()); - foreach (var activeEvent in activeEvents) - { - await UpdateEvent(activeEvent, cursor); - } - } - } - - public async Task UpdateEvent(EventEntity eventEntity, DateTime cursor) - { - eventEntity = eventEntity ?? throw new ArgumentNullException(nameof(eventEntity)); - - using (_logger.Scope("Updating event '{EventRowKey}' given cursor {Cursor}.", eventEntity.RowKey, cursor)) - { - if (!eventEntity.IsActive) - { - _logger.LogInformation("Event is inactive, cannot update."); - return false; - } - - var incidentsLinkedToEventQuery = _table.GetIncidentsLinkedToEvent(eventEntity); - - var incidentsLinkedToEvent = incidentsLinkedToEventQuery.ToList(); - if (!incidentsLinkedToEvent.Any()) - { - _logger.LogInformation("Event has no linked incidents and must have been created manually, cannot update."); - return false; - } - - // We are querying twice here because table storage ignores rows where a column specified by a query is null. - // MitigationTime is null when IsActive is true. - // If we do not query separately here, rows where IsActive is true will be ignored in query results. - - var hasActiveIncidents = incidentsLinkedToEventQuery - .Where(i => i.IsActive) - .ToList() - .Any(); - - var hasRecentIncidents = incidentsLinkedToEventQuery - .Where(i => i.MitigationTime > cursor - _eventEndDelay) - .ToList() - .Any(); - - var shouldDeactivate = !(hasActiveIncidents || hasRecentIncidents); - if (shouldDeactivate) - { - _logger.LogInformation("Deactivating event because its incidents are inactive and too old."); - var mitigationTime = incidentsLinkedToEvent - .Max(i => i.MitigationTime ?? DateTime.MinValue); - eventEntity.EndTime = mitigationTime; - - await _messageUpdater.CreateMessageForEventStart(eventEntity, mitigationTime); - await _messageUpdater.CreateMessageForEventEnd(eventEntity); - - // Update the event - await _table.InsertOrReplaceAsync(eventEntity); - } - else - { - _logger.LogInformation("Event has active or recent incidents so it will not be deactivated."); - await _messageUpdater.CreateMessageForEventStart(eventEntity, cursor); - } - - return shouldDeactivate; - } - } - } -} diff --git a/src/StatusAggregator/Export/ComponentExporter.cs b/src/StatusAggregator/Export/ComponentExporter.cs new file mode 100644 index 000000000..66c4da59d --- /dev/null +++ b/src/StatusAggregator/Export/ComponentExporter.cs @@ -0,0 +1,91 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Factory; +using StatusAggregator.Table; + +namespace StatusAggregator.Export +{ + public class ComponentExporter : IComponentExporter + { + private readonly ITableWrapper _table; + private readonly IComponentFactory _factory; + + private readonly ILogger _logger; + + public ComponentExporter( + ITableWrapper table, + IComponentFactory factory, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public IComponent Export() + { + using (_logger.Scope("Exporting active entities to component.")) + { + var rootComponent = _factory.Create(); + + // Apply the active entities to the component tree. + var activeEvents = _table + .GetActiveEntities() + .ToList() + .Where(e => + _table + .GetChildEntities(e) + .ToList() + .Any()) + .ToList(); + + _logger.LogInformation("Found {EventCount} active events with messages.", activeEvents.Count); + + var activeIncidentGroups = activeEvents + .SelectMany(e => + _table + .GetChildEntities(e) + .Where(i => i.IsActive) + .ToList()) + .ToList(); + + _logger.LogInformation("Found {GroupCount} active incident groups linked to active events with messages.", activeIncidentGroups.Count); + + var activeEntities = activeIncidentGroups + .Concat(activeEvents) + // Only apply entities with a non-Up status. + .Where(e => e.AffectedComponentStatus != (int)ComponentStatus.Up) + // If multiple events are affecting a single region, the event with the highest severity should affect the component. + .GroupBy(e => e.AffectedComponentPath) + .Select(g => g.OrderByDescending(e => e.AffectedComponentStatus).First()) + .ToList(); + + _logger.LogInformation("Active entities affect {PathCount} distinct subcomponents.", activeEntities.Count); + foreach (var activeEntity in activeEntities) + { + using (_logger.Scope("Applying active entity affecting {AffectedComponentPath} of severity {AffectedComponentStatus} at {StartTime} to root component", + activeEntity.AffectedComponentPath, activeEntity.AffectedComponentStatus, activeEntity.StartTime)) + { + var currentComponent = rootComponent.GetByPath(activeEntity.AffectedComponentPath); + + if (currentComponent == null) + { + throw new InvalidOperationException($"Couldn't find component with path {activeEntity.AffectedComponentPath} corresponding to active entities."); + } + + currentComponent.Status = (ComponentStatus)activeEntity.AffectedComponentStatus; + } + } + + return rootComponent; + } + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Export/EventExporter.cs b/src/StatusAggregator/Export/EventExporter.cs new file mode 100644 index 000000000..86e81458a --- /dev/null +++ b/src/StatusAggregator/Export/EventExporter.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Table; + +namespace StatusAggregator.Export +{ + public class EventExporter : IEventExporter + { + private readonly ITableWrapper _table; + private readonly ILogger _logger; + + public EventExporter( + ITableWrapper table, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Event Export(EventEntity eventEntity) + { + using (_logger.Scope("Exporting event {EventRowKey}.", eventEntity.RowKey)) + { + var messages = _table.GetChildEntities(eventEntity) + .ToList() + // Don't show empty messages. + .Where(m => !string.IsNullOrEmpty(m.Contents)) + .ToList(); + + _logger.LogInformation("Event has {MessageCount} messages that are not empty.", messages.Count); + + if (!messages.Any()) + { + return null; + } + + return new Event( + eventEntity.AffectedComponentPath, + eventEntity.StartTime, + eventEntity.EndTime, + messages + .OrderBy(m => m.Time) + .Select(m => new Message(m.Time, m.Contents))); + } + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Export/EventsExporter.cs b/src/StatusAggregator/Export/EventsExporter.cs new file mode 100644 index 000000000..c0bdda3d2 --- /dev/null +++ b/src/StatusAggregator/Export/EventsExporter.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Table; + +namespace StatusAggregator.Export +{ + public class EventsExporter : IEventsExporter + { + private readonly TimeSpan _eventVisibilityPeriod; + + private readonly ITableWrapper _table; + private readonly IEventExporter _exporter; + + private readonly ILogger _logger; + + public EventsExporter( + ITableWrapper table, + IEventExporter exporter, + StatusAggregatorConfiguration configuration, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _exporter = exporter ?? throw new ArgumentNullException(nameof(exporter)); + _eventVisibilityPeriod = TimeSpan.FromDays(configuration?.EventVisibilityPeriodDays ?? throw new ArgumentNullException(nameof(configuration))); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public IEnumerable Export(DateTime cursor) + { + return _table + .CreateQuery() + .Where(e => e.IsActive || (e.EndTime >= cursor - _eventVisibilityPeriod)) + .ToList() + .Select(_exporter.Export) + .Where(e => e != null) + .ToList(); + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Export/IComponentExporter.cs b/src/StatusAggregator/Export/IComponentExporter.cs new file mode 100644 index 000000000..3aad5befd --- /dev/null +++ b/src/StatusAggregator/Export/IComponentExporter.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using NuGet.Services.Status; + +namespace StatusAggregator.Export +{ + public interface IComponentExporter + { + /// + /// Exports the status of the current active entities to an . + /// + IComponent Export(); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Export/IEventExporter.cs b/src/StatusAggregator/Export/IEventExporter.cs new file mode 100644 index 000000000..3e924f384 --- /dev/null +++ b/src/StatusAggregator/Export/IEventExporter.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using NuGet.Services.Status; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Export +{ + public interface IEventExporter + { + /// + /// Exports as a . If it should not be exported, returns null. + /// + Event Export(EventEntity eventEntity); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Export/IEventsExporter.cs b/src/StatusAggregator/Export/IEventsExporter.cs new file mode 100644 index 000000000..e2261210d --- /dev/null +++ b/src/StatusAggregator/Export/IEventsExporter.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using NuGet.Services.Status; + +namespace StatusAggregator.Export +{ + public interface IEventsExporter + { + /// + /// Exports recent events. + /// + IEnumerable Export(DateTime cursor); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/IStatusExporter.cs b/src/StatusAggregator/Export/IStatusExporter.cs similarity index 73% rename from src/StatusAggregator/IStatusExporter.cs rename to src/StatusAggregator/Export/IStatusExporter.cs index 0ed21b316..e60d81f66 100644 --- a/src/StatusAggregator/IStatusExporter.cs +++ b/src/StatusAggregator/Export/IStatusExporter.cs @@ -1,16 +1,17 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using NuGet.Services.Status; +using System; using System.Threading.Tasks; -namespace StatusAggregator +namespace StatusAggregator.Export { public interface IStatusExporter { /// /// Builds a and exports it to public storage so that it can be consumed by other services. /// - Task Export(); + Task Export(DateTime cursor); } -} +} \ No newline at end of file diff --git a/src/StatusAggregator/Export/IStatusSerializer.cs b/src/StatusAggregator/Export/IStatusSerializer.cs new file mode 100644 index 000000000..fea2d31c2 --- /dev/null +++ b/src/StatusAggregator/Export/IStatusSerializer.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using NuGet.Services.Status; + +namespace StatusAggregator.Export +{ + public interface IStatusSerializer + { + /// + /// Serializes and and saves to storage with a time of . + /// + Task Serialize(DateTime cursor, IComponent rootComponent, IEnumerable recentEvents); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Export/StatusExporter.cs b/src/StatusAggregator/Export/StatusExporter.cs new file mode 100644 index 000000000..467bf861d --- /dev/null +++ b/src/StatusAggregator/Export/StatusExporter.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; + +namespace StatusAggregator.Export +{ + public class StatusExporter : IStatusExporter + { + private readonly IComponentExporter _componentExporter; + private readonly IEventsExporter _eventExporter; + private readonly IStatusSerializer _serializer; + + private readonly ILogger _logger; + + public StatusExporter( + IComponentExporter componentExporter, + IEventsExporter eventExporter, + IStatusSerializer serializer, + ILogger logger) + { + _componentExporter = componentExporter ?? throw new ArgumentNullException(nameof(componentExporter)); + _eventExporter = eventExporter ?? throw new ArgumentNullException(nameof(eventExporter)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task Export(DateTime cursor) + { + using (_logger.Scope("Exporting service status.")) + { + var rootComponent = _componentExporter.Export(); + var recentEvents = _eventExporter.Export(cursor); + return _serializer.Serialize(cursor, rootComponent, recentEvents); + } + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Export/StatusSerializer.cs b/src/StatusAggregator/Export/StatusSerializer.cs new file mode 100644 index 000000000..d10340db5 --- /dev/null +++ b/src/StatusAggregator/Export/StatusSerializer.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status; +using StatusAggregator.Container; + +namespace StatusAggregator.Export +{ + public class StatusSerializer : IStatusSerializer + { + public const string StatusBlobName = "status.json"; + + private readonly IContainerWrapper _container; + + private readonly ILogger _logger; + + public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings() + { + Converters = new List() { new StringEnumConverter() }, + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + NullValueHandling = NullValueHandling.Ignore + }; + + public StatusSerializer( + IContainerWrapper container, + ILogger logger) + { + _container = container ?? throw new ArgumentNullException(nameof(container)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Serialize(DateTime cursor, IComponent rootComponent, IEnumerable recentEvents) + { + ServiceStatus status; + string statusJson; + using (_logger.Scope("Serializing service status.")) + { + status = new ServiceStatus(cursor, rootComponent, recentEvents); + statusJson = JsonConvert.SerializeObject(status, Settings); + } + + using (_logger.Scope("Saving service status to blob storage.")) + { + await _container.SaveBlobAsync(StatusBlobName, statusJson); + } + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Factory/AggregationProvider.cs b/src/StatusAggregator/Factory/AggregationProvider.cs new file mode 100644 index 000000000..585765261 --- /dev/null +++ b/src/StatusAggregator/Factory/AggregationProvider.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Services.Status.Table; +using StatusAggregator.Parse; +using StatusAggregator.Table; + +namespace StatusAggregator.Factory +{ + public class AggregationProvider + : IAggregationProvider + where TChildEntity : AggregatedComponentAffectingEntity, new() + where TAggregationEntity : ComponentAffectingEntity, new() + { + private readonly ITableWrapper _table; + private readonly IAffectedComponentPathProvider _aggregationPathProvider; + private readonly IAggregationStrategy _strategy; + private readonly IComponentAffectingEntityFactory _aggregationFactory; + + private readonly ILogger> _logger; + + public AggregationProvider( + ITableWrapper table, + IAffectedComponentPathProvider aggregationPathProvider, + IAggregationStrategy strategy, + IComponentAffectingEntityFactory aggregationFactory, + ILogger> logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _aggregationPathProvider = aggregationPathProvider ?? throw new ArgumentNullException(nameof(aggregationPathProvider)); + _strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); + _aggregationFactory = aggregationFactory ?? throw new ArgumentNullException(nameof(aggregationFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetAsync(ParsedIncident input) + { + TAggregationEntity aggregationEntity = null; + + var possiblePath = _aggregationPathProvider.Get(input); + // Find an aggregation to link to + var possibleAggregations = _table + .CreateQuery() + .Where(e => + // The aggregation must affect the same path + e.AffectedComponentPath == possiblePath && + // The aggregation must begin before or at the same time + e.StartTime <= input.StartTime && + // The aggregation must cover the same time period + ( + // If the aggregation is active, it covers the same time period + e.IsActive || + // Otherwise, if the child is not active, and the aggregation ends after it ends, it covers the same time period + (!input.IsActive && e.EndTime >= input.EndTime) + )) + .ToList(); + + _logger.LogInformation("Found {AggregationCount} possible aggregations to link entity to with path {AffectedComponentPath}.", possibleAggregations.Count(), possiblePath); + foreach (var possibleAggregation in possibleAggregations) + { + if (await _strategy.CanBeAggregatedByAsync(input, possibleAggregation)) + { + _logger.LogInformation("Linking entity to aggregation."); + aggregationEntity = possibleAggregation; + break; + } + } + + if (aggregationEntity == null) + { + _logger.LogInformation("Could not find existing aggregation to link to, creating new aggregation to link entity to."); + aggregationEntity = await _aggregationFactory.CreateAsync(input); + _logger.LogInformation("Created new aggregation {AggregationRowKey} to link entity to.", aggregationEntity.RowKey); + } + + return aggregationEntity; + } + } +} diff --git a/src/StatusAggregator/Factory/AggregationStrategy.cs b/src/StatusAggregator/Factory/AggregationStrategy.cs new file mode 100644 index 000000000..93d2cddd6 --- /dev/null +++ b/src/StatusAggregator/Factory/AggregationStrategy.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status.Table; +using StatusAggregator.Parse; +using StatusAggregator.Table; +using StatusAggregator.Update; + +namespace StatusAggregator.Factory +{ + public class AggregationStrategy + : IAggregationStrategy + where TChildEntity : AggregatedComponentAffectingEntity, new() + where TAggregationEntity : ComponentAffectingEntity, new() + { + private readonly ITableWrapper _table; + private readonly IComponentAffectingEntityUpdater _aggregationUpdater; + + private readonly ILogger> _logger; + + public AggregationStrategy( + ITableWrapper table, + IComponentAffectingEntityUpdater aggregationUpdater, + ILogger> logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _aggregationUpdater = aggregationUpdater ?? throw new ArgumentNullException(nameof(aggregationUpdater)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CanBeAggregatedByAsync(ParsedIncident input, TAggregationEntity aggregationEntity) + { + using (_logger.Scope("Determining if entity can be linked to aggregation {AggregationRowKey}", aggregationEntity.RowKey)) + { + if (!_table.GetChildEntities(aggregationEntity).ToList().Any()) + { + // A manually created aggregation will have no children. We cannot use an aggregation that was manually created. + // It is also possible that some bug or data issue has broken this aggregation. If that is the case, we cannot use it either. + _logger.LogInformation("Cannot link entity to aggregation because it is not linked to any children."); + return false; + } + + // To guarantee that the aggregation reflects the latest information and is actually active, we must update it. + await _aggregationUpdater.UpdateAsync(aggregationEntity, input.StartTime); + if (!aggregationEntity.IsActive && input.IsActive) + { + _logger.LogInformation("Cannot link entity to aggregation because it has been deactivated and the incident has not been."); + return false; + } + + _logger.LogInformation("Entity can be linked to aggregation."); + return true; + } + } + } +} diff --git a/src/StatusAggregator/Factory/EventAffectedComponentPathProvider.cs b/src/StatusAggregator/Factory/EventAffectedComponentPathProvider.cs new file mode 100644 index 000000000..cd1293024 --- /dev/null +++ b/src/StatusAggregator/Factory/EventAffectedComponentPathProvider.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Parse; + +namespace StatusAggregator.Factory +{ + public class EventAffectedComponentPathProvider : IAffectedComponentPathProvider + { + /// + /// s should be created using the path of 's level 1 ancestor. + /// + public string Get(ParsedIncident input) + { + var pathParts = ComponentUtility.GetNames(input.AffectedComponentPath); + var topLevelComponentPathParts = pathParts.Take(2).ToArray(); + return ComponentUtility.GetPath(topLevelComponentPathParts); + } + } +} diff --git a/src/StatusAggregator/Factory/EventFactory.cs b/src/StatusAggregator/Factory/EventFactory.cs new file mode 100644 index 000000000..135325f3e --- /dev/null +++ b/src/StatusAggregator/Factory/EventFactory.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status.Table; +using StatusAggregator.Parse; +using StatusAggregator.Table; + +namespace StatusAggregator.Factory +{ + public class EventFactory : IComponentAffectingEntityFactory + { + private readonly ITableWrapper _table; + private readonly IAffectedComponentPathProvider _pathProvider; + + private readonly ILogger _logger; + + public EventFactory( + ITableWrapper table, + IAffectedComponentPathProvider pathProvider, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _pathProvider = pathProvider ?? throw new ArgumentNullException(nameof(pathProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CreateAsync(ParsedIncident input) + { + var affectedPath = _pathProvider.Get(input); + using (_logger.Scope("Creating event for parsed incident with path {AffectedComponentPath}.", affectedPath)) + { + var entity = new EventEntity(affectedPath, input.StartTime); + await _table.InsertOrReplaceAsync(entity); + + return entity; + } + } + } +} diff --git a/src/StatusAggregator/Factory/IAffectedComponentPathProvider.cs b/src/StatusAggregator/Factory/IAffectedComponentPathProvider.cs new file mode 100644 index 000000000..9e634e361 --- /dev/null +++ b/src/StatusAggregator/Factory/IAffectedComponentPathProvider.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using NuGet.Services.Status.Table; +using StatusAggregator.Parse; + +namespace StatusAggregator.Factory +{ + public interface IAffectedComponentPathProvider + where T : IComponentAffectingEntity + { + /// + /// Returns the to use to create an instance of for . + /// + string Get(ParsedIncident input); + } +} diff --git a/src/StatusAggregator/Factory/IAggregationProvider.cs b/src/StatusAggregator/Factory/IAggregationProvider.cs new file mode 100644 index 000000000..e1e9e182b --- /dev/null +++ b/src/StatusAggregator/Factory/IAggregationProvider.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using NuGet.Services.Status.Table; +using StatusAggregator.Parse; + +namespace StatusAggregator.Factory +{ + public interface IAggregationProvider + where TChildEntity : AggregatedComponentAffectingEntity + where TAggregationEntity : ComponentAffectingEntity + { + /// + /// Gets an aggregation that matches . + /// + Task GetAsync(ParsedIncident input); + } +} diff --git a/src/StatusAggregator/Factory/IAggregationStrategy.cs b/src/StatusAggregator/Factory/IAggregationStrategy.cs new file mode 100644 index 000000000..41685640c --- /dev/null +++ b/src/StatusAggregator/Factory/IAggregationStrategy.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using NuGet.Services.Status.Table; +using StatusAggregator.Parse; + +namespace StatusAggregator.Factory +{ + public interface IAggregationStrategy + where TAggregationEntity : ComponentAffectingEntity + { + /// + /// Returns whether or not an entity built from using a can be aggregated by . + /// + Task CanBeAggregatedByAsync(ParsedIncident input, TAggregationEntity aggregationEntity); + } +} diff --git a/src/StatusAggregator/Factory/IComponentAffectingEntityFactory.cs b/src/StatusAggregator/Factory/IComponentAffectingEntityFactory.cs new file mode 100644 index 000000000..3a246eee4 --- /dev/null +++ b/src/StatusAggregator/Factory/IComponentAffectingEntityFactory.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.WindowsAzure.Storage.Table; +using StatusAggregator.Parse; + +namespace StatusAggregator.Factory +{ + /// + /// Creates a given a . + /// + public interface IComponentAffectingEntityFactory + where TEntity : TableEntity + { + Task CreateAsync(ParsedIncident input); + } +} diff --git a/src/StatusAggregator/Factory/IComponentFactory.cs b/src/StatusAggregator/Factory/IComponentFactory.cs new file mode 100644 index 000000000..e586669b1 --- /dev/null +++ b/src/StatusAggregator/Factory/IComponentFactory.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using NuGet.Services.Status; + +namespace StatusAggregator.Factory +{ + public interface IComponentFactory + { + /// + /// Returns the root component that describes the service. + /// + IComponent Create(); + } +} diff --git a/src/StatusAggregator/Factory/IncidentAffectedComponentPathProvider.cs b/src/StatusAggregator/Factory/IncidentAffectedComponentPathProvider.cs new file mode 100644 index 000000000..46199e1f5 --- /dev/null +++ b/src/StatusAggregator/Factory/IncidentAffectedComponentPathProvider.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using NuGet.Services.Status.Table; +using StatusAggregator.Parse; + +namespace StatusAggregator.Factory +{ + public class IncidentAffectedComponentPathProvider : IAffectedComponentPathProvider, IAffectedComponentPathProvider + { + /// + /// s and s should be created using the same path as the . + /// + public string Get(ParsedIncident input) + { + return input.AffectedComponentPath; + } + } +} diff --git a/src/StatusAggregator/Factory/IncidentFactory.cs b/src/StatusAggregator/Factory/IncidentFactory.cs new file mode 100644 index 000000000..2d63e9f0d --- /dev/null +++ b/src/StatusAggregator/Factory/IncidentFactory.cs @@ -0,0 +1,63 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Parse; +using StatusAggregator.Table; + +namespace StatusAggregator.Factory +{ + public class IncidentFactory : IComponentAffectingEntityFactory + { + private readonly ITableWrapper _table; + private readonly IAggregationProvider _aggregationProvider; + private readonly IAffectedComponentPathProvider _pathProvider; + + private readonly ILogger _logger; + + public IncidentFactory( + ITableWrapper table, + IAggregationProvider aggregationProvider, + IAffectedComponentPathProvider pathProvider, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _aggregationProvider = aggregationProvider ?? throw new ArgumentNullException(nameof(aggregationProvider)); + _pathProvider = pathProvider ?? throw new ArgumentNullException(nameof(pathProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CreateAsync(ParsedIncident input) + { + var groupEntity = await _aggregationProvider.GetAsync(input); + var affectedPath = _pathProvider.Get(input); + using (_logger.Scope("Creating incident for parsed incident with path {AffectedComponentPath}.", affectedPath)) + { + var incidentEntity = new IncidentEntity( + input.Id, + groupEntity, + affectedPath, + (ComponentStatus)input.AffectedComponentStatus, + input.StartTime, + input.EndTime); + + await _table.InsertOrReplaceAsync(incidentEntity); + + if (incidentEntity.AffectedComponentStatus > groupEntity.AffectedComponentStatus) + { + _logger.LogInformation("Incident {IncidentRowKey} has a greater severity than incident group {GroupRowKey} it was just linked to ({NewSeverity} > {OldSeverity}), updating group's severity.", + incidentEntity.RowKey, groupEntity.RowKey, (ComponentStatus)incidentEntity.AffectedComponentStatus, (ComponentStatus)groupEntity.AffectedComponentStatus); + groupEntity.AffectedComponentStatus = incidentEntity.AffectedComponentStatus; + await _table.ReplaceAsync(groupEntity); + } + + return incidentEntity; + } + } + } +} diff --git a/src/StatusAggregator/Factory/IncidentGroupFactory.cs b/src/StatusAggregator/Factory/IncidentGroupFactory.cs new file mode 100644 index 000000000..ed2b3d693 --- /dev/null +++ b/src/StatusAggregator/Factory/IncidentGroupFactory.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Parse; +using StatusAggregator.Table; + +namespace StatusAggregator.Factory +{ + public class IncidentGroupFactory : IComponentAffectingEntityFactory + { + private readonly ITableWrapper _table; + private readonly IAggregationProvider _aggregationProvider; + private readonly IAffectedComponentPathProvider _pathProvider; + + private readonly ILogger _logger; + + public IncidentGroupFactory( + ITableWrapper table, + IAggregationProvider aggregationProvider, + IAffectedComponentPathProvider pathProvider, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _aggregationProvider = aggregationProvider ?? throw new ArgumentNullException(nameof(aggregationProvider)); + _pathProvider = pathProvider ?? throw new ArgumentNullException(nameof(pathProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CreateAsync(ParsedIncident input) + { + var eventEntity = await _aggregationProvider.GetAsync(input); + var affectedPath = _pathProvider.Get(input); + using (_logger.Scope("Creating incident for parsed incident with path {AffectedComponentPath}.", affectedPath)) + { + var incidentGroupEntity = new IncidentGroupEntity( + eventEntity, + affectedPath, + (ComponentStatus)input.AffectedComponentStatus, + input.StartTime); + + await _table.InsertOrReplaceAsync(incidentGroupEntity); + + return incidentGroupEntity; + } + } + } +} diff --git a/src/StatusAggregator/NuGetServiceComponentFactory.cs b/src/StatusAggregator/Factory/NuGetServiceComponentFactory.cs similarity index 92% rename from src/StatusAggregator/NuGetServiceComponentFactory.cs rename to src/StatusAggregator/Factory/NuGetServiceComponentFactory.cs index 9d7407004..8c28b4509 100644 --- a/src/StatusAggregator/NuGetServiceComponentFactory.cs +++ b/src/StatusAggregator/Factory/NuGetServiceComponentFactory.cs @@ -3,12 +3,12 @@ using NuGet.Services.Status; -namespace StatusAggregator +namespace StatusAggregator.Factory { /// /// Helps create an that represents the NuGet service as well as paths to its subcomponents. /// - public static class NuGetServiceComponentFactory + public class NuGetServiceComponentFactory : IComponentFactory { public const string RootName = "NuGet"; public const string GalleryName = "NuGet.org"; @@ -26,11 +26,8 @@ public static class NuGetServiceComponentFactory public const string UsscInstanceName = "South Central US"; public const string EaInstanceName = "East Asia"; public const string SeaInstanceName = "Southeast Asia"; - - /// - /// Creates an that represents the NuGet service. - /// - public static IComponent CreateNuGetServiceRootComponent() + + public IComponent Create() { return new TreeComponent( RootName, @@ -59,7 +56,7 @@ public static IComponent CreateNuGetServiceRootComponent() new LeafComponent(ChinaRegionName, "V3 restore for users inside China") }), new PrimarySecondaryComponent( - V2ProtocolName, + V2ProtocolName, "Restore using the V2 API", new[] { diff --git a/src/StatusAggregator/ICursor.cs b/src/StatusAggregator/ICursor.cs deleted file mode 100644 index f480446a1..000000000 --- a/src/StatusAggregator/ICursor.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Threading.Tasks; - -namespace StatusAggregator -{ - /// - /// Maintains the current progress of the job. - /// - public interface ICursor - { - Task Get(string name); - Task Set(string name, DateTime value); - } -} diff --git a/src/StatusAggregator/IEventUpdater.cs b/src/StatusAggregator/IEventUpdater.cs deleted file mode 100644 index e32650d4a..000000000 --- a/src/StatusAggregator/IEventUpdater.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Threading.Tasks; -using NuGet.Services.Status.Table; - -namespace StatusAggregator -{ - /// - /// Handles updating any active s. - /// - public interface IEventUpdater - { - /// - /// Updates all active s. - /// - /// The current timestamp processed by the job. - Task UpdateActiveEvents(DateTime cursor); - - /// - /// Update given . - /// Determines whether or not to deactivate and updates any messages associated with the event. - /// - /// The current timestamp processed by the job. - /// Whether or not was deactivated. - Task UpdateEvent(EventEntity eventEntity, DateTime cursor); - } -} diff --git a/src/StatusAggregator/IIncidentFactory.cs b/src/StatusAggregator/IIncidentFactory.cs deleted file mode 100644 index 975507d8a..000000000 --- a/src/StatusAggregator/IIncidentFactory.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System.Threading.Tasks; -using NuGet.Services.Status.Table; -using StatusAggregator.Parse; - -namespace StatusAggregator -{ - /// - /// Handles creation of s. - /// - public interface IIncidentFactory - { - /// - /// Creates a from and persists it in storage. - /// - Task CreateIncident(ParsedIncident parsedIncident); - } -} diff --git a/src/StatusAggregator/IIncidentUpdater.cs b/src/StatusAggregator/IIncidentUpdater.cs deleted file mode 100644 index d0be4ba69..000000000 --- a/src/StatusAggregator/IIncidentUpdater.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using NuGet.Services.Incidents; -using NuGet.Services.Status.Table; -using System; -using System.Threading.Tasks; - -namespace StatusAggregator -{ - /// - /// Handles updating any active s. - /// - public interface IIncidentUpdater - { - /// - /// Update the status of any active s. - /// - Task RefreshActiveIncidents(); - - /// - /// Fetches any new s and processes them. - /// - /// The current timestamp processed by the job. - /// The most recent processed by the job or null if no s were processed. - Task FetchNewIncidents(DateTime cursor); - } -} \ No newline at end of file diff --git a/src/StatusAggregator/IMessageUpdater.cs b/src/StatusAggregator/IMessageUpdater.cs deleted file mode 100644 index 7b922aee5..000000000 --- a/src/StatusAggregator/IMessageUpdater.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Threading.Tasks; -using NuGet.Services.Status.Table; - -namespace StatusAggregator -{ - /// - /// Handles updating s for an . - /// - public interface IMessageUpdater - { - /// - /// Posts a for the start of . - /// - /// Used to determine whether or not the message should be posted. - Task CreateMessageForEventStart(EventEntity eventEntity, DateTime cursor); - - /// - /// Posts a for the end of . - /// - Task CreateMessageForEventEnd(EventEntity eventEntity); - } -} diff --git a/src/StatusAggregator/IncidentFactory.cs b/src/StatusAggregator/IncidentFactory.cs deleted file mode 100644 index 8fd1399e8..000000000 --- a/src/StatusAggregator/IncidentFactory.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using NuGet.Jobs.Extensions; -using NuGet.Services.Status.Table; -using StatusAggregator.Parse; -using StatusAggregator.Table; - -namespace StatusAggregator -{ - public class IncidentFactory : IIncidentFactory - { - private readonly ITableWrapper _table; - private readonly IEventUpdater _eventUpdater; - - private readonly ILogger _logger; - - public IncidentFactory( - ITableWrapper table, - IEventUpdater eventUpdater, - ILogger logger) - { - _table = table ?? throw new ArgumentNullException(nameof(table)); - _eventUpdater = eventUpdater ?? throw new ArgumentNullException(nameof(eventUpdater)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task CreateIncident(ParsedIncident parsedIncident) - { - var incidentEntity = new IncidentEntity( - parsedIncident.Id, - parsedIncident.AffectedComponentPath, - parsedIncident.AffectedComponentStatus, - parsedIncident.CreationTime, - parsedIncident.MitigationTime); - - using (_logger.Scope("Creating incident '{IncidentRowKey}'.", incidentEntity.RowKey)) - { - // Find an event to attach this incident to - var possibleEvents = _table - .CreateQuery() - .Where(e => - e.PartitionKey == EventEntity.DefaultPartitionKey && - // The incident and the event must affect the same component - e.AffectedComponentPath == incidentEntity.AffectedComponentPath && - // The event must begin before or at the same time as the incident - e.StartTime <= incidentEntity.CreationTime && - // The event must be active or the event must end after this incident begins - (e.IsActive || (e.EndTime >= incidentEntity.CreationTime))) - .ToList(); - - _logger.LogInformation("Found {EventCount} possible events to link incident to.", possibleEvents.Count()); - EventEntity eventToLinkTo = null; - foreach (var possibleEventToLinkTo in possibleEvents) - { - if (!_table.GetIncidentsLinkedToEvent(possibleEventToLinkTo).ToList().Any()) - { - _logger.LogInformation("Cannot link incident to event '{EventRowKey}' because it is not linked to any incidents.", possibleEventToLinkTo.RowKey); - continue; - } - - if (await _eventUpdater.UpdateEvent(possibleEventToLinkTo, incidentEntity.CreationTime)) - { - _logger.LogInformation("Cannot link incident to event '{EventRowKey}' because it has been deactivated.", possibleEventToLinkTo.RowKey); - continue; - } - - _logger.LogInformation("Linking incident to event '{EventRowKey}'.", possibleEventToLinkTo.RowKey); - eventToLinkTo = possibleEventToLinkTo; - break; - } - - if (eventToLinkTo == null) - { - eventToLinkTo = new EventEntity(incidentEntity); - _logger.LogInformation("Could not find existing event to link to, creating new event '{EventRowKey}' to link incident to.", eventToLinkTo.RowKey); - await _table.InsertOrReplaceAsync(eventToLinkTo); - } - - incidentEntity.EventRowKey = eventToLinkTo.RowKey; - await _table.InsertOrReplaceAsync(incidentEntity); - - if ((int)parsedIncident.AffectedComponentStatus > eventToLinkTo.AffectedComponentStatus) - { - _logger.LogInformation("Increasing severity of event '{EventRowKey}' because newly linked incident is more severe than the event.", eventToLinkTo.RowKey); - eventToLinkTo.AffectedComponentStatus = (int)parsedIncident.AffectedComponentStatus; - await _table.InsertOrReplaceAsync(eventToLinkTo); - } - - return incidentEntity; - } - } - } -} diff --git a/src/StatusAggregator/Job.cs b/src/StatusAggregator/Job.cs index 1e6233188..35df0145a 100644 --- a/src/StatusAggregator/Job.cs +++ b/src/StatusAggregator/Job.cs @@ -11,15 +11,22 @@ using Autofac.Core; using Autofac.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.WindowsAzure.Storage; -using Microsoft.WindowsAzure.Storage.Blob; using Newtonsoft.Json.Linq; using NuGet.Jobs; using NuGet.Services.Incidents; +using NuGet.Services.Status.Table; using NuGet.Services.Status.Table.Manual; +using StatusAggregator.Collector; +using StatusAggregator.Container; +using StatusAggregator.Export; +using StatusAggregator.Factory; using StatusAggregator.Manual; +using StatusAggregator.Messages; using StatusAggregator.Parse; using StatusAggregator.Table; +using StatusAggregator.Update; namespace StatusAggregator { @@ -39,6 +46,10 @@ public override void Init(IServiceContainer serviceContainer, IDictionary() - .Run(); + .Run(DateTime.UtcNow); } private static void AddServices(IServiceCollection serviceCollection) { serviceCollection.AddTransient(); serviceCollection.AddSingleton(); - serviceCollection.AddTransient(); - serviceCollection.AddTransient(); - serviceCollection.AddTransient(); AddParsing(serviceCollection); - serviceCollection.AddTransient(); + serviceCollection.AddTransient(); AddManualStatusChangeHandling(serviceCollection); + AddMessaging(serviceCollection); + serviceCollection.AddTransient(); serviceCollection.AddTransient(); - serviceCollection.AddTransient(); serviceCollection.AddTransient(); } + private static void AddMessaging(IServiceCollection serviceCollection) + { + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + } + private static void AddManualStatusChangeHandling(IServiceCollection serviceCollection) { serviceCollection.AddTransient, AddStatusEventManualChangeHandler>(); @@ -78,13 +97,13 @@ private static void AddManualStatusChangeHandling(IServiceCollection serviceColl private static void AddParsing(IServiceCollection serviceCollection) { - serviceCollection.AddTransient(); - serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); - serviceCollection.AddTransient(); - serviceCollection.AddTransient(); - serviceCollection.AddTransient(); - serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); serviceCollection.AddTransient(); } @@ -129,18 +148,17 @@ private static void AddStorage(ContainerBuilder containerBuilder) var storageAccount = ctx.ResolveNamed(name); return GetCloudBlobContainer(ctx, storageAccount); }) - .As() - .Named(name); + .As() + .Named(name); // We need to listen to manual status change updates from each storage. containerBuilder - .RegisterType() + .RegisterType() .WithParameter(new NamedParameter(StorageAccountNameParameter, name)) .WithParameter(new ResolvedParameter( (pi, ctx) => pi.ParameterType == typeof(ITableWrapper), (pi, ctx) => ctx.ResolveNamed(name))) - .As() - .Named(name); + .As(); } } @@ -156,11 +174,128 @@ private static ITableWrapper GetTableWrapper(IComponentContext ctx, CloudStorage return new TableWrapper(storageAccount, configuration.TableName); } - private static CloudBlobContainer GetCloudBlobContainer(IComponentContext ctx, CloudStorageAccount storageAccount) + private static IContainerWrapper GetCloudBlobContainer(IComponentContext ctx, CloudStorageAccount storageAccount) { var blobClient = storageAccount.CreateCloudBlobClient(); var configuration = ctx.Resolve(); - return blobClient.GetContainerReference(configuration.ContainerName); + var container = blobClient.GetContainerReference(configuration.ContainerName); + return new ContainerWrapper(container); + } + + private static void AddFactoriesAndUpdaters(ContainerBuilder containerBuilder) + { + containerBuilder + .RegisterType>() + .As>(); + + containerBuilder + .RegisterType>() + .As>(); + + containerBuilder + .RegisterType() + .As>() + .As>(); + + containerBuilder + .RegisterType() + .As>(); + + containerBuilder + .RegisterType>() + .As>(); + + containerBuilder + .RegisterType>() + .As>(); + + containerBuilder + .RegisterType() + .As>(); + + containerBuilder + .RegisterType() + .As>(); + + containerBuilder + .RegisterType() + .As>(); + + containerBuilder + .RegisterType() + .As>(); + + containerBuilder + .RegisterType>() + .As>(); + + AddEventUpdater(containerBuilder); + + containerBuilder + .RegisterType() + .As(); + } + + private static void AddEventUpdater(ContainerBuilder containerBuilder) + { + containerBuilder + .RegisterType>() + .AsSelf(); + + containerBuilder + .RegisterType() + .AsSelf(); + + containerBuilder + .RegisterType() + .As>(); + } + + private static void AddIncidentRegexParser(ContainerBuilder containerBuilder) + { + containerBuilder + .RegisterAdapter( + (ctx, handler) => + { + return new IncidentRegexParser( + handler, + ctx.Resolve>()); + }); + } + + private static void AddEntityCollector(ContainerBuilder containerBuilder) + { + containerBuilder + .RegisterAdapter( + (ctx, processor) => + { + return new EntityCollector( + ctx.Resolve(), + processor); + }); + } + + private static void AddExporters(ContainerBuilder containerBuilder) + { + containerBuilder + .RegisterType() + .As(); + + containerBuilder + .RegisterType() + .As(); + + containerBuilder + .RegisterType() + .As(); + + containerBuilder + .RegisterType() + .As(); + + containerBuilder + .RegisterType() + .As(); } private const int _defaultEventStartMessageDelayMinutes = 15; diff --git a/src/StatusAggregator/Manual/AddStatusEventManualChangeHandler.cs b/src/StatusAggregator/Manual/AddStatusEventManualChangeHandler.cs index 709e03176..d6b2458eb 100644 --- a/src/StatusAggregator/Manual/AddStatusEventManualChangeHandler.cs +++ b/src/StatusAggregator/Manual/AddStatusEventManualChangeHandler.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using NuGet.Services.Status; using NuGet.Services.Status.Table; using NuGet.Services.Status.Table.Manual; using StatusAggregator.Table; @@ -25,14 +26,15 @@ public async Task Handle(AddStatusEventManualChangeEntity entity) var eventEntity = new EventEntity( entity.EventAffectedComponentPath, - entity.EventAffectedComponentStatus, time, - entity.EventIsActive ? (DateTime?)null : time); + affectedComponentStatus: (ComponentStatus)entity.EventAffectedComponentStatus, + endTime: entity.EventIsActive ? (DateTime?)null : time); var messageEntity = new MessageEntity( eventEntity, time, - entity.MessageContents); + entity.MessageContents, + MessageType.Manual); await _table.InsertAsync(messageEntity); await _table.InsertAsync(eventEntity); diff --git a/src/StatusAggregator/Manual/AddStatusMessageManualChangeHandler.cs b/src/StatusAggregator/Manual/AddStatusMessageManualChangeHandler.cs index 826668180..d2b9e0a8e 100644 --- a/src/StatusAggregator/Manual/AddStatusMessageManualChangeHandler.cs +++ b/src/StatusAggregator/Manual/AddStatusMessageManualChangeHandler.cs @@ -24,13 +24,13 @@ public async Task Handle(AddStatusMessageManualChangeEntity entity) var time = entity.Timestamp.UtcDateTime; var eventRowKey = EventEntity.GetRowKey(entity.EventAffectedComponentPath, entity.EventStartTime); - var messageEntity = new MessageEntity(eventRowKey, time, entity.MessageContents); - - var eventEntity = await _table.RetrieveAsync(EventEntity.DefaultPartitionKey, eventRowKey); + var eventEntity = await _table.RetrieveAsync(eventRowKey); if (eventEntity == null) { throw new ArgumentException("Cannot create a message for an event that does not exist."); } + + var messageEntity = new MessageEntity(eventEntity, time, entity.MessageContents, MessageType.Manual); await _table.InsertAsync(messageEntity); if (ManualStatusChangeUtility.UpdateEventIsActive(eventEntity, entity.EventIsActive, time)) diff --git a/src/StatusAggregator/Manual/DeleteStatusMessageManualChangeHandler.cs b/src/StatusAggregator/Manual/DeleteStatusMessageManualChangeHandler.cs index f02136948..d9119b6c8 100644 --- a/src/StatusAggregator/Manual/DeleteStatusMessageManualChangeHandler.cs +++ b/src/StatusAggregator/Manual/DeleteStatusMessageManualChangeHandler.cs @@ -19,11 +19,19 @@ public DeleteStatusMessageManualChangeHandler( _table = table ?? throw new ArgumentNullException(nameof(table)); } - public Task Handle(DeleteStatusMessageManualChangeEntity entity) + public async Task Handle(DeleteStatusMessageManualChangeEntity entity) { var eventRowKey = EventEntity.GetRowKey(entity.EventAffectedComponentPath, entity.EventStartTime); - var messageRowKey = MessageEntity.GetRowKey(eventRowKey, entity.MessageTimestamp); - return _table.DeleteAsync(MessageEntity.DefaultPartitionKey, messageRowKey); + var messageEntity = await _table.RetrieveAsync(MessageEntity.GetRowKey(eventRowKey, entity.MessageTimestamp)); + if (messageEntity == null) + { + throw new ArgumentException("Cannot delete a message that does not exist."); + } + + messageEntity.Contents = ""; + messageEntity.Type = (int)MessageType.Manual; + + await _table.ReplaceAsync(messageEntity); } } } diff --git a/src/StatusAggregator/Manual/EditStatusEventManualChangeHandler.cs b/src/StatusAggregator/Manual/EditStatusEventManualChangeHandler.cs index 15901d03a..096dde9e9 100644 --- a/src/StatusAggregator/Manual/EditStatusEventManualChangeHandler.cs +++ b/src/StatusAggregator/Manual/EditStatusEventManualChangeHandler.cs @@ -22,7 +22,7 @@ public EditStatusEventManualChangeHandler( public async Task Handle(EditStatusEventManualChangeEntity entity) { var eventRowKey = EventEntity.GetRowKey(entity.EventAffectedComponentPath, entity.EventStartTime); - var eventEntity = await _table.RetrieveAsync(EventEntity.DefaultPartitionKey, eventRowKey); + var eventEntity = await _table.RetrieveAsync(eventRowKey); if (eventEntity == null) { throw new ArgumentException("Cannot edit an event that does not exist."); diff --git a/src/StatusAggregator/Manual/EditStatusMessageManualChangeHandler.cs b/src/StatusAggregator/Manual/EditStatusMessageManualChangeHandler.cs index 5e328b4cd..68a3b3315 100644 --- a/src/StatusAggregator/Manual/EditStatusMessageManualChangeHandler.cs +++ b/src/StatusAggregator/Manual/EditStatusMessageManualChangeHandler.cs @@ -22,21 +22,20 @@ public EditStatusMessageManualChangeHandler( public async Task Handle(EditStatusMessageManualChangeEntity entity) { var eventRowKey = EventEntity.GetRowKey(entity.EventAffectedComponentPath, entity.EventStartTime); - var eventEntity = await _table.RetrieveAsync(EventEntity.DefaultPartitionKey, eventRowKey); + var eventEntity = await _table.RetrieveAsync(eventRowKey); if (eventEntity == null) { throw new ArgumentException("Cannot edit a message for an event that does not exist."); } - var messageEntity = await _table.RetrieveAsync( - MessageEntity.DefaultPartitionKey, - MessageEntity.GetRowKey(eventRowKey, entity.MessageTimestamp)); + var messageEntity = await _table.RetrieveAsync(MessageEntity.GetRowKey(eventRowKey, entity.MessageTimestamp)); if (messageEntity == null) { throw new ArgumentException("Cannot edit a message that does not exist."); } messageEntity.Contents = entity.MessageContents; + messageEntity.Type = (int)MessageType.Manual; await _table.ReplaceAsync(messageEntity); } diff --git a/src/StatusAggregator/Manual/IManualStatusChangeUpdater.cs b/src/StatusAggregator/Manual/IManualStatusChangeUpdater.cs deleted file mode 100644 index 92eaabb15..000000000 --- a/src/StatusAggregator/Manual/IManualStatusChangeUpdater.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Threading.Tasks; - -namespace StatusAggregator.Manual -{ - /// - /// Monitors a storage for the manual status changes posted to it. - /// - public interface IManualStatusChangeUpdater - { - /// - /// An identifier for what storage that the manual status changes are being monitored from. - /// - string Name { get; } - - /// - /// Processes all manual status changes in the storage that are more recent than . - /// - Task ProcessNewManualChanges(DateTime cursor); - } -} diff --git a/src/StatusAggregator/Manual/ManualStatusChangeHandler.cs b/src/StatusAggregator/Manual/ManualStatusChangeHandler.cs index d5b69869a..c941a5cac 100644 --- a/src/StatusAggregator/Manual/ManualStatusChangeHandler.cs +++ b/src/StatusAggregator/Manual/ManualStatusChangeHandler.cs @@ -112,7 +112,7 @@ public ManualStatusChangeProcessor(IManualStatusChangeHandler handler) public async Task GetTask(ITableWrapper table, ManualStatusChangeEntity entity) { - var typedEntity = await table.RetrieveAsync(entity.PartitionKey, entity.RowKey); + var typedEntity = await table.RetrieveAsync(entity.RowKey); await _handler.Handle(typedEntity); } } diff --git a/src/StatusAggregator/MessageUpdater.cs b/src/StatusAggregator/MessageUpdater.cs deleted file mode 100644 index 17d979d15..000000000 --- a/src/StatusAggregator/MessageUpdater.cs +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using NuGet.Jobs.Extensions; -using NuGet.Services.Status; -using NuGet.Services.Status.Table; -using StatusAggregator.Table; - -namespace StatusAggregator -{ - public class MessageUpdater : IMessageUpdater - { - private readonly TimeSpan _eventStartMessageDelay; - - private readonly ITableWrapper _table; - - private readonly ILogger _logger; - - public MessageUpdater( - ITableWrapper table, - StatusAggregatorConfiguration configuration, - ILogger logger) - { - _table = table ?? throw new ArgumentNullException(nameof(table)); - _eventStartMessageDelay = TimeSpan.FromMinutes(configuration?.EventStartMessageDelayMinutes ?? throw new ArgumentNullException(nameof(configuration))); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task CreateMessageForEventStart(EventEntity eventEntity, DateTime cursor) - { - using (_logger.Scope("Creating message for start of event.")) - { - if (cursor <= eventEntity.StartTime + _eventStartMessageDelay) - { - // We don't want to show events that are too recent to avoid noisy events. - _logger.LogInformation("Event is too recent, cannot create message for its start."); - return; - } - - if (_table.GetMessagesLinkedToEvent(eventEntity).ToList().Any()) - { - // If we've already told customers about an event, we don't need to tell them about it again. - _logger.LogInformation("Event has messages associated with it, cannot create message for its start."); - return; - } - - var incidentsLinkedToEventQuery = _table.GetIncidentsLinkedToEvent(eventEntity); - - // We are querying twice here because table storage ignores rows where a column specified by a query is null. - // MitigationTime is null when IsActive is true. - // If we do not query separately here, rows where IsActive is true will be ignored in query results. - - var hasCurrentlyActiveIncidents = incidentsLinkedToEventQuery - .Where(i => i.IsActive) - .ToList() - .Any(); - - var hasIncidentsActiveAtCursorTime = incidentsLinkedToEventQuery - .Where(i => i.MitigationTime >= cursor) - .ToList() - .Any(); - - var hasActiveIncidents = hasCurrentlyActiveIncidents || hasIncidentsActiveAtCursorTime; - if (!hasActiveIncidents) - { - // If we haven't told customers about an event and it doesn't have any active incidents, it must have be linked to an incident that fired and was quickly mitigated. - // This event will be deactivated if it is not linked to an active incident soon. - // We will create a message when it is linked to another active incident. - _logger.LogInformation("Event has no active incidents, cannot create message for its start."); - return; - } - - if (TryGetContentsForMessageForEventStart(eventEntity, out var contents)) - { - await CreateMessage(eventEntity, eventEntity.StartTime, contents); - } - else - { - _logger.LogWarning("Failed to create a message for start of event!"); - } - } - } - - private const string _messageForEventStartTemplate = "**{0} is {1}.** You may encounter issues {2}."; - - private bool TryGetContentsForMessageForEventStart(EventEntity eventEntity, out string contents) - { - return TryGetContentsForEventHelper(eventEntity, _messageForEventStartTemplate, out contents); - } - - public async Task CreateMessageForEventEnd(EventEntity eventEntity) - { - if (!eventEntity.EndTime.HasValue) - { - throw new ArgumentException("Must pass in an event with an end time!", nameof(eventEntity)); - } - - using (_logger.Scope("Creating message for end of event.")) - { - if (!_table.GetMessagesLinkedToEvent(eventEntity).ToList().Any()) - { - // If we've never told customers about an event, we don't need to tell them it's no longer impacting them. - _logger.LogInformation("Event has no messages associated with it, cannot create message for its end."); - return; - } - - if (TryGetContentsForMessageForEventEnd(eventEntity, out var contents)) - { - await CreateMessage(eventEntity, eventEntity.EndTime.Value, contents); - } - else - { - _logger.LogWarning("Failed to create message!"); - } - } - } - - private Task CreateMessage(EventEntity eventEntity, DateTime time, string contents) - { - var messageEntity = new MessageEntity(eventEntity, time, contents); - _logger.LogInformation("Creating message with time {MessageTimestamp} and contents {MessageContents}", - messageEntity.Time, messageEntity.Contents); - return _table.InsertOrReplaceAsync(messageEntity); - } - - private const string _messageForEventEndTemplate = "**{0} is no longer {1}.** You should no longer encounter any issues {2}. Thank you for your patience."; - - private bool TryGetContentsForMessageForEventEnd(EventEntity eventEntity, out string contents) - { - return TryGetContentsForEventHelper(eventEntity, _messageForEventEndTemplate, out contents); - } - - private bool TryGetContentsForEventHelper( - EventEntity eventEntity, - string messageTemplate, - out string contents) - { - contents = null; - - var path = eventEntity.AffectedComponentPath; - var component = NuGetServiceComponentFactory.CreateNuGetServiceRootComponent().GetByPath(path); - if (component == null) - { - _logger.LogWarning("Could not find a component with path {ComponentPath}.", path); - return false; - } - - var componentNames = path.Split(Constants.ComponentPathDivider); - var componentName = string.Join(" ", componentNames.Skip(1).Reverse()); - var componentStatus = ((ComponentStatus)eventEntity.AffectedComponentStatus).ToString().ToLowerInvariant(); - - string actionDescription = _actionDescriptionForComponentPathMap - .FirstOrDefault(m => m.Matches(path))? - .ActionDescription; - - if (actionDescription == null) - { - _logger.LogWarning("Could not find an action description for path {ComponentPath}.", path); - return false; - } - - contents = string.Format(messageTemplate, componentName, componentStatus, actionDescription); - - return !string.IsNullOrEmpty(contents); - } - - private static readonly IEnumerable _actionDescriptionForComponentPathMap = new ActionDescriptionForComponentPathPrefix[] - { - new ActionDescriptionForComponentPathPrefix( - ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.GalleryName), - $"browsing the NuGet Gallery"), - - new ActionDescriptionForComponentPathPrefix( - ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName, NuGetServiceComponentFactory.ChinaRegionName), - $"restoring packages from NuGet.org's V3 feed from China"), - - new ActionDescriptionForComponentPathPrefix( - ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName), - $"restoring packages from NuGet.org's V3 feed"), - - new ActionDescriptionForComponentPathPrefix( - ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V2ProtocolName), - $"restoring packages from NuGet.org's V2 feed"), - - new ActionDescriptionForComponentPathPrefix( - ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName), - $"restoring packages"), - - new ActionDescriptionForComponentPathPrefix( - ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.ChinaRegionName), - $"searching for packages from China"), - - new ActionDescriptionForComponentPathPrefix( - ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName), - $"searching for packages"), - - new ActionDescriptionForComponentPathPrefix( - ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.UploadName), - "uploading new packages"), - }; - - private class ActionDescriptionForComponentPathPrefix - { - public string ComponentPathPrefix { get; } - public string ActionDescription { get; } - - public ActionDescriptionForComponentPathPrefix(string componentPathPrefix, string actionDescription) - { - ComponentPathPrefix = componentPathPrefix; - ActionDescription = actionDescription; - } - - public bool Matches(string componentPath) - { - return componentPath.StartsWith(ComponentPathPrefix, StringComparison.OrdinalIgnoreCase); - } - } - } -} diff --git a/src/StatusAggregator/Messages/ExistingStartMessageContext.cs b/src/StatusAggregator/Messages/ExistingStartMessageContext.cs new file mode 100644 index 000000000..7079b0d0e --- /dev/null +++ b/src/StatusAggregator/Messages/ExistingStartMessageContext.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Messages +{ + /// + /// Describes a message that was posted with type for which a message with type has not been posted. + /// For use by . + /// + public class ExistingStartMessageContext + { + public DateTime Timestamp { get; } + public IComponent AffectedComponent { get; } + public ComponentStatus AffectedComponentStatus { get; } + + public ExistingStartMessageContext( + DateTime timestamp, + IComponent affectedComponent, + ComponentStatus affectedComponentStatus) + { + Timestamp = timestamp; + AffectedComponent = affectedComponent; + AffectedComponentStatus = affectedComponentStatus; + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/IIncidentGroupMessageFilter.cs b/src/StatusAggregator/Messages/IIncidentGroupMessageFilter.cs new file mode 100644 index 000000000..1bd409d4e --- /dev/null +++ b/src/StatusAggregator/Messages/IIncidentGroupMessageFilter.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Messages +{ + public interface IIncidentGroupMessageFilter + { + /// + /// Returns whether or not messages should be posted about at time . + /// + bool CanPostMessages(IncidentGroupEntity group, DateTime cursor); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/IMessageChangeEventIterator.cs b/src/StatusAggregator/Messages/IMessageChangeEventIterator.cs new file mode 100644 index 000000000..2a6a44e45 --- /dev/null +++ b/src/StatusAggregator/Messages/IMessageChangeEventIterator.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading.Tasks; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Messages +{ + public interface IMessageChangeEventIterator + { + /// + /// Iterates through and processes them. + /// + Task IterateAsync(IEnumerable changes, EventEntity eventEntity); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/IMessageChangeEventProcessor.cs b/src/StatusAggregator/Messages/IMessageChangeEventProcessor.cs new file mode 100644 index 000000000..8da1f888d --- /dev/null +++ b/src/StatusAggregator/Messages/IMessageChangeEventProcessor.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Messages +{ + public interface IMessageChangeEventProcessor + { + /// + /// Processes . + /// + /// The associated with the change. + /// The associated with this iteration. + /// + /// The associated with this iteration. + /// null if there is no existing start message without a corresponding end message. + /// + /// + /// A that reflects the change made by processing . + /// null if there is no existing start message without a corresponding end message. + /// + Task ProcessAsync( + MessageChangeEvent change, + EventEntity eventEntity, + IComponent rootComponent, + ExistingStartMessageContext existingStartMessageContext); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/IMessageChangeEventProvider.cs b/src/StatusAggregator/Messages/IMessageChangeEventProvider.cs new file mode 100644 index 000000000..2fd41d463 --- /dev/null +++ b/src/StatusAggregator/Messages/IMessageChangeEventProvider.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Messages +{ + public interface IMessageChangeEventProvider + { + /// + /// Returns the s associated with given . + /// + IEnumerable Get(EventEntity eventEntity, DateTime cursor); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/IMessageContentBuilder.cs b/src/StatusAggregator/Messages/IMessageContentBuilder.cs new file mode 100644 index 000000000..86b4a876d --- /dev/null +++ b/src/StatusAggregator/Messages/IMessageContentBuilder.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using NuGet.Services.Status; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Messages +{ + /// + /// Used by to build content for s. + /// + public interface IMessageContentBuilder + { + /// + /// Builds contents for a message of type affecting . + /// + string Build(MessageType type, IComponent component); + + /// + /// Builds contents for a message of type affecting with status . + /// + string Build(MessageType type, IComponent component, ComponentStatus status); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/IMessageFactory.cs b/src/StatusAggregator/Messages/IMessageFactory.cs new file mode 100644 index 000000000..3784893fb --- /dev/null +++ b/src/StatusAggregator/Messages/IMessageFactory.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Messages +{ + /// + /// Handles creating, updating, and delete s. + /// + public interface IMessageFactory + { + /// + /// Creates a message for at of type affecting . + /// + Task CreateMessageAsync(EventEntity eventEntity, DateTime time, MessageType type, IComponent component); + + /// + /// Creates a message for at of type affecting with status . + /// + Task CreateMessageAsync(EventEntity eventEntity, DateTime time, MessageType type, IComponent component, ComponentStatus status); + + /// + /// Updates the message for at of type affecting . + /// + Task UpdateMessageAsync(EventEntity eventEntity, DateTime time, MessageType type, IComponent component); + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/IncidentGroupMessageFilter.cs b/src/StatusAggregator/Messages/IncidentGroupMessageFilter.cs new file mode 100644 index 000000000..eb4d1bf7d --- /dev/null +++ b/src/StatusAggregator/Messages/IncidentGroupMessageFilter.cs @@ -0,0 +1,59 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using NuGet.Services.Status.Table; +using StatusAggregator.Table; + +namespace StatusAggregator.Messages +{ + /// + /// Does not post messages about an unless it has incidents that have been active for a specified delay. + /// + public class IncidentGroupMessageFilter : IIncidentGroupMessageFilter + { + private readonly TimeSpan _eventStartMessageDelay; + + private readonly ITableWrapper _table; + + private readonly ILogger _logger; + + public IncidentGroupMessageFilter( + ITableWrapper table, + StatusAggregatorConfiguration configuration, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _eventStartMessageDelay = TimeSpan.FromMinutes(configuration?.EventStartMessageDelayMinutes ?? throw new ArgumentNullException(nameof(configuration))); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public bool CanPostMessages(IncidentGroupEntity group, DateTime cursor) + { + var duration = (group.EndTime ?? cursor) - group.StartTime; + if (duration < _eventStartMessageDelay) + { + _logger.LogInformation("Incident group has not been active for longer than the messaging delay."); + return false; + } + + var linkedIncidentsQuery = _table.GetChildEntities(group); + + var activeIncidents = linkedIncidentsQuery + .Where(i => i.IsActive) + .ToList(); + + var incidentsActiveAfterDelay = linkedIncidentsQuery + .Where(i => i.EndTime >= group.StartTime + _eventStartMessageDelay) + .ToList(); + + _logger.LogInformation("Incident group is linked to {ActiveIncidentsCount} active incidents and {DelayActiveIncidentsCount} incidents that were active after the messaging delay.", + activeIncidents.Count, incidentsActiveAfterDelay.Count); + + var hasBeenActiveLongerThanDelay = activeIncidents.Any() || incidentsActiveAfterDelay.Any(); + return hasBeenActiveLongerThanDelay; + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/MessageChangeEvent.cs b/src/StatusAggregator/Messages/MessageChangeEvent.cs new file mode 100644 index 000000000..ab25d5a01 --- /dev/null +++ b/src/StatusAggregator/Messages/MessageChangeEvent.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Messages +{ + /// + /// The data associated with an event that affects the s associated with an . + /// + public class MessageChangeEvent + { + public DateTime Timestamp { get; } + public string AffectedComponentPath { get; } + public ComponentStatus AffectedComponentStatus { get; } + public MessageType Type { get; } + + public MessageChangeEvent(DateTime timestamp, string affectedComponentPath, ComponentStatus affectedComponentStatus, MessageType type) + { + Timestamp = timestamp; + AffectedComponentPath = affectedComponentPath; + AffectedComponentStatus = affectedComponentStatus; + Type = type; + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/MessageChangeEventIterator.cs b/src/StatusAggregator/Messages/MessageChangeEventIterator.cs new file mode 100644 index 000000000..fc0c0ab89 --- /dev/null +++ b/src/StatusAggregator/Messages/MessageChangeEventIterator.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Services.Status.Table; +using StatusAggregator.Factory; + +namespace StatusAggregator.Messages +{ + public class MessageChangeEventIterator : IMessageChangeEventIterator + { + private readonly IComponentFactory _factory; + private readonly IMessageChangeEventProcessor _processor; + + private readonly ILogger _logger; + + public MessageChangeEventIterator( + IComponentFactory factory, + IMessageChangeEventProcessor processor, + ILogger logger) + { + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + _processor = processor ?? throw new ArgumentNullException(nameof(processor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task IterateAsync(IEnumerable changes, EventEntity eventEntity) + { + var rootComponent = _factory.Create(); + ExistingStartMessageContext existingStartMessageContext = null; + foreach (var change in changes.OrderBy(c => c.Timestamp)) + { + existingStartMessageContext = await _processor.ProcessAsync(change, eventEntity, rootComponent, existingStartMessageContext); + } + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/MessageChangeEventProcessor.cs b/src/StatusAggregator/Messages/MessageChangeEventProcessor.cs new file mode 100644 index 000000000..2aeb70390 --- /dev/null +++ b/src/StatusAggregator/Messages/MessageChangeEventProcessor.cs @@ -0,0 +1,156 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Messages +{ + public class MessageChangeEventProcessor : IMessageChangeEventProcessor + { + private readonly IMessageFactory _factory; + + private readonly ILogger _logger; + + public MessageChangeEventProcessor( + IMessageFactory factory, + ILogger logger) + { + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task ProcessAsync( + MessageChangeEvent change, + EventEntity eventEntity, + IComponent rootComponent, + ExistingStartMessageContext existingStartMessageContext) + { + using (_logger.Scope("Processing change of type {StatusChangeType} at {StatusChangeTimestamp} affecting {StatusChangePath} with status {StatusChangeStatus}.", + change.Type, change.Timestamp, change.AffectedComponentPath, change.AffectedComponentStatus)) + { + _logger.LogInformation("Getting component affected by change."); + var component = rootComponent.GetByPath(change.AffectedComponentPath); + if (component == null) + { + throw new ArgumentException($"Affected path {change.AffectedComponentPath} does not exist in component tree.", nameof(change)); + } + + switch (change.Type) + { + case MessageType.Start: + return ProcessStartMessageAsync(change, eventEntity, rootComponent, component, existingStartMessageContext); + + case MessageType.End: + return ProcessEndMessageAsync(change, eventEntity, rootComponent, component, existingStartMessageContext); + + default: + throw new ArgumentException($"Unexpected message type {change.Type}", nameof(change)); + } + } + } + + private async Task ProcessStartMessageAsync( + MessageChangeEvent change, + EventEntity eventEntity, + IComponent rootComponent, + IComponent component, + ExistingStartMessageContext existingStartMessageContext) + { + _logger.LogInformation("Applying change to component tree."); + component.Status = change.AffectedComponentStatus; + + // This change may affect a component that we do not display on the status page. + // Find the deepester ancestor of the component that is directly affected. + _logger.LogInformation("Determining if change affects visible component tree."); + var lowestVisibleComponent = rootComponent.GetDeepestVisibleAncestorOfSubComponent(component); + if (lowestVisibleComponent == null || lowestVisibleComponent.Status == ComponentStatus.Up) + { + // The change does not bubble up to a component that we display on the status page. + // Therefore, we shouldn't post a message about it. + _logger.LogInformation("Change does not affect visible component tree. Will not post or edit any messages."); + return existingStartMessageContext; + } + + // The change bubbles up to a component that we display on the status page. + // We must post or update a message about it. + if (existingStartMessageContext != null) + { + // There is an existing message we need to update. + _logger.LogInformation("Found existing message, will edit it with information from new change."); + // We must expand the scope of the existing message to include the component affected by this change. + // In other words, if the message claims V2 Restore is down and V3 Restore is now down as well, we need to update the message to say Restore is down. + var leastCommonAncestorPath = ComponentUtility.GetLeastCommonAncestorPath(existingStartMessageContext.AffectedComponent, lowestVisibleComponent); + _logger.LogInformation("Least common ancestor component of existing message and this change is {LeastCommonAncestorPath}.", leastCommonAncestorPath); + var leastCommonAncestor = rootComponent.GetByPath(leastCommonAncestorPath); + if (leastCommonAncestor == null) + { + // If the two components don't have a common ancestor, then they must not be a part of the same component tree. + // This should not be possible because it is asserted earlier that both these components are subcomponents of the root component. + throw new ArgumentException("Least common ancestor component of existing message and this change does not exist!", nameof(change)); + } + + if (leastCommonAncestor.Status == ComponentStatus.Up) + { + // The least common ancestor of the component affected by the change and the component referred to by the existing message is unaffected! + // This should not be possible because the ancestor of any visible component should be visible (in other words, changes to visible components should always bubble up). + throw new ArgumentException("Least common ancestor of two visible components is unaffected!"); + } + + await _factory.UpdateMessageAsync(eventEntity, existingStartMessageContext.Timestamp, MessageType.Start, leastCommonAncestor); + return new ExistingStartMessageContext(existingStartMessageContext.Timestamp, leastCommonAncestor, leastCommonAncestor.Status); + } + else + { + // There is not an existing message we need to update. + _logger.LogInformation("No existing message found. Creating new start message for change."); + await _factory.CreateMessageAsync(eventEntity, change.Timestamp, change.Type, lowestVisibleComponent); + return new ExistingStartMessageContext(change.Timestamp, lowestVisibleComponent, lowestVisibleComponent.Status); + } + } + + private async Task ProcessEndMessageAsync( + MessageChangeEvent change, + EventEntity eventEntity, + IComponent rootComponent, + IComponent component, + ExistingStartMessageContext existingStartMessageContext) + { + _logger.LogInformation("Removing change from component tree."); + component.Status = ComponentStatus.Up; + + if (existingStartMessageContext != null) + { + // There is an existing message that may be resolved by this change. + // We should check if any visible components are still affected. + _logger.LogInformation("Found existing message, testing if component tree is still affected."); + + var affectedSubComponents = existingStartMessageContext.AffectedComponent.GetAllVisibleComponents(); + if (affectedSubComponents.All(c => c.Status == ComponentStatus.Up)) + { + _logger.LogInformation("Component tree is no longer affected. Creating end message."); + await _factory.CreateMessageAsync(eventEntity, change.Timestamp, change.Type, existingStartMessageContext.AffectedComponent, existingStartMessageContext.AffectedComponentStatus); + return null; + } + else + { + _logger.LogInformation("Component tree is still affected. Will not post an end message."); + } + } + else + { + // There is no existing message. + // We must have determined that we do not want to alert customers on this change. + // The change likely affected a component that was not visible and did not bubble up. + _logger.LogInformation("No existing message found. Will not add or delete any messages."); + } + + return existingStartMessageContext; + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/MessageChangeEventProvider.cs b/src/StatusAggregator/Messages/MessageChangeEventProvider.cs new file mode 100644 index 000000000..990a50c14 --- /dev/null +++ b/src/StatusAggregator/Messages/MessageChangeEventProvider.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Table; + +namespace StatusAggregator.Messages +{ + public class MessageChangeEventProvider : IMessageChangeEventProvider + { + private readonly ITableWrapper _table; + private readonly IIncidentGroupMessageFilter _filter; + + private readonly ILogger _logger; + + public MessageChangeEventProvider( + ITableWrapper table, + IIncidentGroupMessageFilter filter, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _filter = filter ?? throw new ArgumentNullException(nameof(filter)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public IEnumerable Get(EventEntity eventEntity, DateTime cursor) + { + var linkedGroups = _table.GetChildEntities(eventEntity).ToList(); + var events = new List(); + _logger.LogInformation("Event has {IncidentGroupsCount} linked incident groups.", linkedGroups.Count); + foreach (var linkedGroup in linkedGroups) + { + using (_logger.Scope("Getting status changes from incident group {IncidentGroupRowKey}.", linkedGroup.RowKey)) + { + if (!_filter.CanPostMessages(linkedGroup, cursor)) + { + _logger.LogInformation("Incident group did not pass filter. Cannot post messages about it."); + continue; + } + + var path = linkedGroup.AffectedComponentPath; + var status = (ComponentStatus)linkedGroup.AffectedComponentStatus; + var startTime = linkedGroup.StartTime; + _logger.LogInformation("Incident group started at {StartTime}.", startTime); + events.Add(new MessageChangeEvent(startTime, path, status, MessageType.Start)); + if (!linkedGroup.IsActive) + { + var endTime = linkedGroup.EndTime.Value; + _logger.LogInformation("Incident group ended at {EndTime}.", endTime); + events.Add(new MessageChangeEvent(endTime, path, status, MessageType.End)); + } + } + } + + return events; + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/MessageContentBuilder.cs b/src/StatusAggregator/Messages/MessageContentBuilder.cs new file mode 100644 index 000000000..4cd529f2f --- /dev/null +++ b/src/StatusAggregator/Messages/MessageContentBuilder.cs @@ -0,0 +1,150 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Factory; + +namespace StatusAggregator.Messages +{ + public class MessageContentBuilder : IMessageContentBuilder + { + private readonly ILogger _logger; + + public MessageContentBuilder(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string Build( + MessageType type, + IComponent component) + { + return Build(type, component, component.Status); + } + + public string Build( + MessageType type, + IComponent component, + ComponentStatus status) + { + return Build(type, component.Path, status); + } + + private string Build( + MessageType type, + string path, + ComponentStatus status) + { + using (_logger.Scope("Getting contents for message of type {MessageType} with path {ComponentPath} and status {ComponentStatus}.", + type, path, status)) + { + if (!_messageTypeToMessageTemplate.TryGetValue(type, out string messageTemplate)) + { + throw new ArgumentException("Could not find a template for type.", nameof(type)); + } + + _logger.LogInformation("Using template {MessageTemplate}.", messageTemplate); + + var nameString = GetName(path); + _logger.LogInformation("Using {ComponentName} for name of component.", nameString); + + var actionDescription = GetActionDescriptionFromPath(path); + if (actionDescription == null) + { + throw new ArgumentException("Could not find an action description for path.", nameof(path)); + } + + var statusString = status.ToString().ToLowerInvariant(); + var contents = string.Format(messageTemplate, nameString, statusString, actionDescription); + _logger.LogInformation("Returned {Contents} for contents of message.", contents); + return contents; + } + } + + private string GetName(string path) + { + var componentNames = ComponentUtility.GetNames(path); + return string.Join(" ", componentNames.Skip(1).Reverse()); + } + + private static readonly IDictionary _messageTypeToMessageTemplate = new Dictionary + { + { MessageType.Start, "**{0} is {1}.** You may encounter issues {2}." }, + { MessageType.End, "**{0} is no longer {1}.** You should no longer encounter any issues {2}. Thank you for your patience." }, + }; + + private string GetActionDescriptionFromPath(string path) + { + return _actionDescriptionForComponentPathMap + .FirstOrDefault(m => m.Matches(path))? + .ActionDescription; + } + + /// + /// This was not implemented as a dictionary because it is not possible to construct a that works with component path prefixes. + /// + /// Proof: + /// A/B and A/C must have the same hashcode as A because A/B and A/C are both prefixed by A. + /// However, A/B must not have the same hashcode as A/C because A/B is not a prefix of A/C and A/C is not a prefix of A/B. + /// Therefore, A/B and A/C must have a hashcode that is both identical AND different. + /// This is not possible. + /// + private static readonly IEnumerable _actionDescriptionForComponentPathMap = new ActionDescriptionForComponentPathPrefix[] + { + new ActionDescriptionForComponentPathPrefix( + ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.GalleryName), + $"browsing the NuGet Gallery"), + + new ActionDescriptionForComponentPathPrefix( + ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName, NuGetServiceComponentFactory.ChinaRegionName), + $"restoring packages from NuGet.org's V3 feed from China"), + + new ActionDescriptionForComponentPathPrefix( + ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName), + $"restoring packages from NuGet.org's V3 feed"), + + new ActionDescriptionForComponentPathPrefix( + ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V2ProtocolName), + $"restoring packages from NuGet.org's V2 feed"), + + new ActionDescriptionForComponentPathPrefix( + ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName), + $"restoring packages"), + + new ActionDescriptionForComponentPathPrefix( + ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.ChinaRegionName), + $"searching for packages from China"), + + new ActionDescriptionForComponentPathPrefix( + ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName), + $"searching for packages"), + + new ActionDescriptionForComponentPathPrefix( + ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.UploadName), + "uploading new packages"), + }; + + private class ActionDescriptionForComponentPathPrefix + { + public string ComponentPathPrefix { get; } + public string ActionDescription { get; } + + public ActionDescriptionForComponentPathPrefix(string componentPathPrefix, string actionDescription) + { + ComponentPathPrefix = componentPathPrefix; + ActionDescription = actionDescription; + } + + public bool Matches(string componentPath) + { + return componentPath.StartsWith(ComponentPathPrefix, StringComparison.OrdinalIgnoreCase); + } + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Messages/MessageFactory.cs b/src/StatusAggregator/Messages/MessageFactory.cs new file mode 100644 index 000000000..0f6f678a2 --- /dev/null +++ b/src/StatusAggregator/Messages/MessageFactory.cs @@ -0,0 +1,91 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Table; + +namespace StatusAggregator.Messages +{ + public class MessageFactory : IMessageFactory + { + private readonly ITableWrapper _table; + private readonly IMessageContentBuilder _builder; + + private readonly ILogger _logger; + + public MessageFactory( + ITableWrapper table, + IMessageContentBuilder builder, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _builder = builder ?? throw new ArgumentNullException(nameof(builder)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task CreateMessageAsync(EventEntity eventEntity, DateTime time, MessageType type, IComponent component) + { + return CreateMessageAsync(eventEntity, time, type, component, component.Status); + } + + public async Task CreateMessageAsync(EventEntity eventEntity, DateTime time, MessageType type, IComponent component, ComponentStatus status) + { + using (_logger.Scope("Creating new message of type {Type} for event {EventRowKey} at {Timestamp} affecting {ComponentPath} with status {ComponentStatus}.", + type, eventEntity.RowKey, time, component.Path, status)) + { + var existingMessage = await _table.RetrieveAsync(MessageEntity.GetRowKey(eventEntity, time)); + if (existingMessage != null) + { + _logger.LogInformation("Message already exists, will not recreate."); + return; + } + + var contents = _builder.Build(type, component, status); + var messageEntity = new MessageEntity(eventEntity, time, contents, type); + _logger.LogInformation("Creating message with time {MessageTimestamp} and contents {MessageContents}.", + messageEntity.Time, messageEntity.Contents); + await _table.InsertAsync(messageEntity); + } + } + + public async Task UpdateMessageAsync(EventEntity eventEntity, DateTime time, MessageType type, IComponent component) + { + using (_logger.Scope("Updating existing message of type {Type} for event {EventRowKey} at {Timestamp} affecting {ComponentPath}.", + type, eventEntity.RowKey, time, component.Path)) + { + var existingMessage = await _table.RetrieveAsync(MessageEntity.GetRowKey(eventEntity, time)); + if (existingMessage == null) + { + _logger.LogWarning("Cannot update message that doesn't exist."); + return; + } + + var existingMessageType = (MessageType)existingMessage.Type; + if (existingMessageType != type) + { + if (existingMessageType == MessageType.Manual) + { + _logger.LogInformation("Message was changed manually, cannot update."); + } + else + { + _logger.LogWarning("Cannot update message, has unexpected type {UnexpectedType}.", existingMessageType); + } + + return; + } + + var newContents = _builder.Build(type, component); + _logger.LogInformation("Replacing contents of message with time {MessageTimestamp} and contents {OldMessageContents} with {NewMessageContents}.", + existingMessage.Time, existingMessage.Contents, newContents); + existingMessage.Contents = newContents; + await _table.ReplaceAsync(existingMessage); + } + } + } +} \ No newline at end of file diff --git a/src/StatusAggregator/Parse/EnvironmentPrefixIncidentParser.cs b/src/StatusAggregator/Parse/EnvironmentPrefixIncidentParser.cs deleted file mode 100644 index 2af15eaee..000000000 --- a/src/StatusAggregator/Parse/EnvironmentPrefixIncidentParser.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using Microsoft.Extensions.Logging; -using NuGet.Services.Incidents; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace StatusAggregator.Parse -{ - /// - /// Subclass of that expects s are prefixed with "[ENVIRONMENT]". - /// - public abstract class EnvironmentPrefixIncidentParser : IncidentParser - { - public EnvironmentPrefixIncidentParser( - string subtitleRegEx, - IEnumerable filters, - ILogger logger) - : base(GetRegEx(subtitleRegEx), filters, logger) - { - if (!filters.Any(f => f is EnvironmentFilter)) - { - throw new ArgumentException($"A {nameof(EnvironmentPrefixIncidentParser)} must be run with an {nameof(EnvironmentFilter)}!", nameof(filters)); - } - } - - private static string GetRegEx(string subtitleRegEx) - { - return $@"\[(?<{EnvironmentFilter.EnvironmentGroupName}>.*)\] {subtitleRegEx}"; - } - } -} diff --git a/src/StatusAggregator/Parse/EnvironmentPrefixIncidentRegexParsingHandler.cs b/src/StatusAggregator/Parse/EnvironmentPrefixIncidentRegexParsingHandler.cs new file mode 100644 index 000000000..7e4305314 --- /dev/null +++ b/src/StatusAggregator/Parse/EnvironmentPrefixIncidentRegexParsingHandler.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using NuGet.Services.Incidents; + +namespace StatusAggregator.Parse +{ + /// + /// Subclass of that expects s are prefixed with "[ENVIRONMENT]". + /// + public abstract class EnvironmentPrefixIncidentRegexParsingHandler : IncidentRegexParsingHandler + { + public EnvironmentPrefixIncidentRegexParsingHandler( + string subtitleRegEx, + IEnumerable filters) + : base( + PrependEnvironmentRegexGroup(subtitleRegEx), + filters) + { + if (!filters.Any(f => f is EnvironmentRegexParsingFilter)) + { + throw new ArgumentException( + $"A {nameof(EnvironmentPrefixIncidentRegexParsingHandler)} must be run with an {nameof(EnvironmentRegexParsingFilter)}!", + nameof(filters)); + } + } + + private static string PrependEnvironmentRegexGroup(string subtitleRegEx) + { + return $@"\[(?<{EnvironmentRegexParsingFilter.EnvironmentGroupName}>.*)\] {subtitleRegEx}"; + } + } +} diff --git a/src/StatusAggregator/Parse/EnvironmentFilter.cs b/src/StatusAggregator/Parse/EnvironmentRegexParsingFilter.cs similarity index 87% rename from src/StatusAggregator/Parse/EnvironmentFilter.cs rename to src/StatusAggregator/Parse/EnvironmentRegexParsingFilter.cs index bf5943496..d267b5ef6 100644 --- a/src/StatusAggregator/Parse/EnvironmentFilter.cs +++ b/src/StatusAggregator/Parse/EnvironmentRegexParsingFilter.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.Extensions.Logging; -using NuGet.Jobs.Extensions; using NuGet.Services.Incidents; using System; using System.Collections.Generic; @@ -14,17 +13,17 @@ namespace StatusAggregator.Parse /// /// Expects that the contains a named with a whitelisted value. /// - public class EnvironmentFilter : IIncidentParsingFilter + public class EnvironmentRegexParsingFilter : IIncidentRegexParsingFilter { public const string EnvironmentGroupName = "Environment"; private IEnumerable _environments { get; } - private readonly ILogger _logger; + private readonly ILogger _logger; - public EnvironmentFilter( + public EnvironmentRegexParsingFilter( StatusAggregatorConfiguration configuration, - ILogger logger) + ILogger logger) { _environments = configuration?.Environments ?? throw new ArgumentNullException(nameof(configuration)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); diff --git a/src/StatusAggregator/Parse/IIncidentParser.cs b/src/StatusAggregator/Parse/IIncidentParser.cs index c28354f6a..3ba48578f 100644 --- a/src/StatusAggregator/Parse/IIncidentParser.cs +++ b/src/StatusAggregator/Parse/IIncidentParser.cs @@ -1,7 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using NuGet.Services.Status; using NuGet.Services.Incidents; namespace StatusAggregator.Parse diff --git a/src/StatusAggregator/Parse/IIncidentParsingFilter.cs b/src/StatusAggregator/Parse/IIncidentRegexParsingFilter.cs similarity index 73% rename from src/StatusAggregator/Parse/IIncidentParsingFilter.cs rename to src/StatusAggregator/Parse/IIncidentRegexParsingFilter.cs index efed05e9d..cf68cd7b3 100644 --- a/src/StatusAggregator/Parse/IIncidentParsingFilter.cs +++ b/src/StatusAggregator/Parse/IIncidentRegexParsingFilter.cs @@ -7,12 +7,12 @@ namespace StatusAggregator.Parse { /// - /// An additional filter that can be applied to a + /// An additional filter that can be applied to a /// - public interface IIncidentParsingFilter + public interface IIncidentRegexParsingFilter { /// - /// Returns whether or not an should parse . + /// Returns whether or not an should parse . /// bool ShouldParse(Incident incident, GroupCollection groups); } diff --git a/src/StatusAggregator/Parse/IIncidentRegexParsingHandler.cs b/src/StatusAggregator/Parse/IIncidentRegexParsingHandler.cs new file mode 100644 index 000000000..f25a72e41 --- /dev/null +++ b/src/StatusAggregator/Parse/IIncidentRegexParsingHandler.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Text.RegularExpressions; +using NuGet.Services.Incidents; +using NuGet.Services.Status; + +namespace StatusAggregator.Parse +{ + public interface IIncidentRegexParsingHandler + { + string RegexPattern { get; } + IReadOnlyCollection Filters { get; } + + /// + /// Attempts to parse a from . + /// + /// + /// The parsed from or null if could not be parsed. + /// + /// + /// true if a can be parsed from and false otherwise. + /// + bool TryParseAffectedComponentPath(Incident incident, GroupCollection groups, out string affectedComponentPath); + + /// + /// Attempts to parse a from . + /// + /// + /// The parsed from or if could not be parsed. + /// + /// true if a can be parsed from and false otherwise. + /// + bool TryParseAffectedComponentStatus(Incident incident, GroupCollection groups, out ComponentStatus affectedComponentStatus); + } +} diff --git a/src/StatusAggregator/Parse/IncidentParser.cs b/src/StatusAggregator/Parse/IncidentRegexParser.cs similarity index 51% rename from src/StatusAggregator/Parse/IncidentParser.cs rename to src/StatusAggregator/Parse/IncidentRegexParser.cs index 86745c693..99bd42991 100644 --- a/src/StatusAggregator/Parse/IncidentParser.cs +++ b/src/StatusAggregator/Parse/IncidentRegexParser.cs @@ -4,58 +4,44 @@ using Microsoft.Extensions.Logging; using NuGet.Jobs.Extensions; using NuGet.Services.Incidents; -using NuGet.Services.Status; using System; -using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; namespace StatusAggregator.Parse { /// - /// Abstract implementation of that allows specifying a to analyze s with. + /// Implementation of that uses to parse s with. /// - public abstract class IncidentParser : IIncidentParser + public class IncidentRegexParser : IIncidentParser { private readonly static TimeSpan MaxRegexExecutionTime = TimeSpan.FromSeconds(5); - private readonly string _regExPattern; + private readonly IIncidentRegexParsingHandler _handler; - private readonly IEnumerable _filters; - - private readonly ILogger _logger; - - public IncidentParser( - string regExPattern, - ILogger logger) + private readonly ILogger _logger; + + public IncidentRegexParser( + IIncidentRegexParsingHandler handler, + ILogger logger) { - _regExPattern = regExPattern ?? throw new ArgumentNullException(nameof(regExPattern)); - _filters = Enumerable.Empty(); + _handler = handler ?? throw new ArgumentNullException(nameof(_handler)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public IncidentParser( - string regExPattern, - IEnumerable filters, - ILogger logger) - : this(regExPattern, logger) - { - _filters = filters?.ToList() ?? throw new ArgumentNullException(nameof(filters)); - } - public bool TryParseIncident(Incident incident, out ParsedIncident parsedIncident) { var title = incident.Title; using (_logger.Scope("Using parser {IncidentParserType} with pattern {RegExPattern} to parse incident with title {IncidentTitle}", - GetType(), _regExPattern, title)) + GetType(), _handler.RegexPattern, title)) { parsedIncident = null; Match match = null; try { - match = Regex.Match(title, _regExPattern, RegexOptions.None, MaxRegexExecutionTime); + match = Regex.Match(title, _handler.RegexPattern, RegexOptions.None, MaxRegexExecutionTime); } catch (Exception e) { @@ -65,6 +51,7 @@ public bool TryParseIncident(Incident incident, out ParsedIncident parsedInciden if (match == null) { + // According to its documentation, Regex.Match shouldn't return null, but this if statement is in here as a precaution. _logger.LogError("Parsed incident using regex successfully, but was unable to get match information!"); return false; } @@ -78,7 +65,7 @@ private bool TryParseIncident(Incident incident, GroupCollection groups, out Par { parsedIncident = null; - if (_filters.Any(f => + if (_handler.Filters.Any(f => { using (_logger.Scope("Filtering incident using filter {IncidentFilterType}", f.GetType())) { @@ -92,7 +79,7 @@ private bool TryParseIncident(Incident incident, GroupCollection groups, out Par return false; } - if (!TryParseAffectedComponentPath(incident, groups, out var affectedComponentPath)) + if (!_handler.TryParseAffectedComponentPath(incident, groups, out var affectedComponentPath)) { _logger.LogInformation("Could not parse incident component path!"); return false; @@ -100,7 +87,7 @@ private bool TryParseIncident(Incident incident, GroupCollection groups, out Par _logger.LogInformation("Parsed affected component path {AffectedComponentPath}.", affectedComponentPath); - if (!TryParseAffectedComponentStatus(incident, groups, out var affectedComponentStatus)) + if (!_handler.TryParseAffectedComponentStatus(incident, groups, out var affectedComponentStatus)) { _logger.LogInformation("Could not parse incident component status!"); return false; @@ -111,26 +98,5 @@ private bool TryParseIncident(Incident incident, GroupCollection groups, out Par parsedIncident = new ParsedIncident(incident, affectedComponentPath, affectedComponentStatus); return true; } - - /// - /// Attempts to parse a from . - /// - /// - /// The parsed from or null if could not be parsed. - /// - /// - /// true if a can be parsed from and false otherwise. - /// - protected abstract bool TryParseAffectedComponentPath(Incident incident, GroupCollection groups, out string affectedComponentPath); - - /// - /// Attempts to parse a from . - /// - /// - /// The parsed from or if could not be parsed. - /// - /// true if a can be parsed from and false otherwise. - /// - protected abstract bool TryParseAffectedComponentStatus(Incident incident, GroupCollection groups, out ComponentStatus affectedComponentStatus); } } diff --git a/src/StatusAggregator/Parse/IncidentRegexParsingHandler.cs b/src/StatusAggregator/Parse/IncidentRegexParsingHandler.cs new file mode 100644 index 000000000..04c2939b7 --- /dev/null +++ b/src/StatusAggregator/Parse/IncidentRegexParsingHandler.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using NuGet.Services.Incidents; +using NuGet.Services.Status; + +namespace StatusAggregator.Parse +{ + public abstract class IncidentRegexParsingHandler : IIncidentRegexParsingHandler + { + public IncidentRegexParsingHandler( + string regexPattern, + IEnumerable filters) + { + RegexPattern = regexPattern ?? throw new ArgumentNullException(nameof(regexPattern)); + Filters = filters?.ToList() ?? throw new ArgumentNullException(nameof(filters)); + } + + public string RegexPattern { get; } + public IReadOnlyCollection Filters { get; } + + public abstract bool TryParseAffectedComponentPath(Incident incident, GroupCollection groups, out string affectedComponentPath); + public abstract bool TryParseAffectedComponentStatus(Incident incident, GroupCollection groups, out ComponentStatus affectedComponentStatus); + } +} diff --git a/src/StatusAggregator/Parse/OutdatedSearchServiceInstanceIncidentParser.cs b/src/StatusAggregator/Parse/OutdatedSearchServiceInstanceIncidentRegexParsingHandler.cs similarity index 52% rename from src/StatusAggregator/Parse/OutdatedSearchServiceInstanceIncidentParser.cs rename to src/StatusAggregator/Parse/OutdatedSearchServiceInstanceIncidentRegexParsingHandler.cs index c0bb0f822..c0d640b25 100644 --- a/src/StatusAggregator/Parse/OutdatedSearchServiceInstanceIncidentParser.cs +++ b/src/StatusAggregator/Parse/OutdatedSearchServiceInstanceIncidentRegexParsingHandler.cs @@ -1,36 +1,37 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using Microsoft.Extensions.Logging; -using NuGet.Services.Incidents; -using NuGet.Services.Status; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using StatusAggregator.Factory; namespace StatusAggregator.Parse { - public class OutdatedSearchServiceInstanceIncidentParser : EnvironmentPrefixIncidentParser + public class OutdatedSearchServiceInstanceIncidentRegexParsingHandler : EnvironmentPrefixIncidentRegexParsingHandler { private const string SubtitleRegEx = "All search service instances are using an outdated index!"; - public OutdatedSearchServiceInstanceIncidentParser( - IEnumerable filters, - ILogger logger) + public OutdatedSearchServiceInstanceIncidentRegexParsingHandler( + IEnumerable filters, + ILogger logger) : base( SubtitleRegEx, - filters.Where(f => !(f is SeverityFilter)), // The incident is always severity 4. - logger) + // The incident is always severity 4. + filters.Where(f => !(f is SeverityRegexParsingFilter))) { } - protected override bool TryParseAffectedComponentPath(Incident incident, GroupCollection groups, out string affectedComponentPath) + public override bool TryParseAffectedComponentPath(Incident incident, GroupCollection groups, out string affectedComponentPath) { affectedComponentPath = ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.UploadName); return true; } - protected override bool TryParseAffectedComponentStatus(Incident incident, GroupCollection groups, out ComponentStatus affectedComponentStatus) + public override bool TryParseAffectedComponentStatus(Incident incident, GroupCollection groups, out ComponentStatus affectedComponentStatus) { affectedComponentStatus = ComponentStatus.Degraded; return true; diff --git a/src/StatusAggregator/Parse/ParsedIncident.cs b/src/StatusAggregator/Parse/ParsedIncident.cs index 4ce85ad10..b12987f1c 100644 --- a/src/StatusAggregator/Parse/ParsedIncident.cs +++ b/src/StatusAggregator/Parse/ParsedIncident.cs @@ -23,16 +23,17 @@ public ParsedIncident( } Id = incident.Id; - CreationTime = incident.Source.CreateDate; - MitigationTime = incident.MitigationData?.Date; + StartTime = incident.Source.CreateDate; + EndTime = incident.MitigationData?.Date; AffectedComponentPath = affectedComponentPath; AffectedComponentStatus = affectedComponentStatus; } - public string Id { get; set; } - public string AffectedComponentPath { get; set; } - public ComponentStatus AffectedComponentStatus { get; set; } - public DateTime CreationTime { get; set; } - public DateTime? MitigationTime { get; set; } + public string Id { get; } + public string AffectedComponentPath { get; } + public ComponentStatus AffectedComponentStatus { get; } + public DateTime StartTime { get; } + public DateTime? EndTime { get; } + public bool IsActive => EndTime == null; } } diff --git a/src/StatusAggregator/Parse/PingdomIncidentParser.cs b/src/StatusAggregator/Parse/PingdomIncidentRegexParsingHandler.cs similarity index 86% rename from src/StatusAggregator/Parse/PingdomIncidentParser.cs rename to src/StatusAggregator/Parse/PingdomIncidentRegexParsingHandler.cs index 4f202cf9f..21cdb4b32 100644 --- a/src/StatusAggregator/Parse/PingdomIncidentParser.cs +++ b/src/StatusAggregator/Parse/PingdomIncidentRegexParsingHandler.cs @@ -7,26 +7,27 @@ using Microsoft.Extensions.Logging; using NuGet.Services.Incidents; using NuGet.Services.Status; +using StatusAggregator.Factory; namespace StatusAggregator.Parse { - public class PingdomIncidentParser : IncidentParser + public class PingdomIncidentRegexParsingHandler : IncidentRegexParsingHandler { - private const string CheckNameGroupName = "CheckName"; - private const string CheckUrlGroupName = "CheckUrl"; + public const string CheckNameGroupName = "CheckName"; + public const string CheckUrlGroupName = "CheckUrl"; private static string SubtitleRegEx = $@"Pingdom check '(?<{CheckNameGroupName}>.*)' is failing! '(?<{CheckUrlGroupName}>.*)' is DOWN!"; - private readonly ILogger _logger; + private readonly ILogger _logger; - public PingdomIncidentParser( - IEnumerable filters, - ILogger logger) - : base(SubtitleRegEx, filters, logger) + public PingdomIncidentRegexParsingHandler( + IEnumerable filters, + ILogger logger) + : base(SubtitleRegEx, filters) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - protected override bool TryParseAffectedComponentPath(Incident incident, GroupCollection groups, out string affectedComponentPath) + public override bool TryParseAffectedComponentPath(Incident incident, GroupCollection groups, out string affectedComponentPath) { affectedComponentPath = null; @@ -95,7 +96,7 @@ protected override bool TryParseAffectedComponentPath(Incident incident, GroupCo return true; } - protected override bool TryParseAffectedComponentStatus(Incident incident, GroupCollection groups, out ComponentStatus affectedComponentStatus) + public override bool TryParseAffectedComponentStatus(Incident incident, GroupCollection groups, out ComponentStatus affectedComponentStatus) { affectedComponentStatus = ComponentStatus.Degraded; return true; diff --git a/src/StatusAggregator/Parse/SeverityFilter.cs b/src/StatusAggregator/Parse/SeverityRegexParsingFilter.cs similarity index 83% rename from src/StatusAggregator/Parse/SeverityFilter.cs rename to src/StatusAggregator/Parse/SeverityRegexParsingFilter.cs index 53346e5fc..eadaad92f 100644 --- a/src/StatusAggregator/Parse/SeverityFilter.cs +++ b/src/StatusAggregator/Parse/SeverityRegexParsingFilter.cs @@ -11,15 +11,15 @@ namespace StatusAggregator.Parse /// /// Expects that the severity of an must be lower than a threshold. /// - public class SeverityFilter : IIncidentParsingFilter + public class SeverityRegexParsingFilter : IIncidentRegexParsingFilter { private readonly int _maximumSeverity; - private readonly ILogger _logger; + private readonly ILogger _logger; - public SeverityFilter( + public SeverityRegexParsingFilter( StatusAggregatorConfiguration configuration, - ILogger logger) + ILogger logger) { _maximumSeverity = configuration?.MaximumSeverity ?? throw new ArgumentNullException(nameof(configuration)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); diff --git a/src/StatusAggregator/Parse/TrafficManagerEndpointStatusIncidentParser.cs b/src/StatusAggregator/Parse/TrafficManagerEndpointStatusIncidentRegexParsingHandler.cs similarity index 84% rename from src/StatusAggregator/Parse/TrafficManagerEndpointStatusIncidentParser.cs rename to src/StatusAggregator/Parse/TrafficManagerEndpointStatusIncidentRegexParsingHandler.cs index d44047c84..9afbb0581 100644 --- a/src/StatusAggregator/Parse/TrafficManagerEndpointStatusIncidentParser.cs +++ b/src/StatusAggregator/Parse/TrafficManagerEndpointStatusIncidentRegexParsingHandler.cs @@ -1,37 +1,39 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using NuGet.Services.Incidents; using NuGet.Services.Status; -using System.Collections.Generic; -using System.Text.RegularExpressions; +using StatusAggregator.Factory; namespace StatusAggregator.Parse { - public class TrafficManagerEndpointStatusIncidentParser : EnvironmentPrefixIncidentParser + public class TrafficManagerEndpointStatusIncidentRegexParsingHandler : EnvironmentPrefixIncidentRegexParsingHandler { - private const string DomainGroupName = "Domain"; - private const string TargetGroupName = "Target"; + public const string DomainGroupName = "Domain"; + public const string TargetGroupName = "Target"; private static string SubtitleRegEx = $"Traffic Manager for (?<{DomainGroupName}>.*) is reporting (?<{TargetGroupName}>.*) as not Online!"; - private readonly ILogger _logger; + private readonly ILogger _logger; - public TrafficManagerEndpointStatusIncidentParser( - IEnumerable filters, - ILogger logger) - : base(SubtitleRegEx, filters, logger) + public TrafficManagerEndpointStatusIncidentRegexParsingHandler( + IEnumerable filters, + ILogger logger) + : base(SubtitleRegEx, filters) { - _logger = logger; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - protected override bool TryParseAffectedComponentPath(Incident incident, GroupCollection groups, out string affectedComponentPath) + public override bool TryParseAffectedComponentPath(Incident incident, GroupCollection groups, out string affectedComponentPath) { affectedComponentPath = null; var domain = groups[DomainGroupName].Value; var target = groups[TargetGroupName].Value; - var environment = groups[EnvironmentFilter.EnvironmentGroupName].Value; + var environment = groups[EnvironmentRegexParsingFilter.EnvironmentGroupName].Value; _logger.LogInformation("Domain is {Domain}, target is {Target}, environment is {Environment}.", domain, target, environment); if (EnvironmentToDomainToTargetToPath.TryGetValue(environment, out var domainToTargetToPath) && @@ -44,7 +46,7 @@ protected override bool TryParseAffectedComponentPath(Incident incident, GroupCo return affectedComponentPath != null; } - protected override bool TryParseAffectedComponentStatus(Incident incident, GroupCollection groups, out ComponentStatus affectedComponentStatus) + public override bool TryParseAffectedComponentStatus(Incident incident, GroupCollection groups, out ComponentStatus affectedComponentStatus) { affectedComponentStatus = ComponentStatus.Down; return true; @@ -90,7 +92,7 @@ protected override bool TryParseAffectedComponentStatus(Incident incident, Group { "nuget-dev-ussc-gallery.cloudapp.net", - GalleryUsncPath + GalleryUsscPath } } }, @@ -125,7 +127,7 @@ protected override bool TryParseAffectedComponentStatus(Incident incident, Group { "nuget-int-ussc-gallery.cloudapp.net", - GalleryUsncPath + GalleryUsscPath } } } @@ -145,7 +147,7 @@ protected override bool TryParseAffectedComponentStatus(Incident incident, Group { "nuget-prod-ussc-gallery.cloudapp.net", - GalleryUsncPath + GalleryUsscPath } } }, diff --git a/src/StatusAggregator/Parse/ValidationDurationIncidentParser.cs b/src/StatusAggregator/Parse/ValidationDurationIncidentRegexParsingHandler.cs similarity index 54% rename from src/StatusAggregator/Parse/ValidationDurationIncidentParser.cs rename to src/StatusAggregator/Parse/ValidationDurationIncidentRegexParsingHandler.cs index 8d9d80480..2e347d03e 100644 --- a/src/StatusAggregator/Parse/ValidationDurationIncidentParser.cs +++ b/src/StatusAggregator/Parse/ValidationDurationIncidentRegexParsingHandler.cs @@ -6,27 +6,28 @@ using Microsoft.Extensions.Logging; using NuGet.Services.Incidents; using NuGet.Services.Status; +using StatusAggregator.Factory; namespace StatusAggregator.Parse { - public class ValidationDurationIncidentParser : EnvironmentPrefixIncidentParser + public class ValidationDurationIncidentRegexParsingHandler : EnvironmentPrefixIncidentRegexParsingHandler { private const string SubtitleRegEx = "Too many packages are stuck in the \"Validating\" state!"; - public ValidationDurationIncidentParser( - IEnumerable filters, - ILogger logger) - : base(SubtitleRegEx, filters, logger) + public ValidationDurationIncidentRegexParsingHandler( + IEnumerable filters, + ILogger logger) + : base(SubtitleRegEx, filters) { } - protected override bool TryParseAffectedComponentPath(Incident incident, GroupCollection groups, out string affectedComponentPath) + public override bool TryParseAffectedComponentPath(Incident incident, GroupCollection groups, out string affectedComponentPath) { affectedComponentPath = ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.UploadName); return true; } - protected override bool TryParseAffectedComponentStatus(Incident incident, GroupCollection groups, out ComponentStatus affectedComponentStatus) + public override bool TryParseAffectedComponentStatus(Incident incident, GroupCollection groups, out ComponentStatus affectedComponentStatus) { affectedComponentStatus = ComponentStatus.Degraded; return true; diff --git a/src/StatusAggregator/Properties/AssemblyInfo.cs b/src/StatusAggregator/Properties/AssemblyInfo.cs index 3c3117262..1acab632c 100644 --- a/src/StatusAggregator/Properties/AssemblyInfo.cs +++ b/src/StatusAggregator/Properties/AssemblyInfo.cs @@ -1,5 +1,7 @@ -using System.Reflection; -using System.Runtime.CompilerServices; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Reflection; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following diff --git a/src/StatusAggregator/StatusAggregator.cs b/src/StatusAggregator/StatusAggregator.cs index 91262597e..35457f757 100644 --- a/src/StatusAggregator/StatusAggregator.cs +++ b/src/StatusAggregator/StatusAggregator.cs @@ -1,25 +1,27 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using Microsoft.WindowsAzure.Storage.Blob; -using StatusAggregator.Table; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using StatusAggregator.Container; +using StatusAggregator.Export; +using StatusAggregator.Table; +using StatusAggregator.Update; namespace StatusAggregator { public class StatusAggregator { - private readonly IEnumerable _containers; + private readonly IEnumerable _containers; private readonly IEnumerable _tables; private readonly IStatusUpdater _statusUpdater; private readonly IStatusExporter _statusExporter; public StatusAggregator( - IEnumerable containers, + IEnumerable containers, IEnumerable tables, IStatusUpdater statusUpdater, IStatusExporter statusExporter) @@ -30,15 +32,15 @@ public StatusAggregator( _statusExporter = statusExporter ?? throw new ArgumentNullException(nameof(statusExporter)); } - public async Task Run() + public async Task Run(DateTime cursor) { // Initialize all tables and containers. await Task.WhenAll(_tables.Select(t => t.CreateIfNotExistsAsync())); await Task.WhenAll(_containers.Select(c => c.CreateIfNotExistsAsync())); // Update and export the status. - await _statusUpdater.Update(); - await _statusExporter.Export(); + await _statusUpdater.Update(cursor); + await _statusExporter.Export(cursor); } } } diff --git a/src/StatusAggregator/StatusAggregator.csproj b/src/StatusAggregator/StatusAggregator.csproj index 1754657d4..6047d211d 100644 --- a/src/StatusAggregator/StatusAggregator.csproj +++ b/src/StatusAggregator/StatusAggregator.csproj @@ -46,6 +46,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -53,50 +107,35 @@ - - - + - + - - - - - - - - - - - - - + + + + - - - - - - - + - + - + - - + + + + @@ -118,13 +157,13 @@ 1.1.1 - 2.28.0-master-38796 + 2.29.0 - 2.28.0-master-38796 + 2.29.0 - 2.28.0-master-38796 + 2.29.0 9.2.0 diff --git a/src/StatusAggregator/StatusAggregatorConfiguration.cs b/src/StatusAggregator/StatusAggregatorConfiguration.cs index f2aea668a..aa069ae64 100644 --- a/src/StatusAggregator/StatusAggregatorConfiguration.cs +++ b/src/StatusAggregator/StatusAggregatorConfiguration.cs @@ -1,8 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using NuGet.Services.Status; -using StatusAggregator.Parse; using System.Collections.Generic; namespace StatusAggregator @@ -43,19 +41,17 @@ public class StatusAggregatorConfiguration /// /// A team ID to use to query the incident API. - /// See . /// public string TeamId { get; set; } /// /// The number of minutes that must pass before a message is created for a recently started event. - /// In other words, will wait this amount of time before it creates a start message for an event. /// public int EventStartMessageDelayMinutes { get; set; } /// /// The number of minutes that must pass before an event whose incidents have all been mitigated is deactivated. - /// In other words, will wait this amount of time before it deactivates an event with all mitigated incidents. + /// In other words, will wait this amount of time before it deactivates an event with all mitigated incidents. /// public int EventEndDelayMinutes { get; set; } diff --git a/src/StatusAggregator/StatusContractResolver.cs b/src/StatusAggregator/StatusContractResolver.cs deleted file mode 100644 index cb9419116..000000000 --- a/src/StatusAggregator/StatusContractResolver.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections; -using System.Reflection; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; - -namespace StatusAggregator -{ - /// - /// Implementation of used by such that empty fields and arrays are not serialized. - /// - public class StatusContractResolver : DefaultContractResolver - { - protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) - { - JsonProperty property = base.CreateProperty(member, memberSerialization); - - var propertyType = property.PropertyType; - - if (propertyType == typeof(string)) - { - // Do not serialize strings if they are null or empty. - property.ShouldSerialize = instance => !string.IsNullOrEmpty((string)instance); - } - - if (typeof(IEnumerable).IsAssignableFrom(propertyType)) - { - SetShouldSerializeForIEnumerable(property, member); - } - - return property; - } - - private void SetShouldSerializeForIEnumerable(JsonProperty property, MemberInfo member) - { - Func getValue; - - // Create a function to get the value of the member using its type. - switch (member.MemberType) - { - case MemberTypes.Field: - getValue = instance => ((FieldInfo)member).GetValue(instance); - break; - case MemberTypes.Property: - getValue = instance => ((PropertyInfo)member).GetValue(instance); - break; - default: - return; - } - - // Do not serialize an IEnumerable if it is null or empty - property.ShouldSerialize = instance => - { - var value = (IEnumerable)getValue(instance); - - if (value == null) - { - return false; - } - - foreach (var obj in value) - { - return true; - } - - return false; - }; - } - } -} diff --git a/src/StatusAggregator/StatusExporter.cs b/src/StatusAggregator/StatusExporter.cs deleted file mode 100644 index 8ebc12082..000000000 --- a/src/StatusAggregator/StatusExporter.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.WindowsAzure.Storage.Blob; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using NuGet.Jobs.Extensions; -using NuGet.Services.Status; -using NuGet.Services.Status.Table; -using StatusAggregator.Table; - -namespace StatusAggregator -{ - public class StatusExporter : IStatusExporter - { - private const string StatusBlobName = "status.json"; - private readonly TimeSpan _eventVisibilityPeriod; - - private readonly CloudBlobContainer _container; - private readonly ITableWrapper _table; - - private readonly ILogger _logger; - - private static readonly JsonSerializerSettings _statusBlobJsonSerializerSettings = new JsonSerializerSettings() - { - ContractResolver = new StatusContractResolver(), - Converters = new List() { new StringEnumConverter() }, - DateTimeZoneHandling = DateTimeZoneHandling.Utc, - NullValueHandling = NullValueHandling.Ignore - }; - - public StatusExporter( - CloudBlobContainer container, - ITableWrapper table, - StatusAggregatorConfiguration configuration, - ILogger logger) - { - _container = container ?? throw new ArgumentNullException(nameof(container)); - _table = table ?? throw new ArgumentNullException(nameof(table)); - _eventVisibilityPeriod = TimeSpan.FromDays(configuration?.EventVisibilityPeriodDays ?? throw new ArgumentNullException(nameof(configuration))); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task Export() - { - using (_logger.Scope("Exporting service status.")) - { - var rootComponent = NuGetServiceComponentFactory.CreateNuGetServiceRootComponent(); - - var recentEvents = _table - .CreateQuery() - .Where(e => - e.PartitionKey == EventEntity.DefaultPartitionKey && - (e.IsActive || (e.EndTime >= DateTime.UtcNow - _eventVisibilityPeriod))) - .ToList() - .Select(e => - { - var messages = _table.GetMessagesLinkedToEvent(e) - .ToList() - .Select(m => m.AsMessage()); - return e.AsEvent(messages); - }) - .Where(e => e.Messages != null && e.Messages.Any()); - - // If multiple events are affecting a single region, the event with the highest severity should affect the component. - var activeEvents = recentEvents - .Where(e => e.EndTime == null || e.EndTime >= DateTime.UtcNow) - .GroupBy(e => e.AffectedComponentPath) - .Select(g => g.OrderByDescending(e => e.AffectedComponentStatus).First()); - - foreach (var activeEvent in activeEvents) - { - using (_logger.Scope("Applying active event affecting '{AffectedComponentPath}' of severity {AffectedComponentStatus} at {StartTime} to root component", - activeEvent.AffectedComponentPath, activeEvent.AffectedComponentStatus, activeEvent.StartTime)) - { - var currentComponent = rootComponent.GetByPath(activeEvent.AffectedComponentPath); - - if (currentComponent == null) - { - _logger.LogWarning("Couldn't find component corresponding to active event."); - continue; - } - - currentComponent.Status = activeEvent.AffectedComponentStatus; - } - } - - ServiceStatus status; - string statusJson; - using (_logger.Scope("Serializing service status.")) - { - status = new ServiceStatus(rootComponent, recentEvents); - statusJson = JsonConvert.SerializeObject(status, _statusBlobJsonSerializerSettings); - } - - using (_logger.Scope("Saving service status to blob storage.")) - { - var blob = _container.GetBlockBlobReference(StatusBlobName); - await blob.UploadTextAsync(statusJson); - } - - return status; - } - } - } -} diff --git a/src/StatusAggregator/StatusUpdater.cs b/src/StatusAggregator/StatusUpdater.cs index e0fdc7060..ed06327e1 100644 --- a/src/StatusAggregator/StatusUpdater.cs +++ b/src/StatusAggregator/StatusUpdater.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using NuGet.Jobs.Extensions; using StatusAggregator.Manual; +using StatusAggregator.Update; using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -18,7 +19,7 @@ public class StatusUpdater : IStatusUpdater private readonly ICursor _cursor; private readonly IEnumerable _manualStatusChangeUpdaters; private readonly IIncidentUpdater _incidentUpdater; - private readonly IEventUpdater _eventUpdater; + private readonly IActiveEventEntityUpdater _activeEventUpdater; private readonly ILogger _logger; @@ -26,13 +27,13 @@ public StatusUpdater( ICursor cursor, IEnumerable manualStatusChangeUpdaters, IIncidentUpdater incidentUpdater, - IEventUpdater eventUpdater, + IActiveEventEntityUpdater eventUpdater, ILogger logger) { _cursor = cursor ?? throw new ArgumentNullException(nameof(cursor)); _manualStatusChangeUpdaters = manualStatusChangeUpdaters ?? throw new ArgumentNullException(nameof(manualStatusChangeUpdaters)); _incidentUpdater = incidentUpdater ?? throw new ArgumentNullException(nameof(incidentUpdater)); - _eventUpdater = eventUpdater ?? throw new ArgumentNullException(nameof(eventUpdater)); + _activeEventUpdater = eventUpdater ?? throw new ArgumentNullException(nameof(eventUpdater)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -47,11 +48,10 @@ public async Task Update() var incidentCursor = await ProcessCursor(IncidentCursorName, async (value) => { - await _incidentUpdater.RefreshActiveIncidents(); return await _incidentUpdater.FetchNewIncidents(value); }); - await _eventUpdater.UpdateActiveEvents(incidentCursor); + await _activeEventUpdater.UpdateAllAsync(incidentCursor); } } diff --git a/src/StatusAggregator/Table/ITableWrapper.cs b/src/StatusAggregator/Table/ITableWrapper.cs index e1d369cd1..180cbef1a 100644 --- a/src/StatusAggregator/Table/ITableWrapper.cs +++ b/src/StatusAggregator/Table/ITableWrapper.cs @@ -11,7 +11,7 @@ public interface ITableWrapper { Task CreateIfNotExistsAsync(); - Task RetrieveAsync(string partitionKey, string rowKey) + Task RetrieveAsync(string rowKey) where T : class, ITableEntity; Task InsertAsync(ITableEntity tableEntity); diff --git a/src/StatusAggregator/Table/TablePartitionKeys.cs b/src/StatusAggregator/Table/TablePartitionKeys.cs new file mode 100644 index 000000000..5c1522909 --- /dev/null +++ b/src/StatusAggregator/Table/TablePartitionKeys.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using NuGet.Services.Status.Table; +using NuGet.Services.Status.Table.Manual; + +namespace StatusAggregator.Table +{ + public static class TablePartitionKeys + { + /// + /// Gets the partition key associated with . + /// Each is mapped to a single key. + /// + public static string Get() + { + var match = PartitionKeyMap.FirstOrDefault(m => m.Matches(typeof(T))); + if (match != null) + { + return match.PartitionKey; + } + + throw new ArgumentException("There is no mapping of the specified type to a partition key!", nameof(T)); + } + + /// + /// This was not implemented as a dictionary because it is not possible to construct a that works with type inheritance. + /// + /// Proof: + /// B and C are subclasses of A, so B and C must have the same hashcode as A. + /// However, B must not have the same hashcode as C because B is not C and C is not B. + /// Therefore, B and C must have a hashcode that is both identical AND different. + /// This is not possible. + /// + private static readonly IEnumerable PartitionKeyMap = new[] + { + new PartitionKeyMapping(typeof(CursorEntity), CursorEntity.DefaultPartitionKey), + new PartitionKeyMapping(typeof(IncidentEntity), IncidentEntity.DefaultPartitionKey), + new PartitionKeyMapping(typeof(IncidentGroupEntity), IncidentGroupEntity.DefaultPartitionKey), + new PartitionKeyMapping(typeof(EventEntity), EventEntity.DefaultPartitionKey), + new PartitionKeyMapping(typeof(MessageEntity), MessageEntity.DefaultPartitionKey), + new PartitionKeyMapping(typeof(ManualStatusChangeEntity), ManualStatusChangeEntity.DefaultPartitionKey), + }; + + private class PartitionKeyMapping + { + public Type Type { get; } + public string PartitionKey { get; } + + public PartitionKeyMapping(Type type, string partitionKey) + { + Type = type; + PartitionKey = partitionKey; + } + + public bool Matches(Type type) + { + return Type.IsAssignableFrom(type); + } + } + } +} diff --git a/src/StatusAggregator/Table/TableUtility.cs b/src/StatusAggregator/Table/TableUtility.cs deleted file mode 100644 index 8c6040e6f..000000000 --- a/src/StatusAggregator/Table/TableUtility.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using Microsoft.WindowsAzure.Storage.Table; - -namespace StatusAggregator.Table -{ - public static class TableUtility - { - /// - /// The to provide when the existing content in the table is unimportant. - /// E.g. "if match any". - /// - public const string ETagWildcard = "*"; - } -} diff --git a/src/StatusAggregator/Table/TableWrapper.cs b/src/StatusAggregator/Table/TableWrapper.cs index 7d5ee8dda..253b04a5c 100644 --- a/src/StatusAggregator/Table/TableWrapper.cs +++ b/src/StatusAggregator/Table/TableWrapper.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Linq; using System.Threading.Tasks; using Microsoft.WindowsAzure.Storage; @@ -10,11 +11,19 @@ namespace StatusAggregator.Table { public class TableWrapper : ITableWrapper { + /// + /// The to provide when the existing content in the table is unimportant. + /// E.g. "if match any". + /// + public const string ETagWildcard = "*"; + public TableWrapper( CloudStorageAccount storageAccount, string tableName) { - var tableClient = storageAccount.CreateCloudTableClient(); + + var tableClient = storageAccount?.CreateCloudTableClient() + ?? throw new ArgumentNullException(nameof(storageAccount)); _table = tableClient.GetTableReference(tableName); } @@ -25,10 +34,10 @@ public Task CreateIfNotExistsAsync() return _table.CreateIfNotExistsAsync(); } - public async Task RetrieveAsync(string partitionKey, string rowKey) + public async Task RetrieveAsync(string rowKey) where T : class, ITableEntity { - var operation = TableOperation.Retrieve(partitionKey, rowKey); + var operation = TableOperation.Retrieve(TablePartitionKeys.Get(), rowKey); return (await _table.ExecuteAsync(operation)).Result as T; } @@ -49,7 +58,7 @@ public Task ReplaceAsync(ITableEntity tableEntity) public Task DeleteAsync(string partitionKey, string rowKey) { - return DeleteAsync(partitionKey, rowKey, TableUtility.ETagWildcard); + return DeleteAsync(partitionKey, rowKey, ETagWildcard); } public Task DeleteAsync(string partitionKey, string rowKey, string eTag) @@ -71,7 +80,8 @@ private Task ExecuteOperationAsync(TableOperation operation) { return _table .CreateQuery() - .AsQueryable(); + .AsQueryable() + .Where(e => e.PartitionKey == TablePartitionKeys.Get()); } } } diff --git a/src/StatusAggregator/Table/TableWrapperExtensions.cs b/src/StatusAggregator/Table/TableWrapperExtensions.cs index 3f8d0087e..9bcf3e351 100644 --- a/src/StatusAggregator/Table/TableWrapperExtensions.cs +++ b/src/StatusAggregator/Table/TableWrapperExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using Microsoft.WindowsAzure.Storage.Table; using NuGet.Services.Status.Table; using System.Linq; @@ -8,30 +9,21 @@ namespace StatusAggregator.Table { public static class TableWrapperExtensions { - public static IQueryable GetActiveEvents(this ITableWrapper table) + public static IQueryable GetActiveEntities(this ITableWrapper table) + where TEntity : ComponentAffectingEntity, new() { return table - .CreateQuery() - .Where(e => e.PartitionKey == EventEntity.DefaultPartitionKey && e.IsActive); + .CreateQuery() + .Where(e => e.IsActive); } - public static IQueryable GetIncidentsLinkedToEvent(this ITableWrapper table, EventEntity eventEntity) + public static IQueryable GetChildEntities(this ITableWrapper table, TParent entity) + where TChild : ITableEntity, IChildEntity, new() + where TParent : ITableEntity { return table - .CreateQuery() - .Where(i => - i.PartitionKey == IncidentEntity.DefaultPartitionKey && - i.IsLinkedToEvent && - i.EventRowKey == eventEntity.RowKey); - } - - public static IQueryable GetMessagesLinkedToEvent(this ITableWrapper table, EventEntity eventEntity) - { - return table - .CreateQuery() - .Where(m => - m.PartitionKey == MessageEntity.DefaultPartitionKey && - m.EventRowKey == eventEntity.RowKey); + .CreateQuery() + .Where(e => e.ParentRowKey == entity.RowKey); } } } diff --git a/src/StatusAggregator/Update/ActiveEventEntityUpdater.cs b/src/StatusAggregator/Update/ActiveEventEntityUpdater.cs new file mode 100644 index 000000000..7bc840c60 --- /dev/null +++ b/src/StatusAggregator/Update/ActiveEventEntityUpdater.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status.Table; +using StatusAggregator.Table; + +namespace StatusAggregator.Update +{ + public class ActiveEventEntityUpdater : IActiveEventEntityUpdater + { + private readonly ITableWrapper _table; + private readonly IComponentAffectingEntityUpdater _updater; + + private readonly ILogger _logger; + + public ActiveEventEntityUpdater( + ITableWrapper table, + IComponentAffectingEntityUpdater updater, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _updater = updater ?? throw new ArgumentNullException(nameof(updater)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task UpdateAllAsync(DateTime cursor) + { + using (_logger.Scope("Updating active events.")) + { + var activeEvents = _table.GetActiveEntities().ToList(); + _logger.LogInformation("Updating {ActiveEventsCount} active events.", activeEvents.Count()); + foreach (var activeEvent in activeEvents) + { + await _updater.UpdateAsync(activeEvent, cursor); + } + } + } + } +} diff --git a/src/StatusAggregator/Update/AggregationEntityUpdater.cs b/src/StatusAggregator/Update/AggregationEntityUpdater.cs new file mode 100644 index 000000000..15fdaab56 --- /dev/null +++ b/src/StatusAggregator/Update/AggregationEntityUpdater.cs @@ -0,0 +1,94 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status.Table; +using StatusAggregator.Table; + +namespace StatusAggregator.Update +{ + /// + /// Updates a and its s. + /// + public class AggregationEntityUpdater + : IComponentAffectingEntityUpdater + where TChildEntity : AggregatedComponentAffectingEntity, new() + where TAggregationEntity : ComponentAffectingEntity + { + public readonly TimeSpan _groupEndDelay; + + private readonly ITableWrapper _table; + private readonly IComponentAffectingEntityUpdater _aggregatedEntityUpdater; + + private readonly ILogger> _logger; + + public AggregationEntityUpdater( + ITableWrapper table, + IComponentAffectingEntityUpdater aggregatedEntityUpdater, + StatusAggregatorConfiguration configuration, + ILogger> logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _aggregatedEntityUpdater = aggregatedEntityUpdater + ?? throw new ArgumentNullException(nameof(aggregatedEntityUpdater)); + _groupEndDelay = TimeSpan.FromMinutes(configuration?.EventEndDelayMinutes + ?? throw new ArgumentNullException(nameof(configuration))); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task UpdateAsync(TAggregationEntity aggregationEntity, DateTime cursor) + { + aggregationEntity = aggregationEntity ?? throw new ArgumentNullException(nameof(aggregationEntity)); + + using (_logger.Scope("Updating aggregation {AggregationRowKey} given cursor {Cursor}.", aggregationEntity.RowKey, cursor)) + { + if (!aggregationEntity.IsActive) + { + _logger.LogInformation("Aggregation is inactive, cannot update."); + return; + } + + var hasActiveOrRecentChildren = false; + var children = _table + .GetChildEntities(aggregationEntity) + .ToList(); + + if (children.Any()) + { + _logger.LogInformation("Aggregation has {ChildrenCount} children. Updating each child.", children.Count); + foreach (var child in children) + { + await _aggregatedEntityUpdater.UpdateAsync(child, cursor); + + hasActiveOrRecentChildren = + hasActiveOrRecentChildren || + child.IsActive || + child.EndTime > cursor - _groupEndDelay; + } + } + else + { + _logger.LogInformation("Aggregation has no children and must have been created manually, cannot update."); + return; + } + + if (!hasActiveOrRecentChildren) + { + _logger.LogInformation("Deactivating aggregation because its children are inactive and too old."); + var lastEndTime = children.Max(i => i.EndTime.Value); + aggregationEntity.EndTime = lastEndTime; + + await _table.ReplaceAsync(aggregationEntity); + } + else + { + _logger.LogInformation("Aggregation has active or recent children so it will not be deactivated."); + } + } + } + } +} diff --git a/src/StatusAggregator/Update/EventMessagingUpdater.cs b/src/StatusAggregator/Update/EventMessagingUpdater.cs new file mode 100644 index 000000000..db115d947 --- /dev/null +++ b/src/StatusAggregator/Update/EventMessagingUpdater.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Status.Table; +using StatusAggregator.Messages; + +namespace StatusAggregator.Update +{ + public class EventMessagingUpdater : IComponentAffectingEntityUpdater + { + private readonly IMessageChangeEventProvider _provider; + private readonly IMessageChangeEventIterator _iterator; + + private readonly ILogger _logger; + + public EventMessagingUpdater( + IMessageChangeEventProvider provider, + IMessageChangeEventIterator iterator, + ILogger logger) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _iterator = iterator ?? throw new ArgumentNullException(nameof(iterator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task UpdateAsync(EventEntity eventEntity, DateTime cursor) + { + using (_logger.Scope("Updating messages for event {EventRowKey} at {Cursor}.", eventEntity.RowKey, cursor)) + { + var changes = _provider.Get(eventEntity, cursor); + return _iterator.IterateAsync(changes, eventEntity); + } + } + } +} diff --git a/src/StatusAggregator/Update/EventUpdater.cs b/src/StatusAggregator/Update/EventUpdater.cs new file mode 100644 index 000000000..a86b8a17e --- /dev/null +++ b/src/StatusAggregator/Update/EventUpdater.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Update +{ + public class EventUpdater : IComponentAffectingEntityUpdater + { + private readonly AggregationEntityUpdater _aggregationUpdater; + private readonly EventMessagingUpdater _messagingUpdater; + + public EventUpdater( + AggregationEntityUpdater aggregationUpdater, + EventMessagingUpdater messagingUpdater) + { + _aggregationUpdater = aggregationUpdater ?? throw new ArgumentNullException(nameof(aggregationUpdater)); + _messagingUpdater = messagingUpdater ?? throw new ArgumentNullException(nameof(messagingUpdater)); + } + + public async Task UpdateAsync(EventEntity entity, DateTime cursor) + { + await _aggregationUpdater.UpdateAsync(entity, cursor); + await _messagingUpdater.UpdateAsync(entity, cursor); + } + } +} diff --git a/src/StatusAggregator/Update/IActiveEventEntityUpdater.cs b/src/StatusAggregator/Update/IActiveEventEntityUpdater.cs new file mode 100644 index 000000000..ca79e4334 --- /dev/null +++ b/src/StatusAggregator/Update/IActiveEventEntityUpdater.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Update +{ + public interface IActiveEventEntityUpdater + { + /// + /// Updates all active s. + /// + /// The current time. + Task UpdateAllAsync(DateTime cursor); + } +} diff --git a/src/StatusAggregator/Update/IComponentAffectingEntityUpdater.cs b/src/StatusAggregator/Update/IComponentAffectingEntityUpdater.cs new file mode 100644 index 000000000..4bbe1ad8d --- /dev/null +++ b/src/StatusAggregator/Update/IComponentAffectingEntityUpdater.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using NuGet.Services.Status.Table; + +namespace StatusAggregator.Update +{ + public interface IComponentAffectingEntityUpdater + where T : ComponentAffectingEntity + { + /// + /// Updates given that the current time is now . + /// Returns whether is inactive. + /// + Task UpdateAsync(T entity, DateTime cursor); + } +} diff --git a/src/StatusAggregator/IStatusUpdater.cs b/src/StatusAggregator/Update/IStatusUpdater.cs similarity index 71% rename from src/StatusAggregator/IStatusUpdater.cs rename to src/StatusAggregator/Update/IStatusUpdater.cs index b2565427e..6a70eb260 100644 --- a/src/StatusAggregator/IStatusUpdater.cs +++ b/src/StatusAggregator/Update/IStatusUpdater.cs @@ -1,16 +1,16 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using NuGet.Services.Status; +using System; using System.Threading.Tasks; -namespace StatusAggregator +namespace StatusAggregator.Update { public interface IStatusUpdater { /// /// Aggregates the information necessary to build a that describes the NuGet service. /// - Task Update(); + Task Update(DateTime cursor); } -} +} \ No newline at end of file diff --git a/src/StatusAggregator/Update/IncidentUpdater.cs b/src/StatusAggregator/Update/IncidentUpdater.cs new file mode 100644 index 000000000..6cc972be7 --- /dev/null +++ b/src/StatusAggregator/Update/IncidentUpdater.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Incidents; +using NuGet.Services.Status.Table; +using StatusAggregator.Table; + +namespace StatusAggregator.Update +{ + public class IncidentUpdater : IComponentAffectingEntityUpdater + { + private readonly ITableWrapper _table; + private readonly IIncidentApiClient _incidentApiClient; + private readonly ILogger _logger; + + public IncidentUpdater( + ITableWrapper table, + IIncidentApiClient incidentApiClient, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _incidentApiClient = incidentApiClient ?? throw new ArgumentNullException(nameof(incidentApiClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task UpdateAsync(IncidentEntity entity, DateTime cursor) + { + using (_logger.Scope("Updating incident with ID {IncidentApiId}.", entity.IncidentApiId)) + { + if (!entity.IsActive) + { + return; + } + + var activeIncident = await _incidentApiClient.GetIncident(entity.IncidentApiId); + var endTime = activeIncident.MitigationData?.Date; + + if (endTime != null) + { + _logger.LogInformation("Updated mitigation time of active incident to {MitigationTime}.", entity.EndTime); + entity.EndTime = endTime; + await _table.ReplaceAsync(entity); + } + } + } + } +} diff --git a/src/StatusAggregator/Update/StatusUpdater.cs b/src/StatusAggregator/Update/StatusUpdater.cs new file mode 100644 index 000000000..affa3389d --- /dev/null +++ b/src/StatusAggregator/Update/StatusUpdater.cs @@ -0,0 +1,63 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using StatusAggregator.Collector; + +namespace StatusAggregator.Update +{ + public class StatusUpdater : IStatusUpdater + { + private const string ManualCursorBaseName = "manual"; + private const string IncidentCursorName = "incident"; + + private readonly ICursor _cursor; + private readonly IEntityCollector _incidentCollector; + private readonly IEnumerable _manualStatusChangeCollectors; + private readonly IActiveEventEntityUpdater _activeEventUpdater; + + private readonly ILogger _logger; + + public StatusUpdater( + ICursor cursor, + IEnumerable collectors, + IActiveEventEntityUpdater activeEventUpdater, + ILogger logger) + { + _cursor = cursor ?? throw new ArgumentNullException(nameof(cursor)); + collectors = collectors?.Where(c => c != null) + ?? throw new ArgumentNullException(nameof(collectors)); + _incidentCollector = collectors.SingleOrDefault(IsIncidentCollector) + ?? throw new ArgumentException(nameof(collectors), $"Must provide a collector with name {IncidentEntityCollectorProcessor.IncidentsCollectorName}!"); + _manualStatusChangeCollectors = collectors + .Where(c => !IsIncidentCollector(c)) + .ToList(); + _activeEventUpdater = activeEventUpdater ?? throw new ArgumentNullException(nameof(activeEventUpdater)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Update(DateTime cursor) + { + using (_logger.Scope("Updating service status.")) + { + foreach (var manualStatusChangeCollector in _manualStatusChangeCollectors) + { + await manualStatusChangeCollector.FetchLatest(); + } + + await _incidentCollector.FetchLatest(); + await _activeEventUpdater.UpdateAllAsync(cursor); + } + } + + private static bool IsIncidentCollector(IEntityCollector collector) + { + return collector.Name == IncidentEntityCollectorProcessor.IncidentsCollectorName; + } + } +} \ No newline at end of file diff --git a/src/Validation.Callback.Vcs/Web.config b/src/Validation.Callback.Vcs/Web.config index c8bd711fb..2a126ca52 100644 --- a/src/Validation.Callback.Vcs/Web.config +++ b/src/Validation.Callback.Vcs/Web.config @@ -36,6 +36,10 @@ + + + + @@ -78,7 +82,7 @@ - + diff --git a/src/Validation.Common.Job/Validation.Common.Job.csproj b/src/Validation.Common.Job/Validation.Common.Job.csproj index 6b12b7c10..49175a8b1 100644 --- a/src/Validation.Common.Job/Validation.Common.Job.csproj +++ b/src/Validation.Common.Job/Validation.Common.Job.csproj @@ -94,22 +94,22 @@ 4.8.0-preview4.5289 - 2.28.0 + 2.29.0 - 2.28.0 + 2.29.0 - 2.28.0 + 2.29.0 - 2.28.0 + 2.29.0 - 2.28.0 + 2.29.0 - 2.28.0 + 2.29.0 4.4.4-master-41290 diff --git a/src/Validation.Common.Job/Validation.Common.Job.nuspec b/src/Validation.Common.Job/Validation.Common.Job.nuspec index 2ee92ceca..cd9c45ab8 100644 --- a/src/Validation.Common.Job/Validation.Common.Job.nuspec +++ b/src/Validation.Common.Job/Validation.Common.Job.nuspec @@ -16,13 +16,13 @@ - - - - - - - + + + + + + + diff --git a/src/Validation.Symbols.Core/EntityServices/SymbolsValidationEntitiesService.cs b/src/Validation.Symbols.Core/EntityServices/SymbolsValidationEntitiesService.cs index 44c168fcd..317d15ea5 100644 --- a/src/Validation.Symbols.Core/EntityServices/SymbolsValidationEntitiesService.cs +++ b/src/Validation.Symbols.Core/EntityServices/SymbolsValidationEntitiesService.cs @@ -71,8 +71,18 @@ public Task GetSymbolsServerRequestAsync(string requestNam public async Task GetSymbolsServerRequestAsync(IValidationRequest validationRequest) { - string requestName = validationRequest.PackageKey.ToString(); - return await GetSymbolsServerRequestAsync(requestName, validationRequest.PackageKey); + string requestName = CreateSymbolServerRequestNameFromValidationRequest(validationRequest); + return await GetSymbolsServerRequestAsync(requestName, validationRequest.PackageKey); + } + + /// + /// From a creates a symbol server request name. + /// + /// + /// + public static string CreateSymbolServerRequestNameFromValidationRequest(IValidationRequest validationRequest) + { + return $"{validationRequest.PackageKey}_{validationRequest.ValidationId}"; } /// @@ -81,13 +91,13 @@ public async Task GetSymbolsServerRequestAsync(IValidation /// The . /// The . /// - public static SymbolsServerRequest CreateFromValidationRequest(IValidationRequest validationRequest, SymbolsPackageIngestRequestStatus status) + public static SymbolsServerRequest CreateFromValidationRequest(IValidationRequest validationRequest, SymbolsPackageIngestRequestStatus status, string requestName) { return new SymbolsServerRequest() { Created = DateTime.UtcNow, LastUpdated = DateTime.UtcNow, - RequestName = validationRequest.PackageKey.ToString(), + RequestName = requestName, RequestStatusKey = status, SymbolsKey = validationRequest.PackageKey }; diff --git a/src/Validation.Symbols.Core/SymbolsIngesterMessage.cs b/src/Validation.Symbols.Core/SymbolsIngesterMessage.cs new file mode 100644 index 000000000..d4af44d8e --- /dev/null +++ b/src/Validation.Symbols.Core/SymbolsIngesterMessage.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace NuGet.Jobs.Validation.Symbols.Core +{ + public class SymbolsIngesterMessage : ISymbolsValidatorMessage + { + public SymbolsIngesterMessage(Guid validationId, + int symbolPackageKey, + string packageId, + string packageNormalizedVersion, + string snupkgUrl, + string requestName) + { + ValidationId = validationId; + SymbolsPackageKey = symbolPackageKey; + PackageId = packageId; + PackageNormalizedVersion = packageNormalizedVersion; + SnupkgUrl = snupkgUrl; + RequestName = requestName; + } + + public Guid ValidationId { get; } + + public int SymbolsPackageKey { get; } + + public string PackageId { get; } + + public string PackageNormalizedVersion { get; } + + public string SnupkgUrl { get; } + + /// + /// This is the request name to be used when ingesting a symbols package to VSTS. + /// + public string RequestName { get; } + } +} diff --git a/src/Validation.Symbols.Core/SymbolsIngesterMessageSerializer.cs b/src/Validation.Symbols.Core/SymbolsIngesterMessageSerializer.cs new file mode 100644 index 000000000..c6487f2d7 --- /dev/null +++ b/src/Validation.Symbols.Core/SymbolsIngesterMessageSerializer.cs @@ -0,0 +1,56 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using NuGet.Services.ServiceBus; + +namespace NuGet.Jobs.Validation.Symbols.Core +{ + public class SymbolsIngesterMessageSerializer : IBrokeredMessageSerializer + { + private const string SchemaName = "SymbolIngesterMessageData"; + + private IBrokeredMessageSerializer _serializer = + new BrokeredMessageSerializer(); + + public SymbolsIngesterMessage Deserialize(IBrokeredMessage message) + { + var deserializedMessage = _serializer.Deserialize(message); + + return new SymbolsIngesterMessage( + deserializedMessage.ValidationId, + deserializedMessage.SymbolsPackageKey, + deserializedMessage.PackageId, + deserializedMessage.PackageNormalizedVersion, + deserializedMessage.SnupkgUrl, + deserializedMessage.RequestName); + } + + public IBrokeredMessage Serialize(SymbolsIngesterMessage message) + => _serializer.Serialize(new SymbolsIngesterMessageDataV1 + { + ValidationId = message.ValidationId, + SymbolsPackageKey = message.SymbolsPackageKey, + PackageId = message.PackageId, + PackageNormalizedVersion = message.PackageNormalizedVersion, + SnupkgUrl = message.SnupkgUrl, + RequestName = message.RequestName + }); + + [Schema(Name = SchemaName, Version = 1)] + private class SymbolsIngesterMessageDataV1 + { + public Guid ValidationId { get; set; } + + public int SymbolsPackageKey { get; set; } + + public string PackageId { get; set; } + + public string PackageNormalizedVersion { get; set; } + + public string SnupkgUrl { get; set; } + + public string RequestName { get; set; } + } + } +} diff --git a/src/Validation.Symbols.Core/SymbolsValidatorMessageSerializer.cs b/src/Validation.Symbols.Core/SymbolsValidatorMessageSerializer.cs index 68dc6332b..d416895f7 100644 --- a/src/Validation.Symbols.Core/SymbolsValidatorMessageSerializer.cs +++ b/src/Validation.Symbols.Core/SymbolsValidatorMessageSerializer.cs @@ -8,7 +8,7 @@ namespace NuGet.Jobs.Validation.Symbols.Core { public class SymbolsValidatorMessageSerializer : IBrokeredMessageSerializer { - private const string SchemaName = "SignatureValidationMessageData"; + private const string SchemaName = "SymbolsValidatorMessageData"; private IBrokeredMessageSerializer _serializer = new BrokeredMessageSerializer(); diff --git a/src/Validation.Symbols.Core/Validation.Symbols.Core.csproj b/src/Validation.Symbols.Core/Validation.Symbols.Core.csproj index 4e2a23691..458185a9a 100644 --- a/src/Validation.Symbols.Core/Validation.Symbols.Core.csproj +++ b/src/Validation.Symbols.Core/Validation.Symbols.Core.csproj @@ -51,16 +51,18 @@ + + - 2.28.0-master-36259 + 2.29.0 - 2.28.0-master-36259 + 2.29.0 diff --git a/src/Validation.Symbols/ITelemetryService.cs b/src/Validation.Symbols/ITelemetryService.cs index 0dd380690..cc11e3194 100644 --- a/src/Validation.Symbols/ITelemetryService.cs +++ b/src/Validation.Symbols/ITelemetryService.cs @@ -3,6 +3,7 @@ using System; using NuGet.Services.ServiceBus; +using NuGet.Services.Validation; namespace Validation.Symbols { @@ -29,5 +30,14 @@ public interface ITelemetryService : ISubscriptionProcessorTelemetryService /// The package normalized version. /// The count of symbols validated. IDisposable TrackSymbolValidationDurationEvent(string packageId, string packageNormalizedVersion, int symbolCount); + + /// + /// Tracks the status of the validation. + /// + /// The pacakge id. + /// The package normalized version. + /// The validation result. + /// Information about the issue id failed or empty if passed.. + void TrackSymbolsValidationResultEvent(string packageId, string packageNormalizedVersion, ValidationStatus validationStatus, string issue); } } diff --git a/src/Validation.Symbols/SymbolsValidatorService.cs b/src/Validation.Symbols/SymbolsValidatorService.cs index 14a6b6eb4..b7d2c5043 100644 --- a/src/Validation.Symbols/SymbolsValidatorService.cs +++ b/src/Validation.Symbols/SymbolsValidatorService.cs @@ -76,6 +76,7 @@ public async Task ValidateSymbolsAsync(string packageId, stri { if (!SymbolsHaveMatchingPEFiles(pdbs, pes)) { + _telemetryService.TrackSymbolsValidationResultEvent(packageId, packageNormalizedVersion, ValidationStatus.Failed, nameof(ValidationIssue.SymbolErrorCode_MatchingPortablePDBNotFound)); return ValidationResult.FailedWithIssues(ValidationIssue.SymbolErrorCode_MatchingPortablePDBNotFound); } var targetDirectory = Settings.GetWorkingDirectory(); @@ -165,6 +166,7 @@ public virtual IValidationResult ValidateSymbolMatching(string targetDirectory, if (checksumRecords.Length == 0) { + _telemetryService.TrackSymbolsValidationResultEvent(packageId, packageNormalizedVersion, ValidationStatus.Failed, nameof(ValidationIssue.SymbolErrorCode_ChecksumDoesNotMatch)); return ValidationResult.FailedWithIssues(ValidationIssue.SymbolErrorCode_ChecksumDoesNotMatch); } @@ -195,15 +197,18 @@ public virtual IValidationResult ValidateSymbolMatching(string targetDirectory, if (checksumRecord.Checksum.ToArray().SequenceEqual(hash)) { // found the right checksum + _telemetryService.TrackSymbolsValidationResultEvent(packageId, packageNormalizedVersion, ValidationStatus.Succeeded, ""); return ValidationResult.Succeeded; } } // Not found any checksum record that matches the PDB. + _telemetryService.TrackSymbolsValidationResultEvent(packageId, packageNormalizedVersion, ValidationStatus.Failed, nameof(ValidationIssue.SymbolErrorCode_ChecksumDoesNotMatch)); return ValidationResult.FailedWithIssues(ValidationIssue.SymbolErrorCode_ChecksumDoesNotMatch); } } } + _telemetryService.TrackSymbolsValidationResultEvent(packageId, packageNormalizedVersion, ValidationStatus.Failed, nameof(ValidationIssue.SymbolErrorCode_MatchingPortablePDBNotFound)); return ValidationResult.FailedWithIssues(ValidationIssue.SymbolErrorCode_MatchingPortablePDBNotFound); } } @@ -215,6 +220,7 @@ public virtual IValidationResult ValidateSymbolMatching(string targetDirectory, packageId, packageNormalizedVersion, Directory.GetFiles(targetDirectory, SymbolExtensionPattern, SearchOption.AllDirectories)); + _telemetryService.TrackSymbolsValidationResultEvent(packageId, packageNormalizedVersion, ValidationStatus.Failed, nameof(ValidationIssue.SymbolErrorCode_MatchingPortablePDBNotFound)); return ValidationResult.FailedWithIssues(ValidationIssue.SymbolErrorCode_MatchingPortablePDBNotFound); } diff --git a/src/Validation.Symbols/TelemetryService.cs b/src/Validation.Symbols/TelemetryService.cs index cd3143957..93473d461 100644 --- a/src/Validation.Symbols/TelemetryService.cs +++ b/src/Validation.Symbols/TelemetryService.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using NuGet.Services.Logging; +using NuGet.Services.Validation; namespace Validation.Symbols { @@ -15,11 +16,14 @@ public class TelemetryService : ITelemetryService private const string SymbolValidationDuration = Prefix + "SymbolValidationDurationInSeconds"; private const string MessageDeliveryLag = Prefix + "MessageDeliveryLag"; private const string MessageEnqueueLag = Prefix + "MessageEnqueueLag"; + private const string SymbolValidationResult = Prefix + "SymbolValidationResult"; private const string PackageId = "PackageId"; private const string PackageNormalizedVersion = "PackageNormalizedVersion"; private const string MessageType = "MessageType"; private const string SymbolCount = "SymbolCount"; + private const string ValidationResult = "ValidationResult"; + private const string Issue = "Issue"; private readonly ITelemetryClient _telemetryClient; @@ -64,6 +68,20 @@ public IDisposable TrackSymbolValidationDurationEvent(string packageId, string p }); } + public void TrackSymbolsValidationResultEvent(string packageId, string packageNormalizedVersion, ValidationStatus validationStatus, string issue) + { + _telemetryClient.TrackMetric( + SymbolValidationResult, + 1, + new Dictionary + { + { ValidationResult, validationStatus.ToString() }, + { Issue, issue }, + { PackageId, packageId }, + { PackageNormalizedVersion, packageNormalizedVersion } + }); + } + public void TrackMessageDeliveryLag(TimeSpan deliveryLag) => _telemetryClient.TrackMetric( MessageDeliveryLag, diff --git a/tests/NuGet.Services.Validation.Orchestrator.Tests/NuGet.Services.Validation.Orchestrator.Tests.csproj b/tests/NuGet.Services.Validation.Orchestrator.Tests/NuGet.Services.Validation.Orchestrator.Tests.csproj index 5b5221bd8..590e3022f 100644 --- a/tests/NuGet.Services.Validation.Orchestrator.Tests/NuGet.Services.Validation.Orchestrator.Tests.csproj +++ b/tests/NuGet.Services.Validation.Orchestrator.Tests/NuGet.Services.Validation.Orchestrator.Tests.csproj @@ -56,6 +56,7 @@ + diff --git a/tests/NuGet.Services.Validation.Orchestrator.Tests/Symbol/SymbolIngesterMessageEnqueuerFacts.cs b/tests/NuGet.Services.Validation.Orchestrator.Tests/Symbol/SymbolIngesterMessageEnqueuerFacts.cs new file mode 100644 index 000000000..f3507d08b --- /dev/null +++ b/tests/NuGet.Services.Validation.Orchestrator.Tests/Symbol/SymbolIngesterMessageEnqueuerFacts.cs @@ -0,0 +1,77 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using NuGet.Jobs.Validation.Symbols.Core; +using NuGet.Services.ServiceBus; +using Moq; +using Xunit; + + +namespace NuGet.Services.Validation.Symbols +{ + public class SymbolIngesterMessageEnqueuerFacts + { + [Fact] + public async Task SendsSerializeMessage() + { + // Arrange + SymbolsIngesterMessage message = null; + _serializer + .Setup(x => x.Serialize(It.IsAny())) + .Returns(() => _brokeredMessage.Object) + .Callback(x => message = x); + + // Act + await _target.EnqueueSymbolsIngestionMessageAsync(_validationRequest.Object); + + // Assert + Assert.Equal(_validationRequest.Object.ValidationId, message.ValidationId); + Assert.Equal(_validationRequest.Object.PackageId, message.PackageId); + Assert.Equal(_validationRequest.Object.PackageVersion, message.PackageNormalizedVersion); + Assert.Equal($"{_validationRequest.Object.PackageKey}_{_validationRequest.Object.ValidationId}", message.RequestName); + + Assert.Equal(_validationRequest.Object.NupkgUrl, message.SnupkgUrl); + _serializer.Verify( + x => x.Serialize(It.IsAny()), + Times.Once); + _topicClient.Verify(x => x.SendAsync(_brokeredMessage.Object), Times.Once); + _topicClient.Verify(x => x.SendAsync(It.IsAny()), Times.Once); + } + + private readonly Mock _topicClient; + private readonly Mock> _serializer; + private readonly SymbolsValidationConfiguration _configuration; + private readonly Mock _brokeredMessage; + private readonly Mock _validationRequest; + private readonly SymbolsIngesterMessageEnqueuer _target; + + public SymbolIngesterMessageEnqueuerFacts() + { + _configuration = new SymbolsValidationConfiguration(); + _brokeredMessage = new Mock(); + _validationRequest = new Mock(); + + _validationRequest.Setup(x => x.ValidationId).Returns(new Guid("ab2629ce-2d67-403a-9a42-49748772ae90")); + _validationRequest.Setup(x => x.PackageId).Returns("NuGet.Versioning"); + _validationRequest.Setup(x => x.PackageKey).Returns(123); + _validationRequest.Setup(x => x.PackageVersion).Returns("4.6.0"); + _validationRequest.Setup(x => x.NupkgUrl).Returns("http://example/nuget.versioning.4.6.0.nupkg?my-sas"); + _brokeredMessage.SetupProperty(x => x.ScheduledEnqueueTimeUtc); + + _topicClient = new Mock(); + + _serializer = new Mock>(); + _serializer + .Setup(x => x.Serialize(It.IsAny())) + .Returns(() => _brokeredMessage.Object); + + _target = new SymbolsIngesterMessageEnqueuer( + _topicClient.Object, + _serializer.Object, + TimeSpan.FromSeconds(1)); + } + } +} diff --git a/tests/NuGet.Services.Validation.Orchestrator.Tests/Symbol/SymbolsIngesterFacts.cs b/tests/NuGet.Services.Validation.Orchestrator.Tests/Symbol/SymbolsIngesterFacts.cs index 7d087dff1..d79a12ff4 100644 --- a/tests/NuGet.Services.Validation.Orchestrator.Tests/Symbol/SymbolsIngesterFacts.cs +++ b/tests/NuGet.Services.Validation.Orchestrator.Tests/Symbol/SymbolsIngesterFacts.cs @@ -25,6 +25,7 @@ public class SymbolsIngesterFacts private const string PackageVersion = "1.2.3"; private static readonly Guid ValidationId = new Guid("12345678-1234-1234-1234-123456789012"); private const string NupkgUrl = "https://example/nuget.versioning/1.2.3/package.nupkg"; + private const string SnupkgUrl = "https://example/nuget.versioning/1.2.3/package.snupkg"; public class TheGetStatusMethod : FactsBase { @@ -97,7 +98,7 @@ public async Task ReturnsPersistedStatusesIfValidationAlreadyStarted(ValidationS await _target.StartAsync(_validationRequest.Object); _symbolMessageEnqueuer - .Verify(x => x.EnqueueSymbolsValidationMessageAsync(It.IsAny()), Times.Never); + .Verify(x => x.EnqueueSymbolsIngestionMessageAsync(It.IsAny()), Times.Never); _symbolsValidationEntitiesService .Verify(x => x.AddSymbolsServerRequestAsync(It.IsAny()), Times.Never); @@ -122,18 +123,19 @@ public async Task StartsValidationIfNotStarted() RequestStatusKey = SymbolsPackageIngestRequestStatus.Ingesting, SymbolsKey = PackageKey }; + var symbolsIngesterMessage = new SymbolsIngesterMessage(ValidationId, PackageKey, PackageId, PackageVersion, SnupkgUrl, "DummyRequestName"); _symbolsValidationEntitiesService .Setup(x => x.GetSymbolsServerRequestAsync(It.IsAny())) .ReturnsAsync((SymbolsServerRequest)null); _symbolMessageEnqueuer - .Setup(x => x.EnqueueSymbolsValidationMessageAsync(It.IsAny())) + .Setup(x => x.EnqueueSymbolsIngestionMessageAsync(It.IsAny())) .Callback(() => { verificationQueuedBeforeStatePersisted = !statePersisted; }) - .Returns(Task.FromResult(0)); + .Returns(Task.FromResult(symbolsIngesterMessage)); _symbolsValidationEntitiesService .Setup(x => x.AddSymbolsServerRequestAsync(It.IsAny())) @@ -148,7 +150,7 @@ public async Task StartsValidationIfNotStarted() // Assert _symbolMessageEnqueuer - .Verify(x => x.EnqueueSymbolsValidationMessageAsync(It.IsAny()), Times.Once); + .Verify(x => x.EnqueueSymbolsIngestionMessageAsync(It.IsAny()), Times.Once); _symbolsValidationEntitiesService .Verify( @@ -168,7 +170,7 @@ public async Task StartsValidationIfNotStarted() public abstract class FactsBase { protected readonly Mock _symbolsValidationEntitiesService; - protected readonly Mock _symbolMessageEnqueuer; + protected readonly Mock _symbolMessageEnqueuer; protected readonly Mock _telemetryService; protected readonly ILogger _logger; protected readonly Mock _validationRequest; @@ -177,7 +179,7 @@ public abstract class FactsBase public FactsBase(ITestOutputHelper output) { _symbolsValidationEntitiesService = new Mock(); - _symbolMessageEnqueuer = new Mock(); + _symbolMessageEnqueuer = new Mock(); _telemetryService = new Mock(); var loggerFactory = new LoggerFactory().AddXunit(output); _logger = loggerFactory.CreateLogger(); diff --git a/tests/StatusAggregator.Tests/Collector/CursorTests.cs b/tests/StatusAggregator.Tests/Collector/CursorTests.cs new file mode 100644 index 000000000..40c7b1187 --- /dev/null +++ b/tests/StatusAggregator.Tests/Collector/CursorTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status.Table; +using StatusAggregator.Collector; +using StatusAggregator.Table; +using Xunit; + +namespace StatusAggregator.Tests.Collector +{ + public class CursorTests + { + public class TheGetMethod : CursorTest + { + [Fact] + public async Task ThrowsIfNameNull() + { + await Assert.ThrowsAsync(() => Cursor.Get(null)); + } + + [Fact] + public async Task ReturnsMinValueIfNotInTable() + { + Table + .Setup(x => x.RetrieveAsync(Name)) + .ReturnsAsync((CursorEntity)null); + + var result = await Cursor.Get(Name); + + Assert.Equal(DateTime.MinValue, result); + } + + [Fact] + public async Task ReturnsValueIfInTable() + { + var entity = new CursorEntity(Name, new DateTime(2018, 9, 11)); + + Table + .Setup(x => x.RetrieveAsync(Name)) + .ReturnsAsync(entity); + + var result = await Cursor.Get(Name); + + Assert.Equal(entity.Value, result); + } + } + + public class TheSetMethod : CursorTest + { + [Fact] + public async Task ThrowsIfNameNull() + { + await Assert.ThrowsAsync(() => Cursor.Set(null, new DateTime(2018, 9, 11))); + } + + [Fact] + public async Task InsertsOrReplacesExistingValueInTable() + { + var value = new DateTime(2018, 9, 11); + + Table + .Setup(x => x.InsertOrReplaceAsync( + It.Is(e => e.Name == Name && e.Value == value))) + .Returns(Task.CompletedTask) + .Verifiable(); + + await Cursor.Set(Name, value); + + Table.Verify(); + } + } + + public class CursorTest + { + public string Name => "name"; + public Mock Table { get; } + public Cursor Cursor { get; } + + public CursorTest() + { + Table = new Mock(); + + Cursor = new Cursor( + Table.Object, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Collector/EntityCollectorTests.cs b/tests/StatusAggregator.Tests/Collector/EntityCollectorTests.cs new file mode 100644 index 000000000..a42745615 --- /dev/null +++ b/tests/StatusAggregator.Tests/Collector/EntityCollectorTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Moq; +using StatusAggregator.Collector; +using Xunit; + +namespace StatusAggregator.Tests.Collector +{ + public class EntityCollectorTests + { + public class TheNameProperty : EntityCollectorTest + { + [Fact] + public void ReturnsProcessorName() + { + Assert.Equal(Name, Collector.Name); + } + } + + public class TheFetchLatestMethod : EntityCollectorTest + { + [Fact] + public async Task DoesNotSetValueIfProcessorReturnsNull() + { + Processor + .Setup(x => x.FetchSince(LastCursor)) + .ReturnsAsync((DateTime?)null); + + var result = await Collector.FetchLatest(); + + Assert.Equal(LastCursor, result); + + Cursor + .Verify( + x => x.Set(It.IsAny(), It.IsAny()), + Times.Never()); + } + + [Fact] + public async Task SetsValueIfProcessorReturnsValue() + { + var nextCursor = new DateTime(2018, 9, 12); + + Processor + .Setup(x => x.FetchSince(LastCursor)) + .ReturnsAsync(nextCursor); + + Cursor + .Setup(x => x.Set(Name, nextCursor)) + .Returns(Task.CompletedTask) + .Verifiable(); + + var result = await Collector.FetchLatest(); + + Assert.Equal(nextCursor, result); + + Cursor.Verify(); + } + } + + public class EntityCollectorTest + { + public string Name => "name"; + public DateTime LastCursor => new DateTime(2018, 9, 11); + public Mock Cursor { get; } + public Mock Processor { get; } + public EntityCollector Collector { get; } + + public EntityCollectorTest() + { + Cursor = new Mock(); + Cursor + .Setup(x => x.Get(Name)) + .ReturnsAsync(LastCursor); + + Processor = new Mock(); + Processor + .Setup(x => x.Name) + .Returns(Name); + + Collector = new EntityCollector( + Cursor.Object, + Processor.Object); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Collector/IncidentEntityCollectorProcessorTests.cs b/tests/StatusAggregator.Tests/Collector/IncidentEntityCollectorProcessorTests.cs new file mode 100644 index 000000000..f3dec4bc9 --- /dev/null +++ b/tests/StatusAggregator.Tests/Collector/IncidentEntityCollectorProcessorTests.cs @@ -0,0 +1,245 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Collector; +using StatusAggregator.Factory; +using StatusAggregator.Parse; +using Xunit; + +namespace StatusAggregator.Tests.Collector +{ + public class IncidentEntityCollectorProcessorTests + { + public class TheNameProperty : IncidentEntityCollectorProcessorTest + { + [Fact] + public void ReturnsIncidentsCollectorName() + { + Assert.Equal(IncidentEntityCollectorProcessor.IncidentsCollectorName, Processor.Name); + } + } + + public abstract class TheFetchSinceMethod : IncidentEntityCollectorProcessorTest + { + public abstract DateTime Cursor { get; } + + [Fact] + public async Task ReturnsNullIfNoIncidents() + { + SetupClientQuery(Cursor, Enumerable.Empty()); + + var result = await Processor.FetchSince(Cursor); + + Assert.Null(result); + + Parser + .Verify( + x => x.ParseIncident(It.IsAny()), + Times.Never()); + + Factory + .Verify( + x => x.CreateAsync(It.IsAny()), + Times.Never()); + } + + [Fact] + public async Task DoesNotCreateIncidentsThatCannotBeParsed() + { + var unparsableIncident = new Incident() + { + CreateDate = Cursor + TimeSpan.FromMinutes(1) + }; + + SetupClientQuery( + Cursor, + new[] { unparsableIncident }); + + Parser + .Setup(x => x.ParseIncident(It.IsAny())) + .Returns(Enumerable.Empty()); + + var result = await Processor.FetchSince(Cursor); + + Assert.Equal(unparsableIncident.CreateDate, result); + + Factory + .Verify( + x => x.CreateAsync(It.IsAny()), + Times.Never()); + } + + [Fact] + public async Task CreatesParsedIncidents() + { + var firstIncident = new Incident() + { + CreateDate = Cursor + TimeSpan.FromMinutes(1), + Source = new IncidentSourceData { CreateDate = Cursor + TimeSpan.FromMinutes(5) } + }; + + var secondIncident = new Incident() + { + CreateDate = Cursor + TimeSpan.FromMinutes(2), + Source = new IncidentSourceData { CreateDate = Cursor + TimeSpan.FromMinutes(6) } + }; + + var thirdIncident = new Incident() + { + CreateDate = Cursor + TimeSpan.FromMinutes(3), + Source = new IncidentSourceData { CreateDate = Cursor + TimeSpan.FromMinutes(4) } + }; + + var incidents = new[] { firstIncident, secondIncident, thirdIncident }; + + SetupClientQuery( + Cursor, + incidents); + + var firstFirstParsedIncident = new ParsedIncident(firstIncident, "", ComponentStatus.Up); + var firstSecondParsedIncident = new ParsedIncident(firstIncident, "", ComponentStatus.Up); + var secondFirstParsedIncident = new ParsedIncident(secondIncident, "", ComponentStatus.Up); + var secondSecondParsedIncident = new ParsedIncident(secondIncident, "", ComponentStatus.Up); + var thirdParsedIncident = new ParsedIncident(thirdIncident, "", ComponentStatus.Up); + + Parser + .Setup(x => x.ParseIncident(firstIncident)) + .Returns(new[] { firstFirstParsedIncident, firstSecondParsedIncident }); + + Parser + .Setup(x => x.ParseIncident(secondIncident)) + .Returns(new[] { secondFirstParsedIncident, secondSecondParsedIncident }); + + Parser + .Setup(x => x.ParseIncident(thirdIncident)) + .Returns(new[] { thirdParsedIncident }); + + var lastCreateDate = DateTime.MinValue; + Factory + .Setup(x => x.CreateAsync(It.IsAny())) + .ReturnsAsync(new IncidentEntity()) + .Callback(incident => + { + var nextCreateDate = incident.StartTime; + Assert.True(nextCreateDate >= lastCreateDate); + lastCreateDate = nextCreateDate; + }); + + var result = await Processor.FetchSince(Cursor); + + Assert.Equal(incidents.Max(i => i.CreateDate), result); + + Factory + .Verify( + x => x.CreateAsync(firstFirstParsedIncident), + Times.Once()); + + Factory + .Verify( + x => x.CreateAsync(secondFirstParsedIncident), + Times.Once()); + } + } + + public class TheFetchSinceMethodAtMinValue : TheFetchSinceMethod + { + public override DateTime Cursor => DateTime.MinValue; + } + + public class TheFetchSinceMethodAtPresent : TheFetchSinceMethod + { + public override DateTime Cursor => new DateTime(2018, 9, 11); + + [Fact] + public async Task FiltersOutIncidentsBeforeOrAtCursor() + { + SetupClientQuery( + Cursor, + new[] + { + new Incident() + { + CreateDate = Cursor - TimeSpan.FromMinutes(1) + }, + + new Incident() + { + CreateDate = Cursor + } + }); + + var result = await Processor.FetchSince(Cursor); + + Assert.Null(result); + + Parser + .Verify( + x => x.ParseIncident(It.IsAny()), + Times.Never()); + + Factory + .Verify( + x => x.CreateAsync(It.IsAny()), + Times.Never()); + } + } + + public class IncidentEntityCollectorProcessorTest + { + public string TeamId => "teamId"; + public Mock Parser { get; } + public Mock Client { get; } + public Mock> Factory { get; } + public IncidentEntityCollectorProcessor Processor { get; } + + public IncidentEntityCollectorProcessorTest() + { + Parser = new Mock(); + + Client = new Mock(); + + Factory = new Mock>(); + + var config = new StatusAggregatorConfiguration() + { + TeamId = TeamId + }; + + Processor = new IncidentEntityCollectorProcessor( + Client.Object, + Parser.Object, + Factory.Object, + config, + Mock.Of>()); + } + + public void SetupClientQuery(DateTime cursor, IEnumerable incidents) + { + Client + .Setup(x => x.GetIncidents(GetExpectedQuery(cursor))) + .ReturnsAsync(incidents); + } + + private string GetExpectedQuery(DateTime cursor) + { + var query = $"$filter=OwningTeamId eq '{TeamId}'"; + + if (cursor != DateTime.MinValue) + { + query += $" and CreateDate gt datetime'{cursor.ToString("o")}'"; + } + + return query; + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Collector/ManualStatusChangeCollectorProcessorTests.cs b/tests/StatusAggregator.Tests/Collector/ManualStatusChangeCollectorProcessorTests.cs new file mode 100644 index 000000000..8967ae558 --- /dev/null +++ b/tests/StatusAggregator.Tests/Collector/ManualStatusChangeCollectorProcessorTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status.Table.Manual; +using StatusAggregator.Collector; +using StatusAggregator.Manual; +using StatusAggregator.Table; +using StatusAggregator.Tests.TestUtility; +using Xunit; + +namespace StatusAggregator.Tests.Collector +{ + public class ManualStatusChangeCollectorProcessorTests + { + public class TheNameProperty : ManualStatusChangeCollectorTest + { + [Fact] + public void ReturnsManualCollectorNamePrefixConcatenation() + { + Assert.Equal( + ManualStatusChangeCollectorProcessor.ManualCollectorNamePrefix + Name, + Processor.Name); + } + } + + public abstract class TheFetchSinceMethod : ManualStatusChangeCollectorTest + { + public abstract DateTime Cursor { get; } + + [Fact] + public async Task ReturnsNullIfNoManualChanges() + { + Table.SetupQuery(); + + var result = await Processor.FetchSince(Cursor); + + Assert.Null(result); + + Handler + .Verify( + x => x.Handle(It.IsAny(), It.IsAny()), + Times.Never()); + } + + [Fact] + public async Task HandlesManualChanges() + { + var firstChange = new ManualStatusChangeEntity() + { + Timestamp = Cursor + TimeSpan.FromMinutes(1) + }; + + var secondChange = new ManualStatusChangeEntity() + { + Timestamp = Cursor + TimeSpan.FromMinutes(2) + }; + + Table.SetupQuery(secondChange, firstChange); + + var lastTimestamp = DateTimeOffset.MinValue; + Handler + .Setup(x => x.Handle(Table.Object, It.IsAny())) + .Returns(Task.CompletedTask) + .Callback((table, entity) => + { + var nextTimestamp = entity.Timestamp; + Assert.True(nextTimestamp > lastTimestamp); + lastTimestamp = nextTimestamp; + }); + + var result = await Processor.FetchSince(Cursor); + + Assert.Equal(secondChange.Timestamp.UtcDateTime, result); + + Handler + .Verify( + x => x.Handle(Table.Object, firstChange), + Times.Once()); + + Handler + .Verify( + x => x.Handle(Table.Object, secondChange), + Times.Once()); + } + } + + public class TheFetchSinceMethodAtMinValue : TheFetchSinceMethod + { + public override DateTime Cursor => DateTime.MinValue; + + [Fact] + public async Task DoesNotFilterByCursor() + { + var change = new ManualStatusChangeEntity() + { + Timestamp = DateTime.MinValue + }; + + Table.SetupQuery(change); + + Handler + .Setup(x => x.Handle(Table.Object, change)) + .Returns(Task.CompletedTask) + .Verifiable(); + + var result = await Processor.FetchSince(Cursor); + + Assert.Equal(change.Timestamp.UtcDateTime, result); + + Handler.Verify(); + } + } + + public class TheFetchSinceMethodAtPresent : TheFetchSinceMethod + { + public override DateTime Cursor => new DateTime(2018, 9, 12); + } + + public class ManualStatusChangeCollectorTest + { + public string Name => "name"; + public Mock Table { get; } + public Mock Handler { get; } + public ManualStatusChangeCollectorProcessor Processor { get; } + + public ManualStatusChangeCollectorTest() + { + Table = new Mock(); + Handler = new Mock(); + + Processor = new ManualStatusChangeCollectorProcessor( + Name, + Table.Object, + Handler.Object, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/EventUpdaterTests.cs b/tests/StatusAggregator.Tests/EventUpdaterTests.cs deleted file mode 100644 index 6f7d49eb3..000000000 --- a/tests/StatusAggregator.Tests/EventUpdaterTests.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using NuGet.Services.Status.Table; -using StatusAggregator.Table; -using Xunit; - -namespace StatusAggregator.Tests -{ - public class EventUpdaterTests - { - private const string RowKey = "rowkey"; - private const int EventEndDelayMinutes = 5; - private static readonly TimeSpan EventEndDelay = TimeSpan.FromMinutes(EventEndDelayMinutes); - private static readonly DateTime NextCreationTime = new DateTime(2018, 7, 10); - - private static IEnumerable ClosableIncidents => new[] - { - CreateIncidentEntity(new DateTime(2018, 7, 9)), // Recently closed incident - CreateIncidentEntity(DateTime.MinValue), // Old incident - }; - - private static IEnumerable UnclosableIncidents => new[] - { - CreateIncidentEntity(NextCreationTime + EventEndDelay), // Incident closed too recently - CreateIncidentEntity() // Active incident - }; - - private Mock _tableWrapperMock { get; } - private Mock _messageUpdaterMock { get; } - private EventUpdater _eventUpdater { get; } - private EventEntity _eventEntity { get; } - - public EventUpdaterTests() - { - var configuration = new StatusAggregatorConfiguration() - { - EventEndDelayMinutes = EventEndDelayMinutes - }; - - _tableWrapperMock = new Mock(); - _messageUpdaterMock = new Mock(); - _eventUpdater = new EventUpdater( - _tableWrapperMock.Object, - _messageUpdaterMock.Object, - configuration, - Mock.Of>()); - - _eventEntity = new EventEntity() - { - RowKey = RowKey, - StartTime = DateTime.MinValue, - EndTime = null - }; - } - - [Fact] - public async Task ThrowsIfEventNull() - { - await Assert.ThrowsAsync(() => _eventUpdater.UpdateEvent(null, DateTime.MinValue)); - } - - [Fact] - public async Task ReturnsFalseIfNotActive() - { - _eventEntity.EndTime = DateTime.MinValue; - - var result = await _eventUpdater.UpdateEvent(_eventEntity, NextCreationTime); - - Assert.False(result); - } - - [Fact] - public async Task ReturnsFalseIfNoLinkedIncidents() - { - _tableWrapperMock - .Setup(x => x.CreateQuery()) - .Returns(new IncidentEntity[0].AsQueryable()); - - var result = await _eventUpdater.UpdateEvent(_eventEntity, NextCreationTime); - - Assert.False(result); - } - - public static IEnumerable DoesNotCloseEventIfUnclosableIncident_Data - { - get - { - foreach (var unclosableIncident in UnclosableIncidents) - { - yield return new object[] { unclosableIncident }; - } - } - } - - [Theory] - [MemberData(nameof(DoesNotCloseEventIfUnclosableIncident_Data))] - public async Task DoesNotCloseEventIfUnclosableIncident(IncidentEntity unclosableIncident) - { - _tableWrapperMock - .Setup(x => x.CreateQuery()) - .Returns(ClosableIncidents.Concat(new[] { unclosableIncident }).AsQueryable()); - - var result = await _eventUpdater.UpdateEvent(_eventEntity, NextCreationTime); - - Assert.False(result); - Assert.Null(_eventEntity.EndTime); - _messageUpdaterMock.Verify( - x => x.CreateMessageForEventStart(_eventEntity, NextCreationTime), - Times.Once()); - _messageUpdaterMock.Verify( - x => x.CreateMessageForEventEnd(It.IsAny()), - Times.Never()); - } - - [Fact] - public async Task ClosesEventIfClosableIncidents() - { - _tableWrapperMock - .Setup(x => x.CreateQuery()) - .Returns(ClosableIncidents.AsQueryable()); - - var result = await _eventUpdater.UpdateEvent(_eventEntity, NextCreationTime); - - var expectedEndTime = ClosableIncidents.Max(i => i.MitigationTime ?? DateTime.MinValue); - Assert.True(result); - Assert.Equal(expectedEndTime, _eventEntity.EndTime); - _tableWrapperMock.Verify( - x => x.InsertOrReplaceAsync(_eventEntity), - Times.Once()); - _messageUpdaterMock.Verify( - x => x.CreateMessageForEventStart(_eventEntity, expectedEndTime), - Times.Once()); - _messageUpdaterMock.Verify( - x => x.CreateMessageForEventEnd(_eventEntity), - Times.Once()); - } - - private static IncidentEntity CreateIncidentEntity(DateTime? mitigationTime = null) - { - return new IncidentEntity() - { - PartitionKey = IncidentEntity.DefaultPartitionKey, - EventRowKey = RowKey, - CreationTime = DateTime.MinValue, - MitigationTime = mitigationTime - }; - } - } -} diff --git a/tests/StatusAggregator.Tests/Export/ComponentExporterTests.cs b/tests/StatusAggregator.Tests/Export/ComponentExporterTests.cs new file mode 100644 index 000000000..8324d046c --- /dev/null +++ b/tests/StatusAggregator.Tests/Export/ComponentExporterTests.cs @@ -0,0 +1,178 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Export; +using StatusAggregator.Factory; +using StatusAggregator.Table; +using StatusAggregator.Tests.TestUtility; +using Xunit; + +namespace StatusAggregator.Tests.Export +{ + public class ComponentExporterTests + { + public class TheExportMethod : ComponentExporterTest + { + [Fact] + public void ReturnsUnaffectedComponentTreeWithNoEntities() + { + Table.SetupQuery(); + Table.SetupQuery(); + Table.SetupQuery(); + + var result = Exporter.Export(); + + Assert.All( + result.GetAllVisibleComponents(), + c => Assert.Equal(ComponentStatus.Up, c.Status)); + } + + [Fact] + public void ThrowsWithMissingPath() + { + var eventWithMessage = new EventEntity(Level2A.Path, DefaultStartTime, ComponentStatus.Degraded); + var messageForEventWithMessage = new MessageEntity(eventWithMessage, DefaultStartTime, "", MessageType.Manual); + var missingPathIncidentGroupForEventWithMessage = new IncidentGroupEntity(eventWithMessage, "missingPath", ComponentStatus.Degraded, DefaultStartTime); + + Table.SetupQuery(messageForEventWithMessage); + Table.SetupQuery(missingPathIncidentGroupForEventWithMessage); + Table.SetupQuery(eventWithMessage); + + Assert.Throws(() => Exporter.Export()); + } + + [Fact] + public void AppliesActiveEntitiesToComponentTree() + { + var eventWithMessage = new EventEntity(Level2A.Path, DefaultStartTime, ComponentStatus.Degraded); + var messageForEventWithMessage = new MessageEntity(eventWithMessage, DefaultStartTime, "", MessageType.Manual); + var degradedIncidentGroupForEventWithMessage = new IncidentGroupEntity(eventWithMessage, Level3AFrom2A.Path, ComponentStatus.Degraded, DefaultStartTime); + var downIncidentGroupForEventWithMessage = new IncidentGroupEntity(eventWithMessage, Level3AFrom2A.Path, ComponentStatus.Down, DefaultStartTime); + var upIncidentGroupForEventWithMessage = new IncidentGroupEntity(eventWithMessage, Level3AFrom2A.Path, ComponentStatus.Up, DefaultStartTime); + var inactiveIncidentGroupForEventWithMessage = new IncidentGroupEntity(eventWithMessage, Level3BFrom2A.Path, ComponentStatus.Degraded, DefaultStartTime, DefaultStartTime); + + var eventWithoutMessage = new EventEntity(Level2B.Path, DefaultStartTime, ComponentStatus.Degraded); + var incidentGroupForEventWithoutMessage = new IncidentGroupEntity(eventWithoutMessage, Level3AFrom2B.Path, ComponentStatus.Degraded, DefaultStartTime); + + var inactiveEventWithMessage = new EventEntity(Level2B.Path, DefaultStartTime + TimeSpan.FromDays(1), ComponentStatus.Degraded, DefaultStartTime + TimeSpan.FromDays(2)); + var messageForInactiveEventWithMessage = new MessageEntity(inactiveEventWithMessage, DefaultStartTime + TimeSpan.FromDays(1), "", MessageType.Manual); + var incidentGroupForInactiveEventWithMessage = new IncidentGroupEntity(inactiveEventWithMessage, Level3BFrom2B.Path, ComponentStatus.Degraded, DefaultStartTime + TimeSpan.FromDays(1)); + + Table.SetupQuery(messageForEventWithMessage, messageForInactiveEventWithMessage); + Table.SetupQuery( + degradedIncidentGroupForEventWithMessage, downIncidentGroupForEventWithMessage, upIncidentGroupForEventWithMessage, inactiveIncidentGroupForEventWithMessage, + incidentGroupForEventWithoutMessage, incidentGroupForInactiveEventWithMessage); + Table.SetupQuery(eventWithMessage, eventWithoutMessage, inactiveEventWithMessage); + + var result = Exporter.Export(); + + // Status of events with messages are applied. + AssertComponentStatus(ComponentStatus.Degraded, Level2A, eventWithMessage); + + // Most severe status affecting same component is applied. + AssertComponentStatus( + ComponentStatus.Down, + Level3AFrom2A, + degradedIncidentGroupForEventWithMessage, + downIncidentGroupForEventWithMessage, + upIncidentGroupForEventWithMessage); + + // Status of inactive incident groups are not applied. + AssertComponentStatus(ComponentStatus.Up, Level3BFrom2A, inactiveIncidentGroupForEventWithMessage); + + // Status of events without messages are not applied. + // Status of inactive events with messages are not applied. + AssertComponentStatus(ComponentStatus.Up, Level2B, eventWithoutMessage, inactiveEventWithMessage); + + // Status of incident groups for events without messages are not applied. + AssertComponentStatus(ComponentStatus.Up, Level3AFrom2B, incidentGroupForEventWithoutMessage); + + // Status of incident groups for inactive events with messages are not applied. + AssertComponentStatus(ComponentStatus.Up, Level3BFrom2B, incidentGroupForInactiveEventWithMessage); + } + + private void AssertComponentStatus(ComponentStatus expected, IComponent component, params ComponentAffectingEntity[] entities) + { + Assert.Equal(expected, component.Status); + Assert.Equal(component.Path, entities.First().AffectedComponentPath); + + for (var i = 1; i < entities.Count(); i++) + { + Assert.Equal( + entities[i - 1].AffectedComponentPath, + entities[i].AffectedComponentPath); + } + } + } + + public class ComponentExporterTest + { + public DateTime DefaultStartTime => new DateTime(2018, 9, 12); + + public IComponent Root { get; } + public IComponent Level2A { get; } + public IComponent Level2B { get; } + public IComponent Level3AFrom2A { get; } + public IComponent Level3BFrom2A { get; } + public IComponent Level3AFrom2B { get; } + public IComponent Level3BFrom2B { get; } + + public Mock Factory { get; } + public Mock Table { get; } + public ComponentExporter Exporter { get; } + + public ComponentExporterTest() + { + var level3AFrom2A = new TestComponent("3A"); + var level3BFrom2A = new TestComponent("3B"); + var level2A = new TestComponent("2A", + new[] { level3AFrom2A, level3BFrom2A }); + + var level3AFrom2B = new TestComponent("3A"); + var level3BFrom2B = new TestComponent("3B"); + var level2B = new TestComponent("2B", + new[] { level3AFrom2B, level3BFrom2B }); + + Root = new TestComponent("Root", new[] { level2A, level2B }); + + // We have to get the subcomponents by iterating through the tree. + // Components only have a path in the context of accessing them through a parent. + Level2A = Root + .SubComponents.Single(c => c.Name == "2A"); + Level3AFrom2A = Root + .SubComponents.Single(c => c.Name == "2A") + .SubComponents.Single(c => c.Name == "3A"); + Level3BFrom2A = Root + .SubComponents.Single(c => c.Name == "2A") + .SubComponents.Single(c => c.Name == "3B"); + + Level2B = Root + .SubComponents.Single(c => c.Name == "2B"); + Level3AFrom2B = Root + .SubComponents.Single(c => c.Name == "2B") + .SubComponents.Single(c => c.Name == "3A"); + Level3BFrom2B = Root + .SubComponents.Single(c => c.Name == "2B") + .SubComponents.Single(c => c.Name == "3B"); + + Factory = new Mock(); + Factory + .Setup(x => x.Create()) + .Returns(Root); + + Table = new Mock(); + + Exporter = new ComponentExporter( + Table.Object, + Factory.Object, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Export/EventExporterTests.cs b/tests/StatusAggregator.Tests/Export/EventExporterTests.cs new file mode 100644 index 000000000..c0d61f8cf --- /dev/null +++ b/tests/StatusAggregator.Tests/Export/EventExporterTests.cs @@ -0,0 +1,82 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Export; +using StatusAggregator.Table; +using StatusAggregator.Tests.TestUtility; +using Xunit; + +namespace StatusAggregator.Tests.Export +{ + public class EventExporterTests + { + public class TheExportMethod : EventMessageExporterTest + { + [Fact] + public void IgnoresEventsWithoutMessages() + { + var eventEntity = new EventEntity("", DefaultStartTime); + + Table.SetupQuery(); + + var result = Exporter.Export(EventEntity); + + Assert.Null(result); + } + + [Fact] + public void ExportsEventMessagesWithContent() + { + var differentEvent = new EventEntity("", DefaultStartTime + TimeSpan.FromDays(1)); + var differentEventMessage = new MessageEntity(differentEvent, DefaultStartTime, "", MessageType.Manual); + + var emptyMessage = new MessageEntity(EventEntity, DefaultStartTime, "", MessageType.Manual); + var firstMessage = new MessageEntity(EventEntity, DefaultStartTime, "hi", MessageType.Manual); + var secondMessage = new MessageEntity(EventEntity, DefaultStartTime + TimeSpan.FromDays(1), "hi", MessageType.Manual); + + Table.SetupQuery(differentEventMessage, secondMessage, firstMessage, emptyMessage); + + var result = Exporter.Export(EventEntity); + + Assert.Equal(EventEntity.AffectedComponentPath, result.AffectedComponentPath); + Assert.Equal(EventEntity.StartTime, result.StartTime); + Assert.Equal(EventEntity.EndTime, result.EndTime); + + Assert.Equal(2, result.Messages.Count()); + AssertMessageEqual(firstMessage, result.Messages.First()); + AssertMessageEqual(secondMessage, result.Messages.Last()); + } + + private void AssertMessageEqual(MessageEntity expected, Message actual) + { + Assert.Equal(expected.Time, actual.Time); + Assert.Equal(expected.Contents, actual.Contents); + } + } + + public class EventMessageExporterTest + { + public DateTime DefaultStartTime = new DateTime(2018, 9, 12); + public EventEntity EventEntity { get; } + public Mock Table { get; } + public EventExporter Exporter { get; } + + public EventMessageExporterTest() + { + EventEntity = new EventEntity("", DefaultStartTime); + + Table = new Mock(); + + Exporter = new EventExporter( + Table.Object, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Export/EventsExporterTests.cs b/tests/StatusAggregator.Tests/Export/EventsExporterTests.cs new file mode 100644 index 000000000..ba8add829 --- /dev/null +++ b/tests/StatusAggregator.Tests/Export/EventsExporterTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Export; +using StatusAggregator.Table; +using StatusAggregator.Tests.TestUtility; +using Xunit; + +namespace StatusAggregator.Tests.Export +{ + public class EventsExporterTests + { + public class TheExportMethod : EventsExporterTest + { + [Fact] + public void ExportsRecentEvents() + { + var oldEventEntity = new EventEntity("", + new DateTime(2017, 9, 12), + endTime: new DateTime(2017, 9, 13)); + + var activeEvent1Entity = new EventEntity("", + new DateTime(2017, 9, 12)); + var activeEvent2Entity = new EventEntity("", + Cursor); + + var recentEvent1Entity = new EventEntity("", + Cursor - EventVisibilityPeriod, + endTime: Cursor - EventVisibilityPeriod); + var recentEvent2Entity = new EventEntity("", + Cursor - EventVisibilityPeriod, + endTime: Cursor); + + Table.SetupQuery( + oldEventEntity, + activeEvent1Entity, + activeEvent2Entity, + recentEvent1Entity, + recentEvent2Entity); + + var eventForActiveEvent1 = new Event("", DateTime.MinValue, DateTime.MinValue, new[] { new Message(DateTime.MinValue, "") }); + IndividualExporter + .Setup(x => x.Export(activeEvent1Entity)) + .Returns(eventForActiveEvent1) + .Verifiable(); + + IndividualExporter + .Setup(x => x.Export(activeEvent2Entity)) + .Returns(null) + .Verifiable(); + + var eventForRecentEvent1 = new Event("", DateTime.MinValue, DateTime.MinValue, new[] { new Message(DateTime.MinValue, "") }); + IndividualExporter + .Setup(x => x.Export(recentEvent1Entity)) + .Returns(eventForRecentEvent1) + .Verifiable(); + + IndividualExporter + .Setup(x => x.Export(recentEvent2Entity)) + .Returns(null) + .Verifiable(); + + var result = Exporter.Export(Cursor); + + var expectedEvents = new[] { eventForActiveEvent1, eventForRecentEvent1 }; + Assert.Equal(expectedEvents.Count(), result.Count()); + foreach (var expectedEvent in expectedEvents) + { + Assert.Contains(expectedEvent, result); + } + + IndividualExporter.Verify(); + IndividualExporter + .Verify( + x => x.Export(oldEventEntity), + Times.Never()); + } + } + + public class EventsExporterTest + { + public DateTime Cursor => new DateTime(2018, 9, 12); + public TimeSpan EventVisibilityPeriod => TimeSpan.FromDays(10); + public Mock Table { get; } + public Mock IndividualExporter { get; } + public EventsExporter Exporter { get; } + + public EventsExporterTest() + { + Table = new Mock(); + + IndividualExporter = new Mock(); + + var config = new StatusAggregatorConfiguration() + { + EventVisibilityPeriodDays = EventVisibilityPeriod.Days + }; + + Exporter = new EventsExporter( + Table.Object, + IndividualExporter.Object, + config, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Export/StatusExporterTests.cs b/tests/StatusAggregator.Tests/Export/StatusExporterTests.cs new file mode 100644 index 000000000..d5437c077 --- /dev/null +++ b/tests/StatusAggregator.Tests/Export/StatusExporterTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status; +using StatusAggregator.Export; +using Xunit; + +namespace StatusAggregator.Tests.Export +{ + public class StatusExporterTests + { + public class TheExportMethod : StatusExporterTest + { + [Fact] + public async Task ExportsAtCursor() + { + var cursor = new DateTime(2018, 9, 13); + + var component = new TestComponent("hi"); + ComponentExporter + .Setup(x => x.Export()) + .Returns(component); + + var events = new Event[0]; + EventExporter + .Setup(x => x.Export(cursor)) + .Returns(events); + + await Exporter.Export(cursor); + + Serializer + .Verify( + x => x.Serialize(cursor, component, events), + Times.Once()); + } + } + + public class StatusExporterTest + { + public Mock ComponentExporter { get; } + public Mock EventExporter { get; } + public Mock Serializer { get; } + public StatusExporter Exporter { get; } + + public StatusExporterTest() + { + ComponentExporter = new Mock(); + EventExporter = new Mock(); + Serializer = new Mock(); + + Exporter = new StatusExporter( + ComponentExporter.Object, + EventExporter.Object, + Serializer.Object, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Export/StatusSerializerTests.cs b/tests/StatusAggregator.Tests/Export/StatusSerializerTests.cs new file mode 100644 index 000000000..62d501749 --- /dev/null +++ b/tests/StatusAggregator.Tests/Export/StatusSerializerTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using Newtonsoft.Json; +using NuGet.Services.Status; +using StatusAggregator.Container; +using StatusAggregator.Export; +using Xunit; + +namespace StatusAggregator.Tests.Export +{ + public class StatusSerializerTests + { + public class TheSerializeMethod : StatusSerializerTest + { + [Fact] + public async Task SerializesStatus() + { + var cursor = new DateTime(2018, 9, 13); + var component = new TestComponent("hi", new[] { new TestComponent("yo"), new TestComponent("what's up") }); + var events = new[] { new Event("", cursor, cursor, new[] { new Message(cursor, "howdy") }) }; + + var expectedStatus = new ServiceStatus(cursor, component, events); + var expectedJson = JsonConvert.SerializeObject(expectedStatus, StatusSerializer.Settings); + + await Serializer.Serialize(cursor, component, events); + + Container + .Verify( + x => x.SaveBlobAsync(StatusSerializer.StatusBlobName, expectedJson), + Times.Once()); + } + } + + public class StatusSerializerTest + { + public Mock Container { get; } + public StatusSerializer Serializer { get; } + + public StatusSerializerTest() + { + Container = new Mock(); + + Serializer = new StatusSerializer( + Container.Object, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Factory/AggregationProviderTests.cs b/tests/StatusAggregator.Tests/Factory/AggregationProviderTests.cs new file mode 100644 index 000000000..cdb72825f --- /dev/null +++ b/tests/StatusAggregator.Tests/Factory/AggregationProviderTests.cs @@ -0,0 +1,277 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Factory; +using StatusAggregator.Parse; +using StatusAggregator.Table; +using StatusAggregator.Tests.TestUtility; +using Xunit; + +namespace StatusAggregator.Tests.Factory +{ + public class AggregationProviderTests + { + public class TheIncidentEntityGetAsyncMethod + : TheGetAsyncMethod + { + } + + public class TheIncidentGroupEntityGetAsyncMethod + : TheGetAsyncMethod + { + } + + public abstract class TheGetAsyncMethod + : AggregationProviderTest + where TAggregatedEntity : AggregatedComponentAffectingEntity, new() + where TEntityAggregation : ComponentAffectingEntity, new() + { + [Fact] + public async Task CreatesNewEntityIfNoPossibleAggregation() + { + var inputPath = "howdy"; + var input = new ParsedIncident(Incident, inputPath, ComponentStatus.Degraded); + + var providedPath = "hello"; + PathProvider + .Setup(x => x.Get(input)) + .Returns(providedPath); + + var aggregationWithDifferentPath = new TEntityAggregation + { + AffectedComponentPath = "other path", + StartTime = input.StartTime + }; + + var aggregationAfter = new TEntityAggregation + { + AffectedComponentPath = providedPath, + StartTime = input.StartTime + TimeSpan.FromDays(1) + }; + + var aggregationBefore = new TEntityAggregation + { + AffectedComponentPath = providedPath, + StartTime = input.StartTime - TimeSpan.FromDays(2), + EndTime = input.StartTime - TimeSpan.FromDays(1) + }; + + var activeAggregationToDeactivate = new TEntityAggregation + { + AffectedComponentPath = providedPath, + StartTime = input.StartTime + }; + + var inactiveAggregationToDeactivate = new TEntityAggregation + { + AffectedComponentPath = providedPath, + StartTime = input.StartTime, + EndTime = input.EndTime + }; + + Table.SetupQuery( + aggregationWithDifferentPath, + aggregationAfter, + aggregationBefore, + activeAggregationToDeactivate, + inactiveAggregationToDeactivate); + + Strategy + .Setup(x => x.CanBeAggregatedByAsync(input, activeAggregationToDeactivate)) + .ReturnsAsync(false); + + Strategy + .Setup(x => x.CanBeAggregatedByAsync(input, inactiveAggregationToDeactivate)) + .ReturnsAsync(false); + + var createdAggregation = new TEntityAggregation(); + AggregationFactory + .Setup(x => x.CreateAsync(input)) + .ReturnsAsync(createdAggregation); + + var result = await Provider.GetAsync(input); + + Assert.Equal(createdAggregation, result); + + Strategy + .Verify( + x => x.CanBeAggregatedByAsync(It.IsAny(), aggregationWithDifferentPath), + Times.Never()); + + Strategy + .Verify( + x => x.CanBeAggregatedByAsync(It.IsAny(), aggregationAfter), + Times.Never()); + + Strategy + .Verify( + x => x.CanBeAggregatedByAsync(It.IsAny(), aggregationBefore), + Times.Never()); + + Strategy + .Verify( + x => x.CanBeAggregatedByAsync(It.IsAny(), activeAggregationToDeactivate), + Times.Once()); + + Strategy + .Verify( + x => x.CanBeAggregatedByAsync(It.IsAny(), inactiveAggregationToDeactivate), + Times.Once()); + } + + [Fact] + public async Task ReturnsPossibleAggregation() + { + var inputPath = "howdy"; + var input = new ParsedIncident(Incident, inputPath, ComponentStatus.Degraded); + + var providedPath = "hello"; + PathProvider + .Setup(x => x.Get(input)) + .Returns(providedPath); + + var aggregationWithDifferentPath = new TEntityAggregation + { + AffectedComponentPath = "other path", + StartTime = input.StartTime + }; + + var aggregationAfter = new TEntityAggregation + { + AffectedComponentPath = providedPath, + StartTime = input.StartTime + TimeSpan.FromDays(1) + }; + + var aggregationBefore = new TEntityAggregation + { + AffectedComponentPath = providedPath, + StartTime = input.StartTime - TimeSpan.FromDays(2), + EndTime = input.StartTime - TimeSpan.FromDays(1) + }; + + var activeAggregationToDeactivate = new TEntityAggregation + { + AffectedComponentPath = providedPath, + StartTime = input.StartTime + }; + + var inactiveAggregationToDeactivate = new TEntityAggregation + { + AffectedComponentPath = providedPath, + StartTime = input.StartTime, + EndTime = input.EndTime + }; + + var activeAggregation = new TEntityAggregation + { + AffectedComponentPath = providedPath, + StartTime = input.StartTime + }; + + Table.SetupQuery( + aggregationWithDifferentPath, + aggregationAfter, + aggregationBefore, + activeAggregationToDeactivate, + inactiveAggregationToDeactivate, + activeAggregation); + + Strategy + .Setup(x => x.CanBeAggregatedByAsync(input, activeAggregationToDeactivate)) + .ReturnsAsync(false); + + Strategy + .Setup(x => x.CanBeAggregatedByAsync(input, inactiveAggregationToDeactivate)) + .ReturnsAsync(false); + + Strategy + .Setup(x => x.CanBeAggregatedByAsync(input, activeAggregation)) + .ReturnsAsync(true); + + var result = await Provider.GetAsync(input); + + Assert.Equal(activeAggregation, result); + + AggregationFactory + .Verify( + x => x.CreateAsync(input), + Times.Never()); + + Strategy + .Verify( + x => x.CanBeAggregatedByAsync(It.IsAny(), aggregationWithDifferentPath), + Times.Never()); + + Strategy + .Verify( + x => x.CanBeAggregatedByAsync(It.IsAny(), aggregationAfter), + Times.Never()); + + Strategy + .Verify( + x => x.CanBeAggregatedByAsync(It.IsAny(), aggregationBefore), + Times.Never()); + + Strategy + .Verify( + x => x.CanBeAggregatedByAsync(It.IsAny(), activeAggregationToDeactivate), + Times.Once()); + + Strategy + .Verify( + x => x.CanBeAggregatedByAsync(It.IsAny(), inactiveAggregationToDeactivate), + Times.Once()); + + Strategy + .Verify( + x => x.CanBeAggregatedByAsync(It.IsAny(), activeAggregation), + Times.Once()); + } + } + + public class AggregationProviderTest + where TAggregatedEntity : AggregatedComponentAffectingEntity, new() + where TEntityAggregation : ComponentAffectingEntity, new() + { + public Incident Incident = new Incident() + { + Source = new IncidentSourceData() + { + CreateDate = new DateTime(2018, 9, 13) + } + }; + + public Mock Table { get; } + public Mock> AggregationFactory { get; } + public Mock> PathProvider { get; } + public Mock> Strategy { get; } + + public AggregationProvider Provider { get; } + + public AggregationProviderTest() + { + Table = new Mock(); + + AggregationFactory = new Mock>(); + + PathProvider = new Mock>(); + + Strategy = new Mock>(); + + Provider = new AggregationProvider( + Table.Object, + PathProvider.Object, + Strategy.Object, + AggregationFactory.Object, + Mock.Of>>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Factory/AggregationStrategyTests.cs b/tests/StatusAggregator.Tests/Factory/AggregationStrategyTests.cs new file mode 100644 index 000000000..66ac4b4df --- /dev/null +++ b/tests/StatusAggregator.Tests/Factory/AggregationStrategyTests.cs @@ -0,0 +1,206 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Factory; +using StatusAggregator.Parse; +using StatusAggregator.Table; +using StatusAggregator.Tests.TestUtility; +using StatusAggregator.Update; +using Xunit; + +namespace StatusAggregator.Tests.Factory +{ + public class AggregationStrategyTests + { + public class TheIncidentEntityCanBeAggregatedByAsyncMethod + : TheCanBeAggregatedByAsyncMethod + { + } + + public class TheIncidentGroupEntityCanBeAggregatedByAsyncMethod + : TheCanBeAggregatedByAsyncMethod + { + } + + public abstract class TheCanBeAggregatedByAsyncMethod + : AggregationStrategyTest + where TAggregatedEntity : AggregatedComponentAffectingEntity, new() + where TEntityAggregation : ComponentAffectingEntity, new() + { + [Fact] + public async Task ReturnsFalseIfNoLinkedEntities() + { + var aggregatedEntityLinkedToDifferentAggregation = new TAggregatedEntity + { + ParentRowKey = "wrongRowKey" + }; + + Table.SetupQuery(aggregatedEntityLinkedToDifferentAggregation); + + var incident = new Incident() + { + Source = new IncidentSourceData() + { + CreateDate = new DateTime(2018, 9, 13) + } + }; + + var input = new ParsedIncident(incident, "", ComponentStatus.Up); + + var result = await Strategy.CanBeAggregatedByAsync(input, Aggregation); + + Assert.False(result); + + Updater + .Verify( + x => x.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Never()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReturnsTrueIfAggregationStillActive(bool inputIsActive) + { + var aggregatedEntity = new TAggregatedEntity + { + ParentRowKey = AggregationRowKey + }; + + Table.SetupQuery(aggregatedEntity); + + var incident = new Incident() + { + Source = new IncidentSourceData() + { + CreateDate = new DateTime(2018, 9, 13) + } + }; + + if (!inputIsActive) + { + incident.MitigationData = new IncidentStateChangeEventData() + { + Date = new DateTime(2018, 10, 9) + }; + } + + var input = new ParsedIncident(incident, "", ComponentStatus.Up); + + Updater + .Setup(x => x.UpdateAsync(Aggregation, input.StartTime)) + .Returns(Task.CompletedTask); + + var result = await Strategy.CanBeAggregatedByAsync(input, Aggregation); + + Assert.True(result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReturnsTrueIfInputInactive(bool aggregationIsActive) + { + var aggregatedEntity = new TAggregatedEntity + { + ParentRowKey = AggregationRowKey + }; + + if (!aggregationIsActive) + { + Aggregation.EndTime = new DateTime(2018, 10, 9); + } + + Table.SetupQuery(aggregatedEntity); + + var incident = new Incident() + { + Source = new IncidentSourceData() + { + CreateDate = new DateTime(2018, 9, 13) + }, + + MitigationData = new IncidentStateChangeEventData() + { + Date = new DateTime(2018, 10, 9) + } + }; + + var input = new ParsedIncident(incident, "", ComponentStatus.Up); + + Updater + .Setup(x => x.UpdateAsync(Aggregation, input.StartTime)) + .Returns(Task.CompletedTask); + + var result = await Strategy.CanBeAggregatedByAsync(input, Aggregation); + + Assert.True(result); + } + + [Fact] + public async Task ReturnsFalseIfAggregationInactiveAndInputActive() + { + Aggregation.EndTime = new DateTime(2018, 10, 9); + + var aggregatedEntity = new TAggregatedEntity + { + ParentRowKey = AggregationRowKey + }; + + var incident = new Incident() + { + Source = new IncidentSourceData() + { + CreateDate = new DateTime(2018, 9, 13) + } + }; + + var input = new ParsedIncident(incident, "", ComponentStatus.Up); + + Table.SetupQuery(aggregatedEntity); + + Updater + .Setup(x => x.UpdateAsync(Aggregation, input.StartTime)) + .Returns(Task.CompletedTask); + + var result = await Strategy.CanBeAggregatedByAsync(input, Aggregation); + + Assert.False(result); + } + } + + public class AggregationStrategyTest + where TAggregatedEntity : AggregatedComponentAffectingEntity, new() + where TEntityAggregation : ComponentAffectingEntity, new() + { + public const string AggregationRowKey = "aggregationRowKey"; + public TEntityAggregation Aggregation = new TEntityAggregation() + { + RowKey = AggregationRowKey + }; + + public Mock Table { get; } + public Mock> Updater { get; } + public AggregationStrategy Strategy { get; } + + public AggregationStrategyTest() + { + Table = new Mock(); + + Updater = new Mock>(); + + Strategy = new AggregationStrategy( + Table.Object, + Updater.Object, + Mock.Of>>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Factory/EventAffectedPathProviderTests.cs b/tests/StatusAggregator.Tests/Factory/EventAffectedPathProviderTests.cs new file mode 100644 index 000000000..669abfcdf --- /dev/null +++ b/tests/StatusAggregator.Tests/Factory/EventAffectedPathProviderTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using StatusAggregator.Factory; +using StatusAggregator.Parse; +using Xunit; + +namespace StatusAggregator.Tests.Factory +{ + public class EventAffectedComponentPathProviderTests + { + public class TheGetMethod + : EventAffectedComponentPathProviderTest + { + public static IEnumerable GetsAffectedComponentPath_Data + { + get + { + var root = new NuGetServiceComponentFactory().Create(); + + yield return new object[] { root.Path, root.Path }; + + foreach (var subcomponent in root.SubComponents) + { + foreach (var subsubcomponent in GetAllComponents(subcomponent)) + { + yield return new object[] { subcomponent.Path, subsubcomponent.Path }; + } + } + } + } + + private static IEnumerable GetAllComponents(IComponent component) + { + if (component == null) + { + yield break; + } + + yield return component; + + foreach (var subcomponent in component.SubComponents.SelectMany(s => GetAllComponents(s))) + { + yield return subcomponent; + } + } + + [Theory] + [MemberData(nameof(GetsAffectedComponentPath_Data))] + public void GetsAffectedComponentPath(string expectedPath, string inputPath) + { + var incident = new Incident() + { + Source = new IncidentSourceData() + { + CreateDate = new DateTime(2018, 10, 10) + } + }; + + var input = new ParsedIncident(incident, inputPath, ComponentStatus.Up); + + var result = Provider.Get(input); + + Assert.Equal(expectedPath, result); + } + } + + public class EventAffectedComponentPathProviderTest + { + public EventAffectedComponentPathProvider Provider { get; } + + public EventAffectedComponentPathProviderTest() + { + Provider = new EventAffectedComponentPathProvider(); + } + } + } +} diff --git a/tests/StatusAggregator.Tests/Factory/EventFactoryTests.cs b/tests/StatusAggregator.Tests/Factory/EventFactoryTests.cs new file mode 100644 index 000000000..f6babae5c --- /dev/null +++ b/tests/StatusAggregator.Tests/Factory/EventFactoryTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.WindowsAzure.Storage.Table; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Factory; +using StatusAggregator.Parse; +using StatusAggregator.Table; +using Xunit; + +namespace StatusAggregator.Tests.Factory +{ + public class EventFactoryTests + { + public class TheCreateAsyncMethod : EventFactoryTest + { + [Fact] + public async Task CreatesEvent() + { + var input = new ParsedIncident(Incident, "somePath", ComponentStatus.Up); + + EventEntity entity = null; + Table + .Setup(x => x.InsertOrReplaceAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Callback(e => + { + Assert.IsType(e); + entity = e as EventEntity; + }); + + var aggregationPath = "thePath"; + Provider + .Setup(x => x.Get(input)) + .Returns(aggregationPath); + + var result = await Factory.CreateAsync(input); + + Assert.Equal(entity, result); + Assert.Equal(aggregationPath, entity.AffectedComponentPath); + Assert.Equal((int)ComponentStatus.Up, entity.AffectedComponentStatus); + Assert.Equal(input.StartTime, entity.StartTime); + + Table + .Verify( + x => x.InsertOrReplaceAsync(It.IsAny()), + Times.Once()); + } + } + + public class EventFactoryTest + { + public Incident Incident = new Incident() { + Source = new IncidentSourceData() { + CreateDate = new DateTime(2018, 9, 13) } }; + + public Mock Table { get; } + public Mock> Provider { get; } + public EventFactory Factory { get; } + + public EventFactoryTest() + { + Table = new Mock(); + + Provider = new Mock>(); + + Factory = new EventFactory( + Table.Object, + Provider.Object, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Factory/IncidentAffectedComponentPathProviderTests.cs b/tests/StatusAggregator.Tests/Factory/IncidentAffectedComponentPathProviderTests.cs new file mode 100644 index 000000000..6ed0382dd --- /dev/null +++ b/tests/StatusAggregator.Tests/Factory/IncidentAffectedComponentPathProviderTests.cs @@ -0,0 +1,74 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using StatusAggregator.Factory; +using StatusAggregator.Parse; +using Xunit; + +namespace StatusAggregator.Tests.Factory +{ + public class IncidentAffectedComponentPathProviderTests + { + public class TheGetMethod + : IncidentAffectedComponentPathProviderTest + { + public static IEnumerable GetsAffectedComponentPath_Data + { + get + { + var root = new NuGetServiceComponentFactory().Create(); + return GetAllComponents(root).Select(c => new object[] { c.Path }); + } + } + + private static IEnumerable GetAllComponents(IComponent component) + { + if (component == null) + { + yield break; + } + + yield return component; + + foreach (var subcomponent in component.SubComponents.SelectMany(s => GetAllComponents(s))) + { + yield return subcomponent; + } + } + + [Theory] + [MemberData(nameof(GetsAffectedComponentPath_Data))] + public void GetsAffectedComponentPath(string path) + { + var incident = new Incident() + { + Source = new IncidentSourceData() + { + CreateDate = new DateTime(2018, 10, 10) + } + }; + + var input = new ParsedIncident(incident, path, ComponentStatus.Up); + + var result = Provider.Get(input); + + Assert.Equal(path, result); + } + } + + public class IncidentAffectedComponentPathProviderTest + { + public IncidentAffectedComponentPathProvider Provider { get; } + + public IncidentAffectedComponentPathProviderTest() + { + Provider = new IncidentAffectedComponentPathProvider(); + } + } + } +} diff --git a/tests/StatusAggregator.Tests/Factory/IncidentFactoryTests.cs b/tests/StatusAggregator.Tests/Factory/IncidentFactoryTests.cs new file mode 100644 index 000000000..d474dcb17 --- /dev/null +++ b/tests/StatusAggregator.Tests/Factory/IncidentFactoryTests.cs @@ -0,0 +1,170 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.WindowsAzure.Storage.Table; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Factory; +using StatusAggregator.Parse; +using StatusAggregator.Table; +using Xunit; + +namespace StatusAggregator.Tests.Factory +{ + public class IncidentFactoryTests + { + public class TheCreateAsyncMethod : IncidentFactoryTest + { + [Fact] + public async Task CreatesEntityAndIncreasesSeverity() + { + var input = new ParsedIncident(Incident, "the path", ComponentStatus.Down); + + IncidentEntity entity = null; + Table + .Setup(x => x.InsertOrReplaceAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Callback(e => + { + Assert.IsType(e); + entity = e as IncidentEntity; + }); + + var group = new IncidentGroupEntity + { + RowKey = "parentRowKey", + AffectedComponentStatus = (int)ComponentStatus.Degraded + }; + + Provider + .Setup(x => x.GetAsync(input)) + .ReturnsAsync(group); + + var expectedPath = "the provided path"; + PathProvider + .Setup(x => x.Get(input)) + .Returns(expectedPath); + + var result = await Factory.CreateAsync(input); + + Assert.Equal(entity, result); + + Assert.Equal(input.Id, entity.IncidentApiId); + Assert.Equal(group.RowKey, entity.ParentRowKey); + Assert.Equal(expectedPath, entity.AffectedComponentPath); + Assert.Equal((int)input.AffectedComponentStatus, entity.AffectedComponentStatus); + Assert.Equal(input.StartTime, entity.StartTime); + Assert.Equal(input.EndTime, entity.EndTime); + Assert.Equal((int)input.AffectedComponentStatus, group.AffectedComponentStatus); + + Table + .Verify( + x => x.InsertOrReplaceAsync(It.IsAny()), + Times.Once()); + + Table + .Verify( + x => x.ReplaceAsync(It.IsAny()), + Times.Once()); + } + + [Theory] + [InlineData(ComponentStatus.Degraded)] + [InlineData(ComponentStatus.Down)] + public async Task CreatesEntityAndDoesNotIncreaseSeverity(ComponentStatus existingStatus) + { + var input = new ParsedIncident(Incident, "the path", ComponentStatus.Degraded); + + IncidentEntity entity = null; + Table + .Setup(x => x.InsertOrReplaceAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Callback(e => + { + Assert.IsType(e); + entity = e as IncidentEntity; + }); + + var group = new IncidentGroupEntity + { + RowKey = "parentRowKey", + AffectedComponentStatus = (int)existingStatus + }; + + Provider + .Setup(x => x.GetAsync(input)) + .ReturnsAsync(group); + + var expectedPath = "the provided path"; + PathProvider + .Setup(x => x.Get(input)) + .Returns(expectedPath); + + var result = await Factory.CreateAsync(input); + + Assert.Equal(entity, result); + + Assert.Equal(input.Id, entity.IncidentApiId); + Assert.Equal(group.RowKey, entity.ParentRowKey); + Assert.Equal(expectedPath, entity.AffectedComponentPath); + Assert.Equal((int)input.AffectedComponentStatus, entity.AffectedComponentStatus); + Assert.Equal(input.StartTime, entity.StartTime); + Assert.Equal(input.EndTime, entity.EndTime); + Assert.Equal((int)existingStatus, group.AffectedComponentStatus); + + Table + .Verify( + x => x.InsertOrReplaceAsync(It.IsAny()), + Times.Once()); + + Table + .Verify( + x => x.ReplaceAsync(It.IsAny()), + Times.Never()); + } + } + + public class IncidentFactoryTest + { + public Incident Incident = new Incident() + { + Id = "some ID", + + Source = new IncidentSourceData + { + CreateDate = new DateTime(2018, 9, 13) + }, + + MitigationData = new IncidentStateChangeEventData + { + Date = new DateTime(2018, 9, 14) + } + }; + + public Mock Table { get; } + public Mock> Provider { get; } + public Mock> PathProvider { get; } + public IncidentFactory Factory { get; } + + public IncidentFactoryTest() + { + Table = new Mock(); + + Provider = new Mock>(); + + PathProvider = new Mock>(); + + Factory = new IncidentFactory( + Table.Object, + Provider.Object, + PathProvider.Object, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Factory/IncidentGroupFactoryTests.cs b/tests/StatusAggregator.Tests/Factory/IncidentGroupFactoryTests.cs new file mode 100644 index 000000000..459d0bb25 --- /dev/null +++ b/tests/StatusAggregator.Tests/Factory/IncidentGroupFactoryTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.WindowsAzure.Storage.Table; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Factory; +using StatusAggregator.Parse; +using StatusAggregator.Table; +using Xunit; + +namespace StatusAggregator.Tests.Factory +{ + public class IncidentGroupFactoryTests + { + public class TheCreateAsyncMethod : IncidentGroupFactoryTest + { + [Fact] + public async Task CreatesEntity() + { + var input = new ParsedIncident(Incident, "the path", ComponentStatus.Degraded); + + IncidentGroupEntity entity = null; + Table + .Setup(x => x.InsertOrReplaceAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Callback(e => + { + Assert.IsType(e); + entity = e as IncidentGroupEntity; + }); + + var expectedPath = "the provided path"; + PathProvider + .Setup(x => x.Get(input)) + .Returns(expectedPath); + + var eventEntity = new EventEntity + { + RowKey = "parentRowKey" + }; + + Provider + .Setup(x => x.GetAsync(input)) + .ReturnsAsync(eventEntity); + + var result = await Factory.CreateAsync(input); + + Assert.Equal(entity, result); + + Assert.Equal(eventEntity.RowKey, entity.ParentRowKey); + Assert.Equal(expectedPath, entity.AffectedComponentPath); + Assert.Equal((int)input.AffectedComponentStatus, entity.AffectedComponentStatus); + Assert.Equal(input.StartTime, entity.StartTime); + Assert.Null(entity.EndTime); + + Table + .Verify( + x => x.InsertOrReplaceAsync(It.IsAny()), + Times.Once()); + } + } + + public class IncidentGroupFactoryTest + { + public Incident Incident = new Incident() + { + Id = "some ID", + + Source = new IncidentSourceData + { + CreateDate = new DateTime(2018, 9, 13) + }, + + MitigationData = new IncidentStateChangeEventData + { + Date = new DateTime(2018, 9, 14) + } + }; + + public Mock Table { get; } + public Mock> Provider { get; } + public Mock> PathProvider { get; } + public IncidentGroupFactory Factory { get; } + + public IncidentGroupFactoryTest() + { + Table = new Mock(); + + Provider = new Mock>(); + + PathProvider = new Mock>(); + + Factory = new IncidentGroupFactory( + Table.Object, + Provider.Object, + PathProvider.Object, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Factory/NuGetServiceComponentFactoryTests.cs b/tests/StatusAggregator.Tests/Factory/NuGetServiceComponentFactoryTests.cs new file mode 100644 index 000000000..e8a0df52c --- /dev/null +++ b/tests/StatusAggregator.Tests/Factory/NuGetServiceComponentFactoryTests.cs @@ -0,0 +1,127 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using NuGet.Services.Status; +using StatusAggregator.Factory; +using Xunit; + +namespace StatusAggregator.Tests.Factory +{ + public class NuGetServiceComponentFactoryTests + { + public class TheCreateMethod : NuGetServiceComponentFactoryTest + { + /// + /// This test guarantees the shape of the NuGet service's component does not change. + /// + [Fact] + public void ContainsExpectedSchema() + { + AssertConstants(); + + var root = Factory.Create(); + + Assert.Equal(NuGetServiceComponentFactory.RootName, root.Name); + Assert.Equal(4, root.SubComponents.Count()); + + AssertGallery(root); + AssertRestore(root); + AssertSearch(root); + AssertUpload(root); + } + + private void AssertConstants() + { + Assert.Equal("NuGet", NuGetServiceComponentFactory.RootName); + Assert.Equal("NuGet.org", NuGetServiceComponentFactory.GalleryName); + Assert.Equal("Restore", NuGetServiceComponentFactory.RestoreName); + Assert.Equal("Search", NuGetServiceComponentFactory.SearchName); + Assert.Equal("Package Publishing", NuGetServiceComponentFactory.UploadName); + + Assert.Equal("V2 Protocol", NuGetServiceComponentFactory.V2ProtocolName); + Assert.Equal("V3 Protocol", NuGetServiceComponentFactory.V3ProtocolName); + + Assert.Equal("Global", NuGetServiceComponentFactory.GlobalRegionName); + Assert.Equal("China", NuGetServiceComponentFactory.ChinaRegionName); + + Assert.Equal("North Central US", NuGetServiceComponentFactory.UsncInstanceName); + Assert.Equal("South Central US", NuGetServiceComponentFactory.UsscInstanceName); + Assert.Equal("East Asia", NuGetServiceComponentFactory.EaInstanceName); + Assert.Equal("Southeast Asia", NuGetServiceComponentFactory.SeaInstanceName); + } + + private void AssertGallery(IComponent root) + { + var gallery = GetSubComponent(root, NuGetServiceComponentFactory.GalleryName); + Assert.Equal(2, gallery.SubComponents.Count()); + + var galleryUsnc = GetSubComponent(gallery, NuGetServiceComponentFactory.UsncInstanceName); + Assert.Empty(galleryUsnc.SubComponents); + var galleryUssc = GetSubComponent(gallery, NuGetServiceComponentFactory.UsscInstanceName); + Assert.Empty(galleryUssc.SubComponents); + } + + private void AssertRestore(IComponent root) + { + var restore = GetSubComponent(root, NuGetServiceComponentFactory.RestoreName); + Assert.Equal(2, restore.SubComponents.Count()); + + var restoreV2 = GetSubComponent(restore, NuGetServiceComponentFactory.V2ProtocolName); + Assert.Equal(2, restoreV2.SubComponents.Count()); + var restoreV2Usnc = GetSubComponent(restoreV2, NuGetServiceComponentFactory.UsncInstanceName); + Assert.Empty(restoreV2Usnc.SubComponents); + var restoreV2Ussc = GetSubComponent(restoreV2, NuGetServiceComponentFactory.UsscInstanceName); + Assert.Empty(restoreV2Ussc.SubComponents); + + var restoreV3 = GetSubComponent(restore, NuGetServiceComponentFactory.V3ProtocolName); + Assert.Equal(2, restoreV3.SubComponents.Count()); + var restoreV3Global = GetSubComponent(restoreV3, NuGetServiceComponentFactory.GlobalRegionName); + Assert.Empty(restoreV3Global.SubComponents); + var restoreV3China = GetSubComponent(restoreV3, NuGetServiceComponentFactory.ChinaRegionName); + Assert.Empty(restoreV3China.SubComponents); + } + + private void AssertSearch(IComponent root) + { + var search = GetSubComponent(root, NuGetServiceComponentFactory.SearchName); + Assert.Equal(2, search.SubComponents.Count()); + + var searchGlobal = GetSubComponent(search, NuGetServiceComponentFactory.GlobalRegionName); + Assert.Equal(2, searchGlobal.SubComponents.Count()); + var searchGlobalUsnc = GetSubComponent(searchGlobal, NuGetServiceComponentFactory.UsncInstanceName); + Assert.Empty(searchGlobalUsnc.SubComponents); + var searchGlobalUssc = GetSubComponent(searchGlobal, NuGetServiceComponentFactory.UsscInstanceName); + Assert.Empty(searchGlobalUssc.SubComponents); + + var searchChina = GetSubComponent(search, NuGetServiceComponentFactory.ChinaRegionName); + Assert.Equal(2, searchChina.SubComponents.Count()); + var searchChinaEA = GetSubComponent(searchChina, NuGetServiceComponentFactory.EaInstanceName); + Assert.Empty(searchChinaEA.SubComponents); + var searchChinaSea = GetSubComponent(searchChina, NuGetServiceComponentFactory.SeaInstanceName); + Assert.Empty(searchChinaSea.SubComponents); + } + + private void AssertUpload(IComponent root) + { + var upload = GetSubComponent(root, NuGetServiceComponentFactory.UploadName); + Assert.Empty(upload.SubComponents); + } + + private IComponent GetSubComponent(IComponent component, string name) + { + return component.GetByNames(component.Name, name); + } + } + + public class NuGetServiceComponentFactoryTest + { + public NuGetServiceComponentFactory Factory { get; } + + public NuGetServiceComponentFactoryTest() + { + Factory = new NuGetServiceComponentFactory(); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/IncidentFactoryTests.cs b/tests/StatusAggregator.Tests/IncidentFactoryTests.cs deleted file mode 100644 index b5fd3e3af..000000000 --- a/tests/StatusAggregator.Tests/IncidentFactoryTests.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.WindowsAzure.Storage.Table; -using Moq; -using NuGet.Services.Incidents; -using NuGet.Services.Status; -using NuGet.Services.Status.Table; -using StatusAggregator.Parse; -using StatusAggregator.Table; -using Xunit; - -namespace StatusAggregator.Tests -{ - public class IncidentFactoryTests - { - private const string Id = "id"; - private const string AffectedComponentPath = "path"; - private const ComponentStatus AffectedComponentStatus = ComponentStatus.Degraded; - private static DateTime CreationTime = new DateTime(2017, 7, 10); - - private Mock _tableWrapperMock { get; } - private Mock _eventUpdaterMock { get; } - private IncidentFactory _incidentFactory { get; } - private ParsedIncident _parsedIncident { get; } - - public IncidentFactoryTests() - { - _tableWrapperMock = new Mock(); - _eventUpdaterMock = new Mock(); - _incidentFactory = new IncidentFactory( - _tableWrapperMock.Object, - _eventUpdaterMock.Object, - Mock.Of>()); - - var incident = new Incident() { Id = Id, Source = new IncidentSourceData() { CreateDate = CreationTime } }; - _parsedIncident = new ParsedIncident(incident, AffectedComponentPath, AffectedComponentStatus); - } - - [Fact] - public async Task CreatesNewEventIfNoPossibleEvents() - { - _tableWrapperMock - .Setup(x => x.CreateQuery()) - .Returns(new EventEntity[0].AsQueryable()); - - _tableWrapperMock - .Setup(x => x.InsertOrReplaceAsync(It.IsAny())) - .Returns(Task.CompletedTask); - - EventEntity eventEntity = null; - _tableWrapperMock - .Setup(x => x.InsertOrReplaceAsync(It.Is(e => e is EventEntity))) - .Callback(e => { eventEntity = e as EventEntity; }) - .Returns(Task.CompletedTask); - - var incidentEntity = await _incidentFactory.CreateIncident(_parsedIncident); - - Assert.Equal(Id, incidentEntity.IncidentApiId); - Assert.Equal(CreationTime, incidentEntity.CreationTime); - Assert.Equal(AffectedComponentPath, incidentEntity.AffectedComponentPath); - Assert.Equal((int)AffectedComponentStatus, incidentEntity.AffectedComponentStatus); - Assert.NotNull(eventEntity); - Assert.Equal(eventEntity.RowKey, incidentEntity.EventRowKey); - Assert.Equal(IncidentEntity.DefaultPartitionKey, incidentEntity.PartitionKey); - Assert.True(incidentEntity.IsLinkedToEvent); - Assert.True(incidentEntity.IsActive); - - _tableWrapperMock.Verify( - x => x.InsertOrReplaceAsync(incidentEntity), - Times.Once()); - - _tableWrapperMock.Verify( - x => x.InsertOrReplaceAsync(eventEntity), - Times.Once()); - - _tableWrapperMock.Verify( - x => x.InsertOrReplaceAsync(It.IsAny()), - Times.Exactly(2)); - } - } -} diff --git a/tests/StatusAggregator.Tests/Manual/AddStatusEventManualChangeHandlerFacts.cs b/tests/StatusAggregator.Tests/Manual/AddStatusEventManualChangeHandlerTests.cs similarity index 93% rename from tests/StatusAggregator.Tests/Manual/AddStatusEventManualChangeHandlerFacts.cs rename to tests/StatusAggregator.Tests/Manual/AddStatusEventManualChangeHandlerTests.cs index fcb6578c5..c676d5756 100644 --- a/tests/StatusAggregator.Tests/Manual/AddStatusEventManualChangeHandlerFacts.cs +++ b/tests/StatusAggregator.Tests/Manual/AddStatusEventManualChangeHandlerTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Moq; @@ -13,7 +13,7 @@ namespace StatusAggregator.Tests.Manual { - public class AddStatusEventManualChangeHandlerFacts + public class AddStatusEventManualChangeHandlerTests { public class TheHandleMethod { @@ -43,7 +43,7 @@ public async Task SavesMessageAndEvent(bool eventIsActive) It.Is(messageEntity => messageEntity.PartitionKey == MessageEntity.DefaultPartitionKey && messageEntity.RowKey == MessageEntity.GetRowKey(eventRowKey, time) && - messageEntity.EventRowKey == eventRowKey && + messageEntity.ParentRowKey == eventRowKey && messageEntity.Time == time && messageEntity.Contents == entity.MessageContents ))) @@ -68,4 +68,4 @@ public async Task SavesMessageAndEvent(bool eventIsActive) } } } -} +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Manual/AddStatusMessageManualChangeHandlerFacts.cs b/tests/StatusAggregator.Tests/Manual/AddStatusMessageManualChangeHandlerTests.cs similarity index 88% rename from tests/StatusAggregator.Tests/Manual/AddStatusMessageManualChangeHandlerFacts.cs rename to tests/StatusAggregator.Tests/Manual/AddStatusMessageManualChangeHandlerTests.cs index f830281d2..9c21b10c6 100644 --- a/tests/StatusAggregator.Tests/Manual/AddStatusMessageManualChangeHandlerFacts.cs +++ b/tests/StatusAggregator.Tests/Manual/AddStatusMessageManualChangeHandlerTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Moq; @@ -14,7 +14,7 @@ namespace StatusAggregator.Tests.Manual { - public class AddStatusMessageManualChangeHandlerFacts + public class AddStatusMessageManualChangeHandlerTests { public class TheHandleMethod { @@ -39,7 +39,7 @@ public async Task DoesNotSaveIfEventIsMissing() var eventRowKey = EventEntity.GetRowKey(entity.EventAffectedComponentPath, entity.EventStartTime); _table - .Setup(x => x.RetrieveAsync(EventEntity.DefaultPartitionKey, eventRowKey)) + .Setup(x => x.RetrieveAsync(eventRowKey)) .Returns(Task.FromResult(null)); _table @@ -74,23 +74,23 @@ public static IEnumerable SavesNewMessageAndUpdatesEventIfNecessary_Da [MemberData(nameof(SavesNewMessageAndUpdatesEventIfNecessary_Data))] public async Task SavesNewMessageAndUpdatesEventIfNecessary(bool eventIsActive, bool shouldEventBeActive) { - var entity = new AddStatusMessageManualChangeEntity("path", new DateTime(2018, 8, 21), "message", shouldEventBeActive) + var eventStartTime = new DateTime(2018, 8, 19); + var entity = new AddStatusMessageManualChangeEntity("path", eventStartTime, "message", shouldEventBeActive) { Timestamp = new DateTimeOffset(2018, 8, 21, 0, 0, 0, TimeSpan.Zero) }; var time = entity.Timestamp.UtcDateTime; - var eventRowKey = EventEntity.GetRowKey(entity.EventAffectedComponentPath, entity.EventStartTime); - var existingEntity = new EventEntity( entity.EventAffectedComponentPath, - (int)ComponentStatus.Up, - new DateTime(2018, 8, 19), + eventStartTime, + ComponentStatus.Up, eventIsActive ? (DateTime?)null : new DateTime(2018, 8, 20)); + var eventRowKey = existingEntity.RowKey; _table - .Setup(x => x.RetrieveAsync(EventEntity.DefaultPartitionKey, eventRowKey)) + .Setup(x => x.RetrieveAsync(eventRowKey)) .Returns(Task.FromResult(existingEntity)); _table @@ -109,7 +109,7 @@ public async Task SavesNewMessageAndUpdatesEventIfNecessary(bool eventIsActive, It.Is(message => message.PartitionKey == MessageEntity.DefaultPartitionKey && message.RowKey == MessageEntity.GetRowKey(eventRowKey, time) && - message.EventRowKey == eventRowKey && + message.ParentRowKey == eventRowKey && message.Time == time && message.Contents == entity.MessageContents )), @@ -131,4 +131,4 @@ public async Task SavesNewMessageAndUpdatesEventIfNecessary(bool eventIsActive, } } } -} +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Manual/DeleteStatusEventManualChangeHandlerFacts.cs b/tests/StatusAggregator.Tests/Manual/DeleteStatusEventManualChangeHandlerTests.cs similarity index 91% rename from tests/StatusAggregator.Tests/Manual/DeleteStatusEventManualChangeHandlerFacts.cs rename to tests/StatusAggregator.Tests/Manual/DeleteStatusEventManualChangeHandlerTests.cs index 12619edf3..8a561b7ec 100644 --- a/tests/StatusAggregator.Tests/Manual/DeleteStatusEventManualChangeHandlerFacts.cs +++ b/tests/StatusAggregator.Tests/Manual/DeleteStatusEventManualChangeHandlerTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Moq; @@ -12,7 +12,7 @@ namespace StatusAggregator.Tests.Manual { - public class DeleteStatusEventManualChangeHandlerFacts + public class DeleteStatusEventManualChangeHandlerTests { public class TheHandleMethod { @@ -43,4 +43,4 @@ public async Task DeletesEvent() } } } -} +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Manual/DeleteStatusMessageManualChangeHandlerFacts.cs b/tests/StatusAggregator.Tests/Manual/DeleteStatusMessageManualChangeHandlerFacts.cs deleted file mode 100644 index 9dc936d5f..000000000 --- a/tests/StatusAggregator.Tests/Manual/DeleteStatusMessageManualChangeHandlerFacts.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using Moq; -using NuGet.Services.Status.Table; -using NuGet.Services.Status.Table.Manual; -using StatusAggregator.Manual; -using StatusAggregator.Table; -using System; -using System.Threading.Tasks; -using Xunit; - -namespace StatusAggregator.Tests.Manual -{ - public class DeleteStatusMessageManualChangeHandlerFacts - { - public class TheHandleMethod - { - private Mock _table; - private DeleteStatusMessageManualChangeHandler _handler; - - public TheHandleMethod() - { - _table = new Mock(); - _handler = new DeleteStatusMessageManualChangeHandler(_table.Object); - } - - [Fact] - public async Task DeletesEvent() - { - var entity = new DeleteStatusMessageManualChangeEntity("path", new DateTime(2018, 8, 20), new DateTime(2018, 8, 21)); - - var eventRowKey = EventEntity.GetRowKey(entity.EventAffectedComponentPath, entity.EventStartTime); - var messageRowKey = MessageEntity.GetRowKey(eventRowKey, entity.MessageTimestamp); - - _table - .Setup(x => x.DeleteAsync(MessageEntity.DefaultPartitionKey, messageRowKey)) - .Returns(Task.CompletedTask) - .Verifiable(); - - await _handler.Handle(entity); - - _table.Verify(); - } - } - } -} diff --git a/tests/StatusAggregator.Tests/Manual/DeleteStatusMessageManualChangeHandlerTests.cs b/tests/StatusAggregator.Tests/Manual/DeleteStatusMessageManualChangeHandlerTests.cs new file mode 100644 index 000000000..be22dcebf --- /dev/null +++ b/tests/StatusAggregator.Tests/Manual/DeleteStatusMessageManualChangeHandlerTests.cs @@ -0,0 +1,82 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Moq; +using NuGet.Services.Status.Table; +using NuGet.Services.Status.Table.Manual; +using StatusAggregator.Manual; +using StatusAggregator.Table; +using System; +using System.Threading.Tasks; +using Xunit; + +namespace StatusAggregator.Tests.Manual +{ + public class DeleteStatusMessageManualChangeHandlerTests + { + public class TheHandleMethod + { + private Mock _table; + private DeleteStatusMessageManualChangeHandler _handler; + + public TheHandleMethod() + { + _table = new Mock(); + _handler = new DeleteStatusMessageManualChangeHandler(_table.Object); + } + + [Fact] + public async Task FailsIfMessageDoesNotExist() + { + var entity = new DeleteStatusMessageManualChangeEntity("path", new DateTime(2018, 8, 20), new DateTime(2018, 8, 21)); + _table + .Setup(x => x.RetrieveAsync(It.IsAny())) + .ReturnsAsync((MessageEntity)null); + + await Assert.ThrowsAsync(() => _handler.Handle(entity)); + } + + [Theory] + [InlineData(MessageType.Manual)] + [InlineData(MessageType.Start)] + [InlineData(MessageType.End)] + public async Task DeletesEvent(MessageType type) + { + var entity = new DeleteStatusMessageManualChangeEntity("path", new DateTime(2018, 8, 20), new DateTime(2018, 8, 21)); + + var eventEntity = new EventEntity( + entity.EventAffectedComponentPath, + entity.EventStartTime); + + var eventRowKey = eventEntity.RowKey; + + var messageEntity = new MessageEntity( + eventEntity, + entity.MessageTimestamp, + "someContents", + type); + + var messageRowKey = messageEntity.RowKey; + + _table + .Setup(x => x.RetrieveAsync(messageRowKey)) + .ReturnsAsync(messageEntity); + + _table + .Setup(x => x.ReplaceAsync(It.Is( + message => + message.PartitionKey == MessageEntity.DefaultPartitionKey && + message.RowKey == messageRowKey && + message.ParentRowKey == eventRowKey && + message.Time == messageEntity.Time && + message.Contents == string.Empty && + message.Type == (int)MessageType.Manual))) + .Returns(Task.CompletedTask); + + await _handler.Handle(entity); + + _table.Verify(); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Manual/EditStatusEventManualChangeHandlerFacts.cs b/tests/StatusAggregator.Tests/Manual/EditStatusEventManualChangeHandlerTests.cs similarity index 92% rename from tests/StatusAggregator.Tests/Manual/EditStatusEventManualChangeHandlerFacts.cs rename to tests/StatusAggregator.Tests/Manual/EditStatusEventManualChangeHandlerTests.cs index 4cc65b458..736a0104d 100644 --- a/tests/StatusAggregator.Tests/Manual/EditStatusEventManualChangeHandlerFacts.cs +++ b/tests/StatusAggregator.Tests/Manual/EditStatusEventManualChangeHandlerTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Moq; @@ -14,7 +14,7 @@ namespace StatusAggregator.Tests.Manual { - public class EditStatusEventManualChangeHandlerFacts + public class EditStatusEventManualChangeHandlerTests { public class TheHandleMethod { @@ -39,7 +39,7 @@ public async Task ThrowsArgumentExceptionIfMissingEvent() var eventRowKey = EventEntity.GetRowKey(entity.EventAffectedComponentPath, entity.EventStartTime); _table - .Setup(x => x.RetrieveAsync(EventEntity.DefaultPartitionKey, eventRowKey)) + .Setup(x => x.RetrieveAsync(eventRowKey)) .Returns(Task.FromResult(null)); await Assert.ThrowsAsync(() => _handler.Handle(entity)); @@ -74,12 +74,12 @@ public async Task EditsEvent(bool eventIsActive, bool shouldEventBeActive) var existingEntity = new EventEntity( entity.EventAffectedComponentPath, - ComponentStatus.Up, entity.EventStartTime, + ComponentStatus.Up, eventIsActive ? (DateTime?)null : new DateTime(2018, 8, 19)); _table - .Setup(x => x.RetrieveAsync(EventEntity.DefaultPartitionKey, eventRowKey)) + .Setup(x => x.RetrieveAsync(eventRowKey)) .Returns(Task.FromResult(existingEntity)); var shouldUpdateEndTime = ManualStatusChangeUtility.ShouldEventBeActive(existingEntity, shouldEventBeActive, time); @@ -103,4 +103,4 @@ public async Task EditsEvent(bool eventIsActive, bool shouldEventBeActive) } } } -} +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Manual/EditStatusMessageManualChangeHandlerFacts.cs b/tests/StatusAggregator.Tests/Manual/EditStatusMessageManualChangeHandlerTests.cs similarity index 77% rename from tests/StatusAggregator.Tests/Manual/EditStatusMessageManualChangeHandlerFacts.cs rename to tests/StatusAggregator.Tests/Manual/EditStatusMessageManualChangeHandlerTests.cs index 8a041ca53..face5091b 100644 --- a/tests/StatusAggregator.Tests/Manual/EditStatusMessageManualChangeHandlerFacts.cs +++ b/tests/StatusAggregator.Tests/Manual/EditStatusMessageManualChangeHandlerTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Moq; @@ -8,13 +8,12 @@ using StatusAggregator.Manual; using StatusAggregator.Table; using System; -using System.Collections.Generic; using System.Threading.Tasks; using Xunit; namespace StatusAggregator.Tests.Manual { - public class EditStatusMessageManualChangeHandlerFacts + public class EditStatusMessageManualChangeHandlerTests { public class TheHandleMethod { @@ -35,7 +34,7 @@ public async Task ThrowsArgumentExceptionIfMissingEvent() var eventRowKey = EventEntity.GetRowKey(entity.EventAffectedComponentPath, entity.EventStartTime); _table - .Setup(x => x.RetrieveAsync(EventEntity.DefaultPartitionKey, eventRowKey)) + .Setup(x => x.RetrieveAsync(eventRowKey)) .Returns(Task.FromResult(null)); await Assert.ThrowsAsync(() => _handler.Handle(entity)); @@ -52,23 +51,26 @@ public async Task ThrowsArgumentExceptionIfMissingMessage() var existingEntity = new EventEntity( entity.EventAffectedComponentPath, - ComponentStatus.Up, entity.EventStartTime, + ComponentStatus.Up, null); _table - .Setup(x => x.RetrieveAsync(EventEntity.DefaultPartitionKey, eventRowKey)) + .Setup(x => x.RetrieveAsync(eventRowKey)) .Returns(Task.FromResult(existingEntity)); _table - .Setup(x => x.RetrieveAsync(MessageEntity.DefaultPartitionKey, messageRowKey)) + .Setup(x => x.RetrieveAsync(messageRowKey)) .Returns(Task.FromResult(null)); await Assert.ThrowsAsync(() => _handler.Handle(entity)); } - [Fact] - public async Task EditsMessage() + [Theory] + [InlineData(MessageType.Manual)] + [InlineData(MessageType.Start)] + [InlineData(MessageType.End)] + public async Task EditsMessage(MessageType type) { var entity = new EditStatusMessageManualChangeEntity("path", new DateTime(2018, 8, 20), new DateTime(2018, 8, 21), "message"); @@ -78,18 +80,22 @@ public async Task EditsMessage() var existingEntity = new EventEntity( entity.EventAffectedComponentPath, - ComponentStatus.Up, entity.EventStartTime, + ComponentStatus.Up, null); _table - .Setup(x => x.RetrieveAsync(EventEntity.DefaultPartitionKey, eventRowKey)) + .Setup(x => x.RetrieveAsync(eventRowKey)) .Returns(Task.FromResult(existingEntity)); - var existingMessage = new MessageEntity(eventRowKey, entity.MessageTimestamp, "old message"); + var existingMessage = new MessageEntity( + existingEntity, + entity.MessageTimestamp, + "old message", + type); _table - .Setup(x => x.RetrieveAsync(MessageEntity.DefaultPartitionKey, messageRowKey)) + .Setup(x => x.RetrieveAsync(messageRowKey)) .Returns(Task.FromResult(existingMessage)); _table @@ -97,9 +103,10 @@ public async Task EditsMessage() It.Is(messageEntity => messageEntity.PartitionKey == MessageEntity.DefaultPartitionKey && messageEntity.RowKey == messageRowKey && - messageEntity.EventRowKey == eventRowKey && + messageEntity.ParentRowKey == eventRowKey && messageEntity.Time == existingMessage.Time && - messageEntity.Contents == entity.MessageContents + messageEntity.Contents == entity.MessageContents && + messageEntity.Type == (int)MessageType.Manual ))) .Returns(Task.CompletedTask) .Verifiable(); @@ -110,4 +117,4 @@ public async Task EditsMessage() } } } -} +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Manual/ManualStatusChangeUtilityFacts.cs b/tests/StatusAggregator.Tests/Manual/ManualStatusChangeUtilityTests.cs similarity index 88% rename from tests/StatusAggregator.Tests/Manual/ManualStatusChangeUtilityFacts.cs rename to tests/StatusAggregator.Tests/Manual/ManualStatusChangeUtilityTests.cs index 30bd472ca..d6170182b 100644 --- a/tests/StatusAggregator.Tests/Manual/ManualStatusChangeUtilityFacts.cs +++ b/tests/StatusAggregator.Tests/Manual/ManualStatusChangeUtilityTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using NuGet.Services.Status.Table; @@ -8,7 +8,7 @@ namespace StatusAggregator.Tests.Manual { - public class ManualStatusChangeUtilityFacts + public class ManualStatusChangeUtilityTests { public abstract class BaseMethod { @@ -25,7 +25,7 @@ public void ThrowsIfEventIsNull() [Fact] public void ActiveEventStaysActive() { - var eventEntity = new EventEntity("", 0, new DateTime(2018, 8, 17)); + var eventEntity = new EventEntity("", new DateTime(2018, 8, 17)); var result = GetResult(eventEntity, true, new DateTime(2018, 8, 18)); @@ -38,7 +38,7 @@ public void ActiveEventBecomesInactive() { DateTime? initialEndTime = null; var deactivatedTime = new DateTime(2018, 8, 18); - var eventEntity = new EventEntity("", 0, new DateTime(2018, 8, 17), initialEndTime); + var eventEntity = new EventEntity("", new DateTime(2018, 8, 17), endTime: initialEndTime); var result = GetResult(eventEntity, false, deactivatedTime); @@ -52,7 +52,7 @@ public void ActiveEventBecomesInactive() public void DeactivatedEventIsUnaffected(bool eventIsActive) { var deactivatedTime = new DateTime(2018, 8, 18); - var eventEntity = new EventEntity("", 0, new DateTime(2018, 8, 17), deactivatedTime); + var eventEntity = new EventEntity("", new DateTime(2018, 8, 17), endTime: deactivatedTime); var result = GetResult(eventEntity, eventIsActive, new DateTime(2018, 8, 19)); @@ -87,4 +87,4 @@ protected override void AssertChangedTo(EventEntity eventEntity, DateTime? initi } } } -} +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Messages/IncidentGroupMessageFilterTests.cs b/tests/StatusAggregator.Tests/Messages/IncidentGroupMessageFilterTests.cs new file mode 100644 index 000000000..d44ad40b0 --- /dev/null +++ b/tests/StatusAggregator.Tests/Messages/IncidentGroupMessageFilterTests.cs @@ -0,0 +1,183 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status.Table; +using StatusAggregator.Messages; +using StatusAggregator.Table; +using StatusAggregator.Tests.TestUtility; +using Xunit; + +namespace StatusAggregator.Tests.Messages +{ + public class IncidentGroupMessageFilterTests + { + public class TheCanPostMessagesMethod : IncidentGroupMessageFilterTest + { + public static IEnumerable ReturnsFalseIfDurationOfGroupTooShort_Data + { + get + { + var activeGroup = new IncidentGroupEntity + { + StartTime = Cursor - StartMessageDelay + TimeSpan.FromTicks(1) + }; + + yield return new object[] { activeGroup }; + + var inactiveGroup = new IncidentGroupEntity + { + StartTime = Cursor - StartMessageDelay + TimeSpan.FromTicks(1), + EndTime = Cursor + }; + + yield return new object[] { inactiveGroup }; + } + } + + [Theory] + [MemberData(nameof(ReturnsFalseIfDurationOfGroupTooShort_Data))] + public void ReturnsFalseIfDurationOfGroupTooShort(IncidentGroupEntity group) + { + var result = Filter.CanPostMessages(group, Cursor); + + Assert.False(result); + + Table + .Verify( + x => x.CreateQuery(), + Times.Never()); + } + + [Fact] + public void ReturnsFalseIfNoMatchingIncidents() + { + var parentRowKey = "parentRowKey"; + var group = new IncidentGroupEntity + { + StartTime = Cursor - StartMessageDelay, + RowKey = parentRowKey + }; + + var unlinkedIncident = new IncidentEntity + { + StartTime = Cursor, + ParentRowKey = "something else" + }; + + var shortIncident = new IncidentEntity + { + StartTime = Cursor - StartMessageDelay, + EndTime = Cursor - TimeSpan.FromTicks(1), + ParentRowKey = parentRowKey + }; + + Table.SetupQuery(unlinkedIncident, shortIncident); + + var result = Filter.CanPostMessages(group, Cursor); + + Assert.False(result); + } + + [Fact] + public void ReturnsTrueIfActiveIncident() + { + var parentRowKey = "parentRowKey"; + var group = new IncidentGroupEntity + { + StartTime = Cursor - StartMessageDelay, + RowKey = parentRowKey + }; + + var unlinkedIncident = new IncidentEntity + { + StartTime = Cursor, + ParentRowKey = "something else" + }; + + var shortIncident = new IncidentEntity + { + StartTime = Cursor - StartMessageDelay, + EndTime = Cursor - TimeSpan.FromTicks(1), + ParentRowKey = parentRowKey + }; + + var activeIncident = new IncidentEntity + { + StartTime = Cursor - StartMessageDelay, + ParentRowKey = parentRowKey + }; + + Table.SetupQuery(unlinkedIncident, shortIncident, activeIncident); + + var result = Filter.CanPostMessages(group, Cursor); + + Assert.True(result); + } + + [Fact] + public void ReturnsTrueIfLongIncident() + { + var parentRowKey = "parentRowKey"; + var group = new IncidentGroupEntity + { + StartTime = Cursor - StartMessageDelay, + RowKey = parentRowKey + }; + + var unlinkedIncident = new IncidentEntity + { + StartTime = Cursor, + ParentRowKey = "something else" + }; + + var shortIncident = new IncidentEntity + { + StartTime = Cursor - StartMessageDelay, + EndTime = Cursor - TimeSpan.FromTicks(1), + ParentRowKey = parentRowKey + }; + + var longIncident = new IncidentEntity + { + StartTime = Cursor - StartMessageDelay, + EndTime = Cursor, + ParentRowKey = parentRowKey + }; + + Table.SetupQuery(unlinkedIncident, shortIncident, longIncident); + + var result = Filter.CanPostMessages(group, Cursor); + + Assert.True(result); + } + } + + public class IncidentGroupMessageFilterTest + { + public static readonly DateTime Cursor = new DateTime(2018, 9, 13); + public static readonly TimeSpan StartMessageDelay = TimeSpan.FromDays(1); + + public Mock Table { get; } + public IncidentGroupMessageFilter Filter { get; } + + public IncidentGroupMessageFilterTest() + { + Table = new Mock(); + + var config = new StatusAggregatorConfiguration + { + EventStartMessageDelayMinutes = (int)StartMessageDelay.TotalMinutes + }; + + Filter = new IncidentGroupMessageFilter( + Table.Object, + config, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Messages/MessageChangeEventIteratorTests.cs b/tests/StatusAggregator.Tests/Messages/MessageChangeEventIteratorTests.cs new file mode 100644 index 000000000..ceca8467c --- /dev/null +++ b/tests/StatusAggregator.Tests/Messages/MessageChangeEventIteratorTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Factory; +using StatusAggregator.Messages; +using Xunit; + +namespace StatusAggregator.Tests.Messages +{ + public class MessageChangeEventIteratorTests + { + public class TheIterateMethod : MessageChangeEventIteratorTest + { + [Fact] + public async Task IteratesChangesInOrder() + { + var eventEntity = new EventEntity(); + + var root = new TestComponent("hi"); + Factory + .Setup(x => x.Create()) + .Returns(root); + + var firstChange = CreateChangeEvent(TimeSpan.Zero); + var secondChange = CreateChangeEvent(TimeSpan.FromDays(1)); + var thirdChange = CreateChangeEvent(TimeSpan.FromDays(2)); + var changes = new[] { thirdChange, firstChange, secondChange }; + + var firstComponent = new TestComponent("first"); + var firstContext = new ExistingStartMessageContext(firstChange.Timestamp, firstComponent, ComponentStatus.Degraded); + Processor + .Setup(x => x.ProcessAsync(firstChange, eventEntity, root, null)) + .ReturnsAsync(firstContext) + .Verifiable(); + + var secondComponent = new TestComponent("second"); + var secondContext = new ExistingStartMessageContext(secondChange.Timestamp, secondComponent, ComponentStatus.Degraded); + Processor + .Setup(x => x.ProcessAsync(secondChange, eventEntity, root, firstContext)) + .ReturnsAsync(secondContext) + .Verifiable(); + + var thirdComponent = new TestComponent("third"); + var thirdContext = new ExistingStartMessageContext(thirdChange.Timestamp, thirdComponent, ComponentStatus.Degraded); + Processor + .Setup(x => x.ProcessAsync(thirdChange, eventEntity, root, secondContext)) + .ReturnsAsync(thirdContext) + .Verifiable(); + + await Iterator.IterateAsync(changes, eventEntity); + + Processor.Verify(); + + Processor + .Verify( + x => x.ProcessAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Exactly(3)); + } + + private MessageChangeEvent CreateChangeEvent(TimeSpan offset) + { + return new MessageChangeEvent( + new DateTime(2018, 9, 14) + offset, + "", + ComponentStatus.Up, + MessageType.Manual); + } + } + + public class MessageChangeEventIteratorTest + { + public Mock Factory { get; } + public Mock Processor { get; } + public MessageChangeEventIterator Iterator { get; } + + public MessageChangeEventIteratorTest() + { + Factory = new Mock(); + + Processor = new Mock(); + + Iterator = new MessageChangeEventIterator( + Factory.Object, + Processor.Object, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Messages/MessageChangeEventProcessorTests.cs b/tests/StatusAggregator.Tests/Messages/MessageChangeEventProcessorTests.cs new file mode 100644 index 000000000..68d6d985a --- /dev/null +++ b/tests/StatusAggregator.Tests/Messages/MessageChangeEventProcessorTests.cs @@ -0,0 +1,437 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Messages; +using Xunit; + +namespace StatusAggregator.Tests.Messages +{ + public class MessageChangeEventProcessorTests + { + public class TheProcessMethod : MessageChangeEventProcessorTest + { + public static IEnumerable AllMessageTypes_Data + { + get + { + yield return new object[] { MessageType.Start }; + yield return new object[] { MessageType.End }; + yield return new object[] { MessageType.Manual }; + } + } + + public static IEnumerable AllImpactedStatuses_Data + { + get + { + yield return new object[] { ComponentStatus.Degraded }; + yield return new object[] { ComponentStatus.Down }; + } + } + + public static IEnumerable AllImpactedStatusesPairs_Data + { + get + { + yield return new object[] { ComponentStatus.Degraded, ComponentStatus.Degraded }; + yield return new object[] { ComponentStatus.Down, ComponentStatus.Degraded }; + yield return new object[] { ComponentStatus.Degraded, ComponentStatus.Down }; + yield return new object[] { ComponentStatus.Down, ComponentStatus.Down }; + } + } + + [Theory] + [MemberData(nameof(AllMessageTypes_Data))] + public async Task ThrowsIfUnexpectedPath(MessageType type) + { + var change = new MessageChangeEvent( + DefaultTimestamp, + "missingPath", + ComponentStatus.Degraded, + type); + + var root = new TestComponent("hi"); + + var context = new ExistingStartMessageContext( + DefaultTimestamp, + new TestComponent("name"), + ComponentStatus.Down); + + await Assert.ThrowsAsync(() => Processor.ProcessAsync(change, EventEntity, root, context)); + } + + [Fact] + public async Task ThrowsWithUnexpectedType() + { + var root = new TestComponent("root"); + + var change = new MessageChangeEvent( + DefaultTimestamp, + root.Path, + ComponentStatus.Degraded, + MessageType.Manual); + + var context = new ExistingStartMessageContext( + DefaultTimestamp, + new TestComponent("name"), + ComponentStatus.Down); + + await Assert.ThrowsAsync(() => Processor.ProcessAsync(change, EventEntity, root, context)); + } + + [Fact] + public async Task IgnoresStartMessageWhereComponentDoesntAffectStatus() + { + var hiddenChild = new TestComponent("child"); + var root = new TestComponent("hi", new[] { hiddenChild }, false); + + var change = new MessageChangeEvent( + DefaultTimestamp, + root.GetByNames(root.Name, hiddenChild.Name).Path, + ComponentStatus.Degraded, + MessageType.Start); + + var context = new ExistingStartMessageContext( + DefaultTimestamp, + new TestComponent("name"), + ComponentStatus.Down); + + var result = await Processor.ProcessAsync(change, EventEntity, root, context); + + Assert.Equal(context, result); + + Factory + .Verify( + x => x.CreateMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never()); + } + + [Theory] + [MemberData(nameof(AllImpactedStatuses_Data))] + public async Task CreatesStartMessageFromNullContextForHiddenComponent(ComponentStatus status) + { + var child = new TestComponent("child"); + var root = new PrimarySecondaryComponent("hi", "", new[] { child }); + + var change = new MessageChangeEvent( + DefaultTimestamp, + root.GetByNames(root.Name, child.Name).Path, + status, + MessageType.Start); + + var result = await Processor.ProcessAsync(change, EventEntity, root, null); + + Assert.Equal(change.Timestamp, result.Timestamp); + Assert.Equal(root, result.AffectedComponent); + Assert.Equal(root.Status, result.AffectedComponentStatus); + + Factory + .Verify( + x => x.CreateMessageAsync( + EventEntity, + DefaultTimestamp, + MessageType.Start, + root), + Times.Once()); + } + + [Theory] + [MemberData(nameof(AllImpactedStatuses_Data))] + public async Task CreatesStartMessageFromNullContext(ComponentStatus status) + { + var child = new TestComponent("child"); + var root = new TreeComponent("hi", "", new[] { child }); + + var affectedComponent = root.GetByNames(root.Name, child.Name); + var change = new MessageChangeEvent( + DefaultTimestamp, + affectedComponent.Path, + status, + MessageType.Start); + + var result = await Processor.ProcessAsync(change, EventEntity, root, null); + + Assert.Equal(change.Timestamp, result.Timestamp); + Assert.Equal(affectedComponent, result.AffectedComponent); + Assert.Equal(affectedComponent.Status, result.AffectedComponentStatus); + + Factory + .Verify( + x => x.CreateMessageAsync( + EventEntity, + DefaultTimestamp, + MessageType.Start, + affectedComponent), + Times.Once()); + } + + [Theory] + [MemberData(nameof(AllImpactedStatusesPairs_Data))] + public async Task ThrowsWhenUpdatingStartMessageFromContextWithoutLeastCommonAncestor(ComponentStatus changeStatus, ComponentStatus existingStatus) + { + var child = new TestComponent("child"); + var root = new TreeComponent("hi", "", new[] { child }); + + var affectedComponent = root.GetByNames(root.Name, child.Name); + var change = new MessageChangeEvent( + DefaultTimestamp, + affectedComponent.Path, + changeStatus, + MessageType.Start); + + var context = new ExistingStartMessageContext( + new DateTime(2018, 10, 9), + new TestComponent("no common ancestor"), + existingStatus); + + await Assert.ThrowsAsync(() => Processor.ProcessAsync(change, EventEntity, root, context)); + + Factory + .Verify( + x => x.UpdateMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never()); + } + + [Theory] + [MemberData(nameof(AllImpactedStatusesPairs_Data))] + public async Task ThrowsWhenUpdatingStartMessageFromContextIfLeastCommonAncestorUnaffected(ComponentStatus changeStatus, ComponentStatus existingStatus) + { + var changedChild = new TestComponent("child"); + var existingChild = new TestComponent("existing"); + var root = new AlwaysSameValueTestComponent( + ComponentStatus.Up, + "hi", + "", + new[] { changedChild, existingChild }); + + var affectedComponent = root.GetByNames(root.Name, changedChild.Name); + var change = new MessageChangeEvent( + DefaultTimestamp, + affectedComponent.Path, + changeStatus, + MessageType.Start); + + var existingAffectedComponent = root.GetByNames(root.Name, existingChild.Name); + var context = new ExistingStartMessageContext( + new DateTime(2018, 10, 9), + existingAffectedComponent, + existingStatus); + + await Assert.ThrowsAsync(() => Processor.ProcessAsync(change, EventEntity, root, context)); + + Factory + .Verify( + x => x.UpdateMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never()); + } + + [Theory] + [MemberData(nameof(AllImpactedStatusesPairs_Data))] + public async Task UpdatesExistingStartMessageFromContext(ComponentStatus changeStatus, ComponentStatus existingStatus) + { + var changedChild = new TestComponent("child"); + var existingChild = new TestComponent("existing"); + var root = new TreeComponent("hi", "", new[] { changedChild, existingChild }); + + var affectedComponent = root.GetByNames(root.Name, changedChild.Name); + var change = new MessageChangeEvent( + DefaultTimestamp, + affectedComponent.Path, + changeStatus, + MessageType.Start); + + var existingAffectedComponent = root.GetByNames(root.Name, existingChild.Name); + var context = new ExistingStartMessageContext( + new DateTime(2018, 10, 9), + existingAffectedComponent, + existingStatus); + + var result = await Processor.ProcessAsync(change, EventEntity, root, context); + + Assert.Equal(context.Timestamp, result.Timestamp); + Assert.Equal(root, result.AffectedComponent); + Assert.Equal(root.Status, result.AffectedComponentStatus); + + Factory + .Verify( + x => x.UpdateMessageAsync( + EventEntity, + context.Timestamp, + MessageType.Start, + root), + Times.Once()); + } + + [Theory] + [MemberData(nameof(AllImpactedStatuses_Data))] + public async Task IgnoresEndMessageWithNullContext(ComponentStatus changeStatus) + { + var root = new TestComponent("root"); + + var change = new MessageChangeEvent( + DefaultTimestamp, + root.Path, + changeStatus, + MessageType.End); + + var result = await Processor.ProcessAsync(change, EventEntity, root, null); + + Assert.Null(result); + + Factory + .Verify( + x => x.CreateMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never()); + } + + [Theory] + [MemberData(nameof(AllImpactedStatusesPairs_Data))] + public async Task CreatesEndMessageWithContext(ComponentStatus changeStatus, ComponentStatus existingStatus) + { + var child = new TestComponent("child"); + child.Status = changeStatus; + var root = new PrimarySecondaryComponent("hi", "", new[] { child }); + + var affectedComponent = root.GetByNames(root.Name, child.Name); + var change = new MessageChangeEvent( + DefaultTimestamp + TimeSpan.FromDays(1), + affectedComponent.Path, + changeStatus, + MessageType.End); + + var context = new ExistingStartMessageContext( + DefaultTimestamp, + root, + existingStatus); + + var result = await Processor.ProcessAsync(change, EventEntity, root, context); + + Assert.Null(result); + + Factory + .Verify( + x => x.CreateMessageAsync( + EventEntity, + change.Timestamp, + MessageType.End, + context.AffectedComponent, + context.AffectedComponentStatus), + Times.Once()); + } + + [Theory] + [MemberData(nameof(AllImpactedStatusesPairs_Data))] + public async Task IgnoresEndMessageWithContextIfStillAffected(ComponentStatus changeStatus, ComponentStatus existingStatus) + { + var child = new TestComponent("child"); + child.Status = changeStatus; + var root = new AlwaysSameValueTestComponent( + ComponentStatus.Degraded, + "hi", + "", + new[] { child }, + false); + + var affectedComponent = root.GetByNames(root.Name, child.Name); + var change = new MessageChangeEvent( + DefaultTimestamp + TimeSpan.FromDays(1), + affectedComponent.Path, + changeStatus, + MessageType.End); + + var context = new ExistingStartMessageContext( + DefaultTimestamp, + root, + existingStatus); + + var result = await Processor.ProcessAsync(change, EventEntity, root, context); + + Assert.Equal(context, result); + + Factory + .Verify( + x => x.CreateMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never()); + } + + private class AlwaysSameValueTestComponent : Component + { + public AlwaysSameValueTestComponent( + ComponentStatus value, + string name, + string description) + : base(name, description) + { + _returnedStatus = value; + } + + public AlwaysSameValueTestComponent( + ComponentStatus value, + string name, + string description, + IEnumerable subComponents, + bool displaySubComponents = true) + : base(name, description, subComponents, displaySubComponents) + { + _returnedStatus = value; + } + + private ComponentStatus _returnedStatus; + private ComponentStatus _internalStatus; + public override ComponentStatus Status + { + get => _returnedStatus; + set + { + _internalStatus = value; + } + } + } + } + + public class MessageChangeEventProcessorTest + { + public DateTime DefaultTimestamp = new DateTime(2018, 9, 14); + public EventEntity EventEntity = new EventEntity(); + + public Mock Factory { get; } + public MessageChangeEventProcessor Processor { get; } + + public MessageChangeEventProcessorTest() + { + Factory = new Mock(); + + Processor = new MessageChangeEventProcessor( + Factory.Object, + Mock.Of>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Messages/MessageChangeEventProviderTests.cs b/tests/StatusAggregator.Tests/Messages/MessageChangeEventProviderTests.cs new file mode 100644 index 000000000..95c38ab82 --- /dev/null +++ b/tests/StatusAggregator.Tests/Messages/MessageChangeEventProviderTests.cs @@ -0,0 +1,126 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status.Table; +using StatusAggregator.Messages; +using StatusAggregator.Table; +using StatusAggregator.Tests.TestUtility; +using Xunit; + +namespace StatusAggregator.Tests.Messages +{ + public class MessageChangeEventProviderTests + { + public class TheGetMethod : MessageChangeEventProviderTest + { + [Fact] + public void GetsChanges() + { + var cursor = new DateTime(2018, 10, 9); + var eventEntity = new EventEntity + { + RowKey = "rowKey" + }; + + var groupFromDifferentEvent = new IncidentGroupEntity + { + ParentRowKey = "different" + }; + + var filteredGroup = new IncidentGroupEntity + { + ParentRowKey = eventEntity.RowKey + }; + + Filter + .Setup(x => x.CanPostMessages(filteredGroup, cursor)) + .Returns(false); + + var activeGroup = new IncidentGroupEntity + { + ParentRowKey = eventEntity.RowKey, + AffectedComponentPath = "path", + AffectedComponentStatus = 99, + StartTime = new DateTime(2018, 10, 10) + }; + + Filter + .Setup(x => x.CanPostMessages(activeGroup, cursor)) + .Returns(true); + + var inactiveGroup = new IncidentGroupEntity + { + ParentRowKey = eventEntity.RowKey, + AffectedComponentPath = "path 2", + AffectedComponentStatus = 101, + StartTime = new DateTime(2018, 10, 11), + EndTime = new DateTime(2018, 10, 12), + }; + + Filter + .Setup(x => x.CanPostMessages(inactiveGroup, cursor)) + .Returns(true); + + Table.SetupQuery(groupFromDifferentEvent, filteredGroup, activeGroup, inactiveGroup); + + var result = Provider.Get(eventEntity, cursor); + + Assert.Equal(3, result.Count()); + + var firstChange = result.First(); + AssertChange(activeGroup, MessageType.Start, firstChange); + + var secondChange = result.ElementAt(1); + AssertChange(inactiveGroup, MessageType.Start, secondChange); + + var thirdChange = result.ElementAt(2); + AssertChange(inactiveGroup, MessageType.End, thirdChange); + } + + private void AssertChange(IncidentGroupEntity group, MessageType type, MessageChangeEvent change) + { + DateTime expectedTimestamp; + switch (type) + { + case MessageType.Start: + expectedTimestamp = group.StartTime; + break; + case MessageType.End: + expectedTimestamp = group.EndTime.Value; + break; + default: + throw new ArgumentException(nameof(type)); + } + + Assert.Equal(expectedTimestamp, change.Timestamp); + Assert.Equal(group.AffectedComponentPath, change.AffectedComponentPath); + Assert.Equal(group.AffectedComponentStatus, (int)change.AffectedComponentStatus); + Assert.Equal(type, change.Type); + } + } + + public class MessageChangeEventProviderTest + { + public Mock Table { get; } + public Mock Filter { get; } + + public MessageChangeEventProvider Provider { get; } + + public MessageChangeEventProviderTest() + { + Table = new Mock(); + + Filter = new Mock(); + + Provider = new MessageChangeEventProvider( + Table.Object, + Filter.Object, + Mock.Of>()); + } + } + } +} diff --git a/tests/StatusAggregator.Tests/Messages/MessageContentBuilderTests.cs b/tests/StatusAggregator.Tests/Messages/MessageContentBuilderTests.cs new file mode 100644 index 000000000..a4bb6cf74 --- /dev/null +++ b/tests/StatusAggregator.Tests/Messages/MessageContentBuilderTests.cs @@ -0,0 +1,401 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Factory; +using StatusAggregator.Messages; +using Xunit; + +namespace StatusAggregator.Tests.Messages +{ + public class MessageContentBuilderTests + { + public class TheBuildMethodWithImplicitStatus + : TheBuildMethodTest + { + protected override string Invoke(MessageType type, IComponent component, ComponentStatus status) + { + return Builder.Build(type, component); + } + + protected override ComponentStatus GetStatus(IComponent component, ComponentStatus status) + { + return component.Status; + } + } + + public class TheBuildMethodWithExplicitStatus + : TheBuildMethodTest + { + protected override string Invoke(MessageType type, IComponent component, ComponentStatus status) + { + return Builder.Build(type, component, status); + } + + protected override ComponentStatus GetStatus(IComponent component, ComponentStatus status) + { + return status; + } + } + + public abstract class TheBuildMethodTest + : MessageContentBuilderTest + { + [Fact] + public void ThrowsIfMissingTemplateForType() + { + var type = MessageType.Manual; + var component = CreateTestComponent( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.GalleryName); + var status = ComponentStatus.Degraded; + + Assert.Throws(() => Invoke(type, component, status)); + } + + [Theory] + [InlineData(MessageType.Start)] + [InlineData(MessageType.End)] + public void ThrowsIfMissingActionDescriptionForPath(MessageType type) + { + var component = CreateTestComponent("missing"); + var status = ComponentStatus.Degraded; + + Assert.Throws(() => Invoke(type, component, status)); + } + + [Theory] + [ClassData(typeof(BuildsContentsSuccessfully_Data))] + public void BuildsContentsSuccessfully(MessageType type, string[] names, ComponentStatus status, Func getExpected) + { + var root = new NuGetServiceComponentFactory().Create(); + var component = root.GetByNames(names); + var result = Invoke(type, component, status); + Assert.Equal( + getExpected(GetStatus(component, status).ToString().ToLowerInvariant()), + result); + } + + protected abstract string Invoke( + MessageType type, + IComponent component, + ComponentStatus status); + + protected abstract ComponentStatus GetStatus( + IComponent component, + ComponentStatus status); + } + + public class MessageContentBuilderTest + { + public MessageContentBuilder Builder { get; } + + public MessageContentBuilderTest() + { + Builder = new MessageContentBuilder( + Mock.Of>()); + } + } + + public static IComponent CreateTestComponent(params string[] names) + { + IComponent bottom = null; + IComponent root = null; + foreach (var name in names.Reverse()) + { + if (bottom == null) + { + bottom = new TestComponent(name); + root = bottom; + } + else + { + root = new TestComponent(name, new[] { root }); + } + } + + return bottom ?? throw new ArgumentException(nameof(names)); + } + + public class BuildsContentsSuccessfully_Data : IEnumerable + { + public IEnumerable>> Data = + new Tuple>[] + { + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.GalleryName }, + ComponentStatus.Degraded, + status => $"**NuGet.org is {status}.** You may encounter issues browsing the NuGet Gallery."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.GalleryName }, + ComponentStatus.Degraded, + status => $"**NuGet.org is no longer {status}.** You should no longer encounter any issues browsing the NuGet Gallery. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.GalleryName }, + ComponentStatus.Down, + status => $"**NuGet.org is {status}.** You may encounter issues browsing the NuGet Gallery."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.GalleryName }, + ComponentStatus.Down, + status => $"**NuGet.org is no longer {status}.** You should no longer encounter any issues browsing the NuGet Gallery. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName, NuGetServiceComponentFactory.ChinaRegionName }, + ComponentStatus.Degraded, + status => $"**China V3 Protocol Restore is {status}.** You may encounter issues restoring packages from NuGet.org's V3 feed from China."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName, NuGetServiceComponentFactory.ChinaRegionName }, + ComponentStatus.Degraded, + status => $"**China V3 Protocol Restore is no longer {status}.** You should no longer encounter any issues restoring packages from NuGet.org's V3 feed from China. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName, NuGetServiceComponentFactory.ChinaRegionName }, + ComponentStatus.Down, + status => $"**China V3 Protocol Restore is {status}.** You may encounter issues restoring packages from NuGet.org's V3 feed from China."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName, NuGetServiceComponentFactory.ChinaRegionName }, + ComponentStatus.Down, + status => $"**China V3 Protocol Restore is no longer {status}.** You should no longer encounter any issues restoring packages from NuGet.org's V3 feed from China. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName, NuGetServiceComponentFactory.GlobalRegionName }, + ComponentStatus.Degraded, + status => $"**Global V3 Protocol Restore is {status}.** You may encounter issues restoring packages from NuGet.org's V3 feed."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName, NuGetServiceComponentFactory.GlobalRegionName }, + ComponentStatus.Degraded, + status => $"**Global V3 Protocol Restore is no longer {status}.** You should no longer encounter any issues restoring packages from NuGet.org's V3 feed. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName, NuGetServiceComponentFactory.GlobalRegionName }, + ComponentStatus.Down, + status => $"**Global V3 Protocol Restore is {status}.** You may encounter issues restoring packages from NuGet.org's V3 feed."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName, NuGetServiceComponentFactory.GlobalRegionName }, + ComponentStatus.Down, + status => $"**Global V3 Protocol Restore is no longer {status}.** You should no longer encounter any issues restoring packages from NuGet.org's V3 feed. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName }, + ComponentStatus.Degraded, + status => $"**V3 Protocol Restore is {status}.** You may encounter issues restoring packages from NuGet.org's V3 feed."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName }, + ComponentStatus.Degraded, + status => $"**V3 Protocol Restore is no longer {status}.** You should no longer encounter any issues restoring packages from NuGet.org's V3 feed. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName }, + ComponentStatus.Down, + status => $"**V3 Protocol Restore is {status}.** You may encounter issues restoring packages from NuGet.org's V3 feed."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName }, + ComponentStatus.Down, + status => $"**V3 Protocol Restore is no longer {status}.** You should no longer encounter any issues restoring packages from NuGet.org's V3 feed. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V2ProtocolName }, + ComponentStatus.Degraded, + status => $"**V2 Protocol Restore is {status}.** You may encounter issues restoring packages from NuGet.org's V2 feed."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V2ProtocolName }, + ComponentStatus.Degraded, + status => $"**V2 Protocol Restore is no longer {status}.** You should no longer encounter any issues restoring packages from NuGet.org's V2 feed. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V2ProtocolName }, + ComponentStatus.Down, + status => $"**V2 Protocol Restore is {status}.** You may encounter issues restoring packages from NuGet.org's V2 feed."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V2ProtocolName }, + ComponentStatus.Down, + status => $"**V2 Protocol Restore is no longer {status}.** You should no longer encounter any issues restoring packages from NuGet.org's V2 feed. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName }, + ComponentStatus.Degraded, + status => $"**Restore is {status}.** You may encounter issues restoring packages."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName }, + ComponentStatus.Degraded, + status => $"**Restore is no longer {status}.** You should no longer encounter any issues restoring packages. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName }, + ComponentStatus.Down, + status => $"**Restore is {status}.** You may encounter issues restoring packages."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName }, + ComponentStatus.Down, + status => $"**Restore is no longer {status}.** You should no longer encounter any issues restoring packages. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.ChinaRegionName }, + ComponentStatus.Degraded, + status => $"**China Search is {status}.** You may encounter issues searching for packages from China."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.ChinaRegionName }, + ComponentStatus.Degraded, + status => $"**China Search is no longer {status}.** You should no longer encounter any issues searching for packages from China. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.ChinaRegionName }, + ComponentStatus.Down, + status => $"**China Search is {status}.** You may encounter issues searching for packages from China."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.ChinaRegionName }, + ComponentStatus.Down, + status => $"**China Search is no longer {status}.** You should no longer encounter any issues searching for packages from China. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.GlobalRegionName }, + ComponentStatus.Degraded, + status => $"**Global Search is {status}.** You may encounter issues searching for packages."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.GlobalRegionName }, + ComponentStatus.Degraded, + status => $"**Global Search is no longer {status}.** You should no longer encounter any issues searching for packages. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.GlobalRegionName }, + ComponentStatus.Down, + status => $"**Global Search is {status}.** You may encounter issues searching for packages."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.GlobalRegionName }, + ComponentStatus.Down, + status => $"**Global Search is no longer {status}.** You should no longer encounter any issues searching for packages. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName }, + ComponentStatus.Degraded, + status => $"**Search is {status}.** You may encounter issues searching for packages."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName }, + ComponentStatus.Degraded, + status => $"**Search is no longer {status}.** You should no longer encounter any issues searching for packages. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName }, + ComponentStatus.Down, + status => $"**Search is {status}.** You may encounter issues searching for packages."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName }, + ComponentStatus.Down, + status => $"**Search is no longer {status}.** You should no longer encounter any issues searching for packages. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.UploadName }, + ComponentStatus.Degraded, + status => $"**Package Publishing is {status}.** You may encounter issues uploading new packages."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.UploadName }, + ComponentStatus.Degraded, + status => $"**Package Publishing is no longer {status}.** You should no longer encounter any issues uploading new packages. Thank you for your patience."), + + Tuple.Create>( + MessageType.Start, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.UploadName }, + ComponentStatus.Down, + status => $"**Package Publishing is {status}.** You may encounter issues uploading new packages."), + + Tuple.Create>( + MessageType.End, + new[] { NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.UploadName }, + ComponentStatus.Down, + status => $"**Package Publishing is no longer {status}.** You should no longer encounter any issues uploading new packages. Thank you for your patience."), + + }; + + public IEnumerator GetEnumerator() => Data + .Select(t => new object[] { t.Item1, t.Item2, t.Item3, t.Item4 }) + .GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [Fact] + public void BuildsContentSuccessfullyTestsAllVisibleComponents() + { + var root = new NuGetServiceComponentFactory().Create(); + var components = new BuildsContentsSuccessfully_Data().Data + .Select(t => root.GetByNames(t.Item2)); + foreach (var component in root.GetAllVisibleComponents()) + { + if (root == component) + { + continue; + } + + if (!components.Any(c => c == component)) + { + throw new KeyNotFoundException(component.Path); + } + } + } + } +} diff --git a/tests/StatusAggregator.Tests/Messages/MessageFactoryTests.cs b/tests/StatusAggregator.Tests/Messages/MessageFactoryTests.cs new file mode 100644 index 000000000..f17dc599d --- /dev/null +++ b/tests/StatusAggregator.Tests/Messages/MessageFactoryTests.cs @@ -0,0 +1,258 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.WindowsAzure.Storage.Table; +using Moq; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Messages; +using StatusAggregator.Table; +using Xunit; + +namespace StatusAggregator.Tests.Messages +{ + public class MessageFactoryTests + { + public class TheCreateMessageAsyncMethodWithImplicitStatus + : TheCreateMessageAsyncMethod + { + protected override Task InvokeMethod(EventEntity eventEntity, DateTime time, MessageType type, IComponent component, ComponentStatus status) + { + return Factory.CreateMessageAsync(eventEntity, time, type, component); + } + + protected override ComponentStatus GetExpectedStatus(IComponent component, ComponentStatus status) + { + return component.Status; + } + } + + public class TheCreateMessageAsyncMethodWithExplicitStatus + : TheCreateMessageAsyncMethod + { + protected override Task InvokeMethod(EventEntity eventEntity, DateTime time, MessageType type, IComponent component, ComponentStatus status) + { + return Factory.CreateMessageAsync(eventEntity, time, type, component, status); + } + + protected override ComponentStatus GetExpectedStatus(IComponent component, ComponentStatus status) + { + return status; + } + } + + public abstract class TheCreateMessageAsyncMethod + : MessageFactoryTest + { + [Fact] + public async Task ReturnsExistingMessage() + { + var type = (MessageType)99; + var status = (ComponentStatus)100; + var componentStatus = (ComponentStatus)101; + var component = new TestComponent("component") + { + Status = componentStatus + }; + + var existingMessage = new MessageEntity(EventEntity, Time, "existing", (MessageType)98); + Table + .Setup(x => x.RetrieveAsync(MessageEntity.GetRowKey(EventEntity, Time))) + .ReturnsAsync(existingMessage) + .Verifiable(); + + await InvokeMethod( + EventEntity, + Time, + type, + component, + status); + + Table.Verify(); + + Table + .Verify( + x => x.InsertAsync(It.IsAny()), + Times.Never()); + + Builder + .Verify( + x => x.Build(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never()); + } + + [Fact] + public async Task CreatesNewMessage() + { + var type = (MessageType)99; + var status = (ComponentStatus)100; + var componentStatus = (ComponentStatus)101; + var component = new TestComponent("component") + { + Status = componentStatus + }; + + var expectedStatus = GetExpectedStatus(component, status); + + var contents = "new message"; + Builder + .Setup(x => x.Build(type, component, expectedStatus)) + .Returns(contents); + + Table + .Setup(x => x.RetrieveAsync(MessageEntity.GetRowKey(EventEntity, Time))) + .ReturnsAsync((MessageEntity)null) + .Verifiable(); + + Table + .Setup(x => x.InsertAsync(It.IsAny())) + .Callback(entity => + { + var message = entity as MessageEntity; + Assert.NotNull(message); + Assert.Equal(EventEntity.RowKey, message.ParentRowKey); + Assert.Equal(Time, message.Time); + Assert.Equal(contents, message.Contents); + }) + .Returns(Task.CompletedTask) + .Verifiable(); + + await InvokeMethod( + EventEntity, + Time, + type, + component, + status); + + Table.Verify(); + } + + protected abstract Task InvokeMethod( + EventEntity eventEntity, + DateTime time, + MessageType type, + IComponent component, + ComponentStatus status); + + protected abstract ComponentStatus GetExpectedStatus( + IComponent component, + ComponentStatus status); + } + + public class TheUpdateMessageAsyncMethod + : MessageFactoryTest + { + [Fact] + public async Task IgnoresIfMessageDoesNotExist() + { + var type = (MessageType)99; + var component = new TestComponent("component"); + + Table + .Setup(x => x.RetrieveAsync(MessageEntity.GetRowKey(EventEntity, Time))) + .ReturnsAsync((MessageEntity)null); + + await Factory.UpdateMessageAsync(EventEntity, Time, type, component); + + Table + .Verify( + x => x.ReplaceAsync(It.IsAny()), + Times.Never()); + + Builder + .Verify( + x => x.Build(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never()); + } + + [Theory] + [InlineData(MessageType.Start)] + [InlineData(MessageType.End)] + [InlineData(MessageType.Manual)] + public async Task IgnoresIfExistingMessageTypeDifferent(MessageType existingType) + { + var type = (MessageType)99; + var component = new TestComponent("component"); + + var existingMessage = new MessageEntity(EventEntity, Time, "existing", existingType); + Table + .Setup(x => x.RetrieveAsync(MessageEntity.GetRowKey(EventEntity, Time))) + .ReturnsAsync(existingMessage) + .Verifiable(); + + await Factory.UpdateMessageAsync(EventEntity, Time, type, component); + + Table.Verify(); + + Table + .Verify( + x => x.ReplaceAsync(It.IsAny()), + Times.Never()); + + Builder + .Verify( + x => x.Build(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never()); + } + + [Theory] + [InlineData(MessageType.Start)] + [InlineData(MessageType.End)] + [InlineData(MessageType.Manual)] + public async Task ReplacesMessage(MessageType type) + { + var component = new TestComponent("component"); + + var contents = "new message"; + Builder + .Setup(x => x.Build(type, component)) + .Returns(contents); + + var message = new MessageEntity(EventEntity, Time, "existing", type); + Table + .Setup(x => x.RetrieveAsync(MessageEntity.GetRowKey(EventEntity, Time))) + .ReturnsAsync(message) + .Verifiable(); + + Table + .Setup(x => x.ReplaceAsync(message)) + .Returns(Task.CompletedTask) + .Verifiable(); + + await Factory.UpdateMessageAsync(EventEntity, Time, type, component); + + Assert.Equal(EventEntity.RowKey, message.ParentRowKey); + Assert.Equal(Time, message.Time); + Assert.Equal(contents, message.Contents); + + Table.Verify(); + } + } + + public class MessageFactoryTest + { + public static EventEntity EventEntity = new EventEntity() { RowKey = "rowKey" }; + public static DateTime Time = new DateTime(2018, 10, 10); + + public Mock Table { get; } + public Mock Builder { get; } + + public MessageFactory Factory { get; } + + public MessageFactoryTest() + { + Table = new Mock(); + + Builder = new Mock(); + + Factory = new MessageFactory( + Table.Object, + Builder.Object, + Mock.Of>()); + } + } + } +} diff --git a/tests/StatusAggregator.Tests/Parse/AggregateIncidentParserTests.cs b/tests/StatusAggregator.Tests/Parse/AggregateIncidentParserTests.cs new file mode 100644 index 000000000..676f09c9c --- /dev/null +++ b/tests/StatusAggregator.Tests/Parse/AggregateIncidentParserTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using StatusAggregator.Parse; +using Xunit; + +namespace StatusAggregator.Tests.Parse +{ + public class AggregateIncidentParserTests + { + public class TheParseIncidentMethod + { + private static readonly Incident Incident = new Incident + { + Id = "1111111", + Source = new IncidentSourceData + { + CreateDate = new DateTime(2018, 10, 11) + } + }; + + [Fact] + public void ReturnsListOfParsedIncidents() + { + var failingParser1 = CreateParser(false); + var failingParser2 = CreateParser(false); + + var parsedIncident1 = new ParsedIncident(Incident, "one", ComponentStatus.Degraded); + var successfulParser1 = CreateParser(true, parsedIncident1); + + var parsedIncident2 = new ParsedIncident(Incident, "two", ComponentStatus.Down); + var successfulParser2 = CreateParser(true, parsedIncident2); + + var parsers = new Mock[] + { + failingParser1, + successfulParser1, + successfulParser2, + failingParser2 + }; + + var aggregateParser = new AggregateIncidentParser( + parsers.Select(p => p.Object), + Mock.Of>()); + + var result = aggregateParser.ParseIncident(Incident); + + foreach (var parser in parsers) + { + parser.Verify(); + } + + Assert.Equal(2, result.Count()); + Assert.Contains(parsedIncident1, result); + Assert.Contains(parsedIncident2, result); + } + + private Mock CreateParser(bool result, ParsedIncident returnedIncident = null) + { + var parser = new Mock(); + parser + .Setup(x => x.TryParseIncident(Incident, out returnedIncident)) + .Returns(result) + .Verifiable(); + + return parser; + } + } + } +} diff --git a/tests/StatusAggregator.Tests/Parse/EnvironmentPrefixIncidentRegexParsingHandlerTests.cs b/tests/StatusAggregator.Tests/Parse/EnvironmentPrefixIncidentRegexParsingHandlerTests.cs new file mode 100644 index 000000000..ded989fca --- /dev/null +++ b/tests/StatusAggregator.Tests/Parse/EnvironmentPrefixIncidentRegexParsingHandlerTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using StatusAggregator.Parse; +using Xunit; + +namespace StatusAggregator.Tests.Parse +{ + public abstract class EnvironmentPrefixIncidentRegexParsingHandlerTests + { + public abstract class TheConstructor + where THandler : EnvironmentPrefixIncidentRegexParsingHandler + { + [Fact] + public void ThrowsWithoutEnvironmentRegexFilter() + { + var filters = Enumerable.Empty(); + Assert.Throws(() => Construct(filters)); + } + + [Fact] + public void DoesNotThrowWithEnvironmentFilter() + { + var handler = Construct(new[] { ParsingUtility.CreateEnvironmentFilter() }); + Assert.NotNull(handler); + } + + protected abstract THandler Construct(IEnumerable filters); + } + } +} diff --git a/tests/StatusAggregator.Tests/Parse/EnvironmentRegexParsingFilterTests.cs b/tests/StatusAggregator.Tests/Parse/EnvironmentRegexParsingFilterTests.cs new file mode 100644 index 000000000..dc6f1b295 --- /dev/null +++ b/tests/StatusAggregator.Tests/Parse/EnvironmentRegexParsingFilterTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using NuGet.Services.Incidents; +using StatusAggregator.Parse; +using Xunit; +using Match = System.Text.RegularExpressions.Match; + +namespace StatusAggregator.Tests.Parse +{ + public class EnvironmentRegexParsingFilterTests + { + public class TheShouldParseMethod + : EnvironmentRegexParsingFilterTest + { + [Fact] + public void ReturnsTrueWithoutEnvironmentGroup() + { + var match = Match.Empty; + + var result = Filter.ShouldParse(Incident, match.Groups); + + Assert.True(result); + } + + [Fact] + public void ReturnsFalseIfIncorrectEnvironment() + { + var match = GetMatchWithEnvironmentGroup("imaginary"); + + var result = Filter.ShouldParse(Incident, match.Groups); + + Assert.False(result); + } + + [Theory] + [InlineData(Environment1)] + [InlineData(Environment2)] + public void ReturnsTrueIfCorrectEnvironment(string environment) + { + var match = GetMatchWithEnvironmentGroup(environment); + + var result = Filter.ShouldParse(Incident, match.Groups); + + Assert.True(result); + } + + private static Match GetMatchWithEnvironmentGroup(string environment) + { + return ParsingUtility.GetMatchWithGroup(EnvironmentRegexParsingFilter.EnvironmentGroupName, environment); + } + } + + public class EnvironmentRegexParsingFilterTest + { + public const string Environment1 = "env1"; + public const string Environment2 = "env2"; + + public static Incident Incident = new Incident(); + + public EnvironmentRegexParsingFilter Filter { get; } + + public EnvironmentRegexParsingFilterTest() + { + Filter = ParsingUtility.CreateEnvironmentFilter(Environment1, Environment2); + } + } + } +} diff --git a/tests/StatusAggregator.Tests/Parse/IncidentRegexParserTests.cs b/tests/StatusAggregator.Tests/Parse/IncidentRegexParserTests.cs new file mode 100644 index 000000000..abb7186bc --- /dev/null +++ b/tests/StatusAggregator.Tests/Parse/IncidentRegexParserTests.cs @@ -0,0 +1,235 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using StatusAggregator.Parse; +using Xunit; + +namespace StatusAggregator.Tests.Parse +{ + public class IncidentRegexParserTests + { + public class TheTryParseIncidentMethod + : IncidentRegexParserTest + { + [Fact] + public void ReturnsFalseIfRegexFailure() + { + var incident = new Incident(); + + // The Regex matching will throw an ArgumentNullException because the incident's title and the handler's Regex pattern are null. + var result = Parser.TryParseIncident(incident, out var parsedIncident); + + Assert.False(result); + } + + [Fact] + public void ReturnsFalseIfMatchUnsuccessful() + { + var incident = new Incident + { + Title = "title" + }; + + Handler + .Setup(x => x.RegexPattern) + .Returns("not title"); + + var result = Parser.TryParseIncident(incident, out var parsedIncident); + + Assert.False(result); + } + + [Fact] + public void ReturnsFalseIfFilterFails() + { + var incident = new Incident + { + Title = "title" + }; + + Handler + .Setup(x => x.RegexPattern) + .Returns(incident.Title); + + var successfulFilter = CreateFilter(incident, true); + var failureFilter = CreateFilter(incident, false); + var filters = new[] { successfulFilter, failureFilter }; + Handler + .Setup(x => x.Filters) + .Returns(filters + .Select(f => f.Object) + .ToList()); + + var result = Parser.TryParseIncident(incident, out var parsedIncident); + + Assert.False(result); + + foreach (var filter in filters) + { + filter.Verify(); + } + } + + [Fact] + public void ReturnsFalseIfPathCannotBeParsed() + { + var incident = new Incident + { + Title = "title" + }; + + Handler + .Setup(x => x.RegexPattern) + .Returns(incident.Title); + + var filter = CreateFilter(incident, true); + Handler + .Setup(x => x.Filters) + .Returns(new[] { filter.Object }.ToList()); + + var path = "path"; + Handler + .Setup(x => x.TryParseAffectedComponentPath(incident, It.IsAny(), out path)) + .Returns(false) + .Verifiable(); + + var result = Parser.TryParseIncident(incident, out var parsedIncident); + + Assert.False(result); + + filter.Verify(); + + Handler.Verify(); + } + + [Fact] + public void ReturnsFalseIfStatusCannotBeParsed() + { + var incident = new Incident + { + Title = "title" + }; + + Handler + .Setup(x => x.RegexPattern) + .Returns(incident.Title); + + var filter = CreateFilter(incident, true); + Handler + .Setup(x => x.Filters) + .Returns(new[] { filter.Object }.ToList()); + + var path = "path"; + Handler + .Setup(x => x.TryParseAffectedComponentPath(incident, It.IsAny(), out path)) + .Returns(true) + .Verifiable(); + + var status = (ComponentStatus)99; + Handler + .Setup(x => x.TryParseAffectedComponentStatus(incident, It.IsAny(), out status)) + .Returns(false) + .Verifiable(); + + var result = Parser.TryParseIncident(incident, out var parsedIncident); + + Assert.False(result); + + filter.Verify(); + + Handler.Verify(); + } + + [Fact] + public void ReturnsTrueIfSuccessful() + { + var incident = new Incident + { + Id = "id", + Title = "title", + + Source = new IncidentSourceData + { + CreateDate = new DateTime(2018, 10, 11) + }, + + MitigationData = new IncidentStateChangeEventData + { + Date = new DateTime(2018, 10, 12) + } + }; + + Handler + .Setup(x => x.RegexPattern) + .Returns(incident.Title); + + var filter = CreateFilter(incident, true); + Handler + .Setup(x => x.Filters) + .Returns(new[] { filter.Object }.ToList()); + + var path = "path"; + Handler + .Setup(x => x.TryParseAffectedComponentPath(incident, It.IsAny(), out path)) + .Returns(true) + .Verifiable(); + + var status = (ComponentStatus)99; + Handler + .Setup(x => x.TryParseAffectedComponentStatus(incident, It.IsAny(), out status)) + .Returns(true) + .Verifiable(); + + var result = Parser.TryParseIncident(incident, out var parsedIncident); + + Assert.True(result); + + Assert.Equal(incident.Id, parsedIncident.Id); + Assert.Equal(incident.Source.CreateDate, parsedIncident.StartTime); + Assert.Equal(incident.MitigationData.Date, parsedIncident.EndTime); + Assert.Equal(path, parsedIncident.AffectedComponentPath); + Assert.Equal(status, parsedIncident.AffectedComponentStatus); + + filter.Verify(); + + Handler.Verify(); + } + + private Mock CreateFilter(Incident incident, bool result) + { + var filter = new Mock(); + filter + .Setup(x => x.ShouldParse( + incident, + It.IsAny())) + .Returns(result) + .Verifiable(); + + return filter; + } + } + + public class IncidentRegexParserTest + { + public Mock Handler { get; } + + public IncidentRegexParser Parser { get; } + + public IncidentRegexParserTest() + { + Handler = new Mock(); + + Parser = new IncidentRegexParser( + Handler.Object, + Mock.Of>()); + } + } + } +} diff --git a/tests/StatusAggregator.Tests/Parse/OutdatedSearchServiceInstanceIncidentRegexParsingHandlerTests.cs b/tests/StatusAggregator.Tests/Parse/OutdatedSearchServiceInstanceIncidentRegexParsingHandlerTests.cs new file mode 100644 index 000000000..00f15f378 --- /dev/null +++ b/tests/StatusAggregator.Tests/Parse/OutdatedSearchServiceInstanceIncidentRegexParsingHandlerTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using StatusAggregator.Factory; +using StatusAggregator.Parse; +using Xunit; +using Match = System.Text.RegularExpressions.Match; + +namespace StatusAggregator.Tests.Parse +{ + public class OutdatedSearchServiceInstanceIncidentRegexParsingHandlerTests + { + public class TheTryParseAffectedComponentPathMethod + : OutdatedSearchServiceInstanceIncidentRegexParsingHandlerTest + { + [Fact] + public void ReturnsExpected() + { + var result = Handler.TryParseAffectedComponentPath(Incident, Groups, out var path); + + Assert.True(result); + + Assert.Equal( + ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.UploadName), + path); + } + } + + public class TheTryParseAffectedComponentStatusMethod + : OutdatedSearchServiceInstanceIncidentRegexParsingHandlerTest + { + [Fact] + public void ReturnsExpected() + { + var result = Handler.TryParseAffectedComponentStatus(Incident, Groups, out var status); + + Assert.True(result); + + Assert.Equal(ComponentStatus.Degraded, status); + } + } + + public class OutdatedSearchServiceInstanceIncidentRegexParsingHandlerTest + { + public static Incident Incident = new Incident(); + public static GroupCollection Groups = Match.Empty.Groups; + + public OutdatedSearchServiceInstanceIncidentRegexParsingHandler Handler { get; } + + public OutdatedSearchServiceInstanceIncidentRegexParsingHandlerTest() + { + var environmentFilter = ParsingUtility.CreateEnvironmentFilter(); + Handler = Construct(new[] { environmentFilter }); + } + } + + public class TheConstructor + : EnvironmentPrefixIncidentRegexParsingHandlerTests.TheConstructor + { + [Fact] + public void IgnoresSeverityFilter() + { + var severityFilter = ParsingUtility.CreateSeverityFilter(0); + var environmentFilter = ParsingUtility.CreateEnvironmentFilter(); + + var handler = Construct(new IIncidentRegexParsingFilter[] { severityFilter, environmentFilter }); + + Assert.Single(handler.Filters); + Assert.Contains(environmentFilter, handler.Filters); + Assert.DoesNotContain(severityFilter, handler.Filters); + } + + protected override OutdatedSearchServiceInstanceIncidentRegexParsingHandler Construct(IEnumerable filters) + { + return OutdatedSearchServiceInstanceIncidentRegexParsingHandlerTests.Construct(filters.ToArray()); + } + } + + public static OutdatedSearchServiceInstanceIncidentRegexParsingHandler Construct(params IIncidentRegexParsingFilter[] filters) + { + return new OutdatedSearchServiceInstanceIncidentRegexParsingHandler( + filters, + Mock.Of>()); + } + } +} diff --git a/tests/StatusAggregator.Tests/Parse/ParsingUtility.cs b/tests/StatusAggregator.Tests/Parse/ParsingUtility.cs new file mode 100644 index 000000000..43a06d849 --- /dev/null +++ b/tests/StatusAggregator.Tests/Parse/ParsingUtility.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Moq; +using StatusAggregator.Parse; +using Match = System.Text.RegularExpressions.Match; + +namespace StatusAggregator.Tests.Parse +{ + public static class ParsingUtility + { + public static EnvironmentRegexParsingFilter CreateEnvironmentFilter(params string[] environments) + { + var config = new StatusAggregatorConfiguration + { + Environments = environments + }; + + return new EnvironmentRegexParsingFilter( + config, + Mock.Of>()); + } + + public static SeverityRegexParsingFilter CreateSeverityFilter(int severity) + { + var config = new StatusAggregatorConfiguration + { + MaximumSeverity = severity + }; + + return new SeverityRegexParsingFilter( + config, + Mock.Of>()); + } + + public static Match GetMatchWithGroup(string group, string value) + { + return GetMatchWithGroups(new KeyValuePair(group, value)); + } + + public static Match GetMatchWithGroups(params KeyValuePair[] pairs) + { + var pattern = string.Empty; + var input = string.Empty; + + foreach (var pair in pairs) + { + pattern += $@"\[(?<{pair.Key}>.*)\]"; + input += $"[{pair.Value}]"; + } + + return Regex.Match(input, pattern); + } + } +} diff --git a/tests/StatusAggregator.Tests/Parse/PingdomIncidentRegexParsingHandlerTests.cs b/tests/StatusAggregator.Tests/Parse/PingdomIncidentRegexParsingHandlerTests.cs new file mode 100644 index 000000000..f31550e15 --- /dev/null +++ b/tests/StatusAggregator.Tests/Parse/PingdomIncidentRegexParsingHandlerTests.cs @@ -0,0 +1,228 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using StatusAggregator.Factory; +using StatusAggregator.Parse; +using Xunit; +using Match = System.Text.RegularExpressions.Match; + +namespace StatusAggregator.Tests.Parse +{ + public class PingdomIncidentRegexParsingHandlerTests + { + public class TheTryParseAffectedComponentPathMethod + : PingdomIncidentRegexParsingHandlerTest + { + [Fact] + public void ReturnsFalseIfUnexpectedCheck() + { + var match = ParsingUtility.GetMatchWithGroup( + PingdomIncidentRegexParsingHandler.CheckNameGroupName, + "invalid"); + + var result = Handler.TryParseAffectedComponentPath(Incident, match.Groups, out var path); + + Assert.False(result); + } + + public static IEnumerable ReturnsExpectedPath_Data => CheckNameMapping.Select(p => new object[] { p.Key, p.Value }); + private static IDictionary CheckNameMapping = new Dictionary + { + { + "CDN DNS", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.RestoreName, + NuGetServiceComponentFactory.V3ProtocolName) + }, + + { + "CDN Global", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.RestoreName, + NuGetServiceComponentFactory.V3ProtocolName, + NuGetServiceComponentFactory.GlobalRegionName) + }, + + { + "CDN China", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.RestoreName, + NuGetServiceComponentFactory.V3ProtocolName, + NuGetServiceComponentFactory.ChinaRegionName) + }, + + { + "Gallery DNS", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.GalleryName) + }, + + { + "Gallery Home", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.GalleryName) + }, + + { + "Gallery USNC /", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.GalleryName, + NuGetServiceComponentFactory.UsncInstanceName) + }, + + { + "Gallery USNC /Packages", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.GalleryName, + NuGetServiceComponentFactory.UsncInstanceName) + }, + + { + "Gallery USSC /", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.GalleryName, + NuGetServiceComponentFactory.UsscInstanceName) + }, + + { + "Gallery USSC /Packages", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.GalleryName, + NuGetServiceComponentFactory.UsscInstanceName) + }, + + { + "Gallery USNC /api/v2/Packages()", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.RestoreName, + NuGetServiceComponentFactory.V2ProtocolName, + NuGetServiceComponentFactory.UsncInstanceName) + }, + + { + "Gallery USNC /api/v2/package/NuGet.GalleryUptime/1.0.0", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.RestoreName, + NuGetServiceComponentFactory.V2ProtocolName, + NuGetServiceComponentFactory.UsncInstanceName) + }, + + { + "Gallery USSC /api/v2/Packages()", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.RestoreName, + NuGetServiceComponentFactory.V2ProtocolName, + NuGetServiceComponentFactory.UsscInstanceName) + }, + + { + "Gallery USSC /api/v2/package/NuGet.GalleryUptime/1.0.0", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.RestoreName, + NuGetServiceComponentFactory.V2ProtocolName, + NuGetServiceComponentFactory.UsscInstanceName) + }, + + { + "Search USNC /query", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.SearchName, + NuGetServiceComponentFactory.GlobalRegionName, + NuGetServiceComponentFactory.UsncInstanceName) + }, + + { + "Search USSC /query", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.SearchName, + NuGetServiceComponentFactory.GlobalRegionName, + NuGetServiceComponentFactory.UsscInstanceName) + }, + + { + "Search EA /query", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.SearchName, + NuGetServiceComponentFactory.ChinaRegionName, + NuGetServiceComponentFactory.EaInstanceName) + }, + + { + "Search SEA /query", + CombineNames( + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.SearchName, + NuGetServiceComponentFactory.ChinaRegionName, + NuGetServiceComponentFactory.SeaInstanceName) + }, + }; + + [Theory] + [MemberData(nameof(ReturnsExpectedPath_Data))] + public void ReturnsExpectedPath(string checkName, string[] names) + { + var match = ParsingUtility.GetMatchWithGroup( + PingdomIncidentRegexParsingHandler.CheckNameGroupName, + checkName); + + var result = Handler.TryParseAffectedComponentPath(Incident, match.Groups, out var path); + + Assert.True(result); + Assert.Equal(ComponentUtility.GetPath(names), path); + } + + private static string[] CombineNames(params string[] names) + { + return names; + } + } + + public class TheTryParseAffectedComponentStatusMethod + : PingdomIncidentRegexParsingHandlerTest + { + [Fact] + public void ReturnsExpected() + { + var result = Handler.TryParseAffectedComponentStatus(Incident, Match.Empty.Groups, out var status); + + Assert.True(result); + Assert.Equal(ComponentStatus.Degraded, status); + } + } + + public class PingdomIncidentRegexParsingHandlerTest + { + public Incident Incident = new Incident(); + public PingdomIncidentRegexParsingHandler Handler { get; } + + public PingdomIncidentRegexParsingHandlerTest() + { + Handler = new PingdomIncidentRegexParsingHandler( + Enumerable.Empty(), + Mock.Of>()); + } + } + } +} diff --git a/tests/StatusAggregator.Tests/Parse/SeverityRegexParsingFilterTests.cs b/tests/StatusAggregator.Tests/Parse/SeverityRegexParsingFilterTests.cs new file mode 100644 index 000000000..8e6f77b86 --- /dev/null +++ b/tests/StatusAggregator.Tests/Parse/SeverityRegexParsingFilterTests.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using NuGet.Services.Incidents; +using Xunit; + +namespace StatusAggregator.Tests.Parse +{ + public class SeverityRegexParsingFilterTests + { + public static IEnumerable ReturnsTrueWhenSeverityLessThanOrEqualTo_Data + { + get + { + foreach (var max in PossibleSeverities) + { + foreach (var input in PossibleSeverities) + { + yield return new object[] { max, input }; + } + } + } + } + + private static readonly IEnumerable PossibleSeverities = new[] { 1, 2, 3, 4 }; + + [Theory] + [MemberData(nameof(ReturnsTrueWhenSeverityLessThanOrEqualTo_Data))] + public void ReturnsTrueWhenSeverityLessThanOrEqualTo(int maximumSeverity, int inputSeverity) + { + var incident = new Incident + { + Severity = inputSeverity + }; + + var filter = ParsingUtility.CreateSeverityFilter(maximumSeverity); + + var result = filter.ShouldParse(incident, null); + + Assert.Equal(inputSeverity <= maximumSeverity, result); + } + } +} diff --git a/tests/StatusAggregator.Tests/Parse/TrafficManagerEndpointStatusIncidentRegexParsingHandlerTests.cs b/tests/StatusAggregator.Tests/Parse/TrafficManagerEndpointStatusIncidentRegexParsingHandlerTests.cs new file mode 100644 index 000000000..a1e5f2b61 --- /dev/null +++ b/tests/StatusAggregator.Tests/Parse/TrafficManagerEndpointStatusIncidentRegexParsingHandlerTests.cs @@ -0,0 +1,164 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using StatusAggregator.Factory; +using StatusAggregator.Parse; +using Xunit; +using Match = System.Text.RegularExpressions.Match; + +namespace StatusAggregator.Tests.Parse +{ + public class TrafficManagerEndpointStatusIncidentRegexParsingHandlerTests + { + public class TheTryParseAffectedComponentPathMethod + : TrafficManagerEndpointStatusIncidentRegexParsingHandlerTest + { + [Fact] + public void ReturnsFalseIfUnexpectedValues() + { + var match = ParsingUtility.GetMatchWithGroups( + new KeyValuePair( + EnvironmentRegexParsingFilter.EnvironmentGroupName, + "environment"), + new KeyValuePair( + TrafficManagerEndpointStatusIncidentRegexParsingHandler.DomainGroupName, + "domain"), + new KeyValuePair( + TrafficManagerEndpointStatusIncidentRegexParsingHandler.TargetGroupName, + "target")); + + var result = Handler.TryParseAffectedComponentPath(Incident, match.Groups, out var path); + + Assert.False(result); + } + + private static readonly string[] GalleryUsncPath = new[] + { + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.GalleryName, + NuGetServiceComponentFactory.UsncInstanceName + }; + + private static readonly string[] GalleryUsscPath = new[] + { + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.GalleryName, + NuGetServiceComponentFactory.UsscInstanceName + }; + + private static readonly string[] RestoreV3GlobalPath = new[] + { + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.RestoreName, + NuGetServiceComponentFactory.V3ProtocolName, + NuGetServiceComponentFactory.GlobalRegionName + }; + + private static readonly string[] RestoreV3ChinaPath = new[] + { + NuGetServiceComponentFactory.RootName, + NuGetServiceComponentFactory.RestoreName, + NuGetServiceComponentFactory.V3ProtocolName, + NuGetServiceComponentFactory.ChinaRegionName + }; + + public static IEnumerable ReturnsExpected_Data => TrafficManagerMapping + .Select(m => new object[] { m.Item1, m.Item2, m.Item3, m.Item4 }); + private static readonly IEnumerable> TrafficManagerMapping = + new Tuple[] + { + CreateMapping(Dev, "devnugettest.trafficmanager.net", "nuget-dev-use2-gallery.cloudapp.net", GalleryUsncPath), + CreateMapping(Dev, "devnugettest.trafficmanager.net", "nuget-dev-ussc-gallery.cloudapp.net", GalleryUsscPath), + CreateMapping(Dev, "nugetapidev.trafficmanager.net", "az635243.vo.msecnd.net", RestoreV3GlobalPath), + CreateMapping(Dev, "nugetapidev.trafficmanager.net", "nugetdevcnredirect.trafficmanager.net", RestoreV3ChinaPath), + + CreateMapping(Int, "nuget-int-test-failover.trafficmanager.net", "nuget-int-0-v2gallery.cloudapp.net", GalleryUsncPath), + CreateMapping(Int, "nuget-int-test-failover.trafficmanager.net", "nuget-int-ussc-gallery.cloudapp.net", GalleryUsscPath), + + CreateMapping(Prod, "nuget-prod-v2gallery.trafficmanager.net", "nuget-prod-0-v2gallery.cloudapp.net", GalleryUsncPath), + CreateMapping(Prod, "nuget-prod-v2gallery.trafficmanager.net", "nuget-prod-ussc-gallery.cloudapp.net", GalleryUsscPath), + CreateMapping(Prod, "nugetapiprod.trafficmanager.net", "az320820.vo.msecnd.net", RestoreV3GlobalPath), + CreateMapping(Prod, "nugetapiprod.trafficmanager.net", "nugetprodcnredirect.trafficmanager.net", RestoreV3ChinaPath), + }; + + [Theory] + [MemberData(nameof(ReturnsExpected_Data))] + public void ReturnsExpected(string environment, string domain, string target, string[] names) + { + var match = ParsingUtility.GetMatchWithGroups( + new KeyValuePair( + EnvironmentRegexParsingFilter.EnvironmentGroupName, + environment), + new KeyValuePair( + TrafficManagerEndpointStatusIncidentRegexParsingHandler.DomainGroupName, + domain), + new KeyValuePair( + TrafficManagerEndpointStatusIncidentRegexParsingHandler.TargetGroupName, + target)); + + var result = Handler.TryParseAffectedComponentPath(Incident, match.Groups, out var path); + + Assert.True(result); + + Assert.Equal(ComponentUtility.GetPath(names), path); + } + + private static Tuple CreateMapping(string environment, string domain, string target, string[] names) + { + return Tuple.Create(environment, domain, target, names); + } + } + + public class TheTryParsedAffectedComponentStatusMethod + : TrafficManagerEndpointStatusIncidentRegexParsingHandlerTest + { + [Fact] + public void ReturnsExpected() + { + var result = Handler.TryParseAffectedComponentStatus(Incident, Match.Empty.Groups, out var status); + Assert.True(result); + Assert.Equal(ComponentStatus.Down, status); + } + } + + public class TrafficManagerEndpointStatusIncidentRegexParsingHandlerTest + { + public const string Dev = "dev"; + public const string Int = "int"; + public const string Prod = "prod"; + + public Incident Incident = new Incident(); + public TrafficManagerEndpointStatusIncidentRegexParsingHandler Handler { get; } + + public TrafficManagerEndpointStatusIncidentRegexParsingHandlerTest() + { + Handler = new TrafficManagerEndpointStatusIncidentRegexParsingHandler( + new[] { ParsingUtility.CreateEnvironmentFilter(Dev, Int, Prod) }, + Mock.Of>()); + } + } + + public class TheConstructor + : EnvironmentPrefixIncidentRegexParsingHandlerTests.TheConstructor + { + protected override TrafficManagerEndpointStatusIncidentRegexParsingHandler Construct(IEnumerable filters) + { + return TrafficManagerEndpointStatusIncidentRegexParsingHandlerTests.Construct(filters.ToArray()); + } + } + + public static TrafficManagerEndpointStatusIncidentRegexParsingHandler Construct(params IIncidentRegexParsingFilter[] filters) + { + return new TrafficManagerEndpointStatusIncidentRegexParsingHandler( + filters, + Mock.Of>()); + } + } +} diff --git a/tests/StatusAggregator.Tests/Parse/ValidationDurationIncidentRegexParsingHandlerTests.cs b/tests/StatusAggregator.Tests/Parse/ValidationDurationIncidentRegexParsingHandlerTests.cs new file mode 100644 index 000000000..4cdbeb956 --- /dev/null +++ b/tests/StatusAggregator.Tests/Parse/ValidationDurationIncidentRegexParsingHandlerTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status; +using StatusAggregator.Factory; +using StatusAggregator.Parse; +using Xunit; +using Match = System.Text.RegularExpressions.Match; + +namespace StatusAggregator.Tests.Parse +{ + public class ValidationDurationIncidentRegexParsingHandlerTests + { + public class TheTryParseAffectedComponentPathMethod + : ValidationDurationIncidentRegexParsingHandlerTest + { + [Fact] + public void ReturnsExpected() + { + var result = Handler.TryParseAffectedComponentPath(Incident, Groups, out var path); + + Assert.True(result); + + Assert.Equal( + ComponentUtility.GetPath(NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.UploadName), + path); + } + } + + public class TheTryParseAffectedComponentStatusMethod + : ValidationDurationIncidentRegexParsingHandlerTest + { + [Fact] + public void ReturnsExpected() + { + var result = Handler.TryParseAffectedComponentStatus(Incident, Groups, out var status); + + Assert.True(result); + + Assert.Equal(ComponentStatus.Degraded, status); + } + } + + public class ValidationDurationIncidentRegexParsingHandlerTest + { + public static Incident Incident = new Incident(); + public static GroupCollection Groups = Match.Empty.Groups; + + public ValidationDurationIncidentRegexParsingHandler Handler { get; } + + public ValidationDurationIncidentRegexParsingHandlerTest() + { + var environmentFilter = ParsingUtility.CreateEnvironmentFilter(); + Handler = Construct(new[] { environmentFilter }); + } + } + + public class TheConstructor + : EnvironmentPrefixIncidentRegexParsingHandlerTests.TheConstructor + { + protected override ValidationDurationIncidentRegexParsingHandler Construct(IEnumerable filters) + { + return ValidationDurationIncidentRegexParsingHandlerTests.Construct(filters.ToArray()); + } + } + + public static ValidationDurationIncidentRegexParsingHandler Construct(params IIncidentRegexParsingFilter[] filters) + { + return new ValidationDurationIncidentRegexParsingHandler( + filters, + Mock.Of>()); + } + } +} diff --git a/tests/StatusAggregator.Tests/Properties/AssemblyInfo.cs b/tests/StatusAggregator.Tests/Properties/AssemblyInfo.cs index 429b1f7c4..8216d11a9 100644 --- a/tests/StatusAggregator.Tests/Properties/AssemblyInfo.cs +++ b/tests/StatusAggregator.Tests/Properties/AssemblyInfo.cs @@ -1,5 +1,4 @@ using System.Reflection; -using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following diff --git a/tests/StatusAggregator.Tests/StatusAggregator.Tests.csproj b/tests/StatusAggregator.Tests/StatusAggregator.Tests.csproj index b9410ead5..50c01abf1 100644 --- a/tests/StatusAggregator.Tests/StatusAggregator.Tests.csproj +++ b/tests/StatusAggregator.Tests/StatusAggregator.Tests.csproj @@ -44,16 +44,53 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -69,13 +106,13 @@ 4.7.145 - 2.28.0-sb-tablegeo-38564 + 2.29.0 - 2.28.0-master-38796 + 2.29.0 - 2.28.0-master-38796 + 2.29.0 4.3.0 @@ -87,11 +124,17 @@ 9.2.0 - 2.3.1 + 2.4.0 - 2.3.1 + 2.4.0 + + + 2.4.0 + runtime; build; native; contentfiles; analyzers + all + \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/TestComponent.cs b/tests/StatusAggregator.Tests/TestComponent.cs new file mode 100644 index 000000000..4973c784c --- /dev/null +++ b/tests/StatusAggregator.Tests/TestComponent.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using NuGet.Services.Status; + +namespace StatusAggregator.Tests +{ + public class TestComponent : Component + { + private const string DefaultDescription = ""; + + public TestComponent(string name) + : base(name, DefaultDescription) + { + } + + public TestComponent( + string name, + IEnumerable subComponents, + bool displaySubComponents = true) + : base(name, DefaultDescription, subComponents, displaySubComponents) + { + } + + public override ComponentStatus Status { get; set; } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/TestUtility/MockTableWrapperExtensions.cs b/tests/StatusAggregator.Tests/TestUtility/MockTableWrapperExtensions.cs new file mode 100644 index 000000000..2f0098f22 --- /dev/null +++ b/tests/StatusAggregator.Tests/TestUtility/MockTableWrapperExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using Microsoft.WindowsAzure.Storage.Table; +using Moq; +using StatusAggregator.Table; + +namespace StatusAggregator.Tests.TestUtility +{ + public static class MockTableWrapperExtensions + { + public static void SetupQuery( + this Mock mock, + params T[] results) + where T : ITableEntity, new() + { + mock + .Setup(x => x.CreateQuery()) + .Returns(results.AsQueryable()); + } + } +} diff --git a/tests/StatusAggregator.Tests/Update/ActiveEventEntityUpdaterTests.cs b/tests/StatusAggregator.Tests/Update/ActiveEventEntityUpdaterTests.cs new file mode 100644 index 000000000..3035b63f6 --- /dev/null +++ b/tests/StatusAggregator.Tests/Update/ActiveEventEntityUpdaterTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status.Table; +using StatusAggregator.Table; +using StatusAggregator.Tests.TestUtility; +using StatusAggregator.Update; +using Xunit; + +namespace StatusAggregator.Tests.Update +{ + public class ActiveEventEntityUpdaterTests + { + public class TheUpdateAllAsyncMethod + : ActiveEventEntityUpdaterTest + { + [Fact] + public async Task UpdatesAllActiveEvents() + { + var cursor = new DateTime(2018, 10, 10); + + var inactiveEvent = new EventEntity + { + RowKey = "inactive", + EndTime = new DateTime(2018, 10, 11) + }; + + var activeEvent1 = new EventEntity + { + RowKey = "active1" + }; + + var activeEvent2 = new EventEntity + { + RowKey = "active2" + }; + + Table.SetupQuery(inactiveEvent, activeEvent1, activeEvent2); + + EventUpdater + .Setup(x => x.UpdateAsync(activeEvent1, cursor)) + .Returns(Task.CompletedTask) + .Verifiable(); + + EventUpdater + .Setup(x => x.UpdateAsync(activeEvent2, cursor)) + .Returns(Task.CompletedTask) + .Verifiable(); + + await Updater.UpdateAllAsync(cursor); + + EventUpdater.Verify(); + + EventUpdater + .Verify( + x => x.UpdateAsync(inactiveEvent, It.IsAny()), + Times.Never()); + } + } + + public class ActiveEventEntityUpdaterTest + { + public Mock Table { get; } + public Mock> EventUpdater { get; } + + public ActiveEventEntityUpdater Updater { get; } + + public ActiveEventEntityUpdaterTest() + { + Table = new Mock(); + + EventUpdater = new Mock>(); + + Updater = new ActiveEventEntityUpdater( + Table.Object, + EventUpdater.Object, + Mock.Of>()); + } + } + } +} diff --git a/tests/StatusAggregator.Tests/Update/AggregationEntityUpdaterTests.cs b/tests/StatusAggregator.Tests/Update/AggregationEntityUpdaterTests.cs new file mode 100644 index 000000000..8f1ba896e --- /dev/null +++ b/tests/StatusAggregator.Tests/Update/AggregationEntityUpdaterTests.cs @@ -0,0 +1,370 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.WindowsAzure.Storage.Table; +using Moq; +using NuGet.Services.Status.Table; +using StatusAggregator.Table; +using StatusAggregator.Tests.TestUtility; +using StatusAggregator.Update; +using Xunit; + +namespace StatusAggregator.Tests.Update +{ + public class AggregationEntityUpdaterTests + { + public class TheIncidentGroupUpdateAsyncMethod + : TheUpdateAsyncMethod + { + } + + public class TheEventUpdateAsyncMethod + : TheUpdateAsyncMethod + { + } + + public abstract class TheUpdateAsyncMethod + : AggregationEntityUpdaterTest + where TChildEntity : AggregatedComponentAffectingEntity, new() + where TAggregationEntity : ComponentAffectingEntity, new() + { + [Fact] + public async Task ThrowsIfAggregationNull() + { + await Assert.ThrowsAsync(() => Updater.UpdateAsync(null, Cursor)); + } + + [Fact] + public async Task IgnoresDeactivatedAggregation() + { + var aggregation = new TAggregationEntity + { + EndTime = new DateTime(2018, 10, 9) + }; + + await Updater.UpdateAsync(aggregation, Cursor); + + Table + .Verify( + x => x.CreateQuery(), + Times.Never()); + + Table + .Verify( + x => x.ReplaceAsync(It.IsAny()), + Times.Never()); + + ChildUpdater + .Verify( + x => x.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Never()); + } + + [Fact] + public async Task IgnoresAggregationWithoutChildren() + { + var aggregation = new TAggregationEntity + { + RowKey = "rowKey" + }; + + var activeChildDifferentEntity = new TChildEntity + { + ParentRowKey = "different" + }; + + var recentChildDifferentEntity = new TChildEntity + { + ParentRowKey = "different", + EndTime = Cursor + }; + + Table.SetupQuery(activeChildDifferentEntity, recentChildDifferentEntity); + + await Updater.UpdateAsync(aggregation, Cursor); + + Table + .Verify( + x => x.ReplaceAsync(It.IsAny()), + Times.Never()); + + ChildUpdater + .Verify( + x => x.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Never()); + } + + [Fact] + public async Task DeactivatesAggregationWithoutRecentChildren() + { + var aggregation = new TAggregationEntity + { + RowKey = "rowKey" + }; + + var activeChildDifferentEntity = new TChildEntity + { + ParentRowKey = "different" + }; + + var recentChildDifferentEntity = new TChildEntity + { + ParentRowKey = "different", + EndTime = Cursor + }; + + var oldChildSameEntity = new TChildEntity + { + ParentRowKey = aggregation.RowKey, + EndTime = Cursor - EndMessageDelay + }; + + var olderChildSameEntity = new TChildEntity + { + ParentRowKey = aggregation.RowKey, + EndTime = Cursor - EndMessageDelay - EndMessageDelay + }; + + Table.SetupQuery(activeChildDifferentEntity, recentChildDifferentEntity, oldChildSameEntity, olderChildSameEntity); + + ChildUpdater + .Setup(x => x.UpdateAsync(oldChildSameEntity, Cursor)) + .Returns(Task.CompletedTask) + .Verifiable(); + + Table + .Setup(x => x.ReplaceAsync(aggregation)) + .Returns(Task.CompletedTask) + .Verifiable(); + + await Updater.UpdateAsync(aggregation, Cursor); + + Assert.Equal(oldChildSameEntity.EndTime, aggregation.EndTime); + + Table.Verify(); + ChildUpdater.Verify(); + } + + [Fact] + public async Task DoesNotDeactivateAggregationWithActiveChildren() + { + var aggregation = new TAggregationEntity + { + RowKey = "rowKey" + }; + + var activeChildDifferentEntity = new TChildEntity + { + ParentRowKey = "different" + }; + + var recentChildDifferentEntity = new TChildEntity + { + ParentRowKey = "different", + EndTime = Cursor + }; + + var oldChildSameEntity = new TChildEntity + { + ParentRowKey = aggregation.RowKey, + EndTime = Cursor - EndMessageDelay + }; + + var activeChildSameEntity = new TChildEntity + { + ParentRowKey = aggregation.RowKey + }; + + Table.SetupQuery( + activeChildDifferentEntity, + recentChildDifferentEntity, + oldChildSameEntity, + activeChildSameEntity); + + ChildUpdater + .Setup(x => x.UpdateAsync(oldChildSameEntity, Cursor)) + .Returns(Task.CompletedTask) + .Verifiable(); + + ChildUpdater + .Setup(x => x.UpdateAsync(activeChildSameEntity, Cursor)) + .Returns(Task.CompletedTask) + .Verifiable(); + + await Updater.UpdateAsync(aggregation, Cursor); + + Assert.True(aggregation.IsActive); + + Table + .Verify( + x => x.ReplaceAsync(aggregation), + Times.Never()); + + ChildUpdater.Verify(); + } + + [Fact] + public async Task DoesNotDeactivateAggregationWithRecentChildren() + { + var aggregation = new TAggregationEntity + { + RowKey = "rowKey" + }; + + var activeChildDifferentEntity = new TChildEntity + { + ParentRowKey = "different" + }; + + var recentChildDifferentEntity = new TChildEntity + { + ParentRowKey = "different", + EndTime = Cursor + }; + + var oldChildSameEntity = new TChildEntity + { + ParentRowKey = aggregation.RowKey, + EndTime = Cursor - EndMessageDelay + }; + + var recentChildSameEntity = new TChildEntity + { + ParentRowKey = aggregation.RowKey, + EndTime = Cursor + }; + + Table.SetupQuery( + activeChildDifferentEntity, + recentChildDifferentEntity, + oldChildSameEntity, + recentChildSameEntity); + + ChildUpdater + .Setup(x => x.UpdateAsync(oldChildSameEntity, Cursor)) + .Returns(Task.CompletedTask) + .Verifiable(); + + ChildUpdater + .Setup(x => x.UpdateAsync(recentChildSameEntity, Cursor)) + .Returns(Task.CompletedTask) + .Verifiable(); + + await Updater.UpdateAsync(aggregation, Cursor); + + Assert.True(aggregation.IsActive); + + Table + .Verify( + x => x.ReplaceAsync(aggregation), + Times.Never()); + + ChildUpdater.Verify(); + } + + [Fact] + public async Task DoesNotDeactivateAggregationWithActiveAndRecentChildren() + { + var aggregation = new TAggregationEntity + { + RowKey = "rowKey" + }; + + var activeChildDifferentEntity = new TChildEntity + { + ParentRowKey = "different" + }; + + var recentChildDifferentEntity = new TChildEntity + { + ParentRowKey = "different", + EndTime = Cursor + }; + + var oldChildSameEntity = new TChildEntity + { + ParentRowKey = aggregation.RowKey, + EndTime = Cursor - EndMessageDelay + }; + + var activeChildSameEntity = new TChildEntity + { + ParentRowKey = aggregation.RowKey + }; + + var recentChildSameEntity = new TChildEntity + { + ParentRowKey = aggregation.RowKey, + EndTime = Cursor + }; + + Table.SetupQuery( + activeChildDifferentEntity, + recentChildDifferentEntity, + oldChildSameEntity, + activeChildSameEntity, + recentChildSameEntity); + + ChildUpdater + .Setup(x => x.UpdateAsync(oldChildSameEntity, Cursor)) + .Returns(Task.CompletedTask) + .Verifiable(); + + ChildUpdater + .Setup(x => x.UpdateAsync(activeChildSameEntity, Cursor)) + .Returns(Task.CompletedTask) + .Verifiable(); + + ChildUpdater + .Setup(x => x.UpdateAsync(recentChildSameEntity, Cursor)) + .Returns(Task.CompletedTask) + .Verifiable(); + + await Updater.UpdateAsync(aggregation, Cursor); + + Assert.True(aggregation.IsActive); + + Table + .Verify( + x => x.ReplaceAsync(aggregation), + Times.Never()); + + ChildUpdater.Verify(); + } + } + + public class AggregationEntityUpdaterTest + where TChildEntity : AggregatedComponentAffectingEntity, new() + where TAggregationEntity : ComponentAffectingEntity, new() + { + public static readonly DateTime Cursor = new DateTime(2018, 9, 13); + public static readonly TimeSpan EndMessageDelay = TimeSpan.FromDays(1); + + public Mock Table { get; } + public Mock> ChildUpdater { get; } + + public AggregationEntityUpdater Updater { get; } + + public AggregationEntityUpdaterTest() + { + Table = new Mock(); + + ChildUpdater = new Mock>(); + + var config = new StatusAggregatorConfiguration + { + EventEndDelayMinutes = (int)EndMessageDelay.TotalMinutes + }; + + Updater = new AggregationEntityUpdater( + Table.Object, + ChildUpdater.Object, + config, + Mock.Of>>()); + } + } + } +} \ No newline at end of file diff --git a/tests/StatusAggregator.Tests/Update/EventMessagingUpdaterTests.cs b/tests/StatusAggregator.Tests/Update/EventMessagingUpdaterTests.cs new file mode 100644 index 000000000..39873c978 --- /dev/null +++ b/tests/StatusAggregator.Tests/Update/EventMessagingUpdaterTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NuGet.Services.Status; +using NuGet.Services.Status.Table; +using StatusAggregator.Messages; +using StatusAggregator.Update; +using Xunit; + +namespace StatusAggregator.Tests.Update +{ + public class EventMessagingUpdaterTests + { + public class TheUpdateAsyncMethod : EventMessagingUpdaterTest + { + [Fact] + public void GetsAndIteratesChanges() + { + var eventEntity = new EventEntity(); + var cursor = new DateTime(2018, 10, 9); + + var changes = new MessageChangeEvent[] { + new MessageChangeEvent(new DateTime(2018, 10, 9), "path", ComponentStatus.Degraded, MessageType.Start) }; + + Provider + .Setup(x => x.Get(eventEntity, cursor)) + .Returns(changes); + + var iteratorTask = Task.FromResult("something to make this task unique"); + Iterator + .Setup(x => x.IterateAsync(changes, eventEntity)) + .Returns(iteratorTask); + + var result = Updater.UpdateAsync(eventEntity, cursor); + + Assert.Equal(iteratorTask, result); + } + } + + public class EventMessagingUpdaterTest + { + public Mock Provider { get; } + public Mock Iterator { get; } + + public EventMessagingUpdater Updater { get; } + + public EventMessagingUpdaterTest() + { + Provider = new Mock(); + + Iterator = new Mock(); + + Updater = new EventMessagingUpdater( + Provider.Object, + Iterator.Object, + Mock.Of>()); + } + } + } +} diff --git a/tests/StatusAggregator.Tests/Update/IncidentUpdaterTests.cs b/tests/StatusAggregator.Tests/Update/IncidentUpdaterTests.cs new file mode 100644 index 000000000..c09e6da31 --- /dev/null +++ b/tests/StatusAggregator.Tests/Update/IncidentUpdaterTests.cs @@ -0,0 +1,126 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.WindowsAzure.Storage.Table; +using Moq; +using NuGet.Services.Incidents; +using NuGet.Services.Status.Table; +using StatusAggregator.Table; +using StatusAggregator.Update; +using Xunit; + +namespace StatusAggregator.Tests.Update +{ + public class IncidentUpdaterTests + { + public class TheUpdateAsyncMethod + : IncidentUpdaterTest + { + [Fact] + public async Task IgnoresDeactivatedIncidents() + { + var cursor = new DateTime(2018, 10, 10); + var endTime = new DateTime(2018, 10, 11); + var incidentEntity = new IncidentEntity + { + EndTime = endTime + }; + + await Updater.UpdateAsync(incidentEntity, cursor); + + Assert.Equal(endTime, incidentEntity.EndTime); + + Client + .Verify( + x => x.GetIncident(It.IsAny()), + Times.Never()); + + Table + .Verify( + x => x.ReplaceAsync(It.IsAny()), + Times.Never()); + } + + [Fact] + public async Task DoesNotSaveIfNotMitigated() + { + var cursor = new DateTime(2018, 10, 10); + var incidentEntity = new IncidentEntity + { + IncidentApiId = "id" + }; + + var incident = new Incident(); + Client + .Setup(x => x.GetIncident(incidentEntity.IncidentApiId)) + .ReturnsAsync(incident); + + await Updater.UpdateAsync(incidentEntity, cursor); + + Assert.True(incidentEntity.IsActive); + + Table + .Verify( + x => x.ReplaceAsync(It.IsAny()), + Times.Never()); + } + + [Fact] + public async Task DeactivatesIfMitigated() + { + var cursor = new DateTime(2018, 10, 10); + var incidentEntity = new IncidentEntity + { + IncidentApiId = "id" + }; + + var incident = new Incident + { + MitigationData = new IncidentStateChangeEventData + { + Date = new DateTime(2018, 10, 11) + } + }; + + Client + .Setup(x => x.GetIncident(incidentEntity.IncidentApiId)) + .ReturnsAsync(incident); + + Table + .Setup(x => x.ReplaceAsync(incidentEntity)) + .Returns(Task.CompletedTask) + .Verifiable(); + + await Updater.UpdateAsync(incidentEntity, cursor); + + Assert.Equal(incident.MitigationData.Date, incidentEntity.EndTime); + Assert.False(incidentEntity.IsActive); + + Table.Verify(); + } + } + + public class IncidentUpdaterTest + { + public Mock Table { get; } + public Mock Client { get; } + + public IncidentUpdater Updater { get; } + + public IncidentUpdaterTest() + { + Table = new Mock(); + + Client = new Mock(); + + Updater = new IncidentUpdater( + Table.Object, + Client.Object, + Mock.Of>()); + } + } + } +} diff --git a/tests/Validation.Symbols.Core.Tests/SymbolsValidationEntitiesServiceFacts.cs b/tests/Validation.Symbols.Core.Tests/SymbolsValidationEntitiesServiceFacts.cs index 69f0b5f93..8f861e5c8 100644 --- a/tests/Validation.Symbols.Core.Tests/SymbolsValidationEntitiesServiceFacts.cs +++ b/tests/Validation.Symbols.Core.Tests/SymbolsValidationEntitiesServiceFacts.cs @@ -185,13 +185,14 @@ public void CreateFromValidationRequestValidRequest() { // Arrange ValidationRequest request = new ValidationRequest(Guid.NewGuid(), PackageKey, PackageId, PackageVersion, ""); + string requestName = "DummyRequestName"; // Act - var result = SymbolsValidationEntitiesService.CreateFromValidationRequest(request, SymbolsPackageIngestRequestStatus.FailedIngestion); + var result = SymbolsValidationEntitiesService.CreateFromValidationRequest(request, SymbolsPackageIngestRequestStatus.FailedIngestion, requestName); // Assert Assert.Equal(PackageKey, result.SymbolsKey); - Assert.Equal(PackageKey.ToString(), result.RequestName); + Assert.Equal(requestName, result.RequestName); Assert.Equal(SymbolsPackageIngestRequestStatus.FailedIngestion, result.RequestStatusKey); } } @@ -260,6 +261,23 @@ public void ConvertToIValidationResultNull() } } + public class The + { + [Fact] + public void CreateRequestName() + { + // Arrange + Guid id = Guid.NewGuid(); + ValidationRequest vRequest = new ValidationRequest(id, 10, "pId", "1.1.1", "url"); + + // Act + var requestName = SymbolsValidationEntitiesService.CreateSymbolServerRequestNameFromValidationRequest(vRequest); + + // Assert + Assert.Equal($"{vRequest.PackageKey}_{vRequest.ValidationId}", requestName); + } + } + public abstract class FactsBase { protected readonly Mock _validationEntitiesContext;