diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtensions.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtensions.cs index 7488ff08a..b62450de1 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtensions.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtensions.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using NzbDrone.Common.Extensions; using NzbDrone.Core.HealthCheck; @@ -21,7 +21,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks } } - public static void ShouldBeWarning(this Core.HealthCheck.HealthCheck result, string message = null) + public static void ShouldBeWarning(this Core.HealthCheck.HealthCheck result, string message = null, string wikiFragment = null) { result.Type.Should().Be(HealthCheckResult.Warning); @@ -29,9 +29,14 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks { result.Message.Should().Contain(message); } + + if (wikiFragment.IsNotNullOrWhiteSpace()) + { + result.WikiUrl.ToString().Should().Contain(wikiFragment); + } } - public static void ShouldBeError(this Core.HealthCheck.HealthCheck result, string message = null) + public static void ShouldBeError(this Core.HealthCheck.HealthCheck result, string message = null, string wikiFragment = null) { result.Type.Should().Be(HealthCheckResult.Error); @@ -39,6 +44,11 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks { result.Message.Should().Contain(message); } + + if (wikiFragment.IsNotNullOrWhiteSpace()) + { + result.WikiUrl.ToString().Should().Contain(wikiFragment); + } } } } diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/RemotePathMappingCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/RemotePathMappingCheckFixture.cs new file mode 100644 index 000000000..e9481b402 --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/RemotePathMappingCheckFixture.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class RemotePathMappingCheckFixture : CoreTest + { + private string _downloadRootPath = @"c:\Test".AsOsAgnostic(); + private string _downloadItemPath = @"c:\Test\item".AsOsAgnostic(); + + private DownloadClientInfo _clientStatus; + private DownloadClientItem _downloadItem; + private Mock _downloadClient; + + private static Exception[] DownloadClientExceptions = + { + new DownloadClientUnavailableException("error"), + new DownloadClientAuthenticationException("error"), + new DownloadClientException("error") + }; + + [SetUp] + public void Setup() + { + _downloadItem = new DownloadClientItem + { + DownloadClientInfo = new DownloadClientItemClientInfo { Name = "Test" }, + DownloadId = "TestId", + OutputPath = new OsPath(_downloadItemPath) + }; + + _clientStatus = new DownloadClientInfo + { + IsLocalhost = true, + OutputRootFolders = new List { new OsPath(_downloadRootPath) } + }; + + _downloadClient = Mocker.GetMock(); + _downloadClient.Setup(s => s.Definition) + .Returns(new DownloadClientDefinition { Name = "Test" }); + + _downloadClient.Setup(s => s.GetItems()) + .Returns(new List { _downloadItem }); + + _downloadClient.Setup(s => s.GetStatus()) + .Returns(_clientStatus); + + Mocker.GetMock() + .Setup(s => s.GetDownloadClients()) + .Returns(new IDownloadClient[] { _downloadClient.Object }); + + Mocker.GetMock() + .Setup(x => x.FolderExists(It.IsAny())) + .Returns((string path) => + { + Ensure.That(path, () => path).IsValidPath(); + return false; + }); + + Mocker.GetMock() + .Setup(x => x.FileExists(It.IsAny())) + .Returns((string path) => + { + Ensure.That(path, () => path).IsValidPath(); + return false; + }); + } + + private void GivenFolderExists(string folder) + { + Mocker.GetMock() + .Setup(x => x.FolderExists(folder)) + .Returns(true); + } + + private void GivenFileExists(string file) + { + Mocker.GetMock() + .Setup(x => x.FileExists(file)) + .Returns(true); + } + + private void GivenDocker() + { + Mocker.GetMock() + .Setup(x => x.IsDocker) + .Returns(true); + } + + [Test] + public void should_return_ok_if_setup_correctly() + { + GivenFolderExists(_downloadRootPath); + + Subject.Check().ShouldBeOk(); + } + + [Test] + public void should_return_permissions_error_if_local_client_download_root_missing() + { + Subject.Check().ShouldBeError(wikiFragment: "permissions-error"); + } + + [Test] + public void should_return_mapping_error_if_remote_client_root_path_invalid() + { + _clientStatus.IsLocalhost = false; + _clientStatus.OutputRootFolders = new List { new OsPath("An invalid path") }; + + Subject.Check().ShouldBeError(wikiFragment: "bad-remote-path-mapping"); + } + + [Test] + public void should_return_download_client_error_if_local_client_root_path_invalid() + { + _clientStatus.IsLocalhost = true; + _clientStatus.OutputRootFolders = new List { new OsPath("An invalid path") }; + + Subject.Check().ShouldBeError(wikiFragment: "bad-download-client-settings"); + } + + [Test] + public void should_return_path_mapping_error_if_remote_client_download_root_missing() + { + _clientStatus.IsLocalhost = false; + + Subject.Check().ShouldBeError(wikiFragment: "bad-remote-path-mapping"); + } + + [Test] + [TestCaseSource("DownloadClientExceptions")] + public void should_return_ok_if_client_throws_downloadclientexception(Exception ex) + { + _downloadClient.Setup(s => s.GetStatus()) + .Throws(ex); + + Subject.Check().ShouldBeOk(); + + ExceptionVerification.ExpectedErrors(0); + } + + [Test] + public void should_return_docker_path_mapping_error_if_on_docker_and_root_missing() + { + GivenDocker(); + + Subject.Check().ShouldBeError(wikiFragment: "docker-bad-remote-path-mapping"); + } + + [Test] + public void should_return_ok_on_movie_imported_event() + { + GivenFolderExists(_downloadRootPath); + var importEvent = new MovieImportedEvent(new LocalMovie(), new MovieFile(), true, new DownloadClientItem(), _downloadItem.DownloadId); + + Subject.Check(importEvent).ShouldBeOk(); + } + + [Test] + public void should_return_permissions_error_on_movie_import_failed_event_if_file_exists() + { + var localMovie = new LocalMovie + { + Path = Path.Combine(_downloadItemPath, "file.mkv") + }; + GivenFileExists(localMovie.Path); + + var importEvent = new MovieImportFailedEvent(new Exception(), localMovie, true, new DownloadClientItem()); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "permissions-error"); + } + + [Test] + public void should_return_permissions_error_on_movie_import_failed_event_if_folder_exists() + { + GivenFolderExists(_downloadItemPath); + + var importEvent = new MovieImportFailedEvent(null, null, true, _downloadItem); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "permissions-error"); + } + + [Test] + public void should_return_permissions_error_on_movie_import_failed_event_for_local_client_if_folder_does_not_exist() + { + var importEvent = new MovieImportFailedEvent(null, null, true, _downloadItem); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "permissions-error"); + } + + [Test] + public void should_return_mapping_error_on_movie_import_failed_event_for_remote_client_if_folder_does_not_exist() + { + _clientStatus.IsLocalhost = false; + var importEvent = new MovieImportFailedEvent(null, null, true, _downloadItem); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "bad-remote-path-mapping"); + } + + [Test] + public void should_return_mapping_error_on_movie_import_failed_event_for_remote_client_if_path_invalid() + { + _clientStatus.IsLocalhost = false; + _downloadItem.OutputPath = new OsPath("an invalid path"); + var importEvent = new MovieImportFailedEvent(null, null, true, _downloadItem); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "bad-remote-path-mapping"); + } + + [Test] + public void should_return_download_client_error_on_movie_import_failed_event_for_remote_client_if_path_invalid() + { + _clientStatus.IsLocalhost = true; + _downloadItem.OutputPath = new OsPath("an invalid path"); + var importEvent = new MovieImportFailedEvent(null, null, true, _downloadItem); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "bad-download-client-settings"); + } + + [Test] + public void should_return_docker_mapping_error_on_movie_import_failed_event_inside_docker_if_folder_does_not_exist() + { + GivenDocker(); + + _clientStatus.IsLocalhost = false; + var importEvent = new MovieImportFailedEvent(null, null, true, _downloadItem); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "docker-bad-remote-path-mapping"); + } + + [Test] + [TestCaseSource("DownloadClientExceptions")] + public void should_return_ok_on_import_failed_event_if_client_throws_downloadclientexception(Exception ex) + { + _downloadClient.Setup(s => s.GetStatus()) + .Throws(ex); + + var importEvent = new MovieImportFailedEvent(null, null, true, _downloadItem); + + Subject.Check(importEvent).ShouldBeOk(); + + ExceptionVerification.ExpectedErrors(0); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/RemotePathMappingCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/RemotePathMappingCheck.cs new file mode 100644 index 000000000..78591cd58 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/RemotePathMappingCheck.cs @@ -0,0 +1,192 @@ +using System; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ProviderAddedEvent))] + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ModelEvent))] + [CheckOn(typeof(MovieImportedEvent), CheckOnCondition.FailedOnly)] + [CheckOn(typeof(MovieImportFailedEvent), CheckOnCondition.SuccessfulOnly)] + public class RemotePathMappingCheck : HealthCheckBase, IProvideHealthCheck + { + private readonly IDiskProvider _diskProvider; + private readonly IProvideDownloadClient _downloadClientProvider; + private readonly Logger _logger; + private readonly IOsInfo _osInfo; + + public RemotePathMappingCheck(IDiskProvider diskProvider, + IProvideDownloadClient downloadClientProvider, + IOsInfo osInfo, + Logger logger) + { + _diskProvider = diskProvider; + _downloadClientProvider = downloadClientProvider; + _logger = logger; + _osInfo = osInfo; + } + + public override HealthCheck Check() + { + var clients = _downloadClientProvider.GetDownloadClients(); + + foreach (var client in clients) + { + try + { + var status = client.GetStatus(); + var folders = status.OutputRootFolders; + if (folders != null) + { + foreach (var folder in folders) + { + if (!folder.IsValid) + { + if (!status.IsLocalhost) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Remote download client {client.Definition.Name} places downloads in {folder.FullPath} but this is not a valid {_osInfo.Name} path. Review your remote path mappings and download client settings.", "#bad-remote-path-mapping"); + } + else if (_osInfo.IsDocker) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"You are using docker; download client {client.Definition.Name} places downloads in {folder.FullPath} but this is not a valid {_osInfo.Name} path. Review your remote path mappings and download client settings.", "#docker-bad-remote-path-mapping"); + } + else + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Local download client {client.Definition.Name} places downloads in {folder.FullPath} but this is not a valid {_osInfo.Name} path. Review your download client settings.", "#bad-download-client-settings"); + } + } + + if (!_diskProvider.FolderExists(folder.FullPath)) + { + if (_osInfo.IsDocker) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"You are using docker; download client {client.Definition.Name} places downloads in {folder.FullPath} but this directory does not appear to exist inside the container. Review your remote path mappings and container volume settings.", "#docker-bad-remote-path-mapping"); + } + else if (!status.IsLocalhost) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Remote download client {client.Definition.Name} places downloads in {folder.FullPath} but this directory does not appear to exist. Likely missing or incorrect remote path mapping.", "#bad-remote-path-mapping"); + } + else + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Download client {client.Definition.Name} places downloads in {folder.FullPath} but Radarr cannot see this directory. You may need to adjust the folder's permissions.", "#permissions-error"); + } + } + } + } + } + catch (DownloadClientException ex) + { + _logger.Debug(ex, "Unable to communicate with {0}", client.Definition.Name); + } + catch (Exception ex) + { + _logger.Error(ex, "Unknown error occured in RemotePathMapping HealthCheck"); + } + } + + return new HealthCheck(GetType()); + } + + public HealthCheck Check(IEvent message) + { + if (typeof(MovieImportFailedEvent).IsAssignableFrom(message.GetType())) + { + var failureMessage = (MovieImportFailedEvent)message; + + // if we can see the file exists but the import failed then likely a permissions issue + if (failureMessage.MovieInfo != null) + { + var moviePath = failureMessage.MovieInfo.Path; + if (_diskProvider.FileExists(moviePath)) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Radarr can see but not access downloaded movie {moviePath}. Likely permissions error.", "#permissions-error"); + } + else + { + // If the file doesn't exist but TrackInfo is not null then the message is coming from + // ImportApprovedTracks and the file must have been removed part way through processing + return new HealthCheck(GetType(), HealthCheckResult.Error, $"File {moviePath} was removed part way though procesing."); + } + } + + // If the previous case did not match then the failure occured in DownloadedTracksImportService, + // while trying to locate the files reported by the download client + var client = _downloadClientProvider.GetDownloadClients().FirstOrDefault(x => x.Definition.Name == failureMessage.DownloadClientInfo.Name); + try + { + var status = client.GetStatus(); + var dlpath = client?.GetItems().FirstOrDefault(x => x.DownloadId == failureMessage.DownloadId)?.OutputPath.FullPath; + + // If dlpath is null then there's not much useful we can report. Give a generic message so + // that the user realises something is wrong. + if (dlpath.IsNullOrWhiteSpace()) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Radarr failed to import a movie. Check your logs for details."); + } + + if (!dlpath.IsPathValid()) + { + if (!status.IsLocalhost) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Remote download client {client.Definition.Name} reported files in {dlpath} but this is not a valid {_osInfo.Name} path. Review your remote path mappings and download client settings.", "#bad-remote-path-mapping"); + } + else if (_osInfo.IsDocker) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"You are using docker; download client {client.Definition.Name} reported files in {dlpath} but this is not a valid {_osInfo.Name} path. Review your remote path mappings and download client settings.", "#docker-bad-remote-path-mapping"); + } + else + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Local download client {client.Definition.Name} reported files in {dlpath} but this is not a valid {_osInfo.Name} path. Review your download client settings.", "#bad-download-client-settings"); + } + } + + if (_diskProvider.FolderExists(dlpath)) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Radarr can see but not access download directory {dlpath}. Likely permissions error.", "#permissions-error"); + } + + // if it's a remote client/docker, likely missing path mappings + if (_osInfo.IsDocker) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"You are using docker; download client {client.Definition.Name} reported files in {dlpath} but this directory does not appear to exist inside the container. Review your remote path mappings and container volume settings.", "#docker-bad-remote-path-mapping"); + } + else if (!status.IsLocalhost) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Remote download client {client.Definition.Name} reported files in {dlpath} but this directory does not appear to exist. Likely missing remote path mapping.", "#bad-remote-path-mapping"); + } + else + { + // path mappings shouldn't be needed locally so probably a permissions issue + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Download client {client.Definition.Name} reported files in {dlpath} but Radarr cannot see this directory. You may need to adjust the folder's permissions.", "#permissions-error"); + } + } + catch (DownloadClientException ex) + { + _logger.Debug(ex, "Unable to communicate with {0}", client.Definition.Name); + } + catch (Exception ex) + { + _logger.Error(ex, "Unknown error occured in RemotePathMapping HealthCheck"); + } + + return new HealthCheck(GetType()); + } + else + { + return Check(); + } + } + } +}