Allow BYOND downloads to be configurable

This commit is contained in:
Jordan Dominion
2025-06-23 21:35:28 -04:00
parent c99208b823
commit fb2b15134f
6 changed files with 137 additions and 27 deletions
@@ -6,8 +6,10 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Jobs;
@@ -41,6 +43,11 @@ namespace Tgstation.Server.Host.Components.Engine
/// <inheritdoc />
protected override EngineType TargetEngineType => EngineType.Byond;
/// <summary>
/// The <see cref="GeneralConfiguration"/> <see cref="IOptionsMonitor{TOptions}"/> for the <see cref="ByondInstallerBase"/>.
/// </summary>
protected IOptionsMonitor<GeneralConfiguration> GeneralConfigurationOptions { get; }
/// <summary>
/// Path to the system user's local BYOND folder.
/// </summary>
@@ -52,25 +59,85 @@ namespace Tgstation.Server.Host.Components.Engine
protected abstract string DreamMakerName { get; }
/// <summary>
/// Gets the URL formatter string for downloading a byond version of {0:Major} {1:Minor}.
/// Template to do ${Marker:xxx} replacements in <see cref="GeneralConfiguration.ByondZipDownloadTemplate"/>.
/// </summary>
protected abstract string ByondRevisionsUrlTemplate { get; }
protected abstract string OSMarkerTemplate { get; }
/// <summary>
/// The <see cref="IFileDownloader"/> for the <see cref="ByondInstallerBase"/>.
/// </summary>
readonly IFileDownloader fileDownloader;
/// <summary>
/// Format a given <paramref name="byondZipDownloadTemplate"/>.
/// </summary>
/// <param name="semver">The BYOND version to download.</param>
/// <param name="byondZipDownloadTemplate">The template.</param>
/// <param name="osMarkerTemplate">The <see cref="OSMarkerTemplate"/>.</param>
/// <returns>The formatted byond download <see cref="Uri"/>.</returns>
/// <remarks>Exposed only for testability.</remarks>
internal static Uri GetDownloadZipUrl(Version semver, string byondZipDownloadTemplate, string osMarkerTemplate)
{
// god forbid
var guardGuid = Guid.NewGuid();
var url = byondZipDownloadTemplate
.Replace("$$", guardGuid.ToString(), StringComparison.Ordinal)
.Replace("${Major}", semver.Major.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)
.Replace("${Minor}", semver.Minor.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal);
var osMarkerPrefix = $"${{{osMarkerTemplate}:";
var osMarkerIndex = url.IndexOf(osMarkerPrefix);
while (osMarkerIndex != -1)
{
var start = osMarkerIndex + osMarkerPrefix.Length;
var end = url.IndexOf('}', start);
if (end == -1)
break;
var substitution = url.Substring(start, end - start);
url = url.Replace($"{osMarkerPrefix}{substitution}}}", substitution, StringComparison.Ordinal);
osMarkerIndex = url.IndexOf(osMarkerPrefix);
}
// at this point, any other substitution attempts should be removed
var otherMarkerPrefix = "${";
var otherMarkerIndex = url.IndexOf(otherMarkerPrefix);
while (otherMarkerIndex != -1)
{
var start = otherMarkerIndex + otherMarkerPrefix.Length;
var end = url.IndexOf('}', start);
if (end == -1)
break;
var substitution = url.Substring(start, end - start);
url = url.Replace($"{otherMarkerPrefix}{substitution}}}", String.Empty, StringComparison.Ordinal);
otherMarkerIndex = url.IndexOf(otherMarkerPrefix);
}
url = url.Replace(guardGuid.ToString(), "$", StringComparison.Ordinal);
return new Uri(url);
}
/// <summary>
/// Initializes a new instance of the <see cref="ByondInstallerBase"/> class.
/// </summary>
/// <param name="ioManager">The <see cref="IIOManager"/> for the <see cref="EngineInstallerBase"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="EngineInstallerBase"/>.</param>
/// <param name="fileDownloader">The value of <see cref="fileDownloader"/>.</param>
protected ByondInstallerBase(IIOManager ioManager, ILogger<ByondInstallerBase> logger, IFileDownloader fileDownloader)
/// <param name="generalConfigurationOptions">The value of <see cref="GeneralConfigurationOptions"/>.</param>
protected ByondInstallerBase(
IIOManager ioManager,
ILogger<ByondInstallerBase> logger,
IFileDownloader fileDownloader,
IOptionsMonitor<GeneralConfiguration> generalConfigurationOptions)
: base(ioManager, logger)
{
this.fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader));
GeneralConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
}
/// <inheritdoc />
@@ -195,8 +262,12 @@ namespace Tgstation.Server.Host.Components.Engine
Uri GetDownloadZipUrl(EngineVersion version)
{
CheckVersionValidity(version);
var url = String.Format(CultureInfo.InvariantCulture, ByondRevisionsUrlTemplate, version.Version!.Major, version.Version.Minor);
return new Uri(url);
var guardGuid = Guid.NewGuid();
var semver = version.Version!;
var template = GeneralConfigurationOptions.CurrentValue.ByondZipDownloadTemplate;
return GetDownloadZipUrl(semver, template, OSMarkerTemplate);
}
}
}
@@ -5,9 +5,11 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Common.Extensions;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.IO;
namespace Tgstation.Server.Host.Components.Engine
@@ -39,7 +41,7 @@ namespace Tgstation.Server.Host.Components.Engine
protected override string DreamMakerName => DreamMakerExecutableName + ShellScriptExtension;
/// <inheritdoc />
protected override string ByondRevisionsUrlTemplate => "https://www.byond.com/download/build/{0}/{0}.{1}_byond_linux.zip";
protected override string OSMarkerTemplate => "Linux";
/// <summary>
/// The <see cref="IPostWriteHandler"/> for the <see cref="PosixByondInstaller"/>.
@@ -52,13 +54,15 @@ namespace Tgstation.Server.Host.Components.Engine
/// <param name="postWriteHandler">The value of <see cref="postWriteHandler"/>.</param>
/// <param name="ioManager">The <see cref="IIOManager"/> for the <see cref="ByondInstallerBase"/>.</param>
/// <param name="fileDownloader">The <see cref="IFileDownloader"/> for the <see cref="ByondInstallerBase"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="GeneralConfiguration"/> <see cref="IOptionsMonitor{TOptions}"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ByondInstallerBase"/>.</param>
public PosixByondInstaller(
IPostWriteHandler postWriteHandler,
IIOManager ioManager,
IFileDownloader fileDownloader,
IOptionsMonitor<GeneralConfiguration> generalConfigurationOptions,
ILogger<PosixByondInstaller> logger)
: base(ioManager, logger, fileDownloader)
: base(ioManager, logger, fileDownloader, generalConfigurationOptions)
{
this.postWriteHandler = postWriteHandler ?? throw new ArgumentNullException(nameof(postWriteHandler));
@@ -69,18 +69,13 @@ namespace Tgstation.Server.Host.Components.Engine
protected override string PathToUserFolder { get; }
/// <inheritdoc />
protected override string ByondRevisionsUrlTemplate => "https://www.byond.com/download/build/{0}/{0}.{1}_byond.zip";
protected override string OSMarkerTemplate => "Windows";
/// <summary>
/// The <see cref="IProcessExecutor"/> for the <see cref="WindowsByondInstaller"/>.
/// </summary>
readonly IProcessExecutor processExecutor;
/// <summary>
/// The <see cref="GeneralConfiguration"/> for the <see cref="WindowsByondInstaller"/>.
/// </summary>
readonly GeneralConfiguration generalConfiguration;
/// <summary>
/// The <see cref="SessionConfiguration"/> for the <see cref="WindowsByondInstaller"/>.
/// </summary>
@@ -100,7 +95,7 @@ namespace Tgstation.Server.Host.Components.Engine
/// Initializes a new instance of the <see cref="WindowsByondInstaller"/> class.
/// </summary>
/// <param name="processExecutor">The value of <see cref="processExecutor"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptionsMonitor{TOptions}"/> containing the <see cref="GeneralConfiguration"/>.</param>
/// <param name="sessionConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="sessionConfiguration"/>.</param>
/// <param name="ioManager">The <see cref="IIOManager"/> for the <see cref="ByondInstallerBase"/>.</param>
/// <param name="fileDownloader">The <see cref="IFileDownloader"/> for the <see cref="ByondInstallerBase"/>.</param>
@@ -109,13 +104,12 @@ namespace Tgstation.Server.Host.Components.Engine
IProcessExecutor processExecutor,
IIOManager ioManager,
IFileDownloader fileDownloader,
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptionsMonitor<GeneralConfiguration> generalConfigurationOptions,
IOptions<SessionConfiguration> sessionConfigurationOptions,
ILogger<WindowsByondInstaller> logger)
: base(ioManager, logger, fileDownloader)
: base(ioManager, logger, fileDownloader, generalConfigurationOptions)
{
this.processExecutor = processExecutor ?? throw new ArgumentNullException(nameof(processExecutor));
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
sessionConfiguration = sessionConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(sessionConfigurationOptions));
var useServiceSpecialTactics = Environment.Is64BitProcess && Environment.UserName == $"{Environment.MachineName}$";
@@ -150,7 +144,7 @@ namespace Tgstation.Server.Host.Components.Engine
installDirectXTask,
};
if (!generalConfiguration.SkipAddingByondFirewallException)
if (!GeneralConfigurationOptions.CurrentValue.SkipAddingByondFirewallException)
{
var firewallTask = AddDreamDaemonToFirewall(version, path, deploymentPipelineProcesses, cancellationToken);
tasks.Add(firewallTask);
@@ -165,7 +159,7 @@ namespace Tgstation.Server.Host.Components.Engine
CheckVersionValidity(version);
ArgumentNullException.ThrowIfNull(path);
if (generalConfiguration.SkipAddingByondFirewallException)
if (GeneralConfigurationOptions.CurrentValue.SkipAddingByondFirewallException)
return;
if (version.Version < DDExeVersion)
@@ -148,6 +148,17 @@ namespace Tgstation.Server.Host.Configuration
[YamlMember(SerializeAs = typeof(string))]
public Uri OpenDreamGitUrl { get; set; } = new Uri(DefaultOpenDreamGitUrl);
/// <summary>
/// The formatter used to download official byond zip files for a given version
/// - ${Major} is substituted with the major version number
/// - ${Minor} is substituted with the minor version number
/// - ${Linux:xxx}, where xxx is any string, will be substituted with xxx if running under Linux.
/// - ${Windows:xxx}, where xxx is any string, will be substituted with xxx if running under Windows.
/// - $$ will evaluate to a literal $ and not be used for substitutions.
/// - Any inapplicable ${xxx} string will be removed.
/// </summary>
public string ByondZipDownloadTemplate { get; set; } = "https://www.byond.com/download/build/${Major}/${Major}.{Minor}_byond${Linux:_linux}.zip";
/// <summary>
/// The prefix to the OpenDream semver as tags appear in the git repository.
/// </summary>
@@ -0,0 +1,23 @@
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Tgstation.Server.Host.Components.Engine.Tests
{
[TestClass]
public sealed class TestByondInstallerBase
{
[TestMethod]
public void TestUrlTemplateFormatting()
{
const string OSMarker = "TempleOS";
Assert.AreEqual(
new Uri("https://example.com/$515.1111_Hello Worl$d.zip"),
ByondInstallerBase.GetDownloadZipUrl(
new Version(515, 1111),
"https://example.com/$$${Major}.${Minor}_${TempleOS:Hello Worl$$d}.zip${Linux:Not this}${Or This}",
OSMarker));
}
}
}
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System;
@@ -9,6 +10,7 @@ using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.IO;
namespace Tgstation.Server.Host.Components.Engine.Tests
@@ -19,16 +21,18 @@ namespace Tgstation.Server.Host.Components.Engine.Tests
[TestMethod]
public void TestConstruction()
{
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(null, null, null, null, null));
var mockPostWriteHandler = new Mock<IPostWriteHandler>();
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(mockPostWriteHandler.Object, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(mockPostWriteHandler.Object, null, null, null, null));
var mockIOManager = new Mock<IIOManager>();
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, null, null));
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, null, null, null));
var mockFileDownloader = Mock.Of<IFileDownloader>();
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, null));
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, null, null));
var mockOptions = Mock.Of<IOptionsMonitor<GeneralConfiguration>>();
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, mockOptions, null));
var mockLogger = new Mock<ILogger<PosixByondInstaller>>();
_ = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, mockLogger.Object);
_ = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, mockOptions, mockLogger.Object);
}
[TestMethod]
@@ -38,7 +42,8 @@ namespace Tgstation.Server.Host.Components.Engine.Tests
var mockIOManager = new Mock<IIOManager>();
var mockLogger = new Mock<ILogger<PosixByondInstaller>>();
var mockFileDownloader = Mock.Of<IFileDownloader>();
var installer = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, mockLogger.Object);
var mockOptions = Mock.Of<IOptionsMonitor<GeneralConfiguration>>();
var installer = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, mockOptions, mockLogger.Object);
await installer.CleanCache(default);
}
@@ -50,7 +55,8 @@ namespace Tgstation.Server.Host.Components.Engine.Tests
var mockPostWriteHandler = new Mock<IPostWriteHandler>();
var mockLogger = new Mock<ILogger<PosixByondInstaller>>();
var mockFileDownloader = new Mock<IFileDownloader>();
var installer = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader.Object, mockLogger.Object);
var mockOptions = Mock.Of<IOptionsMonitor<GeneralConfiguration>>();
var installer = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader.Object, mockOptions, mockLogger.Object);
await Assert.ThrowsExceptionAsync<ArgumentNullException>(() => installer.DownloadVersion(null, null, default).AsTask());
@@ -87,7 +93,8 @@ namespace Tgstation.Server.Host.Components.Engine.Tests
var mockPostWriteHandler = new Mock<IPostWriteHandler>();
var mockLogger = new Mock<ILogger<PosixByondInstaller>>();
var mockFileDownloader = Mock.Of<IFileDownloader>();
var installer = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, mockLogger.Object);
var mockOptions = Mock.Of<IOptionsMonitor<GeneralConfiguration>>();
var installer = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockFileDownloader, mockOptions, mockLogger.Object);
const string FakePath = "fake";
await Assert.ThrowsExceptionAsync<ArgumentNullException>(() => installer.Install(null, null, false, default).AsTask());