Files
UnrealEngine/Engine/Source/Programs/AutomationTool/BuildGraph/Tasks/HordeGetSecretsTask.cs
2025-05-18 13:04:45 +08:00

229 lines
7.4 KiB
C#

// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Xml;
using EpicGames.Core;
using EpicGames.Horde;
using EpicGames.Horde.Secrets;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
#nullable enable
namespace AutomationTool.Tasks
{
/// <summary>
/// Parameters for a <see cref="HordeGetSecretsTask"/>.
/// </summary>
public class HordeGetSecretsTaskParameters
{
/// <summary>
/// File to update with secrets
/// </summary>
[TaskParameter]
public string File { get; set; } = String.Empty;
/// <summary>
/// Text to update with secrets
/// </summary>
[TaskParameter(Optional = true)]
public string? Text { get; set; }
/// <summary>
/// Pairs of strings and secret names to expand in the text file, in the form SOURCE_TEXT=secret-name;SOURCE_TEXT_2=secret-name-2.
/// If not specified, secrets embedded inline in the text will be expanded from {{secret-name.value}} strings.
/// </summary>
[TaskParameter(Optional = true)]
public string? Replace { get; set; }
}
/// <summary>
/// Replaces strings in a text file with secrets obtained from Horde
/// </summary>
[TaskElement("Horde-GetSecrets", typeof(HordeGetSecretsTaskParameters))]
public class HordeGetSecretsTask : BgTaskImpl
{
record class ReplacementInfo(string Property, string Variable);
readonly HordeGetSecretsTaskParameters _parameters;
/// <summary>
/// Constructor.
/// </summary>
/// <param name="parameters">Parameters for this task.</param>
public HordeGetSecretsTask(HordeGetSecretsTaskParameters parameters)
{
_parameters = parameters;
}
/// <summary>
/// ExecuteAsync the task.
/// </summary>
/// <param name="job">Information about the current job.</param>
/// <param name="buildProducts">Set of build products produced by this node.</param>
/// <param name="tagNameToFileSet">Mapping from tag names to the set of files they include.</param>
public override async Task ExecuteAsync(JobContext job, HashSet<FileReference> buildProducts, Dictionary<string, HashSet<FileReference>> tagNameToFileSet)
{
FileReference file = ResolveFile(_parameters.File);
// Read the input text
string? text = _parameters.Text;
if (String.IsNullOrEmpty(text))
{
text = await FileReference.ReadAllTextAsync(file);
}
// Parse the secrets to replace
Dictionary<SecretId, List<ReplacementInfo>> secretToReplacementInfo = new Dictionary<SecretId, List<ReplacementInfo>>();
if (String.IsNullOrEmpty(_parameters.Replace))
{
int pos = 0;
for (; ; )
{
pos = text.IndexOf("{{", pos, StringComparison.Ordinal);
if (pos == -1)
{
break;
}
pos += 2;
int endPos = text.IndexOf("}}", pos, StringComparison.Ordinal);
if (endPos == -1)
{
continue;
}
string variable = text.Substring(pos - 2, (endPos + 2) - (pos - 2));
string replacement = text.Substring(pos, endPos - pos);
if (!ParseReplacementInfo(variable, replacement, secretToReplacementInfo))
{
throw new AutomationException($"Invalid replacement clause for secret in Horde-GetSecrets task: {replacement} (expected VARIABLE=Secret.Property)");
}
pos = endPos + 2;
}
}
else
{
foreach (string replacement in _parameters.Replace.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
int idx = replacement.LastIndexOf('=');
if (idx == -1)
{
throw new AutomationException($"Invalid replacement clause in Horde-GetSecrets task: {replacement} (expected VARIABLE=Secret.Property)");
}
string variable = replacement.Substring(0, idx);
if (!text.Contains(variable, StringComparison.Ordinal))
{
Logger.LogWarning("Variable '{Variable}' not found in {File}", variable, file);
continue;
}
if (!ParseReplacementInfo(variable, replacement.Substring(idx + 1), secretToReplacementInfo))
{
throw new AutomationException($"Invalid replacement clause for secret in Horde-GetSecrets task: {replacement} (expected VARIABLE=Secret.Property)");
}
}
}
// Read the secrets from Horde, and substitute them in the output file
if (secretToReplacementInfo.Count > 0)
{
ServiceCollection serviceCollection = new ServiceCollection();
serviceCollection.AddLogging(builder => builder.AddEpicDefault());
serviceCollection.AddHorde(options => options.AllowAuthPrompt = !CommandUtils.IsBuildMachine);
await using (ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider())
{
IHordeClient hordeClient = serviceProvider.GetRequiredService<IHordeClient>();
using HordeHttpClient hordeHttpClient = hordeClient.CreateHttpClient();
foreach ((SecretId secretId, List<ReplacementInfo> replacements) in secretToReplacementInfo)
{
GetSecretResponse secret;
try
{
secret = await hordeHttpClient.GetSecretAsync(secretId);
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
throw new AutomationException(ex, $"Secret '{secretId}' was not found on {hordeClient.ServerUrl}");
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
throw new AutomationException(ex, $"User does not have permissions to read '{secretId}' on {hordeClient.ServerUrl}");
}
foreach (ReplacementInfo replacement in replacements)
{
string? value;
if (secret.Data.TryGetValue(replacement.Property, out value))
{
text = text.Replace(replacement.Variable, value, StringComparison.Ordinal);
}
else
{
Logger.LogWarning("Property '{PropertyName}' not found in secret {SecretId}", replacement.Property, secretId);
}
}
}
}
}
// Write the output file
DirectoryReference.CreateDirectory(file.Directory);
await FileReference.WriteAllTextAsync(file, text);
Logger.LogInformation("Updated {File} with secrets from Horde.", file);
}
static bool ParseReplacementInfo(string variable, string replacement, Dictionary<SecretId, List<ReplacementInfo>> secretToReplacementInfo)
{
int propertyIdx = replacement.IndexOf('.', StringComparison.Ordinal);
if (propertyIdx == -1)
{
return false;
}
SecretId secretId = new SecretId(replacement.Substring(0, propertyIdx));
string propertyName = replacement.Substring(propertyIdx + 1);
List<ReplacementInfo>? replacements;
if (!secretToReplacementInfo.TryGetValue(secretId, out replacements))
{
replacements = new List<ReplacementInfo>();
secretToReplacementInfo.Add(secretId, replacements);
}
replacements.Add(new ReplacementInfo(propertyName, variable));
return true;
}
/// <summary>
/// Output this task out to an XML writer.
/// </summary>
public override void Write(XmlWriter writer)
{
Write(writer, _parameters);
}
/// <summary>
/// Find all the tags which are used as inputs to this task
/// </summary>
/// <returns>The tag names which are read by this task</returns>
public override IEnumerable<string> FindConsumedTagNames() => Enumerable.Empty<string>();
/// <summary>
/// Find all the tags which are modified by this task
/// </summary>
/// <returns>The tag names which are modified by this task</returns>
public override IEnumerable<string> FindProducedTagNames() => Enumerable.Empty<string>();
}
}