// 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
{
///
/// Parameters for a .
///
public class HordeGetSecretsTaskParameters
{
///
/// File to update with secrets
///
[TaskParameter]
public string File { get; set; } = String.Empty;
///
/// Text to update with secrets
///
[TaskParameter(Optional = true)]
public string? Text { get; set; }
///
/// 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.
///
[TaskParameter(Optional = true)]
public string? Replace { get; set; }
}
///
/// Replaces strings in a text file with secrets obtained from Horde
///
[TaskElement("Horde-GetSecrets", typeof(HordeGetSecretsTaskParameters))]
public class HordeGetSecretsTask : BgTaskImpl
{
record class ReplacementInfo(string Property, string Variable);
readonly HordeGetSecretsTaskParameters _parameters;
///
/// Constructor.
///
/// Parameters for this task.
public HordeGetSecretsTask(HordeGetSecretsTaskParameters parameters)
{
_parameters = parameters;
}
///
/// 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 async Task ExecuteAsync(JobContext job, HashSet buildProducts, Dictionary> 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> secretToReplacementInfo = new Dictionary>();
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();
using HordeHttpClient hordeHttpClient = hordeClient.CreateHttpClient();
foreach ((SecretId secretId, List 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> 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? replacements;
if (!secretToReplacementInfo.TryGetValue(secretId, out replacements))
{
replacements = new List();
secretToReplacementInfo.Add(secretId, replacements);
}
replacements.Add(new ReplacementInfo(propertyName, variable));
return true;
}
///
/// 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() => Enumerable.Empty();
///
/// Find all the tags which are modified by this task
///
/// The tag names which are modified by this task
public override IEnumerable FindProducedTagNames() => Enumerable.Empty();
}
}