// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Threading.Tasks; using System.Xml; using EpicGames.Core; using Microsoft.Extensions.Logging; using OpenTracing; using UnrealBuildBase; #nullable enable namespace AutomationTool { /// /// Attribute to mark parameters to a task, which should be read as XML attributes from the script file. /// [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] public sealed class TaskParameterAttribute : Attribute { /// /// Whether the parameter can be omitted /// public bool Optional { get; set; } /// /// Sets additional restrictions on how this field is validated in the schema. Default is to allow any valid field type. /// public TaskParameterValidationType ValidationType { get; set; } } /// /// Attribute used to associate an XML element name with a parameter block that can be used to construct tasks /// [AttributeUsage(AttributeTargets.Class)] public sealed class TaskElementAttribute : Attribute { /// /// Name of the XML element that can be used to denote this class /// public string Name { get; } /// /// Type to be constructed from the deserialized element /// public Type ParametersType { get; } /// /// Constructor /// /// Name of the XML element used to denote this object /// Type to be constructed from this object public TaskElementAttribute(string name, Type parametersType) { Name = name; ParametersType = parametersType; } } /// /// Proxy to handle executing multiple tasks simultaneously (such as compile tasks). If a task supports simultaneous execution, it can return a separate /// executor an executor instance from GetExecutor() callback. If not, it must implement ExecuteAsync(). /// public interface ITaskExecutor { /// /// Adds another task to this executor /// /// Task to add /// True if the task could be added, false otherwise bool Add(BgTaskImpl task); /// /// ExecuteAsync all the tasks added to this executor. /// /// Information about the current job /// Set of build products produced by this node. /// Mapping from tag names to the set of files they include /// Whether the task succeeded or not. Exiting with an exception will be caught and treated as a failure. Task ExecuteAsync(JobContext job, HashSet buildProducts, Dictionary> tagNameToFileSet); } /// /// Base class for all custom build tasks /// public abstract class BgTaskImpl { /// /// Accessor for the default log interface /// protected static ILogger Logger => Log.Logger; /// /// Line number in a source file that this task was declared. Optional; used for log messages. /// public BgScriptLocation? SourceLocation { get; set; } /// /// ExecuteAsync this node. /// /// Information about the current job /// Set of build products produced by this node. /// Mapping from tag names to the set of files they include /// Whether the task succeeded or not. Exiting with an exception will be caught and treated as a failure. public abstract Task ExecuteAsync(JobContext job, HashSet buildProducts, Dictionary> tagNameToFileSet); /// /// Creates a proxy to execute this node. /// /// New proxy instance if one is available to execute this task, otherwise null. public virtual ITaskExecutor? GetExecutor() { return null; } /// /// Output this task out to an XML writer. /// public abstract void Write(XmlWriter writer); /// /// Writes this task to an XML writer, using the given parameters object. /// /// Writer for the XML schema /// Parameters object that this task is constructed with protected void Write(XmlWriter writer, object parameters) { TaskElementAttribute element = GetType().GetCustomAttribute() ?? throw new InvalidOperationException(); writer.WriteStartElement(element.Name); foreach (FieldInfo field in parameters.GetType().GetFields()) { if (field.MemberType == MemberTypes.Field) { TaskParameterAttribute? parameterAttribute = field.GetCustomAttribute(); if (parameterAttribute != null) { object? value = field.GetValue(parameters); if (value != null) { writer.WriteAttributeString(field.Name, value.ToString()); } } } } foreach (PropertyInfo property in parameters.GetType().GetProperties()) { TaskParameterAttribute? parameterAttribute = property.GetCustomAttribute(); if (parameterAttribute != null) { object? value = property.GetValue(parameters); if (value != null) { writer.WriteAttributeString(property.Name, value.ToString()); } } } writer.WriteEndElement(); } /// /// Returns a string used for trace messages /// public string GetTraceString() { StringBuilder builder = new StringBuilder(); using (XmlWriter writer = XmlWriter.Create(new StringWriter(builder), new XmlWriterSettings() { OmitXmlDeclaration = true })) { Write(writer); } return builder.ToString(); } /// /// Gets the name of this task for tracing /// /// The trace name public virtual string GetTraceName() { TaskElementAttribute? taskElement = GetType().GetCustomAttribute(); return (taskElement != null) ? taskElement.Name : "unknown"; } /// /// Get properties to include in tracing info /// /// The scope to add properties to /// Prefix for metadata entries public virtual void GetTraceMetadata(ITraceSpan span, string prefix) { if (SourceLocation != null) { span.AddMetadata(prefix + "source.file", SourceLocation.File.FullName); span.AddMetadata(prefix + "source.line", SourceLocation.LineNumber.ToString()); } } /// /// Get properties to include in tracing info /// /// The scope to add properties to /// Prefix for metadata entries public virtual void GetTraceMetadata(ISpan span, string prefix) { if (SourceLocation != null) { span.SetTag(prefix + "source.file", SourceLocation.File.FullName); span.SetTag(prefix + "source.line", SourceLocation.LineNumber); } } /// /// Find all the tags which are used as inputs to this task /// /// The tag names which are read by this task public abstract IEnumerable FindConsumedTagNames(); /// /// Find all the tags which are modified by this task /// /// The tag names which are modified by this task public abstract IEnumerable FindProducedTagNames(); /// /// Adds tag names from a filespec /// /// A filespec, as can be passed to ResolveFilespec /// Tag names from this filespec protected static IEnumerable FindTagNamesFromFilespec(string filespec) { if (!String.IsNullOrEmpty(filespec)) { foreach (string pattern in SplitDelimitedList(filespec)) { if (pattern.StartsWith("#", StringComparison.Ordinal)) { yield return pattern; } } } } /// /// Enumerates tag names from a list /// /// List of tags separated by semicolons /// Tag names from this filespec protected static IEnumerable FindTagNamesFromList(string? tagList) { if (!String.IsNullOrEmpty(tagList)) { foreach (string tagName in SplitDelimitedList(tagList)) { yield return tagName; } } } /// /// Resolves a single name to a file reference, resolving relative paths to the root of the current path. /// /// Name of the file /// Fully qualified file reference public static FileReference ResolveFile(string name) { if (Path.IsPathRooted(name)) { return new FileReference(name); } else { return new FileReference(Path.Combine(CommandUtils.CmdEnv.LocalRoot, name)); } } /// /// Resolves a directory reference from the given string. Assumes the root directory is the root of the current branch. /// /// Name of the directory. May be null or empty. /// The resolved directory public static DirectoryReference ResolveDirectory(string? name) { if (String.IsNullOrEmpty(name)) { return Unreal.RootDirectory; } else if (Path.IsPathRooted(name)) { return new DirectoryReference(name); } else { return DirectoryReference.Combine(Unreal.RootDirectory, name); } } /// /// Finds or adds a set containing files with the given tag /// /// Map of tag names to the set of files they contain /// The tag name to return a set for. A leading '#' character is required. /// Set of files public static HashSet FindOrAddTagSet(Dictionary> tagNameToFileSet, string tagName) { // Make sure the tag name contains a single leading hash if (tagName.LastIndexOf('#') != 0) { throw new AutomationException("Tag name '{0}' is not valid - should contain a single leading '#' character", tagName); } // Any spaces should be later than the second char - most likely to be a typo if directly after the # character if (tagName.IndexOf(' ', StringComparison.Ordinal) == 1) { throw new AutomationException("Tag name '{0}' is not valid - spaces should only be used to separate words", tagName); } // Find the files which match this tag HashSet? files; if (!tagNameToFileSet.TryGetValue(tagName, out files)) { files = new HashSet(); tagNameToFileSet.Add(tagName, files); } // If we got a null reference, it's because the tag is not listed as an input for this node (see RunGraph.BuildSingleNode). Fill it in, but only with an error. if (files == null) { Logger.LogError("Attempt to reference tag '{TagName}', which is not listed as a dependency of this node.", tagName); files = new HashSet(); tagNameToFileSet.Add(tagName, files); } return files; } /// /// Resolve a list of files, tag names or file specifications separated by semicolons. Supported entries may be: /// a) The name of a tag set (eg. #CompiledBinaries) /// b) Relative or absolute filenames /// c) A simple file pattern (eg. Foo/*.cpp) /// d) A full directory wildcard (eg. Engine/...) /// Note that wildcards may only match the last fragment in a pattern, so matches like "/*/Foo.txt" and "/.../Bar.txt" are illegal. /// /// The default directory to resolve relative paths to /// List of files, tag names, or file specifications to include separated by semicolons. /// Mapping of tag name to fileset, as passed to the ExecuteAsync() method /// Set of matching files. public static HashSet ResolveFilespec(DirectoryReference defaultDirectory, string delimitedPatterns, Dictionary> tagNameToFileSet) { List excludePatterns = new List(); return ResolveFilespecWithExcludePatterns(defaultDirectory, delimitedPatterns, excludePatterns, tagNameToFileSet); } /// /// Resolve a list of files, tag names or file specifications separated by semicolons as above, but preserves any directory references for further processing. /// /// The default directory to resolve relative paths to /// List of files, tag names, or file specifications to include separated by semicolons. /// Set of patterns to apply to directory searches. This can greatly speed up enumeration by earlying out of recursive directory searches if large directories are excluded (eg. .../Intermediate/...). /// Mapping of tag name to fileset, as passed to the ExecuteAsync() method /// Set of matching files. public static HashSet ResolveFilespecWithExcludePatterns(DirectoryReference defaultDirectory, string delimitedPatterns, List excludePatterns, Dictionary> tagNameToFileSet) { // Split the argument into a list of patterns List patterns = SplitDelimitedList(delimitedPatterns); return ResolveFilespecWithExcludePatterns(defaultDirectory, patterns, excludePatterns, tagNameToFileSet); } /// /// Resolve a list of files, tag names or file specifications as above, but preserves any directory references for further processing. /// /// The default directory to resolve relative paths to /// List of files, tag names, or file specifications to include separated by semicolons. /// Set of patterns to apply to directory searches. This can greatly speed up enumeration by earlying out of recursive directory searches if large directories are excluded (eg. .../Intermediate/...). /// Mapping of tag name to fileset, as passed to the ExecuteAsync() method /// Set of matching files. public static HashSet ResolveFilespecWithExcludePatterns(DirectoryReference defaultDirectory, List filePatterns, List excludePatterns, Dictionary> tagNameToFileSet) { // Parse each of the patterns, and add the results into the given sets HashSet files = new HashSet(); foreach (string pattern in filePatterns) { // Check if it's a tag name if (pattern.StartsWith("#", StringComparison.Ordinal)) { files.UnionWith(FindOrAddTagSet(tagNameToFileSet, pattern)); continue; } // If it doesn't contain any wildcards, just add the pattern directly int wildcardIdx = FileFilter.FindWildcardIndex(pattern); if (wildcardIdx == -1) { files.Add(FileReference.Combine(defaultDirectory, pattern)); continue; } // Find the base directory for the search. We construct this in a very deliberate way including the directory separator itself, so matches // against the OS root directory will resolve correctly both on Mac (where / is the filesystem root) and Windows (where / refers to the current drive). int lastDirectoryIdx = pattern.LastIndexOfAny(new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, wildcardIdx); DirectoryReference baseDir = DirectoryReference.Combine(defaultDirectory, pattern.Substring(0, lastDirectoryIdx + 1)); // Construct the absolute include pattern to match against, re-inserting the resolved base directory to construct a canonical path. string includePattern = baseDir.FullName.TrimEnd(new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }) + "/" + pattern.Substring(lastDirectoryIdx + 1); // Construct a filter and apply it to the directory if (DirectoryReference.Exists(baseDir)) { FileFilter filter = new FileFilter(); filter.AddRule(includePattern, FileFilterType.Include); if (excludePatterns != null && excludePatterns.Count > 0) { filter.AddRules(excludePatterns, FileFilterType.Exclude); } files.UnionWith(filter.ApplyToDirectory(baseDir, baseDir.FullName, true)); } } // If we have exclude rules, create and run a filter against all the output files to catch things that weren't added from an include if (excludePatterns != null && excludePatterns.Count > 0) { FileFilter filter = new FileFilter(FileFilterType.Include); filter.AddRules(excludePatterns, FileFilterType.Exclude); files.RemoveWhere(x => !filter.Matches(x.FullName)); } return files; } /// /// Splits a string separated by semicolons into a list, removing empty entries /// /// The input string /// Array of the parsed items public static List SplitDelimitedList(string text) { return text.Split(';').Select(x => x.Trim()).Where(x => x.Length > 0).ToList(); } /// /// Name of the environment variable containing cleanup commands /// public const string CleanupScriptEnvVarName = "UE_HORDE_CLEANUP"; /// /// Name of the environment variable containing lease cleanup commands /// public const string LeaseCleanupScriptEnvVarName = "UE_HORDE_LEASE_CLEANUP"; /// /// Add cleanup commands to run after the step completes /// /// Lines to add to the cleanup script /// Whether to add the commands to run on lease termination public static async Task AddCleanupCommandsAsync(IEnumerable newLines, bool lease = false) { string? cleanupScriptEnvVar = Environment.GetEnvironmentVariable(lease ? LeaseCleanupScriptEnvVarName : CleanupScriptEnvVarName); if (!String.IsNullOrEmpty(cleanupScriptEnvVar)) { FileReference cleanupScript = new FileReference(cleanupScriptEnvVar); await FileReference.AppendAllLinesAsync(cleanupScript, newLines); } } /// /// Name of the environment variable containing a file to write Horde graph updates to /// public const string GraphUpdateEnvVarName = "UE_HORDE_GRAPH_UPDATE"; /// /// Updates the graph currently used by Horde /// /// Context for the current job that is being executed public static void UpdateGraphForHorde(JobContext job) { string? exportGraphFile = Environment.GetEnvironmentVariable(GraphUpdateEnvVarName); if (String.IsNullOrEmpty(exportGraphFile)) { throw new Exception($"Missing environment variable {GraphUpdateEnvVarName}. This is required to update graphs on Horde."); } List newParams = new List(); newParams.Add("BuildGraph"); newParams.AddRange(job.OwnerCommand.Params.Select(x => $"-{x}")); newParams.RemoveAll(x => x.StartsWith("-SingleNode=", StringComparison.OrdinalIgnoreCase)); newParams.Add($"-HordeExport={exportGraphFile}"); newParams.Add($"-ListOnly"); string newCommandLine = CommandLineArguments.Join(newParams); CommandUtils.RunUAT(CommandUtils.CmdEnv, newCommandLine, "bg"); } } /// /// Legacy implementation of which operates synchronously /// public abstract class CustomTask : BgTaskImpl { /// public sealed override Task ExecuteAsync(JobContext job, HashSet buildProducts, Dictionary> tagNameToFileSet) { Execute(job, buildProducts, tagNameToFileSet); return Task.CompletedTask; } /// /// ExecuteAsync this node. /// /// Information about the current job /// Set of build products produced by this node. /// Mapping from tag names to the set of files they include /// Whether the task succeeded or not. Exiting with an exception will be caught and treated as a failure. public abstract void Execute(JobContext job, HashSet buildProducts, Dictionary> tagNameToFileSet); } }