Skip to content

Instantly share code, notes, and snippets.

@regner
Created November 8, 2024 12:52
Show Gist options
  • Save regner/8fac36b4b30b6af58db4c676fc8851a8 to your computer and use it in GitHub Desktop.
Save regner/8fac36b4b30b6af58db4c676fc8851a8 to your computer and use it in GitHub Desktop.
BuildHorde.cs
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AutomationTool;
using AutomationTool.Tasks;
using EpicGames.BuildGraph;
using EpicGames.BuildGraph.Expressions;
using EpicGames.Core;
using UnrealBuildBase;
using Splash.BgArgParser;
using Splash.BgArgParser.OptionTypes;
namespace SDBuildGraph.Automation.Scripts;
public class BuildHorde : BgGraphBuilder
{
private static readonly DirectoryReference DotNetDirectory = DirectoryReference.Combine(Unreal.RootDirectory, "Engine/Binaries/ThirdParty/DotNet");
private static readonly FileReference DotNet6Path = FileReference.Combine(DotNetDirectory, "6.0.302/windows/dotnet.exe");
private static readonly FileReference DotNet8Path = FileReference.Combine(DotNetDirectory, "8.0.100/windows/dotnet.exe");
private static readonly DirectoryReference StagingDirectory = DirectoryReference.Combine(Unreal.RootDirectory, "HordeStaging");
private static readonly DirectoryReference CmdDirectory = DirectoryReference.Combine(StagingDirectory, "cmd");
private static readonly DirectoryReference AgentDirectory = DirectoryReference.Combine(StagingDirectory, "agent");
public override BgGraph CreateGraph(BgEnvironment environment)
{
// Script arguments
BooleanOption publishCmd = new BooleanOption("PublishCmd", false);
BooleanOption publishAgent = new BooleanOption("PublishAgent", false);
BooleanOption publishServer = new BooleanOption("PublishServer", false);
Parser parser = Parser.NewFromEnvironment();
parser.Add(publishCmd);
parser.Add(publishAgent);
parser.Add(publishServer);
parser.Parse();
// Agents
BgAgent testAgent = new BgAgent("Test Horde", "TestWin64");
BgAgent compileAgent = new BgAgent("Build Horde Agent", "CompileWin64");
BgAgent dockerAgent = new BgAgent("Build Horde Server", "DockerLinuxIncremental");
// Labels
BgLabel testsLabel = new(name: "Tests", category: "Other");
BgLabel cmdLabel = new(name: "CMD", category: "Other");
BgLabel agentLabel = new(name: "Agent", category: "Other");
BgLabel dashboardLabel = new(name: "Dashboard", category: "Other");
BgLabel serverLabel = new(name: "Server", category: "Other");
// Nodes
BgNode dotnetTestsNode = testAgent.AddNode(name: "DotNet Tests", func: context => RunDotnetTests(context))
.AddLabel(testsLabel);
BgNode documentationTestNode = testAgent.AddNode(name: "Validate Documentation", func: context => RunMarkdownTests(context))
.AddLabel(testsLabel);
BgNode staticAnalysisNode = testAgent.AddNode(name: "Static Analysis", func: context => RunBuildAnalyzer(context))
.AddLabel(testsLabel);
BgNode cmdNode = compileAgent.AddNode(name: "Horde (Command-Line)", func: context => BuildCmd(context))
.AddLabel(cmdLabel);
BgNode cmdPublishNode = compileAgent.AddNode(name: "Publish Horde (Command-Line)", func: context => PublishCmd(context))
.Requires(cmdNode)
.AddLabel(cmdLabel);
BgNode agentNode = compileAgent.AddNode(name: "Horde Agent (Cross-Platform)", func: context => BuildAgent(context))
.AddLabel(agentLabel);
BgNode agentPublishNode = compileAgent.AddNode(name: "Publish Horde Agent (Cross-Platform)", func: context => PublishAgent(context))
.Requires(agentNode)
.AddLabel(agentLabel);
BgNode dashboardNode = dockerAgent.AddNode(name: "Horde Dashboard", func: context => BuildDashboard(context))
.AddLabel(dashboardLabel);
BgNode serverNode = dockerAgent.AddNode(name: "Horde Server", func: context => BuildServer(context))
.After(dashboardNode)
.AddLabel(serverLabel);
BgNode serverPublishNode = dockerAgent.AddNode(name: "Publish Horde Server", func: context => PublishServer(context))
.After(serverNode)
.AddLabel(serverLabel);
// Finally put it all together
List<BgNode> nodes = new List<BgNode>()
{
dotnetTestsNode,
documentationTestNode,
staticAnalysisNode,
cmdNode,
agentNode,
dashboardNode,
serverNode,
};
if (publishCmd.Value)
{
nodes.Add(cmdPublishNode);
}
if (publishAgent.Value)
{
nodes.Add(agentPublishNode);
}
if (publishServer.Value)
{
nodes.Add(serverPublishNode);
}
BgAggregate aggregate = new BgAggregate("Aggregate", nodes.ToList());
return new BgGraph(nodes, aggregate);
}
/// <summary>
/// Runs the dotnet tests for Horde and related projects.
/// </summary>
private static async Task RunDotnetTests(BgContext context)
{
// Test net6 projects
List<string> net6ProjectsToTest = new List<string>()
{
"Engine/Source/Programs/Horde/Horde.Agent.Tests/Horde.Agent.Tests.csproj",
"Engine/Source/Programs/Shared/EpicGames.BuildGraph.Tests/EpicGames.BuildGraph.Tests.csproj",
"Engine/Source/Programs/Shared/EpicGames.Core.Tests/EpicGames.Core.Tests.csproj",
"Engine/Source/Programs/Shared/EpicGames.Horde.Tests/EpicGames.Horde.Tests.csproj",
"Engine/Source/Programs/Shared/EpicGames.IoHash.Tests/EpicGames.IoHash.Tests.csproj",
"Engine/Source/Programs/Shared/EpicGames.Perforce.Managed.Tests/EpicGames.Perforce.Managed.Tests.csproj",
"Engine/Source/Programs/Shared/EpicGames.Perforce.Tests/EpicGames.Perforce.Tests.csproj",
"Engine/Source/Programs/Shared/EpicGames.Redis.Tests/EpicGames.Redis.Tests.csproj",
"Engine/Source/Programs/Shared/EpicGames.Serialization.Tests/EpicGames.Serialization.Tests.csproj",
};
foreach (string testProject in net6ProjectsToTest)
{
await context.ExecuteAsync(new SpawnTask(new SpawnTaskParameters()
{
Exe = DotNet6Path.FullName,
Arguments = $"test \"{testProject}\" --blame-hang-timeout 5m --blame-hang-dump-type mini --logger 'console;verbosity=normal'",
Environment = $"UE_DOTNET_VERSION=net6.0",
}));
}
// Test net8 projects
// Wasn't able to get these working, so commented out for now, tests are fine, the problem is more about using dotnet 6 and 8
// List<string> net8ProjectsToTest = new List<string>()
// {
// "Engine/Source/Programs/Horde/Horde.Server.Tests/Horde.Server.Tests.csproj",
// };
//
// foreach (string testProject in net8ProjectsToTest)
// {
// await context.ExecuteAsync(new SpawnTask(new SpawnTaskParameters()
// {
// Exe = DotNet8Path.FullName,
// Arguments = $"test \"{testProject}\" --blame-hang-timeout 5m --blame-hang-dump-type mini --logger 'console;verbosity=normal'",
// Environment = $"UE_DOTNET_VERSION=net8.0",
// }));
// }
}
/// <summary>
/// Validate the Horde documentation.
/// </summary>
private static async Task RunMarkdownTests(BgContext context)
{
await context.ExecuteAsync(new CheckMarkdownTask(new CheckMarkdownTaskParameters()
{
Files = $"{Unreal.RootDirectory}/Engine/Source/Programs/Horde/README.md;{Unreal.RootDirectory}/Engine/Source/Programs/Horde/Docs/..."
}));
}
/// <summary>
/// Build the Horde projects with the analyzer enabled.
/// </summary>
private static async Task RunBuildAnalyzer(BgContext context)
{
// net6 projects to analyze
List<string> net6ProjectsToTest = new List<string>()
{
"Engine/Source/Programs/Horde/Horde.Agent/Horde.Agent.csproj",
"Engine/Source/Programs/Horde/Horde.Agent.Tests/Horde.Agent.Tests.csproj",
};
foreach (string testProject in net6ProjectsToTest)
{
await context.ExecuteAsync(new SpawnTask(new SpawnTaskParameters()
{
Exe = DotNet6Path.FullName,
Arguments = $"build \"{testProject}\" -p:Configuration=Analyze",
Environment = $"UE_DOTNET_VERSION=net6.0",
}));
}
// net8 projects to analyze
List<string> net8ProjectsToTest = new List<string>()
{
"Engine/Source/Programs/Horde/Horde/Horde.csproj",
"Engine/Source/Programs/Horde/Horde.Server/Horde.Server.csproj",
"Engine/Source/Programs/Horde/Horde.Server.Tests/Horde.Server.Tests.csproj",
};
foreach (string testProject in net8ProjectsToTest)
{
await context.ExecuteAsync(new SpawnTask(new SpawnTaskParameters()
{
Exe = DotNet8Path.FullName,
Arguments = $"build \"{testProject}\" -p:Configuration=Analyze",
Environment = $"UE_DOTNET_VERSION=net8.0",
}));
}
}
/// <summary>
/// Builds the Horde project which produces the Horde CLI tool.
/// </summary>
private static async Task<BgFileSet> BuildCmd(BgContext context)
{
FileUtils.ForceDeleteDirectory(CmdDirectory);
string arguments = "publish";
arguments += $" \"{Unreal.RootDirectory}/Engine/Source/Programs/Horde/Horde/Horde.csproj\"";
arguments += $" --output \"{CmdDirectory}\"";
arguments += $" --runtime win-x64 --self-contained";
arguments += Utilities.DotnetBuilds.GetVersionArguments(context);
await context.ExecuteAsync(new SpawnTask(new SpawnTaskParameters()
{
Exe = DotNet8Path.FullName,
Arguments = arguments,
Environment = $"UE_DOTNET_VERSION=net8.0",
}));
if (context.IsBuildMachine)
{
await context.ExecuteAsync(new CreateArtifactTask(new CreateArtifactTaskParameters()
{
Name = "horde-cmd",
BaseDir = CmdDirectory.FullName,
Type = "tools-artifact",
}));
}
return FileSet.FromDirectory(CmdDirectory);
}
/// <summary>
/// Publishes the Horde CLI tool to Horde tools.
/// </summary>
private static async Task PublishCmd(BgContext context)
{
if (context.IsBuildMachine)
{
await context.ExecuteAsync(new DeployToolTask(new DeployToolTaskParameters()
{
Id = "horde-cmd",
Version = $"{Utilities.DotnetBuilds.GetEngineVersion()}-{context.Change}",
Directory = CmdDirectory.FullName,
}));
}
}
/// <summary>
/// Builds the Horde agent itself.
/// </summary>
private static async Task<BgFileSet> BuildAgent(BgContext context)
{
FileUtils.ForceDeleteDirectory(AgentDirectory);
string arguments = "publish";
arguments += $" \"{Unreal.RootDirectory.FullName}/Engine/Source/Programs/Horde/Horde.Agent/Horde.Agent.csproj\"";
arguments += $" --output \"{AgentDirectory}\"";
arguments += Utilities.DotnetBuilds.GetVersionArguments(context);
await context.ExecuteAsync(new SpawnTask(new SpawnTaskParameters()
{
Exe = DotNet6Path.FullName,
Arguments = arguments,
Environment = $"UE_DOTNET_VERSION=net6.0",
}));
if (context.IsBuildMachine)
{
await context.ExecuteAsync(new CreateArtifactTask(new CreateArtifactTaskParameters()
{
Name = $"horde-agent",
BaseDir = AgentDirectory.FullName,
Type = "tools-artifact",
}));
}
return FileSet.FromDirectory(AgentDirectory);
}
/// <summary>
/// Publishes the Horde agent tool to Horde tools.
/// </summary>
private static async Task PublishAgent(BgContext context)
{
if (context.IsBuildMachine)
{
await context.ExecuteAsync(new DeployToolTask(new DeployToolTaskParameters()
{
Id = $"horde-agent",
Version = $"{Utilities.DotnetBuilds.GetEngineVersion()}-{context.Change}",
Directory = AgentDirectory.FullName,
}));
}
}
/// <summary>
/// Builds the Horde dashboard Docker image.
///
/// We only intend to distribute the dashboard as part of the Horde server itself. We therefor
/// do not support pushing the Horde dashboard Docker image to a remote registry.
/// </summary>
private static async Task BuildDashboard(BgContext context)
{
await context.ExecuteAsync(new CopyTask(new CopyTaskParameters()
{
From = "Engine/Source/Programs/Horde/Docs/...",
To = "Engine/Source/Programs/Horde/HordeDashboard/documentation/Docs/...",
}));
await context.ExecuteAsync(new CopyTask(new CopyTaskParameters()
{
From = "Engine/Source/Programs/Horde/README.md",
To = "Engine/Source/Programs/Horde/HordeDashboard/documentation/README.md",
}));
await context.ExecuteAsync(new TagTask(new TagTaskParameters()
{
Files = "Engine/Source/Programs/Horde/HordeDashboard/...",
Except = "Engine/Source/Programs/Horde/HordeDashboard/node_modules/...",
With = "#InputFiles",
}));
string buildArgs = $"--build-arg \"VersionInfo={Utilities.DotnetBuilds.GetEngineVersion()}-{context.Change}\"";
buildArgs += " --build-arg \"DashboardConfig=Production\"";
await context.ExecuteAsync(new DockerBuildTask(new DockerBuildTaskParameters()
{
BaseDir = "Engine",
Files = "#InputFiles",
DockerFile = "Engine/Source/Programs/Horde/HordeDashboard/Dockerfile",
Arguments = buildArgs,
Tag = "hordedashboard-public", // This name is referenced in the Dockerfile.dashboard from Horde server
}));
}
/// <summary>
/// Builds the Horde server Docker image.
///
/// This node must be built on the same agent as the dashboard as it relies on the dashboard image
/// existing on the local machine.
/// </summary>
private static async Task BuildServer(BgContext context)
{
string[] files = {
"Engine/Binaries/DotNET/EpicGames.Perforce.Native/...",
"Engine/Source/Programs/Shared/...",
"Engine/Source/Programs/Horde/...",
"Engine/Source/Programs/AutomationTool/AutomationUtils/Matchers/...",
"Engine/Source/Programs/UnrealBuildTool/Matchers/...",
};
await context.ExecuteAsync(new TagTask(new TagTaskParameters()
{
Files = string.Join(";", files),
Except = ".../.vs/...;.../.git/...;.../bin/...;.../obj/...",
With = "#InputFiles",
}));
// Build a Docker image with just the Horde server
await context.ExecuteAsync(new DockerBuildTask(new DockerBuildTaskParameters()
{
BaseDir = "Engine",
Files = "#InputFiles",
DockerFile = "Engine/Source/Programs/Horde/Horde.Server/Dockerfile",
Arguments = $"--build-arg msbuild_args=\"{Utilities.DotnetBuilds.GetVersionArguments(context)}\"",
Tag = "horde-server-bare", // This name is referenced in the Dockerfile.dashboard from Horde server
}));
// Build a Docker image that combines the bare Horde server with the Horde dashboard
await context.ExecuteAsync(new DockerBuildTask(new DockerBuildTaskParameters()
{
BaseDir = "Engine/Source/Programs/Horde/Horde.Server",
Files = "Dockerfile*",
DockerFile = "Engine/Source/Programs/Horde/Horde.Server/Dockerfile.dashboard",
Tag = "horde-server",
}));
}
/// <summary>
/// Publishes the Horde server Docker image.
/// </summary>
private static async Task PublishServer(BgContext context)
{
if (context.IsBuildMachine)
{
await context.ExecuteAsync(new DockerPushTask(new DockerPushTaskParameters()
{
Repository = "internal-docker-registry",
Image = "horde-server",
TargetImage = $"epic/horde/server:{context.Change}",
RepositoryAuthFile = "/some/path/nexus.json"
}));
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment