UTC data migration doesn't work in Linux (closes #20002) (#20112)

* Adjusted the UTC SQL Server migration to convert time zone ids to the correct format

* Apply suggestions from code review

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Small rename

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
This commit is contained in:
Laura Neto
2025-09-22 13:16:13 +02:00
committed by GitHub
parent 815f28e3b9
commit 5d17920a73
4 changed files with 118 additions and 136 deletions

View File

@@ -1,30 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
namespace Umbraco.Cms.Core.Configuration.Models.Validation;
/// <summary>
/// Validator for configuration representated as <see cref="SystemDateMigrationSettings" />.
/// </summary>
public class SystemDateMigrationSettingsValidator
: IValidateOptions<SystemDateMigrationSettings>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, SystemDateMigrationSettings options)
{
if (string.IsNullOrWhiteSpace(options.LocalServerTimeZone))
{
return ValidateOptionsResult.Success;
}
if (TimeZoneInfo.TryFindSystemTimeZoneById(options.LocalServerTimeZone, out _) is false)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigSystemDateMigration} contains an invalid time zone: {options.LocalServerTimeZone}.");
}
return ValidateOptionsResult.Success;
}
}

View File

@@ -47,7 +47,6 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IValidateOptions<RequestHandlerSettings>, RequestHandlerSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<UnattendedSettings>, UnattendedSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<SecuritySettings>, SecuritySettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<SystemDateMigrationSettings>, SystemDateMigrationSettingsValidator>();
// Register configuration sections.
builder

View File

@@ -1,8 +1,9 @@
using NPoco;
using Umbraco.Cms.Infrastructure.Scoping;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NPoco;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Infrastructure.Scoping;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_0_0;
@@ -11,6 +12,8 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_0_0;
/// </summary>
public class MigrateSystemDatesToUtc : UnscopedMigrationBase
{
private static readonly string[] UtcIdentifiers = ["Coordinated Universal Time", "UTC"];
private readonly IScopeProvider _scopeProvider;
private readonly TimeProvider _timeProvider;
private readonly IOptions<SystemDateMigrationSettings> _migrationSettings;
@@ -49,96 +52,153 @@ public class MigrateSystemDatesToUtc : UnscopedMigrationBase
return;
}
// If the local server timezone is not set, we detect it.
var timeZoneName = _migrationSettings.Value.LocalServerTimeZone;
if (string.IsNullOrWhiteSpace(timeZoneName))
// Offsets and Windows name are lazy loaded as they are not always needed.
// This also allows using timezones that exist in the SQL Database but not on the local server.
(string TimeZoneName, Lazy<TimeSpan> TimeZoneOffset, Lazy<string> WindowsTimeZoneName) timeZone;
var configuredTimeZoneName = _migrationSettings.Value.LocalServerTimeZone;
if (configuredTimeZoneName.IsNullOrWhiteSpace() is false)
{
timeZoneName = _timeProvider.LocalTimeZone.Id;
_logger.LogInformation("Migrating system dates to UTC using the detected local server timezone: {TimeZoneName}.", timeZoneName);
timeZone = (
configuredTimeZoneName,
new Lazy<TimeSpan>(() => TimeZoneInfo.FindSystemTimeZoneById(configuredTimeZoneName).BaseUtcOffset),
new Lazy<string>(() => configuredTimeZoneName));
_logger.LogInformation(
"Migrating system dates to UTC using the configured timezone: {TimeZoneName}.",
timeZone.TimeZoneName);
}
else
{
_logger.LogInformation("Migrating system dates to UTC using the configured local server timezone: {TimeZoneName}.", timeZoneName);
// If the local server timezone is not configured, we detect it.
TimeZoneInfo timeZoneInfo = _timeProvider.LocalTimeZone;
timeZone = (
timeZoneInfo.Id,
new Lazy<TimeSpan>(() => timeZoneInfo.BaseUtcOffset),
new Lazy<string>(() => GetWindowsTimeZoneId(timeZoneInfo)));
_logger.LogInformation(
"Migrating system dates to UTC using the detected local server timezone: {TimeZoneName}.",
timeZone.TimeZoneName);
}
// If the local server timezone is UTC, skip the migration.
if (string.Equals(timeZoneName, "Coordinated Universal Time", StringComparison.OrdinalIgnoreCase))
if (UtcIdentifiers.Contains(timeZone.TimeZoneName, StringComparer.OrdinalIgnoreCase))
{
_logger.LogInformation("Skipping migration {MigrationName} as the local server timezone is UTC.", nameof(MigrateSystemDatesToUtc));
_logger.LogInformation(
"Skipping migration {MigrationName} as the local server timezone is UTC.",
nameof(MigrateSystemDatesToUtc));
Context.Complete();
return;
}
TimeSpan timeZoneOffset = GetTimezoneOffset(timeZoneName);
using IScope scope = _scopeProvider.CreateScope();
using IDisposable notificationSuppression = scope.Notifications.Suppress();
MigrateDateColumn(scope, "cmsMember", "emailConfirmedDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "cmsMember", "lastLoginDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "cmsMember", "lastLockoutDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "cmsMember", "lastPasswordChangeDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoAccess", "createDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoAccess", "updateDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoAccessRule", "createDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoAccessRule", "updateDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoCreatedPackageSchema", "updateDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoContentVersion", "versionDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoContentVersionCleanupPolicy", "updated", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoContentVersionCultureVariation", "date", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoExternalLogin", "createDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoExternalLoginToken", "createDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoKeyValue", "updated", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoLog", "Datestamp", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoNode", "createDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoRelation", "datetime", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoServer", "registeredDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoServer", "lastNotifiedDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoUser", "createDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoUser", "updateDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoUser", "emailConfirmedDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoUser", "lastLockoutDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoUser", "lastPasswordChangeDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoUser", "lastLoginDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoUser", "invitedDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoUserGroup", "createDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "umbracoUserGroup", "updateDate", timeZoneName, timeZoneOffset);
MigrateDateColumn(scope, "cmsMember", "emailConfirmedDate", timeZone);
MigrateDateColumn(scope, "cmsMember", "lastLoginDate", timeZone);
MigrateDateColumn(scope, "cmsMember", "lastLockoutDate", timeZone);
MigrateDateColumn(scope, "cmsMember", "lastPasswordChangeDate", timeZone);
MigrateDateColumn(scope, "umbracoAccess", "createDate", timeZone);
MigrateDateColumn(scope, "umbracoAccess", "updateDate", timeZone);
MigrateDateColumn(scope, "umbracoAccessRule", "createDate", timeZone);
MigrateDateColumn(scope, "umbracoAccessRule", "updateDate", timeZone);
MigrateDateColumn(scope, "umbracoCreatedPackageSchema", "updateDate", timeZone);
MigrateDateColumn(scope, "umbracoContentVersion", "versionDate", timeZone);
MigrateDateColumn(scope, "umbracoContentVersionCleanupPolicy", "updated", timeZone);
MigrateDateColumn(scope, "umbracoContentVersionCultureVariation", "date", timeZone);
MigrateDateColumn(scope, "umbracoExternalLogin", "createDate", timeZone);
MigrateDateColumn(scope, "umbracoExternalLoginToken", "createDate", timeZone);
MigrateDateColumn(scope, "umbracoKeyValue", "updated", timeZone);
MigrateDateColumn(scope, "umbracoLog", "Datestamp", timeZone);
MigrateDateColumn(scope, "umbracoNode", "createDate", timeZone);
MigrateDateColumn(scope, "umbracoRelation", "datetime", timeZone);
MigrateDateColumn(scope, "umbracoServer", "registeredDate", timeZone);
MigrateDateColumn(scope, "umbracoServer", "lastNotifiedDate", timeZone);
MigrateDateColumn(scope, "umbracoUser", "createDate", timeZone);
MigrateDateColumn(scope, "umbracoUser", "updateDate", timeZone);
MigrateDateColumn(scope, "umbracoUser", "emailConfirmedDate", timeZone);
MigrateDateColumn(scope, "umbracoUser", "lastLockoutDate", timeZone);
MigrateDateColumn(scope, "umbracoUser", "lastPasswordChangeDate", timeZone);
MigrateDateColumn(scope, "umbracoUser", "lastLoginDate", timeZone);
MigrateDateColumn(scope, "umbracoUser", "invitedDate", timeZone);
MigrateDateColumn(scope, "umbracoUserGroup", "createDate", timeZone);
MigrateDateColumn(scope, "umbracoUserGroup", "updateDate", timeZone);
scope.Complete();
Context.Complete();
}
private static TimeSpan GetTimezoneOffset(string timeZoneName)
// We know the provided timezone name exists, as it's either detected or configured (and configuration has been validated).
=> TimeZoneInfo.FindSystemTimeZoneById(timeZoneName).BaseUtcOffset;
private void MigrateDateColumn(IScope scope, string tableName, string columName, string timezoneName, TimeSpan timeZoneOffset)
private static string GetWindowsTimeZoneId(TimeZoneInfo timeZone)
{
var offsetInMinutes = -timeZoneOffset.TotalMinutes;
var offSetInMinutesString = offsetInMinutes > 0
? $"+{offsetInMinutes}"
: $"{offsetInMinutes}";
if (timeZone.HasIanaId is false)
{
return timeZone.Id;
}
Sql sql;
if (TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZone.Id, out var windowsId) is false)
{
throw new InvalidOperationException(
$"Could not update system dates to UTC as it was not possible to convert the detected local time zone IANA id '{timeZone.Id}' to a Windows Id necessary for updates with SQL Server. Please manually configure the 'Umbraco:CMS:SystemDateMigration:LocalServerTimeZone' app setting with a valid Windows time zone name.");
}
return windowsId;
}
private void MigrateDateColumn(
IScope scope,
string tableName,
string columName,
(string Name, Lazy<TimeSpan> BaseOffset, Lazy<string> WindowsName) timeZone)
{
if (DatabaseType == DatabaseType.SQLite)
{
// SQLite does not support AT TIME ZONE, but we can use the offset to update the dates. It won't take account of daylight saving time, but
// given these are historical dates in expected non-production environments, that are unlikely to be necessary to be 100% accurate, this is acceptable.
sql = Sql($"UPDATE {tableName} SET {columName} = DATETIME({columName}, '{offSetInMinutesString} minutes')");
MigrateDateColumnSQLite(scope, tableName, columName, timeZone.Name, timeZone.BaseOffset.Value);
}
else
{
sql = Sql($"UPDATE {tableName} SET {columName} = {columName} AT TIME ZONE '{timezoneName}' AT TIME ZONE 'UTC'");
MigrateDateColumnSqlServer(scope, tableName, columName, timeZone.WindowsName.Value);
}
}
private void MigrateDateColumnSQLite(
IScope scope,
string tableName,
string columName,
string timezoneName,
TimeSpan timezoneOffset)
{
// SQLite does not support AT TIME ZONE, but we can use the offset to update the dates. It won't take account of daylight saving time, but
// given these are historical dates in expected non-production environments, that are unlikely to be necessary to be 100% accurate, this is acceptable.
var offsetInMinutes = -timezoneOffset.TotalMinutes;
var offsetInMinutesString = offsetInMinutes > 0
? $"+{offsetInMinutes}"
: $"{offsetInMinutes}";
Sql sql = Sql($"UPDATE {tableName} SET {columName} = DATETIME({columName}, '{offsetInMinutesString} minutes')");
scope.Database.Execute(sql);
_logger.LogInformation(
"Migrated {TableName}.{ColumnName} from local server timezone of {TimeZoneName} ({OffSetInMinutes} minutes) to UTC.",
"Migrated {TableName}.{ColumnName} from timezone {TimeZoneName} ({OffsetInMinutes}) to UTC.",
tableName,
columName,
timezoneName,
offSetInMinutesString);
offsetInMinutesString);
}
private void MigrateDateColumnSqlServer(IScope scope, string tableName, string columName, string timeZoneName)
{
Sql sql = Sql($"UPDATE {tableName} SET {columName} = {columName} AT TIME ZONE '{timeZoneName}' AT TIME ZONE 'UTC'");
scope.Database.Execute(sql);
_logger.LogInformation(
"Migrated {TableName}.{ColumnName} from timezone {TimeZoneName} to UTC.",
tableName,
columName,
timeZoneName);
}
}

View File

@@ -1,47 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Configuration.Models.Validation;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models.Validation
{
[TestFixture]
public class SystemDateMigrationSettingsValidatorTests
{
[Test]
public void Returns_Success_For_Empty_Configuration()
{
var validator = new SystemDateMigrationSettingsValidator();
SystemDateMigrationSettings options = BuildSystemDateMigrationSettings();
ValidateOptionsResult result = validator.Validate("settings", options);
Assert.True(result.Succeeded);
}
[Test]
public void Returns_Success_For_Valid_Configuration()
{
var validator = new SystemDateMigrationSettingsValidator();
SystemDateMigrationSettings options = BuildSystemDateMigrationSettings(localServerTimeZone: "Central European Standard Time");
ValidateOptionsResult result = validator.Validate("settings", options);
Assert.True(result.Succeeded);
}
[Test]
public void Returns_Fail_For_Configuration_With_Invalid_LocalServerTimeZone()
{
var validator = new SystemDateMigrationSettingsValidator();
SystemDateMigrationSettings options = BuildSystemDateMigrationSettings(localServerTimeZone: "Invalid Time Zone");
ValidateOptionsResult result = validator.Validate("settings", options);
Assert.False(result.Succeeded);
}
private static SystemDateMigrationSettings BuildSystemDateMigrationSettings(string? localServerTimeZone = null) =>
new SystemDateMigrationSettings
{
LocalServerTimeZone = localServerTimeZone,
};
}
}