File: SendHelixJob.cs
Web Access
Project: src\src\Microsoft.DotNet.Helix\Sdk\Microsoft.DotNet.Helix.Sdk.csproj (Microsoft.DotNet.Helix.Sdk)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Build.Framework;
using Microsoft.DotNet.Helix.Client;
using Newtonsoft.Json;
 
namespace Microsoft.DotNet.Helix.Sdk
{
    public class SendHelixJob : HelixTask
    {
        public static class MetadataNames
        {
            public const string Identity = "Identity";
            public const string Value = "Value";
 
            // HelixWorkItem
            public const string PayloadDirectory = "PayloadDirectory";
            public const string PayloadArchive = "PayloadArchive";
            public const string PayloadUri = "PayloadUri";
            public const string Timeout = "Timeout";
            public const string Command = "Command";
            public const string PreCommands = "PreCommands";
            public const string PostCommands = "PostCommands";
 
            // Correlation payload
            public const string FullPath = "FullPath";
            public const string Uri = "Uri";
            public const string Destination = "Destination";
            public const string IncludeDirectoryName = "IncludeDirectoryName";
            public const string AsArchive = "AsArchive";
        }
 
        /// <summary>
        ///   The 'type' value reported to Helix
        /// </summary>
        /// <remarks>
        ///   This value is used to filter and sort jobs on Mission Control
        /// </remarks>
        [Required]
        public string Type { get; set; }
 
        /// <summary>
        ///   The Helix queue this job should run on
        /// </summary>
        [Required]
        public string TargetQueue { get; set; }
 
        /// <summary>
        /// Required if sending anonymous, not allowed if authenticated
        /// The GitHub username of the job creator
        /// </summary>
        public string Creator { get; set; }
 
        /// <summary>
        ///   <see langword="true"/> when the work items are executing on a Posix shell; <see langword="false"/> otherwise.
        /// </summary>
        public bool IsPosixShell { get; set; }
 
        /// <summary>
        ///   When the task finishes, the correlation id of the job that has been created
        /// </summary>
        [Output]
        public string JobCorrelationId { get; set; }
 
        /// <summary>
        ///   A string value which allows cancellation of only the job used to generate it (to support anonymous scenarios)
        /// </summary>
        [Output]
        public string JobCancellationToken { get; set; }
 
        /// <summary>
        ///   When the task finishes, the results container uri should be available in case we want to download files.
        /// </summary>
        [Output]
        public string ResultsContainerUri { get; set; }
 
        /// <summary>
        ///   If the job is internal, we need to give the DownloadFromResultsContainer task the Write SAS to download files.
        /// </summary>
        [Output]
        public string ResultsContainerReadSAS { get; set; }
 
        /// <summary>
        ///   A collection of commands that will run for each work item before any work item commands.
        ///   Use a semicolon to delimit these and escape semicolons by percent coding them ('%3B').
        ///   NOTE: This is different behavior from the WorkItem PreCommands, where semicolons are escaped
        ///   by using double semicolons (';;').
        /// </summary>
        public string[] PreCommands { get; set; }
 
        /// <summary>
        ///   A collection of commands that will run for each work item after any work item commands.
        ///   Use a semicolon to delimit these and escape semicolons by percent coding them ('%3B').
        ///   NOTE: This is different behavior from the WorkItem PostCommands, where semicolons are escaped
        ///   by using double semicolons (';;').
        /// </summary>
        public string[] PostCommands { get; set; }
 
        /// <summary>
        ///   A set of correlation payloads that will be sent for the helix job.
        /// </summary>
        /// <remarks>
        ///   These Items can be either:
        ///     A Directory - The specified directory will be zipped up and sent as a correlation payload
        ///     A File - The specified archive file will be sent as a correlation payload
        ///     A Uri - The Item's Uri metadata will be used as a correlation payload
        /// </remarks>
        public ITaskItem[] CorrelationPayloads { get; set; }
 
        /// <summary>
        ///   A set of work items that will run in the helix job.
        /// </summary>
        /// <remarks>
        ///   Required Metadata:
        ///     Identity - The WorkItemName
        ///     Command - The command that is invoked to execute the work item
        ///   Optional Metadata:
        ///     NOTE: only a single Payload parameter should be used; they are not to be used in combination
        ///     PayloadDirectory - A directory that will be zipped up and sent as the Work Item payload
        ///     PayloadArchive - An archive that will be sent up as the Work Item payload
        ///     PayloadUri - An uri of the archive that will be sent up as the Work Item payload
        ///     Timeout - A <see cref="System.TimeSpan"/> string that specifies that Work Item execution timeout
        ///     PreCommands
        ///       A collection of commands that will run for this work item before the 'Command' Runs
        ///       Use ';' to separate commands and escape a ';' with ';;'
        ///       NOTE: This is different behavior from the Helix PreCommands, where semicolons are escaped
        ///       with percent coding.
        ///     PostCommands
        ///       A collection of commands that will run for this work item after the 'Command' Runs
        ///       Use ';' to separate commands and escape a ';' with ';;'
        ///       NOTE: This is different behavior from the Helix PostCommands, where semicolons are escaped
        ///       with percent coding.
        ///     Destination
        ///       The directory in which to unzip the correlation payload on the Helix agent
        /// </remarks>
        public ITaskItem[] WorkItems { get; set; }
 
        /// <summary>
        ///  A set of properties for helix to map the job using architecture and configuration
        /// </summary>
        /// <remarks>
        ///  Required Metadata:
        ///     Identity - The property Key
        ///     Value - The property Value mapped to the key.
        /// </remarks>
        public ITaskItem[] HelixProperties { get; set; }
 
        /// <summary>
        /// Max automatic retry of workitems which do not return 0
        /// </summary>
        public int MaxRetryCount { get; set; }
 
        private CommandPayload _commandPayload;
 
        protected override async Task ExecuteCore(CancellationToken cancellationToken)
        {
            if (string.IsNullOrEmpty(AccessToken) && string.IsNullOrEmpty(Creator))
            {
                Log.LogError(FailureCategory.Build, "Creator is required when using anonymous access.");
                return;
            }
 
            if (!string.IsNullOrEmpty(AccessToken) && !string.IsNullOrEmpty(Creator))
            {
                Log.LogError(FailureCategory.Build, "Creator is forbidden when using authenticated access.");
                return;
            }
 
            Type = Type.ToLowerInvariant();
 
            cancellationToken.ThrowIfCancellationRequested();
 
            using (_commandPayload = new CommandPayload(this))
            {
                var currentHelixApi = HelixApi;
 
                IJobDefinition def = currentHelixApi.Job.Define()
                    .WithType(Type)
                    .WithTargetQueue(TargetQueue)
                    .WithMaxRetryCount(MaxRetryCount);
                Log.LogMessage($"Initialized job definition with type '{Type}', and target queue '{TargetQueue}'");
 
                if (!string.IsNullOrEmpty(Creator))
                {
                    def = def.WithCreator(Creator);
                    Log.LogMessage($"Setting creator to '{Creator}'");
                }
 
                if (CorrelationPayloads == null)
                {
                    Log.LogMessage($"No Correlation Payloads for Job on {TargetQueue} set");
                }
                else
                {
                    Log.LogMessage($"Adding Correlation Payloads for Job on {TargetQueue}...");
 
                    foreach (ITaskItem correlationPayload in CorrelationPayloads)
                    {
                        def = AddCorrelationPayload(def, correlationPayload);
                    }
                }
 
                if (WorkItems != null)
                {
                    foreach (ITaskItem workItem in WorkItems)
                    {
                        def = AddWorkItem(def, workItem);
                    }
                }
                else
                {
                    Log.LogError(FailureCategory.Build, "SendHelixJob given no WorkItems to send.");
                }
 
                if (_commandPayload.TryGetPayloadDirectory(out string directory))
                {
                    def = def.WithCorrelationPayloadDirectory(directory);
                }
 
                if (HelixProperties != null)
                {
                    foreach (ITaskItem helixProperty in HelixProperties)
                    {
                        def = AddProperty(def, helixProperty);
                    }
                }
 
                def = AddBuildVariableProperty(def, "CollectionUri", "System.CollectionUri");
                def = AddBuildVariableProperty(def, "Project", "System.TeamProject");
                def = AddBuildVariableProperty(def, "BuildNumber", "Build.BuildNumber");
                def = AddBuildVariableProperty(def, "BuildId", "Build.BuildId");
                def = AddBuildVariableProperty(def, "DefinitionName", "Build.DefinitionName");
                def = AddBuildVariableProperty(def, "DefinitionId", "System.DefinitionId");
                def = AddBuildVariableProperty(def, "Reason", "Build.Reason");
                var variablesToCopy = new[]
                {
                    "System.JobId",
                    "System.JobName",
                    "System.JobAttempt",
                    "System.PhaseName",
                    "System.PhaseAttempt",
                    "System.PullRequest.TargetBranch",
                    "System.StageName",
                    "System.StageAttempt",
                };
                foreach (var name in variablesToCopy)
                {
                    def = AddBuildVariableProperty(def, name, name);
                }
 
                // don't send the job if we have errors
                if (Log.HasLoggedErrors)
                {
                    return;
                }
 
                Log.LogMessage(MessageImportance.High, $"Sending Job to {TargetQueue}...");
                cancellationToken.ThrowIfCancellationRequested();
                // LogMessageFromText will take any string formatted as a canonical error or warning and convert the type of log to this
                ISentJob job = await def.SendAsync(msg => Log.LogMessageFromText(msg, MessageImportance.Normal), cancellationToken);
                JobCorrelationId = job.CorrelationId;
                JobCancellationToken = job.HelixCancellationToken;
                ResultsContainerUri = job.ResultsContainerUri;
                ResultsContainerReadSAS = job.ResultsContainerReadSAS;
                cancellationToken.ThrowIfCancellationRequested();
            }
 
            cancellationToken.ThrowIfCancellationRequested();
        }
 
        private IJobDefinition AddBuildVariableProperty(IJobDefinition def, string key, string azdoVariableName)
        {
            string envName = FromAzdoVariableNameToEnvironmentVariableName(azdoVariableName);
 
            var value = Environment.GetEnvironmentVariable(envName);
            if (string.IsNullOrEmpty(value))
            {
                return def;
            }
 
            def.WithProperty(key, value);
            Log.LogMessage($"Added build variable property '{key}' (value: '{value}') to job definition.");
            return def;
        }
 
        private static string FromAzdoVariableNameToEnvironmentVariableName(string name)
        {
            return name.Replace('.', '_').ToUpper();
        }
 
        private IJobDefinition AddProperty(IJobDefinition def, ITaskItem property)
        {
            if (!property.GetRequiredMetadata(Log, MetadataNames.Identity, out string key))
            {
                return def;
            }
 
            if (!property.GetRequiredMetadata(Log, MetadataNames.Value, out string value))
            {
                return def;
            }
 
            def.WithProperty(key, value);
            Log.LogMessage($"Added property '{key}' (value: '{value}') to job definition.");
            return def;
        }
 
        private IJobDefinition AddWorkItem(IJobDefinition def, ITaskItem workItem)
        {
            if (!workItem.GetRequiredMetadata(Log, MetadataNames.Identity, out string name))
            {
                return def;
            }
 
            if (name.Contains('%'))
            {
                Log.LogWarning($"Work Item named '{name}' contains encoded characters which is not recommended.");
            }
 
            var cleanedName = Helpers.CleanWorkItemName(name);
 
            if (name != cleanedName)
            {
                Log.LogWarning($"Work Item named '{name}' contains unsupported characters and has been renamed to '{cleanedName}'.");
            }
 
            name = cleanedName;
 
            if (!workItem.GetRequiredMetadata(Log, MetadataNames.Command, out string command))
            {
                return def;
            }
 
            Log.LogMessage(MessageImportance.Low, $"Adding work item '{name}'");
 
            var commands = GetCommands(workItem, command).ToList();
 
            IWorkItemDefinitionWithPayload wiWithPayload;
 
            if (commands.Count == 1)
            {
                wiWithPayload = def.DefineWorkItem(name).WithCommand(commands[0]);
                Log.LogMessage(MessageImportance.Low, $"  Command: '{commands[0]}'");
            }
            else
            {
                string commandFile = _commandPayload.AddCommandFile(commands);
                string helixCorrelationPayload =
                    IsPosixShell ? "$HELIX_CORRELATION_PAYLOAD/" : "%HELIX_CORRELATION_PAYLOAD%\\";
                wiWithPayload = def.DefineWorkItem(name).WithCommand(helixCorrelationPayload + commandFile);
                Log.LogMessage(MessageImportance.Low, $"  Command File: '{commandFile}'");
                foreach (string c in commands)
                {
                    Log.LogMessage(MessageImportance.Low, $"    {c}");
                }
            }
 
            string payloadDirectory = workItem.GetMetadata(MetadataNames.PayloadDirectory);
            string payloadArchive = workItem.GetMetadata(MetadataNames.PayloadArchive);
            string payloadUri = workItem.GetMetadata(MetadataNames.PayloadUri);
            IWorkItemDefinition wi;
            if (!string.IsNullOrEmpty(payloadUri))
            {
                wi = wiWithPayload.WithPayloadUri(new Uri(payloadUri));
                Log.LogMessage(MessageImportance.Low, $"  Uri Payload: '{payloadUri}'");
            }
            else if (!string.IsNullOrEmpty(payloadDirectory))
            {
                wi = wiWithPayload.WithDirectoryPayload(payloadDirectory);
                Log.LogMessage(MessageImportance.Low, $"  Directory Payload: '{payloadDirectory}'");
            }
            else if (!string.IsNullOrEmpty(payloadArchive))
            {
                wi = wiWithPayload.WithArchivePayload(payloadArchive);
                Log.LogMessage(MessageImportance.Low, $"  Archive Payload: '{payloadArchive}'");
            }
            else
            {
                wi = wiWithPayload.WithEmptyPayload();
                Log.LogMessage(MessageImportance.Low, "  Empty Payload");
            }
 
 
            string timeoutString = workItem.GetMetadata(MetadataNames.Timeout);
            if (!string.IsNullOrEmpty(timeoutString))
            {
                if (TimeSpan.TryParse(timeoutString, CultureInfo.InvariantCulture, out TimeSpan timeout))
                {
                    wi = wi.WithTimeout(timeout);
                    Log.LogMessage(MessageImportance.Low, $"  Timeout: '{timeout}'");
                }
                else
                {
                    Log.LogWarning($"Timeout value '{timeoutString}' could not be parsed.");
                }
            }
            else
            {
                Log.LogMessage(MessageImportance.Low, "  Default Timeout");
            }
 
            return wi.AttachToJob();
        }
 
        private IEnumerable<string> GetCommands(ITaskItem workItem, string workItemCommand)
        {
            if (PreCommands != null)
            {
                foreach (string command in PreCommands)
                {
                    yield return command;
                }
            }
 
            if (workItem.TryGetMetadata(MetadataNames.PreCommands, out string workItemPreCommandsString))
            {
                foreach (string command in SplitCommands(workItemPreCommandsString))
                {
                    yield return command;
                }
            }
 
            yield return workItemCommand;
 
            string exitCodeVariableName = "_commandExitCode";
 
            // Capture helix command exit code, in case work item command (i.e xunit call) exited with a failure,
            // this way we can exit the process honoring that exit code, needed for retry.
            yield return IsPosixShell ? $"export {exitCodeVariableName}=$?" : $"set {exitCodeVariableName}=%ERRORLEVEL%";
 
            if (workItem.TryGetMetadata(MetadataNames.PostCommands, out string workItemPostCommandsString))
            {
                foreach (string command in SplitCommands(workItemPostCommandsString))
                {
                    yield return command;
                }
            }
 
            if (PostCommands != null)
            {
                foreach (string command in PostCommands)
                {
                    yield return command;
                }
            }
 
            // Exit with the captured exit code from workitem command.
            yield return IsPosixShell ? $"exit ${exitCodeVariableName}" : $"EXIT /b %{exitCodeVariableName}%";
        }
 
        private IEnumerable<string> SplitCommands(string value)
        {
            var sb = new StringBuilder();
 
            using (var enumerator = value.GetEnumerator())
            {
                char prev = default;
                while (enumerator.MoveNext())
                {
                    if (enumerator.Current != ';')
                    {
                        if (prev == ';' && sb.Length != 0)
                        {
                            var part = sb.ToString().Trim();
                            if (!string.IsNullOrEmpty(part))
                            {
                                yield return part;
                            }
                            sb.Length = 0;
                        }
                        sb.Append(enumerator.Current);
                    }
                    else if (enumerator.Current == ';')
                    {
                        if (prev == ';')
                        {
                            sb.Append(';');
                            prev = default;
                            continue;
                        }
                    }
 
                    prev = enumerator.Current;
                }
            }
            if (sb.Length != 0)
            {
                var part = sb.ToString().Trim();
                if (!string.IsNullOrEmpty(part))
                {
                    yield return part;
                }
            }
        }
 
        private IJobDefinition AddCorrelationPayload(IJobDefinition def, ITaskItem correlationPayload)
        {
            string path = correlationPayload.GetMetadata(MetadataNames.FullPath);
            string uri = correlationPayload.GetMetadata(MetadataNames.Uri);
            string destination = correlationPayload.GetMetadata(MetadataNames.Destination) ?? "";
 
            if (!string.IsNullOrEmpty(uri))
            {
                Log.LogMessage(MessageImportance.Low, $"Adding Correlation Payload URI '{uri}', destination '{destination}'");
 
                if (!string.IsNullOrEmpty(destination))
                {
                    return def.WithCorrelationPayloadUris(new Dictionary<Uri, string>() { { new Uri(uri), destination } });
                }
                else
                {
                    return def.WithCorrelationPayloadUris(new Uri(uri));
                }
            }
 
            if (Directory.Exists(path))
            {
                string includeDirectoryNameStr = correlationPayload.GetMetadata(MetadataNames.IncludeDirectoryName);
                if (!bool.TryParse(includeDirectoryNameStr, out bool includeDirectoryName))
                {
                    includeDirectoryName = false;
                }
 
                Log.LogMessage(
                    MessageImportance.Low,
                    $"Adding Correlation Payload Directory '{path}', destination '{destination}'"
                );
                return def.WithCorrelationPayloadDirectory(path, includeDirectoryName, destination);
 
            }
 
            if (File.Exists(path))
            {
                string asArchiveStr = correlationPayload.GetMetadata(MetadataNames.AsArchive);
                if (!bool.TryParse(asArchiveStr, out bool asArchive))
                {
                    // With no other information, default to true, since that was the previous behavior
                    // before we added the option
                    asArchive = true;
                }
 
                if (asArchive)
                {
                    Log.LogMessage(
                        MessageImportance.Low,
                        $"Adding Correlation Payload Archive '{path}', destination '{destination}'"
                    );
                    return def.WithCorrelationPayloadArchive(path, destination);
                }
 
                Log.LogMessage(
                    MessageImportance.Low,
                    $"Adding Correlation Payload File '{path}', destination '{destination}'"
                );
                return def.WithCorrelationPayloadFiles(path);
            }
 
            Log.LogError(FailureCategory.Build, $"Correlation Payload '{path}' not found.");
            return def;
        }
    }
}