From 410b9de754506d78f9a5f3fe1123ba7dfd15f069 Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Wed, 3 Jun 2026 21:56:21 +0300 Subject: [PATCH] Fixes to multi-geo cmdlets --- .../Get-PnPGeoMoveCrossCompatibilityStatus.md | 4 +- .../Get-PnPUserAndContentMoveState.md | 6 +- .../GetGeoMoveCrossCompatibilityStatus.cs | 17 +- .../Admin/GetUserAndContentMoveState.cs | 4 +- .../MultiGeo/MultiGeoRestApiClient.cs | 152 +++++++++++++++++- 5 files changed, 166 insertions(+), 17 deletions(-) diff --git a/documentation/Get-PnPGeoMoveCrossCompatibilityStatus.md b/documentation/Get-PnPGeoMoveCrossCompatibilityStatus.md index da58faff5..bdebba3bc 100644 --- a/documentation/Get-PnPGeoMoveCrossCompatibilityStatus.md +++ b/documentation/Get-PnPGeoMoveCrossCompatibilityStatus.md @@ -51,8 +51,8 @@ Accept wildcard characters: False ## OUTPUTS -### PnP.PowerShell.Commands.Model.GeoMoveTenantCompatibilityCheck -Returns objects with `SourceDataLocation`, `DestinationDataLocation`, and `GeoMoveTenantCompatibilityResult` properties. +### System.Management.Automation.PSObject +Returns objects with `SourceDataLocation`, `DestinationDataLocation`, and `CompatibilityStatus` properties. ## RELATED LINKS diff --git a/documentation/Get-PnPUserAndContentMoveState.md b/documentation/Get-PnPUserAndContentMoveState.md index 0a1e5f859..fdf1fc24c 100644 --- a/documentation/Get-PnPUserAndContentMoveState.md +++ b/documentation/Get-PnPUserAndContentMoveState.md @@ -33,7 +33,7 @@ Get-PnPUserAndContentMoveState -OdbMoveId [-Connection ] ``` ## DESCRIPTION -Returns status information for SharePoint Online multi-geo user and OneDrive content move jobs. You can retrieve one move job by user principal name or OneDrive move ID, or retrieve a move report filtered by state, direction, time window, and limit. +Returns status information for SharePoint Online multi-geo user and OneDrive content move jobs. You can retrieve one move job by user principal name or OneDrive move ID, or retrieve a move report filtered by state, direction, time window, and limit. When no move state or direction is specified, all states and directions are returned. ## EXAMPLES @@ -150,7 +150,7 @@ Parameter Sets: MoveReport Required: False Position: Named -Default value: NotStarted +Default value: All Accept pipeline input: False Accept wildcard characters: False ``` @@ -164,7 +164,7 @@ Parameter Sets: MoveReport Required: False Position: Named -Default value: MoveOut +Default value: All Accept pipeline input: False Accept wildcard characters: False ``` diff --git a/src/Commands/Admin/GetGeoMoveCrossCompatibilityStatus.cs b/src/Commands/Admin/GetGeoMoveCrossCompatibilityStatus.cs index 7b181de77..86b45c9e2 100644 --- a/src/Commands/Admin/GetGeoMoveCrossCompatibilityStatus.cs +++ b/src/Commands/Admin/GetGeoMoveCrossCompatibilityStatus.cs @@ -9,13 +9,26 @@ namespace PnP.PowerShell.Commands.Admin [Cmdlet(VerbsCommon.Get, "PnPGeoMoveCrossCompatibilityStatus")] [RequiredApiApplicationPermissions("sharepoint/Sites.FullControl.All")] [RequiredApiDelegatedPermissions("sharepoint/AllSites.FullControl")] - [OutputType(typeof(GeoMoveTenantCompatibilityCheck))] + [OutputType(typeof(PSObject))] public class GetGeoMoveCrossCompatibilityStatus : PnPSharePointOnlineAdminCmdlet { protected override void ExecuteCmdlet() { var multiGeoRestApiClient = new MultiGeoRestApiClient(AdminContext); - WriteObject(multiGeoRestApiClient.GetGeoMoveCompatibilityChecks(), true); + foreach (var compatibilityCheck in multiGeoRestApiClient.GetGeoMoveCompatibilityChecks()) + { + WriteObject(ConvertToPSObject(compatibilityCheck)); + } + + } + + private static PSObject ConvertToPSObject(GeoMoveTenantCompatibilityCheck compatibilityCheck) + { + var result = new PSObject(); + result.Properties.Add(new PSNoteProperty("SourceDataLocation", compatibilityCheck.SourceDataLocation)); + result.Properties.Add(new PSNoteProperty("DestinationDataLocation", compatibilityCheck.DestinationDataLocation)); + result.Properties.Add(new PSNoteProperty("CompatibilityStatus", compatibilityCheck.GeoMoveTenantCompatibilityResult)); + return result; } } } \ No newline at end of file diff --git a/src/Commands/Admin/GetUserAndContentMoveState.cs b/src/Commands/Admin/GetUserAndContentMoveState.cs index 924d822c2..b60a3ff7a 100644 --- a/src/Commands/Admin/GetUserAndContentMoveState.cs +++ b/src/Commands/Admin/GetUserAndContentMoveState.cs @@ -40,10 +40,10 @@ public class GetUserAndContentMoveState : PnPSharePointOnlineAdminCmdlet public DateTime MoveEndTime { get; set; } [Parameter(Mandatory = false, ParameterSetName = ParameterSetMoveReport)] - public MoveState MoveState { get; set; } + public MoveState MoveState { get; set; } = MoveState.All; [Parameter(Mandatory = false, ParameterSetName = ParameterSetMoveReport)] - public MoveDirection MoveDirection { get; set; } + public MoveDirection MoveDirection { get; set; } = MoveDirection.All; protected override void ExecuteCmdlet() { diff --git a/src/Commands/Utilities/MultiGeo/MultiGeoRestApiClient.cs b/src/Commands/Utilities/MultiGeo/MultiGeoRestApiClient.cs index ac79138df..f8fef5884 100644 --- a/src/Commands/Utilities/MultiGeo/MultiGeoRestApiClient.cs +++ b/src/Commands/Utilities/MultiGeo/MultiGeoRestApiClient.cs @@ -2,8 +2,10 @@ using PnP.Framework.Http; using PnP.PowerShell.Commands.Model; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -24,18 +26,71 @@ internal class MultiGeoRestApiClient private const string TenantRenameJobsPathToGetStatus = "TenantRenameJobs/Get"; private const string TenantRenameJobsPathToGetStatusV2 = "TenantRenameJobs/GetV2"; private const string TenantRenameJobsPathToCancelAJob = "TenantRenameJobs/Cancel"; - private const string GeoMoveCompatibilityChecksApiVersion = "1.3.6"; + private const string GeoMoveCompatibilityChecksMinimumApiVersion = "1.3.6"; private const string GeoMoveCompatibilityChecksPath = "GeoMoveCompatibilityChecks"; private const string AllowedDataLocationsApiVersion = "1.3.11"; private const string AllowedDataLocationsPath = "AllowedDataLocations"; - private const string UserMoveJobsApiVersion = "1.0"; - private const string UserMoveJobsByMoveIdApiVersion = "1.2.2"; - private const string UserMoveJobsReportApiVersion = "1.3.2"; + private const string MultiGeoApiVersionsPath = "MultiGeoApiVersions"; + private const string UserMoveJobsMinimumApiVersion = "1.0"; + private const string UserMoveJobsByMoveIdMinimumApiVersion = "1.2.2"; + private const string UserMoveJobsReportMinimumApiVersion = "1.3.2"; private const string UserMoveJobPathByUpn = "UserMoveJobs(upn='{0}')"; private const string UserMoveJobPathByMoveId = "UserMoveJobs/GetByMoveId(odbMoveId='{0}')"; private const string UserMoveJobsPathForMoveReport = "UserMoveJobs/GetMoveReport(moveState={0},moveDirection={1},startTime='{2:u}',endTime='{3:u}',limit='{4}')"; private const int MaximumPagination = 10; + private const int ApiVersionCacheValidTimeInHours = 1; private static readonly TimeSpan CreateTenantRenameJobTimeout = TimeSpan.FromSeconds(300); + private static readonly string[] ClientSupportedApiVersions = + [ + "1.6.0", + "1.5.20", + "1.5.19", + "1.5.18", + "1.5.17", + "1.5.16", + "1.5.15", + "1.5.14", + "1.5.13", + "1.5.12", + "1.5.11", + "1.5.10", + "1.5.9", + "1.5.8", + "1.5.7", + "1.5.6", + "1.5.5", + "1.5.4", + "1.5.3", + "1.5.2", + "1.5.1", + "1.5.0", + "1.4.7", + "1.4.6", + "1.4.5", + "1.4.4", + "1.4.3", + "1.4.2", + "1.4.1", + "1.4.0", + "1.3.11", + "1.3.10", + "1.3.9", + "1.3.8", + "1.3.7", + "1.3.6", + "1.3.5", + "1.3.4", + "1.3.3-beta", + "1.3.2", + "1.3.1", + "1.3.0", + "1.2.2", + "1.2.1-beta", + "1.2-beta", + "1.1", + "1.0" + ]; + private static readonly ConcurrentDictionary ApiVersionCache = new(StringComparer.OrdinalIgnoreCase); private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNameCaseInsensitive = true @@ -72,7 +127,7 @@ internal IEnumerable GetTenantRenameWarningMessages() internal IEnumerable GetGeoMoveCompatibilityChecks() { - return Get(GeoMoveCompatibilityChecksPath, GeoMoveCompatibilityChecksApiVersion)?.GeoMoveTenantCompatibilityChecks ?? Array.Empty(); + return GetFeed(GeoMoveCompatibilityChecksPath, GetCurrentApiVersion(GeoMoveCompatibilityChecksMinimumApiVersion)); } internal IEnumerable GetAllowedDataLocations() @@ -82,20 +137,23 @@ internal IEnumerable GetAllowedDataLocations internal UserAndContentMoveState GetUserAndContentMoveState(string userPrincipalName) { + var apiVersion = GetCurrentApiVersion(UserMoveJobsMinimumApiVersion); var path = string.Format(CultureInfo.InvariantCulture, UserMoveJobPathByUpn, ProcessSpecialChars(userPrincipalName)); - return Get(path, UserMoveJobsApiVersion); + return Get(path, apiVersion); } internal UserAndContentMoveState GetUserAndContentMoveState(Guid odbMoveId) { + var apiVersion = GetCurrentApiVersion(UserMoveJobsByMoveIdMinimumApiVersion); var path = string.Format(CultureInfo.InvariantCulture, UserMoveJobPathByMoveId, odbMoveId); - return Get(path, UserMoveJobsByMoveIdApiVersion); + return Get(path, apiVersion); } internal IEnumerable GetUserAndContentMoveStates(MoveState moveState, MoveDirection moveDirection, DateTime startTime, DateTime endTime, uint limit) { + var apiVersion = GetCurrentApiVersion(UserMoveJobsReportMinimumApiVersion); var path = string.Format(CultureInfo.InvariantCulture, UserMoveJobsPathForMoveReport, (int)moveState, (int)moveDirection, startTime, endTime, limit); - return GetFeed(path, UserMoveJobsReportApiVersion); + return GetFeed(path, apiVersion); } internal void CancelTenantRenameJob() @@ -109,6 +167,12 @@ private T Get(string path, string apiVersion = TenantRenameApiVersion) return DeserializeResponse(responseText); } + private T GetWithoutApiVersion(string path) + { + var responseText = Send(() => CreateRequest(HttpMethod.Get, CreateApiUri(path)), timeout: null, allowRetries: true); + return DeserializeResponse(responseText); + } + private IEnumerable GetFeed(string path, string apiVersion) { var results = new List(); @@ -186,6 +250,66 @@ private Uri CreateApiUri(string path, string apiVersion) return new Uri($"{adminContext.Url.TrimEnd('/')}/_api/{normalizedPath}{separator}api-version={apiVersion}"); } + private Uri CreateApiUri(string path) + { + var normalizedPath = path.TrimStart('/'); + return new Uri($"{adminContext.Url.TrimEnd('/')}/_api/{normalizedPath}"); + } + + private string GetCurrentApiVersion(string minimumApiVersion) + { + var apiVersion = GetCurrentApiVersion(); + if (!IsSupportedApiVersion(apiVersion, minimumApiVersion)) + { + throw new NotSupportedException($"SharePoint Online MultiGeo API version {apiVersion} does not support this operation. Minimum required version is {minimumApiVersion}."); + } + + return apiVersion; + } + + private string GetCurrentApiVersion() + { + var cacheKey = adminContext.Url.TrimEnd('/'); + if (ApiVersionCache.TryGetValue(cacheKey, out var cachedApiVersion) && cachedApiVersion.ExpiresOnUtc > DateTime.UtcNow) + { + return cachedApiVersion.Identity; + } + + var supportedVersions = GetWithoutApiVersion(MultiGeoApiVersionsPath)?.SupportedVersions; + var currentApiVersion = GetLatestClientSupportedApiVersion(supportedVersions); + ApiVersionCache[cacheKey] = new CachedApiVersion + { + Identity = currentApiVersion, + ExpiresOnUtc = DateTime.UtcNow.AddHours(ApiVersionCacheValidTimeInHours) + }; + + return currentApiVersion; + } + + private static string GetLatestClientSupportedApiVersion(IEnumerable supportedVersions) + { + if (supportedVersions == null) + { + throw new InvalidOperationException("SharePoint Online REST API did not return any supported MultiGeo API versions."); + } + + var supportedVersionSet = new HashSet(supportedVersions, StringComparer.OrdinalIgnoreCase); + var apiVersion = ClientSupportedApiVersions.FirstOrDefault(supportedVersionSet.Contains); + if (apiVersion == null) + { + throw new InvalidOperationException("SharePoint Online REST API did not return a supported MultiGeo API version."); + } + + return apiVersion; + } + + private static bool IsSupportedApiVersion(string apiVersion, string minimumApiVersion) + { + var apiVersionIndex = Array.IndexOf(ClientSupportedApiVersions, apiVersion); + var minimumApiVersionIndex = Array.IndexOf(ClientSupportedApiVersions, minimumApiVersion); + return apiVersionIndex >= 0 && minimumApiVersionIndex >= 0 && apiVersionIndex <= minimumApiVersionIndex; + } + private string Send(Func requestFactory, TimeSpan? timeout, bool allowRetries) { var retryAttempt = 0; @@ -388,5 +512,17 @@ private sealed class ODataFeed public string NextLink { get; set; } } + + private sealed class ApiVersions + { + public string[] SupportedVersions { get; set; } + } + + private sealed class CachedApiVersion + { + public string Identity { get; set; } + + public DateTime ExpiresOnUtc { get; set; } + } } }