// 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(); } }