diff --git a/NuGet.Jobs.sln b/NuGet.Jobs.sln index 1da890884..1faee233f 100644 --- a/NuGet.Jobs.sln +++ b/NuGet.Jobs.sln @@ -107,6 +107,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Validation.Common.Job", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PackageHash", "src\PackageHash\PackageHash.csproj", "{40843020-6F0A-48F0-AC28-42FFE3A5C21E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusAggregator", "src\StatusAggregator\StatusAggregator.csproj", "{D357FDB5-BF19-41A5-82B0-14C8CEC2A5EB}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Validation.Common.Job.Tests", "tests\Validation.Common.Job.Tests\Validation.Common.Job.Tests.csproj", "{430F63C7-20C2-4872-AC3E-DDE846E50AA4}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Validation.PackageSigning.ProcessSignature", "src\Validation.PackageSigning.ProcessSignature\Validation.PackageSigning.ProcessSignature.csproj", "{DD043977-6BCD-475A-BEE2-8C34309EC622}" @@ -135,6 +137,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NuGet.Services.Revalidate", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NuGet.Services.Revalidate.Tests", "tests\NuGet.Services.Revalidate.Tests\NuGet.Services.Revalidate.Tests.csproj", "{19780DCB-B307-4254-B10C-4335FC784DEA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusAggregator.Tests", "tests\StatusAggregator.Tests\StatusAggregator.Tests.csproj", "{784F938D-4142-4C1C-B654-0978FEAD1731}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Validation.Symbols.Core", "src\Validation.Symbols.Core\Validation.Symbols.Core.csproj", "{17510A22-176F-4E96-A867-E79F1B54F54F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Monitoring.RebootSearchInstance", "src\Monitoring.RebootSearchInstance\Monitoring.RebootSearchInstance.csproj", "{ECD8DFCE-8E3C-4510-AFE3-D7EC168E8D66}" @@ -309,6 +313,10 @@ Global {40843020-6F0A-48F0-AC28-42FFE3A5C21E}.Debug|Any CPU.Build.0 = Debug|Any CPU {40843020-6F0A-48F0-AC28-42FFE3A5C21E}.Release|Any CPU.ActiveCfg = Release|Any CPU {40843020-6F0A-48F0-AC28-42FFE3A5C21E}.Release|Any CPU.Build.0 = Release|Any CPU + {D357FDB5-BF19-41A5-82B0-14C8CEC2A5EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D357FDB5-BF19-41A5-82B0-14C8CEC2A5EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D357FDB5-BF19-41A5-82B0-14C8CEC2A5EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D357FDB5-BF19-41A5-82B0-14C8CEC2A5EB}.Release|Any CPU.Build.0 = Release|Any CPU {430F63C7-20C2-4872-AC3E-DDE846E50AA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {430F63C7-20C2-4872-AC3E-DDE846E50AA4}.Debug|Any CPU.Build.0 = Debug|Any CPU {430F63C7-20C2-4872-AC3E-DDE846E50AA4}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -361,6 +369,10 @@ Global {19780DCB-B307-4254-B10C-4335FC784DEA}.Debug|Any CPU.Build.0 = Debug|Any CPU {19780DCB-B307-4254-B10C-4335FC784DEA}.Release|Any CPU.ActiveCfg = Release|Any CPU {19780DCB-B307-4254-B10C-4335FC784DEA}.Release|Any CPU.Build.0 = Release|Any CPU + {784F938D-4142-4C1C-B654-0978FEAD1731}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {784F938D-4142-4C1C-B654-0978FEAD1731}.Debug|Any CPU.Build.0 = Debug|Any CPU + {784F938D-4142-4C1C-B654-0978FEAD1731}.Release|Any CPU.ActiveCfg = Release|Any CPU + {784F938D-4142-4C1C-B654-0978FEAD1731}.Release|Any CPU.Build.0 = Release|Any CPU {17510A22-176F-4E96-A867-E79F1B54F54F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {17510A22-176F-4E96-A867-E79F1B54F54F}.Debug|Any CPU.Build.0 = Debug|Any CPU {17510A22-176F-4E96-A867-E79F1B54F54F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -425,6 +437,7 @@ Global {B4B7564A-965B-447B-927F-6749E2C08880} = {6A776396-02B1-475D-A104-26940ADB04AB} {FA87D075-A934-4443-8D0B-5DB32640B6D7} = {678D7B14-F8BC-4193-99AF-2EE8AA390A02} {40843020-6F0A-48F0-AC28-42FFE3A5C21E} = {FA5644B5-4F08-43F6-86B3-039374312A47} + {D357FDB5-BF19-41A5-82B0-14C8CEC2A5EB} = {FA5644B5-4F08-43F6-86B3-039374312A47} {430F63C7-20C2-4872-AC3E-DDE846E50AA4} = {6A776396-02B1-475D-A104-26940ADB04AB} {DD043977-6BCD-475A-BEE2-8C34309EC622} = {678D7B14-F8BC-4193-99AF-2EE8AA390A02} {ED2D370C-D921-433A-A0B9-A601F936EDD3} = {FA5644B5-4F08-43F6-86B3-039374312A47} @@ -438,6 +451,7 @@ Global {60152AB1-2EB4-4D44-B6D6-EEE24209A1F7} = {6A776396-02B1-475D-A104-26940ADB04AB} {1963909D-8BE3-4CB8-B57E-AB6A8CB22FED} = {678D7B14-F8BC-4193-99AF-2EE8AA390A02} {19780DCB-B307-4254-B10C-4335FC784DEA} = {6A776396-02B1-475D-A104-26940ADB04AB} + {784F938D-4142-4C1C-B654-0978FEAD1731} = {6A776396-02B1-475D-A104-26940ADB04AB} {17510A22-176F-4E96-A867-E79F1B54F54F} = {678D7B14-F8BC-4193-99AF-2EE8AA390A02} {ECD8DFCE-8E3C-4510-AFE3-D7EC168E8D66} = {814F9B31-4AF3-46CC-AD61-CEB40F47083A} {21C0A0EE-8696-4013-950F-D6495D0C6E40} = {6A776396-02B1-475D-A104-26940ADB04AB} diff --git a/build.ps1 b/build.ps1 index 807a2d720..96baf1372 100644 --- a/build.ps1 +++ b/build.ps1 @@ -115,6 +115,7 @@ Invoke-BuildStep 'Set version metadata in AssemblyInfo.cs' { ` "$PSScriptRoot\src\Validation.Common.Job\Properties\AssemblyInfo.g.cs", "$PSScriptRoot\src\Validation.ScanAndSign.Core\Properties\AssemblyInfo.g.cs", "$PSScriptRoot\src\PackageLagMonitor\Properties\AssemblyInfo.g.cs", + "$PSScriptRoot\src\StatusAggregator\Properties\AssemblyInfo.g.cs", "$PSScriptRoot\src\Validation.Symbols.Core\Properties\AssemblyInfo.g.cs", "$PSScriptRoot\src\Monitoring.RebootSearchInstance\Properties\AssemblyInfo.g.cs" @@ -177,7 +178,8 @@ Invoke-BuildStep 'Creating artifacts' { "src/Validation.PackageSigning.ValidateCertificate/Validation.PackageSigning.ValidateCertificate.csproj", ` "src/Validation.PackageSigning.RevalidateCertificate/Validation.PackageSigning.RevalidateCertificate.csproj", ` "src/PackageLagMonitor/Monitoring.PackageLag.csproj", ` - "src/Monitoring.RebootSearchInstance/Monitoring.RebootSearchInstance.csproj", ` + "src/StatusAggregator/StatusAggregator.csproj", ` + "src/Validation.Symbols.Core/Validation.Symbols.Core.csproj", ` "src/Validation.Symbols/Validation.Symbols.csproj" ` + $ProjectsWithSymbols diff --git a/src/NuGet.Jobs.Common/Configuration/JobArgumentNames.cs b/src/NuGet.Jobs.Common/Configuration/JobArgumentNames.cs index 76878fdd2..3f01ac002 100644 --- a/src/NuGet.Jobs.Common/Configuration/JobArgumentNames.cs +++ b/src/NuGet.Jobs.Common/Configuration/JobArgumentNames.cs @@ -118,6 +118,19 @@ public static class JobArgumentNames public const string MailFrom = "MailFrom"; public const string SmtpUri = "SmtpUri"; + // Arguments specific to StatusAggregator + public const string StatusStorageAccount = "StatusStorageAccount"; + public const string StatusContainerName = "StatusContainerName"; + public const string StatusTableName = "StatusTableName"; + public const string StatusEnvironment = "StatusEnvironment"; + public const string StatusMaximumSeverity = "StatusMaximumSeverity"; + public const string StatusIncidentApiBaseUri = "StatusIncidentApiBaseUri"; + public const string StatusIncidentApiCertificate = "StatusIncidentApiCertificate"; + public const string StatusIncidentApiTeamId = "StatusIncidentApiTeamId"; + public const string StatusEventStartMessageDelayMinutes = "StatusEventStartMessageDelayMinutes"; + public const string StatusEventEndDelayMinutes = "StatusEventEndDelayMinutes"; + public const string StatusEventVisibilityPeriodDays = "StatusEventVisibilityPeriodDays"; + // Arguments specific to Stats.AggregateCdnDownloadsInGallery public static string BatchSleepSeconds = "BatchSleepSeconds"; } diff --git a/src/NuGet.Jobs.Common/Configuration/JobConfigurationManager.cs b/src/NuGet.Jobs.Common/Configuration/JobConfigurationManager.cs index 351449308..fcaeeb51c 100644 --- a/src/NuGet.Jobs.Common/Configuration/JobConfigurationManager.cs +++ b/src/NuGet.Jobs.Common/Configuration/JobConfigurationManager.cs @@ -5,7 +5,9 @@ using System.Collections.Generic; using System.ComponentModel.Design; using System.Linq; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; using NuGet.Services.Configuration; using NuGet.Services.KeyVault; @@ -190,7 +192,7 @@ private static Dictionary ReadCommandLineArguments(ILogger logge private static IDictionary InjectSecrets(IServiceContainer serviceContainer, Dictionary argsDictionary) { - var secretReaderFactory = (ISecretReaderFactory)serviceContainer.GetService(typeof(ISecretReaderFactory)); + var secretReaderFactory = serviceContainer.GetRequiredService(); var secretReader = secretReaderFactory.CreateSecretReader(argsDictionary); if (secretReader == null) diff --git a/src/NuGet.Jobs.Common/Extensions/LoggerExtensions.cs b/src/NuGet.Jobs.Common/Extensions/LoggerExtensions.cs new file mode 100644 index 000000000..2276b06bb --- /dev/null +++ b/src/NuGet.Jobs.Common/Extensions/LoggerExtensions.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 Microsoft.Extensions.Logging; +using System; + +namespace NuGet.Jobs.Extensions +{ + public static class LoggerExtensions + { + /// + /// Calls and logs a message when entering and leaving the scope. + /// + public static IDisposable Scope( + this ILogger logger, + string message, + params object[] args) + { + return new LoggerScopeHelper(logger, message, args); + } + + private class LoggerScopeHelper : IDisposable + { + private readonly ILogger _logger; + private readonly IDisposable _scope; + + private readonly string _message; + private readonly object[] _args; + + private bool _isDisposed = false; + + public LoggerScopeHelper( + ILogger logger, string message, object[] args) + { + _logger = logger; + _message = message; + _args = args; + + _scope = logger.BeginScope(_message, _args); + _logger.LogInformation("Entering scope: " + _message, _args); + } + + public void Dispose() + { + if (!_isDisposed) + { + _logger.LogInformation("Leaving scope: " + _message, _args); + _scope?.Dispose(); // ILogger can return a null scope (most notably during testing with a Mock) + _isDisposed = true; + } + } + } + } +} diff --git a/src/NuGet.Jobs.Common/NuGet.Jobs.Common.csproj b/src/NuGet.Jobs.Common/NuGet.Jobs.Common.csproj index 2dd0251bc..a33533479 100644 --- a/src/NuGet.Jobs.Common/NuGet.Jobs.Common.csproj +++ b/src/NuGet.Jobs.Common/NuGet.Jobs.Common.csproj @@ -49,6 +49,7 @@ + diff --git a/src/StatusAggregator/Cursor.cs b/src/StatusAggregator/Cursor.cs new file mode 100644 index 000000000..dd1bc25af --- /dev/null +++ b/src/StatusAggregator/Cursor.cs @@ -0,0 +1,60 @@ +// 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.Table; + +namespace StatusAggregator +{ + public class Cursor : ICursor + { + public Cursor( + ITableWrapper table, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + private readonly ITableWrapper _table; + + private readonly ILogger _logger; + + public async Task Get() + { + using (_logger.Scope("Fetching cursor.")) + { + var cursor = await _table.Retrieve( + CursorEntity.DefaultPartitionKey, CursorEntity.DefaultRowKey); + + DateTime value; + if (cursor == null) + { + // If we can't find a cursor, the job is likely uninitialized, so start at the beginning of time. + value = DateTime.MinValue; + _logger.LogInformation("Could not fetch cursor, reinitializing cursor at {Cursor}.", value); + } + else + { + value = cursor.Value; + _logger.LogInformation("Fetched cursor with value {Cursor}.", value); + } + + return value; + } + } + + public Task Set(DateTime value) + { + using (_logger.Scope("Updating cursor to {Cursor}.", value)) + { + var cursorEntity = new CursorEntity(value); + return _table.InsertOrReplaceAsync(cursorEntity); + } + } + } +} diff --git a/src/StatusAggregator/EventUpdater.cs b/src/StatusAggregator/EventUpdater.cs new file mode 100644 index 000000000..b5d320f59 --- /dev/null +++ b/src/StatusAggregator/EventUpdater.cs @@ -0,0 +1,97 @@ +// 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; + } + + var shouldDeactivate = !incidentsLinkedToEventQuery + .Where(i => i.IsActive || i.MitigationTime > cursor - _eventEndDelay) + .ToList() + .Any(); + + 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/ICursor.cs b/src/StatusAggregator/ICursor.cs new file mode 100644 index 000000000..bb6754b4e --- /dev/null +++ b/src/StatusAggregator/ICursor.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.Threading.Tasks; + +namespace StatusAggregator +{ + /// + /// Maintains the current progress of the job. + /// + public interface ICursor + { + Task Get(); + Task Set(DateTime value); + } +} diff --git a/src/StatusAggregator/IEventUpdater.cs b/src/StatusAggregator/IEventUpdater.cs new file mode 100644 index 000000000..e32650d4a --- /dev/null +++ b/src/StatusAggregator/IEventUpdater.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 +{ + /// + /// 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 new file mode 100644 index 000000000..975507d8a --- /dev/null +++ b/src/StatusAggregator/IIncidentFactory.cs @@ -0,0 +1,20 @@ +// 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 new file mode 100644 index 000000000..d0be4ba69 --- /dev/null +++ b/src/StatusAggregator/IIncidentUpdater.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 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 new file mode 100644 index 000000000..7b922aee5 --- /dev/null +++ b/src/StatusAggregator/IMessageUpdater.cs @@ -0,0 +1,26 @@ +// 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/IStatusExporter.cs b/src/StatusAggregator/IStatusExporter.cs new file mode 100644 index 000000000..0ed21b316 --- /dev/null +++ b/src/StatusAggregator/IStatusExporter.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 System.Threading.Tasks; + +namespace StatusAggregator +{ + public interface IStatusExporter + { + /// + /// Builds a and exports it to public storage so that it can be consumed by other services. + /// + Task Export(); + } +} diff --git a/src/StatusAggregator/IStatusUpdater.cs b/src/StatusAggregator/IStatusUpdater.cs new file mode 100644 index 000000000..b2565427e --- /dev/null +++ b/src/StatusAggregator/IStatusUpdater.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 System.Threading.Tasks; + +namespace StatusAggregator +{ + public interface IStatusUpdater + { + /// + /// Aggregates the information necessary to build a that describes the NuGet service. + /// + Task Update(); + } +} diff --git a/src/StatusAggregator/IncidentFactory.cs b/src/StatusAggregator/IncidentFactory.cs new file mode 100644 index 000000000..8fd1399e8 --- /dev/null +++ b/src/StatusAggregator/IncidentFactory.cs @@ -0,0 +1,98 @@ +// 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/IncidentUpdater.cs b/src/StatusAggregator/IncidentUpdater.cs new file mode 100644 index 000000000..c67aba462 --- /dev/null +++ b/src/StatusAggregator/IncidentUpdater.cs @@ -0,0 +1,103 @@ +// 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.Parse; +using StatusAggregator.Table; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace StatusAggregator +{ + public class IncidentUpdater : IIncidentUpdater + { + private readonly ITableWrapper _table; + private readonly IEventUpdater _eventUpdater; + private readonly IAggregateIncidentParser _aggregateIncidentParser; + private readonly IIncidentApiClient _incidentApiClient; + private readonly IIncidentFactory _incidentFactory; + private readonly ILogger _logger; + + private readonly string _incidentApiTeamId; + + public IncidentUpdater( + ITableWrapper table, + IEventUpdater eventUpdater, + IIncidentApiClient incidentApiClient, + IAggregateIncidentParser aggregateIncidentParser, + IIncidentFactory incidentFactory, + StatusAggregatorConfiguration configuration, + ILogger logger) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _eventUpdater = eventUpdater ?? throw new ArgumentNullException(nameof(eventUpdater)); + _incidentApiClient = incidentApiClient ?? throw new ArgumentNullException(nameof(incidentApiClient)); + _aggregateIncidentParser = aggregateIncidentParser ?? throw new ArgumentNullException(nameof(aggregateIncidentParser)); + _incidentFactory = incidentFactory ?? throw new ArgumentNullException(nameof(incidentFactory)); + _incidentApiTeamId = configuration?.TeamId ?? throw new ArgumentNullException(nameof(configuration)); + _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 async Task FetchNewIncidents(DateTime cursor) + { + using (_logger.Scope("Fetching all new incidents since {Cursor}.", cursor)) + { + var incidents = (await _incidentApiClient.GetIncidents(GetRecentIncidentsQuery(cursor))) + // The incident API trims the milliseconds from any filter. + // Therefore, a query asking for incidents newer than '2018-06-29T00:00:00.5Z' will return an incident from '2018-06-29T00:00:00.25Z' + // We must perform a check on the CreateDate ourselves to verify that no old incidents are returned. + .Where(i => i.CreateDate > cursor) + .ToList(); + + var parsedIncidents = incidents + .SelectMany(i => _aggregateIncidentParser.ParseIncident(i)) + .ToList(); + foreach (var parsedIncident in parsedIncidents.OrderBy(i => i.CreationTime)) + { + await _incidentFactory.CreateIncident(parsedIncident); + } + + return incidents.Any() ? incidents.Max(i => i.CreateDate) : (DateTime?)null; + } + } + + private string GetRecentIncidentsQuery(DateTime cursor) + { + var query = $"$filter=OwningTeamId eq '{_incidentApiTeamId}'"; + + if (cursor != DateTime.MinValue) + { + query += $" and CreateDate gt datetime'{cursor.ToString("o")}'"; + } + + return query; + } + } +} diff --git a/src/StatusAggregator/Job.cs b/src/StatusAggregator/Job.cs new file mode 100644 index 000000000..c1b8a4c9c --- /dev/null +++ b/src/StatusAggregator/Job.cs @@ -0,0 +1,158 @@ +// 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.ComponentModel.Design; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.WindowsAzure.Storage; +using Newtonsoft.Json.Linq; +using NuGet.Jobs; +using NuGet.Services.Incidents; +using StatusAggregator.Parse; +using StatusAggregator.Table; + +namespace StatusAggregator +{ + public class Job : JobBase + { + public IServiceProvider _serviceProvider; + + public override void Init(IServiceContainer serviceContainer, IDictionary jobArgsDictionary) + { + var serviceCollection = new ServiceCollection(); + + AddLogging(serviceCollection); + AddConfiguration(serviceCollection, jobArgsDictionary); + AddStorage(serviceCollection); + AddServices(serviceCollection); + + _serviceProvider = serviceCollection.BuildServiceProvider(); + } + + public override Task Run() + { + return _serviceProvider + .GetRequiredService() + .Run(); + } + + private static void AddServices(IServiceCollection serviceCollection) + { + serviceCollection.AddTransient(); + serviceCollection.AddSingleton(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + AddParsing(serviceCollection); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + } + + private static void AddParsing(IServiceCollection serviceCollection) + { + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + + serviceCollection.AddTransient(); + } + + private static void AddStorage(IServiceCollection serviceCollection) + { + serviceCollection.AddSingleton( + serviceProvider => + { + var configuration = serviceProvider.GetRequiredService(); + return CloudStorageAccount.Parse(configuration.StorageAccount); + }); + + serviceCollection.AddSingleton( + serviceProvider => + { + var storageAccount = serviceProvider.GetRequiredService(); + var configuration = serviceProvider.GetRequiredService(); + return new TableWrapper(storageAccount, configuration.TableName); + }); + + serviceCollection.AddSingleton( + serviceProvider => + { + var storageAccount = serviceProvider.GetRequiredService(); + var blobClient = storageAccount.CreateCloudBlobClient(); + var configuration = serviceProvider.GetRequiredService(); + return blobClient.GetContainerReference(configuration.ContainerName); + }); + } + + private const int _defaultEventStartMessageDelayMinutes = 15; + private const int _defaultEventEndDelayMinutes = 10; + private const int _defaultEventVisibilityPeriod = 10; + + private static void AddConfiguration(IServiceCollection serviceCollection, IDictionary jobArgsDictionary) + { + var configuration = new StatusAggregatorConfiguration() + { + StorageAccount = + JobConfigurationManager.GetArgument(jobArgsDictionary, JobArgumentNames.StatusStorageAccount), + ContainerName = + JobConfigurationManager.GetArgument(jobArgsDictionary, JobArgumentNames.StatusContainerName), + TableName = + JobConfigurationManager.GetArgument(jobArgsDictionary, JobArgumentNames.StatusTableName), + Environments = + JobConfigurationManager.GetArgument(jobArgsDictionary, JobArgumentNames.StatusEnvironment) + .Split(';'), + MaximumSeverity = + JobConfigurationManager.TryGetIntArgument(jobArgsDictionary, JobArgumentNames.StatusMaximumSeverity) + ?? int.MaxValue, + TeamId = + JobConfigurationManager.GetArgument(jobArgsDictionary, JobArgumentNames.StatusIncidentApiTeamId), + EventStartMessageDelayMinutes = + JobConfigurationManager.TryGetIntArgument(jobArgsDictionary, JobArgumentNames.StatusEventStartMessageDelayMinutes) + ?? _defaultEventStartMessageDelayMinutes, + EventEndDelayMinutes = + JobConfigurationManager.TryGetIntArgument(jobArgsDictionary, JobArgumentNames.StatusEventEndDelayMinutes) + ?? _defaultEventEndDelayMinutes, + EventVisibilityPeriodDays = + JobConfigurationManager.TryGetIntArgument(jobArgsDictionary, JobArgumentNames.StatusEventVisibilityPeriodDays) + ?? _defaultEventVisibilityPeriod, + }; + + serviceCollection.AddSingleton(configuration); + + var incidentApiConfiguration = new IncidentApiConfiguration() + { + BaseUri = + new Uri(JobConfigurationManager.GetArgument(jobArgsDictionary, JobArgumentNames.StatusIncidentApiBaseUri)), + Certificate = + GetCertificateFromJson(JobConfigurationManager.GetArgument(jobArgsDictionary, JobArgumentNames.StatusIncidentApiCertificate)) + }; + + serviceCollection.AddSingleton(incidentApiConfiguration); + } + + private void AddLogging(IServiceCollection serviceCollection) + { + serviceCollection.AddSingleton(LoggerFactory); + serviceCollection.AddLogging(); + } + + private static X509Certificate2 GetCertificateFromJson(string certJson) + { + var certJObject = JObject.Parse(certJson); + + var certData = certJObject["Data"].Value(); + var certPassword = certJObject["Password"].Value(); + + var certBytes = Convert.FromBase64String(certData); + return new X509Certificate2(certBytes, certPassword); + } + } +} diff --git a/src/StatusAggregator/LogEvents.cs b/src/StatusAggregator/LogEvents.cs new file mode 100644 index 000000000..0075ca510 --- /dev/null +++ b/src/StatusAggregator/LogEvents.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.Logging; + +namespace StatusAggregator +{ + public static class LogEvents + { + public static EventId RegexFailure = new EventId(400, "Failed to parse incident using Regex."); + } +} diff --git a/src/StatusAggregator/MessageUpdater.cs b/src/StatusAggregator/MessageUpdater.cs new file mode 100644 index 000000000..7c4900c2d --- /dev/null +++ b/src/StatusAggregator/MessageUpdater.cs @@ -0,0 +1,198 @@ +// 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; + } + + 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/NuGetServiceComponentFactory.cs b/src/StatusAggregator/NuGetServiceComponentFactory.cs new file mode 100644 index 000000000..9d7407004 --- /dev/null +++ b/src/StatusAggregator/NuGetServiceComponentFactory.cs @@ -0,0 +1,96 @@ +// 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 +{ + /// + /// Helps create an that represents the NuGet service as well as paths to its subcomponents. + /// + public static class NuGetServiceComponentFactory + { + public const string RootName = "NuGet"; + public const string GalleryName = "NuGet.org"; + public const string RestoreName = "Restore"; + public const string SearchName = "Search"; + public const string UploadName = "Package Publishing"; + + public const string V2ProtocolName = "V2 Protocol"; + public const string V3ProtocolName = "V3 Protocol"; + + public const string GlobalRegionName = "Global"; + public const string ChinaRegionName = "China"; + + public const string UsncInstanceName = "North Central US"; + 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() + { + return new TreeComponent( + RootName, + "", + new IComponent[] + { + new PrimarySecondaryComponent( + GalleryName, + "Browsing the Gallery website", + new[] + { + new LeafComponent(UsncInstanceName, "Primary region"), + new LeafComponent(UsscInstanceName, "Backup region") + }), + new TreeComponent( + RestoreName, + "Downloading and installing packages from NuGet", + new IComponent[] + { + new TreeComponent( + V3ProtocolName, + "Restore using the V3 API", + new[] + { + new LeafComponent(GlobalRegionName, "V3 restore for users outside of China"), + new LeafComponent(ChinaRegionName, "V3 restore for users inside China") + }), + new PrimarySecondaryComponent( + V2ProtocolName, + "Restore using the V2 API", + new[] + { + new LeafComponent(UsncInstanceName, "Primary region"), + new LeafComponent(UsscInstanceName, "Backup region") + }) + }), + new TreeComponent( + SearchName, + "Searching for new and existing packages in Visual Studio or the Gallery website", + new[] + { + new PrimarySecondaryComponent( + GlobalRegionName, + "Search for packages outside China", + new[] + { + new LeafComponent(UsncInstanceName, "Primary region"), + new LeafComponent(UsscInstanceName, "Backup region") + }), + new PrimarySecondaryComponent( + ChinaRegionName, + "Search for packages inside China", + new[] + { + new LeafComponent(EaInstanceName, "Primary region"), + new LeafComponent(SeaInstanceName, "Backup region") + }) + }), + new LeafComponent(UploadName, "Uploading new packages to NuGet.org") + }); + } + } +} diff --git a/src/StatusAggregator/Parse/AggregateIncidentParser.cs b/src/StatusAggregator/Parse/AggregateIncidentParser.cs new file mode 100644 index 000000000..934714bcc --- /dev/null +++ b/src/StatusAggregator/Parse/AggregateIncidentParser.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 Microsoft.Extensions.Logging; +using NuGet.Jobs.Extensions; +using NuGet.Services.Incidents; +using System; +using System.Collections.Generic; + +namespace StatusAggregator.Parse +{ + /// + /// Default implementation of that returns all s returned by its s. + /// + public class AggregateIncidentParser : IAggregateIncidentParser + { + private readonly IEnumerable _incidentParsers; + + private readonly ILogger _logger; + + public AggregateIncidentParser( + IEnumerable incidentParsers, + ILogger logger) + { + _incidentParsers = incidentParsers ?? throw new ArgumentNullException(nameof(incidentParsers)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public IEnumerable ParseIncident(Incident incident) + { + using (_logger.Scope("Parsing incident {IncidentId}", incident.Id)) + { + var parsedIncidents = new List(); + foreach (var incidentParser in _incidentParsers) + { + if (incidentParser.TryParseIncident(incident, out var parsedIncident)) + { + parsedIncidents.Add(parsedIncident); + } + } + + return parsedIncidents; + } + } + } +} diff --git a/src/StatusAggregator/Parse/EnvironmentFilter.cs b/src/StatusAggregator/Parse/EnvironmentFilter.cs new file mode 100644 index 000000000..bf5943496 --- /dev/null +++ b/src/StatusAggregator/Parse/EnvironmentFilter.cs @@ -0,0 +1,52 @@ +// 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 System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace StatusAggregator.Parse +{ + /// + /// Expects that the contains a named with a whitelisted value. + /// + public class EnvironmentFilter : IIncidentParsingFilter + { + public const string EnvironmentGroupName = "Environment"; + + private IEnumerable _environments { get; } + + private readonly ILogger _logger; + + public EnvironmentFilter( + StatusAggregatorConfiguration configuration, + ILogger logger) + { + _environments = configuration?.Environments ?? throw new ArgumentNullException(nameof(configuration)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public bool ShouldParse(Incident incident, GroupCollection groups) + { + var group = groups[EnvironmentGroupName]; + + if (group.Success) + { + var groupValue = group.Value; + _logger.LogInformation("Incident has environment of {Environment}, expecting one of {Environments}.", + groupValue, string.Join(";", _environments)); + return _environments.Any( + e => string.Equals(groups[EnvironmentGroupName].Value, e, StringComparison.OrdinalIgnoreCase)); + } + else + { + _logger.LogInformation("Incident does not have an enviroment group, will not filter by environment."); + return true; + } + } + } +} diff --git a/src/StatusAggregator/Parse/EnvironmentPrefixIncidentParser.cs b/src/StatusAggregator/Parse/EnvironmentPrefixIncidentParser.cs new file mode 100644 index 000000000..2af15eaee --- /dev/null +++ b/src/StatusAggregator/Parse/EnvironmentPrefixIncidentParser.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 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/IAggregateIncidentParser.cs b/src/StatusAggregator/Parse/IAggregateIncidentParser.cs new file mode 100644 index 000000000..8bbcdc6af --- /dev/null +++ b/src/StatusAggregator/Parse/IAggregateIncidentParser.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.Incidents; +using System.Collections.Generic; + +namespace StatusAggregator.Parse +{ + /// + /// Aggregates the result of multiple s on . + /// + public interface IAggregateIncidentParser + { + /// + /// Runs multiple 's with and aggregates their results. + /// + IEnumerable ParseIncident(Incident incident); + } +} diff --git a/src/StatusAggregator/Parse/IIncidentParser.cs b/src/StatusAggregator/Parse/IIncidentParser.cs new file mode 100644 index 000000000..c28354f6a --- /dev/null +++ b/src/StatusAggregator/Parse/IIncidentParser.cs @@ -0,0 +1,25 @@ +// 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 +{ + /// + /// Parses s and determines whether or not they should be used to determine the . + /// + public interface IIncidentParser + { + /// + /// Attempts to parse into . + /// + /// + /// A that describes or null if could not be parsed. + /// + /// + /// true if this can be parse and false otherwise. + /// + bool TryParseIncident(Incident incident, out ParsedIncident parsedIncident); + } +} diff --git a/src/StatusAggregator/Parse/IIncidentParsingFilter.cs b/src/StatusAggregator/Parse/IIncidentParsingFilter.cs new file mode 100644 index 000000000..efed05e9d --- /dev/null +++ b/src/StatusAggregator/Parse/IIncidentParsingFilter.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.Incidents; +using System.Text.RegularExpressions; + +namespace StatusAggregator.Parse +{ + /// + /// An additional filter that can be applied to a + /// + public interface IIncidentParsingFilter + { + /// + /// Returns whether or not an should parse . + /// + bool ShouldParse(Incident incident, GroupCollection groups); + } +} diff --git a/src/StatusAggregator/Parse/IncidentParser.cs b/src/StatusAggregator/Parse/IncidentParser.cs new file mode 100644 index 000000000..86745c693 --- /dev/null +++ b/src/StatusAggregator/Parse/IncidentParser.cs @@ -0,0 +1,136 @@ +// 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; +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. + /// + public abstract class IncidentParser : IIncidentParser + { + private readonly static TimeSpan MaxRegexExecutionTime = TimeSpan.FromSeconds(5); + + private readonly string _regExPattern; + + private readonly IEnumerable _filters; + + private readonly ILogger _logger; + + public IncidentParser( + string regExPattern, + ILogger logger) + { + _regExPattern = regExPattern ?? throw new ArgumentNullException(nameof(regExPattern)); + _filters = Enumerable.Empty(); + _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)) + { + parsedIncident = null; + + Match match = null; + try + { + match = Regex.Match(title, _regExPattern, RegexOptions.None, MaxRegexExecutionTime); + } + catch (Exception e) + { + _logger.LogError(LogEvents.RegexFailure, e, "Failed to parse incident using regex!"); + return false; + } + + if (match == null) + { + _logger.LogError("Parsed incident using regex successfully, but was unable to get match information!"); + return false; + } + + _logger.LogInformation("RegEx match result: {MatchResult}", title, match.Success); + return match.Success && TryParseIncident(incident, match.Groups, out parsedIncident); + } + } + + private bool TryParseIncident(Incident incident, GroupCollection groups, out ParsedIncident parsedIncident) + { + parsedIncident = null; + + if (_filters.Any(f => + { + using (_logger.Scope("Filtering incident using filter {IncidentFilterType}", f.GetType())) + { + var shouldParse = f.ShouldParse(incident, groups); + _logger.LogInformation("Filter returned {FilterResult}.", shouldParse); + return !shouldParse; + } + })) + { + _logger.LogInformation("Incident failed at least one filter!"); + return false; + } + + if (!TryParseAffectedComponentPath(incident, groups, out var affectedComponentPath)) + { + _logger.LogInformation("Could not parse incident component path!"); + return false; + } + + _logger.LogInformation("Parsed affected component path {AffectedComponentPath}.", affectedComponentPath); + + if (!TryParseAffectedComponentStatus(incident, groups, out var affectedComponentStatus)) + { + _logger.LogInformation("Could not parse incident component status!"); + return false; + } + + _logger.LogInformation("Parsed affected component status {AffectedComponentPath}.", affectedComponentStatus); + + 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/OutdatedSearchServiceInstanceIncidentParser.cs b/src/StatusAggregator/Parse/OutdatedSearchServiceInstanceIncidentParser.cs new file mode 100644 index 000000000..f0843d41c --- /dev/null +++ b/src/StatusAggregator/Parse/OutdatedSearchServiceInstanceIncidentParser.cs @@ -0,0 +1,35 @@ +// 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.Text.RegularExpressions; + +namespace StatusAggregator.Parse +{ + public class OutdatedSearchServiceInstanceIncidentParser : EnvironmentPrefixIncidentParser + { + private const string SubtitleRegEx = "A search service instance is using an outdated index!"; + + public OutdatedSearchServiceInstanceIncidentParser( + IEnumerable filters, + ILogger logger) + : base(SubtitleRegEx, filters, logger) + { + } + + protected 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) + { + affectedComponentStatus = ComponentStatus.Degraded; + return true; + } + } +} diff --git a/src/StatusAggregator/Parse/ParsedIncident.cs b/src/StatusAggregator/Parse/ParsedIncident.cs new file mode 100644 index 000000000..4ce85ad10 --- /dev/null +++ b/src/StatusAggregator/Parse/ParsedIncident.cs @@ -0,0 +1,38 @@ +// 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; +using System; + +namespace StatusAggregator.Parse +{ + /// + /// Describes how a affects a . + /// + public class ParsedIncident + { + public ParsedIncident( + Incident incident, + string affectedComponentPath, + ComponentStatus affectedComponentStatus) + { + if (incident == null) + { + throw new ArgumentNullException(nameof(incident)); + } + + Id = incident.Id; + CreationTime = incident.Source.CreateDate; + MitigationTime = 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; } + } +} diff --git a/src/StatusAggregator/Parse/PingdomIncidentParser.cs b/src/StatusAggregator/Parse/PingdomIncidentParser.cs new file mode 100644 index 000000000..4f202cf9f --- /dev/null +++ b/src/StatusAggregator/Parse/PingdomIncidentParser.cs @@ -0,0 +1,104 @@ +// 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; + +namespace StatusAggregator.Parse +{ + public class PingdomIncidentParser : IncidentParser + { + private const string CheckNameGroupName = "CheckName"; + private const string CheckUrlGroupName = "CheckUrl"; + private static string SubtitleRegEx = $@"Pingdom check '(?<{CheckNameGroupName}>.*)' is failing! '(?<{CheckUrlGroupName}>.*)' is DOWN!"; + + private readonly ILogger _logger; + + public PingdomIncidentParser( + IEnumerable filters, + ILogger logger) + : base(SubtitleRegEx, filters, logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override bool TryParseAffectedComponentPath(Incident incident, GroupCollection groups, out string affectedComponentPath) + { + affectedComponentPath = null; + + var checkName = groups[CheckNameGroupName].Value; + _logger.LogInformation("Check name is {CheckName}.", checkName); + + switch (checkName) + { + case "CDN DNS": + affectedComponentPath = ComponentUtility.GetPath( + NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName); + break; + case "CDN Global": + affectedComponentPath = ComponentUtility.GetPath( + NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName, NuGetServiceComponentFactory.GlobalRegionName); + break; + case "CDN China": + affectedComponentPath = ComponentUtility.GetPath( + NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V3ProtocolName, NuGetServiceComponentFactory.ChinaRegionName); + break; + case "Gallery DNS": + case "Gallery Home": + affectedComponentPath = ComponentUtility.GetPath( + NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.GalleryName); + break; + case "Gallery USNC /": + case "Gallery USNC /Packages": + affectedComponentPath = ComponentUtility.GetPath( + NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.GalleryName, NuGetServiceComponentFactory.UsncInstanceName); + break; + case "Gallery USSC /": + case "Gallery USSC /Packages": + affectedComponentPath = ComponentUtility.GetPath( + NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.GalleryName, NuGetServiceComponentFactory.UsscInstanceName); + break; + case "Gallery USNC /api/v2/Packages()": + case "Gallery USNC /api/v2/package/NuGet.GalleryUptime/1.0.0": + affectedComponentPath = ComponentUtility.GetPath( + NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V2ProtocolName, NuGetServiceComponentFactory.UsncInstanceName); + break; + case "Gallery USSC /api/v2/Packages()": + case "Gallery USSC /api/v2/package/NuGet.GalleryUptime/1.0.0": + affectedComponentPath = ComponentUtility.GetPath( + NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.RestoreName, NuGetServiceComponentFactory.V2ProtocolName, NuGetServiceComponentFactory.UsscInstanceName); + break; + case "Search USNC /query": + affectedComponentPath = ComponentUtility.GetPath( + NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.GlobalRegionName, NuGetServiceComponentFactory.UsncInstanceName); + break; + case "Search USSC /query": + affectedComponentPath = ComponentUtility.GetPath( + NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.GlobalRegionName, NuGetServiceComponentFactory.UsscInstanceName); + break; + case "Search EA /query": + affectedComponentPath = ComponentUtility.GetPath( + NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.ChinaRegionName, NuGetServiceComponentFactory.EaInstanceName); + break; + case "Search SEA /query": + affectedComponentPath = ComponentUtility.GetPath( + NuGetServiceComponentFactory.RootName, NuGetServiceComponentFactory.SearchName, NuGetServiceComponentFactory.ChinaRegionName, NuGetServiceComponentFactory.SeaInstanceName); + break; + default: + return false; + } + + return true; + } + + protected 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/SeverityFilter.cs new file mode 100644 index 000000000..53346e5fc --- /dev/null +++ b/src/StatusAggregator/Parse/SeverityFilter.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 Microsoft.Extensions.Logging; +using NuGet.Services.Incidents; +using System; +using System.Text.RegularExpressions; + +namespace StatusAggregator.Parse +{ + /// + /// Expects that the severity of an must be lower than a threshold. + /// + public class SeverityFilter : IIncidentParsingFilter + { + private readonly int _maximumSeverity; + + private readonly ILogger _logger; + + public SeverityFilter( + StatusAggregatorConfiguration configuration, + ILogger logger) + { + _maximumSeverity = configuration?.MaximumSeverity ?? throw new ArgumentNullException(nameof(configuration)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public bool ShouldParse(Incident incident, GroupCollection groups) + { + var actualSeverity = incident.Severity; + _logger.LogInformation( + "Filtering incident severity: severity is {IncidentSeverity}, must be less than or equal to {MaximumSeverity}", + actualSeverity, _maximumSeverity); + return actualSeverity <= _maximumSeverity; + } + } +} diff --git a/src/StatusAggregator/Parse/ValidationDurationIncidentParser.cs b/src/StatusAggregator/Parse/ValidationDurationIncidentParser.cs new file mode 100644 index 000000000..8d9d80480 --- /dev/null +++ b/src/StatusAggregator/Parse/ValidationDurationIncidentParser.cs @@ -0,0 +1,35 @@ +// 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 NuGet.Services.Incidents; +using NuGet.Services.Status; + +namespace StatusAggregator.Parse +{ + public class ValidationDurationIncidentParser : EnvironmentPrefixIncidentParser + { + private const string SubtitleRegEx = "Too many packages are stuck in the \"Validating\" state!"; + + public ValidationDurationIncidentParser( + IEnumerable filters, + ILogger logger) + : base(SubtitleRegEx, filters, logger) + { + } + + protected 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) + { + affectedComponentStatus = ComponentStatus.Degraded; + return true; + } + } +} diff --git a/src/StatusAggregator/Program.cs b/src/StatusAggregator/Program.cs new file mode 100644 index 000000000..127275b30 --- /dev/null +++ b/src/StatusAggregator/Program.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.Jobs; + +namespace StatusAggregator +{ + public class Program + { + public static void Main(string[] args) + { + var job = new Job(); + JobRunner.Run(job, args).GetAwaiter().GetResult(); + } + } +} diff --git a/src/StatusAggregator/Properties/AssemblyInfo.cs b/src/StatusAggregator/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..3c3117262 --- /dev/null +++ b/src/StatusAggregator/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("StatusAggregator")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("StatusAggregator")] +[assembly: AssemblyCopyright("Copyright © 2018")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("d357fdb5-bf19-41a5-82b0-14c8cec2a5eb")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/src/StatusAggregator/Scripts/Functions.ps1 b/src/StatusAggregator/Scripts/Functions.ps1 new file mode 100644 index 000000000..a8bff40fc --- /dev/null +++ b/src/StatusAggregator/Scripts/Functions.ps1 @@ -0,0 +1,30 @@ +Function Uninstall-NuGetService() { + Param ([string]$ServiceName) + + if (Get-Service $ServiceName -ErrorAction SilentlyContinue) + { + Write-Host Removing service $ServiceName... + Stop-Service $ServiceName -Force + sc.exe delete $ServiceName + Write-Host Removed service $ServiceName. + } else { + Write-Host Skipping removal of service $ServiceName - no such service exists. + } +} + +Function Install-NuGetService() { + Param ([string]$ServiceName, [string]$ServiceTitle, [string]$ScriptToRun) + + Write-Host Installing service $ServiceName... + + $installService = "nssm install $ServiceName $ScriptToRun" + cmd /C $installService + + Set-Service -Name $ServiceName -DisplayName "$ServiceTitle - $ServiceName" -Description "Runs $ServiceTitle." -StartupType Automatic + sc.exe failure $ServiceName reset= 30 actions= restart/5000 + + # Run service + net start $ServiceName + + Write-Host Installed service $ServiceName. +} \ No newline at end of file diff --git a/src/StatusAggregator/Scripts/PostDeploy.ps1 b/src/StatusAggregator/Scripts/PostDeploy.ps1 new file mode 100644 index 000000000..7d5183d5b --- /dev/null +++ b/src/StatusAggregator/Scripts/PostDeploy.ps1 @@ -0,0 +1,18 @@ +. .\Functions.ps1 + +$jobsToInstall = $OctopusParameters["Jobs.ServiceNames"].Split("{,}") + +Write-Host Installing services... + +$currentDirectory = [string](Get-Location) + +$jobsToInstall.Split("{;}") | %{ + $serviceName = $_ + $serviceTitle = $OctopusParameters["Jobs.$serviceName.Title"] + $scriptToRun = $OctopusParameters["Jobs.$serviceName.Script"] + $scriptToRun = "$currentDirectory\$scriptToRun" + + Install-NuGetService -ServiceName $serviceName -ServiceTitle $serviceTitle -ScriptToRun $scriptToRun +} + +Write-Host Installed services. \ No newline at end of file diff --git a/src/StatusAggregator/Scripts/PreDeploy.ps1 b/src/StatusAggregator/Scripts/PreDeploy.ps1 new file mode 100644 index 000000000..ef711a912 --- /dev/null +++ b/src/StatusAggregator/Scripts/PreDeploy.ps1 @@ -0,0 +1,11 @@ +. .\Functions.ps1 + +$jobsToInstall = $OctopusParameters["Jobs.ServiceNames"].Split("{,}") + +Write-Host Removing services... + +$jobsToInstall.Split("{;}") | %{ + Uninstall-NuGetService -ServiceName $_ +} + +Write-Host Removed services. \ No newline at end of file diff --git a/src/StatusAggregator/Scripts/StatusAggregator.cmd b/src/StatusAggregator/Scripts/StatusAggregator.cmd new file mode 100644 index 000000000..f3754c5c8 --- /dev/null +++ b/src/StatusAggregator/Scripts/StatusAggregator.cmd @@ -0,0 +1,28 @@ +@echo OFF + +cd bin + +:Top +echo "Starting job - #{Jobs.statusaggregator.Title}" + +title #{Jobs.archivepackages.Title} + +start /w statusaggregator.exe ^ + -StatusIncidentApiBaseUri "#{Jobs.statusaggregator.IncidentApiBaseUri}" ^ + -StatusIncidentApiTeamId "#{Jobs.statusaggregator.IncidentApiTeamId}" ^ + -StatusIncidentApiCertificate "#{Jobs.statusaggregator.IncidentApiCertificate}" ^ + -StatusStorageAccount "#{Jobs.statusaggregator.StorageAccount}" ^ + -StatusContainerName "#{Jobs.statusaggregator.ContainerName}" ^ + -StatusTableName "#{Jobs.statusaggregator.TableName}" ^ + -StatusEnvironment "#{Jobs.statusaggregator.Environment}" ^ + -StatusMaximumSeverity "#{Jobs.statusaggregator.MaximumSeverity}" ^ + -VaultName "#{Deployment.Azure.KeyVault.VaultName}" ^ + -ClientId "#{Deployment.Azure.KeyVault.ClientId}" ^ + -CertificateThumbprint "#{Deployment.Azure.KeyVault.CertificateThumbprint}" ^ + -Sleep "#{Jobs.statusaggregator.Sleep}" ^ + -InstrumentationKey "#{Jobs.statusaggregator.ApplicationInsightsInstrumentationKey}" + +echo "Finished #{Jobs.statusaggregator.Title}" + +goto Top + \ No newline at end of file diff --git a/src/StatusAggregator/Scripts/nssm.exe b/src/StatusAggregator/Scripts/nssm.exe new file mode 100644 index 000000000..6ccfe3cfb Binary files /dev/null and b/src/StatusAggregator/Scripts/nssm.exe differ diff --git a/src/StatusAggregator/StatusAggregator.cs b/src/StatusAggregator/StatusAggregator.cs new file mode 100644 index 000000000..03b650877 --- /dev/null +++ b/src/StatusAggregator/StatusAggregator.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 Microsoft.WindowsAzure.Storage.Blob; +using StatusAggregator.Table; +using System; +using System.Threading.Tasks; + +namespace StatusAggregator +{ + public class StatusAggregator + { + private readonly CloudBlobContainer _container; + private readonly ITableWrapper _table; + + private readonly IStatusUpdater _statusUpdater; + private readonly IStatusExporter _statusExporter; + + public StatusAggregator( + CloudBlobContainer container, + ITableWrapper table, + IStatusUpdater statusUpdater, + IStatusExporter statusExporter) + { + _container = container ?? throw new ArgumentNullException(nameof(container)); + _table = table ?? throw new ArgumentNullException(nameof(table)); + _statusUpdater = statusUpdater ?? throw new ArgumentNullException(nameof(statusUpdater)); + _statusExporter = statusExporter ?? throw new ArgumentNullException(nameof(statusExporter)); + } + + public async Task Run() + { + await _table.CreateIfNotExistsAsync(); + await _container.CreateIfNotExistsAsync(); + + await _statusUpdater.Update(); + await _statusExporter.Export(); + } + } +} diff --git a/src/StatusAggregator/StatusAggregator.csproj b/src/StatusAggregator/StatusAggregator.csproj new file mode 100644 index 000000000..00eba2850 --- /dev/null +++ b/src/StatusAggregator/StatusAggregator.csproj @@ -0,0 +1,119 @@ + + + + + Debug + AnyCPU + {D357FDB5-BF19-41A5-82B0-14C8CEC2A5EB} + Exe + Properties + StatusAggregator + StatusAggregator + v4.6.2 + 512 + true + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + StatusAggregator.Program + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {4B4B1EFB-8F33-42E6-B79F-54E7F3293D31} + NuGet.Jobs.Common + + + + + + + + 1.1.1 + + + 2.28.0-sb-morestatus-35915 + + + 2.28.0-sb-morestatus-35915 + + + 2.28.0-sb-morestatus-35915 + + + 9.2.0 + + + + + + + + + \ No newline at end of file diff --git a/src/StatusAggregator/StatusAggregator.nuspec b/src/StatusAggregator/StatusAggregator.nuspec new file mode 100644 index 000000000..814ed5f51 --- /dev/null +++ b/src/StatusAggregator/StatusAggregator.nuspec @@ -0,0 +1,22 @@ + + + + StatusAggregator.$branch$ + $version$ + ArchivePackages + .NET Foundation + .NET Foundation + StatusAggregator + Copyright .NET Foundation + + + + + + + + + + + + \ No newline at end of file diff --git a/src/StatusAggregator/StatusAggregatorConfiguration.cs b/src/StatusAggregator/StatusAggregatorConfiguration.cs new file mode 100644 index 000000000..c38179351 --- /dev/null +++ b/src/StatusAggregator/StatusAggregatorConfiguration.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 NuGet.Services.Status; +using StatusAggregator.Parse; +using System.Collections.Generic; + +namespace StatusAggregator +{ + public class StatusAggregatorConfiguration + { + /// + /// A connection string for the storage account to use. + /// + public string StorageAccount { get; set; } + + /// + /// The container name to export the to. + /// + public string ContainerName { get; set; } + + /// + /// The table name to persist the in. + /// + public string TableName { get; set; } + + /// + /// A list of environments to filter incidents by. + /// See . + /// + public IEnumerable Environments { get; set; } + + /// + /// The maximum severity of any incidents to process. + /// See . + /// + public int MaximumSeverity { get; set; } + + /// + /// 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. + /// + public int EventEndDelayMinutes { get; set; } + + /// + /// The number of days that a deactivated event is visible in the . + /// An event is only added to by if it is active or it has been deactivated for less than this number of days. + /// + public int EventVisibilityPeriodDays { get; set; } + } +} diff --git a/src/StatusAggregator/StatusContractResolver.cs b/src/StatusAggregator/StatusContractResolver.cs new file mode 100644 index 000000000..cb9419116 --- /dev/null +++ b/src/StatusAggregator/StatusContractResolver.cs @@ -0,0 +1,73 @@ +// 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 new file mode 100644 index 000000000..8ebc12082 --- /dev/null +++ b/src/StatusAggregator/StatusExporter.cs @@ -0,0 +1,111 @@ +// 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 new file mode 100644 index 000000000..c6be8d687 --- /dev/null +++ b/src/StatusAggregator/StatusUpdater.cs @@ -0,0 +1,49 @@ +// 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 System; +using System.Threading.Tasks; + +namespace StatusAggregator +{ + public class StatusUpdater : IStatusUpdater + { + private readonly ICursor _cursor; + private readonly IIncidentUpdater _incidentUpdater; + private readonly IEventUpdater _eventUpdater; + + private readonly ILogger _logger; + + public StatusUpdater( + ICursor cursor, + IIncidentUpdater incidentUpdater, + IEventUpdater eventUpdater, + ILogger logger) + { + _cursor = cursor ?? throw new ArgumentNullException(nameof(cursor)); + _incidentUpdater = incidentUpdater ?? throw new ArgumentNullException(nameof(IncidentUpdater)); + _eventUpdater = eventUpdater ?? throw new ArgumentNullException(nameof(eventUpdater)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Update() + { + using (_logger.Scope("Updating service status.")) + { + var lastCursor = await _cursor.Get(); + + await _incidentUpdater.RefreshActiveIncidents(); + var nextCursor = await _incidentUpdater.FetchNewIncidents(lastCursor); + + await _eventUpdater.UpdateActiveEvents(nextCursor ?? DateTime.UtcNow); + + if (nextCursor.HasValue) + { + await _cursor.Set(nextCursor.Value); + } + } + } + } +} diff --git a/src/StatusAggregator/Table/ITableWrapper.cs b/src/StatusAggregator/Table/ITableWrapper.cs new file mode 100644 index 000000000..24aa23e38 --- /dev/null +++ b/src/StatusAggregator/Table/ITableWrapper.cs @@ -0,0 +1,22 @@ +// 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 System.Threading.Tasks; +using Microsoft.WindowsAzure.Storage.Table; + + +namespace StatusAggregator.Table +{ + public interface ITableWrapper + { + Task CreateIfNotExistsAsync(); + + Task Retrieve(string partitionKey, string rowKey) + where T : class, ITableEntity; + + Task InsertOrReplaceAsync(ITableEntity tableEntity); + + IQueryable CreateQuery() where T : ITableEntity, new(); + } +} diff --git a/src/StatusAggregator/Table/TableWrapper.cs b/src/StatusAggregator/Table/TableWrapper.cs new file mode 100644 index 000000000..56d908421 --- /dev/null +++ b/src/StatusAggregator/Table/TableWrapper.cs @@ -0,0 +1,48 @@ +// 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 System.Threading.Tasks; +using Microsoft.WindowsAzure.Storage; +using Microsoft.WindowsAzure.Storage.Table; + +namespace StatusAggregator.Table +{ + public class TableWrapper : ITableWrapper + { + public TableWrapper( + CloudStorageAccount storageAccount, + string tableName) + { + var tableClient = storageAccount.CreateCloudTableClient(); + _table = tableClient.GetTableReference(tableName); + } + + private readonly CloudTable _table; + + public Task CreateIfNotExistsAsync() + { + return _table.CreateIfNotExistsAsync(); + } + + public async Task Retrieve(string partitionKey, string rowKey) + where T : class, ITableEntity + { + var operation = TableOperation.Retrieve(partitionKey, rowKey); + return (await _table.ExecuteAsync(operation)).Result as T; + } + + public Task InsertOrReplaceAsync(ITableEntity tableEntity) + { + var operation = TableOperation.InsertOrReplace(tableEntity); + return _table.ExecuteAsync(operation); + } + + public IQueryable CreateQuery() where T : ITableEntity, new() + { + return _table + .CreateQuery() + .AsQueryable(); + } + } +} diff --git a/src/StatusAggregator/Table/TableWrapperExtensions.cs b/src/StatusAggregator/Table/TableWrapperExtensions.cs new file mode 100644 index 000000000..b3387a9d1 --- /dev/null +++ b/src/StatusAggregator/Table/TableWrapperExtensions.cs @@ -0,0 +1,38 @@ +// 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 System.Collections.Generic; +using System.Linq; + +namespace StatusAggregator.Table +{ + public static class TableWrapperExtensions + { + public static IQueryable GetActiveEvents(this ITableWrapper table) + { + return table + .CreateQuery() + .Where(e => e.PartitionKey == EventEntity.DefaultPartitionKey && e.IsActive); + } + + public static IQueryable GetIncidentsLinkedToEvent(this ITableWrapper table, EventEntity eventEntity) + { + 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); + } + } +} diff --git a/src/StatusAggregator/app.config b/src/StatusAggregator/app.config new file mode 100644 index 000000000..8881498ea --- /dev/null +++ b/src/StatusAggregator/app.config @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/test.ps1 b/test.ps1 index 359dcfeef..c7ccc7f08 100644 --- a/test.ps1 +++ b/test.ps1 @@ -38,7 +38,8 @@ Function Run-Tests { "tests\Validation.PackageSigning.RevalidateCertificate.Tests\bin\$Configuration\Validation.PackageSigning.RevalidateCertificate.Tests.dll", ` "tests\Validation.PackageSigning.Core.Tests\bin\$Configuration\Validation.PackageSigning.Core.Tests.dll", ` "tests\Validation.Common.Job.Tests\bin\$Configuration\Validation.Common.Job.Tests.dll", ` - "tests\Monitoring.RebootSearchInstance.Tests\bin\$Configuration\NuGet.Monitoring.RebootSearchInstance.Tests.dll", + "tests\StatusAggregator\bin\$Configuration\StatusAggregator.dll", ` + "tests\Monitoring.RebootSearchInstance.Tests\bin\$Configuration\NuGet.Monitoring.RebootSearchInstance.Tests.dll", ` "tests\Validation.Symbols.Tests\bin\$Configuration\Validation.Symbols.Tests.dll" $TestCount = 0 @@ -88,4 +89,4 @@ if ($BuildErrors) { Error-Log "Tests completed with $($BuildErrors.Count) error(s):`r`n$($ErrorLines -join "`r`n")" -Fatal } -Write-Host ("`r`n" * 3) \ No newline at end of file +Write-Host ("`r`n" * 3) diff --git a/tests/StatusAggregator.Tests/EventUpdaterTests.cs b/tests/StatusAggregator.Tests/EventUpdaterTests.cs new file mode 100644 index 000000000..6f7d49eb3 --- /dev/null +++ b/tests/StatusAggregator.Tests/EventUpdaterTests.cs @@ -0,0 +1,154 @@ +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/IncidentFactoryTests.cs b/tests/StatusAggregator.Tests/IncidentFactoryTests.cs new file mode 100644 index 000000000..b5fd3e3af --- /dev/null +++ b/tests/StatusAggregator.Tests/IncidentFactoryTests.cs @@ -0,0 +1,83 @@ +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/Properties/AssemblyInfo.cs b/tests/StatusAggregator.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..429b1f7c4 --- /dev/null +++ b/tests/StatusAggregator.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("StatusAggregator.Tests")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("StatusAggregator.Tests")] +[assembly: AssemblyCopyright("Copyright © 2018")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("784f938d-4142-4c1c-b654-0978fead1731")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/tests/StatusAggregator.Tests/StatusAggregator.Tests.csproj b/tests/StatusAggregator.Tests/StatusAggregator.Tests.csproj new file mode 100644 index 000000000..39c9b3192 --- /dev/null +++ b/tests/StatusAggregator.Tests/StatusAggregator.Tests.csproj @@ -0,0 +1,90 @@ + + + + + Debug + AnyCPU + {784F938D-4142-4C1C-B654-0978FEAD1731} + Library + Properties + StatusAggregator.Tests + StatusAggregator.Tests + v4.6.2 + 512 + true + + + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + {d357fdb5-bf19-41a5-82b0-14c8cec2a5eb} + StatusAggregator + + + + + 4.3.0 + + + 4.7.145 + + + 2.28.0-sb-morestatus-35915 + + + 2.28.0-sb-morestatus-35915 + + + 2.28.0-sb-morestatus-35915 + + + 4.3.0 + + + 4.4.0 + + + 9.2.0 + + + 2.3.1 + + + 2.3.1 + + + + \ No newline at end of file