Merge pull request #180 from Sonarr/updatecheck-fixes
Updatecheck fixes
This commit is contained in:
commit
5750f012cb
src
NzbDrone.Common.Test
NzbDrone.Common/Disk
NzbDrone.Core.Test
HealthCheck/Checks
UpdateTests
NzbDrone.Core
NzbDrone.Mono.Test
NzbDrone.Test.Common
NzbDrone.Windows.Test/DiskProviderTests
UI
|
@ -1,6 +1,9 @@
|
|||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Authentication;
|
||||
|
@ -13,6 +16,8 @@ namespace NzbDrone.Common.Test
|
|||
|
||||
public class ConfigFileProviderTest : TestBase<ConfigFileProvider>
|
||||
{
|
||||
private string _configFileContents;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
|
@ -20,8 +25,24 @@ namespace NzbDrone.Common.Test
|
|||
|
||||
var configFile = Mocker.Resolve<IAppFolderInfo>().GetConfigPath();
|
||||
|
||||
if (File.Exists(configFile))
|
||||
File.Delete(configFile);
|
||||
_configFileContents = null;
|
||||
|
||||
WithMockConfigFile(configFile);
|
||||
}
|
||||
|
||||
protected void WithMockConfigFile(string configFile)
|
||||
{
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(v => v.FileExists(configFile))
|
||||
.Returns<string>(p => _configFileContents != null);
|
||||
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(v => v.ReadAllText(configFile))
|
||||
.Returns<string>(p => _configFileContents);
|
||||
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(v => v.WriteAllText(configFile, It.IsAny<string>()))
|
||||
.Callback<string, string>((p, t) => _configFileContents = t);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
@ -142,8 +163,28 @@ namespace NzbDrone.Common.Test
|
|||
|
||||
Subject.SaveConfigDictionary(dic);
|
||||
|
||||
Subject.Port.Should().Be(port);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveDictionary_should_only_save_specified_values()
|
||||
{
|
||||
int port = 20555;
|
||||
int origSslPort = 20551;
|
||||
int sslPort = 20552;
|
||||
|
||||
var dic = Subject.GetConfigDictionary();
|
||||
dic["Port"] = port;
|
||||
dic["SslPort"] = origSslPort;
|
||||
Subject.SaveConfigDictionary(dic);
|
||||
|
||||
|
||||
dic = new Dictionary<string, object>();
|
||||
dic["SslPort"] = sslPort;
|
||||
Subject.SaveConfigDictionary(dic);
|
||||
|
||||
Subject.Port.Should().Be(port);
|
||||
Subject.SslPort.Should().Be(sslPort);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ using NzbDrone.Test.Common;
|
|||
|
||||
namespace NzbDrone.Common.Test.DiskTests
|
||||
{
|
||||
public class DiskProviderFixtureBase<TSubject> : TestBase<TSubject> where TSubject : class, IDiskProvider
|
||||
public abstract class DiskProviderFixtureBase<TSubject> : TestBase<TSubject> where TSubject : class, IDiskProvider
|
||||
{
|
||||
public DirectoryInfo GetFilledTempFolder()
|
||||
{
|
||||
|
@ -46,6 +46,38 @@ namespace NzbDrone.Common.Test.DiskTests
|
|||
Subject.FolderExists(@"C:\ThisBetterNotExist\".AsOsAgnostic()).Should().BeFalse();
|
||||
}
|
||||
|
||||
protected abstract void SetWritePermissions(string path, bool writable);
|
||||
|
||||
[Test]
|
||||
public void FolderWritable_should_return_true_for_writable_directory()
|
||||
{
|
||||
var tempFolder = GetTempFilePath();
|
||||
Directory.CreateDirectory(tempFolder);
|
||||
|
||||
var result = Subject.FolderWritable(tempFolder);
|
||||
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FolderWritable_should_return_false_for_unwritable_directory()
|
||||
{
|
||||
var tempFolder = GetTempFilePath();
|
||||
Directory.CreateDirectory(tempFolder);
|
||||
|
||||
SetWritePermissions(tempFolder, false);
|
||||
try
|
||||
{
|
||||
var result = Subject.FolderWritable(tempFolder);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetWritePermissions(tempFolder, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MoveFile_should_overwrite_existing_file()
|
||||
{
|
||||
|
|
|
@ -109,6 +109,25 @@ namespace NzbDrone.Common.Disk
|
|||
}
|
||||
}
|
||||
|
||||
public bool FolderWritable(string path)
|
||||
{
|
||||
Ensure.That(path, () => path).IsValidPath();
|
||||
|
||||
try
|
||||
{
|
||||
var testPath = Path.Combine(path, "sonarr_write_test.txt");
|
||||
var testContent = string.Format("This file was created to verify if '{0}' is writable. It should've been automatically deleted. Feel free to delete it.", path);
|
||||
File.WriteAllText(testPath, testContent);
|
||||
File.Delete(testPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.Trace("Directory '{0}' isn't writable. {1}", path, e.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public string[] GetDirectories(string path)
|
||||
{
|
||||
Ensure.That(path, () => path).IsValidPath();
|
||||
|
|
|
@ -19,6 +19,7 @@ namespace NzbDrone.Common.Disk
|
|||
bool FolderExists(string path);
|
||||
bool FileExists(string path);
|
||||
bool FileExists(string path, StringComparison stringComparison);
|
||||
bool FolderWritable(string path);
|
||||
string[] GetDirectories(string path);
|
||||
string[] GetFiles(string path, SearchOption searchOption);
|
||||
long GetFolderSize(string path);
|
||||
|
|
|
@ -13,7 +13,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
|
|||
{
|
||||
private const string DRONE_FACTORY_FOLDER = @"C:\Test\Unsorted";
|
||||
|
||||
private void GivenDroneFactoryFolder(bool exists = false)
|
||||
private void GivenDroneFactoryFolder(bool exists = false, bool writable = true)
|
||||
{
|
||||
Mocker.GetMock<IConfigService>()
|
||||
.SetupGet(s => s.DownloadedEpisodesFolder)
|
||||
|
@ -22,6 +22,10 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
|
|||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(s => s.FolderExists(DRONE_FACTORY_FOLDER))
|
||||
.Returns(exists);
|
||||
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(s => s.FolderWritable(It.IsAny<String>()))
|
||||
.Returns(exists && writable);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
@ -35,11 +39,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
|
|||
[Test]
|
||||
public void should_return_error_when_unable_to_write_to_drone_factory_folder()
|
||||
{
|
||||
GivenDroneFactoryFolder(true);
|
||||
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(s => s.WriteAllText(It.IsAny<String>(), It.IsAny<String>()))
|
||||
.Throws<Exception>();
|
||||
GivenDroneFactoryFolder(true, false);
|
||||
|
||||
Subject.Check().ShouldBeError();
|
||||
}
|
||||
|
|
|
@ -21,9 +21,9 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
|
|||
.Setup(s => s.StartUpFolder)
|
||||
.Returns(@"C:\NzbDrone");
|
||||
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(s => s.WriteAllText(It.IsAny<String>(), It.IsAny<String>()))
|
||||
.Throws<Exception>();
|
||||
Mocker.GetMock<NzbDrone.Common.Disk.IDiskProvider>()
|
||||
.Setup(c => c.FolderWritable(Moq.It.IsAny<string>()))
|
||||
.Returns(false);
|
||||
|
||||
Subject.Check().ShouldBeError();
|
||||
}
|
||||
|
@ -41,9 +41,9 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
|
|||
.Setup(s => s.StartUpFolder)
|
||||
.Returns(@"/opt/nzbdrone");
|
||||
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(s => s.WriteAllText(It.IsAny<String>(), It.IsAny<String>()))
|
||||
.Throws<Exception>();
|
||||
Mocker.GetMock<NzbDrone.Common.Disk.IDiskProvider>()
|
||||
.Setup(c => c.FolderWritable(Moq.It.IsAny<string>()))
|
||||
.Returns(false);
|
||||
|
||||
Subject.Check().ShouldBeError();
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ namespace NzbDrone.Core.Test.UpdateTests
|
|||
{
|
||||
const string branch = "master";
|
||||
UseRealHttp();
|
||||
var recent = Subject.GetRecentUpdates(branch);
|
||||
var recent = Subject.GetRecentUpdates(branch, new Version(2, 0));
|
||||
|
||||
recent.Should().NotBeEmpty();
|
||||
recent.Should().OnlyContain(c => c.Hash.IsNotNullOrWhiteSpace());
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
@ -11,6 +12,7 @@ using NzbDrone.Common.Http;
|
|||
using NzbDrone.Common.Model;
|
||||
using NzbDrone.Common.Processes;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Exceptions;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Core.Update;
|
||||
using NzbDrone.Core.Update.Commands;
|
||||
|
@ -59,6 +61,14 @@ namespace NzbDrone.Core.Test.UpdateTests
|
|||
Mocker.GetMock<IProcessProvider>().Setup(c => c.GetCurrentProcess()).Returns(new ProcessInfo { Id = 12 });
|
||||
Mocker.GetMock<IRuntimeInfo>().Setup(c => c.ExecutingApplication).Returns(@"C:\Test\NzbDrone.exe");
|
||||
|
||||
Mocker.GetMock<IConfigFileProvider>()
|
||||
.SetupGet(s => s.UpdateAutomatically)
|
||||
.Returns(true);
|
||||
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(c => c.FolderWritable(It.IsAny<string>()))
|
||||
.Returns(true);
|
||||
|
||||
_sandboxFolder = Mocker.GetMock<IAppFolderInfo>().Object.GetUpdateSandboxFolder();
|
||||
}
|
||||
|
||||
|
@ -259,6 +269,47 @@ namespace NzbDrone.Core.Test.UpdateTests
|
|||
ExceptionVerification.ExpectedErrors(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_log_error_when_startup_folder_is_not_writable()
|
||||
{
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(c => c.FolderWritable(It.IsAny<string>()))
|
||||
.Returns(false);
|
||||
|
||||
var updateArchive = Path.Combine(_sandboxFolder, _updatePackage.FileName);
|
||||
|
||||
Subject.Execute(new ApplicationUpdateCommand());
|
||||
|
||||
Mocker.GetMock<IHttpClient>().Verify(c => c.DownloadFile(_updatePackage.Url, updateArchive), Times.Never());
|
||||
ExceptionVerification.ExpectedErrors(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_log_when_install_cannot_be_started()
|
||||
{
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(c => c.FolderWritable(It.IsAny<string>()))
|
||||
.Returns(false);
|
||||
|
||||
var updateArchive = Path.Combine(_sandboxFolder, _updatePackage.FileName);
|
||||
|
||||
Subject.Execute(new ApplicationUpdateCommand());
|
||||
|
||||
Mocker.GetMock<IHttpClient>().Verify(c => c.DownloadFile(_updatePackage.Url, updateArchive), Times.Never());
|
||||
ExceptionVerification.ExpectedErrors(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_switch_to_branch_specified_in_updatepackage()
|
||||
{
|
||||
_updatePackage.Branch = "fake";
|
||||
|
||||
Subject.Execute(new ApplicationUpdateCommand());
|
||||
|
||||
Mocker.GetMock<IConfigFileProvider>()
|
||||
.Verify(v => v.SaveConfigDictionary(It.Is<Dictionary<string, object>>(d => d.ContainsKey("Branch") && (string)d["Branch"] == "fake")), Times.Once());
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
|
|
|
@ -339,7 +339,7 @@ namespace NzbDrone.Core.Configuration
|
|||
{
|
||||
if (_diskProvider.FileExists(_configFile))
|
||||
{
|
||||
return XDocument.Load(_configFile);
|
||||
return XDocument.Parse(_diskProvider.ReadAllText(_configFile));
|
||||
}
|
||||
|
||||
var xDoc = new XDocument(new XDeclaration("1.0", "utf-8", "yes"));
|
||||
|
|
|
@ -122,30 +122,8 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic
|
|||
|
||||
protected override void Test(List<ValidationFailure> failures)
|
||||
{
|
||||
failures.AddIfNotNull(TestWrite(Settings.NzbFolder, "NzbFolder"));
|
||||
failures.AddIfNotNull(TestWrite(Settings.StrmFolder, "StrmFolder"));
|
||||
}
|
||||
|
||||
private ValidationFailure TestWrite(String folder, String propertyName)
|
||||
{
|
||||
if (!_diskProvider.FolderExists(folder))
|
||||
{
|
||||
return new ValidationFailure(propertyName, "Folder does not exist");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var testPath = Path.Combine(folder, "drone_test.txt");
|
||||
_diskProvider.WriteAllText(testPath, DateTime.Now.ToString());
|
||||
_diskProvider.DeleteFile(testPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.ErrorException(ex.Message, ex);
|
||||
return new ValidationFailure(propertyName, "Unable to write to folder");
|
||||
}
|
||||
|
||||
return null;
|
||||
failures.AddIfNotNull(TestFolder(Settings.NzbFolder, "NzbFolder"));
|
||||
failures.AddIfNotNull(TestFolder(Settings.StrmFolder, "StrmFolder"));
|
||||
}
|
||||
|
||||
private String WriteStrmFile(String title, String nzbFile)
|
||||
|
|
|
@ -98,26 +98,17 @@ namespace NzbDrone.Core.Download
|
|||
{
|
||||
return new NzbDroneValidationFailure(propertyName, "Folder does not exist")
|
||||
{
|
||||
DetailedDescription = "The folder you specified does not exist or is inaccessible. Please verify the folder permissions for the user account that is used to execute NzbDrone."
|
||||
DetailedDescription = string.Format("The folder you specified does not exist or is inaccessible. Please verify the folder permissions for the user account '{0}', which is used to execute Sonarr.", Environment.UserName)
|
||||
};
|
||||
}
|
||||
|
||||
if (mustBeWritable)
|
||||
if (mustBeWritable && !_diskProvider.FolderWritable(folder))
|
||||
{
|
||||
try
|
||||
_logger.Error("Folder '{0}' is not writable.", folder);
|
||||
return new NzbDroneValidationFailure(propertyName, "Unable to write to folder")
|
||||
{
|
||||
var testPath = Path.Combine(folder, "drone_test.txt");
|
||||
_diskProvider.WriteAllText(testPath, DateTime.Now.ToString());
|
||||
_diskProvider.DeleteFile(testPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.ErrorException(ex.Message, ex);
|
||||
return new NzbDroneValidationFailure(propertyName, "Unable to write to folder")
|
||||
{
|
||||
DetailedDescription = "The folder you specified is not writable. Please verify the folder permissions for the user account that is used to execute NzbDrone."
|
||||
};
|
||||
}
|
||||
DetailedDescription = string.Format("The folder you specified is not writable. Please verify the folder permissions for the user account '{0}', which is used to execute Sonarr.", Environment.UserName)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -31,13 +31,7 @@ namespace NzbDrone.Core.HealthCheck.Checks
|
|||
return new HealthCheck(GetType(), HealthCheckResult.Error, "Drone factory folder does not exist");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var testPath = Path.Combine(droneFactoryFolder, "drone_test.txt");
|
||||
_diskProvider.WriteAllText(testPath, DateTime.Now.ToString());
|
||||
_diskProvider.DeleteFile(testPath);
|
||||
}
|
||||
catch (Exception)
|
||||
if (!_diskProvider.FolderWritable(droneFactoryFolder))
|
||||
{
|
||||
return new HealthCheck(GetType(), HealthCheckResult.Error, "Unable to write to drone factory folder");
|
||||
}
|
||||
|
|
|
@ -29,15 +29,11 @@ namespace NzbDrone.Core.HealthCheck.Checks
|
|||
{
|
||||
if (OsInfo.IsWindows || _configFileProvider.UpdateAutomatically)
|
||||
{
|
||||
try
|
||||
if (!_diskProvider.FolderWritable(_appFolderInfo.StartUpFolder))
|
||||
{
|
||||
var testPath = Path.Combine(_appFolderInfo.StartUpFolder, "drone_test.txt");
|
||||
_diskProvider.WriteAllText(testPath, DateTime.Now.ToString());
|
||||
_diskProvider.DeleteFile(testPath);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new HealthCheck(GetType(), HealthCheckResult.Error, "Unable to update, running from write-protected folder");
|
||||
return new HealthCheck(GetType(), HealthCheckResult.Error,
|
||||
string.Format("Cannot install update because startup folder '{0}' is not writable by the user '{1}'.", _appFolderInfo.StartUpFolder, Environment.UserName),
|
||||
"Cannot install update because startup folder is not writable by the user");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -94,7 +94,7 @@ namespace NzbDrone.Core.HealthCheck
|
|||
|
||||
public void Execute(CheckHealthCommand message)
|
||||
{
|
||||
PerformHealthCheck(c => c.CheckOnSchedule);
|
||||
PerformHealthCheck(c => message.Manual || c.CheckOnSchedule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,21 +60,21 @@ namespace NzbDrone.Core.Messaging.Commands
|
|||
SetMessage("Starting");
|
||||
}
|
||||
|
||||
public void Failed(Exception exception)
|
||||
public void Failed(Exception exception, string message = "Failed")
|
||||
{
|
||||
_stopWatch.Stop();
|
||||
StateChangeTime = DateTime.UtcNow;
|
||||
State = CommandStatus.Failed;
|
||||
Exception = exception;
|
||||
SetMessage("Failed");
|
||||
SetMessage(message);
|
||||
}
|
||||
|
||||
public void Completed()
|
||||
public void Completed(string message = "Completed")
|
||||
{
|
||||
_stopWatch.Stop();
|
||||
StateChangeTime = DateTime.UtcNow;
|
||||
State = CommandStatus.Completed;
|
||||
SetMessage("Completed");
|
||||
SetMessage(message);
|
||||
}
|
||||
|
||||
public void SetMessage(string message)
|
||||
|
|
|
@ -129,7 +129,11 @@ namespace NzbDrone.Core.Messaging.Commands
|
|||
}
|
||||
|
||||
handler.Execute((TCommand)command);
|
||||
_trackCommands.Completed(command);
|
||||
|
||||
if (command.State == CommandStatus.Running)
|
||||
{
|
||||
_trackCommands.Completed(command);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
|
|
@ -853,11 +853,12 @@
|
|||
<Compile Include="Tv\SeriesTypes.cs" />
|
||||
<Compile Include="Tv\ShouldRefreshSeries.cs" />
|
||||
<Compile Include="Update\Commands\ApplicationUpdateCommand.cs" />
|
||||
<Compile Include="Update\Commands\InstallUpdateCommand.cs" />
|
||||
<Compile Include="Update\InstallUpdateService.cs" />
|
||||
<Compile Include="Update\RecentUpdateProvider.cs" />
|
||||
<Compile Include="Update\UpdateAbortedException.cs" />
|
||||
<Compile Include="Update\UpdateChanges.cs" />
|
||||
<Compile Include="Update\UpdateCheckService.cs" />
|
||||
<Compile Include="Update\UpdateFolderNotWritableException.cs" />
|
||||
<Compile Include="Update\UpdateMechanism.cs" />
|
||||
<Compile Include="Update\UpdatePackage.cs" />
|
||||
<Compile Include="Update\UpdatePackageAvailable.cs" />
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
using NzbDrone.Core.Messaging.Commands;
|
||||
|
||||
namespace NzbDrone.Core.Update.Commands
|
||||
{
|
||||
public class InstallUpdateCommand : Command
|
||||
{
|
||||
public UpdatePackage UpdatePackage { get; set; }
|
||||
|
||||
public override bool SendUpdatesToClient
|
||||
{
|
||||
get
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common;
|
||||
using NzbDrone.Common.Disk;
|
||||
|
@ -15,8 +17,7 @@ using NzbDrone.Core.Update.Commands;
|
|||
|
||||
namespace NzbDrone.Core.Update
|
||||
{
|
||||
|
||||
public class InstallUpdateService : IExecute<ApplicationUpdateCommand>, IExecute<InstallUpdateCommand>
|
||||
public class InstallUpdateService : IExecute<ApplicationUpdateCommand>
|
||||
{
|
||||
private readonly ICheckUpdateService _checkUpdateService;
|
||||
private readonly Logger _logger;
|
||||
|
@ -63,58 +64,80 @@ namespace NzbDrone.Core.Update
|
|||
|
||||
private void InstallUpdate(UpdatePackage updatePackage)
|
||||
{
|
||||
try
|
||||
EnsureAppDataSafety();
|
||||
|
||||
if (OsInfo.IsWindows || _configFileProvider.UpdateMechanism != UpdateMechanism.Script)
|
||||
{
|
||||
EnsureAppDataSafety();
|
||||
|
||||
var updateSandboxFolder = _appFolderInfo.GetUpdateSandboxFolder();
|
||||
|
||||
var packageDestination = Path.Combine(updateSandboxFolder, updatePackage.FileName);
|
||||
|
||||
if (_diskProvider.FolderExists(updateSandboxFolder))
|
||||
if (!_diskProvider.FolderWritable(_appFolderInfo.StartUpFolder))
|
||||
{
|
||||
_logger.Info("Deleting old update files");
|
||||
_diskProvider.DeleteFolder(updateSandboxFolder, true);
|
||||
throw new UpdateFolderNotWritableException("Cannot install update because startup folder '{0}' is not writable by the user '{1}'.", _appFolderInfo.StartUpFolder, Environment.UserName);
|
||||
}
|
||||
|
||||
_logger.ProgressInfo("Downloading update {0}", updatePackage.Version);
|
||||
_logger.Debug("Downloading update package from [{0}] to [{1}]", updatePackage.Url, packageDestination);
|
||||
_httpClient.DownloadFile(updatePackage.Url, packageDestination);
|
||||
|
||||
_logger.ProgressInfo("Verifying update package");
|
||||
|
||||
if (!_updateVerifier.Verify(updatePackage, packageDestination))
|
||||
{
|
||||
_logger.Error("Update package is invalid");
|
||||
throw new UpdateVerificationFailedException("Update file '{0}' is invalid", packageDestination);
|
||||
}
|
||||
|
||||
_logger.Info("Update package verified successfully");
|
||||
|
||||
_logger.ProgressInfo("Extracting Update package");
|
||||
_archiveService.Extract(packageDestination, updateSandboxFolder);
|
||||
_logger.Info("Update package extracted successfully");
|
||||
|
||||
_backupService.Backup(BackupType.Update);
|
||||
|
||||
if (OsInfo.IsNotWindows && _configFileProvider.UpdateMechanism == UpdateMechanism.Script)
|
||||
{
|
||||
InstallUpdateWithScript(updateSandboxFolder);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info("Preparing client");
|
||||
_diskProvider.MoveFolder(_appFolderInfo.GetUpdateClientFolder(),
|
||||
updateSandboxFolder);
|
||||
|
||||
_logger.Info("Starting update client {0}", _appFolderInfo.GetUpdateClientExePath());
|
||||
_logger.ProgressInfo("NzbDrone will restart shortly.");
|
||||
|
||||
_processProvider.Start(_appFolderInfo.GetUpdateClientExePath(), GetUpdaterArgs(updateSandboxFolder));
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
var updateSandboxFolder = _appFolderInfo.GetUpdateSandboxFolder();
|
||||
|
||||
var packageDestination = Path.Combine(updateSandboxFolder, updatePackage.FileName);
|
||||
|
||||
if (_diskProvider.FolderExists(updateSandboxFolder))
|
||||
{
|
||||
_logger.ErrorException("Update process failed", ex);
|
||||
_logger.Info("Deleting old update files");
|
||||
_diskProvider.DeleteFolder(updateSandboxFolder, true);
|
||||
}
|
||||
|
||||
_logger.ProgressInfo("Downloading update {0}", updatePackage.Version);
|
||||
_logger.Debug("Downloading update package from [{0}] to [{1}]", updatePackage.Url, packageDestination);
|
||||
_httpClient.DownloadFile(updatePackage.Url, packageDestination);
|
||||
|
||||
_logger.ProgressInfo("Verifying update package");
|
||||
|
||||
if (!_updateVerifier.Verify(updatePackage, packageDestination))
|
||||
{
|
||||
_logger.Error("Update package is invalid");
|
||||
throw new UpdateVerificationFailedException("Update file '{0}' is invalid", packageDestination);
|
||||
}
|
||||
|
||||
_logger.Info("Update package verified successfully");
|
||||
|
||||
_logger.ProgressInfo("Extracting Update package");
|
||||
_archiveService.Extract(packageDestination, updateSandboxFolder);
|
||||
_logger.Info("Update package extracted successfully");
|
||||
|
||||
EnsureValidBranch(updatePackage);
|
||||
|
||||
_backupService.Backup(BackupType.Update);
|
||||
|
||||
if (OsInfo.IsNotWindows && _configFileProvider.UpdateMechanism == UpdateMechanism.Script)
|
||||
{
|
||||
InstallUpdateWithScript(updateSandboxFolder);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info("Preparing client");
|
||||
_diskProvider.MoveFolder(_appFolderInfo.GetUpdateClientFolder(),
|
||||
updateSandboxFolder);
|
||||
|
||||
_logger.Info("Starting update client {0}", _appFolderInfo.GetUpdateClientExePath());
|
||||
_logger.ProgressInfo("Sonarr will restart shortly.");
|
||||
|
||||
_processProvider.Start(_appFolderInfo.GetUpdateClientExePath(), GetUpdaterArgs(updateSandboxFolder));
|
||||
}
|
||||
|
||||
private void EnsureValidBranch(UpdatePackage package)
|
||||
{
|
||||
var currentBranch = _configFileProvider.Branch;
|
||||
if (package.Branch != currentBranch)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("Branch [{0}] is being redirected to [{1}]]", currentBranch, package.Branch);
|
||||
var config = new Dictionary<string, object>();
|
||||
config["Branch"] = package.Branch;
|
||||
_configFileProvider.SaveConfigDictionary(config);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.ErrorException(string.Format("Couldn't change the branch from [{0}] to [{1}].", currentBranch, package.Branch), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -124,13 +147,12 @@ namespace NzbDrone.Core.Update
|
|||
|
||||
if (scriptPath.IsNullOrWhiteSpace())
|
||||
{
|
||||
throw new ArgumentException("Update Script has not been defined");
|
||||
throw new UpdateFailedException("Update Script has not been defined");
|
||||
}
|
||||
|
||||
if (!_diskProvider.FileExists(scriptPath, StringComparison.Ordinal))
|
||||
{
|
||||
var message = String.Format("Update Script: '{0}' does not exist", scriptPath);
|
||||
throw new FileNotFoundException(message, scriptPath);
|
||||
throw new UpdateFailedException("Update Script: '{0}' does not exist", scriptPath);
|
||||
}
|
||||
|
||||
_logger.Info("Removing NzbDrone.Update");
|
||||
|
@ -153,24 +175,49 @@ namespace NzbDrone.Core.Update
|
|||
if (_appFolderInfo.StartUpFolder.IsParentPath(_appFolderInfo.AppDataFolder) ||
|
||||
_appFolderInfo.StartUpFolder.PathEquals(_appFolderInfo.AppDataFolder))
|
||||
{
|
||||
throw new NotSupportedException("Update will cause AppData to be deleted, correct you configuration before proceeding");
|
||||
throw new UpdateFailedException("Your Sonarr configuration '{0}' is being stored in application folder '{1}' which will cause data lost during the upgrade. Please remove any symlinks or redirects before trying again.", _appFolderInfo.AppDataFolder, _appFolderInfo.StartUpFolder);
|
||||
}
|
||||
}
|
||||
|
||||
public void Execute(ApplicationUpdateCommand message)
|
||||
{
|
||||
_logger.ProgressDebug("Checking for updates");
|
||||
|
||||
var latestAvailable = _checkUpdateService.AvailableUpdate();
|
||||
|
||||
if (latestAvailable != null)
|
||||
if (latestAvailable == null)
|
||||
{
|
||||
_logger.ProgressDebug("No update available.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (OsInfo.IsNotWindows && !_configFileProvider.UpdateAutomatically && !message.Manual)
|
||||
{
|
||||
_logger.ProgressDebug("Auto-update not enabled, not installing available update.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
InstallUpdate(latestAvailable);
|
||||
}
|
||||
}
|
||||
|
||||
public void Execute(InstallUpdateCommand message)
|
||||
{
|
||||
InstallUpdate(message.UpdatePackage);
|
||||
message.Completed("Restarting Sonarr to apply updates");
|
||||
}
|
||||
catch (UpdateFolderNotWritableException ex)
|
||||
{
|
||||
_logger.ErrorException("Update process failed", ex);
|
||||
message.Failed(ex, string.Format("Startup folder not writable by user '{0}'", Environment.UserName));
|
||||
}
|
||||
catch (UpdateVerificationFailedException ex)
|
||||
{
|
||||
_logger.ErrorException("Update process failed", ex);
|
||||
message.Failed(ex, "Downloaded update package is corrupt");
|
||||
}
|
||||
catch (UpdateFailedException ex)
|
||||
{
|
||||
_logger.ErrorException("Update process failed", ex);
|
||||
message.Failed(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Core.Configuration;
|
||||
|
||||
namespace NzbDrone.Core.Update
|
||||
|
@ -23,7 +24,7 @@ namespace NzbDrone.Core.Update
|
|||
public List<UpdatePackage> GetRecentUpdatePackages()
|
||||
{
|
||||
var branch = _configFileProvider.Branch;
|
||||
return _updatePackageProvider.GetRecentUpdates(branch);
|
||||
return _updatePackageProvider.GetRecentUpdates(branch, BuildInfo.Version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
using NzbDrone.Common.Exceptions;
|
||||
|
||||
namespace NzbDrone.Core.Update
|
||||
{
|
||||
public class UpdateFailedException : NzbDroneException
|
||||
{
|
||||
public UpdateFailedException(string message, params object[] args)
|
||||
: base(message, args)
|
||||
{
|
||||
}
|
||||
|
||||
public UpdateFailedException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NLog;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Instrumentation.Extensions;
|
||||
|
@ -30,34 +31,7 @@ namespace NzbDrone.Core.Update
|
|||
|
||||
public UpdatePackage AvailableUpdate()
|
||||
{
|
||||
if (OsInfo.IsNotWindows && !_configFileProvider.UpdateAutomatically)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var latestAvailable = _updatePackageProvider.GetLatestUpdate(_configFileProvider.Branch, BuildInfo.Version);
|
||||
|
||||
if (latestAvailable == null)
|
||||
{
|
||||
_logger.ProgressDebug("No update available.");
|
||||
}
|
||||
else if (latestAvailable.Branch != _configFileProvider.Branch)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("Branch [{0}] is being redirected to [{1}]]", _configFileProvider.Branch, latestAvailable.Branch);
|
||||
var config = _configFileProvider.GetConfigDictionary();
|
||||
config["Branch"] = latestAvailable.Branch;
|
||||
_configFileProvider.SaveConfigDictionary(config);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.ErrorException("Couldn't save the branch redirect.", e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return latestAvailable;
|
||||
return _updatePackageProvider.GetLatestUpdate(_configFileProvider.Branch, BuildInfo.Version);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
using NzbDrone.Common.Exceptions;
|
||||
|
||||
namespace NzbDrone.Core.Update
|
||||
{
|
||||
public class UpdateFolderNotWritableException : UpdateFailedException
|
||||
{
|
||||
public UpdateFolderNotWritableException(string message, params object[] args)
|
||||
: base(message, args)
|
||||
{
|
||||
}
|
||||
|
||||
public UpdateFolderNotWritableException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ namespace NzbDrone.Core.Update
|
|||
public interface IUpdatePackageProvider
|
||||
{
|
||||
UpdatePackage GetLatestUpdate(string branch, Version currentVersion);
|
||||
List<UpdatePackage> GetRecentUpdates(string branch);
|
||||
List<UpdatePackage> GetRecentUpdates(string branch, Version currentVersion);
|
||||
}
|
||||
|
||||
public class UpdatePackageProvider : IUpdatePackageProvider
|
||||
|
@ -37,9 +37,10 @@ namespace NzbDrone.Core.Update
|
|||
return update.UpdatePackage;
|
||||
}
|
||||
|
||||
public List<UpdatePackage> GetRecentUpdates(string branch)
|
||||
public List<UpdatePackage> GetRecentUpdates(string branch, Version currentVersion)
|
||||
{
|
||||
var request = _requestBuilder.Build("/update/{branch}/changes");
|
||||
request.UriBuilder.SetQueryParam("version", currentVersion);
|
||||
request.UriBuilder.SetQueryParam("os", OsInfo.Os.ToString().ToLowerInvariant());
|
||||
request.AddSegment("branch", branch);
|
||||
|
||||
|
|
|
@ -2,14 +2,16 @@
|
|||
|
||||
namespace NzbDrone.Core.Update
|
||||
{
|
||||
public class UpdateVerificationFailedException : NzbDroneException
|
||||
public class UpdateVerificationFailedException : UpdateFailedException
|
||||
{
|
||||
public UpdateVerificationFailedException(string message, params object[] args) : base(message, args)
|
||||
public UpdateVerificationFailedException(string message, params object[] args)
|
||||
: base(message, args)
|
||||
{
|
||||
}
|
||||
|
||||
public UpdateVerificationFailedException(string message) : base(message)
|
||||
public UpdateVerificationFailedException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
using NUnit.Framework;
|
||||
using System;
|
||||
using Mono.Unix;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Test.DiskTests;
|
||||
|
||||
namespace NzbDrone.Mono.Test.DiskProviderTests
|
||||
|
@ -11,5 +13,26 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
|
|||
{
|
||||
MonoOnly();
|
||||
}
|
||||
|
||||
protected override void SetWritePermissions(string path, bool writable)
|
||||
{
|
||||
if (Environment.UserName == "root")
|
||||
{
|
||||
Assert.Inconclusive("Need non-root user to test write permissions.");
|
||||
}
|
||||
|
||||
// Remove Write permissions, we're still owner so we can clean it up, but we'll have to do that explicitly.
|
||||
|
||||
var entry = UnixFileSystemInfo.GetFileSystemEntry(path);
|
||||
|
||||
if (writable)
|
||||
{
|
||||
entry.FileAccessPermissions |= FileAccessPermissions.UserWrite | FileAccessPermissions.GroupWrite | FileAccessPermissions.OtherWrite;
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.FileAccessPermissions &= ~(FileAccessPermissions.UserWrite | FileAccessPermissions.GroupWrite | FileAccessPermissions.OtherWrite);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,6 +58,10 @@
|
|||
<SpecificVersion>False</SpecificVersion>
|
||||
<HintPath>..\packages\FluentAssertions.3.2.1\lib\net40\FluentAssertions.Core.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Mono.Posix, Version=4.0.0.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL">
|
||||
<SpecificVersion>False</SpecificVersion>
|
||||
<HintPath>..\Libraries\Mono.Posix.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="nunit.framework, Version=2.6.3.13283, Culture=neutral, PublicKeyToken=96d09a1eb7f44a77, processorArchitecture=MSIL">
|
||||
<SpecificVersion>False</SpecificVersion>
|
||||
<HintPath>..\packages\NUnit.2.6.3\lib\nunit.framework.dll</HintPath>
|
||||
|
|
|
@ -6,6 +6,7 @@ using Moq;
|
|||
using NLog;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Cache;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Messaging;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
using NUnit.Framework;
|
||||
using System.IO;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Test.DiskTests;
|
||||
|
||||
namespace NzbDrone.Windows.Test.DiskProviderTests
|
||||
|
@ -11,5 +14,26 @@ namespace NzbDrone.Windows.Test.DiskProviderTests
|
|||
{
|
||||
WindowsOnly();
|
||||
}
|
||||
|
||||
protected override void SetWritePermissions(string path, bool writable)
|
||||
{
|
||||
// Remove Write permissions, we're owner and have Delete permissions, so we can still clean it up.
|
||||
|
||||
var owner = WindowsIdentity.GetCurrent().Owner;
|
||||
var accessControlType = writable ? AccessControlType.Allow : AccessControlType.Deny;
|
||||
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
var ds = Directory.GetAccessControl(path);
|
||||
ds.SetAccessRule(new FileSystemAccessRule(owner, FileSystemRights.Write, accessControlType));
|
||||
Directory.SetAccessControl(path, ds);
|
||||
}
|
||||
else
|
||||
{
|
||||
var fs = File.GetAccessControl(path);
|
||||
fs.SetAccessRule(new FileSystemAccessRule(owner, FileSystemRights.Write, accessControlType));
|
||||
File.SetAccessControl(path, fs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,12 +15,15 @@ module.exports = Marionette.ItemView.extend({
|
|||
id : this.model.id,
|
||||
hideAfter : 0
|
||||
};
|
||||
|
||||
var isManual = this.model.get('manual');
|
||||
|
||||
switch (this.model.get('state')) {
|
||||
case 'completed':
|
||||
message.hideAfter = 4;
|
||||
break;
|
||||
case 'failed':
|
||||
message.hideAfter = 4;
|
||||
message.hideAfter = isManual ? 10 : 4;
|
||||
message.type = 'error';
|
||||
break;
|
||||
default:
|
||||
|
|
|
@ -12,21 +12,21 @@ var StatusModel = require('./StatusModel');
|
|||
module.exports = Marionette.Layout.extend({
|
||||
template : 'System/SystemLayoutTemplate',
|
||||
regions : {
|
||||
info : '#info',
|
||||
status : '#status',
|
||||
logs : '#logs',
|
||||
updates : '#updates',
|
||||
backup : '#backup',
|
||||
tasks : '#tasks'
|
||||
},
|
||||
ui : {
|
||||
infoTab : '.x-info-tab',
|
||||
statusTab : '.x-status-tab',
|
||||
logsTab : '.x-logs-tab',
|
||||
updatesTab : '.x-updates-tab',
|
||||
backupTab : '.x-backup-tab',
|
||||
tasksTab : '.x-tasks-tab'
|
||||
},
|
||||
events : {
|
||||
'click .x-info-tab' : '_showInfo',
|
||||
'click .x-status-tab' : '_showStatus',
|
||||
'click .x-logs-tab' : '_showLogs',
|
||||
'click .x-updates-tab' : '_showUpdates',
|
||||
'click .x-backup-tab' : '_showBackup',
|
||||
|
@ -58,7 +58,7 @@ module.exports = Marionette.Layout.extend({
|
|||
this._showTasks();
|
||||
break;
|
||||
default:
|
||||
this._showInfo();
|
||||
this._showStatus();
|
||||
}
|
||||
},
|
||||
_navigate : function(route){
|
||||
|
@ -67,13 +67,14 @@ module.exports = Marionette.Layout.extend({
|
|||
replace : true
|
||||
});
|
||||
},
|
||||
_showInfo : function(e){
|
||||
if(e) {
|
||||
_showStatus : function (e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
this.info.show(new SystemInfoLayout());
|
||||
this.ui.infoTab.tab('show');
|
||||
this._navigate('system/info');
|
||||
|
||||
this.status.show(new SystemInfoLayout());
|
||||
this.ui.statusTab.tab('show');
|
||||
this._navigate('system/status');
|
||||
},
|
||||
_showLogs : function(e){
|
||||
if(e) {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<ul class="nav nav-tabs">
|
||||
<li><a href="#info" class="x-info-tab no-router">Info</a></li>
|
||||
<li><a href="#logs" class="x-logs-tab no-router">Logs</a></li>
|
||||
<li><a href="#status" class="x-status-tab no-router">Status</a></li>
|
||||
<li><a href="#updates" class="x-updates-tab no-router">Updates</a></li>
|
||||
<li><a href="#backup" class="x-backup-tab no-router">Backup</a></li>
|
||||
<li><a href="#tasks" class="x-tasks-tab no-router">Tasks</a></li>
|
||||
<li><a href="#backup" class="x-backup-tab no-router">Backup</a></li>
|
||||
<li><a href="#logs" class="x-logs-tab no-router">Logs</a></li>
|
||||
<li class="lifecycle-controls pull-right">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-default btn-icon-only x-shutdown" title="Shutdown" data-container="body">
|
||||
|
@ -23,9 +23,9 @@
|
|||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane" id="info"></div>
|
||||
<div class="tab-pane" id="logs"></div>
|
||||
<div class="tab-pane" id="status"></div>
|
||||
<div class="tab-pane" id="updates"></div>
|
||||
<div class="tab-pane" id="backup"></div>
|
||||
<div class="tab-pane" id="tasks"></div>
|
||||
<div class="tab-pane" id="backup"></div>
|
||||
<div class="tab-pane" id="logs"></div>
|
||||
</div>
|
|
@ -13,7 +13,7 @@ module.exports = Marionette.ItemView.extend({
|
|||
}
|
||||
this.updating = true;
|
||||
var self = this;
|
||||
var promise = CommandController.Execute('installUpdate', {updatePackage : this.model.toJSON()});
|
||||
var promise = CommandController.Execute('applicationUpdate');
|
||||
promise.done(function(){
|
||||
window.setTimeout(function(){
|
||||
self.updating = false;
|
||||
|
|
|
@ -5,14 +5,17 @@
|
|||
- {{ShortDate releaseDate}}
|
||||
</span>
|
||||
<span class="status">
|
||||
{{#unless_eq branch compare="master"}}
|
||||
<span class="label label-default">{{branch}}</span>
|
||||
{{/unless_eq}}
|
||||
{{#if installed}}
|
||||
<span class="label label-success">Installed</span>
|
||||
{{else}}
|
||||
{{#if latest}}
|
||||
{{#if installable}}
|
||||
<span class="label label-default install-update x-install-update">Install</span>
|
||||
<span class="label label-info install-update x-install-update">Install Latest</span>
|
||||
{{else}}
|
||||
<span class="label label-default label-disabled" title="Cannot install an older version">Install</span>
|
||||
<span class="label label-info label-disabled" title="Cannot install an older version">Install Latest</span>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
|
Loading…
Reference in New Issue