Skip to content
This repository was archived by the owner on Jul 30, 2024. It is now read-only.

Commit 135fe02

Browse files
authored
Merge pull request #460 from NuGet/dev
[ReleasePrep][06.01.2018]RI of dev into master
2 parents 5b7c001 + 0abe198 commit 135fe02

18 files changed

Lines changed: 655 additions & 84 deletions

NuGet.Jobs.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Monitoring", "Monitoring",
129129
EndProject
130130
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Monitoring.PackageLag", "src\PackageLagMonitor\Monitoring.PackageLag.csproj", "{B5147169-E941-4CF8-9FCD-1C123ACD3149}"
131131
EndProject
132+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.CredentialExpiration", "tests\Tests.CredentialExpiration\Tests.CredentialExpiration.csproj", "{60152AB1-2EB4-4D44-B6D6-EEE24209A1F7}"
133+
EndProject
132134
Global
133135
GlobalSection(SolutionConfigurationPlatforms) = preSolution
134136
Debug|Any CPU = Debug|Any CPU
@@ -333,6 +335,10 @@ Global
333335
{B5147169-E941-4CF8-9FCD-1C123ACD3149}.Debug|Any CPU.Build.0 = Debug|Any CPU
334336
{B5147169-E941-4CF8-9FCD-1C123ACD3149}.Release|Any CPU.ActiveCfg = Release|Any CPU
335337
{B5147169-E941-4CF8-9FCD-1C123ACD3149}.Release|Any CPU.Build.0 = Release|Any CPU
338+
{60152AB1-2EB4-4D44-B6D6-EEE24209A1F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
339+
{60152AB1-2EB4-4D44-B6D6-EEE24209A1F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
340+
{60152AB1-2EB4-4D44-B6D6-EEE24209A1F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
341+
{60152AB1-2EB4-4D44-B6D6-EEE24209A1F7}.Release|Any CPU.Build.0 = Release|Any CPU
336342
EndGlobalSection
337343
GlobalSection(SolutionProperties) = preSolution
338344
HideSolutionNode = FALSE
@@ -387,6 +393,7 @@ Global
387393
{136411AF-B9FA-438D-B790-9FB78A5F7F54} = {6A776396-02B1-475D-A104-26940ADB04AB}
388394
{CAE45AC9-F11B-4215-9D1A-C98BC0F1F687} = {6A776396-02B1-475D-A104-26940ADB04AB}
389395
{B5147169-E941-4CF8-9FCD-1C123ACD3149} = {814F9B31-4AF3-46CC-AD61-CEB40F47083A}
396+
{60152AB1-2EB4-4D44-B6D6-EEE24209A1F7} = {6A776396-02B1-475D-A104-26940ADB04AB}
390397
EndGlobalSection
391398
GlobalSection(ExtensibilityGlobals) = postSolution
392399
SolutionGuid = {284A7AC3-FB43-4F1F-9C9C-2AF0E1F46C2B}

src/Gallery.CredentialExpiration/Gallery.CredentialExpiration.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@
4545
<Reference Include="System.Xml" />
4646
</ItemGroup>
4747
<ItemGroup>
48+
<Compile Include="GalleryCredentialExpiration.cs" />
49+
<Compile Include="ICredentialExpirationExporter.cs" />
50+
<Compile Include="JobRunTimeCursor.cs" />
4851
<Compile Include="LogEvents.cs" />
52+
<Compile Include="Models\CredentialExpirationJobMetadata.cs" />
4953
<Compile Include="MyJobArgumentNames.cs" />
5054
<Compile Include="Models\ExpiredCredentialData.cs" />
5155
<Compile Include="Job.cs" />
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Data.SqlClient;
7+
using System.Linq;
8+
using System.Threading.Tasks;
9+
using NuGet.Services.Sql;
10+
using Gallery.CredentialExpiration.Models;
11+
12+
namespace Gallery.CredentialExpiration
13+
{
14+
public class GalleryCredentialExpiration : ICredentialExpirationExporter
15+
{
16+
private readonly CredentialExpirationJobMetadata _jobMetadata;
17+
private readonly ISqlConnectionFactory _galleryDatabase;
18+
19+
public GalleryCredentialExpiration(CredentialExpirationJobMetadata jobMetadata, ISqlConnectionFactory galleryDatabase)
20+
{
21+
_jobMetadata = jobMetadata;
22+
_galleryDatabase = galleryDatabase;
23+
}
24+
25+
/// <summary>
26+
/// Used for the expiring credentials.
27+
/// </summary>
28+
/// <param name="jobMetadata"></param>
29+
/// <returns></returns>
30+
public DateTimeOffset GetMaxNotificationDate()
31+
{
32+
return _jobMetadata.JobRunTime.AddDays(_jobMetadata.WarnDaysBeforeExpiration);
33+
}
34+
35+
/// <summary>
36+
/// Used for the Expired credentials.
37+
/// </summary>
38+
/// <param name="jobMetadata"></param>
39+
/// <returns></returns>
40+
public DateTimeOffset GetMinNotificationDate()
41+
{
42+
// In case that the job failed to run for more than 1 day, go back more than the WarnDaysBeforeExpiration value
43+
// with the number of days that the job did not run
44+
return _jobMetadata.JobCursor.JobCursorTime;
45+
}
46+
47+
public async Task<List<ExpiredCredentialData>> GetCredentialsAsync(TimeSpan timeout)
48+
{
49+
// Set the day interval for the accounts that will be queried for expiring /expired credentials.
50+
var maxNotificationDate = ConvertToString(GetMaxNotificationDate());
51+
var minNotificationDate = ConvertToString(GetMinNotificationDate());
52+
53+
// Connect to database
54+
using (var galleryConnection = await _galleryDatabase.CreateAsync())
55+
{
56+
// Fetch credentials that expire in _warnDaysBeforeExpiration days
57+
// + the user's e-mail address
58+
return (await galleryConnection.QueryWithRetryAsync<ExpiredCredentialData>(
59+
Strings.GetExpiredCredentialsQuery,
60+
param: new { MaxNotificationDate = maxNotificationDate, MinNotificationDate = minNotificationDate },
61+
maxRetries: 3,
62+
commandTimeout: timeout)).ToList();
63+
}
64+
}
65+
66+
/// <summary>
67+
/// Send email of credential expired during the time interval [_jobMetadata.CursorTime, _jobMetadata.JobRunTime)
68+
/// </summary>
69+
/// <param name="credentialSet"></param>
70+
/// <returns></returns>
71+
public List<ExpiredCredentialData> GetExpiredCredentials(List<ExpiredCredentialData> credentialSet)
72+
{
73+
// Send email to the accounts that had credentials expired from the last execution.
74+
// The second condition is meant only for far cases that the SQL query for data filtering was modified by mistake and more credentials were included.
75+
return credentialSet.Where(x => (x.Expires < _jobMetadata.JobRunTime) && (x.Expires >= _jobMetadata.JobCursor.JobCursorTime)).ToList();
76+
}
77+
78+
/// <summary>
79+
/// Returns the expiring credentials.
80+
/// </summary>
81+
/// <param name="credentialSet"></param>
82+
/// <returns></returns>
83+
public List<ExpiredCredentialData> GetExpiringCredentials(List<ExpiredCredentialData> credentialSet)
84+
{
85+
// Send email to the accounts that will have credentials expiring in the next _warnDaysBeforeExpiration days and did not have any warning email sent yet.
86+
// Avoid cases when the cursor is out of date and MaxProcessedCredentialsTime < JobRuntime
87+
var sendEmailsDateLeftBoundary = (_jobMetadata.JobCursor.MaxProcessedCredentialsTime > _jobMetadata.JobRunTime) ? _jobMetadata.JobCursor.MaxProcessedCredentialsTime : _jobMetadata.JobRunTime;
88+
return credentialSet.Where( x => x.Expires > sendEmailsDateLeftBoundary).ToList();
89+
}
90+
91+
/// <summary>
92+
/// Converts a <see cref="DateTimeOffset"/> string with the "yyyy-MM-dd HH:mm:ss" format.
93+
/// </summary>
94+
/// <param name="value">The <see cref="DateTimeOffset"/> to be converterd.</param>
95+
/// <returns></returns>
96+
private string ConvertToString(DateTimeOffset value)
97+
{
98+
return value.ToString("yyyy-MM-dd HH:mm:ss");
99+
}
100+
}
101+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Threading.Tasks;
7+
using Gallery.CredentialExpiration.Models;
8+
9+
namespace Gallery.CredentialExpiration
10+
{
11+
public interface ICredentialExpirationExporter
12+
{
13+
/// <summary>
14+
/// Returns the entire credential set to work with.
15+
/// </summary>
16+
/// <param name="credentialSet"></param>
17+
/// <returns></returns>
18+
Task<List<ExpiredCredentialData>> GetCredentialsAsync(TimeSpan timeout);
19+
20+
/// <summary>
21+
/// Returns the set of expired credentials that will have notification emails sent.
22+
/// </summary>
23+
/// <param name="credentialSet"></param>
24+
/// <returns></returns>
25+
List<ExpiredCredentialData> GetExpiredCredentials(List<ExpiredCredentialData> credentialSet);
26+
27+
/// <summary>
28+
/// Returns the set of expiring credentials that will have notification emails sent.
29+
/// </summary>
30+
/// <param name="credentialSet"></param>
31+
/// <returns></returns>
32+
List<ExpiredCredentialData> GetExpiringCredentials(List<ExpiredCredentialData> credentialSet);
33+
}
34+
}

src/Gallery.CredentialExpiration/Job.cs

Lines changed: 34 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using Microsoft.Extensions.Logging;
1616
using Microsoft.WindowsAzure.Storage;
1717
using Newtonsoft.Json;
18+
using Newtonsoft.Json.Linq;
1819
using NuGet.Jobs;
1920
using NuGet.Services.KeyVault;
2021
using NuGet.Services.Sql;
@@ -26,8 +27,7 @@ public class Job : JobBase
2627
{
2728
private readonly TimeSpan _defaultCommandTimeout = TimeSpan.FromMinutes(30);
2829

29-
private readonly ConcurrentDictionary<string, DateTimeOffset> _contactedUsers = new ConcurrentDictionary<string, DateTimeOffset>();
30-
private readonly string _cursorFile = "cursor.json";
30+
private readonly string _cursorFile = "cursorv2.json";
3131

3232
private bool _whatIf = false;
3333

@@ -39,7 +39,6 @@ public class Job : JobBase
3939
private string _mailFrom;
4040
private SmtpClient _smtpClient;
4141

42-
private int _allowEmailResendAfterDays = 7;
4342
private int _warnDaysBeforeExpiration = 10;
4443

4544
private Storage _storage;
@@ -64,9 +63,6 @@ public override void Init(IServiceContainer serviceContainer, IDictionary<string
6463
_warnDaysBeforeExpiration = JobConfigurationManager.TryGetIntArgument(jobArgsDictionary, MyJobArgumentNames.WarnDaysBeforeExpiration)
6564
?? _warnDaysBeforeExpiration;
6665

67-
_allowEmailResendAfterDays = JobConfigurationManager.TryGetIntArgument(jobArgsDictionary, MyJobArgumentNames.AllowEmailResendAfterDays)
68-
?? _allowEmailResendAfterDays;
69-
7066
var storageConnectionString = JobConfigurationManager.GetArgument(jobArgsDictionary, JobArgumentNames.DataStorageAccount);
7167
var storageContainerName = JobConfigurationManager.GetArgument(jobArgsDictionary, JobArgumentNames.ContainerName);
7268

@@ -77,89 +73,61 @@ public override void Init(IServiceContainer serviceContainer, IDictionary<string
7773

7874
public override async Task Run()
7975
{
76+
var jobRunTime = DateTimeOffset.UtcNow;
77+
// Default values
78+
var jobCursor = new JobRunTimeCursor( jobCursorTime: jobRunTime, maxProcessedCredentialsTime: jobRunTime );
79+
var galleryCredentialExpiration = new GalleryCredentialExpiration(new CredentialExpirationJobMetadata(jobRunTime, _warnDaysBeforeExpiration, jobCursor), _galleryDatabase);
80+
8081
try
8182
{
82-
List<ExpiredCredentialData> expiredCredentials = null;
83+
List<ExpiredCredentialData> credentialsInRange = null;
8384

84-
// Who did we contact before?
85+
// Get the most recent date for the emails being sent
8586
if (_storage.Exists(_cursorFile))
8687
{
8788
string content = await _storage.LoadString(_storage.ResolveUri(_cursorFile), CancellationToken.None);
8889
// Load from cursor
89-
var contactedUsers = JsonConvert.DeserializeObject<Dictionary<string, DateTimeOffset>>(content);
90-
91-
// Clean older entries (contacted in last _allowEmailResendAfterDays)
92-
var referenceDate = DateTimeOffset.UtcNow.AddDays(-1 * _allowEmailResendAfterDays);
93-
foreach (var kvp in contactedUsers.Where(kvp => kvp.Value >= referenceDate))
94-
{
95-
_contactedUsers.AddOrUpdate(kvp.Key, kvp.Value, (s, offset) => kvp.Value);
96-
}
90+
// Throw if the schema is not correct to ensure that not-intended emails are sent.
91+
jobCursor = JsonConvert.DeserializeObject<JobRunTimeCursor>(content, new JsonSerializerSettings() { MissingMemberHandling = MissingMemberHandling.Error });
92+
galleryCredentialExpiration = new GalleryCredentialExpiration(new CredentialExpirationJobMetadata(jobRunTime, _warnDaysBeforeExpiration, jobCursor), _galleryDatabase);
9793
}
9894

9995
// Connect to database
100-
using (var galleryConnection = await _galleryDatabase.CreateAsync())
101-
{
102-
// Fetch credentials that expire in _warnDaysBeforeExpiration days
103-
// + the user's e-mail address
104-
Logger.LogInformation("Retrieving expired credentials from Gallery database...");
105-
106-
expiredCredentials = (await galleryConnection.QueryWithRetryAsync<ExpiredCredentialData>(
107-
Strings.GetExpiredCredentialsQuery,
108-
param: new { DaysBeforeExpiration = _warnDaysBeforeExpiration },
109-
maxRetries: 3,
110-
commandTimeout: _defaultCommandTimeout)).ToList();
111-
112-
Logger.LogInformation("Retrieved {ExpiredCredentials} expired credentials.",
113-
expiredCredentials.Count);
114-
}
96+
Logger.LogInformation("Retrieving expired credentials from Gallery database...");
97+
credentialsInRange = await galleryCredentialExpiration.GetCredentialsAsync(_defaultCommandTimeout);
98+
Logger.LogInformation("Retrieved {ExpiredCredentials} expired credentials.",
99+
credentialsInRange.Count);
115100

116101
// Add default description for non-scoped API keys
117-
expiredCredentials
102+
credentialsInRange
118103
.Where(cred => string.IsNullOrEmpty(cred.Description))
119104
.ToList()
120105
.ForEach(ecd => ecd.Description = Constants.NonScopedApiKeyDescription);
121106

122107
// Group credentials for each user
123-
var userToExpiredCredsMapping = expiredCredentials
108+
var userToExpiredCredsMapping = credentialsInRange
124109
.GroupBy(x => x.Username)
125110
.ToDictionary(user => user.Key, value => value.ToList());
126111

127-
// Handle expiring credentials
128-
var jobRunTime = DateTimeOffset.UtcNow;
129112
foreach (var userCredMapping in userToExpiredCredsMapping)
130113
{
131114
var username = userCredMapping.Key;
132115
var credentialList = userCredMapping.Value;
133116

134117
// Split credentials into two lists: Expired and Expiring to aggregate messages
135-
var expiringCredentialList = credentialList
136-
.Where(x => (x.Expires - jobRunTime).TotalDays > 0)
137-
.ToList();
138-
var expiredCredentialList = credentialList
139-
.Where(x => (x.Expires - jobRunTime).TotalDays <= 0)
140-
.ToList();
141-
142-
DateTimeOffset userContactTime;
143-
if (!_contactedUsers.TryGetValue(username, out userContactTime))
144-
{
145-
// send expiring API keys email notification
146-
await HandleExpiredCredentialEmail(username, expiringCredentialList, jobRunTime, expired: false);
147-
148-
// send expired API keys email notification
149-
await HandleExpiredCredentialEmail(username, expiredCredentialList, jobRunTime, expired: true);
150-
}
151-
else
152-
{
153-
Logger.LogDebug("Skipping expired credential for user {Username} - already handled at {JobRuntime}.",
154-
username, userContactTime);
155-
}
118+
var expiringCredentialList = galleryCredentialExpiration.GetExpiringCredentials(credentialList);
119+
var expiredCredentialList = galleryCredentialExpiration.GetExpiredCredentials(credentialList);
120+
121+
await HandleExpiredCredentialEmail(username, expiringCredentialList, jobRunTime, expired: false);
122+
123+
// send expired API keys email notification
124+
await HandleExpiredCredentialEmail(username, expiredCredentialList, jobRunTime, expired: true);
156125
}
157126
}
158127
finally
159128
{
160-
// Make sure we know who has been contacted today, so they do not get double
161-
// e-mail notifications.
162-
string json = JsonConvert.SerializeObject(_contactedUsers);
129+
JobRunTimeCursor newCursor = new JobRunTimeCursor( jobCursorTime: jobRunTime, maxProcessedCredentialsTime: galleryCredentialExpiration.GetMaxNotificationDate());
130+
string json = JsonConvert.SerializeObject(newCursor);
163131
var content = new StringStorageContent(json, "application/json");
164132
await _storage.Save(_storage.ResolveUri(_cursorFile), content, CancellationToken.None);
165133
}
@@ -172,9 +140,8 @@ private async Task HandleExpiredCredentialEmail(string username, List<ExpiredCre
172140
return;
173141
}
174142

175-
Logger.LogInformation("Handling {Expired} credential(s) for user {Username} (Keys: {Descriptions})...",
143+
Logger.LogInformation("Handling {Expired} credential(s) (Keys: {Descriptions})...",
176144
expired ? "expired" : "expiring",
177-
username,
178145
string.Join(", ", credentialList.Select(x => x.Description).ToList()));
179146

180147
// Build message
@@ -206,21 +173,18 @@ private async Task HandleExpiredCredentialEmail(string username, List<ExpiredCre
206173
await _smtpClient.SendMailAsync(mailMessage);
207174
}
208175

209-
Logger.LogInformation("Handled {Expired} credential for user {Username}.",
210-
expired ? "expired" : "expiring",
211-
username);
212-
213-
_contactedUsers.AddOrUpdate(username, jobRunTime, (s, offset) => jobRunTime);
176+
Logger.LogInformation("Handled {Expired} credential .",
177+
expired ? "expired" : "expiring");
214178
}
215179
catch (SmtpFailedRecipientException ex)
216180
{
217-
var logMessage = "Failed to handle credential for user {Username} - recipient failed!";
218-
Logger.LogWarning(LogEvents.FailedToSendMail, ex, logMessage, username);
181+
var logMessage = "Failed to handle credential - recipient failed!";
182+
Logger.LogWarning(LogEvents.FailedToSendMail, ex, logMessage);
219183
}
220184
catch (Exception ex)
221185
{
222-
var logMessage = "Failed to handle credential for user {Username}.";
223-
Logger.LogCritical(LogEvents.FailedToHandleExpiredCredential, ex, logMessage, username);
186+
var logMessage = "Failed to handle credential .";
187+
Logger.LogCritical(LogEvents.FailedToHandleExpiredCredential, ex, logMessage);
224188

225189
throw;
226190
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
6+
namespace Gallery.CredentialExpiration
7+
{
8+
public class JobRunTimeCursor
9+
{
10+
public JobRunTimeCursor(DateTimeOffset jobCursorTime, DateTimeOffset maxProcessedCredentialsTime)
11+
{
12+
JobCursorTime = jobCursorTime;
13+
MaxProcessedCredentialsTime = maxProcessedCredentialsTime;
14+
}
15+
16+
public DateTimeOffset JobCursorTime { get; }
17+
18+
public DateTimeOffset MaxProcessedCredentialsTime { get; }
19+
}
20+
}

0 commit comments

Comments
 (0)