tgstation-server 6.9.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
170 using var progressReporter = new JobProgressReporter();
171
172 var installLock = await AssertAndLockVersion(
173 progressReporter,
174 versionToUse,
175 null,
176 requiredVersion != null,
177 true,
178 cancellationToken);
179 try
180 {
181 if (trustDmbFullPath != null)
182 await engineInstaller.TrustDmbPath(installLock.Version, trustDmbFullPath, cancellationToken);
183
184 return installLock;
185 }
186 catch
187 {
188 installLock.Dispose();
189 throw;
190 }
191 }
192
194 public async ValueTask DeleteVersion(JobProgressReporter progressReporter, EngineVersion version, CancellationToken cancellationToken)
195 {
196 ArgumentNullException.ThrowIfNull(progressReporter);
197
198 CheckVersionParameter(version);
199
200 logger.LogTrace("DeleteVersion {version}", version);
201
202 var activeVersion = ActiveVersion;
203 if (activeVersion != null && version.Equals(activeVersion))
204 throw new JobException(ErrorCode.EngineCannotDeleteActiveVersion);
205
207 logger.LogTrace("Waiting to acquire installedVersions lock...");
208 lock (installedVersions)
209 {
210 if (!installedVersions.TryGetValue(version, out var containerNullable))
211 {
212 logger.LogTrace("Version {version} already deleted.", version);
213 return;
214 }
215
216 container = containerNullable;
217 logger.LogTrace("Installation container acquired for deletion");
218 }
219
220 progressReporter.StageName = "Waiting for version to not be in use...";
221 while (true)
222 {
223 var containerTask = container.OnZeroReferences;
224
225 // We also want to check when the active version changes in case we need to fail the job because of that.
226 Task activeVersionUpdate;
227 using (await SemaphoreSlimContext.Lock(changeDeleteSemaphore, cancellationToken))
228 activeVersionUpdate = activeVersionChanged.Task;
229
230 logger.LogTrace("Waiting for container.OnZeroReferences or switch of active version...");
231 await Task.WhenAny(
232 containerTask,
233 activeVersionUpdate)
234 .WaitAsync(cancellationToken);
235
236 if (containerTask.IsCompleted)
237 logger.LogTrace("All locks for version {version} are gone", version);
238 else
239 logger.LogTrace("activeVersion changed, we may have to wait again. Acquiring semaphore...");
240
241 using (await SemaphoreSlimContext.Lock(changeDeleteSemaphore, cancellationToken))
242 {
243 // check again because it could have become the active version.
244 activeVersion = ActiveVersion;
245 if (activeVersion != null && version.Equals(activeVersion))
246 throw new JobException(ErrorCode.EngineCannotDeleteActiveVersion);
247
248 bool proceed;
249 logger.LogTrace("Locking installedVersions...");
250 lock (installedVersions)
251 {
252 proceed = container.OnZeroReferences.IsCompleted;
253 if (proceed)
254 if (!installedVersions.TryGetValue(version, out var newerContainer))
255 logger.LogWarning("Unable to remove engine installation {version} from list! Is there a duplicate job running?", version);
256 else
257 {
258 if (container != newerContainer)
259 {
260 // 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?
261 // I know realistically this is practically impossible, but god damn that small possiblility
262 // best thing to do is check we exclusively own the newer container
263 logger.LogDebug("Extreme race condition encountered, applying concentrated copium...");
264 container = newerContainer;
265 proceed = container.OnZeroReferences.IsCompleted;
266 }
267
268 if (proceed)
269 {
270 logger.LogTrace("Proceeding with installation deletion...");
271 installedVersions.Remove(version);
272 }
273 }
274 }
275
276 if (proceed)
277 {
278 logger.LogInformation("Deleting version {version}...", version);
279 progressReporter.StageName = "Deleting installation...";
280
281 // 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
282 var installPath = version.ToString();
283 await ioManager.DeleteFile(
285 cancellationToken);
286 await ioManager.DeleteDirectory(installPath, cancellationToken);
287 return;
288 }
289
290 if (containerTask.IsCompleted)
291 logger.LogDebug(
292 "Another lock was acquired before we could remove version {version} from the list. We will have to wait again.",
293 version);
294 else
295 logger.LogTrace("Not proceeding for some reason or another");
296 }
297 }
298 }
299
301 public async Task StartAsync(CancellationToken cancellationToken)
302 {
303 async ValueTask<byte[]?> GetActiveVersion()
304 {
305 var activeVersionFileExists = await ioManager.FileExists(ActiveVersionFileName, cancellationToken);
306 return !activeVersionFileExists ? null : await ioManager.ReadAllBytes(ActiveVersionFileName, cancellationToken);
307 }
308
309 var activeVersionBytesTask = GetActiveVersion();
310
312 var directories = await ioManager.GetDirectories(DefaultIOManager.CurrentDirectory, cancellationToken);
313
314 var installedVersionPaths = new Dictionary<string, EngineVersion>();
315
316 async ValueTask ReadVersion(string path)
317 {
318 var versionFile = ioManager.ConcatPath(path, VersionFileName);
319 if (!await ioManager.FileExists(versionFile, cancellationToken))
320 {
321 logger.LogWarning("Cleaning path with no version file: {versionPath}", ioManager.ResolvePath(path));
322 await ioManager.DeleteDirectory(path, cancellationToken); // cleanup
323 return;
324 }
325
326 var bytes = await ioManager.ReadAllBytes(versionFile, cancellationToken);
327 var text = Encoding.UTF8.GetString(bytes);
328 EngineVersion version;
329 if (!EngineVersion.TryParse(text, out var versionNullable))
330 {
331 logger.LogWarning("Cleaning path with unparsable version file: {versionPath}", ioManager.ResolvePath(path));
332 await ioManager.DeleteDirectory(path, cancellationToken); // cleanup
333 return;
334 }
335 else
336 version = versionNullable!;
337
338 try
339 {
340 AddInstallationContainer(version, path, Task.CompletedTask);
341 logger.LogDebug("Added detected BYOND version {versionKey}...", version);
342 }
343 catch (Exception ex)
344 {
345 logger.LogWarning(
346 ex,
347 "It seems that there are multiple directories that say they contain BYOND version {version}. We're ignoring and cleaning the duplicate: {duplicatePath}",
348 version,
349 ioManager.ResolvePath(path));
350 await ioManager.DeleteDirectory(path, cancellationToken);
351 return;
352 }
353
354 lock (installedVersionPaths)
355 installedVersionPaths.Add(ioManager.ResolvePath(version.ToString()), version);
356 }
357
359 directories
360 .Select(ReadVersion));
361
362 logger.LogTrace("Upgrading BYOND installations...");
364 installedVersionPaths
365 .Select(kvp => engineInstaller.UpgradeInstallation(kvp.Value, kvp.Key, cancellationToken)));
366
367 var activeVersionBytes = await activeVersionBytesTask;
368 if (activeVersionBytes != null)
369 {
370 var activeVersionString = Encoding.UTF8.GetString(activeVersionBytes);
371
372 EngineVersion? activeVersion;
373 bool hasRequestedActiveVersion;
374 lock (installedVersions)
375 hasRequestedActiveVersion = EngineVersion.TryParse(activeVersionString, out activeVersion)
376 && installedVersions.ContainsKey(activeVersion!);
377
378 if (hasRequestedActiveVersion)
379 ActiveVersion = activeVersion; // not setting TCS because there's no need during init
380 else
381 {
382 logger.LogWarning("Failed to load saved active version {activeVersion}!", activeVersionString);
383 await ioManager.DeleteFile(ActiveVersionFileName, cancellationToken);
384 }
385 }
386 }
387
389 public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
390
401 async ValueTask<EngineExecutableLock> AssertAndLockVersion(
402 JobProgressReporter progressReporter,
403 EngineVersion version,
404 Stream? customVersionStream,
405 bool neededForLock,
406 bool allowInstallation,
407 CancellationToken cancellationToken)
408 {
409 var ourTcs = new TaskCompletionSource();
410 IEngineInstallation installation;
411 EngineExecutableLock installLock;
412 bool installedOrInstalling;
413 lock (installedVersions)
414 {
415 if (customVersionStream != null)
416 {
417 var customInstallationNumber = 1;
418 do
419 {
420 version.CustomIteration = customInstallationNumber++;
421 }
422 while (installedVersions.ContainsKey(version));
423 }
424
425 installedOrInstalling = installedVersions.TryGetValue(version, out var installationContainerNullable);
427 if (!installedOrInstalling)
428 {
429 if (!allowInstallation)
430 throw new InvalidOperationException($"Engine version {version} not installed!");
431
432 installationContainer = AddInstallationContainer(
433 version,
434 ioManager.ResolvePath(version.ToString()),
435 ourTcs.Task);
436 }
437 else
438 installationContainer = installationContainerNullable!;
439
440 installation = installationContainer.Instance;
441 installLock = installationContainer.AddReference();
442 }
443
444 var deploymentPipelineProcesses = !neededForLock;
445 try
446 {
447 if (installedOrInstalling)
448 {
449 progressReporter.StageName = "Waiting for existing installation job...";
450
451 if (neededForLock && !installation.InstallationTask.IsCompleted)
452 logger.LogWarning("The required engine version ({version}) is not readily available! We will have to wait for it to install.", version);
453
454 await installation.InstallationTask.WaitAsync(cancellationToken);
455 return installLock;
456 }
457
458 // okay up to us to install it then
459 try
460 {
461 if (customVersionStream != null)
462 logger.LogInformation("Installing custom engine version as {version}...", version);
463 else if (neededForLock)
464 {
465 if (version.CustomIteration.HasValue)
466 throw new JobException(ErrorCode.EngineNonExistentCustomVersion);
467
468 logger.LogWarning("The required engine version ({version}) is not readily available! We will have to install it.", version);
469 }
470 else
471 logger.LogInformation("Requested engine version {version} not currently installed. Doing so now...", version);
472
473 progressReporter.StageName = "Running event";
474
475 var versionString = version.ToString();
476 await eventConsumer.HandleEvent(EventType.EngineInstallStart, new List<string> { versionString }, deploymentPipelineProcesses, cancellationToken);
477
478 await InstallVersionFiles(progressReporter, version, customVersionStream, deploymentPipelineProcesses, cancellationToken);
479
480 ourTcs.SetResult();
481
482 await eventConsumer.HandleEvent(EventType.EngineInstallComplete, new List<string> { versionString }, deploymentPipelineProcesses, cancellationToken);
483 }
484 catch (Exception ex)
485 {
486 if (ex is not OperationCanceledException)
487 await eventConsumer.HandleEvent(EventType.EngineInstallFail, new List<string> { ex.Message }, deploymentPipelineProcesses, cancellationToken);
488
489 lock (installedVersions)
490 installedVersions.Remove(version);
491
492 ourTcs.SetException(ex);
493 throw;
494 }
495
496 return installLock;
497 }
498 catch
499 {
500 installLock.Dispose();
501 throw;
502 }
503 }
504
514 async ValueTask InstallVersionFiles(
515 JobProgressReporter progressReporter,
516 EngineVersion version,
517 Stream? customVersionStream,
518 bool deploymentPipelineProcesses,
519 CancellationToken cancellationToken)
520 {
521 var installFullPath = ioManager.ResolvePath(version.ToString());
522 async ValueTask DirectoryCleanup()
523 {
524 await ioManager.DeleteDirectory(installFullPath, cancellationToken);
525 await ioManager.CreateDirectory(installFullPath, cancellationToken);
526 }
527
528 var directoryCleanupTask = DirectoryCleanup();
529 try
530 {
531 IEngineInstallationData engineInstallationData;
532 var remainingProgress = 1.0;
533 if (customVersionStream == null)
534 {
535 using var subReporter = progressReporter.CreateSection("Downloading Version", 0.5);
536 remainingProgress -= 0.5;
537 engineInstallationData = await engineInstaller.DownloadVersion(version, subReporter, cancellationToken);
538 }
539 else
540#pragma warning disable CA2000 // Dispose objects before losing scope, false positive
541 engineInstallationData = new ZipStreamEngineInstallationData(
542 ioManager,
543 customVersionStream);
544#pragma warning restore CA2000 // Dispose objects before losing scope
545
546 JobProgressReporter remainingReporter;
547 try
548 {
549 remainingReporter = progressReporter.CreateSection(null, remainingProgress);
550 }
551 catch
552 {
553 await engineInstallationData.DisposeAsync();
554 throw;
555 }
556
557 using (remainingReporter)
558 {
559 await using (engineInstallationData)
560 {
561 remainingReporter.StageName = "Cleaning target directory";
562
563 await directoryCleanupTask;
564 remainingReporter.ReportProgress(0.1);
565 remainingReporter.StageName = "Extracting data";
566
567 logger.LogTrace("Extracting engine to {extractPath}...", installFullPath);
568 await engineInstallationData.ExtractToPath(installFullPath, cancellationToken);
569 remainingReporter.ReportProgress(0.3);
570 }
571
572 remainingReporter.StageName = "Running installation actions";
573
574 await engineInstaller.Install(version, installFullPath, deploymentPipelineProcesses, cancellationToken);
575
576 remainingReporter.ReportProgress(0.9);
577 remainingReporter.StageName = "Writing version file";
578
579 // make sure to do this last because this is what tells us we have a valid version in the future
581 ioManager.ConcatPath(installFullPath, VersionFileName),
582 Encoding.UTF8.GetBytes(version.ToString()),
583 cancellationToken);
584 }
585 }
586 catch (HttpRequestException ex)
587 {
588 // since the user can easily provide non-exitent version numbers, we'll turn this into a JobException
589 throw new JobException(ErrorCode.EngineDownloadFail, ex);
590 }
591 catch (OperationCanceledException)
592 {
593 throw;
594 }
595 catch
596 {
597 await ioManager.DeleteDirectory(installFullPath, cancellationToken);
598 throw;
599 }
600 }
601
610 {
611 var installation = engineInstaller.CreateInstallation(version, installPath, installationTask);
612
613 var installationContainer = new ReferenceCountingContainer<IEngineInstallation, EngineExecutableLock>(installation);
614
615 lock (installedVersions)
616 installedVersions.Add(version, installationContainer);
617
618 return installationContainer;
619 }
620 }
621}
Information about an engine installation.
static bool TryParse(string input, out EngineVersion? engineVersion)
Attempts to parse a stringified EngineVersion.
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)
const string VersionFileName
The file in which we store the Version for installations.
async Task StartAsync(CancellationToken cancellationToken)
IReadOnlyList< EngineVersion > InstalledVersions
The installed EngineVersions.
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.
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> resu...
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.
async ValueTask ChangeVersion(JobProgressReporter progressReporter, EngineVersion version, Stream? customVersionStream, bool allowInstallation, CancellationToken cancellationToken)
Change the active EngineVersion.A ValueTask representing the running operation.
readonly IEventConsumer eventConsumer
The IEventConsumer for the EngineManager.
readonly IIOManager ioManager
The IIOManager for the EngineManager.
readonly IEngineInstaller engineInstaller
The IEngineInstaller for the EngineManager.
async ValueTask InstallVersionFiles(JobProgressReporter progressReporter, EngineVersion version, Stream? customVersionStream, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
Installs the files for a given BYOND version .
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.
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.
JobProgressReporter CreateSection(string? newStageName, double percentage)
Create a subsection of the JobProgressReporter with its optional own stage name.
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< IEngineInstallationData > DownloadVersion(EngineVersion version, JobProgressReporter jobProgressReporter, CancellationToken cancellationToken)
Download a given engine 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 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.
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