// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using System.Xml; using EpicGames.Core; using Microsoft.Extensions.Logging; using UnrealBuildTool; namespace AutomationTool.Tasks { /// /// Parameters for a task that purges data from a symbol store after a given age /// public class AgeStoreTaskParameters { /// /// The target platform to age symbols for. /// [TaskParameter] public UnrealTargetPlatform Platform { get; set; } /// /// The symbol server directory. /// [TaskParameter] public string StoreDir { get; set; } /// /// Number of days worth of symbols to keep. /// [TaskParameter] public int Days { get; set; } /// /// The root of the build directory to check for existing buildversion named directories. /// [TaskParameter(Optional = true)] public string BuildDir { get; set; } /// /// A substring to match in directory file names before deleting symbols. This allows the "age store" task /// to avoid deleting symbols from other builds in the case where multiple builds share the same symbol server. /// Specific use of the filter value is determined by the symbol server structure defined by the platform toolchain. /// [TaskParameter(Optional = true)] public string Filter { get; set; } } /// /// Task that strips symbols from a set of files. This task is named after the AGESTORE utility that comes with the Microsoft debugger tools SDK, but is actually a separate implementation. The main /// difference is that it uses the last modified time rather than last access time to determine which files to delete. /// [TaskElement("AgeStore", typeof(AgeStoreTaskParameters))] public class AgeStoreTask : BgTaskImpl { /// /// Parameters for this task /// readonly AgeStoreTaskParameters _parameters; /// /// Construct a spawn task /// /// Parameters for the task public AgeStoreTask(AgeStoreTaskParameters parameters) { _parameters = parameters; } private static void TryDelete(DirectoryInfo directory) { try { directory.Delete(true); Logger.LogInformation("Removed '{Arg0}'", directory.FullName); } catch { Logger.LogWarning("Couldn't delete '{Arg0}' - skipping", directory.FullName); } } private static void TryDelete(FileInfo file) { try { file.Delete(); Logger.LogInformation("Removed '{Arg0}'", file.FullName); } catch { Logger.LogWarning("Couldn't delete '{Arg0}' - skipping", file.FullName); } } // Checks if an existing build has a version file, returns false to NOT delete if it exists private static bool CheckCanDeleteFromVersionFile(HashSet existingBuilds, DirectoryInfo directory, FileInfo individualFile = null) { // check for any existing version files foreach (FileInfo buildVersionFile in directory.EnumerateFiles("*.version")) { // If the buildversion matches one of the directories in build share provided, don't delete no matter the age. string buildVersion = Path.GetFileNameWithoutExtension(buildVersionFile.Name); if (existingBuilds.Contains(buildVersion)) { // if checking for an individual file, see if the filename matches what's in the .version file. // these file names won't have extensions. if (individualFile != null) { string individualFilePath = individualFile.FullName; string filePointerName = File.ReadAllText(buildVersionFile.FullName).Trim(); if (filePointerName == Path.GetFileNameWithoutExtension(individualFilePath)) { Logger.LogInformation("Found existing build {BuildVersion} in the BuildDir with matching individual file {IndividualFilePath} - skipping.", buildVersion, individualFilePath); return false; } } // otherwise it's okay to just mark the entire folder for delete else { Logger.LogInformation("Found existing build {BuildVersion} in the BuildDir - skipping.", buildVersion); return false; } } } return true; } private static void RecurseDirectory(DateTime expireTimeUtc, DirectoryInfo currentDirectory, string[] directoryStructure, int level, string filter, HashSet existingBuilds, bool deleteIndividualFiles) { // Do a file search at the last level. if (level == directoryStructure.Length) { if (deleteIndividualFiles) { // Delete any file in the directory that is out of date. foreach (FileInfo outdatedFile in currentDirectory.EnumerateFiles().Where(x => x.LastWriteTimeUtc < expireTimeUtc && x.Extension != ".version")) { // check to make sure this file is valid to delete if (CheckCanDeleteFromVersionFile(existingBuilds, currentDirectory, outdatedFile)) { TryDelete(outdatedFile); } } } // If all files are out of date, delete the directory... else if (currentDirectory.EnumerateFiles().Where(x => x.Extension != ".version").All(x => x.LastWriteTimeUtc < expireTimeUtc) && CheckCanDeleteFromVersionFile(existingBuilds, currentDirectory)) { TryDelete(currentDirectory); } } else { string[] patterns = directoryStructure[level].Split(';'); foreach (string pattern in patterns) { string replacedPattern = String.Format(pattern, filter); foreach (DirectoryInfo childDirectory in currentDirectory.GetDirectories(replacedPattern, SearchOption.TopDirectoryOnly)) { RecurseDirectory(expireTimeUtc, childDirectory, directoryStructure, level + 1, filter, existingBuilds, deleteIndividualFiles); } } // Delete this directory if it is empty, and it is not the root directory. if (level > 0 && !currentDirectory.EnumerateFileSystemInfos().Any()) { TryDelete(currentDirectory); } } } /// /// ExecuteAsync the task. /// /// Information about the current job /// Set of build products produced by this node. /// Mapping from tag names to the set of files they include public override Task ExecuteAsync(JobContext job, HashSet buildProducts, Dictionary> tagNameToFileSet) { // Get the list of symbol file name patterns from the platform. Platform targetPlatform = Platform.GetPlatform(_parameters.Platform); string[] directoryStructure = targetPlatform.SymbolServerDirectoryStructure; if (directoryStructure == null) { throw new AutomationException("Platform does not specify the symbol server structure. Cannot age the symbol server."); } string filter = String.IsNullOrWhiteSpace(_parameters.Filter) ? String.Empty : _parameters.Filter.Trim(); // Eumerate the root directory of builds for buildversions to check against // Folder names in the root directory should match the name of the .version files HashSet existingBuilds = new HashSet(StringComparer.OrdinalIgnoreCase); if (!String.IsNullOrWhiteSpace(_parameters.BuildDir)) { DirectoryReference buildDir = new DirectoryReference(_parameters.BuildDir); if (DirectoryReference.Exists(buildDir)) { foreach (string buildName in DirectoryReference.EnumerateDirectories(buildDir).Select(build => build.GetDirectoryName())) { existingBuilds.Add(buildName); } } else { Logger.LogWarning("BuildDir of {Arg0} was provided but it doesn't exist! Will not check buildversions against it.", _parameters.BuildDir); } } // Get the time at which to expire files DateTime expireTimeUtc = DateTime.UtcNow - TimeSpan.FromDays(_parameters.Days); Logger.LogInformation("Expiring all files before {ExpireTimeUtc}...", expireTimeUtc); // Scan the store directory and delete old symbol files DirectoryReference symbolServerDirectory = ResolveDirectory(_parameters.StoreDir); CommandUtils.OptionallyTakeLock(targetPlatform.SymbolServerRequiresLock, symbolServerDirectory, TimeSpan.FromMinutes(15), () => { RecurseDirectory(expireTimeUtc, new DirectoryInfo(symbolServerDirectory.FullName), directoryStructure, 0, filter, existingBuilds, targetPlatform.SymbolServerDeleteIndividualFiles); }); return Task.CompletedTask; } /// /// Output this task out to an XML writer. /// public override void Write(XmlWriter writer) { Write(writer, _parameters); } /// /// Find all the tags which are used as inputs to this task /// /// The tag names which are read by this task public override IEnumerable FindConsumedTagNames() { yield break; } /// /// Find all the tags which are modified by this task /// /// The tag names which are modified by this task public override IEnumerable FindProducedTagNames() { yield break; } } public static partial class StandardTasks { /// /// Task that strips symbols from a set of files /// /// Platform to clean /// Path to symbol store /// Number of days to keep /// The root of the build directory to check for existing buildversion named directories /// A substring to match in directory file names before deleting symbols /// public static async Task AgeStoreAsync(UnrealTargetPlatform platform, string storeDir, int days, string buildDir, string filter = null) { AgeStoreTaskParameters parameters = new AgeStoreTaskParameters() { Platform = platform, StoreDir = storeDir, Days = days, BuildDir = buildDir, Filter = filter }; return await ExecuteAsync(new AgeStoreTask(parameters)); } } }