diff --git a/src/Nox.Cli.Abstractions/Configuration/IJobConfiguration.cs b/src/Nox.Cli.Abstractions/Configuration/IJobConfiguration.cs index c21dc5e..e2b9d42 100644 --- a/src/Nox.Cli.Abstractions/Configuration/IJobConfiguration.cs +++ b/src/Nox.Cli.Abstractions/Configuration/IJobConfiguration.cs @@ -5,6 +5,7 @@ public interface IJobConfiguration string Id { get; set; } string Name { get; set; } string? If { get; set; } + string? ForEach { get; set; } NoxJobDisplayMessage? Display { get; set; } List Steps { get; set; } } \ No newline at end of file diff --git a/src/Nox.Cli.Abstractions/INoxJob.cs b/src/Nox.Cli.Abstractions/INoxJob.cs index e7a3fef..92d5aec 100644 --- a/src/Nox.Cli.Abstractions/INoxJob.cs +++ b/src/Nox.Cli.Abstractions/INoxJob.cs @@ -8,6 +8,7 @@ public interface INoxJob string Name { get; set; } string? If { get; set; } + object? ForEach { get; set; } NoxJobDisplayMessage? Display { get; set; } IDictionary Steps { get; set; } diff --git a/src/Nox.Cli.Abstractions/NoxJobDictionary.cs b/src/Nox.Cli.Abstractions/NoxJobDictionary.cs new file mode 100644 index 0000000..0fbe988 --- /dev/null +++ b/src/Nox.Cli.Abstractions/NoxJobDictionary.cs @@ -0,0 +1,16 @@ +using System.Collections.ObjectModel; + +namespace Nox.Cli.Abstractions; + +public class NoxJobDictionary: KeyedCollection +{ + public NoxJobDictionary(): base(StringComparer.OrdinalIgnoreCase) + { + + } + + protected override string GetKeyForItem(INoxJob item) + { + return item.Id; + } +} \ No newline at end of file diff --git a/src/Nox.Cli.Caching/NoxCliCacheManager.cs b/src/Nox.Cli.Caching/NoxCliCacheManager.cs index 74cb9f3..e9b3146 100755 --- a/src/Nox.Cli.Caching/NoxCliCacheManager.cs +++ b/src/Nox.Cli.Caching/NoxCliCacheManager.cs @@ -182,7 +182,7 @@ internal INoxCliCacheManager Build() } } - var deserializer = BuildDeserializer(); + BuildDeserializer(); ResolveManifest(yamlFiles); ResolveWorkflows(yamlFiles); diff --git a/src/Nox.Cli.Configuration/JobConfiguration.cs b/src/Nox.Cli.Configuration/JobConfiguration.cs index 9b6b81e..8ba50c7 100644 --- a/src/Nox.Cli.Configuration/JobConfiguration.cs +++ b/src/Nox.Cli.Configuration/JobConfiguration.cs @@ -10,7 +10,8 @@ public class JobConfiguration: IJobConfiguration public string Name { get; set; } = string.Empty; public string? If { get; set; } - + public string? ForEach { get; set; } + public NoxJobDisplayMessage? Display { get; set; } public List Steps { get; set; } = new(); } \ No newline at end of file diff --git a/src/Nox.Cli.Plugins/Nox.Cli.Plugin.Console/ConsolePromptSchema_v1.cs b/src/Nox.Cli.Plugins/Nox.Cli.Plugin.Console/ConsolePromptSchema_v1.cs index 544d482..84925f5 100755 --- a/src/Nox.Cli.Plugins/Nox.Cli.Plugin.Console/ConsolePromptSchema_v1.cs +++ b/src/Nox.Cli.Plugins/Nox.Cli.Plugin.Console/ConsolePromptSchema_v1.cs @@ -84,8 +84,6 @@ public NoxActionMetaData Discover() }; } - private Regex _defaultArrayRegex = new(@"\[(.*?)\]\.(.*)", RegexOptions.Compiled | RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); - private string? _schemaUrl = null!; private string? _schema = null!; @@ -562,6 +560,8 @@ private void AppendKey(string yamlSpacing, string key) private void ProcessDefaults(string key, string yamlSpacing, string yamlSpacingPostfix) { + Regex defaultArrayRegex = new(@"\[(.*?)\]\.(.*)", RegexOptions.Compiled | RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); + _yaml.AppendLine($"{yamlSpacing}{key}:"); var arrayIndex = -1; foreach (var defaultItem in _defaults!.Where(d => d.Key.StartsWith(key, StringComparison.CurrentCultureIgnoreCase))) @@ -569,7 +569,7 @@ private void ProcessDefaults(string key, string yamlSpacing, string yamlSpacingP //check if this item is an array var itemKey = defaultItem.Key; var defaultSpacing = new string(' ', itemKey.Count(d => d == '.') * 2); - var match = _defaultArrayRegex.Match(itemKey); + var match = defaultArrayRegex.Match(itemKey); if (match.Success) //array { if (int.TryParse(match.Groups[1].ToString(), out var itemIndex)) diff --git a/src/Nox.Cli.Variables/ClientVariableProvider.cs b/src/Nox.Cli.Variables/ClientVariableProvider.cs index 20f0116..19b9f0e 100755 --- a/src/Nox.Cli.Variables/ClientVariableProvider.cs +++ b/src/Nox.Cli.Variables/ClientVariableProvider.cs @@ -140,6 +140,11 @@ public void ResolveJobVariables(INoxJob job) { job.If = ReplaceVariable(job.If).ToString()!; } + + if (job.ForEach != null && !string.IsNullOrWhiteSpace(job.ForEach.ToString())) + { + job.ForEach = ReplaceVariable(job.ForEach.ToString()!); + } } private void Initialize(IWorkflowConfiguration workflow) diff --git a/src/Nox.Cli.Variables/ForEachVariableProvider.cs b/src/Nox.Cli.Variables/ForEachVariableProvider.cs new file mode 100644 index 0000000..7fcb66d --- /dev/null +++ b/src/Nox.Cli.Variables/ForEachVariableProvider.cs @@ -0,0 +1,131 @@ +using System.Text.RegularExpressions; +using Nox.Cli.Abstractions; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Nox.Cli.Variables; + +public class ForEachVariableProvider +{ + private readonly Regex _variableRegex = new(@"\$\{\{\s*(?\b(foreach)\b[\w\.\-_:]+)\s*\}\}", RegexOptions.Compiled | RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); + + private readonly Dictionary _variables; + + + public ForEachVariableProvider(INoxJob job) + { + _variables = new Dictionary(StringComparer.OrdinalIgnoreCase); + Initialize(job); + } + + public void ResolveAll(INoxJob job, object forEachObject) + { + _variables.ResolveForEachVariables(forEachObject); + foreach (var step in job.Steps) + { + foreach (var (_, input) in step.Value.Inputs) + { + if (input.Default is string inputValueString) + { + input.Default = ReplaceVariable(inputValueString); + } + else if (input.Default is List inputValueList) + { + for (var i = 0; i < inputValueList.Count; i++) + { + if (inputValueList[i] is string) + { + var index = inputValueList.FindIndex(n => n.Equals(inputValueList[i])); + inputValueList[index] = ReplaceVariable((string)inputValueList[i]); + } + } + } + else if (input.Default is Dictionary inputValueDictionary) + { + for (var i = 0; i < inputValueDictionary.Count; i++) + { + var item = inputValueDictionary.ElementAt(i); + + if (item.Value is string itemValueString) + { + inputValueDictionary[item.Key] = ReplaceVariable(itemValueString); + } + } + } + } + } + + job.Name = ReplaceVariable(job.Name).ToString()!; + if (!string.IsNullOrWhiteSpace(job.Display?.Success)) + { + job.Display.Success = ReplaceVariable(job.Display.Success).ToString()!; + } + } + + private void Initialize(INoxJob job) + { + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + var serialized = serializer.Serialize(job); + + var matches = _variableRegex.Matches(serialized); + + var variablesTemp = matches.Select(m => m.Groups[2].Value) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(e => e); + + foreach (var v in variablesTemp) + { + _variables.Add(v, null); + } + } + + private object ReplaceVariable(string value) + { + object result = value; + + var match = _variableRegex.Match(result.ToString()!); + + while (match.Success) + { + var fullPhrase = match.Groups[0].Value; + + var variable = match.Groups["variable"].Value; + + var resolvedValue = LookupValue(variable); + + if (resolvedValue == null || resolvedValue.GetType() == typeof(object)) + { + break; + } + else if (resolvedValue.GetType().IsSimpleType()) + { + result = result.ToString()!.Replace(fullPhrase, resolvedValue.ToString()); + } + else + { + result = resolvedValue; + break; + } + + match = _variableRegex.Match(result.ToString()!); + } + + return result; + } + + private object? LookupValue(string variable) + { + if (_variables.ContainsKey(variable)) + { + var lookupVar = _variables[variable]; + if (lookupVar == null) return null; + return _variables[variable]; + } + return null; + } + + +} \ No newline at end of file diff --git a/src/Nox.Cli.Variables/ForEachVariableResolver.cs b/src/Nox.Cli.Variables/ForEachVariableResolver.cs new file mode 100644 index 0000000..879eb09 --- /dev/null +++ b/src/Nox.Cli.Variables/ForEachVariableResolver.cs @@ -0,0 +1,17 @@ +using Nox.Cli.Abstractions; + +namespace Nox.Cli.Variables; + +public static class ForEachVariableResolver +{ + public static void ResolveForEachVariables(this IDictionary variables, object forEachObject) + { + var forEachKeys = variables + .Select(pk => pk.Key) + .Where(pk => pk.StartsWith("forEach.", StringComparison.OrdinalIgnoreCase)) + .Select(pk => pk[8..]) + .ToArray(); + + forEachObject.WalkProperties( (name, value) => { if (forEachKeys.Contains(name, StringComparer.OrdinalIgnoreCase)) { variables[$"forEach.{name}"] = value; } }); + } +} \ No newline at end of file diff --git a/src/Nox.Cli/Actions/NoxJob.cs b/src/Nox.Cli/Actions/NoxJob.cs index 13684a8..fe0f45d 100644 --- a/src/Nox.Cli/Actions/NoxJob.cs +++ b/src/Nox.Cli/Actions/NoxJob.cs @@ -11,7 +11,8 @@ public class NoxJob: INoxJob public string Name { get; set; } = string.Empty; public string? If { get; set; } - + public object? ForEach { get; set; } + public NoxJobDisplayMessage? Display { get; set; } = new(); public IDictionary Steps { get; set; } = new Dictionary(); diff --git a/src/Nox.Cli/Actions/NoxWorkflowContext.cs b/src/Nox.Cli/Actions/NoxWorkflowContext.cs index f987945..13af49c 100755 --- a/src/Nox.Cli/Actions/NoxWorkflowContext.cs +++ b/src/Nox.Cli/Actions/NoxWorkflowContext.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.Collections.ObjectModel; +using System.Text.RegularExpressions; using Nox.Cli.Abstractions; using Nox.Cli.Abstractions.Configuration; using Nox.Cli.Abstractions.Helpers; @@ -16,7 +17,7 @@ namespace Nox.Cli.Actions; public class NoxWorkflowContext : INoxWorkflowContext { private readonly IWorkflowConfiguration _workflow; - private readonly IDictionary _jobs; + private readonly NoxJobDictionary _jobs; private IDictionary _steps; private readonly IClientVariableProvider _varProvider; private readonly INoxCliCacheManager _cacheManager; @@ -60,8 +61,9 @@ public NoxWorkflowContext( public void NextJob() { _currentJobSequence++; - _currentJob = _jobs.Select(j => j.Value).FirstOrDefault(j => j.Sequence == _currentJobSequence); - _nextJob = _jobs.Select(j => j.Value).FirstOrDefault(j => j.Sequence == _currentJobSequence + 1); + + _currentJob = _jobs.FirstOrDefault(j => j.Sequence == _currentJobSequence); + _nextJob = _jobs.FirstOrDefault(j => j.Sequence == _currentJobSequence + 1); if (_currentJob != null) { @@ -164,17 +166,44 @@ public IDictionary GetUnresolvedInputVariables(INoxAction action return _varProvider.GetUnresolvedInputVariables(action); } - private Dictionary ParseWorkflow() + public int RemoveCurrentJob() + { + var currentIndex = _jobs.IndexOf(_currentJob!); + for (int i = currentIndex; i <= _jobs.Count - 1; i++) + { + _jobs[i].Sequence--; + } + _jobs.RemoveAt(currentIndex); + return currentIndex; + } + + /// + /// Injects a job iteration of a recurring job at the current location + /// + public void InjectJobIteration(int index, INoxJob job, bool setAsCurrent) + { + job.Sequence = _jobs[index].Sequence; + _jobs.Insert(index, job); + //increment the sequence of the rest of the jobs + for (int i = index + 1; i <= _jobs.Count - 1; i++) + { + _jobs[i].Sequence++; + } + + if (setAsCurrent) _currentJob = job; + } + + private NoxJobDictionary ParseWorkflow() { var jobSequence = 1; var stepSequence = 1; - var jobs = new Dictionary(StringComparer.OrdinalIgnoreCase); + var jobs = new NoxJobDictionary(); foreach (var jobConfiguration in _workflow.Jobs) { var jobKey = jobConfiguration.Id; - if (jobs.ContainsKey(jobConfiguration.Id)) + if (jobs.Contains(jobConfiguration.Id)) { throw new NoxCliException($"Job Id {jobKey} exists more than once in your workflow configuration. Job Ids must be unique in a workflow configuration"); } @@ -185,6 +214,7 @@ private Dictionary ParseWorkflow() Id = jobConfiguration.Id, Name = jobConfiguration.Name, If = jobConfiguration.If, + ForEach = jobConfiguration.ForEach, Display = jobConfiguration.Display, FirstStepSequence = stepSequence, Steps = ParseSteps(jobConfiguration, ref stepSequence) @@ -204,8 +234,8 @@ private Dictionary ParseWorkflow() newJob.Display.IfCondition = MaskSecretsInDisplayText(newJob.Display.IfCondition); } } - - jobs[jobKey] = newJob; + + jobs.Add(newJob); } return jobs; diff --git a/src/Nox.Cli/Actions/NoxWorkflowExecutor.cs b/src/Nox.Cli/Actions/NoxWorkflowExecutor.cs index de156a7..fe4ec61 100755 --- a/src/Nox.Cli/Actions/NoxWorkflowExecutor.cs +++ b/src/Nox.Cli/Actions/NoxWorkflowExecutor.cs @@ -1,12 +1,15 @@ -using Microsoft.Graph.Print.Printers.Item.Jobs; +using System.Collections; using Nox.Cli.Abstractions; using Nox.Cli.Abstractions.Caching; using Nox.Cli.Abstractions.Configuration; +using Nox.Cli.Abstractions.Exceptions; using Nox.Cli.Secrets; using Nox.Cli.Server.Integration; +using Nox.Cli.Variables; using Nox.Secrets.Abstractions; using Nox.Solution; using Spectre.Console; +using ActionState = Nox.Cli.Abstractions.ActionState; namespace Nox.Cli.Actions; @@ -47,7 +50,7 @@ public async Task Execute(IWorkflowConfiguration workflow) var watch = System.Diagnostics.Stopwatch.StartNew(); - var success = true; + var success = false; var workflowCtx = _console.Status() .Spinner(Spinner.Known.Clock) @@ -61,94 +64,20 @@ public async Task Execute(IWorkflowConfiguration workflow) break; } - var jobName = workflowCtx.CurrentJob.Name.EscapeMarkup(); - - _console.WriteLine(); - if (workflowCtx.CurrentJob.Steps.Any(s => s.Value.RunAtServer == true)) + if (workflowCtx.CurrentJob.ForEach != null && !string.IsNullOrWhiteSpace(workflowCtx.CurrentJob.ForEach.ToString())) { - ConsoleRootLine($"[mediumpurple3_1]{jobName}[/] {Emoji.Known.DesktopComputer} [bold yellow]Running at: {_serverIntegration!.Endpoint}[/]"); - + await AddRecurringJob(workflowCtx); } - else - { - ConsoleRootLine($"[mediumpurple3_1]{jobName}[/]"); - } - - await workflowCtx.ResolveJobVariables(workflowCtx.CurrentJob); - var jobSkipped = false; + success = await ProcessSingleJob(workflowCtx); + + if (!success) break; - if (!workflowCtx.CurrentJob.EvaluateIf()) - { - jobSkipped = true; - if (string.IsNullOrWhiteSpace(workflowCtx.CurrentJob.Display?.IfCondition)) - { - ConsoleRootLine($"{Emoji.Known.BlueSquare} [deepskyblue1]Skipped because an if condition evaluated true[/]"); - } - else - { - ConsoleRootLine($"{Emoji.Known.BlueSquare} [deepskyblue1]{workflowCtx.CurrentJob.Display.IfCondition.EscapeMarkup()}[/]"); - } - } - else - { - while (workflowCtx.CurrentAction != null) - { - if (workflowCtx.CancellationToken != null) - { - break; - } - - var taskDescription = $"{workflowCtx.CurrentAction.Name}".EscapeMarkup(); - - if (workflowCtx.CurrentAction.RunAtServer == true) - { - success = await _console - .Status() - .Spinner(Spinner.Known.Clock) - .StartAsync(taskDescription, async _ => await ProcessServerTask(workflowCtx, taskDescription)); - } - else - { - var requiresConsole = workflowCtx.CurrentAction.ActionProvider.Discover().RequiresConsole; - if (requiresConsole) - { - success = await ProcessTask(workflowCtx, taskDescription); - } - else - { - - success = await _console - .Status() - .Spinner(Spinner.Known.Clock) - .StartAsync(taskDescription, async _ => await ProcessTask(workflowCtx, taskDescription)); - } - } - - if (!success) break; - - workflowCtx.NextStep(); - } - } - - if (!success) - { - break; - } - - if (workflowCtx.CurrentJob.Display != null && - !string.IsNullOrWhiteSpace(workflowCtx.CurrentJob.Display.Success) && - !jobSkipped) - { - ConsoleRootLine($"{Emoji.Known.GreenSquare} [lightgreen]{workflowCtx.CurrentJob.Display.Success}[/]"); - } - workflowCtx.NextJob(); } await Task.WhenAll(_processedActions.Where(p => p.RunAtServer == false).Select(p => p.ActionProvider.EndAsync())); - watch.Stop(); _console.WriteLine(); @@ -156,13 +85,13 @@ public async Task Execute(IWorkflowConfiguration workflow) if (success) { _console.MarkupLine($"[seagreen1]Success! ({watch.Elapsed:hh\\:mm\\:ss})[/]"); + return true; } else { _console.MarkupLine($"[indianred1]Workflow halted with an error. ({watch.Elapsed:hh\\:mm\\:ss})[/]"); + return false; } - - return success; } private async Task ProcessTask(NoxWorkflowContext ctx, string taskDescription) @@ -331,6 +260,112 @@ private void ConsoleStatusLine(string value) var padding = new string(' ', 4); _console.MarkupLine($"{padding}{value}"); } + + private async Task ProcessSingleJob(NoxWorkflowContext context) + { + var job = context.CurrentJob!; + await context.ResolveJobVariables(job); + + var jobName = job.Name.EscapeMarkup(); + + _console.WriteLine(); + if (job.Steps.Any(s => s.Value.RunAtServer == true)) + { + ConsoleRootLine($"[mediumpurple3_1]{jobName}[/] {Emoji.Known.DesktopComputer} [bold yellow]Running at: {_serverIntegration!.Endpoint}[/]"); + } + else + { + ConsoleRootLine($"[mediumpurple3_1]{jobName}[/]"); + } + + var jobSkipped = false; + + if (!job.EvaluateIf()) + { + jobSkipped = true; + if (string.IsNullOrWhiteSpace(job.Display?.IfCondition)) + { + ConsoleRootLine($"{Emoji.Known.BlueSquare} [deepskyblue1]Skipped because an if condition evaluated true[/]"); + } + else + { + ConsoleRootLine($"{Emoji.Known.BlueSquare} [deepskyblue1]{job.Display.IfCondition.EscapeMarkup()}[/]"); + } + } + else + { + while (context.CurrentAction != null) + { + if (context.CancellationToken != null) + { + break; + } + + var taskDescription = $"{context.CurrentAction.Name}".EscapeMarkup(); + + bool success; + if (context.CurrentAction.RunAtServer == true) + { + success = await _console + .Status() + .Spinner(Spinner.Known.Clock) + .StartAsync(taskDescription, async _ => await ProcessServerTask(context, taskDescription)); + } + else + { + var requiresConsole = context.CurrentAction.ActionProvider.Discover().RequiresConsole; + if (requiresConsole) + { + success = await ProcessTask(context, taskDescription); + } + else + { + + success = await _console + .Status() + .Spinner(Spinner.Known.Clock) + .StartAsync(taskDescription, async _ => await ProcessTask(context, taskDescription)); + } + } + + if (!success) return false; + + context.NextStep(); + } + } + + if (job.Display != null && + !string.IsNullOrWhiteSpace(job.Display.Success) && + !jobSkipped) + { + ConsoleRootLine($"{Emoji.Known.GreenSquare} [lightgreen]{job.Display.Success}[/]"); + } + + return true; + } + + private async Task AddRecurringJob(NoxWorkflowContext context) + { + var job = context.CurrentJob!; + await context.ResolveJobVariables(job); + + var forEach = job.ForEach!; + + if (forEach is not IList forEachList) throw new NoxCliException("The value of the for-each in a Nox Job must implement IList."); + var varProvider = new ForEachVariableProvider(job); + + var index = context.RemoveCurrentJob(); + + //Add the iterations in reverse + for (int i = forEachList.Count - 1; i >= 0; i--) + { + var jobInstance = job.Clone(Guid.NewGuid().ToString()); + varProvider.ResolveAll(jobInstance, forEachList[i]!); + context.InjectJobIteration(index, jobInstance, i == 0); + } + + } + } diff --git a/src/Nox.Cli/Extensions/JobExtensions.cs b/src/Nox.Cli/Extensions/JobExtensions.cs new file mode 100644 index 0000000..644db5c --- /dev/null +++ b/src/Nox.Cli/Extensions/JobExtensions.cs @@ -0,0 +1,20 @@ +using Nox.Cli.Abstractions; +using Nox.Cli.Actions; + +namespace Nox.Cli; + +public static class JobExtensions +{ + public static INoxJob Clone(this INoxJob source, string id) + { + var result = new NoxJob + { + Id = source.Id + id, + Name = source.Name, + Steps = source.Steps, + Display = source.Display + }; + + return result; + } +} \ No newline at end of file diff --git a/src/Nox.Cli/Properties/launchSettings.json b/src/Nox.Cli/Properties/launchSettings.json index fd26193..3cdd6ff 100755 --- a/src/Nox.Cli/Properties/launchSettings.json +++ b/src/Nox.Cli/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "Nox.Cli": { "commandName": "Project", - "commandLineArgs": "init solution", + "commandLineArgs": "test workflow", "workingDirectory": "/home/jan/demo/NoxCliDemoProject", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development"