Add support for installing custom BYOND versions

This commit is contained in:
Jordan Brown
2020-06-26 16:09:59 -04:00
parent 0d475287d7
commit 5372b74fd0
8 changed files with 168 additions and 36 deletions
+2
View File
@@ -265,6 +265,8 @@ To set the active Byond version:
I POST "/Byond" @ref Tgstation.Server.Api.Models.Byond => @ref Tgstation.Server.Api.Models.Byond
This will queue up a job to install the byond version if it doesn't exist which will be returned in the response. The @ref Tgstation.Server.Api.Models.Internal.RawData.Content field can be used to upload custom zip files.
To list all installed Byond versions use the following request:
I GET "/Byond/List" => Array of @ref Tgstation.Server.Api.Models.Byond
+3 -2
View File
@@ -1,11 +1,12 @@
using System;
using Tgstation.Server.Api.Models.Internal;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Represents a BYOND installation
/// Represents a BYOND installation. <see cref="RawData.Content"/> is used to upload custom BYOND version zip files, though <see cref="Version"/> must still be set.
/// </summary>
public sealed class Byond
public sealed class Byond : RawData
{
/// <summary>
/// The <see cref="System.Version"/> of the <see cref="Byond"/> installation used for new compiles. Will be <see langword="null"/> if the user does not have permission to view it or there is no BYOND version installed. Only considers the <see cref="Version.Major"/> and <see cref="Version.Minor"/> numbers.
@@ -537,5 +537,11 @@ namespace Tgstation.Server.Api.Models
/// </summary>
[Description("Test merging cannot be performed with this remote!")]
RepoTestMergeInvalidRemote,
/// <summary>
/// Attempted to switch to a custom BYOND version that does not exist.
/// </summary>
[Description("Cannot switch to requested custom BYOND version as it is not currently installed.")]
ByondNonExistentCustomVersion,
}
}
@@ -34,12 +34,12 @@ namespace Tgstation.Server.Host.Components.Byond
const string TrustedDmbFileName = "trusted.txt";
/// <summary>
/// The file in which we store the <see cref="VersionKey(Version)"/> for installations
/// The file in which we store the <see cref="VersionKey(Version, bool)"/> for installations
/// </summary>
const string VersionFileName = "Version.txt";
/// <summary>
/// The file in which we store the <see cref="VersionKey(Version)"/> for the active installation
/// The file in which we store the <see cref="VersionKey(Version, bool)"/> for the active installation
/// </summary>
const string ActiveVersionFileName = "ActiveVersion.txt";
@@ -89,9 +89,12 @@ namespace Tgstation.Server.Host.Components.Byond
/// <summary>
/// Converts a BYOND <paramref name="version"/> to a <see cref="string"/>
/// </summary>
/// <param name="version">The <see cref="Version"/> to convert</param>
/// <param name="version">The <see cref="Version"/> to convert.</param>
/// <param name="allowPatch">If the <see cref="Version.Build"/> property of <paramref name="version"/> should be kept.</param>
/// <returns>The <see cref="string"/> representation of <paramref name="version"/></returns>
static string VersionKey(Version version) => new Version(version.Major, version.Minor).ToString();
static string VersionKey(Version version, bool allowPatch) => (allowPatch && version.Build > 0
? new Version(version.Major, version.Minor, version.Build)
: new Version(version.Major, version.Minor)).ToString();
/// <summary>
/// Construct a <see cref="ByondManager"/>
@@ -118,17 +121,29 @@ namespace Tgstation.Server.Host.Components.Byond
/// Installs a BYOND <paramref name="version"/> if it isn't already
/// </summary>
/// <param name="version">The BYOND <see cref="Version"/> to install</param>
/// <param name="versionZipBytes">Custom zip file bytes to use. Will cause a <see cref="Version.Build"/> number to be added.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
async Task InstallVersion(Version version, CancellationToken cancellationToken)
async Task<string> InstallVersion(Version version, byte[] versionZipBytes, CancellationToken cancellationToken)
{
var ourTcs = new TaskCompletionSource<object>();
Task inProgressTask;
var versionKey = VersionKey(version);
string versionKey;
bool installed;
lock (installedVersions)
{
if (versionZipBytes != null)
{
int customInstallationNumber = 1;
do
{
versionKey = $"{VersionKey(version, false)}.{customInstallationNumber++}";
}
while (installedVersions.ContainsKey(versionKey));
}
else
versionKey = VersionKey(version, true);
installed = installedVersions.TryGetValue(versionKey, out inProgressTask);
if (!installed)
installedVersions.Add(versionKey, ourTcs.Task);
@@ -139,8 +154,13 @@ namespace Tgstation.Server.Host.Components.Byond
{
await Task.WhenAny(ourTcs.Task, inProgressTask).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return;
return versionKey;
}
if (versionZipBytes != null)
logger.LogInformation("Installing custom BYOND version as {0}...", versionKey);
else if (version.Build > 0)
throw new JobException(ErrorCode.ByondNonExistentCustomVersion);
else
logger.LogDebug("Requested BYOND version {0} not currently installed. Doing so now...");
@@ -148,18 +168,22 @@ namespace Tgstation.Server.Host.Components.Byond
try
{
await eventConsumer.HandleEvent(EventType.ByondInstallStart, new List<string> { versionKey }, cancellationToken).ConfigureAwait(false);
var downloadTask = byondInstaller.DownloadVersion(version, cancellationToken);
var zipFileBytesTask = versionZipBytes == null
? byondInstaller.DownloadVersion(version, cancellationToken)
: Task.FromResult(versionZipBytes);
await ioManager.DeleteDirectory(versionKey, cancellationToken).ConfigureAwait(false);
try
{
var download = await downloadTask.ConfigureAwait(false);
versionZipBytes = await zipFileBytesTask.ConfigureAwait(false);
await ioManager.CreateDirectory(versionKey, cancellationToken).ConfigureAwait(false);
var extractPath = ioManager.ResolvePath(versionKey);
logger.LogTrace("Extracting downloaded BYOND zip to {0}...", extractPath);
await ioManager.ZipToDirectory(extractPath, download, cancellationToken).ConfigureAwait(false);
await ioManager.ZipToDirectory(extractPath, versionZipBytes, cancellationToken).ConfigureAwait(false);
versionZipBytes = null;
await byondInstaller.InstallByond(extractPath, version, cancellationToken).ConfigureAwait(false);
// make sure to do this last because this is what tells us we have a valid version in the future
@@ -191,20 +215,34 @@ namespace Tgstation.Server.Host.Components.Byond
ourTcs.SetException(e);
throw;
}
return versionKey;
}
/// <inheritdoc />
public async Task ChangeVersion(Version version, CancellationToken cancellationToken)
public async Task ChangeVersion(Version version, byte[] customVersionBytes, CancellationToken cancellationToken)
{
if (version == null)
throw new ArgumentNullException(nameof(version));
var versionKey = VersionKey(version);
await InstallVersion(version, cancellationToken).ConfigureAwait(false);
var versionKey = await InstallVersion(version, customVersionBytes, cancellationToken).ConfigureAwait(false);
using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false))
{
await ioManager.WriteAllBytes(ActiveVersionFileName, Encoding.UTF8.GetBytes(versionKey), cancellationToken).ConfigureAwait(false);
await eventConsumer.HandleEvent(EventType.ByondActiveVersionChange, new List<string> { ActiveVersion != null ? VersionKey(ActiveVersion) : null, versionKey }, cancellationToken).ConfigureAwait(false);
ActiveVersion = version;
await eventConsumer.HandleEvent(
EventType.ByondActiveVersionChange,
new List<string>
{
ActiveVersion != null
? VersionKey(ActiveVersion, true)
: null,
versionKey
},
cancellationToken)
.ConfigureAwait(false);
// We reparse the version key because it could be changed after a custom install.
ActiveVersion = Version.Parse(versionKey);
}
}
@@ -214,9 +252,9 @@ namespace Tgstation.Server.Host.Components.Byond
var versionToUse = requiredVersion ?? ActiveVersion;
if (versionToUse == null)
throw new JobException(ErrorCode.ByondNoVersionsInstalled);
await InstallVersion(versionToUse, cancellationToken).ConfigureAwait(false);
await InstallVersion(versionToUse, null, cancellationToken).ConfigureAwait(false);
var versionKey = VersionKey(versionToUse);
var versionKey = VersionKey(versionToUse, true);
var binPathForVersion = ioManager.ConcatPath(versionKey, BinPath);
logger.LogTrace("Creating ByondExecutableLock lock for version {0}", requiredVersion);
@@ -286,7 +324,7 @@ namespace Tgstation.Server.Host.Components.Byond
var text = Encoding.UTF8.GetString(bytes);
if (Version.TryParse(text, out var version))
{
var key = VersionKey(version);
var key = VersionKey(version, true);
lock (installedVersions)
if (!installedVersions.ContainsKey(key))
{
@@ -25,9 +25,10 @@ namespace Tgstation.Server.Host.Components.Byond
/// Change the active BYOND version
/// </summary>
/// <param name="version">The new <see cref="Version"/></param>
/// <param name="customVersionBytes">Optional <see cref="byte"/>s of a custom BYOND version zip file.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task ChangeVersion(Version version, CancellationToken cancellationToken);
Task ChangeVersion(Version version, byte[] customVersionBytes, CancellationToken cancellationToken);
/// <summary>
/// Lock the current installation's location and return a <see cref="IByondExecutableLock"/>
@@ -68,11 +68,16 @@ namespace Tgstation.Server.Host.Controllers
[HttpGet(Routes.List)]
[TgsAuthorize(ByondRights.ListInstalled)]
[ProducesResponseType(typeof(IEnumerable<Api.Models.Byond>), 200)]
public Task<IActionResult> List() => Task.FromResult<IActionResult>(
Json(instanceManager.GetInstance(Instance).ByondManager.InstalledVersions.Select(x => new Api.Models.Byond
{
Version = x
})));
public IActionResult List()
=> Json(
instanceManager
.GetInstance(Instance)
.ByondManager
.InstalledVersions
.Select(x => new Api.Models.Byond
{
Version = x
}));
/// <summary>
/// Changes the active BYOND version to the one specified in a given <paramref name="model"/>.
@@ -91,20 +96,33 @@ namespace Tgstation.Server.Host.Controllers
if (model == null)
throw new ArgumentNullException(nameof(model));
if (model.Version == null
|| model.Version.Revision != -1
|| (model.Content != null && model.Version.Build > 0))
return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure));
var byondManager = instanceManager.GetInstance(Instance).ByondManager;
// remove cruff fields
var installingVersion = new Version(model.Version.Major, model.Version.Minor);
var result = new Api.Models.Byond();
if (byondManager.InstalledVersions.Any(x => x == model.Version))
if (model.Content == null && byondManager.InstalledVersions.Any(x => x == model.Version))
{
Logger.LogInformation("User ID {0} changing instance ID {1} BYOND version to {2}", AuthenticationContext.User.Id, Instance.Id, installingVersion);
await byondManager.ChangeVersion(model.Version, cancellationToken).ConfigureAwait(false);
Logger.LogInformation(
"User ID {0} changing instance ID {1} BYOND version to {2}",
AuthenticationContext.User.Id,
Instance.Id,
model.Version);
await byondManager.ChangeVersion(model.Version, null, cancellationToken).ConfigureAwait(false);
}
else if (model.Version.Build > 0)
return BadRequest(new ErrorMessage(ErrorCode.ByondNonExistentCustomVersion));
else
{
var installingVersion = model.Version.Build <= 0
? new Version(model.Version.Major, model.Version.Minor)
: model.Version;
Logger.LogInformation(
"User ID {0} installing BYOND version to {1} on instance ID {2}",
AuthenticationContext.User.Id,
@@ -114,13 +132,20 @@ namespace Tgstation.Server.Host.Controllers
// run the install through the job manager
var job = new Models.Job
{
Description = $"Install BYOND version {installingVersion}",
Description = $"Install BYOND version {model.Version}",
StartedBy = AuthenticationContext.User,
CancelRightsType = RightsType.Byond,
CancelRight = (ulong)ByondRights.CancelInstall,
Instance = Instance
};
await jobManager.RegisterOperation(job, (paramJob, databaseContextFactory, progressHandler, ct) => byondManager.ChangeVersion(installingVersion, ct), cancellationToken).ConfigureAwait(false);
await jobManager.RegisterOperation(
job,
(paramJob, databaseContextFactory, progressHandler, jobCancellationToken) => byondManager.ChangeVersion(
model.Version,
model.Content,
jobCancellationToken),
cancellationToken)
.ConfigureAwait(false);
result.InstallJob = job.ToApi();
}
@@ -1,16 +1,25 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Castle.Core.Logging;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Client;
using Tgstation.Server.Client.Components;
using Tgstation.Server.Host.Components.Byond;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Tests.Instance
{
sealed class ByondTest : JobsRequiredTest
{
public static readonly Version TestVersion = new Version(513, 1526);
readonly IByondClient byondClient;
readonly Api.Models.Instance metadata;
@@ -27,6 +36,7 @@ namespace Tgstation.Server.Tests.Instance
await TestNoVersion(cancellationToken).ConfigureAwait(false);
await TestInstallStable(cancellationToken).ConfigureAwait(false);
await TestInstallFakeVersion(cancellationToken).ConfigureAwait(false);
await TestCustomInstalls(cancellationToken);
}
async Task TestInstallFakeVersion(CancellationToken cancellationToken)
@@ -44,7 +54,7 @@ namespace Tgstation.Server.Tests.Instance
{
var newModel = new Api.Models.Byond
{
Version = new Version(513, 1514)
Version = TestVersion
};
var test = await byondClient.SetActiveVersion(newModel, cancellationToken).ConfigureAwait(false);
Assert.IsNotNull(test.InstallJob);
@@ -74,5 +84,48 @@ namespace Tgstation.Server.Tests.Instance
Assert.IsNotNull(otherShit);
Assert.AreEqual(0, otherShit.Count);
}
async Task TestCustomInstalls(CancellationToken cancellationToken)
{
var byondInstaller = new PlatformIdentifier().IsWindows
? (IByondInstaller)new WindowsByondInstaller(
Mock.Of<IProcessExecutor>(),
new DefaultIOManager(),
Mock.Of<ILogger<WindowsByondInstaller>>())
: new PosixByondInstaller(
Mock.Of<IPostWriteHandler>(),
new DefaultIOManager(),
Mock.Of<ILogger<PosixByondInstaller>>());
// get the bytes for stable
var test = await byondClient.SetActiveVersion(new Api.Models.Byond
{
Version = TestVersion,
Content = await byondInstaller.DownloadVersion(TestVersion, cancellationToken)
}, cancellationToken).ConfigureAwait(false);
Assert.IsNotNull(test.InstallJob);
await WaitForJob(test.InstallJob, 60, false, cancellationToken).ConfigureAwait(false);
var newSettings = await byondClient.ActiveVersion(cancellationToken);
Assert.AreEqual(new Version(TestVersion.Major, TestVersion.Minor, 1), newSettings.Version);
// test a few switches
newSettings = await byondClient.SetActiveVersion(new Api.Models.Byond
{
Version = TestVersion
}, cancellationToken);
Assert.IsNull(newSettings.InstallJob);
await ApiAssert.ThrowsException<ApiConflictException>(() => byondClient.SetActiveVersion(new Api.Models.Byond
{
Version = new Version(TestVersion.Major, TestVersion.Minor, 2)
}, cancellationToken), ErrorCode.ByondNonExistentCustomVersion);
newSettings = await byondClient.SetActiveVersion(new Api.Models.Byond
{
Version = new Version(TestVersion.Major, TestVersion.Minor, 1)
}, cancellationToken);
Assert.IsNull(newSettings.InstallJob);
}
}
}
@@ -48,6 +48,12 @@ namespace Tgstation.Server.Tests.Instance
await RunBasicTest(cancellationToken);
// That was using the custom BYOND version, let's switch back to a regular one now
await instanceClient.Byond.SetActiveVersion(new Api.Models.Byond
{
Version = ByondTest.TestVersion
}, cancellationToken);
// await RunLongRunningTestThenUpdate(cancellationToken);
// await RunLongRunningTestThenUpdateWithByondVersionSwitch(cancellationToken);
// Remove this deploy when the above tests are reenabled