diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs index 8f389b67b9c2..d9102729edc5 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs @@ -7,6 +7,7 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Features; using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Cms.Web.Common.Controllers; namespace Umbraco.Cms.Api.Delivery.Controllers; @@ -14,6 +15,7 @@ namespace Umbraco.Cms.Api.Delivery.Controllers; [JsonOptionsName(Constants.JsonOptionsNames.DeliveryApi)] [MapToApi(DeliveryApiConfiguration.ApiName)] [Authorize(Policy = AuthorizationPolicies.UmbracoFeatureEnabled)] +[MaintenanceModeActionFilter] public abstract class DeliveryApiControllerBase : Controller, IUmbracoFeature { protected string DecodePath(string path) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs index c5ff9382c499..d1a9e08a1f74 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs @@ -13,6 +13,7 @@ using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Cms.Web.Common.Controllers; using Umbraco.Cms.Web.Common.Filters; namespace Umbraco.Cms.Api.Management.Controllers; @@ -25,6 +26,7 @@ namespace Umbraco.Cms.Api.Management.Controllers; [AppendEventMessages] [DisableBrowserCache] [Produces("application/json")] +[MaintenanceModeActionFilter] public abstract class ManagementApiControllerBase : Controller, IUmbracoFeature { protected IActionResult CreatedAtId(Expression> action, Guid id) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs index 3ed50c651dec..023015f9b17f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs @@ -9,9 +9,16 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Web.Common.Controllers; + namespace Umbraco.Cms.Api.Management.Controllers.Server; [ApiVersion("1.0")] + +// The backoffice shell reads /server/configuration during its initial connection to determine +// settings such as whether local login is allowed. This endpoint must be reachable during an +// unattended upgrade, so the maintenance filter is explicitly bypassed. +[SkipMaintenanceModeFilter] public class ConfigurationServerController : ServerControllerBase { private readonly SecuritySettings _securitySettings; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Server/StatusServerController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Server/StatusServerController.cs index b82c4ec2a9cb..c0791ce5097e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Server/StatusServerController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Server/StatusServerController.cs @@ -4,10 +4,16 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Api.Management.ViewModels.Server; +using Umbraco.Cms.Web.Common.Controllers; namespace Umbraco.Cms.Api.Management.Controllers.Server; [ApiVersion("1.0")] + +// The backoffice shell reads /server/status to detect RuntimeLevel.Upgrading and show the +// "automatic upgrade in progress" modal. This endpoint must be reachable during an unattended +// upgrade, so the maintenance filter is explicitly bypassed. +[SkipMaintenanceModeFilter] public class StatusServerController : ServerControllerBase { private readonly IRuntimeState _runtimeState; diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 2ca5ab204481..8f811f205f60 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -48418,6 +48418,7 @@ "Boot", "Install", "Upgrade", + "Upgrading", "Run", "BootFailed" ], diff --git a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs index 778dbf691f12..136e0c148301 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs @@ -26,7 +26,7 @@ public BackOfficeAreaRoutes(IRuntimeState runtimeState) /// public void CreateRoutes(IEndpointRouteBuilder endpoints) { - if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Run) + if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run) { MapMinimalBackOffice(endpoints); diff --git a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs index 1327e1e14466..2f4cf8fb0fb7 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs @@ -19,7 +19,7 @@ public PreviewRoutes(IRuntimeState runtimeState) public void CreateRoutes(IEndpointRouteBuilder endpoints) { - if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Run) + if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run) { endpoints.MapHub(GetPreviewHubRoute()); } diff --git a/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Upgrading.cshtml b/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Upgrading.cshtml new file mode 100644 index 000000000000..0c09c6ef3b98 --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Upgrading.cshtml @@ -0,0 +1,48 @@ +@using Umbraco.Cms.Core.Hosting +@using Umbraco.Cms.Core.Routing +@inject IHostingEnvironment HostingEnvironment +@{ + var backOfficePath = HostingEnvironment.GetBackOfficePath(); +} + + + + + + + + Site is Being Upgraded + + + + + + +
+
+
+

Website is Under Maintenance

+

Automatic upgrade in progress

+

The site is currently running an automatic upgrade. It will be available again shortly.

+
+
+
+ + + diff --git a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs index d05d0c898961..0057685d66ae 100644 --- a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs @@ -102,6 +102,11 @@ public class GlobalSettings private const bool StaticForceCombineUrlPathLeftToRight = true; private const bool StaticShowMaintenancePageWhenInUpgradeState = true; + /// + /// The default value for the setting. + /// + internal const string StaticUpgradingViewPath = "~/umbraco/UmbracoWebsite/Upgrading.cshtml"; + /// /// Gets or sets a value for the reserved URLs (must end with a comma). /// @@ -317,4 +322,10 @@ public class GlobalSettings /// [DefaultValue(StaticShowMaintenancePageWhenInUpgradeState)] public bool ShowMaintenancePageWhenInUpgradeState { get; set; } = StaticShowMaintenancePageWhenInUpgradeState; + + /// + /// Gets or sets the view path shown during an unattended background upgrade (). + /// + [DefaultValue(StaticUpgradingViewPath)] + public string UpgradingViewPath { get; set; } = StaticUpgradingViewPath; } diff --git a/src/Umbraco.Core/Extensions/RuntimeStateExtensions.cs b/src/Umbraco.Core/Extensions/RuntimeStateExtensions.cs index b6d80f14e41e..a1bb850bdfc7 100644 --- a/src/Umbraco.Core/Extensions/RuntimeStateExtensions.cs +++ b/src/Umbraco.Core/Extensions/RuntimeStateExtensions.cs @@ -31,7 +31,6 @@ public static bool EnableInstaller(this IRuntimeState state) /// /// public static bool RunUnattendedBootLogic(this IRuntimeState state) - => (state.Reason == RuntimeLevelReason.UpgradeMigrations || - state.Reason == RuntimeLevelReason.UpgradePackageMigrations) - && state.Level == RuntimeLevel.Run; + => (state.Reason == RuntimeLevelReason.UpgradeMigrations || state.Reason == RuntimeLevelReason.UpgradePackageMigrations) + && (state.Level == RuntimeLevel.Run || state.Level == RuntimeLevel.Upgrading); } diff --git a/src/Umbraco.Core/RuntimeLevel.cs b/src/Umbraco.Core/RuntimeLevel.cs index 5b726045a93f..f11e5a3a5955 100644 --- a/src/Umbraco.Core/RuntimeLevel.cs +++ b/src/Umbraco.Core/RuntimeLevel.cs @@ -32,6 +32,12 @@ public enum RuntimeLevel /// Upgrade = 3, + /// + /// The runtime is running an unattended upgrade as a background task. + /// The HTTP server is up and responding to health probes and maintenance-page requests. + /// + Upgrading = 4, + /// /// The runtime has detected an up-to-date Umbraco install and is running. /// diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index b0e9689c69bf..152cafeec962 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -92,6 +92,7 @@ public static IUmbracoBuilder AddCoreInitialServices(this IUmbracoBuilder builde builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); + builder.Services.AddHostedService(); // Database availability check. builder.Services.AddUnique(); diff --git a/src/Umbraco.Infrastructure/Install/UnattendedUpgradeBackgroundService.cs b/src/Umbraco.Infrastructure/Install/UnattendedUpgradeBackgroundService.cs new file mode 100644 index 000000000000..ecbfed4329ca --- /dev/null +++ b/src/Umbraco.Infrastructure/Install/UnattendedUpgradeBackgroundService.cs @@ -0,0 +1,181 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Exceptions; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using ComponentCollection = Umbraco.Cms.Core.Composing.ComponentCollection; + +namespace Umbraco.Cms.Infrastructure.Install; + +/// +/// Runs unattended database migrations as a background task so the HTTP server can start +/// immediately and respond to health probes during the upgrade. +/// +/// +/// This service is only active when is set, which +/// happens when UpgradeUnattended or PackageMigrationsUnattended is true +/// and database migrations are pending. +/// +internal sealed class UnattendedUpgradeBackgroundService : BackgroundService +{ + private readonly IRuntimeState _runtimeState; + private readonly IEventAggregator _eventAggregator; + private readonly ComponentCollection _components; + private readonly IHostApplicationLifetime _hostApplicationLifetime; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The Umbraco runtime state. + /// The event aggregator used to publish upgrade notifications. + /// The component collection to initialize after migration completes. + /// The host application lifetime for registering started/stopped callbacks. + /// The logger. + public UnattendedUpgradeBackgroundService( + IRuntimeState runtimeState, + IEventAggregator eventAggregator, + ComponentCollection components, + IHostApplicationLifetime hostApplicationLifetime, + ILogger logger) + { + _runtimeState = runtimeState; + _eventAggregator = eventAggregator; + _components = components; + _hostApplicationLifetime = hostApplicationLifetime; + _logger = logger; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Only run when an unattended upgrade is pending. + if (_runtimeState.Level != RuntimeLevel.Upgrading) + { + return; + } + + _logger.LogInformation("Unattended upgrade background service started."); + + try + { + await RunMigrationsAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unattended upgrade failed."); + _runtimeState.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException, ex); + return; + } + + // Re-evaluate runtime level after migrations complete. This handles all result cases: + // - CoreUpgradeComplete / PackageMigrationComplete: confirms the new Run level. + // - NotRequired: another instance may have already run migrations; re-check to get Run level. + // - HasErrors: BootFailedException is set, so DetermineRuntimeLevel() returns early (no-op). + DetermineRuntimeLevel(); + + // RunMigrationsAsync may have set BootFailed via a non-throwing error path (HasErrors result). + if (_runtimeState.Level == RuntimeLevel.BootFailed) + { + return; + } + + // Migrations complete: initialize components and fire application lifecycle events. + try + { + await _components.InitializeAsync(false, stoppingToken); + await _eventAggregator.PublishAsync(new UmbracoApplicationStartingNotification(_runtimeState.Level, false), stoppingToken); + + _hostApplicationLifetime.ApplicationStarted.Register( + () => _eventAggregator.Publish(new UmbracoApplicationStartedNotification(false))); + _hostApplicationLifetime.ApplicationStopped.Register( + () => _eventAggregator.Publish(new UmbracoApplicationStoppedNotification(false))); + + _logger.LogInformation("Unattended upgrade completed successfully."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unattended upgrade post-migration initialization failed."); + _runtimeState.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException, ex); + } + } + + private async Task RunMigrationsAsync(CancellationToken stoppingToken) + { + // Step 1: Premigrations upgrade. + var premigrationNotification = new RuntimePremigrationsUpgradeNotification(); + await _eventAggregator.PublishAsync(premigrationNotification, stoppingToken); + + switch (premigrationNotification.UpgradeResult) + { + case RuntimePremigrationsUpgradeNotification.PremigrationUpgradeResult.HasErrors: + if (_runtimeState.BootFailedException is null) + { + throw new InvalidOperationException( + $"Premigration upgrade result was {RuntimePremigrationsUpgradeNotification.PremigrationUpgradeResult.HasErrors} but no {nameof(BootFailedException)} was registered."); + } + + _runtimeState.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException); + _logger.LogError(_runtimeState.BootFailedException, "Premigration upgrade failed."); + return; + + case RuntimePremigrationsUpgradeNotification.PremigrationUpgradeResult.CoreUpgradeComplete: + case RuntimePremigrationsUpgradeNotification.PremigrationUpgradeResult.NotRequired: + break; + } + + if (_runtimeState.Level == RuntimeLevel.BootFailed) + { + return; + } + + // Step 2: Post-premigrations (navigation and publish-status initialization). + await _eventAggregator.PublishAsync(new PostRuntimePremigrationsUpgradeNotification(), stoppingToken); + + // Step 3: Unattended upgrade (main migrations and package migrations). + var upgradeNotification = new RuntimeUnattendedUpgradeNotification(); + await _eventAggregator.PublishAsync(upgradeNotification, stoppingToken); + + switch (upgradeNotification.UnattendedUpgradeResult) + { + case RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors: + if (_runtimeState.BootFailedException is null) + { + throw new InvalidOperationException( + $"Unattended upgrade result was {RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors} but no {nameof(BootFailedException)} was registered."); + } + + _runtimeState.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException); + _logger.LogError(_runtimeState.BootFailedException, "Unattended upgrade failed."); + return; + + case RuntimeUnattendedUpgradeNotification.UpgradeResult.CoreUpgradeComplete: + case RuntimeUnattendedUpgradeNotification.UpgradeResult.PackageMigrationComplete: + case RuntimeUnattendedUpgradeNotification.UpgradeResult.NotRequired: + break; + } + } + + private void DetermineRuntimeLevel() + { + // If a boot failure was already registered (e.g. by a premigration handler), there is + // nothing more to determine — preserve the existing BootFailed state and return early, + // matching the equivalent guard in CoreRuntime.DetermineRuntimeLevel(). + if (_runtimeState.BootFailedException is not null) + { + return; + } + + try + { + _runtimeState.DetermineRuntimeLevel(); + } + catch (Exception ex) + { + _runtimeState.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException, ex); + _logger.LogError(ex, "Failed to determine runtime level during unattended upgrade."); + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/AuditEntryRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/AuditEntryRepository.cs index f9c66b4036a5..8ff3b051e03b 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/AuditEntryRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/AuditEntryRepository.cs @@ -130,7 +130,7 @@ protected override void PersistNewItem(IAuditEntry entity) { Database.Insert(dto); } - catch (DbException) when (_runtimeState.Level == RuntimeLevel.Upgrade) + catch (DbException) when (_runtimeState.Level is RuntimeLevel.Upgrade or RuntimeLevel.Upgrading) { // This can happen when in upgrade state, before the migration to add user keys runs. // In this case, we will try to insert the audit entry without the user keys. diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs index 60b27db4ca83..a0c902035796 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs @@ -160,7 +160,7 @@ private IEnumerable ConvertFromDtos(IEnumerable dtos) => private IUser? GetUpgradeUserWith(Action> with) { - if (_runtimeState.Level != RuntimeLevel.Upgrade) + if (_runtimeState.Level is not RuntimeLevel.Upgrade and not RuntimeLevel.Upgrading) { return null; } @@ -438,7 +438,7 @@ private void PerformGetReferencedDtos(List dtos) catch (DbException) { // ignore doing upgrade, as we know the Key potentially do not exists - if (_runtimeState.Level != RuntimeLevel.Upgrade) + if (_runtimeState.Level is not RuntimeLevel.Upgrade and not RuntimeLevel.Upgrading) { throw; } diff --git a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs index 994241990d22..68b4224d1b1e 100644 --- a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs +++ b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs @@ -113,12 +113,17 @@ private async Task StartAsync(CancellationToken cancellationToken, bool isRestar return; } - IApplicationShutdownRegistry hostingEnvironmentLifetime = _applicationShutdownRegistry; - if (hostingEnvironmentLifetime == null) + if (State.Level == RuntimeLevel.Upgrading) { - throw new InvalidOperationException($"An instance of {typeof(IApplicationShutdownRegistry)} could not be resolved from the container, ensure that one if registered in your runtime before calling {nameof(IRuntime)}.{nameof(StartAsync)}"); + // Unattended upgrade: the database factory is already configured for upgrade. + // The UnattendedUpgradeBackgroundService will run the migration sequence once the + // HTTP server has started, allowing liveness probes to respond immediately. + return; } + IApplicationShutdownRegistry hostingEnvironmentLifetime = _applicationShutdownRegistry + ?? throw new InvalidOperationException($"An instance of {typeof(IApplicationShutdownRegistry)} could not be resolved from the container, ensure that one if registered in your runtime before calling {nameof(IRuntime)}.{nameof(StartAsync)}"); + var premigrationUpgradeNotification = new RuntimePremigrationsUpgradeNotification(); await _eventAggregator.PublishAsync(premigrationUpgradeNotification, cancellationToken); switch (premigrationUpgradeNotification.UpgradeResult) @@ -139,7 +144,6 @@ private async Task StartAsync(CancellationToken cancellationToken, bool isRestar break; } - // var postRuntimePremigrationsUpgradeNotification = new PostRuntimePremigrationsUpgradeNotification(); await _eventAggregator.PublishAsync(postRuntimePremigrationsUpgradeNotification, cancellationToken); @@ -221,7 +225,7 @@ private void DetermineRuntimeLevel() _logger.LogDebug("Runtime level: {RuntimeLevel} - {RuntimeLevelReason}", State.Level, State.Reason); } - if (State.Level == RuntimeLevel.Upgrade) + if (State.Level is RuntimeLevel.Upgrade or RuntimeLevel.Upgrading) { if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) { diff --git a/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs b/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs index bd1153f51af6..dc888cb1d07c 100644 --- a/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs +++ b/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs @@ -207,11 +207,17 @@ public void DetermineRuntimeLevel() // although the files version matches the code version, the database version does not // which means the local files have been upgraded but not the database - need to upgrade - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("Has not reached the final upgrade step, need to upgrade Umbraco."); } - Level = _unattendedSettings.Value.UpgradeUnattended ? RuntimeLevel.Run : RuntimeLevel.Upgrade; + + // When unattended upgrade is enabled, set Upgrading so that CoreRuntime returns early and + // UnattendedUpgradeBackgroundService runs the migrations after the HTTP server has + // started (allowing health probes to respond). + // When unattended upgrade is disabled, set Upgrade so that the operator can trigger the migration + // manually via the back office. + Level = _unattendedSettings.Value.UpgradeUnattended ? RuntimeLevel.Upgrading : RuntimeLevel.Upgrade; Reason = RuntimeLevelReason.UpgradeMigrations; } break; @@ -223,14 +229,22 @@ public void DetermineRuntimeLevel() if (_unattendedSettings.Value.PackageMigrationsUnattended) { - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("Package migrations need to execute."); } + + // Upgrading signals CoreRuntime to return early so the HTTP server starts immediately; + // UnattendedUpgradeBackgroundService then runs the package migrations in the background + // while health probes remain reachable. + Level = RuntimeLevel.Upgrading; Reason = RuntimeLevelReason.UpgradePackageMigrations; } else { + // Package migrations are pending but unattended execution is disabled. + // Keep Level = Run so the site stays operational; the operator must trigger the package + // migrations manually from the back office. _logger.LogInformation("Package migrations need to execute but unattended package migrations is disabled. They will need to be run from the back office."); Reason = RuntimeLevelReason.Run; } @@ -239,8 +253,6 @@ public void DetermineRuntimeLevel() case UmbracoDatabaseState.Ok: default: { - - // the database version matches the code & files version, all clear, can run Level = RuntimeLevel.Run; Reason = RuntimeLevelReason.Run; diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 32b613739103..17192157ca15 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -370,7 +370,7 @@ public Task> GetAllInGroupAsync(int groupId) } private bool IsUpgrading => - _runtimeState.Level == RuntimeLevel.Install || _runtimeState.Level == RuntimeLevel.Upgrade; + _runtimeState.Level == RuntimeLevel.Install || _runtimeState.Level == RuntimeLevel.Upgrade || _runtimeState.Level == RuntimeLevel.Upgrading; /// public override Task UpdateAsync( diff --git a/src/Umbraco.Web.Common/ActionsResults/MaintenanceResult.cs b/src/Umbraco.Web.Common/ActionsResults/MaintenanceResult.cs index 6e27321f0038..c97fdbe5abc7 100644 --- a/src/Umbraco.Web.Common/ActionsResults/MaintenanceResult.cs +++ b/src/Umbraco.Web.Common/ActionsResults/MaintenanceResult.cs @@ -1,16 +1,30 @@ -using System.Net; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Core.Routing; -using Umbraco.Cms.Core.Web; namespace Umbraco.Cms.Web.Common.ActionsResults; /// -/// Returns the Umbraco maintenance result +/// Returns the Umbraco maintenance result. /// public class MaintenanceResult : IActionResult { + private readonly string _viewName; + + /// + /// Initializes a new instance of the class using the default maintenance view. + /// + public MaintenanceResult() + : this("~/umbraco/UmbracoWebsite/Maintenance.cshtml") + { + } + + /// + /// Initializes a new instance of the class using a custom view path. + /// + /// The view path to render, e.g. ~/umbraco/UmbracoWebsite/Upgrading.cshtml. + public MaintenanceResult(string viewName) + => _viewName = viewName; + /// public async Task ExecuteResultAsync(ActionContext context) { @@ -20,7 +34,7 @@ public async Task ExecuteResultAsync(ActionContext context) response.StatusCode = StatusCodes.Status503ServiceUnavailable; - var viewResult = new ViewResult { ViewName = "~/umbraco/UmbracoWebsite/Maintenance.cshtml" }; + var viewResult = new ViewResult { ViewName = _viewName }; await viewResult.ExecuteResultAsync(context); } diff --git a/src/Umbraco.Web.Common/Controllers/MaintenanceModeActionFilterAttribute.cs b/src/Umbraco.Web.Common/Controllers/MaintenanceModeActionFilterAttribute.cs index 4f5877a76567..0f1dddbd9ec6 100644 --- a/src/Umbraco.Web.Common/Controllers/MaintenanceModeActionFilterAttribute.cs +++ b/src/Umbraco.Web.Common/Controllers/MaintenanceModeActionFilterAttribute.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; @@ -8,10 +9,18 @@ namespace Umbraco.Cms.Web.Common.Controllers; -internal sealed class MaintenanceModeActionFilterAttribute : TypeFilterAttribute +/// +/// Represents an action filter attribute that enforces maintenance mode by displaying a maintenance page during +/// application upgrades. +/// +public sealed class MaintenanceModeActionFilterAttribute : TypeFilterAttribute { - public MaintenanceModeActionFilterAttribute() : base(typeof(MaintenanceModeActionFilter)) => Order = int.MinValue; // Ensures this run as the first filter. + /// + /// Initializes a new instance of the class. + /// + public MaintenanceModeActionFilterAttribute() + : base(typeof(MaintenanceModeActionFilter)) => Order = int.MinValue; // Ensures this run as the first filter. private sealed class MaintenanceModeActionFilter : IActionFilter { @@ -26,16 +35,50 @@ public MaintenanceModeActionFilter(IRuntimeState runtimeState, IOptionsMonitor().Any()) { - context.Result = new MaintenanceResult(); + return; } + if (_globalSettings.CurrentValue.ShowMaintenancePageWhenInUpgradeState is false) + { + return; + } + + bool isApiController = context.ActionDescriptor.EndpointMetadata.OfType().Any(); + + // API controllers (Management/Delivery API) are only blocked during an unattended upgrade + // (RuntimeLevel.Upgrading). During an attended upgrade (RuntimeLevel.Upgrade) the operator + // needs API access to log in and trigger the upgrade from the backoffice. + // MVC controllers (website, surface) are blocked during both Upgrade and Upgrading. + bool shouldBlock = isApiController + ? _runtimeState.Level == RuntimeLevel.Upgrading + : _runtimeState.Level is RuntimeLevel.Upgrade or RuntimeLevel.Upgrading; + + if (shouldBlock is false) + { + return; + } + + if (isApiController) + { + var problem = new ProblemDetails + { + Status = StatusCodes.Status503ServiceUnavailable, + Title = "Service Unavailable", + Detail = "The application is currently being upgraded. Please try again later.", + }; + context.Result = new ObjectResult(problem) { StatusCode = StatusCodes.Status503ServiceUnavailable }; + return; + } + + context.Result = _runtimeState.Level == RuntimeLevel.Upgrading + ? new MaintenanceResult(_globalSettings.CurrentValue.UpgradingViewPath) + : new MaintenanceResult(); } public void OnActionExecuted(ActionExecutedContext context) { - } } } diff --git a/src/Umbraco.Web.Common/Controllers/SkipMaintenanceModeFilterAttribute.cs b/src/Umbraco.Web.Common/Controllers/SkipMaintenanceModeFilterAttribute.cs new file mode 100644 index 000000000000..b7390f2dd5ed --- /dev/null +++ b/src/Umbraco.Web.Common/Controllers/SkipMaintenanceModeFilterAttribute.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Web.Common.Controllers; + +/// +/// Suppresses for the decorated controller or action, +/// allowing it to respond normally during application upgrades. +/// +/// +/// Apply to endpoints that must remain accessible during an upgrade — for example, server status and +/// configuration endpoints that the backoffice needs to detect the upgrading state. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class SkipMaintenanceModeFilterAttribute : Attribute +{ +} diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index d0ae6c04c959..3c696c264170 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -29,27 +29,23 @@ using Umbraco.Cms.Core.Preview; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Cms.Core.Templates; using Umbraco.Cms.Core.Web; -using Umbraco.Cms.Infrastructure.BackgroundJobs; -using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; -using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; -using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration; using Umbraco.Cms.Infrastructure.DependencyInjection; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Cms.Infrastructure.Migrations.Install; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; using Umbraco.Cms.Web.Common; using Umbraco.Cms.Web.Common.ApplicationModels; using Umbraco.Cms.Web.Common.AspNetCore; +using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.Blocks; using Umbraco.Cms.Web.Common.Cache; using Umbraco.Cms.Web.Common.Configuration; using Umbraco.Cms.Web.Common.Controllers; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.FileProviders; +using Umbraco.Cms.Web.Common.HealthChecks; using Umbraco.Cms.Web.Common.Helpers; using Umbraco.Cms.Web.Common.Localization; using Umbraco.Cms.Web.Common.Middleware; @@ -61,7 +57,6 @@ using Umbraco.Cms.Web.Common.Security; using Umbraco.Cms.Web.Common.Templates; using Umbraco.Cms.Web.Common.UmbracoContext; -using Umbraco.Cms.Web.Common.Authorization; using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; namespace Umbraco.Extensions; @@ -332,6 +327,9 @@ public static IUmbracoBuilder AddWebComponents(this IUmbracoBuilder builder) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddHealthChecks() + .AddCheck("umbraco-ready", tags: [UmbracoReadinessHealthCheck.ReadyTag]); + builder.Services.AddUnique(); builder.Services.AddUnique(); diff --git a/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs b/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs index 76ef786fbf19..65770b5e7fd6 100644 --- a/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs @@ -1,5 +1,6 @@ using Dazinator.Extensions.FileProviders.PrependBasePath; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Rewrite; using Microsoft.Extensions.DependencyInjection; @@ -17,6 +18,7 @@ using Umbraco.Cms.Core.Logging.Serilog.Enrichers; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.ApplicationBuilder; +using Umbraco.Cms.Web.Common.HealthChecks; using Umbraco.Cms.Web.Common.Hosting; using Umbraco.Cms.Web.Common.Media; using Umbraco.Cms.Web.Common.Middleware; @@ -101,6 +103,16 @@ public static IApplicationBuilder UseUmbracoRouting(this IApplicationBuilder app } else { + // BootFailedMiddleware must also be registered on the boot-success path because + // RuntimeLevel.BootFailed can be set at runtime by UnattendedUpgradeBackgroundService + // if a background migration fails after the HTTP server has already started. + app.UseMiddleware(); + + // Health probes are registered before other middleware so they are reachable + // during Upgrading state. They are intercepted by BootFailedMiddleware when + // the runtime transitions to BootFailed after an upgrade failure. + app.UseUmbracoHealthChecks(); + app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); @@ -159,6 +171,37 @@ public static IApplicationBuilder UseUmbracoPluginsStaticFiles(this IApplication return app; } + /// + /// Registers Umbraco health probe endpoints. + /// + /// + /// + /// GET /umbraco/api/health/live — always 200 while the process is alive. + /// + /// + /// GET /umbraco/api/health/ready — 200 only when , + /// 503 Degraded during and other non-Run states. + /// + /// + public static IApplicationBuilder UseUmbracoHealthChecks(this IApplicationBuilder app) + { + // Liveness — always 200 if the process responds (no custom checks). + app.UseHealthChecks("/umbraco/api/health/live", new HealthCheckOptions + { + Predicate = _ => false, + AllowCachingResponses = false, + }); + + // Readiness — 200 only when RuntimeLevel.Run. + app.UseHealthChecks("/umbraco/api/health/ready", new HealthCheckOptions + { + Predicate = check => check.Tags.Contains(UmbracoReadinessHealthCheck.ReadyTag), + AllowCachingResponses = false, + }); + + return app; + } + /// /// Configure custom umbraco file provider for media /// diff --git a/src/Umbraco.Web.Common/HealthChecks/UmbracoReadinessHealthCheck.cs b/src/Umbraco.Web.Common/HealthChecks/UmbracoReadinessHealthCheck.cs new file mode 100644 index 000000000000..a4496e19c87f --- /dev/null +++ b/src/Umbraco.Web.Common/HealthChecks/UmbracoReadinessHealthCheck.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Web.Common.HealthChecks; + +/// +/// ASP.NET Core health check that reports readiness based on the Umbraco runtime level. +/// Reports only when the runtime level is . +/// +internal sealed class UmbracoReadinessHealthCheck : IHealthCheck +{ + internal const string ReadyTag = "umbraco-ready"; + + private readonly IRuntimeState _runtimeState; + + public UmbracoReadinessHealthCheck(IRuntimeState runtimeState) + => _runtimeState = runtimeState; + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + => Task.FromResult( + _runtimeState.Level == RuntimeLevel.Run + ? HealthCheckResult.Healthy("Umbraco is ready.") + : HealthCheckResult.Degraded($"Umbraco is not yet ready. Level: {_runtimeState.Level}")); +} diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index 827850beab1f..3c1bff7336d8 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -253,6 +253,14 @@ export class UmbAppElement extends UmbLitElement { history.replaceState(null, '', 'upgrade'); break; + case RuntimeLevelModel.UPGRADING: + this.#errorPage( + 'An automatic upgrade is currently in progress. The backoffice will be available once the upgrade has completed.', + undefined, + { headline: 'Website is Under Maintenance', hideBackButton: true }, + ); + break; + case RuntimeLevelModel.BOOT_FAILED: this.#errorPage('The Umbraco server failed to boot'); break; @@ -280,7 +288,7 @@ export class UmbAppElement extends UmbLitElement { return () => this.#authController.isAuthorized() ?? false; } - #errorPage(errorMsg: string, error?: unknown) { + #errorPage(errorMsg: string, error?: unknown, options?: { headline?: string; hideBackButton?: boolean }) { // Redirect to the error page this._routes = [ { @@ -289,6 +297,8 @@ export class UmbAppElement extends UmbLitElement { setup: (component) => { (component as UmbAppErrorElement).errorMessage = errorMsg; (component as UmbAppErrorElement).error = error; + if (options?.headline) (component as UmbAppErrorElement).errorHeadline = options.headline; + if (options?.hideBackButton) (component as UmbAppErrorElement).hideBackButton = true; }, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts index 995ae7b21b9b..6dbb55f35e91 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts @@ -2252,6 +2252,7 @@ export enum RuntimeLevelModel { BOOT = 'Boot', INSTALL = 'Install', UPGRADE = 'Upgrade', + UPGRADING = 'Upgrading', RUN = 'Run', BOOT_FAILED = 'BootFailed' } diff --git a/src/Umbraco.Web.Website/Controllers/SurfaceController.cs b/src/Umbraco.Web.Website/Controllers/SurfaceController.cs index e32edd2fe367..37b4c2f998f8 100644 --- a/src/Umbraco.Web.Website/Controllers/SurfaceController.cs +++ b/src/Umbraco.Web.Website/Controllers/SurfaceController.cs @@ -17,6 +17,7 @@ namespace Umbraco.Cms.Web.Website.Controllers; /// Provides a base class for front-end add-in controllers. /// [AutoValidateAntiforgeryToken] +[MaintenanceModeActionFilter] public abstract class SurfaceController : PluginController { /// diff --git a/src/Umbraco.Web.Website/Routing/EagerMatcherPolicy.cs b/src/Umbraco.Web.Website/Routing/EagerMatcherPolicy.cs index 37035ef8ab4b..376530810748 100644 --- a/src/Umbraco.Web.Website/Routing/EagerMatcherPolicy.cs +++ b/src/Umbraco.Web.Website/Routing/EagerMatcherPolicy.cs @@ -231,7 +231,7 @@ private Endpoint GetRenderEndpoint() private Task HandleInstallUpgrade(HttpContext httpContext, CandidateSet candidates) { - if (_runtimeState.Level != RuntimeLevel.Upgrade) + if (_runtimeState.Level is not RuntimeLevel.Upgrade and not RuntimeLevel.Upgrading) { // We need to let the installer API requests through // Currently we do this with a check for the installer path @@ -250,8 +250,8 @@ private Task HandleInstallUpgrade(HttpContext httpContext, CandidateSet ca return Task.FromResult(true); } - // Check if maintenance page should be shown - // Current behaviour is that statically routed endpoints still work in upgrade state + // Check if maintenance page should be shown. + // Current behaviour is that statically routed endpoints still work in upgrade state. // This means that IF there is a static route, we should not show the maintenance page. // And instead carry on as we normally would. var hasStaticRoute = false; @@ -266,7 +266,7 @@ private Task HandleInstallUpgrade(HttpContext httpContext, CandidateSet ca } } - if (_runtimeState.Level != RuntimeLevel.Upgrade + if (_runtimeState.Level is not RuntimeLevel.Upgrade and not RuntimeLevel.Upgrading || _globalSettings.ShowMaintenancePageWhenInUpgradeState is false || hasStaticRoute) { @@ -282,6 +282,5 @@ private Task HandleInstallUpgrade(HttpContext httpContext, CandidateSet ca }); return Task.FromResult(true); - } } diff --git a/src/Umbraco.Web.Website/Routing/FrontEndRoutes.cs b/src/Umbraco.Web.Website/Routing/FrontEndRoutes.cs index e6ecb1e0cc01..0923830a7e9a 100644 --- a/src/Umbraco.Web.Website/Routing/FrontEndRoutes.cs +++ b/src/Umbraco.Web.Website/Routing/FrontEndRoutes.cs @@ -31,7 +31,7 @@ public FrontEndRoutes(IRuntimeState runtimeState, SurfaceControllerTypeCollectio /// public void CreateRoutes(IEndpointRouteBuilder endpoints) { - if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Run) + if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run) { AutoRouteSurfaceControllers(endpoints); AutoRouteFrontEndApiControllers(endpoints); diff --git a/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs b/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs index a23f1a531e5c..267f35f8b296 100644 --- a/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs +++ b/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Routing; @@ -126,6 +127,14 @@ public override async ValueTask TransformAsync( return null!; } + // During a background unattended upgrade, content services are not yet initialized. + // Return null so the dynamic route is removed from candidates, leaving any static routes + // (e.g. surface controllers) to be matched and handled by their own action filters. + if (_runtime.Level == RuntimeLevel.Upgrading) + { + return null!; + } + if (!_routableDocumentFilter.IsDocumentRequest(httpContext.Request.Path)) { return null!; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/RuntimeStateTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/RuntimeStateTests.cs index 9903c5a25e0c..42902edab5ab 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/RuntimeStateTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/RuntimeStateTests.cs @@ -46,11 +46,11 @@ public void GivenPackageMigrationsExist_WhenLatestStateIsRegistered_ThenLevelIsR } [Test] - public void GivenPackageMigrationsExist_WhenUnattendedMigrations_ThenLevelIsRun() + public void GivenPackageMigrationsExist_WhenUnattendedMigrations_ThenLevelIsUpgrading() { RuntimeState.DetermineRuntimeLevel(); - Assert.AreEqual(RuntimeLevel.Run, RuntimeState.Level); + Assert.AreEqual(RuntimeLevel.Upgrading, RuntimeState.Level); Assert.AreEqual(RuntimeLevelReason.UpgradePackageMigrations, RuntimeState.Reason); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/RuntimeStateExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/RuntimeStateExtensionsTests.cs new file mode 100644 index 000000000000..1c9b467a42a7 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/RuntimeStateExtensionsTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Extensions; + +[TestFixture] +public class RuntimeStateExtensionsTests +{ + [TestCase(RuntimeLevel.Run, RuntimeLevelReason.UpgradeMigrations)] + [TestCase(RuntimeLevel.Run, RuntimeLevelReason.UpgradePackageMigrations)] + [TestCase(RuntimeLevel.Upgrading, RuntimeLevelReason.UpgradeMigrations)] + [TestCase(RuntimeLevel.Upgrading, RuntimeLevelReason.UpgradePackageMigrations)] + public void RunUnattendedBootLogic_WhenLevelAndReasonIndicateUnattendedUpgrade_ReturnsTrue( + RuntimeLevel level, RuntimeLevelReason reason) + { + var state = MockState(level, reason); + Assert.That(state.RunUnattendedBootLogic(), Is.True); + } + + [TestCase(RuntimeLevel.Run, RuntimeLevelReason.Run)] + [TestCase(RuntimeLevel.Upgrading, RuntimeLevelReason.Run)] + [TestCase(RuntimeLevel.Upgrade, RuntimeLevelReason.UpgradeMigrations)] + [TestCase(RuntimeLevel.BootFailed, RuntimeLevelReason.UpgradeMigrations)] + public void RunUnattendedBootLogic_WhenLevelOrReasonDoNotIndicateUnattendedUpgrade_ReturnsFalse( + RuntimeLevel level, RuntimeLevelReason reason) + { + var state = MockState(level, reason); + Assert.That(state.RunUnattendedBootLogic(), Is.False); + } + + [TestCase(RuntimeLevel.Upgrading)] + [TestCase(RuntimeLevel.Run)] + public void UmbracoCanBoot_WhenLevelIsAboveBootFailed_ReturnsTrue(RuntimeLevel level) + { + var state = MockState(level); + Assert.That(state.UmbracoCanBoot(), Is.True); + } + + public void UmbracoCanBoot_WhenLevelIsBootFailed_ReturnsFalse() + { + var state = MockState(RuntimeLevel.BootFailed); + Assert.That(state.UmbracoCanBoot(), Is.False); + } + + // Unknown = 0 > BootFailed = -1, so UmbracoCanBoot returns true for Unknown. + [TestCase(RuntimeLevel.Unknown)] + [TestCase(RuntimeLevel.Boot)] + public void UmbracoCanBoot_WhenLevelIsAboveBootFailedButBelowRun_ReturnsTrue(RuntimeLevel level) + { + var state = MockState(level); + Assert.That(state.UmbracoCanBoot(), Is.True); + } + + [TestCase(RuntimeLevel.Upgrading)] + [TestCase(RuntimeLevel.Run)] + [TestCase(RuntimeLevel.Boot)] + public void EnableInstaller_WhenLevelIsNeitherInstallNorUpgrade_ReturnsFalse(RuntimeLevel level) + { + var state = MockState(level); + Assert.That(state.EnableInstaller(), Is.False); + } + + [TestCase(RuntimeLevel.Install)] + [TestCase(RuntimeLevel.Upgrade)] + public void EnableInstaller_WhenLevelIsInstallOrUpgrade_ReturnsTrue(RuntimeLevel level) + { + var state = MockState(level); + Assert.That(state.EnableInstaller(), Is.True); + } + + private static IRuntimeState MockState(RuntimeLevel level, RuntimeLevelReason reason = RuntimeLevelReason.Unknown) + => Mock.Of(s => s.Level == level && s.Reason == reason); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Install/UnattendedUpgradeBackgroundServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Install/UnattendedUpgradeBackgroundServiceTests.cs new file mode 100644 index 000000000000..af82d268ba60 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Install/UnattendedUpgradeBackgroundServiceTests.cs @@ -0,0 +1,312 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Exceptions; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Install; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Install; + +[TestFixture] +public class UnattendedUpgradeBackgroundServiceTests +{ + [TestCase(RuntimeLevel.Boot)] + [TestCase(RuntimeLevel.Install)] + [TestCase(RuntimeLevel.Upgrade)] + [TestCase(RuntimeLevel.Run)] + [TestCase(RuntimeLevel.BootFailed)] + [TestCase(RuntimeLevel.BootFailed)] + public async Task ExecuteAsync_WhenLevelIsNotUpgrading_DoesNothing(RuntimeLevel level) + { + var runtimeState = CreateMockRuntimeState(level); + var eventAggregator = new Mock(); + var sut = CreateSut(runtimeState.Object, eventAggregator.Object); + + await sut.StartAsync(CancellationToken.None); + await sut.ExecuteTask!; + + eventAggregator.Verify( + x => x.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task ExecuteAsync_WhenAllNotificationsAreNotRequired_InitializesComponentsAndRegistersLifetimeCallbacks() + { + // Simulates the multi-instance scenario where a second instance finds no migrations to run + // (because the first instance already completed them). Both notifications return NotRequired, + // but DetermineRuntimeLevel() must still be called so the level transitions from Upgrading to Run. + var runtimeState = CreateMockRuntimeState(); + var eventAggregator = new Mock(); + SetupPublishAsync(eventAggregator); + + var lifetime = CreateMockLifetime(); + var sut = CreateSut(runtimeState.Object, eventAggregator.Object, hostApplicationLifetime: lifetime.Object); + + await sut.StartAsync(CancellationToken.None); + await sut.ExecuteTask!; + + // All three notification steps must have fired. + eventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + eventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + eventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + eventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + + // DetermineRuntimeLevel must be called unconditionally after migrations complete. + runtimeState.Verify(x => x.DetermineRuntimeLevel(), Times.Once); + + // BootFailed must NOT be set. + runtimeState.Verify(x => x.Configure(RuntimeLevel.BootFailed, It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task ExecuteAsync_WhenPremigrationsReturnsCorUpgradeComplete_CallsDetermineRuntimeLevel() + { + var runtimeState = CreateMockRuntimeState(); + var eventAggregator = new Mock(); + + SetupPublishAsync(eventAggregator, premigrationResult: RuntimePremigrationsUpgradeNotification.PremigrationUpgradeResult.CoreUpgradeComplete); + + var sut = CreateSut(runtimeState.Object, eventAggregator.Object); + + await sut.StartAsync(CancellationToken.None); + await sut.ExecuteTask!; + + runtimeState.Verify(x => x.DetermineRuntimeLevel(), Times.Once); + } + + [Test] + public async Task ExecuteAsync_WhenPremigrationsHasErrorsAndBootFailedExceptionExists_SetsBootFailed() + { + var bootEx = new BootFailedException("db error"); + var runtimeState = CreateMockRuntimeState(initialBootFailedException: bootEx); + var eventAggregator = new Mock(); + + SetupPublishAsync(eventAggregator, premigrationResult: RuntimePremigrationsUpgradeNotification.PremigrationUpgradeResult.HasErrors); + + var sut = CreateSut(runtimeState.Object, eventAggregator.Object); + + await sut.StartAsync(CancellationToken.None); + await sut.ExecuteTask!; + + runtimeState.Verify(x => x.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException, null), Times.Once); + + // Execution stops after premigrations: post-premigrations and main upgrade must NOT fire. + eventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Never); + eventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task ExecuteAsync_WhenPremigrationsHasErrorsAndBootFailedExceptionIsNull_SetsBootFailed() + { + var runtimeState = CreateMockRuntimeState(); // BootFailedException starts null + var eventAggregator = new Mock(); + + SetupPublishAsync(eventAggregator, premigrationResult: RuntimePremigrationsUpgradeNotification.PremigrationUpgradeResult.HasErrors); + + var sut = CreateSut(runtimeState.Object, eventAggregator.Object); + + await sut.StartAsync(CancellationToken.None); + await sut.ExecuteTask!; + + // The InvalidOperationException thrown inside RunMigrationsAsync is caught by ExecuteAsync, + // which then calls Configure with the caught exception. + runtimeState.Verify( + x => x.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException, It.IsNotNull()), + Times.Once); + } + + [Test] + public async Task ExecuteAsync_WhenUnattendedUpgradeHasErrorsAndBootFailedExceptionExists_SetsBootFailed() + { + var bootEx = new BootFailedException("migration error"); + var runtimeState = CreateMockRuntimeState(initialBootFailedException: bootEx); + var eventAggregator = new Mock(); + + SetupPublishAsync(eventAggregator, upgradeResult: RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors); + + var sut = CreateSut(runtimeState.Object, eventAggregator.Object); + + await sut.StartAsync(CancellationToken.None); + await sut.ExecuteTask!; + + runtimeState.Verify(x => x.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException, null), Times.Once); + + // Components must NOT be initialized. + eventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestCase(RuntimeUnattendedUpgradeNotification.UpgradeResult.CoreUpgradeComplete)] + [TestCase(RuntimeUnattendedUpgradeNotification.UpgradeResult.PackageMigrationComplete)] + public async Task ExecuteAsync_WhenUnattendedUpgradeSucceeds_CallsDetermineRuntimeLevel( + RuntimeUnattendedUpgradeNotification.UpgradeResult result) + { + var runtimeState = CreateMockRuntimeState(); + var eventAggregator = new Mock(); + + SetupPublishAsync(eventAggregator, upgradeResult: result); + + var sut = CreateSut(runtimeState.Object, eventAggregator.Object); + + await sut.StartAsync(CancellationToken.None); + await sut.ExecuteTask!; + + runtimeState.Verify(x => x.DetermineRuntimeLevel(), Times.Once); + } + + [Test] + public async Task ExecuteAsync_WhenPublishAsyncThrows_SetsBootFailed() + { + var runtimeState = CreateMockRuntimeState(); + var eventAggregator = new Mock(); + + eventAggregator + .Setup(x => x.PublishAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("db gone")); + + var sut = CreateSut(runtimeState.Object, eventAggregator.Object); + + await sut.StartAsync(CancellationToken.None); + await sut.ExecuteTask!; + + runtimeState.Verify( + x => x.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException, It.IsNotNull()), + Times.Once); + } + + [Test] + public async Task ExecuteAsync_WhenBootFailedExceptionAlreadySetBeforeDetermineRuntimeLevel_SkipsDetermineRuntimeLevel() + { + // The BootFailedException is set before CoreRuntime calls DetermineRuntimeLevel. + // The guard in UnattendedUpgradeBackgroundService.DetermineRuntimeLevel() should return early. + var bootEx = new BootFailedException("pre-existing error"); + var runtimeState = CreateMockRuntimeState( + initialBootFailedException: bootEx); + + var eventAggregator = new Mock(); + SetupPublishAsync( + eventAggregator, + premigrationResult: RuntimePremigrationsUpgradeNotification.PremigrationUpgradeResult.CoreUpgradeComplete); + + var sut = CreateSut(runtimeState.Object, eventAggregator.Object); + + await sut.StartAsync(CancellationToken.None); + await sut.ExecuteTask!; + + // DetermineRuntimeLevel on the state object must NOT be called because the guard returned early. + runtimeState.Verify(x => x.DetermineRuntimeLevel(), Times.Never); + } + + [Test] + public async Task ExecuteAsync_WhenDetermineRuntimeLevelThrows_SetsBootFailed() + { + var runtimeState = CreateMockRuntimeState(); + runtimeState + .Setup(x => x.DetermineRuntimeLevel()) + .Throws(new InvalidOperationException("db gone")); + + var eventAggregator = new Mock(); + SetupPublishAsync( + eventAggregator, + premigrationResult: RuntimePremigrationsUpgradeNotification.PremigrationUpgradeResult.CoreUpgradeComplete); + + var sut = CreateSut(runtimeState.Object, eventAggregator.Object); + + await sut.StartAsync(CancellationToken.None); + await sut.ExecuteTask!; + + runtimeState.Verify( + x => x.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException, It.IsNotNull()), + Times.Once); + } + + private static UnattendedUpgradeBackgroundService CreateSut( + IRuntimeState runtimeState, + IEventAggregator eventAggregator, + IHostApplicationLifetime? hostApplicationLifetime = null) + { + var components = new ComponentCollection( + () => Enumerable.Empty(), + Mock.Of(), + NullLogger.Instance); + + return new UnattendedUpgradeBackgroundService( + runtimeState, + eventAggregator, + components, + hostApplicationLifetime ?? CreateMockLifetime().Object, + NullLogger.Instance); + } + + private static Mock CreateMockRuntimeState( + RuntimeLevel initialLevel = RuntimeLevel.Upgrading, + BootFailedException? initialBootFailedException = null) + { + var mock = new Mock(); + var currentLevel = initialLevel; + var bootFailedException = initialBootFailedException; + + mock.SetupGet(x => x.Level).Returns(() => currentLevel); + mock.SetupGet(x => x.BootFailedException).Returns(() => bootFailedException); + mock.Setup(x => x.Configure( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((level, _, ex) => + { + currentLevel = level; + if (ex is not null) + { + bootFailedException = new BootFailedException(ex.Message, ex); + } + }); + + return mock; + } + + private static Mock CreateMockLifetime() + { + var mock = new Mock(); + mock.SetupGet(x => x.ApplicationStarted).Returns(CancellationToken.None); + mock.SetupGet(x => x.ApplicationStopped).Returns(CancellationToken.None); + return mock; + } + + /// + /// Sets up IEventAggregator.PublishAsync to complete successfully for all notification types + /// used by . + /// Optional parameters control the result written back on to the mutable notification objects. + /// + private static void SetupPublishAsync( + Mock mock, + RuntimePremigrationsUpgradeNotification.PremigrationUpgradeResult premigrationResult = + RuntimePremigrationsUpgradeNotification.PremigrationUpgradeResult.NotRequired, + RuntimeUnattendedUpgradeNotification.UpgradeResult upgradeResult = + RuntimeUnattendedUpgradeNotification.UpgradeResult.NotRequired) + { + mock.Setup(x => x.PublishAsync(It.IsAny(), It.IsAny())) + .Callback( + (n, _) => n.UpgradeResult = premigrationResult) + .Returns(Task.CompletedTask); + + mock.Setup(x => x.PublishAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + mock.Setup(x => x.PublishAsync(It.IsAny(), It.IsAny())) + .Callback( + (n, _) => n.UnattendedUpgradeResult = upgradeResult) + .Returns(Task.CompletedTask); + + mock.Setup(x => x.PublishAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Controllers/MaintenanceModeActionFilterAttributeTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Controllers/MaintenanceModeActionFilterAttributeTests.cs new file mode 100644 index 000000000000..f7d8e7c7a72e --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Controllers/MaintenanceModeActionFilterAttributeTests.cs @@ -0,0 +1,191 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.ActionsResults; +using Umbraco.Cms.Web.Common.Controllers; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Controllers; + +[TestFixture] +public class MaintenanceModeActionFilterAttributeTests +{ + private const string CustomUpgradingViewPath = "~/Views/CustomUpgrading.cshtml"; + + [Test] + public void OnActionExecuting_MvcController_WhenUpgradingAndMaintenanceEnabled_SetsMaintenanceResultWithUpgradingViewPath() + { + var settings = new GlobalSettings + { + ShowMaintenancePageWhenInUpgradeState = true, + UpgradingViewPath = CustomUpgradingViewPath, + }; + var context = CreateMvcControllerContext(); + + InvokeFilter(RuntimeLevel.Upgrading, settings, context); + + Assert.That(context.Result, Is.InstanceOf()); + } + + [Test] + public void OnActionExecuting_MvcController_WhenUpgradingAndMaintenanceDisabled_DoesNotSetResult() + { + var settings = new GlobalSettings { ShowMaintenancePageWhenInUpgradeState = false }; + var context = CreateMvcControllerContext(); + + InvokeFilter(RuntimeLevel.Upgrading, settings, context); + + Assert.That(context.Result, Is.Null); + } + + [Test] + public void OnActionExecuting_MvcController_WhenUpgradeAndMaintenanceEnabled_SetsMaintenanceResult() + { + var settings = new GlobalSettings { ShowMaintenancePageWhenInUpgradeState = true }; + var context = CreateMvcControllerContext(); + + InvokeFilter(RuntimeLevel.Upgrade, settings, context); + + Assert.That(context.Result, Is.InstanceOf()); + } + + [Test] + public void OnActionExecuting_MvcController_WhenUpgradeAndMaintenanceDisabled_DoesNotSetResult() + { + var settings = new GlobalSettings { ShowMaintenancePageWhenInUpgradeState = false }; + var context = CreateMvcControllerContext(); + + InvokeFilter(RuntimeLevel.Upgrade, settings, context); + + Assert.That(context.Result, Is.Null); + } + + [TestCase(RuntimeLevel.Run)] + [TestCase(RuntimeLevel.Install)] + [TestCase(RuntimeLevel.Boot)] + public void OnActionExecuting_MvcController_WhenLevelIsNotUpgradeOrUpgrading_DoesNotSetResult(RuntimeLevel level) + { + var settings = new GlobalSettings { ShowMaintenancePageWhenInUpgradeState = true }; + var context = CreateMvcControllerContext(); + + InvokeFilter(level, settings, context); + + Assert.That(context.Result, Is.Null); + } + + [Test] + public void OnActionExecuting_ApiController_WhenUpgradingAndMaintenanceEnabled_SetsProblemDetailsResult() + { + var settings = new GlobalSettings { ShowMaintenancePageWhenInUpgradeState = true }; + var context = CreateApiControllerContext(); + + InvokeFilter(RuntimeLevel.Upgrading, settings, context); + + Assert.Multiple(() => + { + Assert.That(context.Result, Is.InstanceOf()); + var result = (ObjectResult)context.Result!; + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Status503ServiceUnavailable)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That(((ProblemDetails)result.Value!).Status, Is.EqualTo(StatusCodes.Status503ServiceUnavailable)); + }); + } + + [Test] + public void OnActionExecuting_ApiController_WhenUpgradeAndMaintenanceEnabled_DoesNotSetResult() + { + // During an attended upgrade (Upgrade), API controllers are NOT blocked — the operator + // needs API access to log in and trigger the upgrade from the backoffice. + var settings = new GlobalSettings { ShowMaintenancePageWhenInUpgradeState = true }; + var context = CreateApiControllerContext(); + + InvokeFilter(RuntimeLevel.Upgrade, settings, context); + + Assert.That(context.Result, Is.Null); + } + + [Test] + public void OnActionExecuting_ApiController_WhenUpgradingAndMaintenanceDisabled_DoesNotSetResult() + { + var settings = new GlobalSettings { ShowMaintenancePageWhenInUpgradeState = false }; + var context = CreateApiControllerContext(); + + InvokeFilter(RuntimeLevel.Upgrading, settings, context); + + Assert.That(context.Result, Is.Null); + } + + [TestCase(RuntimeLevel.Run)] + [TestCase(RuntimeLevel.Install)] + [TestCase(RuntimeLevel.Boot)] + [TestCase(RuntimeLevel.Upgrade)] + public void OnActionExecuting_ApiController_WhenLevelIsNotUpgrading_DoesNotSetResult(RuntimeLevel level) + { + var settings = new GlobalSettings { ShowMaintenancePageWhenInUpgradeState = true }; + var context = CreateApiControllerContext(); + + InvokeFilter(level, settings, context); + + Assert.That(context.Result, Is.Null); + } + + [Test] + public void OnActionExecuting_WhenSkipAttributePresent_DoesNotSetResult() + { + // SkipMaintenanceModeFilterAttribute bypasses blocking even during Upgrading. + var settings = new GlobalSettings { ShowMaintenancePageWhenInUpgradeState = true }; + var context = CreateContext(endpointMetadata: [new ApiControllerAttribute(), new SkipMaintenanceModeFilterAttribute()]); + + InvokeFilter(RuntimeLevel.Upgrading, settings, context); + + Assert.That(context.Result, Is.Null); + } + + private static void InvokeFilter(RuntimeLevel level, GlobalSettings settings, ActionExecutingContext context) + { + var attribute = new MaintenanceModeActionFilterAttribute(); + var services = new ServiceCollection() + .AddSingleton(Mock.Of(s => s.Level == level)) + .AddSingleton(Mock.Of>(m => m.CurrentValue == settings)) + .BuildServiceProvider(); + + var filter = (IActionFilter)attribute.CreateInstance(services); + filter.OnActionExecuting(context); + } + + private static ActionExecutingContext CreateMvcControllerContext() + => CreateContext(endpointMetadata: []); + + private static ActionExecutingContext CreateApiControllerContext() + => CreateContext(endpointMetadata: [new ApiControllerAttribute()]); + + private static ActionExecutingContext CreateContext(IList endpointMetadata) + { + var actionDescriptor = new ActionDescriptor + { + EndpointMetadata = new List(endpointMetadata), + }; + + var actionContext = new ActionContext( + new DefaultHttpContext(), + new RouteData(), + actionDescriptor); + + return new ActionExecutingContext( + actionContext, + [], + new Dictionary(), + controller: null!); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/HealthChecks/UmbracoReadinessHealthCheckTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/HealthChecks/UmbracoReadinessHealthCheckTests.cs new file mode 100644 index 000000000000..4eb91a6f22de --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/HealthChecks/UmbracoReadinessHealthCheckTests.cs @@ -0,0 +1,46 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.HealthChecks; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.HealthChecks; + +[TestFixture] +public class UmbracoReadinessHealthCheckTests +{ + [Test] + public async Task CheckHealthAsync_WhenLevelIsRun_ReturnsHealthy() + { + var check = CreateCheck(RuntimeLevel.Run); + HealthCheckResult result = await check.CheckHealthAsync(null!); + Assert.That(result.Status, Is.EqualTo(HealthStatus.Healthy)); + } + + [TestCase(RuntimeLevel.Upgrading)] + [TestCase(RuntimeLevel.Boot)] + [TestCase(RuntimeLevel.Install)] + [TestCase(RuntimeLevel.Upgrade)] + [TestCase(RuntimeLevel.BootFailed)] + public async Task CheckHealthAsync_WhenLevelIsNotRun_ReturnsDegraded(RuntimeLevel level) + { + var check = CreateCheck(level); + HealthCheckResult result = await check.CheckHealthAsync(null!); + Assert.That(result.Status, Is.EqualTo(HealthStatus.Degraded)); + } + + [Test] + public async Task CheckHealthAsync_WhenLevelIsUpgrading_IncludesLevelInDescription() + { + var check = CreateCheck(RuntimeLevel.Upgrading); + HealthCheckResult result = await check.CheckHealthAsync(null!); + Assert.That(result.Description, Does.Contain(RuntimeLevel.Upgrading.ToString())); + } + + private static UmbracoReadinessHealthCheck CreateCheck(RuntimeLevel level) + => new(Mock.Of(s => s.Level == level)); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Middleware/BootFailedMiddlewareTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Middleware/BootFailedMiddlewareTests.cs new file mode 100644 index 000000000000..91a8f1f0ef9e --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Middleware/BootFailedMiddlewareTests.cs @@ -0,0 +1,106 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.FileProviders; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Exceptions; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Middleware; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Middleware; + +[TestFixture] +public class BootFailedMiddlewareTests +{ + private const string BootFailedHtml = "BootFailed"; + + [Test] + public async Task InvokeAsync_WhenBootFailed_NonDebug_Returns500() + { + var context = CreateHttpContext(); + var middleware = CreateMiddleware(RuntimeLevel.BootFailed, bootFailedContent: BootFailedHtml); + + await middleware.InvokeAsync(context, _ => Task.CompletedTask); + + Assert.That(context.Response.StatusCode, Is.EqualTo(StatusCodes.Status500InternalServerError)); + } + + [Test] + public async Task InvokeAsync_WhenBootFailed_DebugMode_RethrowsException() + { + var context = CreateHttpContext(); + var bootException = new BootFailedException("Test failure"); + var middleware = CreateMiddleware(RuntimeLevel.BootFailed, isDebug: true, bootFailedException: bootException); + + Assert.ThrowsAsync(() => middleware.InvokeAsync(context, _ => Task.CompletedTask)); + } + + [TestCase(RuntimeLevel.Run)] + [TestCase(RuntimeLevel.Upgrading)] + [TestCase(RuntimeLevel.Upgrade)] + public async Task InvokeAsync_WhenLevelIsNotBootFailed_CallsNext(RuntimeLevel level) + { + var context = CreateHttpContext(); + var nextCalled = false; + var middleware = CreateMiddleware(level); + + await middleware.InvokeAsync(context, _ => { nextCalled = true; return Task.CompletedTask; }); + + Assert.That(nextCalled, Is.True); + } + + private static DefaultHttpContext CreateHttpContext() + { + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + return context; + } + + private static BootFailedMiddleware CreateMiddleware( + RuntimeLevel level, + string? bootFailedContent = null, + bool isDebug = false, + BootFailedException? bootFailedException = null) + { + var runtimeState = new Mock(); + runtimeState.SetupGet(x => x.Level).Returns(level); + runtimeState.SetupGet(x => x.BootFailedException).Returns(bootFailedException); + + var hostingEnv = Mock.Of(e => e.IsDebugMode == isDebug); + + var fileProvider = new Mock(); + + // Custom override path (e.g. config/errors/) — always absent in these tests. + fileProvider + .Setup(x => x.GetFileInfo(It.Is(p => p.StartsWith("config/")))) + .Returns(Mock.Of(f => f.Exists == false)); + + SetupFile(fileProvider, "umbraco/views/errors/BootFailed.html", bootFailedContent); + + var webHostEnv = Mock.Of( + e => e.WebRootFileProvider == fileProvider.Object); + + return new BootFailedMiddleware(runtimeState.Object, hostingEnv, webHostEnv); + } + + private static void SetupFile(Mock provider, string path, string? content) + { + if (content is null) + { + provider.Setup(x => x.GetFileInfo(path)) + .Returns(Mock.Of(f => f.Exists == false)); + return; + } + + var fileInfo = new Mock(); + fileInfo.SetupGet(x => x.Exists).Returns(true); + fileInfo.Setup(x => x.CreateReadStream()) + .Returns(new MemoryStream(Encoding.UTF8.GetBytes(content))); + provider.Setup(x => x.GetFileInfo(path)).Returns(fileInfo.Object); + } +}