tgstation-server 6.19.1
The /tg/station 13 server suite
Loading...
Searching...
No Matches
DreamMaker.cs
Go to the documentation of this file.
1using System;
2using System.Collections.Generic;
3using System.Linq;
4using System.Text;
5using System.Threading;
6using System.Threading.Tasks;
7
8using Microsoft.EntityFrameworkCore;
9using Microsoft.Extensions.Logging;
10using Microsoft.Extensions.Options;
11
12using Prometheus;
13
30
32{
34#pragma warning disable CA1506 // TODO: Decomplexify
35 sealed class DreamMaker : IDreamMaker
36#pragma warning restore CA1506
37 {
41 const string DmeExtension = "dme";
42
47
52
57
62
67
72
77
82
87
92
96 readonly IOptionsMonitor<SessionConfiguration> sessionConfigurationOptions;
97
101 readonly ILogger<DreamMaker> logger;
102
107
111 readonly Counter attemptedDeployments;
112
116 readonly Counter successfulDeployments;
117
121 readonly Counter failedDeployments;
122
126 readonly object deploymentLock;
127
132
136 Func<string?, string, Action<bool>>? currentChatCallback;
137
142
147
153 static string FormatExceptionForUsers(Exception exception)
154 => exception is OperationCanceledException
155 ? "The job was cancelled!"
156 : exception.Message;
157
178 StaticFiles.IConfiguration configuration,
186 IMetricFactory metricFactory,
187 IOptionsMonitor<SessionConfiguration> sessionConfigurationOptions,
188 ILogger<DreamMaker> logger,
189 Api.Models.Instance metadata)
190 {
191 this.engineManager = engineManager ?? throw new ArgumentNullException(nameof(engineManager));
192 this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
193 this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
194 this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer));
195 this.chatManager = chatManager ?? throw new ArgumentNullException(nameof(chatManager));
196 this.processExecutor = processExecutor ?? throw new ArgumentNullException(nameof(processExecutor));
197 this.compileJobConsumer = compileJobConsumer ?? throw new ArgumentNullException(nameof(compileJobConsumer));
198 this.repositoryManager = repositoryManager ?? throw new ArgumentNullException(nameof(repositoryManager));
199 this.remoteDeploymentManagerFactory = remoteDeploymentManagerFactory ?? throw new ArgumentNullException(nameof(remoteDeploymentManagerFactory));
200 this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
201 ArgumentNullException.ThrowIfNull(metricFactory);
202 this.sessionConfigurationOptions = sessionConfigurationOptions ?? throw new ArgumentNullException(nameof(sessionConfigurationOptions));
203 this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
204 this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
205
206 successfulDeployments = metricFactory.CreateCounter("tgs_successful_deployments", "The number of deployments that have completed successfully");
207 failedDeployments = metricFactory.CreateCounter("tgs_failed_deployments", "The number of deployments that have failed");
208 attemptedDeployments = metricFactory.CreateCounter("tgs_total_deployments", "The number of deployments that have been attempted");
209
210 deploymentLock = new object();
211 }
212
214#pragma warning disable CA1506
215 public async ValueTask DeploymentProcess(
216 Models.Job job,
217 IDatabaseContextFactory databaseContextFactory,
218 JobProgressReporter progressReporter,
219 CancellationToken cancellationToken)
220 {
221 ArgumentNullException.ThrowIfNull(job);
222 ArgumentNullException.ThrowIfNull(databaseContextFactory);
223 ArgumentNullException.ThrowIfNull(progressReporter);
224
225 lock (deploymentLock)
226 {
227 if (deploying)
228 throw new JobException(ErrorCode.DeploymentInProgress);
229 deploying = true;
230 }
231
233
234 currentChatCallback = null;
236 Models.CompileJob? compileJob = null;
237 bool success = false;
238 try
239 {
240 string? repoOwner = null;
241 string? repoName = null;
242 TimeSpan? averageSpan = null;
243 Models.RepositorySettings? repositorySettings = null;
244 Models.DreamDaemonSettings? ddSettings = null;
245 Models.DreamMakerSettings? dreamMakerSettings = null;
246 IRepository? repo = null;
247 IRemoteDeploymentManager? remoteDeploymentManager = null;
248 Models.RevisionInformation? revInfo = null;
249 await databaseContextFactory.UseContext(
250 async databaseContext =>
251 {
252 averageSpan = await CalculateExpectedDeploymentTime(databaseContext, cancellationToken);
253
254 ddSettings = await databaseContext
255 .DreamDaemonSettings
256 .Where(x => x.InstanceId == metadata.Id)
257 .Select(x => new Models.DreamDaemonSettings
258 {
259 StartupTimeout = x.StartupTimeout,
260 LogOutput = x.LogOutput,
261 })
262 .FirstOrDefaultAsync(cancellationToken);
263 if (ddSettings == default)
264 throw new JobException(ErrorCode.InstanceMissingDreamDaemonSettings);
265
266 dreamMakerSettings = await databaseContext
267 .DreamMakerSettings
268 .Where(x => x.InstanceId == metadata.Id)
269 .FirstAsync(cancellationToken);
270 if (dreamMakerSettings == default)
271 throw new JobException(ErrorCode.InstanceMissingDreamMakerSettings);
272
273 repositorySettings = await databaseContext
274 .RepositorySettings
275 .Where(x => x.InstanceId == metadata.Id)
276 .Select(x => new Models.RepositorySettings
277 {
278 AccessToken = x.AccessToken,
279 AccessUser = x.AccessUser,
280 ShowTestMergeCommitters = x.ShowTestMergeCommitters,
281 PushTestMergeCommits = x.PushTestMergeCommits,
282 PostTestMergeComment = x.PostTestMergeComment,
283 })
284 .FirstOrDefaultAsync(cancellationToken);
285 if (repositorySettings == default)
286 throw new JobException(ErrorCode.InstanceMissingRepositorySettings);
287
288 repo = await repositoryManager.LoadRepository(cancellationToken);
289 try
290 {
291 if (repo == null)
292 throw new JobException(ErrorCode.RepoMissing);
293
294 remoteDeploymentManager = remoteDeploymentManagerFactory
296
297 var repoSha = repo.Head;
298 repoOwner = repo.RemoteRepositoryOwner;
299 repoName = repo.RemoteRepositoryName;
300 revInfo = await databaseContext
301 .RevisionInformations
302 .Where(x => x.CommitSha == repoSha && x.InstanceId == metadata.Id)
303 .Include(x => x.ActiveTestMerges!)
304 .ThenInclude(x => x.TestMerge!)
305 .ThenInclude(x => x.MergedBy)
306 .FirstOrDefaultAsync(cancellationToken);
307
308 if (revInfo == null)
309 {
310 revInfo = new Models.RevisionInformation
311 {
312 CommitSha = repoSha,
313 Timestamp = await repo.TimestampCommit(repoSha, cancellationToken),
314 OriginCommitSha = repoSha,
316 {
317 Id = metadata.Id,
318 },
319 ActiveTestMerges = new List<RevInfoTestMerge>(),
320 };
321
322 logger.LogInformation(Repository.Repository.OriginTrackingErrorTemplate, repoSha);
323 databaseContext.RevisionInformations.Add(revInfo);
324 databaseContext.Instances.Attach(revInfo.Instance);
325 await databaseContext.Save(cancellationToken);
326 }
327 }
328 catch
329 {
330 repo?.Dispose();
331 throw;
332 }
333 });
334
335 Models.CompileJob? oldCompileJob;
336 using (repo)
337 {
338 var likelyPushedTestMergeCommit =
339 repositorySettings!.PushTestMergeCommits!.Value
340 && repositorySettings.AccessToken != null
341 && repositorySettings.AccessUser != null;
342 oldCompileJob = await compileJobConsumer.LatestCompileJob();
343 compileJob = await Compile(
344 job,
345 oldCompileJob,
346 revInfo!,
347 dreamMakerSettings!,
348 ddSettings!,
349 repo!,
350 remoteDeploymentManager!,
351 progressReporter,
352 averageSpan,
353 likelyPushedTestMergeCommit,
354 cancellationToken);
355 }
356
357 try
358 {
359 await databaseContextFactory.UseContext(
360 async databaseContext =>
361 {
362 var fullJob = compileJob.Job;
363 compileJob.Job = new Models.Job(job.Require(x => x.Id));
364 var fullRevInfo = compileJob.RevisionInformation;
365 compileJob.RevisionInformation = new Models.RevisionInformation
366 {
367 Id = revInfo!.Id,
368 };
369
370 databaseContext.Jobs.Attach(compileJob.Job);
371 databaseContext.RevisionInformations.Attach(compileJob.RevisionInformation);
372 databaseContext.CompileJobs.Add(compileJob);
373
374 // The difficulty with compile jobs is they have a two part commit
375 await databaseContext.Save(cancellationToken);
376 logger.LogTrace("Created CompileJob {compileJobId}", compileJob.Id);
377 try
378 {
379 var chatNotificationAction = currentChatCallback!(null, compileJob.Output!);
380 await compileJobConsumer.LoadCompileJob(compileJob, chatNotificationAction, cancellationToken);
381 success = true;
382 }
383 catch
384 {
385 // So we need to un-commit the compile job if the above throws
386 databaseContext.CompileJobs.Remove(compileJob);
387
388 // DCT: Cancellation token is for job, operation must run regardless
389 await databaseContext.Save(CancellationToken.None);
390 throw;
391 }
392
393 compileJob.Job = fullJob;
394 compileJob.RevisionInformation = fullRevInfo;
395 });
396 }
397 catch (Exception ex)
398 {
399 await CleanupFailedCompile(compileJob, remoteDeploymentManager!, ex);
400 throw;
401 }
402
403 var commentsTask = remoteDeploymentManager!.PostDeploymentComments(
404 compileJob,
405 oldCompileJob?.RevisionInformation,
406 repositorySettings,
407 repoOwner,
408 repoName,
409 cancellationToken);
410
411 var eventTask = eventConsumer.HandleEvent(EventType.DeploymentComplete, Enumerable.Empty<string>(), false, false, cancellationToken);
412
413 try
414 {
415 await ValueTaskExtensions.WhenAll(commentsTask, eventTask);
416 }
417 catch (Exception ex)
418 {
419 throw new JobException(ErrorCode.PostDeployFailure, ex);
420 }
421 finally
422 {
423 currentChatCallback = null;
424 }
425 }
426 catch (Exception ex)
427 {
428 currentChatCallback?.Invoke(
431
432 throw;
433 }
434 finally
435 {
436 deploying = false;
437 if (success)
439 else
440 failedDeployments.Inc();
441 }
442 }
443#pragma warning restore CA1506
444
450 {
451 ArgumentNullException.ThrowIfNull(sessionControllerFactory);
452 if (Interlocked.CompareExchange(ref this.sessionControllerFactory, sessionControllerFactory, null) != null)
453 throw new InvalidOperationException($"{nameof(SetSessionControllerFactory)} called multiple times!");
454 }
455
462 async ValueTask<TimeSpan?> CalculateExpectedDeploymentTime(IDatabaseContext databaseContext, CancellationToken cancellationToken)
463 {
464 var previousCompileJobs = await databaseContext
466 .Where(x => x.Job.Instance!.Id == metadata.Id)
467 .OrderByDescending(x => x.Job.StoppedAt)
468 .Take(10)
469 .Select(x => new
470 {
471 StoppedAt = x.Job.StoppedAt!.Value,
472 StartedAt = x.Job.StartedAt!.Value,
473 })
474 .ToListAsync(cancellationToken);
475
476 TimeSpan? averageSpan = null;
477 if (previousCompileJobs.Count != 0)
478 {
479 var totalSpan = TimeSpan.Zero;
480 foreach (var previousCompileJob in previousCompileJobs)
481 totalSpan += previousCompileJob.StoppedAt - previousCompileJob.StartedAt;
482 averageSpan = totalSpan / previousCompileJobs.Count;
483 }
484
485 return averageSpan;
486 }
487
503 async ValueTask<Models.CompileJob> Compile(
504 Models.Job job,
505 Models.CompileJob? oldCompileJob,
506 Models.RevisionInformation revisionInformation,
507 Api.Models.Internal.DreamMakerSettings dreamMakerSettings,
508 DreamDaemonLaunchParameters launchParameters,
509 IRepository repository,
510 IRemoteDeploymentManager remoteDeploymentManager,
511 JobProgressReporter progressReporter,
512 TimeSpan? estimatedDuration,
513 bool localCommitExistsOnRemote,
514 CancellationToken cancellationToken)
515 {
516 logger.LogTrace("Begin Compile");
517
518 using var progressCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
519
520 progressReporter.StageName = "Reserving BYOND version";
521 var progressTask = ProgressTask(progressReporter, estimatedDuration, progressCts.Token);
522 try
523 {
524 using var engineLock = await engineManager.UseExecutables(null, null, cancellationToken);
526 revisionInformation,
527 oldCompileJob?.RevisionInformation,
528 engineLock.Version,
529 DateTimeOffset.UtcNow + estimatedDuration,
530 repository.RemoteRepositoryOwner,
531 repository.RemoteRepositoryName,
532 localCommitExistsOnRemote);
533
534 var compileJob = new Models.CompileJob(job, revisionInformation, engineLock.Version.ToString())
535 {
536 DirectoryName = Guid.NewGuid(),
537 DmeName = dreamMakerSettings.ProjectName,
538 RepositoryOrigin = repository.Origin.ToString(),
539 };
540
541 progressReporter.StageName = "Creating remote deployment notification";
542 await remoteDeploymentManager.StartDeployment(
543 repository,
544 compileJob,
545 cancellationToken);
546
547 logger.LogTrace("Deployment will timeout at {timeoutTime}", DateTimeOffset.UtcNow + dreamMakerSettings.Timeout!.Value);
548 using var timeoutTokenSource = new CancellationTokenSource(dreamMakerSettings.Timeout.Value);
549 var timeoutToken = timeoutTokenSource.Token;
550 using (timeoutToken.Register(() => logger.LogWarning("Deployment timed out!")))
551 {
552 using var combinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken, cancellationToken);
553 try
554 {
555 await RunCompileJob(
556 progressReporter,
557 compileJob,
558 dreamMakerSettings,
559 launchParameters,
560 engineLock,
561 repository,
562 remoteDeploymentManager,
563 combinedTokenSource.Token);
564 }
565 catch (OperationCanceledException) when (timeoutToken.IsCancellationRequested)
566 {
567 throw new JobException(ErrorCode.DeploymentTimeout);
568 }
569 }
570
571 return compileJob;
572 }
573 catch (OperationCanceledException)
574 {
575 // DCT: Cancellation token is for job, delaying here is fine
576 progressReporter.StageName = "Running CompileCancelled event";
577 await eventConsumer.HandleEvent(EventType.CompileCancelled, Enumerable.Empty<string>(), false, true, CancellationToken.None);
578 throw;
579 }
580 finally
581 {
582 progressCts.Cancel();
583 await progressTask;
584 }
585 }
586
599 async ValueTask RunCompileJob(
600 JobProgressReporter progressReporter,
601 Models.CompileJob job,
602 Api.Models.Internal.DreamMakerSettings dreamMakerSettings,
603 DreamDaemonLaunchParameters launchParameters,
604 IEngineExecutableLock engineLock,
605 IRepository repository,
606 IRemoteDeploymentManager remoteDeploymentManager,
607 CancellationToken cancellationToken)
608 {
609 var outputDirectory = job.DirectoryName!.Value.ToString();
610 logger.LogTrace("Compile output GUID: {dirGuid}", outputDirectory);
611
612 try
613 {
614 // copy the repository
615 logger.LogTrace("Copying repository to game directory");
616 progressReporter.StageName = "Copying repository";
617 var resolvedOutputDirectory = ioManager.ResolvePath(outputDirectory);
618 var repoOrigin = repository.Origin;
619 var repoReference = repository.Reference;
620 using (repository)
621 await repository.CopyTo(resolvedOutputDirectory, cancellationToken);
622
623 // repository closed now
624
625 // run precompile scripts
626 progressReporter.StageName = "Running PreCompile event";
628 EventType.CompileStart,
629 new List<string>
630 {
631 resolvedOutputDirectory,
632 repoOrigin.ToString(),
633 engineLock.Version.ToString(),
634 repoReference,
635 },
636 false,
637 true,
638 cancellationToken);
639
640 // determine the dme
641 progressReporter.StageName = "Determining .dme";
642 if (job.DmeName == null)
643 {
644 logger.LogTrace("Searching for available .dmes");
645 var foundPaths = await ioManager.GetFilesWithExtension(resolvedOutputDirectory, DmeExtension, true, cancellationToken);
646 var foundPath = foundPaths.FirstOrDefault();
647 if (foundPath == default)
648 throw new JobException(ErrorCode.DeploymentNoDme);
649 job.DmeName = foundPath.Substring(
650 resolvedOutputDirectory.Length + 1,
651 foundPath.Length - resolvedOutputDirectory.Length - DmeExtension.Length - 2); // +1 for . in extension
652 }
653 else
654 {
655 var targetDme = ioManager.ConcatPath(outputDirectory, String.Join('.', job.DmeName, DmeExtension));
656 if (!await ioManager.PathIsChildOf(outputDirectory, targetDme, cancellationToken))
657 throw new JobException(ErrorCode.DeploymentWrongDme);
658
659 var targetDmeExists = await ioManager.FileExists(targetDme, cancellationToken);
660 if (!targetDmeExists)
661 throw new JobException(ErrorCode.DeploymentMissingDme);
662 }
663
664 logger.LogDebug("Selected \"{dmeName}.dme\" for compilation!", job.DmeName);
665
666 progressReporter.StageName = "Modifying .dme";
667 await ModifyDme(job, cancellationToken);
668
669 // run precompile scripts
670 progressReporter.StageName = "Running PreDreamMaker event";
672 EventType.PreDreamMaker,
673 new List<string>
674 {
675 resolvedOutputDirectory,
676 repoOrigin.ToString(),
677 engineLock.Version.ToString(),
678 },
679 false,
680 true,
681 cancellationToken);
682
683 // run compiler
684 progressReporter.StageName = "Running Compiler";
685 var compileSuceeded = await RunDreamMaker(engineLock, job, dreamMakerSettings.CompilerAdditionalArguments, cancellationToken);
686
687 // Session takes ownership of the lock and Disposes it so save this for later
688 var engineVersion = engineLock.Version;
689
690 // verify api
691 try
692 {
693 if (!compileSuceeded)
694 throw new JobException(
695 ErrorCode.DeploymentExitCode,
696 new JobException($"Compilation failed:{Environment.NewLine}{Environment.NewLine}{job.Output}"));
697
698 await VerifyApi(
699 launchParameters.StartupTimeout!.Value,
700 dreamMakerSettings.ApiValidationSecurityLevel!.Value,
701 job,
702 progressReporter,
703 engineLock,
704 dreamMakerSettings.ApiValidationPort!.Value,
705 dreamMakerSettings.DMApiValidationMode!.Value,
706 launchParameters.LogOutput!.Value,
707 cancellationToken);
708 }
709 catch (JobException)
710 {
711 // DD never validated or compile failed
712 progressReporter.StageName = "Running CompileFailure event";
714 EventType.CompileFailure,
715 new List<string>
716 {
717 resolvedOutputDirectory,
718 compileSuceeded ? "1" : "0",
719 engineVersion.ToString(),
720 },
721 false,
722 true,
723 cancellationToken);
724 throw;
725 }
726
727 progressReporter.StageName = "Running CompileComplete event";
729 EventType.CompileComplete,
730 new List<string>
731 {
732 resolvedOutputDirectory,
733 engineVersion.ToString(),
734 },
735 false,
736 true,
737 cancellationToken);
738
739 logger.LogTrace("Applying static game file symlinks...");
740 progressReporter.StageName = "Symlinking GameStaticFiles";
741
742 // symlink in the static data
743 await configuration.SymlinkStaticFilesTo(resolvedOutputDirectory, cancellationToken);
744
745 logger.LogDebug("Compile complete!");
746 }
747 catch (Exception ex)
748 {
749 progressReporter.StageName = "Cleaning output directory";
750 await CleanupFailedCompile(job, remoteDeploymentManager, ex);
751 throw;
752 }
753 }
754
762 async ValueTask ProgressTask(JobProgressReporter progressReporter, TimeSpan? estimatedDuration, CancellationToken cancellationToken)
763 {
764 double? lastReport = estimatedDuration.HasValue ? 0 : null;
765 progressReporter.ReportProgress(lastReport);
766
767 var minimumSleepInterval = TimeSpan.FromMilliseconds(250);
768 var sleepInterval = estimatedDuration.HasValue ? estimatedDuration.Value / 100 : minimumSleepInterval;
769
770 if (estimatedDuration.HasValue)
771 {
772 logger.LogDebug("Compile is expected to take: {estimatedDuration}", estimatedDuration);
773 }
774 else
775 {
776 logger.LogTrace("No metric to estimate compile time.");
777 }
778
779 try
780 {
781 for (var iteration = 0; iteration < (estimatedDuration.HasValue ? 99 : Int32.MaxValue); ++iteration)
782 {
783 if (estimatedDuration.HasValue)
784 {
785 var nextInterval = DateTimeOffset.UtcNow + sleepInterval;
786 do
787 {
788 var remainingSleepThisInterval = nextInterval - DateTimeOffset.UtcNow;
789 var nextSleepSpan = remainingSleepThisInterval < minimumSleepInterval ? minimumSleepInterval : remainingSleepThisInterval;
790
791 await asyncDelayer.Delay(nextSleepSpan, cancellationToken);
792 progressReporter.ReportProgress(lastReport);
793 }
794 while (DateTimeOffset.UtcNow < nextInterval);
795 }
796 else
797 await asyncDelayer.Delay(minimumSleepInterval, cancellationToken);
798
799 lastReport = estimatedDuration.HasValue ? sleepInterval * (iteration + 1) / estimatedDuration.Value : null;
800 progressReporter.ReportProgress(lastReport);
801 }
802 }
803 catch (OperationCanceledException ex)
804 {
805 logger.LogTrace(ex, "ProgressTask aborted.");
806 }
807 catch (Exception ex)
808 {
809 logger.LogError(ex, "ProgressTask crashed!");
810 }
811 }
812
826 async ValueTask VerifyApi(
827 uint timeout,
828 DreamDaemonSecurity securityLevel,
829 Models.CompileJob job,
830 JobProgressReporter progressReporter,
831 IEngineExecutableLock engineLock,
832 ushort portToUse,
833 DMApiValidationMode validationMode,
834 bool logOutput,
835 CancellationToken cancellationToken)
836 {
837 if (validationMode == DMApiValidationMode.Skipped)
838 {
839 logger.LogDebug("Skipping DMAPI validation");
840 job.MinimumSecurityLevel = DreamDaemonSecurity.Ultrasafe;
841 return;
842 }
843
844 progressReporter.StageName = "Validating DMAPI";
845
846 var requireValidate = validationMode == DMApiValidationMode.Required;
847 logger.LogTrace("Verifying {possiblyRequired}DMAPI...", requireValidate ? "required " : String.Empty);
848 var launchParameters = new DreamDaemonLaunchParameters
849 {
850 AllowWebClient = false,
851 Port = portToUse,
852 OpenDreamTopicPort = 0,
853 SecurityLevel = securityLevel,
854 Visibility = DreamDaemonVisibility.Invisible,
855 StartupTimeout = timeout,
856 TopicRequestTimeout = 0, // not used
857 HealthCheckSeconds = 0, // not used
858 StartProfiler = false,
859 LogOutput = logOutput,
860 MapThreads = 1, // lowest possible amount
861 };
862
863 job.MinimumSecurityLevel = securityLevel; // needed for the TempDmbProvider
864
865 if (sessionControllerFactory == null)
866 throw new InvalidOperationException($"{nameof(SetSessionControllerFactory)} was not called!");
867
868 ApiValidationStatus validationStatus;
869 await using (var provider = new TemporaryDmbProvider(
870 ioManager.ResolvePath(job.DirectoryName!.Value.ToString()),
871 job,
872 engineLock.Version))
873 await using (var controller = await sessionControllerFactory.LaunchNew(provider, engineLock, launchParameters, true, cancellationToken))
874 {
875 var launchResult = await controller.LaunchResult.WaitAsync(cancellationToken);
876
877 if (launchResult.StartupTime.HasValue)
878 await controller.Lifetime.WaitAsync(cancellationToken);
879
880 if (!controller.Lifetime.IsCompleted)
881 await controller.DisposeAsync();
882
883 validationStatus = controller.ApiValidationStatus;
884
885 logger.LogTrace("API validation status: {validationStatus}", validationStatus);
886
887 job.DMApiVersion = controller.DMApiVersion;
888 }
889
890 switch (validationStatus)
891 {
892 case ApiValidationStatus.RequiresUltrasafe:
893 job.MinimumSecurityLevel = DreamDaemonSecurity.Ultrasafe;
894 return;
895 case ApiValidationStatus.RequiresSafe:
896 job.MinimumSecurityLevel = DreamDaemonSecurity.Safe;
897 return;
898 case ApiValidationStatus.RequiresTrusted:
899 job.MinimumSecurityLevel = DreamDaemonSecurity.Trusted;
900 return;
901 case ApiValidationStatus.NeverValidated:
902 if (requireValidate)
903 throw new JobException(ErrorCode.DeploymentNeverValidated);
904 job.MinimumSecurityLevel = DreamDaemonSecurity.Ultrasafe;
905 break;
906 case ApiValidationStatus.BadValidationRequest:
907 case ApiValidationStatus.Incompatible:
908 throw new JobException(ErrorCode.DeploymentInvalidValidation);
909 case ApiValidationStatus.UnaskedValidationRequest:
910 default:
911 throw new InvalidOperationException(
912 $"Session controller returned unexpected ApiValidationStatus: {validationStatus}");
913 }
914 }
915
924 async ValueTask<bool> RunDreamMaker(
925 IEngineExecutableLock engineLock,
926 Models.CompileJob job,
927 string? additionalCompilerArguments,
928 CancellationToken cancellationToken)
929 {
930 var environment = await engineLock.LoadEnv(logger, true, cancellationToken);
931 var arguments = engineLock.FormatCompilerArguments($"{job.DmeName}.{DmeExtension}", additionalCompilerArguments);
932
933 await using var dm = await processExecutor.LaunchProcess(
934 engineLock.CompilerExePath,
935 ioManager.ResolvePath(
936 job.DirectoryName!.Value.ToString()),
937 arguments,
938 cancellationToken,
939 environment,
940 readStandardHandles: true,
941 noShellExecute: true);
942
943 if (sessionConfigurationOptions.CurrentValue.LowPriorityDeploymentProcesses)
944 dm.AdjustPriority(false);
945
946 int exitCode;
947 using (cancellationToken.Register(() => dm.Terminate()))
948 exitCode = (await dm.Lifetime).Value;
949 cancellationToken.ThrowIfCancellationRequested();
950
951 logger.LogDebug("DreamMaker exit code: {exitCode}", exitCode);
952 job.Output = $"{await dm.GetCombinedOutput(cancellationToken)}{Environment.NewLine}{Environment.NewLine}Exit Code: {exitCode}";
953 logger.LogDebug("DreamMaker output: {newLine}{output}", Environment.NewLine, job.Output);
954
955 currentDreamMakerOutput = job.Output;
956 return exitCode == 0;
957 }
958
965 async ValueTask ModifyDme(Models.CompileJob job, CancellationToken cancellationToken)
966 {
967 var dmeFileName = String.Join('.', job.DmeName, DmeExtension);
968 var stringDirectoryName = job.DirectoryName!.Value.ToString();
969 var dmePath = ioManager.ConcatPath(stringDirectoryName, dmeFileName);
970 var dmeReadTask = ioManager.ReadAllBytes(dmePath, cancellationToken);
971
972 var dmeModificationsTask = configuration.CopyDMFilesTo(
973 dmeFileName,
974 ioManager.ResolvePath(
975 ioManager.ConcatPath(
976 stringDirectoryName,
977 ioManager.GetDirectoryName(dmeFileName))),
978 cancellationToken);
979
980 var dmeBytes = await dmeReadTask;
981 var dme = Encoding.UTF8.GetString(dmeBytes);
982
983 var dmeModifications = await dmeModificationsTask;
984
985 if (dmeModifications == null || dmeModifications.TotalDmeOverwrite)
986 {
987 if (dmeModifications != null)
988 logger.LogDebug(".dme replacement configured!");
989 else
990 logger.LogTrace("No .dme modifications required.");
991 return;
992 }
993
994 var dmeLines = new List<string>(dme.Split('\n', StringSplitOptions.None));
995 for (var dmeLineIndex = 0; dmeLineIndex < dmeLines.Count; ++dmeLineIndex)
996 {
997 var line = dmeLines[dmeLineIndex];
998 if (line.Contains("BEGIN_INCLUDE", StringComparison.Ordinal) && dmeModifications.HeadIncludeLine != null)
999 {
1000 var headIncludeLineNumber = dmeLineIndex + 1;
1001 logger.LogDebug(
1002 "Inserting HeadInclude.dm at line {lineNumber}: {includeLine}",
1003 headIncludeLineNumber,
1004 dmeModifications.HeadIncludeLine);
1005 dmeLines.Insert(headIncludeLineNumber, dmeModifications.HeadIncludeLine);
1006 ++dmeLineIndex;
1007 }
1008 else if (line.Contains("END_INCLUDE", StringComparison.Ordinal) && dmeModifications.TailIncludeLine != null)
1009 {
1010 logger.LogDebug(
1011 "Inserting TailInclude.dm at line {lineNumber}: {includeLine}",
1012 dmeLineIndex,
1013 dmeModifications.TailIncludeLine);
1014 dmeLines.Insert(dmeLineIndex, dmeModifications.TailIncludeLine);
1015 break;
1016 }
1017 }
1018
1019 dmeBytes = Encoding.UTF8.GetBytes(String.Join('\n', dmeLines));
1020 await ioManager.WriteAllBytes(dmePath, dmeBytes, cancellationToken);
1021 }
1022
1030 ValueTask CleanupFailedCompile(Models.CompileJob job, IRemoteDeploymentManager remoteDeploymentManager, Exception exception)
1031 {
1032 async ValueTask CleanDir()
1033 {
1034 if (sessionConfigurationOptions.CurrentValue.DelayCleaningFailedDeployments)
1035 {
1036 logger.LogDebug("Not cleaning up errored deployment directory {guid} due to config.", job.DirectoryName);
1037 return;
1038 }
1039
1040 logger.LogTrace("Cleaning compile directory...");
1041 var jobPath = job.DirectoryName!.Value.ToString();
1042 try
1043 {
1044 // DCT: None available
1045 await eventConsumer.HandleEvent(EventType.DeploymentCleanup, new List<string> { jobPath }, false, true, CancellationToken.None);
1046 await ioManager.DeleteDirectory(jobPath, CancellationToken.None);
1047 }
1048 catch (Exception e)
1049 {
1050 logger.LogWarning(e, "Error cleaning up compile directory {path}!", ioManager.ResolvePath(jobPath));
1051 }
1052 }
1053
1054 var dirCleanTask = CleanDir();
1055
1056 var failRemoteDeployTask = remoteDeploymentManager.FailDeployment(
1057 job,
1058 FormatExceptionForUsers(exception),
1059 CancellationToken.None); // DCT: None available
1060
1062 dirCleanTask,
1063 failRemoteDeployTask);
1064 }
1065 }
1066}
Metadata about a server instance.
Definition Instance.cs:9
bool? LogOutput
If process output/error text should be logged.
Represents information about a current git revison.
Extension methods for the ValueTask and ValueTask<TResult> classes.
static async ValueTask WhenAll(IEnumerable< ValueTask > tasks)
Fully await a given list of tasks .
DreamMaker(IEngineManager engineManager, IIOManager ioManager, StaticFiles.IConfiguration configuration, IEventConsumer eventConsumer, IChatManager chatManager, IProcessExecutor processExecutor, ICompileJobSink compileJobConsumer, IRepositoryManager repositoryManager, IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, IAsyncDelayer asyncDelayer, IMetricFactory metricFactory, IOptionsMonitor< SessionConfiguration > sessionConfigurationOptions, ILogger< DreamMaker > logger, Api.Models.Instance metadata)
Initializes a new instance of the DreamMaker class.
Func< string?, string, Action< bool > >? currentChatCallback
The active callback from IChatManager.QueueDeploymentMessage.
async ValueTask< Models.CompileJob > Compile(Models.Job job, Models.CompileJob? oldCompileJob, Models.RevisionInformation revisionInformation, Api.Models.Internal.DreamMakerSettings dreamMakerSettings, DreamDaemonLaunchParameters launchParameters, IRepository repository, IRemoteDeploymentManager remoteDeploymentManager, JobProgressReporter progressReporter, TimeSpan? estimatedDuration, bool localCommitExistsOnRemote, CancellationToken cancellationToken)
Run the compile implementation.
readonly IRepositoryManager repositoryManager
The IRepositoryManager for DreamMaker.
Definition DreamMaker.cs:76
ValueTask CleanupFailedCompile(Models.CompileJob job, IRemoteDeploymentManager remoteDeploymentManager, Exception exception)
Cleans up a failed compile job .
async ValueTask ProgressTask(JobProgressReporter progressReporter, TimeSpan? estimatedDuration, CancellationToken cancellationToken)
Gradually triggers a given progressReporter over a given estimatedDuration .
async ValueTask VerifyApi(uint timeout, DreamDaemonSecurity securityLevel, Models.CompileJob job, JobProgressReporter progressReporter, IEngineExecutableLock engineLock, ushort portToUse, DMApiValidationMode validationMode, bool logOutput, CancellationToken cancellationToken)
Run a quick DD instance to test the DMAPI is installed on the target code.
readonly ILogger< DreamMaker > logger
The ILogger for DreamMaker.
async ValueTask RunCompileJob(JobProgressReporter progressReporter, Models.CompileJob job, Api.Models.Internal.DreamMakerSettings dreamMakerSettings, DreamDaemonLaunchParameters launchParameters, IEngineExecutableLock engineLock, IRepository repository, IRemoteDeploymentManager remoteDeploymentManager, CancellationToken cancellationToken)
Executes and populate a given job .
readonly StaticFiles.IConfiguration configuration
The StaticFiles.IConfiguration for DreamMaker.
Definition DreamMaker.cs:56
const string DmeExtension
Extension for .dmes.
Definition DreamMaker.cs:41
async ValueTask DeploymentProcess(Models.Job job, IDatabaseContextFactory databaseContextFactory, JobProgressReporter progressReporter, CancellationToken cancellationToken)
Create and a compile job and insert it into the database. Meant to be called by a IJobManager....
string? currentDreamMakerOutput
Cached for currentChatCallback.
async ValueTask< TimeSpan?> CalculateExpectedDeploymentTime(IDatabaseContext databaseContext, CancellationToken cancellationToken)
Calculate the average length of a deployment using a given databaseContext .
readonly IIOManager ioManager
The IIOManager for DreamMaker.
Definition DreamMaker.cs:51
readonly IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory
The IRemoteDeploymentManagerFactory for DreamMaker.
Definition DreamMaker.cs:86
readonly Counter successfulDeployments
The number of successful deployments.
readonly Counter attemptedDeployments
The number of attempted deployments.
readonly Counter failedDeployments
The number of failed deployments.
static string FormatExceptionForUsers(Exception exception)
Format a given Exception for display to users.
readonly IProcessExecutor processExecutor
The IProcessExecutor for DreamMaker.
Definition DreamMaker.cs:71
void SetSessionControllerFactory(ISessionControllerFactory sessionControllerFactory)
Set the sessionControllerFactory for the DreamMaker. Must be called exactly once after construction b...
readonly IEngineManager engineManager
The IEngineManager for DreamMaker.
Definition DreamMaker.cs:46
readonly ICompileJobSink compileJobConsumer
The ICompileJobSink for DreamMaker.
Definition DreamMaker.cs:81
readonly IEventConsumer eventConsumer
The IEventConsumer for DreamMaker.
Definition DreamMaker.cs:61
readonly Api.Models.Instance metadata
The Instance DreamMaker belongs to.
ISessionControllerFactory? sessionControllerFactory
The ISessionControllerFactory for DreamMaker.
async ValueTask ModifyDme(Models.CompileJob job, CancellationToken cancellationToken)
Adds server side includes to the .dme being compiled.
readonly IOptionsMonitor< SessionConfiguration > sessionConfigurationOptions
The IOptionsMonitor<TOptions> of SessionConfiguration for DreamMaker.
Definition DreamMaker.cs:96
readonly IChatManager chatManager
The IChatManager for DreamMaker.
Definition DreamMaker.cs:66
async ValueTask< bool > RunDreamMaker(IEngineExecutableLock engineLock, Models.CompileJob job, string? additionalCompilerArguments, CancellationToken cancellationToken)
Compiles a .dme with DreamMaker.
readonly object deploymentLock
lock object for deploying.
readonly IAsyncDelayer asyncDelayer
The IAsyncDelayer for DreamMaker.
Definition DreamMaker.cs:91
Repository(LibGit2Sharp.IRepository libGitRepo, ILibGit2Commands commands, IIOManager ioManager, IEventConsumer eventConsumer, ICredentialsProvider credentialsProvider, IPostWriteHandler postWriteHandler, IGitRemoteFeaturesFactory gitRemoteFeaturesFactory, ILibGit2RepositoryFactory submoduleFactory, IOptionsMonitor< GeneralConfiguration > generalConfigurationOptions, ILogger< Repository > logger, Action disposeAction)
Initializes a new instance of the Repository class.
Operation exceptions thrown from the context of a Models.Job.
void ReportProgress(double? progress)
Report progress.
Represents an Api.Models.Instance in the database.
Definition Instance.cs:11
string? RemoteRepositoryName
If RemoteGitProvider is not RemoteGitProvider.Unknown this will be set with the name of the repositor...
RemoteGitProvider? RemoteGitProvider
The Models.RemoteGitProvider in use by the repository.
string? RemoteRepositoryOwner
If RemoteGitProvider is not RemoteGitProvider.Unknown this will be set with the owner of the reposito...
For managing connected chat services.
Func< string?, string, Action< bool > > QueueDeploymentMessage(Models.RevisionInformation revisionInformation, Models.RevisionInformation? previousRevisionInformation, Api.Models.EngineVersion engineVersion, DateTimeOffset? estimatedCompletionTime, string? gitHubOwner, string? gitHubRepo, bool localCommitPushed)
Send the message for a deployment to configured deployment channels.
ValueTask LoadCompileJob(CompileJob job, Action< bool >? activationAction, CancellationToken cancellationToken)
Load a new job into the ICompileJobSink.
ValueTask< CompileJob?> LatestCompileJob()
Gets the latest CompileJob.
IRemoteDeploymentManager CreateRemoteDeploymentManager(Api.Models.Instance metadata, RemoteGitProvider remoteGitProvider)
Creates a IRemoteDeploymentManager for a given remoteGitProvider .
ValueTask PostDeploymentComments(CompileJob compileJob, RevisionInformation? previousRevisionInformation, RepositorySettings repositorySettings, string? repoOwner, string? repoName, CancellationToken cancellationToken)
Post deployment comments to the test merge ticket.
ValueTask StartDeployment(Api.Models.Internal.IGitRemoteInformation remoteInformation, CompileJob compileJob, CancellationToken cancellationToken)
Start a deployment for a given compileJob .
ValueTask FailDeployment(CompileJob compileJob, string errorMessage, CancellationToken cancellationToken)
Fail a deployment for a given compileJob .
Represents usage of the two primary BYOND server executables.
string FormatCompilerArguments(string dmePath, string? additionalArguments)
Return the command line arguments for compiling a given dmePath if compilation is necessary.
ValueTask< Dictionary< string, string >?> LoadEnv(ILogger logger, bool forCompiler, CancellationToken cancellationToken)
Loads the environment settings for either the server or compiler.
EngineVersion Version
The EngineVersion of the IEngineInstallation.
string CompilerExePath
The full path to the dm/DreamMaker executable.
ValueTask< IEngineExecutableLock > UseExecutables(EngineVersion? requiredVersion, string? trustDmbFullPath, CancellationToken cancellationToken)
Lock the current installation's location and return a IEngineExecutableLock.
Consumes EventTypes and takes the appropriate actions.
ValueTask HandleEvent(EventType eventType, IEnumerable< string?> parameters, bool sensitiveParameters, bool deploymentPipeline, CancellationToken cancellationToken)
Handle a given eventType .
Represents an on-disk git repository.
Task< DateTimeOffset > TimestampCommit(string sha, CancellationToken cancellationToken)
Gets the DateTimeOffset a given sha was created on.
string Reference
The current reference the IRepository HEAD is using. This can be a branch or tag.
ValueTask CopyTo(string path, CancellationToken cancellationToken)
Copies the current working directory to a given path .
string Head
The SHA of the IRepository HEAD.
Uri Origin
The current origin remote the IRepository is using.
ValueTask< IRepository?> LoadRepository(CancellationToken cancellationToken)
Attempt to load the IRepository from the default location.
ValueTask< ISessionController > LaunchNew(IDmbProvider dmbProvider, IEngineExecutableLock? currentByondLock, DreamDaemonLaunchParameters launchParameters, bool apiValidate, CancellationToken cancellationToken)
Create a ISessionController from a freshly launch DreamDaemon instance.
Factory for scoping usage of IDatabaseContexts. Meant for use by Components.
ValueTask UseContext(Func< IDatabaseContext, ValueTask > operation)
Run an operation in the scope of an IDatabaseContext.
IDatabaseCollection< CompileJob > CompileJobs
The CompileJobs in the IDatabaseContext.
Interface for using filesystems.
Definition IIOManager.cs:14
Task< bool > PathIsChildOf(string parentPath, string childPath, CancellationToken cancellationToken)
Check if a given parentPath is a parent of a given parentPath .
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< bool > FileExists(string path, CancellationToken cancellationToken)
Check that the file at path exists.
Task< List< string > > GetFilesWithExtension(string path, string extension, bool recursive, CancellationToken cancellationToken)
Gets a list of files in path with the given extension .
ErrorCode
Types of Response.ErrorMessageResponses that the API may return.
Definition ErrorCode.cs:12
DreamDaemonVisibility
The visibility setting for DreamDaemon.
DreamDaemonSecurity
DreamDaemon's security level.
DMApiValidationMode
The DMAPI validation setting for deployments.
EventType
Types of events. Mirror in tgs.dm. Prefer last listed name for script.
Definition EventType.cs:7
ApiValidationStatus
Status of DMAPI validation.