diff --git a/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Extensions/MaintenanceExtensions.cs b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Extensions/MaintenanceExtensions.cs
new file mode 100644
index 00000000..0fb468c7
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Extensions/MaintenanceExtensions.cs
@@ -0,0 +1,22 @@
+using Lombiq.Tests.UI.Services;
+using System.Threading.Tasks;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Tests.UI.Extensions;
+
+public static class MaintenanceExtensions
+{
+ public static void SetUpdateSiteUrlMaintenanceConfiguration(
+ this OrchardCoreUITestExecutorConfiguration configuration) => configuration.OrchardCoreConfiguration.BeforeAppStart +=
+ (_, argumentsBuilder) =>
+ {
+ argumentsBuilder
+ .AddWithValue(
+ "OrchardCore:Lombiq_Hosting_Tenants_Maintenance:UpdateSiteUrl:IsEnabled",
+ value: true)
+ .AddWithValue(
+ "OrchardCore:Lombiq_Hosting_Tenants_Maintenance:UpdateSiteUrl:DefaultTenantSiteUrl",
+ value: "https://test.com");
+
+ return Task.CompletedTask;
+ };
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs
new file mode 100644
index 00000000..d7191e42
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs
@@ -0,0 +1,18 @@
+using Atata;
+using Lombiq.Tests.UI.Extensions;
+using Lombiq.Tests.UI.Services;
+using OpenQA.Selenium;
+using Shouldly;
+using System.Threading.Tasks;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Tests.UI.Extensions;
+
+public static class TestCaseUITestContextExtensions
+{
+ public static async Task TestSiteUrlMaintenanceExecution(this UITestContext context)
+ {
+ await context.SignInDirectlyAsync();
+ await context.GoToAdminRelativeUrlAsync("/Settings/general");
+ context.Get(By.Name("ISite.BaseUrl")).GetValue().ShouldBe("https://test.com");
+ }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/License.md b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/License.md
new file mode 100644
index 00000000..d57e1305
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/License.md
@@ -0,0 +1,13 @@
+Copyright © 2021, [Lombiq Technologies Ltd.](https://lombiq.com)
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+- Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Lombiq.Hosting.Tenants.Maintenance.Tests.UI.csproj b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Lombiq.Hosting.Tenants.Maintenance.Tests.UI.csproj
new file mode 100644
index 00000000..ab640ee5
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Lombiq.Hosting.Tenants.Maintenance.Tests.UI.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net6.0
+
+
+
+ Lombiq Hosting - Tenants Maintenance for Orchard Core - UI Test Extensions
+ Lombiq Technologies
+ Copyright © 2021, Lombiq Technologies Ltd.
+ Lombiq Hosting - Tenants Maintenance for Orchard Core - UI Test Extensions: Extension methods that test tenants maintenance for Orchard Core.
+ NuGetIcon.png
+ OrchardCore;Lombiq;AspNetCore;Multitenancy;SaaS
+ https://github.com/Lombiq/Hosting-Tenants
+ https://github.com/Lombiq/Hosting-Tenants/blob/dev/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Readme.md
+ License.md
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/NuGetIcon.png b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/NuGetIcon.png
new file mode 100644
index 00000000..162a0050
Binary files /dev/null and b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/NuGetIcon.png differ
diff --git a/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Readme.md b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Readme.md
new file mode 100644
index 00000000..e17c0a1d
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/Readme.md
@@ -0,0 +1,7 @@
+# Lombiq Hosting - Tenants Maintenance for Orchard Core - UI Test Extensions
+
+## About
+
+Extension methods that test tenants maintenance for Orchard Core, with the help of [Lombiq UI Testing Toolbox for Orchard Core](https://github.com/Lombiq/UI-Testing-Toolbox).
+
+Call these from a UI test project to verify the module's basic features; as seen in [Open-Source Orchard Core Extensions](https://github.com/Lombiq/Open-Source-Orchard-Core-Extensions).
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Constants/DocumentCollections.cs b/Lombiq.Hosting.Tenants.Maintenance/Constants/DocumentCollections.cs
new file mode 100644
index 00000000..86ef07bd
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Constants/DocumentCollections.cs
@@ -0,0 +1,6 @@
+namespace Lombiq.Hosting.Tenants.Maintenance.Constants;
+
+public static class DocumentCollections
+{
+ public const string Maintenance = nameof(Maintenance);
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Constants/FeatureNames.cs b/Lombiq.Hosting.Tenants.Maintenance/Constants/FeatureNames.cs
new file mode 100644
index 00000000..bbe6ae41
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Constants/FeatureNames.cs
@@ -0,0 +1,9 @@
+namespace Lombiq.Hosting.Tenants.Maintenance.Constants;
+
+public static class FeatureNames
+{
+ public const string Module = "Lombiq.Hosting.Tenants.Maintenance";
+ public const string Maintenance = Module;
+ public const string UpdateSiteUrl = Maintenance + "." + nameof(UpdateSiteUrl);
+ public const string UpdateShellRequestUrls = Maintenance + "." + nameof(UpdateShellRequestUrls);
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Extensions/MaintenanceTaskExecutionContextExtensions.cs b/Lombiq.Hosting.Tenants.Maintenance/Extensions/MaintenanceTaskExecutionContextExtensions.cs
new file mode 100644
index 00000000..25897e7b
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Extensions/MaintenanceTaskExecutionContextExtensions.cs
@@ -0,0 +1,9 @@
+using Lombiq.Hosting.Tenants.Maintenance.Models;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Extensions;
+
+public static class MaintenanceTaskExecutionContextExtensions
+{
+ public static bool WasLatestExecutionSuccessful(this MaintenanceTaskExecutionContext execution) =>
+ execution.LatestExecution?.IsSuccess == true;
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Helpers/TenantUrlHelpers.cs b/Lombiq.Hosting.Tenants.Maintenance/Helpers/TenantUrlHelpers.cs
new file mode 100644
index 00000000..7f7a214a
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Helpers/TenantUrlHelpers.cs
@@ -0,0 +1,25 @@
+using OrchardCore.Environment.Shell;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Helpers;
+
+internal static class TenantUrlHelpers
+{
+ public static string ReplaceTenantName(string url, string tenantName) =>
+ // Evaluate the tenant name in lowercase as it will be used in the URL or request URL prefixes.
+#pragma warning disable CA1308 // Normalize strings to uppercase
+ url?.Replace("{TenantName}", tenantName.ToLowerInvariant());
+#pragma warning restore CA1308 // Normalize strings to uppercase
+
+ public static string GetTenantUrl(string urlForDefaultTenant, string urlForAnyTenant, ShellSettings shellSettings)
+ {
+ var evaluatedRequestUrl = !string.IsNullOrEmpty(urlForAnyTenant)
+ ? ReplaceTenantName(urlForAnyTenant, shellSettings.Name)
+ : string.Empty;
+ var defaultShellRequestUrl =
+ string.IsNullOrEmpty(urlForDefaultTenant)
+ ? evaluatedRequestUrl
+ : urlForDefaultTenant;
+
+ return shellSettings.IsDefaultShell() ? defaultShellRequestUrl : evaluatedRequestUrl;
+ }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Indexes/MaintenanceTaskExecutionIndex.cs b/Lombiq.Hosting.Tenants.Maintenance/Indexes/MaintenanceTaskExecutionIndex.cs
new file mode 100644
index 00000000..3d23b51d
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Indexes/MaintenanceTaskExecutionIndex.cs
@@ -0,0 +1,28 @@
+using Lombiq.Hosting.Tenants.Maintenance.Constants;
+using Lombiq.Hosting.Tenants.Maintenance.Models;
+using System;
+using YesSql.Indexes;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Indexes;
+
+public class MaintenanceTaskExecutionIndex : MapIndex
+{
+ public string MaintenanceId { get; set; }
+ public DateTime ExecutionTimeUtc { get; set; }
+ public bool IsSuccess { get; set; }
+}
+
+public class MaintenanceTaskExecutionIndexProvider : IndexProvider
+{
+ public MaintenanceTaskExecutionIndexProvider() =>
+ CollectionName = DocumentCollections.Maintenance;
+
+ public override void Describe(DescribeContext context) =>
+ context.For()
+ .Map(execution => new MaintenanceTaskExecutionIndex
+ {
+ MaintenanceId = execution.MaintenanceId,
+ ExecutionTimeUtc = execution.ExecutionTimeUtc,
+ IsSuccess = execution.IsSuccess,
+ });
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/License.md b/Lombiq.Hosting.Tenants.Maintenance/License.md
new file mode 100644
index 00000000..d57e1305
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/License.md
@@ -0,0 +1,13 @@
+Copyright © 2021, [Lombiq Technologies Ltd.](https://lombiq.com)
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+- Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Lombiq.Hosting.Tenants.Maintenance.csproj b/Lombiq.Hosting.Tenants.Maintenance/Lombiq.Hosting.Tenants.Maintenance.csproj
new file mode 100644
index 00000000..c21d3476
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Lombiq.Hosting.Tenants.Maintenance.csproj
@@ -0,0 +1,44 @@
+
+
+
+ net6.0
+ true
+ $(DefaultItemExcludes);.git*;node_modules\**;Tests\**
+
+
+
+ Lombiq Hosting - Tenants Maintenance for Orchard Core
+ Lombiq Technologies
+ Copyright © 2021, Lombiq Technologies Ltd.
+ Lombiq Hosting - Tenants Maintenance for Orchard Core: With the help of this module you can execute maintenance tasks on tenants.
+ NuGetIcon.png
+ OrchardCore;Lombiq;AspNetCore;Multitenancy;SaaS;Maintenance
+ https://github.com/Lombiq/Hosting-Tenants
+ https://github.com/Lombiq/Hosting-Tenants/blob/dev/Lombiq.Hosting.Tenants.Maintenance/Readme.md
+ License.md
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateShellRequestUrl/Startup.cs b/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateShellRequestUrl/Startup.cs
new file mode 100644
index 00000000..2b21fa1e
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateShellRequestUrl/Startup.cs
@@ -0,0 +1,27 @@
+using Lombiq.Hosting.Tenants.Maintenance.Constants;
+using Lombiq.Hosting.Tenants.Maintenance.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using OrchardCore.Environment.Shell.Configuration;
+using OrchardCore.Modules;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Maintenance.UpdateShellRequestUrl;
+
+[Feature(FeatureNames.UpdateShellRequestUrls)]
+public class Startup : StartupBase
+{
+ private readonly IShellConfiguration _shellConfiguration;
+
+ public Startup(IShellConfiguration shellConfiguration) =>
+ _shellConfiguration = shellConfiguration;
+
+ public override void ConfigureServices(IServiceCollection services)
+ {
+ var options = new UpdateShellRequestUrlMaintenanceOptions();
+ var configSection = _shellConfiguration.GetSection("Lombiq_Hosting_Tenants_Maintenance:UpdateShellRequestUrl");
+ configSection.Bind(options);
+ services.Configure(configSection);
+
+ services.AddScoped();
+ }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateShellRequestUrl/UpdateShellRequestUrlMaintenanceOptions.cs b/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateShellRequestUrl/UpdateShellRequestUrlMaintenanceOptions.cs
new file mode 100644
index 00000000..4f23e550
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateShellRequestUrl/UpdateShellRequestUrlMaintenanceOptions.cs
@@ -0,0 +1,9 @@
+namespace Lombiq.Hosting.Tenants.Maintenance.Maintenance.UpdateShellRequestUrl;
+
+public class UpdateShellRequestUrlMaintenanceOptions
+{
+ public bool IsEnabled { get; set; }
+ public string DefaultShellRequestUrl { get; set; }
+ public string RequestUrl { get; set; }
+ public string RequestUrlPrefix { get; set; }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateShellRequestUrl/UpdateShellRequestUrlsMaintenanceProvider.cs b/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateShellRequestUrl/UpdateShellRequestUrlsMaintenanceProvider.cs
new file mode 100644
index 00000000..2f5c7e53
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateShellRequestUrl/UpdateShellRequestUrlsMaintenanceProvider.cs
@@ -0,0 +1,50 @@
+using Lombiq.Hosting.Tenants.Maintenance.Extensions;
+using Lombiq.Hosting.Tenants.Maintenance.Helpers;
+using Lombiq.Hosting.Tenants.Maintenance.Models;
+using Lombiq.Hosting.Tenants.Maintenance.Services;
+using Microsoft.Extensions.Options;
+using OrchardCore.Environment.Shell;
+using System.Threading.Tasks;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Maintenance.UpdateShellRequestUrl;
+
+public class UpdateShellRequestUrlsMaintenanceProvider : MaintenanceProviderBase
+{
+ private readonly ShellSettings _shellSettings;
+ private readonly IOptions _options;
+ private readonly IShellSettingsManager _shellSettingsManager;
+
+ public UpdateShellRequestUrlsMaintenanceProvider(
+ ShellSettings shellSettings,
+ IOptions options,
+ IShellSettingsManager shellSettingsManager)
+ {
+ _shellSettings = shellSettings;
+ _options = options;
+ _shellSettingsManager = shellSettingsManager;
+ }
+
+ public override Task ShouldExecuteAsync(MaintenanceTaskExecutionContext context) =>
+ Task.FromResult(_options.Value.IsEnabled &&
+ _shellSettings.IsDefaultShell() &&
+ !context.WasLatestExecutionSuccessful());
+
+ public override async Task ExecuteAsync(MaintenanceTaskExecutionContext context)
+ {
+ var allShellSettings = await _shellSettingsManager.LoadSettingsAsync();
+ foreach (var shellSettings in allShellSettings)
+ {
+ shellSettings.RequestUrlHost = TenantUrlHelpers.GetTenantUrl(
+ _options.Value.DefaultShellRequestUrl,
+ _options.Value.RequestUrl,
+ shellSettings);
+ shellSettings.RequestUrlPrefix = TenantUrlHelpers.ReplaceTenantName(
+ _options.Value.RequestUrlPrefix,
+ shellSettings.Name);
+
+ await _shellSettingsManager.SaveSettingsAsync(shellSettings);
+ }
+
+ context.ReloadShellAfterMaintenanceCompletion = true;
+ }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateSiteUrl/Startup.cs b/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateSiteUrl/Startup.cs
new file mode 100644
index 00000000..ff86c21c
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateSiteUrl/Startup.cs
@@ -0,0 +1,27 @@
+using Lombiq.Hosting.Tenants.Maintenance.Constants;
+using Lombiq.Hosting.Tenants.Maintenance.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using OrchardCore.Environment.Shell.Configuration;
+using OrchardCore.Modules;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Maintenance.UpdateSiteUrl;
+
+[Feature(FeatureNames.UpdateSiteUrl)]
+public class Startup : StartupBase
+{
+ private readonly IShellConfiguration _shellConfiguration;
+
+ public Startup(IShellConfiguration shellConfiguration) =>
+ _shellConfiguration = shellConfiguration;
+
+ public override void ConfigureServices(IServiceCollection services)
+ {
+ var options = new UpdateSiteUrlMaintenanceOptions();
+ var configSection = _shellConfiguration.GetSection("Lombiq_Hosting_Tenants_Maintenance:UpdateSiteUrl");
+ configSection.Bind(options);
+ services.Configure(configSection);
+
+ services.AddScoped();
+ }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateSiteUrl/UpdateSiteUrlMaintenanceOptions.cs b/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateSiteUrl/UpdateSiteUrlMaintenanceOptions.cs
new file mode 100644
index 00000000..21104e88
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateSiteUrl/UpdateSiteUrlMaintenanceOptions.cs
@@ -0,0 +1,8 @@
+namespace Lombiq.Hosting.Tenants.Maintenance.Maintenance.UpdateSiteUrl;
+
+public class UpdateSiteUrlMaintenanceOptions
+{
+ public bool IsEnabled { get; set; }
+ public string DefaultTenantSiteUrl { get; set; }
+ public string SiteUrl { get; set; }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateSiteUrl/UpdateSiteUrlMaintenanceProvider.cs b/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateSiteUrl/UpdateSiteUrlMaintenanceProvider.cs
new file mode 100644
index 00000000..a732507c
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Maintenance/UpdateSiteUrl/UpdateSiteUrlMaintenanceProvider.cs
@@ -0,0 +1,41 @@
+using Lombiq.Hosting.Tenants.Maintenance.Extensions;
+using Lombiq.Hosting.Tenants.Maintenance.Helpers;
+using Lombiq.Hosting.Tenants.Maintenance.Models;
+using Lombiq.Hosting.Tenants.Maintenance.Services;
+using Microsoft.Extensions.Options;
+using OrchardCore.Environment.Shell;
+using OrchardCore.Settings;
+using System.Threading.Tasks;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Maintenance.UpdateSiteUrl;
+
+public class UpdateSiteUrlMaintenanceProvider : MaintenanceProviderBase
+{
+ private readonly ISiteService _siteService;
+ private readonly ShellSettings _shellSettings;
+ private readonly IOptions _options;
+
+ public UpdateSiteUrlMaintenanceProvider(
+ ISiteService siteService,
+ ShellSettings shellSettings,
+ IOptions options)
+ {
+ _siteService = siteService;
+ _shellSettings = shellSettings;
+ _options = options;
+ }
+
+ public override Task ShouldExecuteAsync(MaintenanceTaskExecutionContext context) =>
+ Task.FromResult(_options.Value.IsEnabled && !context.WasLatestExecutionSuccessful());
+
+ public override async Task ExecuteAsync(MaintenanceTaskExecutionContext context)
+ {
+ var siteSettings = await _siteService.LoadSiteSettingsAsync();
+ siteSettings.BaseUrl = TenantUrlHelpers.GetTenantUrl(
+ _options.Value.DefaultTenantSiteUrl,
+ _options.Value.SiteUrl,
+ _shellSettings);
+
+ await _siteService.UpdateSiteSettingsAsync(siteSettings);
+ }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Manifest.cs b/Lombiq.Hosting.Tenants.Maintenance/Manifest.cs
new file mode 100644
index 00000000..3c8ebf55
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Manifest.cs
@@ -0,0 +1,34 @@
+using OrchardCore.Modules.Manifest;
+using static Lombiq.Hosting.Tenants.Maintenance.Constants.FeatureNames;
+
+[assembly: Module(
+ Name = "Lombiq Hosting - Tenants Maintenance",
+ Author = "Lombiq Technologies",
+ Website = "https://github.com/Lombiq/Hosting-Tenants",
+ Version = "0.0.1"
+)]
+
+[assembly: Feature(
+ Id = Maintenance,
+ Name = "Lombiq Hosting - Tenants Maintenance",
+ Description = "Provides maintenance operations for tenants.",
+ Category = "Hosting",
+ Dependencies = new[] { "OrchardCore.Tenants" }
+)]
+
+[assembly: Feature(
+ Id = UpdateSiteUrl,
+ Name = "Lombiq Hosting - Tenants Maintenance - Update Site URL",
+ Description = "Updates the URL of the site in the site settings (e.g., when the production database is copied to staging).",
+ Category = "Maintenance",
+ Dependencies = new[] { Maintenance }
+)]
+
+[assembly: Feature(
+ Id = UpdateShellRequestUrls,
+ Name = "Lombiq Hosting - Tenants Maintenance - Update Shell Request URLs",
+ Description = "Updates the shell request URLs of each tenant (e.g., when the production database is copied to staging). It's executed only on the default tenant.",
+ Category = "Maintenance",
+ DefaultTenantOnly = true,
+ Dependencies = new[] { Maintenance }
+)]
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Migrations.cs b/Lombiq.Hosting.Tenants.Maintenance/Migrations.cs
new file mode 100644
index 00000000..29e563b8
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Migrations.cs
@@ -0,0 +1,29 @@
+using Lombiq.Hosting.Tenants.Maintenance.Constants;
+using Lombiq.Hosting.Tenants.Maintenance.Indexes;
+using OrchardCore.Data.Migration;
+using System;
+using YesSql.Sql;
+
+namespace Lombiq.Hosting.Tenants.Maintenance;
+
+public class Migrations : DataMigration
+{
+ public int Create()
+ {
+ SchemaBuilder.CreateMapIndexTable(
+ table => table
+ .Column(nameof(MaintenanceTaskExecutionIndex.MaintenanceId))
+ .Column(nameof(MaintenanceTaskExecutionIndex.ExecutionTimeUtc))
+ .Column(nameof(MaintenanceTaskExecutionIndex.IsSuccess)),
+ collection: DocumentCollections.Maintenance);
+
+ SchemaBuilder.AlterIndexTable(
+ table => table
+ .CreateIndex(
+ $"IDX_{nameof(MaintenanceTaskExecutionIndex)}_{nameof(MaintenanceTaskExecutionIndex.MaintenanceId)}",
+ nameof(MaintenanceTaskExecutionIndex.MaintenanceId)),
+ collection: DocumentCollections.Maintenance);
+
+ return 1;
+ }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Models/MaintenanceTaskExecutionContext.cs b/Lombiq.Hosting.Tenants.Maintenance/Models/MaintenanceTaskExecutionContext.cs
new file mode 100644
index 00000000..cad2a2b9
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Models/MaintenanceTaskExecutionContext.cs
@@ -0,0 +1,8 @@
+namespace Lombiq.Hosting.Tenants.Maintenance.Models;
+
+public class MaintenanceTaskExecutionContext
+{
+ public MaintenanceTaskExecutionData LatestExecution { get; set; }
+ public MaintenanceTaskExecutionData CurrentExecution { get; set; }
+ public bool ReloadShellAfterMaintenanceCompletion { get; set; }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Models/MaintenanceTaskExecutionData.cs b/Lombiq.Hosting.Tenants.Maintenance/Models/MaintenanceTaskExecutionData.cs
new file mode 100644
index 00000000..8b1ee726
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Models/MaintenanceTaskExecutionData.cs
@@ -0,0 +1,13 @@
+using OrchardCore.Entities;
+using System;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Models;
+
+public class MaintenanceTaskExecutionData : Entity
+{
+ public int Id { get; set; }
+ public string MaintenanceId { get; set; }
+ public DateTime ExecutionTimeUtc { get; set; }
+ public bool IsSuccess { get; set; }
+ public string Error { get; set; }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/NuGetIcon.png b/Lombiq.Hosting.Tenants.Maintenance/NuGetIcon.png
new file mode 100644
index 00000000..162a0050
Binary files /dev/null and b/Lombiq.Hosting.Tenants.Maintenance/NuGetIcon.png differ
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Readme.md b/Lombiq.Hosting.Tenants.Maintenance/Readme.md
new file mode 100644
index 00000000..1c4decf0
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Readme.md
@@ -0,0 +1,76 @@
+# Lombiq Hosting - Tenant Maintenance for Orchard Core
+
+[![Lombiq.Hosting.Tenants.Maintenance NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.Maintenance?label=Lombiq.Hosting.Tenants.Maintenance)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.Maintenance/)
+
+## About
+
+With the help of this module you can execute maintenance tasks on tenants. These tasks can be anything that you want to run on tenants, like updating the tenants' URL based on the app configuration.
+
+## Documentation
+
+Please see the below features for more information.
+
+### `Lombiq.Hosting.Tenants.Maintenance`
+
+This is the core functionality required to execute maintenance tasks on tenants. It is available on any tenant. To make your application execute maintenance tasks, you need to add the following to your `Startup.cs`:
+
+```csharp
+public void ConfigureServices(IServiceCollection services) =>
+ services.AddOrchardCms(
+ builder => builder.AddTenantFeatures(Lombiq.Hosting.Tenants.Maintenance.Constants.FeatureNames.Maintenance));
+```
+
+To add new maintenance tasks, you need to implement the `IMaintenanceProvider` interface and register it as a service.
+
+### `Lombiq.Hosting.Tenants.Maintenance.UpdateSiteUrl`
+
+It's a maintenance task that updates the site's base URL in the site settings based on the app configuration. It is available on any tenant.
+
+To make your application execute this task, you need to add the following to your `Startup.cs`:
+
+```csharp
+public void ConfigureServices(IServiceCollection services) =>
+ services.AddOrchardCms(
+ builder => builder.AddTenantFeatures(Lombiq.Hosting.Tenants.Maintenance.Constants.FeatureNames.UpdateTenantUrl));
+```
+
+The following configuration options are available to set the site URL:
+
+```json
+{
+ "OrchardCore": {
+ "Lombiq_Hosting_Tenants_Maintenance": {
+ "UpdateSiteUrl": {
+ "IsEnabled": true,
+ "SiteUrl": "https://domain.com/{TenantName}",
+ "DefaultTenantSiteUrl": "https://domain.com"
+ }
+ }
+ }
+}
+```
+
+**NOTE**: The `{TenantName}` placeholder will be replaced with the actual tenant name automatically.
+
+### `Lombiq.Hosting.Tenants.Maintenance.UpdateShellRequestUrls`
+
+It's a maintenance task that updates the shell's request URLs in each tenant's shell settings based on the app configuration. It is available only for the default tenant.
+
+The following configuration options are available to set the shell request URLs:
+
+```json
+{
+ "OrchardCore": {
+ "Lombiq_Hosting_Tenants_Maintenance": {
+ "UpdateShellRequestUrl": {
+ "IsEnabled": true,
+ "DefaultShellRequestUrl": "domain.com",
+ "RequestUrl": "{TenantName}.domain.com",
+ "RequestUrlPrefix": "{TenantName}"
+ }
+ }
+ }
+}
+```
+
+**NOTE**: The `{TenantName}` placeholder will be replaced with the actual tenant name automatically.
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Services/IMaintenanceManager.cs b/Lombiq.Hosting.Tenants.Maintenance/Services/IMaintenanceManager.cs
new file mode 100644
index 00000000..cdee1a75
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Services/IMaintenanceManager.cs
@@ -0,0 +1,22 @@
+using Lombiq.Hosting.Tenants.Maintenance.Models;
+using System.Threading.Tasks;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Services;
+
+///
+/// This service is responsible for executing maintenance tasks.
+///
+public interface IMaintenanceManager
+{
+ ///
+ /// Returns the latest execution of a maintenance task by its ID.
+ ///
+ /// The ID of the maintenance task.
+ /// The latest execution of the maintenance task.
+ public Task GetLatestExecutionByMaintenanceIdAsync(string maintenanceId);
+
+ ///
+ /// Executes all maintenance tasks if needed.
+ ///
+ public Task ExecuteMaintenanceTasksAsync();
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Services/IMaintenanceProvider.cs b/Lombiq.Hosting.Tenants.Maintenance/Services/IMaintenanceProvider.cs
new file mode 100644
index 00000000..0abc2829
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Services/IMaintenanceProvider.cs
@@ -0,0 +1,33 @@
+using Lombiq.Hosting.Tenants.Maintenance.Models;
+using System.Threading.Tasks;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Services;
+
+///
+/// A provider for a particular maintenance task.
+///
+public interface IMaintenanceProvider
+{
+ ///
+ /// Gets the ID of the maintenance task.
+ ///
+ string Id { get; }
+
+ ///
+ /// Gets the order of the maintenance task. The lower the number the earlier the task will be executed.
+ ///
+ int Order { get; }
+
+ ///
+ /// Determines whether the maintenance task should be executed.
+ ///
+ /// Provides information about the current execution.
+ /// if the maintenance task should be executed, otherwise.
+ Task ShouldExecuteAsync(MaintenanceTaskExecutionContext context);
+
+ ///
+ /// Executes the maintenance task.
+ ///
+ /// Provides information about the current execution.
+ Task ExecuteAsync(MaintenanceTaskExecutionContext context);
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Services/MaintenanceManager.cs b/Lombiq.Hosting.Tenants.Maintenance/Services/MaintenanceManager.cs
new file mode 100644
index 00000000..afdc86a6
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Services/MaintenanceManager.cs
@@ -0,0 +1,112 @@
+using Lombiq.Hosting.Tenants.Maintenance.Constants;
+using Lombiq.Hosting.Tenants.Maintenance.Indexes;
+using Lombiq.Hosting.Tenants.Maintenance.Models;
+using Microsoft.Extensions.Logging;
+using OrchardCore.Environment.Shell;
+using OrchardCore.Modules;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using YesSql;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Services;
+
+public class MaintenanceManager : IMaintenanceManager
+{
+ private readonly IClock _clock;
+ private readonly ILogger _logger;
+ private readonly IEnumerable _maintenanceProviders;
+ private readonly ISession _session;
+ private readonly IShellHost _shellHost;
+ private readonly ShellSettings _shellSettings;
+
+ public MaintenanceManager(
+ IClock clock,
+ ILogger logger,
+ IEnumerable maintenanceProviders,
+ ISession session,
+ IShellHost shellHost,
+ ShellSettings shellSettings)
+ {
+ _clock = clock;
+ _logger = logger;
+ _maintenanceProviders = maintenanceProviders;
+ _session = session;
+ _shellHost = shellHost;
+ _shellSettings = shellSettings;
+ }
+
+ public Task GetLatestExecutionByMaintenanceIdAsync(string maintenanceId) =>
+ _session.Query(collection: DocumentCollections.Maintenance)
+ .Where(execution => execution.MaintenanceId == maintenanceId)
+ .OrderByDescending(execution => execution.ExecutionTimeUtc)
+ .FirstOrDefaultAsync();
+
+ public async Task ExecuteMaintenanceTasksAsync()
+ {
+ var orderedProviders = _maintenanceProviders.OrderBy(provider => provider.Order);
+ foreach (var provider in orderedProviders)
+ {
+ var currentExecution = new MaintenanceTaskExecutionData
+ {
+ MaintenanceId = provider.Id,
+ ExecutionTimeUtc = _clock.UtcNow,
+ };
+ var context = new MaintenanceTaskExecutionContext
+ {
+ LatestExecution = await GetLatestExecutionByMaintenanceIdAsync(provider.Id),
+ CurrentExecution = currentExecution,
+ };
+
+ await ExecuteMaintenanceTaskIfNeededAsync(provider, context, currentExecution);
+ }
+ }
+
+ private async Task ExecuteMaintenanceTaskIfNeededAsync(
+ IMaintenanceProvider provider,
+ MaintenanceTaskExecutionContext context,
+ MaintenanceTaskExecutionData execution)
+ {
+ _logger.LogDebug("Executing maintenance task {MaintenanceId}, if needed.", provider.Id);
+
+ if (await provider.ShouldExecuteAsync(context))
+ {
+ try
+ {
+ await provider.ExecuteAsync(context);
+ execution.IsSuccess = string.IsNullOrEmpty(execution.Error);
+ if (execution.IsSuccess)
+ {
+ _logger.LogDebug("Maintenance task {MaintenanceId} executed successfully.", provider.Id);
+ }
+ else
+ {
+ _logger.LogError(
+ "Maintenance task {MaintenanceId} executed with error: {Error}",
+ provider.Id,
+ execution.Error);
+ }
+ }
+ catch (Exception exception) when (!exception.IsFatal())
+ {
+ execution.IsSuccess = false;
+ execution.Error = exception.ToString();
+
+ _logger.LogError(
+ exception,
+ "Maintenance task {MaintenanceId} failed to execute due to an exception.",
+ provider.Id);
+ }
+
+ _session.Save(execution, collection: DocumentCollections.Maintenance);
+ await _session.SaveChangesAsync();
+
+ if (context.ReloadShellAfterMaintenanceCompletion) await _shellHost.ReloadShellContextAsync(_shellSettings);
+ }
+ else
+ {
+ _logger.LogDebug("Maintenance task {MaintenanceId} is not needed.", provider.Id);
+ }
+ }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Services/MaintenanceProviderBase.cs b/Lombiq.Hosting.Tenants.Maintenance/Services/MaintenanceProviderBase.cs
new file mode 100644
index 00000000..f6e9317f
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Services/MaintenanceProviderBase.cs
@@ -0,0 +1,16 @@
+using Lombiq.Hosting.Tenants.Maintenance.Extensions;
+using Lombiq.Hosting.Tenants.Maintenance.Models;
+using System.Threading.Tasks;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Services;
+
+public abstract class MaintenanceProviderBase : IMaintenanceProvider
+{
+ public virtual string Id => GetType().Name;
+ public virtual int Order => 0;
+
+ public virtual Task ShouldExecuteAsync(MaintenanceTaskExecutionContext context) =>
+ Task.FromResult(!context.WasLatestExecutionSuccessful());
+
+ public abstract Task ExecuteAsync(MaintenanceTaskExecutionContext context);
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Services/MaintenanceRunnerService.cs b/Lombiq.Hosting.Tenants.Maintenance/Services/MaintenanceRunnerService.cs
new file mode 100644
index 00000000..a349b0cf
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Services/MaintenanceRunnerService.cs
@@ -0,0 +1,34 @@
+using Microsoft.Extensions.Logging;
+using OrchardCore.Environment.Shell;
+using OrchardCore.Environment.Shell.Models;
+using OrchardCore.Modules;
+using System.Threading.Tasks;
+
+namespace Lombiq.Hosting.Tenants.Maintenance.Services;
+
+public class MaintenanceRunnerService : ModularTenantEvents
+{
+ private readonly ShellSettings _shellSettings;
+ private readonly ILogger _logger;
+ private readonly IMaintenanceManager _maintenanceManager;
+
+ public MaintenanceRunnerService(
+ ShellSettings shellSettings,
+ ILogger logger,
+ IMaintenanceManager maintenanceManager)
+ {
+ _shellSettings = shellSettings;
+ _logger = logger;
+ _maintenanceManager = maintenanceManager;
+ }
+
+ public override async Task ActivatedAsync()
+ {
+ if (_shellSettings.State != TenantState.Running) return;
+
+ _logger.LogDebug(
+ "Executing maintenance tasks on shell '{ShellName}'.",
+ _shellSettings.Name);
+ await _maintenanceManager.ExecuteMaintenanceTasksAsync();
+ }
+}
diff --git a/Lombiq.Hosting.Tenants.Maintenance/Startup.cs b/Lombiq.Hosting.Tenants.Maintenance/Startup.cs
new file mode 100644
index 00000000..aad3dd3c
--- /dev/null
+++ b/Lombiq.Hosting.Tenants.Maintenance/Startup.cs
@@ -0,0 +1,23 @@
+using Lombiq.Hosting.Tenants.Maintenance.Constants;
+using Lombiq.Hosting.Tenants.Maintenance.Indexes;
+using Lombiq.Hosting.Tenants.Maintenance.Services;
+using Microsoft.Extensions.DependencyInjection;
+using OrchardCore.Data;
+using OrchardCore.Data.Migration;
+using OrchardCore.Modules;
+using YesSql.Indexes;
+
+namespace Lombiq.Hosting.Tenants.Maintenance;
+
+public class Startup : StartupBase
+{
+ public override void ConfigureServices(IServiceCollection services)
+ {
+ services.Configure(options => options.Collections.Add(DocumentCollections.Maintenance));
+ services.AddScoped();
+ services.AddSingleton();
+
+ services.AddScoped();
+ services.AddScoped();
+ }
+}
diff --git a/Lombiq.Hosting.Tenants.sln b/Lombiq.Hosting.Tenants.sln
index 9f7daaaf..e7fcaca9 100644
--- a/Lombiq.Hosting.Tenants.sln
+++ b/Lombiq.Hosting.Tenants.sln
@@ -19,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lombiq.Hosting.Tenants.Medi
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI", "Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI\Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI.csproj", "{4BE4E5C8-7DB6-49D2-855E-7220B039DDF4}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lombiq.Hosting.Tenants.Maintenance", "Lombiq.Hosting.Tenants.Maintenance\Lombiq.Hosting.Tenants.Maintenance.csproj", "{6D3217B9-EA0F-4608-BA9B-B9B935AEDF3A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lombiq.Hosting.Tenants.Maintenance.Tests.UI", "Lombiq.Hosting.Tenants.Maintenance.Tests.UI\Lombiq.Hosting.Tenants.Maintenance.Tests.UI.csproj", "{5E1E19E5-18EB-49A1-B392-3BD74418D9FC}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -57,6 +61,14 @@ Global
{4BE4E5C8-7DB6-49D2-855E-7220B039DDF4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4BE4E5C8-7DB6-49D2-855E-7220B039DDF4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4BE4E5C8-7DB6-49D2-855E-7220B039DDF4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6D3217B9-EA0F-4608-BA9B-B9B935AEDF3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6D3217B9-EA0F-4608-BA9B-B9B935AEDF3A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6D3217B9-EA0F-4608-BA9B-B9B935AEDF3A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6D3217B9-EA0F-4608-BA9B-B9B935AEDF3A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5E1E19E5-18EB-49A1-B392-3BD74418D9FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5E1E19E5-18EB-49A1-B392-3BD74418D9FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5E1E19E5-18EB-49A1-B392-3BD74418D9FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5E1E19E5-18EB-49A1-B392-3BD74418D9FC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Readme.md b/Readme.md
index c7fde6d0..6d3b5c4d 100644
--- a/Readme.md
+++ b/Readme.md
@@ -1,6 +1,6 @@
# Lombiq Hosting - Tenants for Orchard Core
-[![Lombiq.Hosting.Tenants.Admin.Login NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.Admin.Login?label=Lombiq.Hosting.Tenants.Admin.Login)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.Admin.Login/) [![Lombiq.Hosting.Tenants.FeaturesGuard NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.FeaturesGuard?label=Lombiq.Hosting.Tenants.FeaturesGuard)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.FeaturesGuard/) [![Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI?label=Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI/) [![Lombiq.Hosting.Tenants.IdleTenantManagement NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.IdleTenantManagement?label=Lombiq.Hosting.Tenants.IdleTenantManagement)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.IdleTenantManagement/) [![Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI?label=Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI/) [![Lombiq.Hosting.Tenants.Management NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.Management?label=Lombiq.Hosting.Tenants.Management)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.Management/) [![Lombiq.Hosting.Tenants.MediaStorageManagement NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.MediaStorageManagement?label=Lombiq.Hosting.Tenants.MediaStorageManagement)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.MediaStorageManagement/) [![Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI?label=Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI/)
+[![Lombiq.Hosting.Tenants.Admin.Login NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.Admin.Login?label=Lombiq.Hosting.Tenants.Admin.Login)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.Admin.Login/) [![Lombiq.Hosting.Tenants.FeaturesGuard NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.FeaturesGuard?label=Lombiq.Hosting.Tenants.FeaturesGuard)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.FeaturesGuard/) [![Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI?label=Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.FeaturesGuard.Tests.UI/) [![Lombiq.Hosting.Tenants.IdleTenantManagement NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.IdleTenantManagement?label=Lombiq.Hosting.Tenants.IdleTenantManagement)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.IdleTenantManagement/) [![Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI?label=Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.IdleTenantManagement.Tests.UI/) [![Lombiq.Hosting.Tenants.Management NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.Management?label=Lombiq.Hosting.Tenants.Management)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.Management/) [![Lombiq.Hosting.Tenants.Maintenance NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.Maintenance?label=Lombiq.Hosting.Tenants.Maintenance)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.Maintenance/) [![Lombiq.Hosting.Tenants.Maintenance.Tests.UI NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.Maintenance.Tests.UI?label=Lombiq.Hosting.Tenants.Maintenance.Tests.UI)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.Maintenance.Tests.UI/) [![Lombiq.Hosting.Tenants.MediaStorageManagement NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.MediaStorageManagement?label=Lombiq.Hosting.Tenants.MediaStorageManagement)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.MediaStorageManagement/) [![Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI NuGet](https://img.shields.io/nuget/v/Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI?label=Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI)](https://www.nuget.org/packages/Lombiq.Hosting.Tenants.MediaStorageManagement.Tests.UI/)
## About
@@ -17,6 +17,7 @@ The project consists of the following independent modules:
- [Lombiq Hosting - Tenants Features Guard](Lombiq.Hosting.Tenants.FeaturesGuard/Readme.md)
- [Lombiq Hosting - Tenants Admin Login](Lombiq.Hosting.Tenants.Admin.Login/Readme.md)
- [Lombiq Hosting - Tenants Management](Lombiq.Hosting.Tenants.Management/Readme.md)
+- [Lombiq Hosting - Tenants Maintenance](Lombiq.Hosting.Tenants.Maintenance/Readme.md)
- [Lombiq Hosting - Tenants Idle Tenant Management](Lombiq.Hosting.Tenants.IdleTenantManagement/Readme.md)
- [Lombiq Hosting - Tenants Media Storage Management](Lombiq.Hosting.Tenants.MediaStorageManagement/Readme.md)