tgstation-server 6.8.0
The /tg/station 13 server suite
Loading...
Searching...
No Matches
EngineManager.cs
Go to the documentation of this file.
1using System;
2using System.Collections.Generic;
3using System.IO;
4using System.Linq;
5using System.Net.Http;
6using System.Text;
7using System.Threading;
8using System.Threading.Tasks;
9
10using Microsoft.Extensions.Logging;
11
18
20{
23 {
27 const string VersionFileName = "Version.txt";
28
32 const string ActiveVersionFileName = "ActiveVersion.txt";
33
35 public EngineVersion? ActiveVersion { get; private set; }
36
38 public IReadOnlyList<EngineVersion> InstalledVersions
39 {
40 get
41 {
43 return installedVersions.Keys.ToList();
44 }
45 }
46
51
56
61
65 readonly ILogger<EngineManager> logger;
66
70 readonly Dictionary<EngineVersion, ReferenceCountingContainer<IEngineInstallation, EngineExecutableLock>> installedVersions;
71
75 readonly SemaphoreSlim changeDeleteSemaphore;
76
80 volatile TaskCompletionSource activeVersionChanged;
81
87 {
88 ArgumentNullException.ThrowIfNull(version);
89
90 if (!version.Engine.HasValue)
91 throw new InvalidOperationException("version.Engine cannot be null!");
92
93 if (version.CustomIteration == 0)
94 throw new InvalidOperationException("version.CustomIteration cannot be 0!");
95 }
96
105 {
106 this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
107 this.engineInstaller = engineInstaller ?? throw new ArgumentNullException(nameof(engineInstaller));
108 this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer));
109 this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
110
111 installedVersions = new Dictionary<EngineVersion, ReferenceCountingContainer<IEngineInstallation, EngineExecutableLock>>();
112 changeDeleteSemaphore = new SemaphoreSlim(1);
113 activeVersionChanged = new TaskCompletionSource();
114 }
115
117 public void Dispose() => changeDeleteSemaphore.Dispose();
118
120 public async ValueTask ChangeVersion(
121 JobProgressReporter? progressReporter,
122 EngineVersion version,
123 Stream? customVersionStream,
124 bool allowInstallation,
125 CancellationToken cancellationToken)
126 {
127 CheckVersionParameter(version);
128
129 using (await SemaphoreSlimContext.Lock(changeDeleteSemaphore, cancellationToken))
130 {
131 using var installLock = await AssertAndLockVersion(
132 progressReporter,
133 version,
134 customVersionStream,
135 false,
136 allowInstallation,
137 cancellationToken);
138
139 // We reparse the version because it could be changed after a custom install.
140 version = new EngineVersion(installLock.Version);
141
142 var stringVersion = version.ToString();
143 await ioManager.WriteAllBytes(ActiveVersionFileName, Encoding.UTF8.GetBytes(stringVersion), cancellationToken);
145 EventType.EngineActiveVersionChange,
146 new List<string?>
147 {
148 ActiveVersion?.ToString(),
149 stringVersion,
150 },
151 false,
152 cancellationToken);
153
154 ActiveVersion = version;
155
156 logger.LogInformation("Active version changed to {version}", version);
157 var oldTcs = Interlocked.Exchange(ref activeVersionChanged, new TaskCompletionSource());
158 oldTcs.SetResult();
159 }
160 }
161
163 public async ValueTask<IEngineExecutableLock> UseExecutables(EngineVersion? requiredVersion, string? trustDmbFullPath, CancellationToken cancellationToken)
164 {
165 logger.LogTrace(
166 "Acquiring lock on BYOND version {version}...",
167 requiredVersion?.ToString() ?? $"{ActiveVersion} (active)");
168 var versionToUse = requiredVersion ?? ActiveVersion ?? throw new JobException(ErrorCode.EngineNoVersionsInstalled);
169 var installLock = await AssertAndLockVersion(
170 null,
171 versionToUse,
172 null,
173 requiredVersion != null,
174 true,
175 cancellationToken);
176 try
177 {
178 if (trustDmbFullPath != null)
179 await engineInstaller.TrustDmbPath(installLock.Version, trustDmbFullPath, cancellationToken);
180
181 return installLock;
182 }
183 catch
184 {
185 installLock.Dispose();
186 throw;
187 }
188 }
189
191 public async ValueTask DeleteVersion(JobProgressReporter progressReporter, EngineVersion version, CancellationToken cancellationToken)
192 {
193 ArgumentNullException.ThrowIfNull(progressReporter);
194
195 CheckVersionParameter(version);
196
197 logger.LogTrace("DeleteVersion {version}", version);
198
199 var activeVersion = ActiveVersion;
200 if (activeVersion != null && version.Equals(activeVersion))
201 throw new JobException(ErrorCode.EngineCannotDeleteActiveVersion);
202
204 logger.LogTrace("Waiting to acquire installedVersions lock...");
205 lock (installedVersions)
206 {
207 if (!installedVersions.TryGetValue(version, out var containerNullable))
208 {
209 logger.LogTrace("Version {version} already deleted.", version);
210 return;
211 }
212
213 container = containerNullable;
214 logger.LogTrace("Installation container acquired for deletion");
215 }
216
217 progressReporter.StageName = "Waiting for version to not be in use...";
218 while (true)
219 {
220 var containerTask = container.OnZeroReferences;
221
222 // We also want to check when the active version changes in case we need to fail the job because of that.
223 Task activeVersionUpdate;
224 using (await SemaphoreSlimContext.Lock(changeDeleteSemaphore, cancellationToken))
225 activeVersionUpdate = activeVersionChanged.Task;
226
227 logger.LogTrace("Waiting for container.OnZeroReferences or switch of active version...");
228 await Task.WhenAny(
229 containerTask,
230 activeVersionUpdate)
231 .WaitAsync(cancellationToken);
232
233 if (containerTask.IsCompleted)
234 logger.LogTrace("All locks for version {version} are gone", version);
235 else
236 logger.LogTrace("activeVersion changed, we may have to wait again. Acquiring semaphore...");
237
238 using (await SemaphoreSlimContext.Lock(changeDeleteSemaphore, cancellationToken))
239 {
240 // check again because it could have become the active version.
241 activeVersion = ActiveVersion;
242 if (activeVersion != null && version.Equals(activeVersion))
243 throw new JobException(ErrorCode.EngineCannotDeleteActiveVersion);
244
245 bool proceed;
246 logger.LogTrace("Locking installedVersions...");
247 lock (installedVersions)
248 {
249 proceed = container.OnZeroReferences.IsCompleted;
250 if (proceed)
251 if (!installedVersions.TryGetValue(version, out var newerContainer))
252 logger.LogWarning("Unable to remove engine installation {version} from list! Is there a duplicate job running?", version);
253 else
254 {
255 if (container != newerContainer)
256 {
257 // Okay let me get this straight, there was a duplicate delete job, it ran before us after we grabbed the container, AND another installation of the same version completed?
258 // I know realistically this is practically impossible, but god damn that small possiblility
259 // best thing to do is check we exclusively own the newer container
260 logger.LogDebug("Extreme race condition encountered, applying concentrated copium...");
261 container = newerContainer;
262 proceed = container.OnZeroReferences.IsCompleted;
263 }
264
265 if (proceed)
266 {
267 logger.LogTrace("Proceeding with installation deletion...");
268 installedVersions.Remove(version);
269 }
270 }
271 }
272
273 if (proceed)
274 {
275 logger.LogInformation("Deleting version {version}...", version);
276 progressReporter.StageName = "Deleting installation...";
277
278 // delete the version file first, because we will know not to re-discover the installation if it's not present and it will get cleaned on reboot
279 var installPath = version.ToString();
280 await ioManager.DeleteFile(
282 cancellationToken);
283 await ioManager.DeleteDirectory(installPath, cancellationToken);
284 return;
285 }
286
287 if (containerTask.IsCompleted)
288 logger.LogDebug(
289 "Another lock was acquired before we could remove version {version} from the list. We will have to wait again.",
290 version);
291 else
292 logger.LogTrace("Not proceeding for some reason or another");
293 }
294 }
295 }
296
298 public async Task StartAsync(CancellationToken cancellationToken)
299 {
300 async ValueTask<byte[]?> GetActiveVersion()
301 {
302 var activeVersionFileExists = await ioManager.FileExists(ActiveVersionFileName, cancellationToken);
303 return !activeVersionFileExists ? null : await ioManager.ReadAllBytes(ActiveVersionFileName, cancellationToken);
304 }
305
306 var activeVersionBytesTask = GetActiveVersion();
307
309 var directories = await ioManager.GetDirectories(DefaultIOManager.CurrentDirectory, cancellationToken);
310
311 var installedVersionPaths = new Dictionary<string, EngineVersion>();
312
313 async ValueTask ReadVersion(string path)
314 {
315 var versionFile = ioManager.ConcatPath(path, VersionFileName);
316 if (!await ioManager.FileExists(versionFile, cancellationToken))
317 {
318 logger.LogWarning("Cleaning path with no version file: {versionPath}", ioManager.ResolvePath(path));
319 await ioManager.DeleteDirectory(path, cancellationToken); // cleanup
320 return;
321 }
322
323 var bytes = await ioManager.ReadAllBytes(versionFile, cancellationToken);
324 var text = Encoding.UTF8.GetString(bytes);
325 EngineVersion version;
326 if (!EngineVersion.TryParse(text, out var versionNullable))
327 {
328 logger.LogWarning("Cleaning path with unparsable version file: {versionPath}", ioManager.ResolvePath(path));
329 await ioManager.DeleteDirectory(path, cancellationToken); // cleanup
330 return;
331 }
332 else
333 version = versionNullable!;
334
335 try
336 {
337 AddInstallationContainer(version, path, Task.CompletedTask);
338 logger.LogDebug("Added detected BYOND version {versionKey}...", version);
339 }
340 catch (Exception ex)
341 {
342 logger.LogWarning(
343 ex,
344 "It seems that there are multiple directories that say they contain BYOND version {version}. We're ignoring and cleaning the duplicate: {duplicatePath}",
345 version,
346 ioManager.ResolvePath(path));
347 await ioManager.DeleteDirectory(path, cancellationToken);
348 return;
349 }
350
351 lock (installedVersionPaths)
352 installedVersionPaths.Add(ioManager.ResolvePath(version.ToString()), version);
353 }
354
356 directories
357 .Select(ReadVersion));
358
359 logger.LogTrace("Upgrading BYOND installations...");
361 installedVersionPaths
362 .Select(kvp => engineInstaller.UpgradeInstallation(kvp.Value, kvp.Key, cancellationToken)));
363
364 var activeVersionBytes = await activeVersionBytesTask;
365 if (activeVersionBytes != null)
366 {
367 var activeVersionString = Encoding.UTF8.GetString(activeVersionBytes);
368
369 EngineVersion? activeVersion;
370 bool hasRequestedActiveVersion;
371 lock (installedVersions)
372 hasRequestedActiveVersion = EngineVersion.TryParse(activeVersionString, out activeVersion)
373 && installedVersions.ContainsKey(activeVersion!);
374
375 if (hasRequestedActiveVersion)
376 ActiveVersion = activeVersion; // not setting TCS because there's no need during init
377 else
378 {
379 logger.LogWarning("Failed to load saved active version {activeVersion}!", activeVersionString);
380 await ioManager.DeleteFile(ActiveVersionFileName, cancellationToken);
381 }
382 }
383 }
384
386 public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
387
398 async ValueTask<EngineExecutableLock> AssertAndLockVersion(
399 JobProgressReporter? progressReporter,
400 EngineVersion version,
401 Stream? customVersionStream,
402 bool neededForLock,
403 bool allowInstallation,
404 CancellationToken cancellationToken)
405 {
406 var ourTcs = new TaskCompletionSource();
407 IEngineInstallation installation;
408 EngineExecutableLock installLock;
409 bool installedOrInstalling;
410 lock (installedVersions)
411 {
412 if (customVersionStream != null)
413 {
414 var customInstallationNumber = 1;
415 do
416 {
417 version.CustomIteration = customInstallationNumber++;
418 }
419 while (installedVersions.ContainsKey(version));
420 }
421
422 installedOrInstalling = installedVersions.TryGetValue(version, out var installationContainerNullable);
424 if (!installedOrInstalling)
425 {
426 if (!allowInstallation)
427 throw new InvalidOperationException($"Engine version {version} not installed!");
428
429 installationContainer = AddInstallationContainer(
430 version,
431 ioManager.ResolvePath(version.ToString()),
432 ourTcs.Task);
433 }
434 else
435 installationContainer = installationContainerNullable!;
436
437 installation = installationContainer.Instance;
438 installLock = installationContainer.AddReference();
439 }
440
441 var deploymentPipelineProcesses = !neededForLock;
442 try
443 {
444 if (installedOrInstalling)
445 {
446 if (progressReporter != null)
447 progressReporter.StageName = "Waiting for existing installation job...";
448
449 if (neededForLock && !installation.InstallationTask.IsCompleted)
450 logger.LogWarning("The required engine version ({version}) is not readily available! We will have to wait for it to install.", version);
451
452 await installation.InstallationTask.WaitAsync(cancellationToken);
453 return installLock;
454 }
455
456 // okay up to us to install it then
457 try
458 {
459 if (customVersionStream != null)
460 logger.LogInformation("Installing custom engine version as {version}...", version);
461 else if (neededForLock)
462 {
463 if (version.CustomIteration.HasValue)
464 throw new JobException(ErrorCode.EngineNonExistentCustomVersion);
465
466 logger.LogWarning("The required engine version ({version}) is not readily available! We will have to install it.", version);
467 }
468 else
469 logger.LogInformation("Requested engine version {version} not currently installed. Doing so now...", version);
470
471 if (progressReporter != null)
472 progressReporter.StageName = "Running event";
473
474 var versionString = version.ToString();
475 await eventConsumer.HandleEvent(EventType.EngineInstallStart, new List<string> { versionString }, deploymentPipelineProcesses, cancellationToken);
476
477 await InstallVersionFiles(progressReporter, version, customVersionStream, deploymentPipelineProcesses, cancellationToken);
478
479 ourTcs.SetResult();
480
481 await eventConsumer.HandleEvent(EventType.EngineInstallComplete, new List<string> { versionString }, deploymentPipelineProcesses, cancellationToken);
482 }
483 catch (Exception ex)
484 {
485 if (ex is not OperationCanceledException)
486 await eventConsumer.HandleEvent(EventType.EngineInstallFail, new List<string> { ex.Message }, deploymentPipelineProcesses, cancellationToken);
487
488 lock (installedVersions)
489 installedVersions.Remove(version);
490
491 ourTcs.SetException(ex);
492 throw;
493 }
494
495 return installLock;
496 }
497 catch
498 {
499 installLock.Dispose();
500 throw;
501 }
502 }
503
513 async ValueTask InstallVersionFiles(
514 JobProgressReporter? progressReporter,
515 EngineVersion version,
516 Stream? customVersionStream,
517 bool deploymentPipelineProcesses,
518 CancellationToken cancellationToken)
519 {
520 var installFullPath = ioManager.ResolvePath(version.ToString());
521 async ValueTask DirectoryCleanup()
522 {
523 await ioManager.DeleteDirectory(installFullPath, cancellationToken);
524 await ioManager.CreateDirectory(installFullPath, cancellationToken);
525 }
526
527 var directoryCleanupTask = DirectoryCleanup();
528 try
529 {
530 IEngineInstallationData engineInstallationData;
531 if (customVersionStream == null)
532 {
533 if (progressReporter != null)
534 progressReporter.StageName = "Downloading version";
535
536 engineInstallationData = await engineInstaller.DownloadVersion(version, progressReporter, cancellationToken);
537
538 progressReporter?.ReportProgress(null);
539 }
540 else
541#pragma warning disable CA2000 // Dispose objects before losing scope, false positive
542 engineInstallationData = new ZipStreamEngineInstallationData(
543 ioManager,
544 customVersionStream);
545#pragma warning restore CA2000 // Dispose objects before losing scope
546
547 await using (engineInstallationData)
548 {
549 if (progressReporter != null)
550 progressReporter.StageName = "Cleaning target directory";
551
552 await directoryCleanupTask;
553
554 if (progressReporter != null)
555 progressReporter.StageName = "Extracting data";
556
557 logger.LogTrace("Extracting engine to {extractPath}...", installFullPath);
558 await engineInstallationData.ExtractToPath(installFullPath, cancellationToken);
559 }
560
561 if (progressReporter != null)
562 progressReporter.StageName = "Running installation actions";
563
564 await engineInstaller.Install(version, installFullPath, deploymentPipelineProcesses, cancellationToken);
565
566 if (progressReporter != null)
567 progressReporter.StageName = "Writing version file";
568
569 // make sure to do this last because this is what tells us we have a valid version in the future
571 ioManager.ConcatPath(installFullPath, VersionFileName),
572 Encoding.UTF8.GetBytes(version.ToString()),
573 cancellationToken);
574 }
575 catch (HttpRequestException ex)
576 {
577 // since the user can easily provide non-exitent version numbers, we'll turn this into a JobException
578 throw new JobException(ErrorCode.EngineDownloadFail, ex);
579 }
580 catch (OperationCanceledException)
581 {
582 throw;
583 }
584 catch
585 {
586 await ioManager.DeleteDirectory(installFullPath, cancellationToken);
587 throw;
588 }
589 }
590
599 {
600 var installation = engineInstaller.CreateInstallation(version, installPath, installationTask);
601
602 var installationContainer = new ReferenceCountingContainer<IEngineInstallation, EngineExecutableLock>(installation);
603
604 lock (installedVersions)
605 installedVersions.Add(version, installationContainer);
606
607 return installationContainer;
608 }
609 }
610}
Information about an engine installation.
static bool TryParse(string input, out EngineVersion? engineVersion)
Attempts to parse a stringified EngineVersion.
EngineType? Engine
The EngineType.
int? CustomIteration
The revision of the custom build.
Extension methods for the ValueTask and ValueTask<TResult> classes.
static async ValueTask WhenAll(IEnumerable< ValueTask > tasks)
Fully await a given list of tasks .
async ValueTask InstallVersionFiles(JobProgressReporter? progressReporter, EngineVersion version, Stream? customVersionStream, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
Installs the files for a given BYOND version .
static void CheckVersionParameter(EngineVersion version)
Validates a given version parameter.
readonly Dictionary< EngineVersion, ReferenceCountingContainer< IEngineInstallation, EngineExecutableLock > > installedVersions
Map of byond EngineVersions to Tasks that complete when they are installed.
Task StopAsync(CancellationToken cancellationToken)
async ValueTask< EngineExecutableLock > AssertAndLockVersion(JobProgressReporter? progressReporter, EngineVersion version, Stream? customVersionStream, bool neededForLock, bool allowInstallation, CancellationToken cancellationToken)
Ensures a BYOND version is installed if it isn't already.
const string VersionFileName
The file in which we store the Version for installations.
async Task StartAsync(CancellationToken cancellationToken)
IReadOnlyList< EngineVersion > InstalledVersions
The installed EngineVersions.
volatile TaskCompletionSource activeVersionChanged
TaskCompletionSource that notifes when the ActiveVersion changes.
const string ActiveVersionFileName
The file in which we store the ActiveVersion.
async ValueTask< IEngineExecutableLock > UseExecutables(EngineVersion? requiredVersion, string? trustDmbFullPath, CancellationToken cancellationToken)
Lock the current installation's location and return a IEngineExecutableLock. A ValueTask<TResult> res...
async ValueTask DeleteVersion(JobProgressReporter progressReporter, EngineVersion version, CancellationToken cancellationToken)
Deletes a given version from the disk. A Task representing the running operation.
EngineVersion? ActiveVersion
The currently active EngineVersion.
EngineManager(IIOManager ioManager, IEngineInstaller engineInstaller, IEventConsumer eventConsumer, ILogger< EngineManager > logger)
Initializes a new instance of the EngineManager class.
readonly SemaphoreSlim changeDeleteSemaphore
The SemaphoreSlim for changing or deleting the active BYOND version.
readonly IEventConsumer eventConsumer
The IEventConsumer for the EngineManager.
readonly IIOManager ioManager
The IIOManager for the EngineManager.
readonly IEngineInstaller engineInstaller
The IEngineInstaller for the EngineManager.
readonly ILogger< EngineManager > logger
The ILogger for the EngineManager.
ReferenceCountingContainer< IEngineInstallation, EngineExecutableLock > AddInstallationContainer(EngineVersion version, string installPath, Task installationTask)
Create and add a new IEngineInstallation to installedVersions.
async ValueTask ChangeVersion(JobProgressReporter? progressReporter, EngineVersion version, Stream? customVersionStream, bool allowInstallation, CancellationToken cancellationToken)
Change the active EngineVersion. A ValueTask representing the running operation.
Implementation of IEngineInstallationData for a zip file in a Stream.
IIOManager that resolves paths to Environment.CurrentDirectory.
const string CurrentDirectory
Path to the current working directory for the IIOManager.
Operation exceptions thrown from the context of a Models.Job.
Definition: JobException.cs:11
void ReportProgress(double? progress)
Report progress.
TReference AddReference()
Create a new TReference to the Instance.
Task OnZeroReferences
A Task that completes when there are no TReference s active for the Instance.
static async ValueTask< SemaphoreSlimContext > Lock(SemaphoreSlim semaphore, CancellationToken cancellationToken)
Asyncronously locks a semaphore .
Task ExtractToPath(string path, CancellationToken cancellationToken)
Extracts the installation to a given path.
Task InstallationTask
The Task that completes when the BYOND version finished installing.
For downloading and installing game engines for a given system.
IEngineInstallation CreateInstallation(EngineVersion version, string path, Task installationTask)
Creates an IEngineInstallation for a given version .
ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken)
Does actions necessary to get upgrade a version installed by a previous version of TGS.
ValueTask< IEngineInstallationData > DownloadVersion(EngineVersion version, JobProgressReporter? jobProgressReporter, CancellationToken cancellationToken)
Download a given engine version .
ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
Does actions necessary to get an extracted installation working.
ValueTask TrustDmbPath(EngineVersion version, string fullDmbPath, CancellationToken cancellationToken)
Add a given fullDmbPath to the trusted DMBs list in BYOND's config.
For managing the engine installations.
Consumes EventTypes and takes the appropriate actions.
ValueTask HandleEvent(EventType eventType, IEnumerable< string?> parameters, bool deploymentPipeline, CancellationToken cancellationToken)
Handle a given eventType .
Interface for using filesystems.
Definition: IIOManager.cs:13
string ResolvePath()
Retrieve the full path of the current working directory.
ValueTask< byte[]> ReadAllBytes(string path, CancellationToken cancellationToken)
Returns all the contents of a file at path as a byte array.
string ConcatPath(params string[] paths)
Combines an array of strings into a path.
Task< IReadOnlyList< string > > GetDirectories(string path, CancellationToken cancellationToken)
Returns full directory names in a given path .
Task CreateDirectory(string path, CancellationToken cancellationToken)
Create a directory at path .
Task DeleteFile(string path, CancellationToken cancellationToken)
Deletes a file at path .
ValueTask WriteAllBytes(string path, byte[] contents, CancellationToken cancellationToken)
Writes some contents to a file at path overwriting previous content.
Task DeleteDirectory(string path, CancellationToken cancellationToken)
Recursively delete a directory, removes and does not enter any symlinks encounterd.
Task< bool > FileExists(string path, CancellationToken cancellationToken)
Check that the file at path exists.
ErrorCode
Types of Response.ErrorMessageResponses that the API may return.
Definition: ErrorCode.cs:12
EventType
Types of events. Mirror in tgs.dm. Prefer last listed name for script.
Definition: EventType.cs:7