using System; using System.IO; using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations; using Emby.Server.Implementations.Configuration; using Emby.Server.Implementations.Serialization; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using Microsoft.Extensions.Logging.Abstractions; using StackExchange.Redis; using Xunit; namespace Jellyfin.Server.Implementations.Tests.Configuration; /// /// Two independently constructed instances over one configuration /// directory are the in-process stand-in for two replicas sharing one /config mount: what either of /// them writes, the other has to pick up without being restarted. /// public sealed class SharedConfigurationPropagationTests : IDisposable { private readonly string _root; /// /// Initializes a new instance of the class. /// public SharedConfigurationPropagationTests() { _root = Path.Combine(Path.GetTempPath(), "jf-config-prop-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); } /// public void Dispose() { try { Directory.Delete(_root, true); } catch (IOException) { } } /// /// A system configuration setting tightened on one replica has to hold on every other replica, not /// only on the one that served the admin's request. /// /// A representing the asynchronous operation. [Fact] public async Task SystemConfigurationSavedOnOneInstance_IsSeenByAnotherWithoutRestart() { var fabric = new FakeInvalidationBusFabric(); var instanceA = CreateInstance(fabric, "pod-a"); var instanceB = await CreateSubscribedInstanceAsync(fabric, "pod-b"); // B has the pre-change configuration in hand before A writes, as a running replica would. Assert.True(instanceB.Configuration.QuickConnectAvailable); instanceA.Configuration.QuickConnectAvailable = false; instanceA.SaveConfiguration(); Assert.False(instanceB.Configuration.QuickConnectAvailable); } /// /// The same has to hold for the named configurations, which are cached per key and never reloaded. /// /// A representing the asynchronous operation. [Fact] public async Task NamedConfigurationSavedOnOneInstance_IsSeenByAnotherWithoutRestart() { var fabric = new FakeInvalidationBusFabric(); var instanceA = CreateInstance(fabric, "pod-a"); var instanceB = await CreateSubscribedInstanceAsync(fabric, "pod-b"); Assert.True(instanceB.GetNetworkConfiguration().EnableRemoteAccess); var updated = instanceA.GetNetworkConfiguration(); updated.EnableRemoteAccess = false; instanceA.SaveConfiguration(NetworkConfigurationStore.StoreKey, updated); Assert.False(instanceB.GetNetworkConfiguration().EnableRemoteAccess); } /// /// A replica that cannot reach the bus keeps serving: the admin's save still lands on the shared /// directory, and the only loss is that the other replicas are not told about it. /// [Fact] public void SaveConfiguration_WithUnreachableBus_DoesNotThrow() { using var multiplexer = ConnectionMultiplexer.Connect("127.0.0.1:1,abortConnect=false,connectTimeout=200,connectRetry=1,syncTimeout=200"); var bus = new RedisConfigurationInvalidationBus(multiplexer, NullLogger.Instance); bus.Subscribe(_ => throw new InvalidOperationException("Nothing can be delivered by an unreachable bus.")); var instance = CreateInstance(new FakeInvalidationBusFabric(), "pod-a"); instance.InvalidationBus = bus; instance.Configuration.QuickConnectAvailable = false; instance.SaveConfiguration(); instance.SaveConfiguration(NetworkConfigurationStore.StoreKey, instance.GetNetworkConfiguration()); Assert.False(instance.Configuration.QuickConnectAvailable); } private async Task CreateSubscribedInstanceAsync(FakeInvalidationBusFabric fabric, string originId) { var instance = CreateInstance(fabric, originId); var subscriber = new ConfigurationInvalidationSubscriber( instance.InvalidationBus, instance, NullLogger.Instance); await subscriber.StartAsync(CancellationToken.None); return instance; } private ServerConfigurationManager CreateInstance(FakeInvalidationBusFabric fabric, string originId) { // Every instance has its own paths object, all of them pointing at the one shared directory. var paths = new ServerApplicationPaths( Ensure("data"), Ensure("log"), Ensure("config"), Ensure("cache"), Ensure("web")); var manager = new ServerConfigurationManager(paths, NullLoggerFactory.Instance, new MyXmlSerializer()) { InvalidationBus = fabric.Connect(originId) }; manager.AddParts([new NetworkConfigurationFactory()]); return manager; } private string Ensure(string name) { var path = Path.Combine(_root, name); Directory.CreateDirectory(path); return path; } }