tgstation-server 5.12.7
The /tg/station 13 server suite
Loading...
Searching...
No Matches
ByondManager.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 public const string BinPath = "byond/bin";
28
32 const string CfgDirectoryName = "cfg";
33
37 const string TrustedDmbFileName = "trusted.txt";
38
42 const string VersionFileName = "Version.txt";
43
47 const string ActiveVersionFileName = "ActiveVersion.txt";
48
50 public Version ActiveVersion { get; private set; }
51
53 public IReadOnlyList<Version> InstalledVersions
54 {
55 get
56 {
58 return installedVersions.Keys.ToList();
59 }
60 }
61
65 static readonly SemaphoreSlim UserFilesSemaphore = new (1);
66
71
76
81
85 readonly ILogger<ByondManager> logger;
86
90 readonly Dictionary<Version, ReferenceCountingContainer<ByondInstallation, ByondExecutableLock>> installedVersions;
91
95 readonly SemaphoreSlim changeDeleteSemaphore;
96
100 TaskCompletionSource activeVersionChanged;
101
106 static void CheckVersionParameter(Version version)
107 {
108 ArgumentNullException.ThrowIfNull(version);
109
110 if (version.Build == 0)
111 throw new ArgumentException("version.Build cannot be 0!", nameof(version));
112 }
113
122 {
123 this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
124 this.byondInstaller = byondInstaller ?? throw new ArgumentNullException(nameof(byondInstaller));
125 this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer));
126 this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
127
128 installedVersions = new Dictionary<Version, ReferenceCountingContainer<ByondInstallation, ByondExecutableLock>>();
129 changeDeleteSemaphore = new SemaphoreSlim(1);
130 activeVersionChanged = new TaskCompletionSource();
131 }
132
134 public void Dispose() => changeDeleteSemaphore.Dispose();
135
137 public async Task ChangeVersion(
138 JobProgressReporter progressReporter,
139 Version version,
140 Stream customVersionStream,
141 bool allowInstallation,
142 CancellationToken cancellationToken)
143 {
144 CheckVersionParameter(version);
145
146 using (await SemaphoreSlimContext.Lock(changeDeleteSemaphore, cancellationToken))
147 {
148 using var installLock = await AssertAndLockVersion(
149 progressReporter,
150 version,
151 customVersionStream,
152 false,
153 allowInstallation,
154 cancellationToken);
155
156 // We reparse the version because it could be changed after a custom install.
157 version = installLock.Version;
158
159 var stringVersion = version.ToString();
160 await ioManager.WriteAllBytes(ActiveVersionFileName, Encoding.UTF8.GetBytes(stringVersion), cancellationToken);
162 EventType.ByondActiveVersionChange,
163 new List<string>
164 {
165 ActiveVersion?.ToString(),
166 stringVersion,
167 },
168 false,
169 cancellationToken);
170
171 ActiveVersion = version;
172 activeVersionChanged.SetResult();
173 activeVersionChanged = new TaskCompletionSource();
174 }
175
176 logger.LogInformation("Active version changed to {version}", version);
177 }
178
180 public async Task<IByondExecutableLock> UseExecutables(Version requiredVersion, string trustDmbFullPath, CancellationToken cancellationToken)
181 {
182 logger.LogTrace(
183 "Acquiring lock on BYOND version {version}...",
184 requiredVersion?.ToString() ?? $"{ActiveVersion} (active)");
185 var versionToUse = requiredVersion ?? ActiveVersion ?? throw new JobException(ErrorCode.ByondNoVersionsInstalled);
186 var installLock = await AssertAndLockVersion(
187 null,
188 versionToUse,
189 null,
190 requiredVersion != null,
191 true,
192 cancellationToken);
193 try
194 {
195 if (trustDmbFullPath != null)
196 await TrustDmbPath(trustDmbFullPath, cancellationToken);
197
198 return installLock;
199 }
200 catch
201 {
202 installLock.Dispose();
203 throw;
204 }
205 }
206
208 public async Task DeleteVersion(JobProgressReporter progressReporter, Version version, CancellationToken cancellationToken)
209 {
210 ArgumentNullException.ThrowIfNull(progressReporter);
211
212 CheckVersionParameter(version);
213
214 logger.LogTrace("DeleteVersion {version}", version);
215
216 if (version == ActiveVersion)
217 throw new JobException(ErrorCode.ByondCannotDeleteActiveVersion);
218
220 lock (installedVersions)
221 if (!installedVersions.TryGetValue(version, out container))
222 return; // already "deleted"
223
224 logger.LogInformation("Deleting BYOND version {version}...", version);
225 progressReporter.StageName = "Waiting for version to not be in use...";
226 while (true)
227 {
228 var containerTask = container.OnZeroReferences;
229
230 // We also want to check when the active version changes in case we need to fail the job because of that.
231 Task activeVersionUpdate;
232 using (await SemaphoreSlimContext.Lock(changeDeleteSemaphore, cancellationToken))
233 activeVersionUpdate = activeVersionChanged.Task;
234
235 await Task.WhenAny(
236 containerTask,
237 activeVersionUpdate)
238 .WithToken(cancellationToken);
239
240 if (containerTask.IsCompleted)
241 logger.LogTrace("All BYOND locks for {version} are gone", version);
242
243 using (await SemaphoreSlimContext.Lock(changeDeleteSemaphore, cancellationToken))
244 {
245 // check again because it could have become the active version.
246 if (version == ActiveVersion)
247 throw new JobException(ErrorCode.ByondCannotDeleteActiveVersion);
248
249 bool proceed;
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 BYOND 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 installedVersions.Remove(version);
270 }
271 }
272
273 if (proceed)
274 {
275 progressReporter.StageName = "Deleting installation...";
276
277 // 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
278 var installPath = version.ToString();
279 await ioManager.DeleteFile(
281 cancellationToken);
282 await ioManager.DeleteDirectory(installPath, cancellationToken);
283 return;
284 }
285
286 if (containerTask.IsCompleted)
287 logger.LogDebug(
288 "Another lock was acquired before we could remove version {version} from the list. We will have to wait again.",
289 version);
290 }
291 }
292 }
293
295 public async Task StartAsync(CancellationToken cancellationToken)
296 {
297 async Task<byte[]> GetActiveVersion()
298 {
299 var activeVersionFileExists = await ioManager.FileExists(ActiveVersionFileName, cancellationToken);
300 return !activeVersionFileExists ? null : await ioManager.ReadAllBytes(ActiveVersionFileName, cancellationToken);
301 }
302
303 var activeVersionBytesTask = GetActiveVersion();
304
305 using (await SemaphoreSlimContext.Lock(UserFilesSemaphore, cancellationToken))
306 {
307 // Create local cfg directory in case it doesn't exist
308 var localCfgDirectory = ioManager.ConcatPath(
312 localCfgDirectory,
313 cancellationToken);
314
315 // Delete trusted.txt so it doesn't grow too large
316 var trustedFilePath =
318 localCfgDirectory,
320 logger.LogTrace("Deleting trusted .dmbs file {trustedFilePath}", trustedFilePath);
321 await ioManager.DeleteFile(
322 trustedFilePath,
323 cancellationToken);
324 }
325
327 var directories = await ioManager.GetDirectories(DefaultIOManager.CurrentDirectory, cancellationToken);
328
329 var installedVersionPaths = new Dictionary<string, Version>();
330
331 async Task ReadVersion(string path)
332 {
333 var versionFile = ioManager.ConcatPath(path, VersionFileName);
334 if (!await ioManager.FileExists(versionFile, cancellationToken))
335 {
336 logger.LogWarning("Cleaning path with no version file: {versionPath}", ioManager.ResolvePath(path));
337 await ioManager.DeleteDirectory(path, cancellationToken); // cleanup
338 return;
339 }
340
341 var bytes = await ioManager.ReadAllBytes(versionFile, cancellationToken);
342 var text = Encoding.UTF8.GetString(bytes);
343 if (!Version.TryParse(text, out var version))
344 {
345 logger.LogWarning("Cleaning path with unparsable version file: {versionPath}", ioManager.ResolvePath(path));
346 await ioManager.DeleteDirectory(path, cancellationToken); // cleanup
347 return;
348 }
349
350 try
351 {
352 AddInstallationContainer(version, Task.CompletedTask);
353 logger.LogDebug("Added detected BYOND version {versionKey}...", version);
354 }
355 catch (Exception ex)
356 {
357 logger.LogWarning(
358 ex,
359 "It seems that there are multiple directories that say they contain BYOND version {version}. We're ignoring and cleaning the duplicate: {duplicatePath}",
360 version,
361 ioManager.ResolvePath(path));
362 await ioManager.DeleteDirectory(path, cancellationToken);
363 return;
364 }
365
366 lock (installedVersionPaths)
367 installedVersionPaths.Add(ioManager.ResolvePath(version.ToString()), version);
368 }
369
370 await Task.WhenAll(directories.Select(ReadVersion));
371
372 logger.LogTrace("Upgrading BYOND installations...");
373 await Task.WhenAll(installedVersionPaths.Select(kvp => byondInstaller.UpgradeInstallation(kvp.Value, kvp.Key, cancellationToken)));
374
375 var activeVersionBytes = await activeVersionBytesTask;
376 if (activeVersionBytes != null)
377 {
378 var activeVersionString = Encoding.UTF8.GetString(activeVersionBytes);
379
380 Version activeVersion;
381 bool hasRequestedActiveVersion;
382 lock (installedVersions)
383 hasRequestedActiveVersion = Version.TryParse(activeVersionString, out activeVersion)
384 && installedVersions.ContainsKey(activeVersion);
385
386 if (hasRequestedActiveVersion)
387 ActiveVersion = activeVersion; // not setting TCS because there's no need during init
388 else
389 {
390 logger.LogWarning("Failed to load saved active version {activeVersion}!", activeVersionString);
391 await ioManager.DeleteFile(ActiveVersionFileName, cancellationToken);
392 }
393 }
394 }
395
397 public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
398
409 async Task<ByondExecutableLock> AssertAndLockVersion(
410 JobProgressReporter progressReporter,
411 Version version,
412 Stream customVersionStream,
413 bool neededForLock,
414 bool allowInstallation,
415 CancellationToken cancellationToken)
416 {
417 var ourTcs = new TaskCompletionSource();
418 ByondInstallation installation;
419 ByondExecutableLock installLock;
420 bool installedOrInstalling;
421 lock (installedVersions)
422 {
423 if (customVersionStream != null)
424 {
425 var customInstallationNumber = 1;
426 do
427 {
428 version = new Version(version.Major, version.Minor, customInstallationNumber++);
429 }
430 while (installedVersions.ContainsKey(version));
431 }
432
433 installedOrInstalling = installedVersions.TryGetValue(version, out var installationContainer);
434 if (!installedOrInstalling)
435 {
436 if (!allowInstallation)
437 throw new InvalidOperationException($"BYOND version {version} not installed!");
438
439 installationContainer = AddInstallationContainer(version, ourTcs.Task);
440 }
441
442 installation = installationContainer.Instance;
443 installLock = installationContainer.AddReference();
444 }
445
446 try
447 {
448 if (installedOrInstalling)
449 {
450 if (progressReporter != null)
451 progressReporter.StageName = "Waiting for existing installation job...";
452
453 if (neededForLock && !installation.InstallationTask.IsCompleted)
454 logger.LogWarning("The required BYOND version ({version}) is not readily available! We will have to wait for it to install.", version);
455
456 await installation.InstallationTask.WithToken(cancellationToken);
457 return installLock;
458 }
459
460 // okay up to us to install it then
461 try
462 {
463 if (customVersionStream != null)
464 logger.LogInformation("Installing custom BYOND version as {version}...", version);
465 else if (neededForLock)
466 {
467 if (version.Build > 0)
468 throw new JobException(ErrorCode.ByondNonExistentCustomVersion);
469
470 logger.LogWarning("The required BYOND version ({version}) is not readily available! We will have to install it.", version);
471 }
472 else
473 logger.LogDebug("Requested BYOND version {version} not currently installed. Doing so now...", version);
474
475 if (progressReporter != null)
476 progressReporter.StageName = "Running event";
477
478 var versionString = version.ToString();
479 await eventConsumer.HandleEvent(EventType.ByondInstallStart, new List<string> { versionString }, false, cancellationToken);
480
481 await InstallVersionFiles(progressReporter, version, customVersionStream, cancellationToken);
482
483 ourTcs.SetResult();
484 }
485 catch (Exception ex)
486 {
487 if (ex is not OperationCanceledException)
488 await eventConsumer.HandleEvent(EventType.ByondInstallFail, new List<string> { ex.Message }, false, cancellationToken);
489
490 lock (installedVersions)
491 installedVersions.Remove(version);
492
493 ourTcs.SetException(ex);
494 throw;
495 }
496
497 return installLock;
498 }
499 catch
500 {
501 installLock.Dispose();
502 throw;
503 }
504 }
505
514 async Task InstallVersionFiles(JobProgressReporter progressReporter, Version version, Stream customVersionStream, CancellationToken cancellationToken)
515 {
516 var installFullPath = ioManager.ResolvePath(version.ToString());
517 async Task DirectoryCleanup()
518 {
519 await ioManager.DeleteDirectory(installFullPath, cancellationToken);
520 await ioManager.CreateDirectory(installFullPath, cancellationToken);
521 }
522
523 var directoryCleanupTask = DirectoryCleanup();
524 try
525 {
526 Stream versionZipStream;
527 if (customVersionStream == null)
528 {
529 if (progressReporter != null)
530 progressReporter.StageName = "Downloading version";
531
532 versionZipStream = await byondInstaller.DownloadVersion(version, cancellationToken);
533 }
534 else
535 versionZipStream = customVersionStream;
536
537 await using (versionZipStream)
538 {
539 if (progressReporter != null)
540 progressReporter.StageName = "Cleaning target directory";
541
542 await directoryCleanupTask;
543
544 if (progressReporter != null)
545 progressReporter.StageName = "Extracting zip";
546
547 logger.LogTrace("Extracting downloaded BYOND zip to {extractPath}...", installFullPath);
548 await ioManager.ZipToDirectory(installFullPath, versionZipStream, cancellationToken);
549 }
550
551 if (progressReporter != null)
552 progressReporter.StageName = "Running installation actions";
553
554 await byondInstaller.InstallByond(version, installFullPath, cancellationToken);
555
556 if (progressReporter != null)
557 progressReporter.StageName = "Writing version file";
558
559 // make sure to do this last because this is what tells us we have a valid version in the future
561 ioManager.ConcatPath(installFullPath, VersionFileName),
562 Encoding.UTF8.GetBytes(version.ToString()),
563 cancellationToken);
564 }
565 catch (HttpRequestException e)
566 {
567 // since the user can easily provide non-exitent version numbers, we'll turn this into a JobException
568 throw new JobException(ErrorCode.ByondDownloadFail, e);
569 }
570 catch (OperationCanceledException)
571 {
572 throw;
573 }
574 catch
575 {
576 await ioManager.DeleteDirectory(installFullPath, cancellationToken);
577 throw;
578 }
579 }
580
588 {
589 var binPathForVersion = ioManager.ConcatPath(version.ToString(), BinPath);
590 var installation = new ByondInstallation(
591 installationTask,
592 version,
595 binPathForVersion,
596 byondInstaller.GetDreamDaemonName(version, out var supportsCli))),
599 binPathForVersion,
601 supportsCli);
602
603 var installationContainer = new ReferenceCountingContainer<ByondInstallation, ByondExecutableLock>(installation);
604
605 lock (installedVersions)
606 installedVersions.Add(version, installationContainer);
607
608 return installationContainer;
609 }
610
617 async Task TrustDmbPath(string fullDmbPath, CancellationToken cancellationToken)
618 {
619 var trustedFilePath = ioManager.ConcatPath(
623
624 logger.LogDebug("Adding .dmb ({dmbPath}) to {trustedFilePath}", fullDmbPath, trustedFilePath);
625
626 using (await SemaphoreSlimContext.Lock(UserFilesSemaphore, cancellationToken))
627 {
628 string trustedFileText;
629 if (await ioManager.FileExists(trustedFilePath, cancellationToken))
630 {
631 var trustedFileBytes = await ioManager.ReadAllBytes(trustedFilePath, cancellationToken);
632 trustedFileText = Encoding.UTF8.GetString(trustedFileBytes);
633 trustedFileText = $"{trustedFileText.Trim()}{Environment.NewLine}";
634 }
635 else
636 {
637 trustedFileText = String.Empty;
638 }
639
640 if (trustedFileText.Contains(fullDmbPath, StringComparison.Ordinal))
641 return;
642
643 trustedFileText = $"{trustedFileText}{fullDmbPath}{Environment.NewLine}";
644
645 var newTrustedFileBytes = Encoding.UTF8.GetBytes(trustedFileText);
646 await ioManager.WriteAllBytes(trustedFilePath, newTrustedFileBytes, cancellationToken);
647 }
648 }
649 }
650}
Task InstallationTask
The Task that completes when the BYOND version finished installing.
async Task< ByondExecutableLock > AssertAndLockVersion(JobProgressReporter progressReporter, Version version, Stream customVersionStream, bool neededForLock, bool allowInstallation, CancellationToken cancellationToken)
Ensures a BYOND version is installed if it isn't already.
async Task TrustDmbPath(string fullDmbPath, CancellationToken cancellationToken)
Add a given fullDmbPath to the trusted DMBs list in BYOND's config.
readonly IEventConsumer eventConsumer
The IEventConsumer for the ByondManager.
Definition: ByondManager.cs:80
readonly Dictionary< Version, ReferenceCountingContainer< ByondInstallation, ByondExecutableLock > > installedVersions
Map of byond Versions to Tasks that complete when they are installed.
Definition: ByondManager.cs:90
static readonly SemaphoreSlim UserFilesSemaphore
SemaphoreSlim for writing to files in the user's BYOND directory.
Definition: ByondManager.cs:65
async Task InstallVersionFiles(JobProgressReporter progressReporter, Version version, Stream customVersionStream, CancellationToken cancellationToken)
Installs the files for a given BYOND version .
readonly ILogger< ByondManager > logger
The ILogger for the ByondManager.
Definition: ByondManager.cs:85
readonly IIOManager ioManager
The IIOManager for the ByondManager.
Definition: ByondManager.cs:70
readonly IByondInstaller byondInstaller
The IByondInstaller for the ByondManager.
Definition: ByondManager.cs:75
Version ActiveVersion
The currently active BYOND version.
Definition: ByondManager.cs:50
static void CheckVersionParameter(Version version)
Validates a given version parameter.
ByondManager(IIOManager ioManager, IByondInstaller byondInstaller, IEventConsumer eventConsumer, ILogger< ByondManager > logger)
Initializes a new instance of the ByondManager class.
const string ActiveVersionFileName
The file in which we store the ActiveVersion.
Definition: ByondManager.cs:47
const string TrustedDmbFileName
The name of the list of trusted .dmb files in the user's BYOND cfg directory.
Definition: ByondManager.cs:37
readonly SemaphoreSlim changeDeleteSemaphore
The SemaphoreSlim for changing or deleting the active BYOND version.
Definition: ByondManager.cs:95
Task StopAsync(CancellationToken cancellationToken)
async Task< IByondExecutableLock > UseExecutables(Version requiredVersion, string trustDmbFullPath, CancellationToken cancellationToken)
Lock the current installation's location and return a IByondExecutableLock. A Task<TResult> resulting...
async Task ChangeVersion(JobProgressReporter progressReporter, Version version, Stream customVersionStream, bool allowInstallation, CancellationToken cancellationToken)
Change the active BYOND version. A Task representing the running operation.
async Task StartAsync(CancellationToken cancellationToken)
async Task DeleteVersion(JobProgressReporter progressReporter, Version version, CancellationToken cancellationToken)
Deletes a given BYOND version from the disk. A Task representing the running operation.
const string BinPath
The path to the BYOND bin folder.
Definition: ByondManager.cs:27
IReadOnlyList< Version > InstalledVersions
The installed BYOND versions.
Definition: ByondManager.cs:54
const string VersionFileName
The file in which we store the Version for installations.
Definition: ByondManager.cs:42
const string CfgDirectoryName
The path to the cfg directory.
Definition: ByondManager.cs:32
ReferenceCountingContainer< ByondInstallation, ByondExecutableLock > AddInstallationContainer(Version version, Task installationTask)
Create and add a new ByondInstallation to installedVersions.
TaskCompletionSource activeVersionChanged
TaskCompletionSource that notifes when the ActiveVersion changes.
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
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 .
For downloading and installing BYOND extractions for a given system.
string PathToUserByondFolder
The path to the BYOND folder for the user.
Task< MemoryStream > DownloadVersion(Version version, CancellationToken cancellationToken)
Download a given BYOND version .
string DreamMakerName
Get the file name of the DreamMaker executable.
string GetDreamDaemonName(Version version, out bool supportsCli)
Get the file name of the DreamDaemon executable.
Task InstallByond(Version version, string path, CancellationToken cancellationToken)
Does actions necessary to get an extracted BYOND installation working.
Task UpgradeInstallation(Version version, string path, CancellationToken cancellationToken)
Does actions necessary to get upgrade a BYOND version installed by a previous version of TGS.
For managing the BYOND installation.
Consumes EventTypes and takes the appropriate actions.
Task 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.
string ConcatPath(params string[] paths)
Combines an array of strings into a path.
Task< IReadOnlyList< string > > GetDirectories(string path, CancellationToken cancellationToken)
Returns 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 .
Task ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken)
Extract a set of zipFile to a given path .
Task< byte[]> ReadAllBytes(string path, CancellationToken cancellationToken)
Returns all the contents of a file at path as a byte array.
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.
Task WriteAllBytes(string path, byte[] contents, CancellationToken cancellationToken)
Writes some contents to a file at path overwriting previous content.
ErrorCode
Types of Response.ErrorMessageResponses that the API may return.
Definition: ErrorCode.cs:11
EventType
Types of events. Mirror in tgs.dm.
Definition: EventType.cs:7