// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using AutomationTool; using UnrealBuildTool; using System.Text.RegularExpressions; using System.IO; using System.Linq; using EpicGames.Core; using System.Text.Json.Serialization; using System.Text.Json; using UnrealGame; namespace Gauntlet { public class UnrealBuildSource : IBuildSource { public DirectoryReference UnrealPath { get; protected set; } public string ProjectName { get; protected set; } public FileReference ProjectPath { get; protected set; } public bool UsesSharedBuildType { get; protected set; } public string BuildName { get; protected set; } public IEnumerable BuildPaths { get; protected set; } public string Branch { get; protected set; } public int Changelist { get; protected set; } public int Preflight { get; protected set; } public bool EditorValid { get; protected set; } protected Dictionary> DiscoveredBuilds; protected class ArtifactMetadata { [JsonPropertyName("change")] public string Change { get; set; } [JsonPropertyName("preflight")] public string Preflight { get; set; } [JsonPropertyName("branch")] public string Branch { get; set; } [JsonPropertyName("buildName")] public string BuildName { get; set; } [JsonPropertyName("configuration")] public string Configuration { get; set; } [JsonPropertyName("platform")] public string Platform { get; set; } [JsonPropertyName("type")] public string Type { get; set; } public static ArtifactMetadata LoadFromJson(string FilePath) { JsonSerializerOptions Options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; string JsonString = File.ReadAllText(FilePath); ArtifactMetadata JsonTestPassResults = JsonSerializer.Deserialize(JsonString, Options); return JsonTestPassResults; } } public UnrealBuildSource(string InProjectName, FileReference InProjectPath, DirectoryReference InUnrealPath, bool InUsesSharedBuildType, string BuildReference) { InitBuildSource(InProjectName, InProjectPath, InUnrealPath, InUsesSharedBuildType, BuildReference, null); } public UnrealBuildSource(string InProjectName, FileReference InProjectPath, DirectoryReference InUnrealPath, bool InUsesSharedBuildType, string BuildReference, Func ResolutionDelegate) { InitBuildSource(InProjectName, InProjectPath, InUnrealPath, InUsesSharedBuildType, BuildReference, ResolutionDelegate); } public UnrealBuildSource(string InProjectName, FileReference InProjectPath, DirectoryReference InUnrealPath, bool InUsesSharedBuildType, string BuildReference, IEnumerable InSearchPaths) { InitBuildSource(InProjectName, InProjectPath, InUnrealPath, InUsesSharedBuildType, BuildReference, (string BuildRef) => { foreach (string SearchPath in InSearchPaths) { string AggregatedPath = Path.Combine(SearchPath, BuildRef); if (AggregatedPath.Length > 0) { DirectoryInfo SearchDir = new DirectoryInfo(AggregatedPath); if (SearchDir.Exists) { return SearchDir.FullName; } } } return null; }); } public bool CanSupportPlatform(UnrealTargetPlatform Platform) { return UnrealTargetPlatform.GetValidPlatforms().Contains(Platform); } protected void InitBuildSource(string InProjectName, FileReference InProjectPath, DirectoryReference InUnrealPath, bool InUsesSharedBuildType, string InBuildArgument, Func ResolutionDelegate) { UnrealPath = InUnrealPath; UsesSharedBuildType = InUsesSharedBuildType; ProjectPath = InProjectPath; ProjectName = InProjectName; // Resolve the build argument into something meaningful string ResolvedBuildName; IEnumerable ResolvedPaths = null; if (!ResolveBuildReference(InBuildArgument, ResolutionDelegate, out ResolvedPaths, out ResolvedBuildName)) { throw new AutomationException("Unable to resolve {0} to a valid build", InBuildArgument); } BuildName = ResolvedBuildName; BuildPaths = ResolvedPaths; // any Branch/CL info? Match M = Regex.Match(BuildName, @"(\+\+.+)-CL-(\d+)"); if (M.Success) { Branch = M.Groups[1].Value.Replace("+", "/"); Changelist = Convert.ToInt32(M.Groups[2].Value); } else { Branch = ""; Changelist = 0; } // Preflight? M = Regex.Match(BuildName, @"-PF-(\d+)"); Preflight = M.Success? Convert.ToInt32(M.Groups[1].Value) : 0; // Look if the build has an artifact metadata file string ArtifactMetadataFilePath = Path.Combine(InBuildArgument, "artifactmetadata.json"); if (File.Exists(ArtifactMetadataFilePath)) { ArtifactMetadata Metadata = null; try { Metadata = ArtifactMetadata.LoadFromJson(ArtifactMetadataFilePath); } catch (Exception Ex) { Log.Warning("Could not parse ArtifactMetadata file '{File}': {Exception}", ArtifactMetadataFilePath, Ex.Message); } if (Metadata != null) { if (!string.IsNullOrEmpty(Metadata.Branch)) { Branch = Metadata.Branch; } if (!string.IsNullOrEmpty(Metadata.Change)) { int MetaChange; if (int.TryParse(Metadata.Change, out MetaChange)) { Changelist = MetaChange; } } if (!string.IsNullOrEmpty(Metadata.Preflight)) { int MetaPreflight; if (int.TryParse(Metadata.Preflight, out MetaPreflight)) { Preflight = MetaPreflight; } } if (!string.IsNullOrEmpty(Metadata.BuildName)) { BuildName = Metadata.BuildName; } } } // allow user overrides (TODO - centralize all this!) Branch = Globals.Params.ParseValue("branch", Branch); Changelist = Convert.ToInt32(Globals.Params.ParseValue("changelist", Changelist.ToString())); Preflight = Convert.ToInt32(Globals.Params.ParseValue("preflight", Preflight.ToString())); // We resolve these on demand DiscoveredBuilds = new Dictionary>(); } virtual protected bool ResolveBuildReference(string InBuildReference, Func ResolutionDelegate, out IEnumerable OutBuildPaths, out string OutBuildName) { OutBuildName = null; // start as null. It's valid for some references to return empty paths so we use null to verify // that a resolution did happen OutBuildPaths = null; if (string.IsNullOrEmpty(InBuildReference)) { return false; } if (InBuildReference.Equals("AutoP4", StringComparison.InvariantCultureIgnoreCase)) { if (!CommandUtils.P4Enabled) { throw new AutomationException("-Build=AutoP4 requires -P4"); } if (CommandUtils.P4Env.Changelist < 1000) { throw new AutomationException("-Build=AutoP4 requires a CL from P4 and we have {0}", CommandUtils.P4Env.Changelist); } string BuildRoot = CommandUtils.CombinePaths(CommandUtils.RootBuildStorageDirectory()); string CachePath = InternalUtils.GetEnvironmentVariable("UE-BuildCachePath", ""); string SrcBuildPath = CommandUtils.CombinePaths(BuildRoot, ProjectName); string SrcBuildPath2 = CommandUtils.CombinePaths(BuildRoot, ProjectName.Replace("Game", "").Replace("game", "")); string SrcBuildPath_Cache = CommandUtils.CombinePaths(CachePath, ProjectName); string SrcBuildPath2_Cache = CommandUtils.CombinePaths(CachePath, ProjectName.Replace("Game", "").Replace("game", "")); if (!InternalUtils.SafeDirectoryExists(SrcBuildPath)) { if (!InternalUtils.SafeDirectoryExists(SrcBuildPath2)) { throw new AutomationException("-Build=AutoP4: Neither {0} nor {1} exists.", SrcBuildPath, SrcBuildPath2); } SrcBuildPath = SrcBuildPath2; SrcBuildPath_Cache = SrcBuildPath2_Cache; } string SrcCLPath = CommandUtils.CombinePaths(SrcBuildPath, CommandUtils.EscapePath(CommandUtils.P4Env.Branch) + "-CL-" + CommandUtils.P4Env.Changelist.ToString()); string SrcCLPath_Cache = CommandUtils.CombinePaths(SrcBuildPath_Cache, CommandUtils.EscapePath(CommandUtils.P4Env.Branch) + "-CL-" + CommandUtils.P4Env.Changelist.ToString()); if (!InternalUtils.SafeDirectoryExists(SrcCLPath)) { throw new AutomationException("-Build=AutoP4: {0} does not exist.", SrcCLPath); } if (InternalUtils.SafeDirectoryExists(SrcCLPath_Cache)) { InBuildReference = SrcCLPath_Cache; } else { InBuildReference = SrcCLPath; } Log.Verbose("Using AutoP4 path {0}", InBuildReference); } // BuildParam could be a path, a name that we should resolve to a path, Staged, or Editor DirectoryInfo BuildDir = new DirectoryInfo(InBuildReference); if (BuildDir.Exists) { // Easy option first - is this a full path? OutBuildName = BuildDir.Name; OutBuildPaths = new string[] { BuildDir.FullName }; } else if (BuildDir.Name.Equals("editor", StringComparison.OrdinalIgnoreCase)) { // Second special case - "Editor" means run using the editor, no path needed OutBuildName = "Editor"; OutBuildPaths = Enumerable.Empty(); } else if (BuildDir.Name.Equals("local", StringComparison.OrdinalIgnoreCase) || BuildDir.Name.Equals("staged", StringComparison.OrdinalIgnoreCase)) { // First special case - "Staged" means use whats locally staged OutBuildName = "Local"; string StagedPath = Path.Combine(ProjectPath.Directory.FullName, "Saved", "StagedBuilds"); if (Directory.Exists(StagedPath) == false) { Log.Error("BuildReference was Staged but staged directory {0} not found", StagedPath); return false; } // include binaries path for packaged builds if it exists string BinariesPath = Path.Combine(ProjectPath.Directory.FullName, "Binaries"); OutBuildPaths = Directory.Exists(BinariesPath) ? new string[] { StagedPath, BinariesPath } : new string[] { StagedPath }; } else if (BuildDir.Name.Equals("LatestGood", StringComparison.OrdinalIgnoreCase) || BuildDir.Name.Equals("LKG", StringComparison.OrdinalIgnoreCase)) { string RequestedValidator = Globals.Params.ParseValue("BuildValidator", null); IBuildValidator Validator = Utils.InterfaceHelpers.FindImplementations(true) .Where(Validator => Validator.CanSupportProject(ProjectName)) .Where(Validator => string.IsNullOrEmpty(RequestedValidator) || Validator.Name.Equals(RequestedValidator, StringComparison.OrdinalIgnoreCase)) .FirstOrDefault(); if(Validator == null) { Log.Error("No build validator that can support project {ProjectName} was found.", ProjectName); return false; } string LatestGoodBuild = Validator.GetLatestGoodBuild(); if(string.IsNullOrEmpty(LatestGoodBuild)) { Log.Error("No latest good build was able to be found!"); return false; } OutBuildPaths = new[] { LatestGoodBuild }; OutBuildName = Path.GetFileName(LatestGoodBuild); Log.Info("{Validator} selected {Build} as the latest good build. Proceeding with this build", Validator.GetType().Name, OutBuildName); return true; } else { // todo - make this more generic if (BuildDir.Name.Equals("usesyncedbuild", StringComparison.OrdinalIgnoreCase)) { BuildVersion Version; if (BuildVersion.TryRead(BuildVersion.GetDefaultFileName(), out Version)) { InBuildReference = Version.BranchName + "-CL-" + Version.Changelist.ToString(); } } // See if it's in the passed locations if (ResolutionDelegate != null) { string FullPath = ResolutionDelegate(InBuildReference); if (string.IsNullOrEmpty(FullPath) == false) { DirectoryInfo Di = new DirectoryInfo(FullPath); if (Di.Exists == false) { throw new AutomationException("Resolution delegate returned non existent path"); } OutBuildName = Di.Name; OutBuildPaths = new string[] { Di.FullName }; } } } if (string.IsNullOrEmpty(OutBuildName) || OutBuildPaths == null) { Log.Error("Unable to resolve build argument '{0}'", InBuildReference); return false; } return true; } /// /// Adds the provided build to our list (calls ShouldMakeBuildAvailable to verify). /// /// virtual protected void AddBuild(IBuild NewBuild) { NewBuild = ShouldMakeBuildAvailable(NewBuild); if (NewBuild != null) { if (!DiscoveredBuilds.ContainsKey(NewBuild.Platform)) { DiscoveredBuilds[NewBuild.Platform] = new List(); } string Flavor = string.IsNullOrEmpty(NewBuild.Flavor) ? "None" : NewBuild.Flavor; Log.Info("Adding {Platform} {BuildType} | Configuration: {Configuration} | Flavor: {Flavor} | Flags: {Flags} | PreferenceOrder: {PreferenceOrder}", NewBuild.Platform, NewBuild.GetType().Name, NewBuild.Configuration, Flavor, NewBuild.Flags, NewBuild.PreferenceOrder); DiscoveredBuilds[NewBuild.Platform].Add(NewBuild); } } /// /// Allows derived classes to nix or modify builds as they are discovered /// /// /// virtual protected IBuild ShouldMakeBuildAvailable(IBuild InBuild) { return InBuild; } /// /// Adds an Editor build to our list of available builds if one exists /// /// /// virtual protected IBuild CreateEditorBuild(DirectoryReference InUnrealPath, UnrealTargetConfiguration InConfiguration = UnrealTargetConfiguration.Development) { if (InUnrealPath != null) { // check for the editor string EditorExe = Path.Combine(InUnrealPath.FullName, GetRelativeExecutablePath(UnrealTargetRole.Editor, BuildHostPlatform.Current.Platform, InConfiguration)); string OverlayExe = Path.Combine(InUnrealPath.FullName, GetModuleOverlayExecutablePath(UnrealTargetRole.Editor, BuildHostPlatform.Current.Platform)); if (Utils.SystemHelpers.ApplicationExists(EditorExe)) { EditorBuild NewBuild = new EditorBuild(EditorExe, InConfiguration); return NewBuild; } if (Utils.SystemHelpers.ApplicationExists(OverlayExe)) { return new EditorBuild(OverlayExe, InConfiguration); } Log.Info("No editor binaries found at {0}. Unable to create an editor build source.", EditorExe); } else { Log.Info("No path to Unreal found. Unable to create an editor build source."); } return null; } /// /// True/false on whether we've tried to discover builds for the specified platform /// /// /// bool HaveDiscoveredBuilds(UnrealTargetPlatform InPlatform) { return DiscoveredBuilds.ContainsKey(InPlatform); } /// /// Discover all builds for the specified platform. Nop if this has already been run /// for the provided platform /// /// /// virtual protected void DiscoverBuilds(UnrealTargetPlatform InPlatform, UnrealTargetConfiguration InConfiguration = UnrealTargetConfiguration.Development) { if (!HaveDiscoveredBuilds(InPlatform)) { Log.Info("Discovering builds for {0}", InPlatform); DiscoveredBuilds[InPlatform] = new List(); // Add an editor build if this is our current platform. if (InPlatform == BuildHostPlatform.Current.Platform) { IBuild EditorBuild = CreateEditorBuild(UnrealPath, InConfiguration); if (EditorBuild == null) { Log.Info("Could not create editor build for project. Binaries are likely missing"); } else { AddBuild(EditorBuild); } } if (BuildPaths.Any()) { foreach (string Path in BuildPaths) { IEnumerable BuildSources = Gauntlet.Utils.InterfaceHelpers.FindImplementations(true) .Where(BS => BS.CanSupportPlatform(InPlatform)); foreach (var BS in BuildSources) { if (!BS.BuildName.Contains(InPlatform.ToString(), StringComparison.OrdinalIgnoreCase)) { continue; } IEnumerable Builds = BS.GetBuildsAtPath(ProjectName, Path); foreach (IBuild Build in Builds) { AddBuild(Build); } } } } } } /// /// Returns how many builds are available for the specified platform /// /// /// /// public int GetBuildCount(UnrealTargetPlatform InPlatform, BuildFlags InFlags = BuildFlags.None) { if (!HaveDiscoveredBuilds(InPlatform)) { DiscoverBuilds(InPlatform); } return DiscoveredBuilds[InPlatform].Where(B => (B.Flags & InFlags) == InFlags).Count(); } /// /// Returns all builds that match the specified parameters. If no builds have been discovered then that is performed first /// /// /// /// /// /// Optional special flavor of the build, e.g. asan/ubsan/clang/etc..., which can be added on top of a standard configuration. /// IEnumerable GetMatchingBuilds(UnrealTargetRole InRole, UnrealTargetPlatform? InPlatform, UnrealTargetConfiguration InConfiguration, BuildFlags InFlags, string InFlavor="") { // can't get a build with no platform or if we have none if (InPlatform == null) { return new IBuild[0]; } if (!HaveDiscoveredBuilds(InPlatform.Value)) { DiscoverBuilds(InPlatform.Value, InConfiguration); } IEnumerable PlatformBuilds = DiscoveredBuilds[InPlatform.Value]; IEnumerable MatchingBuilds = PlatformBuilds.Where((B) => { if (B.CanSupportRole(InRole) && B.Configuration == InConfiguration && (B.Flags & InFlags) == InFlags && (B.Flavor == InFlavor)) { return true; } return false; }); if (MatchingBuilds.Count() > 0) { return MatchingBuilds; } MatchingBuilds = PlatformBuilds.Where((B) => { if ((InFlags & BuildFlags.CanReplaceExecutable) == BuildFlags.CanReplaceExecutable) { if (B.CanSupportRole(InRole) && (B.Flags & InFlags) == InFlags && (B.Flavor == InFlavor)) { Log.Warning("Build did not have configuration {0} for {1}, but selecting due to presence of -dev flag", InConfiguration, InPlatform); return true; } } return false; }); return MatchingBuilds; } /// /// Checks if we are able to support the specified role. This will trigger build discovery if it has not yet /// happened for the specified platform /// /// /// /// public bool CanSupportRole(UnrealSessionRole Role, ref List Reasons) { if (Role.RoleType.UsesEditor() && UnrealPath == null) { Reasons.Add(string.Format("Role {0} wants editor but no path to Unreal exists", Role)); return false; } // null platform. Need a better way of specifying this if (Role.IsNullRole()) { return true; } // Query our build list if (Role.Platform != null) { var MatchingBuilds = GetMatchingBuilds(Role.RoleType, Role.Platform.Value, Role.Configuration, Role.RequiredBuildFlags, Role.RequiredFlavor); if (MatchingBuilds.Count() > 0) { return true; } } Reasons.Add(string.Format("No build at {0} that matches {1} (RequiredFlags={2})", string.Join(",", BuildPaths), Role.ToString(), Role.RequiredBuildFlags.ToString())); return false; } virtual public UnrealAppConfig CreateConfiguration(UnrealSessionRole Role) { return CreateConfiguration(Role, new UnrealSessionRole[] { }); } virtual public UnrealAppConfig CreateConfiguration(UnrealSessionRole Role, IEnumerable OtherRoles) { List Issues = new List(); Log.Verbose("Creating configuration Role {0}", Role); if (!CanSupportRole(Role, ref Issues)) { Issues.ForEach(S => Log.Error(S)); return null; } UnrealTestConfiguration TestConfig = Role.Options as UnrealTestConfiguration; UnrealAppConfig Config = new UnrealAppConfig(); Config.Name = this.BuildName; Config.ProjectFile = this.ProjectPath; Config.ProjectName = ProjectName; Config.ProcessType = Role.RoleType; Config.Platform = Role.Platform; Config.Configuration = Role.Configuration; Config.CommandLineParams = new GauntletCommandLine(Role.CommandLineParams); Config.FilesToCopy = new List(); Config.MaxDuration = (int)(TestConfig?.MaxDuration ?? 0); // new system of retrieving and encapsulating the info needed to install/launch. Android & Mac Config.Build = GetMatchingBuilds(Role.RoleType, Role.Platform, Role.Configuration, Role.RequiredBuildFlags, Role.RequiredFlavor).OrderBy(B => B.PreferenceOrder).FirstOrDefault(); if (Config.Build == null) { if (Role.IsNullRole()) { Log.Warning("No supported build found, however role is Null and not configure to run anything."); } else { var SupportedBuilds = String.Join("\n", DiscoveredBuilds.Select(B => B.ToString())); Log.Info("Available builds:\n{0}", SupportedBuilds); throw new AutomationException("No build found that can support a role of {0}.", Role); } } else { Log.Info("Selected build {Build} for test run.", Config.Build.ToString()); } if (Role.Options != null) { UnrealTestConfiguration ConfigOptions = Role.Options as UnrealTestConfiguration; ConfigOptions.ApplyToConfig(Config, Role, OtherRoles); } // Cleanup the commandline Log.Info("Processing CommandLine {0}", Config.CommandLine); Config.CommandLine = GenerateProcessedCommandLine(Config.CommandLine); // Now add the project (the above code doesn't handle arguments without a leading - so do this last bool IsContentOnlyProject = (Config.Build != null) && ((Config.Build.Flags & BuildFlags.ContentOnlyProject) == BuildFlags.ContentOnlyProject); // Add in editor - TODO, should this be in the editor build? if (Role.RoleType.UsesEditor() || IsContentOnlyProject) { // add in -game or -server if (Role.RoleType.IsClient()) { Config.CommandLineParams.Add("game"); } else if (Role.RoleType.IsServer()) { Config.CommandLineParams.Add("server"); } string ProjectParam = ProjectPath.FullName; // if content only we need to provide a relative path to the uproject. if (IsContentOnlyProject && !Role.RoleType.UsesEditor()) { ProjectParam = string.Format("../../../{0}/{0}.uproject", ProjectName); } Config.CommandLineParams.Project = ProjectParam; } // Detect json log line output if (Config.CommandLineParams.HasParam("JsonStdOut") || (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("UE_LOG_JSON_TO_STDOUT")) && (Role.Platform == BuildHostPlatform.Current.Platform || CanRunPlatformVirtualized(Role.Platform)))) { Config.FilterLoggingDelegate = (string M, bool IsErr) => UnrealLogParser.SanitizeJsonOutputLine(M); } if (Role.FilesToCopy != null) { Config.FilesToCopy = Role.FilesToCopy; } if(Globals.IsRunningDev) { Config.OverlayExecutable = new OverlayExecutable(Role, Config.ProjectName); } return Config; } private bool CanRunPlatformVirtualized(UnrealTargetPlatform? Platform) { return Utils.InterfaceHelpers.FindImplementations() .Any(F => F.GetPlatform() == Platform && F.CanRunVirtualFromPlatform(BuildHostPlatform.Current.Platform)); } /// /// Remove all duplicate flags and combine any execcmd strings that might be floating around in the commandline. /// /// /// private string GenerateProcessedCommandLine(string InCommandLine) { // Break down Commandline into individual tokens Dictionary CommandlineTokens = new Dictionary(StringComparer.OrdinalIgnoreCase); // turn Name(p1,etc) into a collection of Name|(p1,etc) groups MatchCollection Matches = Regex.Matches(InCommandLine, @"(?