2using System.Collections.Generic;
3using System.Globalization;
6using System.Security.Cryptography;
9using System.Threading.Tasks;
11using Microsoft.Extensions.Logging;
12using Microsoft.Extensions.Options;
65 static readonly
string DefaultHeadInclude = @$
"// TGS AUTO GENERATED HeadInclude.dm{Environment.NewLine}// This file will be included BEFORE all code in your .dme IF a replacement .dme does not exist in this directory{Environment.NewLine}// Please note that changes need to be made available if you are hosting an AGPL licensed codebase{Environment.NewLine}// The presence file in its default state does not constitute a code change that needs to be published by licensing standards{Environment.NewLine}";
70 static readonly
string DefaultTailInclude = @$
"// TGS AUTO GENERATED TailInclude.dm{Environment.NewLine}// This file will be included AFTER all code in your .dme IF a replacement .dme does not exist in this directory{Environment.NewLine}// Please note that changes need to be made available if you are hosting an AGPL licensed codebase{Environment.NewLine}// The presence file in its default state does not constitute a code change that needs to be published by licensing standards{Environment.NewLine}";
79 eventType =>
new KeyValuePair<
EventType,
string[]>(
82 .GetField(eventType.ToString())!
83 .GetCustomAttributes(
false)
182 ILogger<Configuration>
logger,
194 this.logger =
logger ??
throw new ArgumentNullException(nameof(
logger));
195 this.metadata =
metadata ??
throw new ArgumentNullException(nameof(
metadata));
210 public async ValueTask<ServerSideModifications?>
CopyDMFilesTo(
string dmeFile,
string destination, CancellationToken cancellationToken)
221 await ensureDirectoriesTask;
230 await Task.WhenAll(dmeExistsTask, headFileExistsTask, tailFileExistsTask, copyTask.AsTask());
232 if (!dmeExistsTask.Result && !headFileExistsTask.Result && !tailFileExistsTask.Result)
235 if (dmeExistsTask.Result)
238 if (!headFileExistsTask.Result && !tailFileExistsTask.Result)
241 static string IncludeLine(
string filePath) => String.Format(CultureInfo.InvariantCulture,
"#include \"{0}\"", filePath);
244 headFileExistsTask.Result
247 tailFileExistsTask.Result
255 public async ValueTask<IOrderedQueryable<ConfigurationFileResponse>?>
ListDirectory(
string? configurationRelativePath,
ISystemIdentity? systemIdentity, CancellationToken cancellationToken)
260 configurationRelativePath ??=
"/";
262 var result =
new List<ConfigurationFileResponse>();
270 Path = ioManager.ConcatPath(configurationRelativePath, x),
277 Path = ioManager.ConcatPath(configurationRelativePath, x),
285 logger.LogDebug(
"Contention when attempting to enumerate directory!");
289 if (systemIdentity ==
null)
297 .OrderBy(configFile => !configFile.IsDirectory)
298 .ThenBy(configFile => configFile.Path);
302 public async ValueTask<ConfigurationFileResponse?>
Read(
string configurationRelativePath,
ISystemIdentity? systemIdentity, CancellationToken cancellationToken)
316 return String.Join(String.Empty, SHA1.HashData(content).Select(b => b.ToString(
"x2", CultureInfo.InvariantCulture)));
319 var originalSha = GetFileSha();
326 if (disposeToken.IsCancellationRequested)
329 var newSha = GetFileSha();
330 if (newSha != originalSha)
331 return ErrorCode.ConfigurationFileUpdated;
335 async cancellationToken =>
343 if (systemIdentity ==
null)
346 await systemIdentity.
RunImpersonated(GetFileStream, cancellationToken);
357 LastReadHash = originalSha,
358 AccessDenied =
false,
359 Path = configurationRelativePath,
362 catch (UnauthorizedAccessException)
372 logger.LogDebug(ex,
"IsDirectory exception!");
378 Path = configurationRelativePath,
381 result.AccessDenied =
true;
383 result.IsDirectory = isDirectory;
391 logger.LogDebug(
"Contention when attempting to read file!");
395 if (systemIdentity ==
null)
407 List<string> ignoreFiles;
409 async ValueTask SymlinkBase(
bool files)
411 Task<IReadOnlyList<string>> task;
416 var entries = await task;
420 var fileName = ioManager.GetFileName(file);
423 var fileComparison = platformIdentifier.IsWindows
424 ? StringComparison.OrdinalIgnoreCase
425 : StringComparison.Ordinal;
426 var ignored = ignoreFiles.Any(y => fileName.Equals(y, fileComparison));
429 logger.LogTrace(
"Ignoring static file {fileName}...", fileName);
434 logger.LogTrace(
"Symlinking {filePath} to {destPath}...", file, destPath);
438 var fileExists = await fileExistsTask;
449 var ignoreFileText = Encoding.UTF8.GetString(ignoreFileBytes);
454 using (var reader =
new StringReader(ignoreFileText))
456 cancellationToken.ThrowIfCancellationRequested();
457 var line = await reader.ReadLineAsync(cancellationToken);
458 if (!String.IsNullOrEmpty(line))
459 ignoreFiles.Add(line);
462 var filesSymlinkTask = SymlinkBase(
true);
463 var dirsSymlinkTask = SymlinkBase(
false);
469 public async ValueTask<ConfigurationFileResponse?>
Write(
string configurationRelativePath,
ISystemIdentity? systemIdentity,
string? previousHash, CancellationToken cancellationToken)
471 await EnsureDirectories(cancellationToken);
472 var path = ValidateConfigRelativePath(configurationRelativePath);
474 logger.LogTrace(
"Starting write to {path}", path);
483 var uploadCancellationToken = stoppingCts.Token;
484 async Task UploadHandler()
486 await
using (fileTicket)
488 var fileHash = previousHash;
489 logger.LogTrace(
"Write to {path} waiting for upload stream", path);
490 var uploadStream = await fileTicket.GetResult(uploadCancellationToken);
491 if (uploadStream ==
null)
493 logger.LogTrace(
"Write to {path} expired", path);
497 logger.LogTrace(
"Write to {path} received stream of length {length}...", path, uploadStream.Length);
498 bool success =
false;
501 logger.LogTrace(
"Running synchronous write...");
502 success = synchronousIOManager.WriteFileChecked(path, uploadStream, ref fileHash, uploadCancellationToken);
503 logger.LogTrace(
"Finished write {un}successfully!", success ? String.Empty :
"un");
506 if (fileTicket ==
null)
508 logger.LogDebug(
"File upload ticket for {path} expired!", path);
516 fileTicket.SetError(
ErrorCode.ConfigurationContendedAccess,
null);
520 logger.LogTrace(
"Kicking off write callback");
521 if (systemIdentity ==
null)
524 await systemIdentity.
RunImpersonated(WriteCallback, uploadCancellationToken);
528 fileTicket.SetError(
ErrorCode.ConfigurationFileUpdated, fileHash);
529 else if (uploadStream.Length > 0)
530 postWriteHandler.HandleWrite(path);
532 logger.LogTrace(
"Write complete");
539 LastReadHash = previousHash,
541 AccessDenied =
false,
542 Path = configurationRelativePath,
547 async Task ChainUploadTasks()
549 var oldUploadTask = uploadTasks;
550 var newUploadTask = UploadHandler();
561 uploadTasks = ChainUploadTasks();
564 catch (UnauthorizedAccessException)
570 isDirectory = synchronousIOManager.IsDirectory(path);
574 logger.LogDebug(ex,
"IsDirectory exception!");
580 Path = configurationRelativePath,
583 result.AccessDenied =
true;
585 result.IsDirectory = isDirectory;
593 logger.LogDebug(
"Contention when attempting to write file!");
597 if (systemIdentity ==
null)
609 await EnsureDirectories(cancellationToken);
610 var path = ValidateConfigRelativePath(configurationRelativePath);
613 void DoCreate() => result = synchronousIOManager.CreateDirectory(path, cancellationToken);
619 logger.LogDebug(
"Contention when attempting to create directory!");
623 if (systemIdentity ==
null)
629 return result!.Value;
633 public Task
StartAsync(CancellationToken cancellationToken) => EnsureDirectories(cancellationToken);
636 public async Task
StopAsync(CancellationToken cancellationToken)
638 await EnsureDirectories(cancellationToken);
640 stoppingCts.Cancel();
645 catch (OperationCanceledException ex)
647 logger.LogDebug(ex,
"One or more uploads/downloads were aborted!");
651 logger.LogError(ex,
"Error awaiting upload tasks!");
656 public ValueTask
HandleEvent(
EventType eventType, IEnumerable<string?> parameters,
bool deploymentPipeline, CancellationToken cancellationToken)
658 ArgumentNullException.ThrowIfNull(parameters);
660 if (!EventTypeScriptFileNameMap.TryGetValue(eventType, out var scriptNames))
662 logger.LogTrace(
"No event script for event {event}!", eventType);
663 return ValueTask.CompletedTask;
666 return ExecuteEventScripts(parameters, deploymentPipeline, cancellationToken, scriptNames);
670 public ValueTask?
HandleCustomEvent(
string scriptName, IEnumerable<string?> parameters, CancellationToken cancellationToken)
672 var scriptNameIsTgsEventName = EventTypeScriptFileNameMap
674 .SelectMany(scriptNames => scriptNames)
675 .Any(tgsScriptName => tgsScriptName.Equals(
677 platformIdentifier.IsWindows
678 ? StringComparison.OrdinalIgnoreCase
679 : StringComparison.Ordinal));
680 if (scriptNameIsTgsEventName)
682 logger.LogWarning(
"DMAPI attempted to execute TGS reserved event: {eventName}", scriptName);
686#pragma warning disable CA2012
687 return ExecuteEventScripts(parameters,
false, cancellationToken, scriptName);
688#pragma warning restore CA2012
694 await EnsureDirectories(cancellationToken);
695 var path = ValidateConfigRelativePath(configurationRelativePath);
702 logger.LogDebug(
"Contention when attempting to enumerate directory!");
706 void CheckDeleteImpl() => result = synchronousIOManager.DeleteDirectory(path);
708 if (systemIdentity !=
null)
709 await systemIdentity.
RunImpersonated(CheckDeleteImpl, cancellationToken);
721 string StaticIgnorePath() => ioManager.ConcatPath(GameStaticFilesSubdirectory, StaticIgnoreFile);
730 async Task ValidateStaticFolder()
732 await ioManager.CreateDirectory(GameStaticFilesSubdirectory, cancellationToken);
733 var staticIgnorePath = StaticIgnorePath();
734 if (!await ioManager.FileExists(staticIgnorePath, cancellationToken))
735 await ioManager.WriteAllBytes(staticIgnorePath, Array.Empty<
byte>(), cancellationToken);
738 async Task ValidateCodeModsFolder()
740 if (await ioManager.DirectoryExists(CodeModificationsSubdirectory, cancellationToken))
743 await ioManager.CreateDirectory(CodeModificationsSubdirectory, cancellationToken);
744 var headWriteTask = ioManager.WriteAllBytes(
745 ioManager.ConcatPath(
746 CodeModificationsSubdirectory,
747 CodeModificationsHeadFile),
748 Encoding.UTF8.GetBytes(DefaultHeadInclude),
750 var tailWriteTask = ioManager.WriteAllBytes(
751 ioManager.ConcatPath(
752 CodeModificationsSubdirectory,
753 CodeModificationsTailFile),
754 Encoding.UTF8.GetBytes(DefaultTailInclude),
760 ValidateCodeModsFolder(),
761 ioManager.CreateDirectory(EventScriptsSubdirectory, cancellationToken),
762 ValidateStaticFolder());
772 var nullOrEmptyCheck = String.IsNullOrEmpty(configurationRelativePath);
773 if (nullOrEmptyCheck)
775 if (configurationRelativePath![0] == ioManager.DirectorySeparatorChar || configurationRelativePath[0] == ioManager.AltDirectorySeparatorChar)
776 configurationRelativePath = DefaultIOManager.CurrentDirectory + configurationRelativePath;
777 var resolved = ioManager.ResolvePath(configurationRelativePath);
778 var local = !nullOrEmptyCheck ? ioManager.ResolvePath() :
null;
779 if (!nullOrEmptyCheck && resolved.Length < local!.Length)
780 throw new InvalidOperationException(
"Attempted to access file outside of configuration manager!");
792 async ValueTask
ExecuteEventScripts(IEnumerable<string?> parameters,
bool deploymentPipeline, CancellationToken cancellationToken, params
string[] scriptNames)
794 await EnsureDirectories(cancellationToken);
799 var sessionConfiguration = sessionConfigurationOptions.CurrentValue;
800 var directories = generalConfigurationOptions.CurrentValue.AdditionalEventScriptsDirectories?.ToList() ??
new List<string>();
801 directories.Add(EventScriptsSubdirectory);
803 var allScripts =
new List<string>();
804 var tasks = directories.Select<string, ValueTask>(
805 async scriptDirectory =>
807 var resolvedScriptsDir = ioManager.ResolvePath(scriptDirectory);
808 logger.LogTrace(
"Checking for scripts in {directory}...", scriptDirectory);
809 var files = await ioManager.GetFilesWithExtension(scriptDirectory, platformIdentifier.ScriptFileExtension,
false, cancellationToken);
811 var scriptFiles = files
812 .Select(ioManager.GetFileName)
813 .Where(x => scriptNames.Any(
814 scriptName => x.StartsWith(scriptName, StringComparison.Ordinal)))
817 var fullScriptPath = ioManager.ConcatPath(resolvedScriptsDir, x);
818 logger.LogTrace(
"Found matching script: {scriptPath}", fullScriptPath);
819 return fullScriptPath;
823 allScripts.AddRange(scriptFiles);
828 if (allScripts.Count == 0)
830 logger.LogTrace(
"No event scripts starting with \"{scriptName}\" detected", String.Join(
"\" or \"", scriptNames));
834 var resolvedInstanceScriptsDir = ioManager.ResolvePath(EventScriptsSubdirectory);
836 foreach (var scriptFile
in allScripts.OrderBy(ioManager.GetFileName))
838 logger.LogTrace(
"Running event script {scriptFile}...", scriptFile);
839 await
using (var script = await processExecutor.LaunchProcess(
841 resolvedInstanceScriptsDir,
844 parameters.Select(arg =>
849 if (!arg.Contains(
' ', StringComparison.Ordinal))
852 arg = arg.Replace(
"\"",
"\\\"", StringComparison.Ordinal);
857 new Dictionary<string, string>
859 {
"TGS_INSTANCE_ROOT", metadata.Path! },
861 readStandardHandles:
true,
862 noShellExecute:
true))
863 using (cancellationToken.Register(() => script.Terminate()))
865 if (sessionConfiguration.LowPriorityDeploymentProcesses && deploymentPipeline)
866 script.AdjustPriority(
false);
868 var exitCode = await script.Lifetime;
869 cancellationToken.ThrowIfCancellationRequested();
870 var scriptOutput = await script.GetCombinedOutput(cancellationToken);
872 throw new JobException($
"Script {scriptFile} exited with code {exitCode}:{Environment.NewLine}{scriptOutput}");
874 logger.LogDebug(
"Script output:{newLine}{scriptOutput}", Environment.NewLine, scriptOutput);
Response when reading configuration files.
virtual ? string FileTicket
The ticket to use to access the Routes.Transfer controller.
Extension methods for the ValueTask and ValueTask<TResult> classes.
static async ValueTask WhenAll(IEnumerable< ValueTask > tasks)
Fully await a given list of tasks .
Attribute for indicating the script that a given EventType runs.
string[] ScriptNames
The name and order of the scripts the event script the EventType runs.
readonly IProcessExecutor processExecutor
The IProcessExecutor for Configuration.
string ValidateConfigRelativePath(string? configurationRelativePath)
Resolve a given configurationRelativePath to it's full path or throw an InvalidOperationException if...
readonly IFileTransferTicketProvider fileTransferService
The IFileTransferTicketProvider for Configuration.
static readonly string DefaultTailInclude
Default contents of CodeModificationsHeadFile.
readonly ISynchronousIOManager synchronousIOManager
The ISynchronousIOManager for Configuration.
async ValueTask ExecuteEventScripts(IEnumerable< string?> parameters, bool deploymentPipeline, CancellationToken cancellationToken, params string[] scriptNames)
Execute a set of given scriptNames .
const string StaticIgnoreFile
Name of the ignore file in GameStaticFilesSubdirectory.
Task EnsureDirectories(CancellationToken cancellationToken)
Ensures standard configuration directories exist.
readonly Models.Instance metadata
The Api.Models.Instance the Configuration belongs to.
string StaticIgnorePath()
Get the proper path to StaticIgnoreFile.
async ValueTask SymlinkStaticFilesTo(string destination, CancellationToken cancellationToken)
Symlinks all directories in the GameData directory to destination .A ValueTask representing the runni...
readonly IOptionsMonitor< GeneralConfiguration > generalConfigurationOptions
The IOptionsMonitor<TOptions> of GeneralConfiguration for Configuration.
readonly IOptionsMonitor< SessionConfiguration > sessionConfigurationOptions
The IOptionsMonitor<TOptions> of SessionConfiguration for Configuration.
Task uploadTasks
The culmination of all upload file transfer callbacks.
readonly IFilesystemLinkFactory linkFactory
The IFilesystemLinkFactory for Configuration.
const string CodeModificationsSubdirectory
The CodeModifications directory name.
readonly IPostWriteHandler postWriteHandler
The IPostWriteHandler for Configuration.
readonly IPlatformIdentifier platformIdentifier
The IPlatformIdentifier for Configuration.
async ValueTask< ConfigurationFileResponse?> Write(string configurationRelativePath, ISystemIdentity? systemIdentity, string? previousHash, CancellationToken cancellationToken)
Writes to a given configurationRelativePath .A ValueTask<TResult> resulting in the updated Configurat...
const string CodeModificationsHeadFile
The HeadInclude.dm filename.
ValueTask HandleEvent(EventType eventType, IEnumerable< string?> parameters, bool deploymentPipeline, CancellationToken cancellationToken)
Handle a given eventType .A ValueTask representing the running operation.
readonly CancellationTokenSource stoppingCts
The CancellationTokenSource that is triggered when StopAsync(CancellationToken) is called.
readonly ILogger< Configuration > logger
The ILogger for Configuration.
async ValueTask< IOrderedQueryable< ConfigurationFileResponse >?> ListDirectory(string? configurationRelativePath, ISystemIdentity? systemIdentity, CancellationToken cancellationToken)
Get ConfigurationFileResponses for all items in a given configurationRelativePath ....
const string CodeModificationsTailFile
The TailInclude.dm filename.
async ValueTask< bool?> DeleteDirectory(string configurationRelativePath, ISystemIdentity? systemIdentity, CancellationToken cancellationToken)
Attempt to delete an empty directory at configurationRelativePath .A ValueTask<TResult> resulting in ...
async Task StopAsync(CancellationToken cancellationToken)
ValueTask? HandleCustomEvent(string scriptName, IEnumerable< string?> parameters, CancellationToken cancellationToken)
Handles a given custom event.A ValueTask representing the running operation if the event was triggere...
async ValueTask< ServerSideModifications?> CopyDMFilesTo(string dmeFile, string destination, CancellationToken cancellationToken)
Copies all files in the CodeModifications directory to destination .A ValueTask<TResult> resulting in...
async ValueTask< bool?> CreateDirectory(string configurationRelativePath, ISystemIdentity? systemIdentity, CancellationToken cancellationToken)
Create an empty directory at configurationRelativePath .A ValueTask<TResult> resulting in true if the...
static readonly string DefaultHeadInclude
Default contents of CodeModificationsHeadFile.
async ValueTask< ConfigurationFileResponse?> Read(string configurationRelativePath, ISystemIdentity? systemIdentity, CancellationToken cancellationToken)
Reads a given configurationRelativePath .A ValueTask<TResult> resulting in the ConfigurationFileRespo...
const string GameStaticFilesSubdirectory
The GameStaticFiles directory name.
Configuration(IIOManager ioManager, ISynchronousIOManager synchronousIOManager, IFilesystemLinkFactory linkFactory, IProcessExecutor processExecutor, IPostWriteHandler postWriteHandler, IPlatformIdentifier platformIdentifier, IFileTransferTicketProvider fileTransferService, IOptionsMonitor< GeneralConfiguration > generalConfigurationOptions, IOptionsMonitor< SessionConfiguration > sessionConfigurationOptions, ILogger< Configuration > logger, Models.Instance metadata)
Initializes a new instance of the Configuration class.
Task StartAsync(CancellationToken cancellationToken)
const string EventScriptsSubdirectory
The EventScripts directory name.
readonly SemaphoreSlim semaphore
The SemaphoreSlim for Configuration. Also used as a lock object.
static IReadOnlyDictionary< EventType, string[]> EventTypeScriptFileNameMap
Map of EventTypes to the filename of the event scripts they trigger.
readonly IIOManager ioManager
The IIOManager for Configuration.
Represents code modifications via configuration.
IIOManager that resolves paths to Environment.CurrentDirectory.
const string CurrentDirectory
Path to the current working directory for the IIOManager.
const TaskCreationOptions BlockingTaskCreationOptions
The TaskCreationOptions used to spawn Tasks for potentially long running, blocking operations.
Operation exceptions thrown from the context of a Models.Job.
Represents an Api.Models.Instance in the database.
Represents a file on disk to be downloaded.
Async lock context helper.
static async ValueTask< SemaphoreSlimContext > Lock(SemaphoreSlim semaphore, CancellationToken cancellationToken, ILogger? logger=null)
Asyncronously locks a semaphore .
static ? SemaphoreSlimContext TryLock(SemaphoreSlim semaphore, ILogger? logger, out bool locked)
Asyncronously attempts to lock a semaphore .
For managing the Configuration directory.
For creating filesystem symbolic links.
Task CreateSymbolicLink(string targetPath, string linkPath, CancellationToken cancellationToken)
Create a symbolic link.
Interface for using filesystems.
Task< IReadOnlyList< string > > GetFiles(string path, CancellationToken cancellationToken)
Returns full file names in a given path .
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 DeleteFile(string path, CancellationToken cancellationToken)
Deletes a file at path .
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.
ValueTask CopyDirectory(IEnumerable< string >? ignore, Func< string, string, ValueTask >? postCopyCallback, string src, string dest, int? taskThrottle, CancellationToken cancellationToken)
Copies a directory from src to dest .
Task< bool > DirectoryExists(string path, CancellationToken cancellationToken)
Check that the directory at path exists.
Handles changing file modes/permissions after writing.
For accessing the disk in a synchronous manner.
byte[] ReadFile(string path)
Read the bytes of a file at a given path .
bool IsDirectory(string path)
Checks if a given path is a directory.
IEnumerable< string > GetFiles(string path, CancellationToken cancellationToken)
Enumerate files in a given path .
Stream GetFileStream(string path)
Gets the Stream for a given file path without write share.
IEnumerable< string > GetDirectories(string path, CancellationToken cancellationToken)
Enumerate directories in a given path .
Represents a user on the current global::System.Runtime.InteropServices.OSPlatform.
Task RunImpersonated(Action action, CancellationToken cancellationToken)
Runs a given action in the context of the ISystemIdentity.
Service for temporarily storing files to be downloaded or uploaded.
FileTicketResponse CreateDownload(FileDownloadProvider fileDownloadProvider)
Create a FileTicketResponse for a download.
ErrorCode
Types of Response.ErrorMessageResponses that the API may return.
EventType
Types of events. Mirror in tgs.dm. Prefer last listed name for script.
FileUploadStreamKind
Determines the type of global::System.IO.Stream returned from IFileUploadTicket's created from IFileT...