tgstation-server 6.1.2
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 try
442 {
443 if (installedOrInstalling)
444 {
445 if (progressReporter != null)
446 progressReporter.StageName = "Waiting for existing installation job...";
447
448 if (neededForLock && !installation.InstallationTask.IsCompleted)
449 logger.LogWarning("The required engine version ({version}) is not readily available! We will have to wait for it to install.", version);
450
451 await installation.InstallationTask.WaitAsync(cancellationToken);
452 return installLock;
453 }
454
455 // okay up to us to install it then
456 try
457 {
458 if (customVersionStream != null)
459 logger.LogInformation("Installing custom engine version as {version}...", version);
460 else if (neededForLock)
461 {
462 if (version.CustomIteration.HasValue)
463 throw new JobException(ErrorCode.EngineNonExistentCustomVersion);
464
465 logger.LogWarning("The required engine version ({version}) is not readily available! We will have to install it.", version);
466 }
467 else
468 logger.LogDebug("Requested engine version {version} not currently installed. Doing so now...", version);
469
470 if (progressReporter != null)
471 progressReporter.StageName = "Running event";
472
473 var versionString = version.ToString();
474 await eventConsumer.HandleEvent(EventType.EngineInstallStart, new List<string> { versionString }, false, cancellationToken);
475
476 await InstallVersionFiles(progressReporter, version, customVersionStream, cancellationToken);
477
478 ourTcs.SetResult();
479
480 await eventConsumer.HandleEvent(EventType.EngineInstallComplete, new List<string> { versionString }, false, cancellationToken);
481 }
482 catch (Exception ex)
483 {
484 if (ex is not OperationCanceledException)
485 await eventConsumer.HandleEvent(EventType.EngineInstallFail, new List<string> { ex.Message }, false, cancellationToken);
486
487 lock (installedVersions)
488 installedVersions.Remove(version);
489
490 ourTcs.SetException(ex);
491 throw;
492 }
493
494 return installLock;
495 }
496 catch
497 {
498 installLock.Dispose();
499 throw;
500 }
501 }
502
511 async ValueTask InstallVersionFiles(JobProgressReporter? progressReporter, EngineVersion version, Stream? customVersionStream, CancellationToken cancellationToken)
512 {
513 var installFullPath = ioManager.ResolvePath(version.ToString());
514 async ValueTask DirectoryCleanup()
515 {
516 await ioManager.DeleteDirectory(installFullPath, cancellationToken);
517 await ioManager.CreateDirectory(installFullPath, cancellationToken);
518 }
519
520 var directoryCleanupTask = DirectoryCleanup();
521 try
522 {
523 IEngineInstallationData engineInstallationData;
524 if (customVersionStream == null)
525 {
526 if (progressReporter != null)
527 progressReporter.StageName = "Downloading version";
528
529 engineInstallationData = await engineInstaller.DownloadVersion(version, progressReporter, cancellationToken);
530
531 progressReporter?.ReportProgress(null);
532 }
533 else
534#pragma warning disable CA2000 // Dispose objects before losing scope, false positive
535 engineInstallationData = new ZipStreamEngineInstallationData(
536 ioManager,
537 customVersionStream);
538#pragma warning restore CA2000 // Dispose objects before losing scope
539
540 await using (engineInstallationData)
541 {
542 if (progressReporter != null)
543 progressReporter.StageName = "Cleaning target directory";
544
545 await directoryCleanupTask;
546
547 if (progressReporter != null)
548 progressReporter.StageName = "Extracting data";
549
550 logger.LogTrace("Extracting engine to {extractPath}...", installFullPath);
551 await engineInstallationData.ExtractToPath(installFullPath, cancellationToken);
552 }
553
554 if (progressReporter != null)
555 progressReporter.StageName = "Running installation actions";
556
557 await engineInstaller.Install(version, installFullPath, cancellationToken);
558
559 if (progressReporter != null)
560 progressReporter.StageName = "Writing version file";
561
562 // make sure to do this last because this is what tells us we have a valid version in the future
564 ioManager.ConcatPath(installFullPath, VersionFileName),
565 Encoding.UTF8.GetBytes(version.ToString()),
566 cancellationToken);
567 }
568 catch (HttpRequestException ex)
569 {
570 // since the user can easily provide non-exitent version numbers, we'll turn this into a JobException
571 throw new JobException(ErrorCode.EngineDownloadFail, ex);
572 }
573 catch (OperationCanceledException)
574 {
575 throw;
576 }
577 catch
578 {
579 await ioManager.DeleteDirectory(installFullPath, cancellationToken);
580 throw;
581 }
582 }
583
592 {
593 var installation = engineInstaller.CreateInstallation(version, installPath, installationTask);
594
595 var installationContainer = new ReferenceCountingContainer<IEngineInstallation, EngineExecutableLock>(installation);
596
597 lock (installedVersions)
598 installedVersions.Add(version, installationContainer);
599
600 return installationContainer;
601 }
602 }
603}
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 .
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.
async ValueTask InstallVersionFiles(JobProgressReporter? progressReporter, EngineVersion version, Stream? customVersionStream, CancellationToken cancellationToken)
Installs the files for a given BYOND version .
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, 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:13
EventType
Types of events. Mirror in tgs.dm. Prefer last listed name for script.
Definition: EventType.cs:7