Skip to content

Commit dae2d46

Browse files
YanaXuNickcandy
andauthored
Support in-tool notification for version upgrade (#396)
* support version upgrade notification * update the migration guides link * remove check by user * update warning message * move warning logic to UpgradeNotificationHelper * update string match and newline character * rename FrequencyService methods --------- Co-authored-by: NanxiangLiu <[email protected]>
1 parent 4956ccd commit dae2d46

File tree

5 files changed

+313
-1
lines changed

5 files changed

+313
-1
lines changed

src/Authentication.Abstractions/Models/ConfigKeysForCommon.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,6 @@ public static class ConfigKeysForCommon
2828
public const string DisplayBreakingChangeWarning = "DisplayBreakingChangeWarning";
2929
public const string EnableDataCollection = "EnableDataCollection";
3030
public const string EnableTestCoverage = "EnableTestCoverage";
31+
public const string CheckForUpgrade = "CheckForUpgrade";
3132
}
3233
}

src/Common/AzurePSCmdlet.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
using Microsoft.Azure.Commands.Common.Authentication.Abstractions;
1717
using Microsoft.Azure.PowerShell.Common.Config;
1818
using Microsoft.Azure.PowerShell.Common.Share.Survey;
19+
using Microsoft.Azure.PowerShell.Common.Share.UpgradeNotification;
1920
using Microsoft.Azure.ServiceManagement.Common.Models;
2021
using Microsoft.WindowsAzure.Commands.Common;
2122
using Microsoft.WindowsAzure.Commands.Common.CustomAttributes;
@@ -400,7 +401,8 @@ private void WriteBreakingChangeOrPreviewMessage()
400401
protected override void EndProcessing()
401402
{
402403
WriteEndProcessingRecommendation();
403-
404+
WriteWarningMessageForVersionUpgrade();
405+
404406
if (MetricHelper.IsCalledByUser()
405407
&& SurveyHelper.GetInstance().ShouldPromptAzSurvey()
406408
&& (AzureSession.Instance.TryGetComponent<IConfigManager>(nameof(IConfigManager), out var configManager)
@@ -436,6 +438,13 @@ private void WriteEndProcessingRecommendation()
436438
}
437439
}
438440

441+
private void WriteWarningMessageForVersionUpgrade()
442+
{
443+
AzureSession.Instance.TryGetComponent<IConfigManager>(nameof(IConfigManager), out var configManager);
444+
AzureSession.Instance.TryGetComponent<IFrequencyService>(nameof(IFrequencyService), out var frequencyService);
445+
UpgradeNotificationHelper.GetInstance().WriteWarningMessageForVersionUpgrade(this, _qosEvent, configManager, frequencyService);
446+
}
447+
439448
protected string CurrentPath()
440449
{
441450
// SessionState is only available within PowerShell so default to

src/Common/IFrequencyService.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System;
2+
3+
namespace Microsoft.WindowsAzure.Commands.Common
4+
{
5+
/// <summary>
6+
/// Interface for a service that manages the frequency of business logic execution based on configured feature flags.
7+
/// </summary>
8+
public interface IFrequencyService
9+
{
10+
/// <summary>
11+
/// Checks if the specified feature is enabled and if it's time to run the business logic based on the feature's frequency.
12+
/// If both conditions are met, it runs the specified business action.
13+
/// </summary>
14+
/// <param name="featureName">The name of the feature to check.</param>
15+
/// <param name="businessCheck">A function that returns true if the business logic should be executed.</param>
16+
/// <param name="business">An action to execute if the business logic should be executed.</param>
17+
void TryRun(string featureName, Func<bool> businessCheck, Action business);
18+
19+
/// <summary>
20+
/// Registers a feature with the specified name and frequency to the service.
21+
/// </summary>
22+
/// <param name="featureName">The name of the feature to add.</param>
23+
/// <param name="frequency">The frequency at which the business logic should be executed for the feature.</param>
24+
void Register(string featureName, TimeSpan frequency);
25+
26+
/// <summary>
27+
/// Registers the specified feature to the service's per-PSsession registry.
28+
/// </summary>
29+
/// <param name="featureName">The name of the feature to add.</param>
30+
void RegisterInSession(string featureName);
31+
32+
/// <summary>
33+
/// Saves the current state of the service to persistent storage.
34+
/// </summary>
35+
void Save();
36+
}
37+
}

src/Common/MetricHelper.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,8 @@ private void PopulatePropertiesFromQos(AzurePSQoSEvent qos, IDictionary<string,
322322
eventProperties.Add("duration", qos.Duration.ToString("c"));
323323
eventProperties.Add("InternalCalledCmdlets", MetricHelper.InternalCalledCmdlets);
324324
eventProperties.Add("InstallationId", MetricHelper.InstallationId);
325+
eventProperties.Add("upgrade-notification-checked", qos.HigherVersionsChecked.ToString());
326+
eventProperties.Add("upgrade-notification-prompted", qos.UpgradeNotificationPrompted.ToString());
325327
if (!string.IsNullOrWhiteSpace(SharedVariable.PredictorCorrelationId))
326328
{
327329
eventProperties.Add("predictor-correlation-id", SharedVariable.PredictorCorrelationId);
@@ -456,6 +458,7 @@ private void PopulatePropertiesFromQos(AzurePSQoSEvent qos, IDictionary<string,
456458
{
457459
eventProperties.Add("OutputToPipeline", qos.OutputToPipeline.Value.ToString());
458460
}
461+
459462
foreach (var key in qos.CustomProperties.Keys)
460463
{
461464
eventProperties[key] = qos.CustomProperties[key];
@@ -607,6 +610,8 @@ public class AzurePSQoSEvent
607610
public string SubscriptionId { get; set; }
608611
public string TenantId { get; set; }
609612
public bool SurveyPrompted { get; set; }
613+
public bool HigherVersionsChecked { get; set; }
614+
public bool UpgradeNotificationPrompted { get; set; }
610615

611616
/// <summary>
612617
/// Appear in certain resource creation commands like New-AzVM. See RegionalRecommender (PS repo).
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
using Microsoft.Azure.PowerShell.Common.Config;
2+
using Microsoft.WindowsAzure.Commands.Common;
3+
using Newtonsoft.Json;
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Collections.ObjectModel;
7+
using System.IO;
8+
using System.Management.Automation;
9+
using System.Threading;
10+
11+
namespace Microsoft.Azure.PowerShell.Common.Share.UpgradeNotification
12+
{
13+
public class UpgradeNotificationHelper
14+
{
15+
private const string AZPSMigrationGuideLink = "https://go.microsoft.com/fwlink/?linkid=2241373";
16+
private const string FrequencyKeyForUpgradeNotification = "VersionUpgradeNotification";
17+
private static TimeSpan FrequencyTimeSpanForUpgradeNotification = TimeSpan.FromDays(30);
18+
19+
private const string FrequencyKeyForUpgradeCheck = "VersionUpgradeCheck";
20+
private static TimeSpan FrequencyTimeSpanForUpgradeCheck = TimeSpan.FromDays(2);
21+
//temp record file for az module versions
22+
private static string AzVersionCacheFile = Path.Combine(
23+
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
24+
".Azure", "AzModuleVerions.json");
25+
private bool hasNotified { get; set; }
26+
private Dictionary<string, string> versionDict = null;
27+
28+
private static UpgradeNotificationHelper _instance;
29+
30+
private UpgradeNotificationHelper()
31+
{
32+
try
33+
{
34+
// load temp record file to versionDict
35+
if (File.Exists(AzVersionCacheFile))
36+
{
37+
using (StreamReader sr = new StreamReader(new FileStream(AzVersionCacheFile, FileMode.Open, FileAccess.Read, FileShare.None)))
38+
{
39+
versionDict = JsonConvert.DeserializeObject<Dictionary<string, string>>(sr.ReadToEnd());
40+
}
41+
}
42+
}
43+
catch (Exception)
44+
{
45+
versionDict = null;
46+
}
47+
}
48+
49+
public static UpgradeNotificationHelper GetInstance()
50+
{
51+
if (_instance == null)
52+
{
53+
_instance = new UpgradeNotificationHelper();
54+
}
55+
return _instance;
56+
}
57+
58+
public void WriteWarningMessageForVersionUpgrade(Microsoft.WindowsAzure.Commands.Utilities.Common.AzurePSCmdlet cmdlet, AzurePSQoSEvent _qosEvent, IConfigManager configManager, IFrequencyService frequencyService) {
59+
_qosEvent.HigherVersionsChecked = false;
60+
_qosEvent.UpgradeNotificationPrompted = false;
61+
62+
try
63+
{
64+
//disabled by az config, skip
65+
if (configManager!=null&& configManager.GetConfigValue<bool>(ConfigKeysForCommon.CheckForUpgrade).Equals(false))
66+
{
67+
return;
68+
}
69+
70+
//has done check this session, skip
71+
if (hasNotified)
72+
{
73+
return;
74+
}
75+
76+
//register verion check and upgrade notification in frequency service
77+
if (frequencyService == null) {
78+
return;
79+
}
80+
frequencyService.Register(FrequencyKeyForUpgradeCheck, FrequencyTimeSpanForUpgradeCheck);
81+
frequencyService.Register(FrequencyKeyForUpgradeNotification, FrequencyTimeSpanForUpgradeNotification);
82+
83+
string checkModuleName = "Az";
84+
string checkModuleCurrentVersion = _qosEvent.AzVersion;
85+
string upgradeModuleNames = "Az";
86+
if ("0.0.0".Equals(_qosEvent.AzVersion))
87+
{
88+
checkModuleName = _qosEvent.ModuleName;
89+
checkModuleCurrentVersion = _qosEvent.ModuleVersion;
90+
upgradeModuleNames = "Az.*";
91+
}
92+
93+
//refresh az module versions if necessary
94+
frequencyService.TryRun(FrequencyKeyForUpgradeCheck, () => true, () =>
95+
{
96+
Thread loadHigherVersionsThread = new Thread(new ThreadStart(() =>
97+
{
98+
_qosEvent.HigherVersionsChecked = true;
99+
try
100+
{
101+
//no lock for this method, may skip some notifications, it's expected.
102+
RefreshVersionInfo(upgradeModuleNames);
103+
}
104+
catch (Exception)
105+
{
106+
//do nothing
107+
}
108+
}));
109+
loadHigherVersionsThread.Start();
110+
});
111+
112+
bool shouldPrintWarningMsg = HasHigherVersion(checkModuleName, checkModuleCurrentVersion);
113+
114+
//prompt warning message for upgrade if necessary
115+
frequencyService.TryRun(FrequencyKeyForUpgradeNotification, () => shouldPrintWarningMsg, () =>
116+
{
117+
_qosEvent.UpgradeNotificationPrompted = true;
118+
hasNotified = true;
119+
120+
string latestModuleVersion = GetModuleLatestVersion(checkModuleName);
121+
string updateModuleCmdletName = GetCmdletForUpdateModule();
122+
string warningMsg = $"You're using {checkModuleName} version {checkModuleCurrentVersion}. The latest version of {checkModuleName} is {latestModuleVersion}. Upgrade your Az modules using the following commands:{Environment.NewLine}";
123+
warningMsg += $" {updateModuleCmdletName} {upgradeModuleNames} -WhatIf -- Simulate updating your Az modules.{Environment.NewLine}";
124+
warningMsg += $" {updateModuleCmdletName} {upgradeModuleNames} -- Update your Az modules.{Environment.NewLine}";
125+
if ("Az".Equals(checkModuleName) && GetInstance().HasHigherMajorVersion(checkModuleName, checkModuleCurrentVersion))
126+
{
127+
warningMsg += $"There will be breaking changes from {checkModuleCurrentVersion} to {latestModuleVersion}. Open {AZPSMigrationGuideLink} and check the details.{Environment.NewLine}";
128+
}
129+
cmdlet.WriteWarning(warningMsg);
130+
});
131+
}
132+
catch (Exception ex)
133+
{
134+
cmdlet.WriteDebug($"Failed to write warning message for version upgrade due to '{ex.Message}'.");
135+
}
136+
}
137+
138+
private void RefreshVersionInfo(string loadModuleNames)
139+
{
140+
this.versionDict = LoadHigherAzVersions(loadModuleNames);
141+
if (!VersionsAreFreshed())
142+
{
143+
return;
144+
}
145+
string content = JsonConvert.SerializeObject(this.versionDict);
146+
using (StreamWriter sw = new StreamWriter(new FileStream(AzVersionCacheFile, FileMode.Create, FileAccess.Write, FileShare.None)))
147+
{
148+
sw.Write(content);
149+
}
150+
}
151+
152+
private bool VersionsAreFreshed()
153+
{
154+
return versionDict != null && versionDict.Count > 0;
155+
}
156+
157+
private string GetModuleLatestVersion(string moduleName)
158+
{
159+
string defaultVersion = "0.0.0";
160+
if (!VersionsAreFreshed())
161+
{
162+
return defaultVersion;
163+
}
164+
return versionDict.ContainsKey(moduleName) ? versionDict[moduleName] : defaultVersion;
165+
}
166+
167+
private bool HasHigherVersion(string moduleName, string currentVersion)
168+
{
169+
if (!VersionsAreFreshed())
170+
{
171+
return false;
172+
}
173+
try
174+
{
175+
Version currentVersionValue = Version.Parse(currentVersion);
176+
Version latestVersionValue = Version.Parse(versionDict[moduleName]);
177+
return latestVersionValue > currentVersionValue;
178+
}
179+
catch (Exception)
180+
{
181+
return false;
182+
}
183+
}
184+
185+
private bool HasHigherMajorVersion(string moduleName, string currentVersion)
186+
{
187+
if (!VersionsAreFreshed())
188+
{
189+
return false;
190+
}
191+
try
192+
{
193+
Version currentVersionValue = Version.Parse(currentVersion);
194+
Version latestVersionValue = Version.Parse(versionDict[moduleName]);
195+
return latestVersionValue.Major > currentVersionValue.Major;
196+
}
197+
catch (Exception)
198+
{
199+
return false;
200+
}
201+
}
202+
203+
private static Dictionary<string, string> LoadHigherAzVersions(string moduleName)
204+
{
205+
Dictionary<string, string> versionDict = new Dictionary<string, string>();
206+
207+
string findModuleCmdlet = GetCmdletForFindModule();
208+
findModuleCmdlet += " -Name " + moduleName + " | Select-Object Name, Version";
209+
210+
var outputs = ExecutePSScript<PSObject>(findModuleCmdlet);
211+
foreach (PSObject obj in outputs)
212+
{
213+
versionDict[obj.Properties["Name"].Value.ToString()] = obj.Properties["Version"].Value.ToString();
214+
}
215+
return versionDict;
216+
}
217+
218+
private static string GetCmdletForUpdateModule()
219+
{
220+
if (ExecutePSScript<PSObject>("Get-Command -Name Update-PSResource").Count > 0)
221+
{
222+
return "Update-PSResource";
223+
}
224+
else
225+
{
226+
return "Update-Module";
227+
}
228+
}
229+
230+
private static string GetCmdletForFindModule()
231+
{
232+
if (ExecutePSScript<PSObject>("Get-Command -Name Find-PSResource").Count > 0)
233+
{
234+
return "Find-PSResource -Repository PSGallery -Type Module";
235+
}
236+
else
237+
{
238+
return "Find-Module -Repository PSGallery";
239+
}
240+
}
241+
242+
// This method is copied from CmdletExtensions.ExecuteScript. But it'll run with NewRunspace, ignore the warning or error message.
243+
private static List<T> ExecutePSScript<T>(string contents)
244+
{
245+
List<T> output = new List<T>();
246+
247+
using (System.Management.Automation.PowerShell powershell = System.Management.Automation.PowerShell.Create(RunspaceMode.NewRunspace))
248+
{
249+
powershell.AddScript(contents);
250+
Collection<T> result = powershell.Invoke<T>();
251+
if (result != null && result.Count > 0)
252+
{
253+
output.AddRange(result);
254+
}
255+
}
256+
257+
return output;
258+
}
259+
}
260+
}

0 commit comments

Comments
 (0)