Merge pull request #1683 from tgstation/DMAPIValidateTimeoutFix [TGSDeploy]

v5.16.4: Fix static files not working on Linux

Fixes #1686
This commit is contained in:
Jordan Dominion
2023-10-22 18:47:53 -04:00
committed by GitHub
14 changed files with 260 additions and 33 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: "Check PR Has Milestone"
on:
pull_request:
types: [ opened, edited, synchronize, reopened ]
types: [ opened, edited, synchronize, reopened, labeled ]
branches:
- dev
- master
+1 -1
View File
@@ -1771,5 +1771,5 @@ jobs:
- name: Run ReleaseNotes with --link-winget
shell: powershell
run: |
Sleep 15
Sleep 600
dotnet run -c Release --no-build --project tools/Tgstation.Server.ReleaseNotes --link-winget ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+25 -6
View File
@@ -401,14 +401,15 @@ Once complete, test that your configuration worked by visiting your proxy site f
#### Caddy (Reccommended for Linux, or those unfamilar with configuring NGINX or Apache)
1. Setup a basic website configuration. Instructions on how to do so are out of scope.
2. In your Caddyfile, under a server entry, add the following (replace 8080 with the port TGS is hosted on):
2. In your Caddyfile, under a server entry, add the following (replace 5000 with the port TGS is hosted on):
```
proxy /tgs localhost:8080 {
transparent
https://your.site.here {
reverse_proxy localhost:5000
}
```
3. For this setup, your configuration's `ControlPanel:PublicPath` needs to be blank. If you have a path in `PublicPath`, it needs to be in "reverse_proxy PublicPathHere localhost:5000".
See https://caddyserver.com/docs/proxy
See https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#### NGINX (Reccommended for Linux)
@@ -570,7 +571,26 @@ This folder can contain anything. But, when certain events occur in the instance
#### GameStaticFiles
Any files and folders contained in this root level of this folder will be symbolically linked to all deployments at the time they are created. This allows persistent game data (BYOND `.sav`s or code configuration files for example) to persist across all deployments. This folder contains a .tgsignore file which can be used to prevent symlinks from being generated by entering the names of files and folders (1 per line)
Any files and folders contained in this root level of this folder will be symbolically linked to all deployments at the time they are created. This allows persistent game data (BYOND `.sav`s or code configuration files for example) to persist across all deployments. This folder contains a .tgsignore file which can be used to prevent symlinks from being generated by entering the names of files and folders (1 per line).
This functionality has the following prerequisites:
- You are using Windows.
**OR**
- Your world uses the TGS DreamMaker API.
- Your world runs with the `Trusted` security level.
**OR**
- You are NOT using the basic watchdog.
- The contents of the `GameStaticFiles` directory are on the same filesystem as the instance's `Game` directory.
**OR**
- You are using the basic watchdog.
- Your world runs with the `Trusted` security level.
### Clients
@@ -614,4 +634,3 @@ Feel free to ask for help [on the discussions page](https://github.com/tgstation
* The remainder of the project is licensed under [GNU AGPL v3](http://www.gnu.org/licenses/agpl-3.0.html)
See the files in the `/src/DMAPI` tree for the MIT license
+1 -1
View File
@@ -3,7 +3,7 @@
<!-- Integration tests will ensure they match across the board -->
<Import Project="ControlPanelVersion.props" />
<PropertyGroup>
<TgsCoreVersion>5.16.3</TgsCoreVersion>
<TgsCoreVersion>5.16.4</TgsCoreVersion>
<TgsConfigVersion>4.7.1</TgsConfigVersion>
<TgsApiVersion>9.12.0</TgsApiVersion>
<TgsCommonLibraryVersion>6.0.1</TgsCommonLibraryVersion>
+1 -1
View File
@@ -164,7 +164,7 @@ namespace Tgstation.Server.Api
/// <param name="requestHeaders">The <see cref="RequestHeaders"/> containing the serialized <see cref="ApiHeaders"/>.</param>
/// <param name="ignoreMissingAuth">If a missing <see cref="HeaderNames.Authorization"/> should be ignored.</param>
/// <exception cref="HeadersException">Thrown if the <paramref name="requestHeaders"/> constitue invalid <see cref="ApiHeaders"/>.</exception>
#pragma warning disable CA1502
#pragma warning disable CA1502 // TODO: Decomplexify
public ApiHeaders(RequestHeaders requestHeaders, bool ignoreMissingAuth = false)
{
if (requestHeaders == null)
@@ -3,11 +3,13 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.IO;
@@ -18,6 +20,7 @@ namespace Tgstation.Server.Host.Components.Deployment
/// <summary>
/// A <see cref="IDmbProvider"/> that uses hard links.
/// </summary>
[UnsupportedOSPlatform("windows")]
sealed class HardLinkDmbProvider : SwappableDmbProvider
{
/// <summary>
@@ -96,6 +99,7 @@ namespace Tgstation.Server.Host.Components.Deployment
/// <inheritdoc />
protected override async Task DoSwap(CancellationToken cancellationToken)
{
logger.LogTrace("Begin DoSwap, mirroring task complete: {complete}...", mirroringTask.IsCompleted);
var mirroredDir = await mirroringTask.WaitAsync(cancellationToken);
var goAheadTcs = new TaskCompletionSource();
@@ -103,12 +107,15 @@ namespace Tgstation.Server.Host.Components.Deployment
async void DisposeOfOldDirectory()
{
var directoryMoved = false;
var disposePath = Guid.NewGuid().ToString();
var disposeGuid = Guid.NewGuid();
var disposePath = disposeGuid.ToString();
logger.LogTrace("Moving Live directory to {path} for deletion...", disposeGuid);
try
{
await IOManager.MoveDirectory(LiveGameDirectory, disposePath, cancellationToken);
directoryMoved = true;
goAheadTcs.SetResult();
logger.LogTrace("Deleting old Live directory {path}...", disposePath);
await IOManager.DeleteDirectory(disposePath, CancellationToken.None); // DCT: We're detached at this point
}
catch (DirectoryNotFoundException ex)
@@ -127,7 +134,9 @@ namespace Tgstation.Server.Host.Components.Deployment
DisposeOfOldDirectory();
await goAheadTcs.Task;
logger.LogTrace("Moving mirror directory {path} to Live...", mirroredDir);
await IOManager.MoveDirectory(mirroredDir, LiveGameDirectory, cancellationToken);
logger.LogTrace("Swap complete!");
}
/// <summary>
@@ -173,17 +182,40 @@ namespace Tgstation.Server.Host.Components.Deployment
{
var dir = new DirectoryInfo(src);
Task subdirCreationTask = null;
var dreamDaemonWillAcceptOutOfDirectorySymlinks = CompileJob.MinimumSecurityLevel == DreamDaemonSecurity.Trusted;
foreach (var subDirectory in dir.EnumerateDirectories())
{
var mirroredName = Path.Combine(dest, subDirectory.Name);
// check if we are a symbolic link
if (!subDirectory.Attributes.HasFlag(FileAttributes.Directory) || subDirectory.Attributes.HasFlag(FileAttributes.ReparsePoint))
{
logger.LogTrace("Skipping symlink to {subdir}", subDirectory.Name);
continue;
}
if (subDirectory.Attributes.HasFlag(FileAttributes.ReparsePoint))
if (dreamDaemonWillAcceptOutOfDirectorySymlinks)
{
var target = subDirectory.ResolveLinkTarget(false);
logger.LogDebug("Recreating directory {name} as symlink to {target}", subDirectory.Name, target);
if (subdirCreationTask == null)
{
subdirCreationTask = IOManager.CreateDirectory(dest, cancellationToken);
yield return subdirCreationTask;
}
async Task CopyLink()
{
await subdirCreationTask.WaitAsync(cancellationToken);
using var lockContext = semaphore != null
? await SemaphoreSlimContext.Lock(semaphore, cancellationToken)
: null;
await LinkFactory.CreateSymbolicLink(target.FullName, mirroredName, cancellationToken);
}
yield return CopyLink();
continue;
}
else
logger.LogDebug("Recreating symlinked directory {name} as hard links...", subDirectory.Name);
var checkingSubdirCreationTask = true;
foreach (var copyTask in MirrorDirectoryImpl(subDirectory.FullName, Path.Combine(dest, subDirectory.Name), semaphore, cancellationToken))
foreach (var copyTask in MirrorDirectoryImpl(subDirectory.FullName, mirroredName, semaphore, cancellationToken))
{
if (subdirCreationTask == null)
{
@@ -214,7 +246,24 @@ namespace Tgstation.Server.Host.Components.Deployment
using var lockContext = semaphore != null
? await SemaphoreSlimContext.Lock(semaphore, cancellationToken)
: null;
await LinkFactory.CreateHardLink(sourceFile, destFile, cancellationToken);
if (fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint))
{
// AHHHHHHHHHHHHH
var target = fileInfo.ResolveLinkTarget(!dreamDaemonWillAcceptOutOfDirectorySymlinks);
if (dreamDaemonWillAcceptOutOfDirectorySymlinks)
{
logger.LogDebug("Recreating symlinked file {name} as symlink to {target}", fileInfo.Name, target.FullName);
await LinkFactory.CreateSymbolicLink(target.FullName, destFile, cancellationToken);
}
else
{
logger.LogDebug("Recreating symlinked file {name} as hard link to {target}", fileInfo.Name, target.FullName);
await LinkFactory.CreateHardLink(target.FullName, destFile, cancellationToken);
}
}
else
await LinkFactory.CreateHardLink(sourceFile, destFile, cancellationToken);
}
yield return LinkThisFile();
@@ -167,6 +167,11 @@ namespace Tgstation.Server.Host.Components.Session
/// </summary>
readonly object synchronizationLock;
/// <summary>
/// If this session is meant to validate the presence of the DMAPI.
/// </summary>
readonly bool apiValidationSession;
/// <summary>
/// The <see cref="TaskCompletionSource{TResult}"/> <see cref="SetPort(ushort, CancellationToken)"/> waits on when DreamDaemon currently has it's ports closed.
/// </summary>
@@ -192,6 +197,11 @@ namespace Tgstation.Server.Host.Components.Session
/// </summary>
volatile Task rebootGate;
/// <summary>
/// <see cref="Task"/> for shutting down the server if it is taking too long after validation.
/// </summary>
volatile Task postValidationShutdownTask;
/// <summary>
/// The number of currently active calls to <see cref="ProcessBridgeRequest(BridgeParameters, CancellationToken)"/> from TgsReboot().
/// </summary>
@@ -239,7 +249,7 @@ namespace Tgstation.Server.Host.Components.Session
/// <param name="postLifetimeCallback">The <see cref="Func{TResult}"/> returning a <see cref="Task"/> to be run after the <paramref name="process"/> ends.</param>
/// <param name="startupTimeout">The optional time to wait before failing the <see cref="LaunchResult"/>.</param>
/// <param name="reattached">If this is a reattached session.</param>
/// <param name="apiValidate">If this is a DMAPI validation session.</param>
/// <param name="apiValidate">The value of <see cref="apiValidationSession"/>.</param>
public SessionController(
ReattachInformation reattachInformation,
Api.Models.Instance metadata,
@@ -271,6 +281,8 @@ namespace Tgstation.Server.Host.Components.Session
this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
apiValidationSession = apiValidate;
portClosedForReboot = false;
disposed = false;
apiValidationStatus = ApiValidationStatus.NeverValidated;
@@ -291,7 +303,7 @@ namespace Tgstation.Server.Host.Components.Session
topicSendSemaphore = new FifoSemaphore();
synchronizationLock = new object();
if (apiValidate || DMApiAvailable)
if (apiValidationSession || DMApiAvailable)
{
bridgeRegistration = bridgeRegistrar.RegisterHandler(this);
this.chatTrackingContext.SetChannelSink(this);
@@ -307,6 +319,9 @@ namespace Tgstation.Server.Host.Components.Session
{
var exitCode = await process.Lifetime;
await postLifetimeCallback();
if (postValidationShutdownTask != null)
await postValidationShutdownTask;
return exitCode;
}
@@ -660,12 +675,40 @@ namespace Tgstation.Server.Host.Components.Session
throw new ObjectDisposedException(nameof(SessionController));
}
/// <summary>
/// Terminates the server after ten seconds if it does not exit.
/// </summary>
/// <param name="proceedTask">A <see cref="Task{TResult}"/> that this method <see langword="await"/>s before executing. If the <see cref="Task{TResult}.Result"/> is <see langword="false"/>, this method will return immediately.</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
async Task PostValidationShutdown(Task<bool> proceedTask)
{
Logger.LogTrace("Entered post validation terminate task.");
if (!await proceedTask)
{
Logger.LogTrace("Not running post validation terminate task for repeated bridge request.");
return;
}
Logger.LogDebug("Server will terminated in 10s if it does not exit...");
var delayTask = asyncDelayer.Delay(TimeSpan.FromSeconds(10), CancellationToken.None); // DCT: None available
var completedTask = await Task.WhenAny(process.Lifetime, delayTask);
if (completedTask == delayTask)
{
Logger.LogWarning("DMAPI took too long to shutdown server after validation request!");
process.Terminate();
apiValidationStatus = ApiValidationStatus.BadValidationRequest;
}
else
Logger.LogTrace("Server exited properly post validation.");
}
/// <summary>
/// Handle a set of bridge <paramref name="parameters"/>.
/// </summary>
/// <param name="parameters">The <see cref="BridgeParameters"/> to handle.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="BridgeResponse"/> for the request or <see langword="null"/> if the request could not be dispatched.</returns>
#pragma warning disable CA1502 // TODO: Decomplexify
async Task<BridgeResponse> ProcessBridgeCommand(BridgeParameters parameters, CancellationToken cancellationToken)
{
var response = new BridgeResponse();
@@ -740,6 +783,22 @@ namespace Tgstation.Server.Host.Components.Session
break;
case BridgeCommandType.Startup:
apiValidationStatus = ApiValidationStatus.BadValidationRequest;
// This business is is cancelled until this BYOND bug is resolved: https://www.byond.com/forum/post/2894866
#if FALSE
if (apiValidationSession)
{
var proceedTcs = new TaskCompletionSource<bool>();
var firstValidationRequest = Interlocked.CompareExchange(ref postValidationShutdownTask, PostValidationShutdown(proceedTcs.Task), null) == null;
proceedTcs.SetResult(firstValidationRequest);
if (!firstValidationRequest)
return BridgeError("Startup bridge request was repeated!");
}
#else
postValidationShutdownTask = Task.CompletedTask;
#endif
if (parameters.Version == null)
return BridgeError("Missing dmApiVersion field!");
@@ -813,6 +872,7 @@ namespace Tgstation.Server.Host.Components.Session
return response;
}
#pragma warning restore CA1502
/// <summary>
/// Log and return a <see cref="BridgeResponse"/> for a given <paramref name="message"/>.
@@ -1,4 +1,5 @@
using System;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
@@ -21,6 +22,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <summary>
/// A variant of the <see cref="AdvancedWatchdog"/> that works on POSIX systems.
/// </summary>
[UnsupportedOSPlatform("windows")]
sealed class PosixWatchdog : AdvancedWatchdog
{
/// <summary>
@@ -1,4 +1,5 @@
using System;
using System.Runtime.Versioning;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -20,6 +21,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <summary>
/// <see cref="IWatchdogFactory"/> for creating <see cref="PosixWatchdog"/>s.
/// </summary>
[UnsupportedOSPlatform("windows")]
sealed class PosixWatchdogFactory : WindowsWatchdogFactory
{
/// <summary>
+17
View File
@@ -31,6 +31,23 @@
if(!fexists("[DME_NAME].rsc"))
FailTest("Failed to create .rsc!")
#ifdef RUN_STATIC_FILE_TESTS
if(params["expect_static_files"])
if(!fexists("test2.txt"))
FailTest("Missing test2.txt")
var/f2content = file2text("test2.txt")
if(f2content != "bbb")
FailTest("Unexpected test2.txt content: [f2content]")
if(!fexists("data/test.txt"))
FailTest("Missing data/test.txt")
var/f1content = file2text("data/test.txt")
if(f1content != "aaa")
FailTest("Unexpected data/test.txt content: [f1content]")
#endif
StartAsync()
/proc/dab()
@@ -11,6 +11,8 @@
// END_PREFERENCES
// BEGIN_INCLUDE
#define RUN_STATIC_FILE_TESTS
#define DME_NAME "long_running_test_rooted"
#include "tests/DMAPI/LongRunning/Config.dm"
#include "tests/DMAPI/test_prelude.dm"
#include "tests/DMAPI/LongRunning/Test.dm"
@@ -83,19 +83,40 @@ namespace Tgstation.Server.Tests.Live.Instance
var path = Path.Combine(instance.Path, "Configuration", tmp);
Assert.IsFalse(Directory.Exists(path));
// leave a directory there to test the deployment process
var staticDir = new ConfigurationFileRequest
{
Path = "/GameStaticFiles/data"
};
await configurationClient.CreateDirectory(staticDir, cancellationToken);
}
public Task SetupDMApiTests(bool includingRoot, CancellationToken cancellationToken)
{
// just use an I/O manager here
var ioManager = new DefaultIOManager();
async Task TestStaticFileAndDir()
{
// leave a file there to test the deployment process
var staticDir = new ConfigurationFileRequest
{
Path = "/GameStaticFiles/data"
};
await configurationClient.CreateDirectory(staticDir, cancellationToken);
var staticFile = new ConfigurationFileRequest
{
Path = "/GameStaticFiles/data/test.txt"
};
await using var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes("aaa"));
await configurationClient.Write(staticFile, memoryStream, cancellationToken);
var staticFile2 = new ConfigurationFileRequest
{
Path = "/GameStaticFiles/test2.txt"
};
await using var memoryStream2 = new MemoryStream(Encoding.UTF8.GetBytes("bbb"));
await configurationClient.Write(staticFile2, memoryStream2, cancellationToken);
}
return Task.WhenAll(
ioManager.CopyDirectory(
Enumerable.Empty<string>(),
@@ -110,6 +131,9 @@ namespace Tgstation.Server.Tests.Live.Instance
ioManager.ConcatPath(instance.Path, "Repository", "long_running_test_rooted.dme"),
cancellationToken)
: Task.CompletedTask,
includingRoot
? TestStaticFileAndDir()
: Task.CompletedTask,
ioManager.CopyDirectory(
Enumerable.Empty<string>(),
null,
@@ -114,7 +114,7 @@ namespace Tgstation.Server.Tests.Live.Instance
Port = ddPort,
MapThreads = 2,
LogOutput = false,
AdditionalParameters = "expect_chat_channels=1"
AdditionalParameters = "expect_chat_channels=1&expect_static_files=1"
}, cancellationToken),
CheckByondVersions(),
ApiAssert.ThrowsException<ApiConflictException>(() => instanceClient.DreamDaemon.Update(new DreamDaemonRequest
@@ -157,8 +157,64 @@ namespace Tgstation.Server.Tests.Live.Instance
System.Console.WriteLine($"TEST: END WATCHDOG TESTS {instanceClient.Metadata.Name}");
}
async ValueTask RegressionTest1686(CancellationToken cancellationToken)
{
async ValueTask RunTest(bool useTrusted)
{
System.Console.WriteLine($"TEST: RegressionTest1686 {useTrusted}...");
var ddUpdateTask = instanceClient.DreamDaemon.Update(new DreamDaemonRequest
{
SecurityLevel = useTrusted ? DreamDaemonSecurity.Trusted : DreamDaemonSecurity.Safe,
AdditionalParameters = "expect_chat_channels=1&expect_static_files=1",
}, cancellationToken);
var currentStatus = await DeployTestDme("long_running_test_rooted", DreamDaemonSecurity.Trusted, true, cancellationToken);
await ddUpdateTask;
Assert.AreEqual(WatchdogStatus.Offline, currentStatus.Status);
var startJob = await StartDD(cancellationToken);
await WaitForJob(startJob, 40, false, null, cancellationToken);
currentStatus = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest
{
SoftShutdown = true,
}, cancellationToken);
Assert.AreEqual(WatchdogStatus.Online, currentStatus.Status);
// reimplement TellWorldToReboot because it expects a new deployment and we don't care
System.Console.WriteLine("TEST: Hack world reboot topic...");
var result = await topicClient.SendTopic(IPAddress.Loopback, "tgs_integration_test_special_tactics=1", ddPort, cancellationToken);
Assert.AreEqual("ack", result.StringData);
using var tempCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var tempToken = tempCts.Token;
using (tempToken.Register(() => System.Console.WriteLine("TEST ERROR: Timeout in RegressionTest1686!")))
{
tempCts.CancelAfter(TimeSpan.FromMinutes(2));
do
{
await Task.Delay(TimeSpan.FromSeconds(1), tempToken);
currentStatus = await instanceClient.DreamDaemon.Read(tempToken);
}
while (currentStatus.Status != WatchdogStatus.Offline);
}
await CheckDMApiFail(currentStatus.ActiveCompileJob, cancellationToken);
}
await RunTest(true);
if (new PlatformIdentifier().IsWindows || !usingBasicWatchdog)
await RunTest(false);
}
async Task InteropTestsForLongRunningDme(CancellationToken cancellationToken)
{
await RegressionTest1686(cancellationToken);
await StartAndLeaveRunning(cancellationToken);
await RegressionTest1550(cancellationToken);
@@ -191,8 +247,7 @@ namespace Tgstation.Server.Tests.Live.Instance
async ValueTask RegressionTest1550(CancellationToken cancellationToken)
{
// we need to cycle deployments twice because TGS holds the initial deployment
await DeployTestDme("LongRunning/long_running_test", DreamDaemonSecurity.Trusted, true, cancellationToken);
var currentStatus = await instanceClient.DreamDaemon.Read(cancellationToken);
var currentStatus = await DeployTestDme("LongRunning/long_running_test", DreamDaemonSecurity.Trusted, true, cancellationToken);
Assert.AreEqual(WatchdogStatus.Online, currentStatus.Status);
Assert.IsNotNull(currentStatus.StagedCompileJob);
@@ -955,9 +955,6 @@ namespace Tgstation.Server.Tests.Live
new LiveTestingServer(null, false).Dispose();
}
[TestMethod]
public async Task TestTgstationInteractive() => await TestTgstation(true);
[TestMethod]
public async Task TestTgstationHeadless() => await TestTgstation(false);