Просмотр исходного кода

Added yaml ansible inventory generator option (#225)

Tim Jones 1 месяц назад
Родитель
Сommit
dddf166f97

+ 131 - 57
RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs

@@ -3,118 +3,159 @@ using RackPeek.Domain.Resources;
 
 namespace RackPeek.Domain.UseCases.Ansible;
 
+public enum InventoryFormat
+{
+    Ini,
+    Yaml
+}
+
 public sealed record InventoryOptions
 {
+    /// <summary>
+    /// Output format (default: INI)
+    /// </summary>
+    public InventoryFormat Format { get; init; } = InventoryFormat.Ini;
+
     /// <summary>
     /// If set, create groups based on these tags.
-    /// Example: ["env", "site"] -> [env], [site]
+    /// Example: ["prod", "staging"] -> [prod], [staging]
     /// </summary>
     public IReadOnlyList<string> GroupByTags { get; init; } = [];
 
     /// <summary>
     /// If set, create groups based on these label keys.
-    /// Example: ["env", "site"] -> [env_prod], [site_london]
+    /// Example: ["env"] -> [env_prod]
     /// </summary>
     public IReadOnlyList<string> GroupByLabelKeys { get; init; } = [];
 
     /// <summary>
-    /// If set, emitted under [all:vars].
+    /// If set, emitted under [all:vars] (INI) or all.vars (YAML).
     /// </summary>
-    public IDictionary<string, string> GlobalVars { get; init; } = new Dictionary<string, string>();
+    public IDictionary<string, string> GlobalVars { get; init; } =
+        new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 }
 
 public sealed record InventoryResult(string InventoryText, IReadOnlyList<string> Warnings);
 
 public static class AnsibleInventoryGenerator
 {
-    /// <summary>
-    /// Generate an Ansible inventory in INI format from RackPeek resources.
-    /// </summary>
-    public static InventoryResult ToAnsibleInventoryIni(
+    public static InventoryResult ToAnsibleInventory(
         this IReadOnlyList<Resource> resources,
         InventoryOptions? options = null)
     {
         options ??= new InventoryOptions();
 
+        var model = BuildInventoryModel(resources, options);
+
+        return options.Format switch
+        {
+            InventoryFormat.Yaml => RenderYaml(model, options),
+            _ => RenderIni(model, options)
+        };
+    }
+
+    private sealed record HostEntry(
+        string Name,
+        Dictionary<string, string> Vars,
+        Resource Resource);
+
+    private sealed record InventoryModel(
+        Dictionary<string, List<HostEntry>> Groups,
+        List<string> Warnings);
+
+    private static InventoryModel BuildInventoryModel(
+        IReadOnlyList<Resource> resources,
+        InventoryOptions options)
+    {
         var warnings = new List<string>();
         var hosts = new List<HostEntry>();
 
-        // Build host entries (only resources that look addressable)
         foreach (var r in resources)
         {
             var address = GetAddress(r);
 
             if (string.IsNullOrWhiteSpace(address))
-            {
                 continue;
-            }
 
-            var hostVars = BuildHostVars(r, address);
-            hosts.Add(new HostEntry(r, hostVars));
+            var vars = BuildHostVars(r, address);
+            hosts.Add(new HostEntry(r.Name, vars, r));
         }
 
-        // Groups: kind + tags + label-based
-        var groupToHosts = new Dictionary<string, List<HostEntry>>(StringComparer.OrdinalIgnoreCase);
+        var groupToHosts =
+            new Dictionary<string, List<HostEntry>>(StringComparer.OrdinalIgnoreCase);
 
         void AddToGroup(string groupName, HostEntry h)
         {
-            if (string.IsNullOrWhiteSpace(groupName)) return;
+            if (string.IsNullOrWhiteSpace(groupName))
+                return;
+
             groupName = SanitizeGroup(groupName);
 
             if (!groupToHosts.TryGetValue(groupName, out var list))
                 groupToHosts[groupName] = list = new List<HostEntry>();
 
-            // avoid duplicates if multiple rules add the same host
-            if (!list.Any(x => string.Equals(x.Resource.Name, h.Resource.Name, StringComparison.OrdinalIgnoreCase)))
+            if (!list.Any(x => string.Equals(x.Name, h.Name, StringComparison.OrdinalIgnoreCase)))
                 list.Add(h);
         }
 
         foreach (var h in hosts)
         {
-            // Tag groups
-            var tags = options.GroupByTags.Intersect(h.Resource.Tags).ToArray();
-            foreach (var tag in tags)
-            {
-                if (string.IsNullOrWhiteSpace(tag)) continue;
+            // Tag-based groups
+            var matchingTags = options.GroupByTags
+                .Intersect(h.Resource.Tags ?? [])
+                .ToArray();
+
+            foreach (var tag in matchingTags)
                 AddToGroup(tag, h);
-            }
 
-            // Label-based groups: e.g. env=prod -> [env_prod]
+            // Label-based groups
             foreach (var key in options.GroupByLabelKeys)
             {
-                if (string.IsNullOrWhiteSpace(key)) continue;
-
-                if (h.Resource.Labels.TryGetValue(key, out var val) && !string.IsNullOrWhiteSpace(val))
+                if (h.Resource.Labels.TryGetValue(key, out var val)
+                    && !string.IsNullOrWhiteSpace(val))
                 {
                     AddToGroup($"{key}_{val}", h);
                 }
             }
         }
 
-        // Build output
+        return new InventoryModel(groupToHosts, warnings);
+    }
+    
+    private static InventoryResult RenderIni(
+        InventoryModel model,
+        InventoryOptions options)
+    {
         var sb = new StringBuilder();
 
-        // [all:vars]
         if (options.GlobalVars.Count > 0)
         {
             sb.AppendLine("[all:vars]");
-            foreach (var kvp in options.GlobalVars.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
+
+            foreach (var kvp in options.GlobalVars
+                         .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
+            {
                 sb.AppendLine($"{kvp.Key}={EscapeIniValue(kvp.Value)}");
+            }
+
             sb.AppendLine();
         }
 
-        // Emit groups sorted, hosts sorted
-        foreach (var group in groupToHosts.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
+        foreach (var group in model.Groups.Keys
+                     .OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
         {
             sb.AppendLine($"[{group}]");
 
-            foreach (var h in groupToHosts[group].OrderBy(x => x.Resource.Name, StringComparer.OrdinalIgnoreCase))
+            foreach (var host in model.Groups[group]
+                         .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase))
             {
-                sb.Append(h.Resource.Name);
+                sb.Append(host.Name);
 
-                // host vars (inline)
-                foreach (var kvp in h.HostVars.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
+                foreach (var kvp in host.Vars
+                             .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
+                {
                     sb.Append($" {kvp.Key}={EscapeIniValue(kvp.Value)}");
+                }
 
                 sb.AppendLine();
             }
@@ -122,16 +163,53 @@ public static class AnsibleInventoryGenerator
             sb.AppendLine();
         }
 
-        return new InventoryResult(sb.ToString().TrimEnd(), warnings);
+        return new InventoryResult(sb.ToString().TrimEnd(), model.Warnings);
     }
 
-    // ---------- helpers ----------
+    private static InventoryResult RenderYaml(
+        InventoryModel model,
+        InventoryOptions options)
+    {
+        var sb = new StringBuilder();
+
+        sb.AppendLine("all:");
+
+        if (options.GlobalVars.Count > 0)
+        {
+            sb.AppendLine("  vars:");
+            foreach (var kvp in options.GlobalVars
+                         .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
+            {
+                sb.AppendLine($"    {kvp.Key}: {kvp.Value}");
+            }
+        }
+
+        sb.AppendLine("  children:");
+
+        foreach (var group in model.Groups.Keys
+                     .OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
+        {
+            sb.AppendLine($"    {group}:");
+            sb.AppendLine("      hosts:");
+
+            foreach (var host in model.Groups[group]
+                         .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase))
+            {
+                sb.AppendLine($"        {host.Name}:");
+
+                foreach (var kvp in host.Vars
+                             .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
+                {
+                    sb.AppendLine($"          {kvp.Key}: {kvp.Value}");
+                }
+            }
+        }
 
-    private sealed record HostEntry(Resource Resource, Dictionary<string, string> HostVars);
+        return new InventoryResult(sb.ToString().TrimEnd(), model.Warnings);
+    }
 
     private static string? GetAddress(Resource r)
     {
-        // Preferred: ansible_host, else ip, else hostname
         if (r.Labels.TryGetValue("ansible_host", out var ah) && !string.IsNullOrWhiteSpace(ah))
             return ah;
 
@@ -151,19 +229,13 @@ public static class AnsibleInventoryGenerator
             ["ansible_host"] = address
         };
 
-        // Copy any labels prefixed with ansible_
         foreach (var (k, v) in r.Labels)
         {
-            if (string.IsNullOrWhiteSpace(k) || string.IsNullOrWhiteSpace(v)) continue;
+            if (string.IsNullOrWhiteSpace(k) || string.IsNullOrWhiteSpace(v))
+                continue;
 
             if (k.StartsWith("ansible_", StringComparison.OrdinalIgnoreCase))
-            {
-                // don't overwrite ansible_host we already derived unless explicitly present
-                if (string.Equals(k, "ansible_host", StringComparison.OrdinalIgnoreCase))
-                    vars["ansible_host"] = v;
-                else
-                    vars[k] = v;
-            }
+                vars[k] = v;
         }
 
         return vars;
@@ -171,15 +243,14 @@ public static class AnsibleInventoryGenerator
 
     private static string SanitizeGroup(string s)
     {
-        // Ansible group names: letters/digits/underscore
-        var sb = new StringBuilder(s.Length);
+        var sb = new StringBuilder();
+
         foreach (var ch in s.Trim().ToLowerInvariant())
         {
             if (char.IsLetterOrDigit(ch) || ch == '_')
                 sb.Append(ch);
             else if (ch == '-' || ch == '.' || ch == ' ')
                 sb.Append('_');
-            // drop everything else
         }
 
         var result = sb.ToString();
@@ -188,11 +259,14 @@ public static class AnsibleInventoryGenerator
 
     private static string EscapeIniValue(string value)
     {
-        // quote if it contains spaces or special chars
-        if (string.IsNullOrEmpty(value)) return "\"\"";
+        if (string.IsNullOrEmpty(value))
+            return "\"\"";
+
+        var needsQuotes = value.Any(ch =>
+            char.IsWhiteSpace(ch) || ch is '"' or '\'' or '=');
 
-        var needsQuotes = value.Any(ch => char.IsWhiteSpace(ch) || ch is '"' or '\'' or '=');
-        if (!needsQuotes) return value;
+        if (!needsQuotes)
+            return value;
 
         return "\"" + value.Replace("\"", "\\\"") + "\"";
     }

+ 1 - 1
RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGeneratorUseCase.cs

@@ -8,6 +8,6 @@ public class AnsibleInventoryGeneratorUseCase(IResourceCollection repository) :
     public async Task<InventoryResult?> ExecuteAsync(InventoryOptions options)
     {
         var resources = await repository.GetAllOfTypeAsync<Resource>();
-        return resources.ToAnsibleInventoryIni(options);
+        return resources.ToAnsibleInventory(options);
     }
 }

+ 35 - 3
Shared.Rcl/Commands/Ansible/GenerateAnsibleInventoryCommand.cs

@@ -6,6 +6,7 @@ using Spectre.Console.Cli;
 
 namespace Shared.Rcl.Commands.Ansible;
 
+
 public sealed class GenerateAnsibleInventorySettings : CommandSettings
 {
     [CommandOption("--group-tags")]
@@ -20,11 +21,15 @@ public sealed class GenerateAnsibleInventorySettings : CommandSettings
     [Description("Global variable (repeatable). Format: key=value")]
     public string[] GlobalVars { get; init; } = [];
 
+    [CommandOption("--format")]
+    [Description("Inventory format: ini (default) or yaml")]
+    [DefaultValue("ini")]
+    public string Format { get; init; } = "ini";
+
     [CommandOption("-o|--output")]
     [Description("Write inventory to file instead of stdout")]
     public string? OutputPath { get; init; }
 }
-
 public sealed class GenerateAnsibleInventoryCommand(IServiceProvider provider)
     : AsyncCommand<GenerateAnsibleInventorySettings>
 {
@@ -38,8 +43,16 @@ public sealed class GenerateAnsibleInventoryCommand(IServiceProvider provider)
         var useCase = scope.ServiceProvider
             .GetRequiredService<AnsibleInventoryGeneratorUseCase>();
 
+        if (!TryParseFormat(settings.Format, out var format))
+        {
+            AnsiConsole.MarkupLine(
+                $"[red]Invalid format:[/] {Markup.Escape(settings.Format)}. Use 'ini' or 'yaml'.");
+            return -1;
+        }
+
         var options = new InventoryOptions
         {
+            Format = format,
             GroupByTags = ParseCsv(settings.GroupTags),
             GroupByLabelKeys = ParseCsv(settings.GroupLabels),
             GlobalVars = ParseGlobalVars(settings.GlobalVars)
@@ -63,7 +76,10 @@ public sealed class GenerateAnsibleInventoryCommand(IServiceProvider provider)
 
         if (!string.IsNullOrWhiteSpace(settings.OutputPath))
         {
-            await File.WriteAllTextAsync(settings.OutputPath, result.InventoryText, cancellationToken);
+            await File.WriteAllTextAsync(
+                settings.OutputPath,
+                result.InventoryText,
+                cancellationToken);
 
             AnsiConsole.MarkupLine(
                 $"[green]Inventory written to:[/] {Markup.Escape(settings.OutputPath)}");
@@ -80,6 +96,21 @@ public sealed class GenerateAnsibleInventoryCommand(IServiceProvider provider)
 
     // ------------------------
 
+    private static bool TryParseFormat(string raw, out InventoryFormat format)
+    {
+        format = raw.Trim().ToLowerInvariant() switch
+        {
+            "ini" => InventoryFormat.Ini,
+            "yaml" => InventoryFormat.Yaml,
+            "yml" => InventoryFormat.Yaml,
+            _ => default
+        };
+
+        return raw.Equals("ini", StringComparison.OrdinalIgnoreCase)
+            || raw.Equals("yaml", StringComparison.OrdinalIgnoreCase)
+            || raw.Equals("yml", StringComparison.OrdinalIgnoreCase);
+    }
+
     private static IReadOnlyList<string> ParseCsv(string? raw)
     {
         if (string.IsNullOrWhiteSpace(raw))
@@ -98,7 +129,8 @@ public sealed class GenerateAnsibleInventoryCommand(IServiceProvider provider)
         foreach (var entry in vars ?? [])
         {
             var parts = entry.Split('=', 2);
-            if (parts.Length != 2) continue;
+            if (parts.Length != 2)
+                continue;
 
             var key = parts[0].Trim();
             var value = parts[1].Trim();

+ 19 - 2
Shared.Rcl/Components/AnsibleInventory.razor

@@ -1,5 +1,4 @@
 @page "/ansible/inventory"
-@using RackPeek.Domain.Resources.Desktops
 @using RackPeek.Domain.UseCases.Ansible
 @inject AnsibleInventoryGeneratorUseCase InventoryUseCase
 
@@ -21,6 +20,20 @@
     <!-- Options -->
     <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
 
+        <!-- Format -->
+        <div>
+            <div class="text-zinc-400 mb-1">Format</div>
+            <select class="w-full bg-zinc-800 text-zinc-200 p-2 rounded border border-zinc-700"
+                    data-testid="inventory-format-select"
+                    @bind="_selectedFormat">
+                <option value="Ini">INI</option>
+                <option value="Yaml">YAML</option>
+            </select>
+            <div class="text-xs text-zinc-500 mt-1">
+                Select output format
+            </div>
+        </div>
+
         <!-- Tags -->
         <div>
             <div class="text-zinc-400 mb-1">Group By Tags</div>
@@ -90,12 +103,14 @@
 
 @code {
 
+    private InventoryFormat _selectedFormat = InventoryFormat.Ini;
+
     private string _groupByTagsRaw = "prod, staging";
     private string _groupByLabelsRaw = "env, site";
     private string _globalVarsRaw =
 @"ansible_user=ansible
 ansible_python_interpreter=/usr/bin/python3";
-    
+
     private string _inventoryText = string.Empty;
     private List<string> _warnings = new();
 
@@ -108,12 +123,14 @@ ansible_python_interpreter=/usr/bin/python3";
 
             var options = new InventoryOptions
             {
+                Format = _selectedFormat,
                 GroupByTags = ParseCsv(_groupByTagsRaw),
                 GroupByLabelKeys = ParseCsv(_groupByLabelsRaw),
                 GlobalVars = ParseGlobalVars(_globalVarsRaw)
             };
 
             var result = await InventoryUseCase.ExecuteAsync(options);
+
             if (result is null)
             {
                 _warnings.Add("Inventory generation returned null.");

+ 99 - 0
Tests/EndToEnd/AnsibleTests/AnsibleInventoryWorkflowTests.cs

@@ -87,4 +87,103 @@ public class AnsibleInventoryWorkflowTests(
                      vm-staging01 ansible_host=192.168.1.20 ansible_user=debian
                      """, output);
     }
+    
+    [Fact]
+    public async Task ansible_inventory_yaml_output_test()
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 1
+                                                                           resources:
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             os: ubuntu-22.04
+                                                                             cores: 2
+                                                                             ram: 4
+                                                                             name: vm-web01
+                                                                             tags:
+                                                                             - prod
+                                                                             labels:
+                                                                               ansible_host: 192.168.1.10
+                                                                               ansible_user: ubuntu
+                                                                               env: prod
+                                                                           """);
+
+        var (output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--format", "yaml",
+            "--group-tags", "prod",
+            "--group-labels", "env"
+        );
+
+        Assert.Equal("""
+                     Generated Inventory:
+
+                     all:
+                       children:
+                         env_prod:
+                           hosts:
+                             vm-web01:
+                               ansible_host: 192.168.1.10
+                               ansible_user: ubuntu
+                         prod:
+                           hosts:
+                             vm-web01:
+                               ansible_host: 192.168.1.10
+                               ansible_user: ubuntu
+                     """, output);
+    }
+    
+    [Fact]
+    public async Task ansible_inventory_groups_are_sorted()
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 1
+                                                                           resources:
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: b-host
+                                                                             tags: [zeta]
+                                                                             labels:
+                                                                               ansible_host: 10.0.0.2
+
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: a-host
+                                                                             tags: [alpha]
+                                                                             labels:
+                                                                               ansible_host: 10.0.0.1
+                                                                           """);
+
+        var (output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--group-tags", "alpha,zeta"
+        );
+
+        var alphaIndex = output.IndexOf("[alpha]", StringComparison.Ordinal);
+        var zetaIndex = output.IndexOf("[zeta]", StringComparison.Ordinal);
+
+        Assert.True(alphaIndex < zetaIndex);
+    }
+    
+    [Fact]
+    public async Task yaml_format_does_not_emit_ini_sections()
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 1
+                                                                           resources:
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: vm1
+                                                                             labels:
+                                                                               ansible_host: 10.0.0.1
+                                                                           """);
+
+        var (output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--format", "yaml"
+        );
+
+        Assert.DoesNotContain("[", output);
+        Assert.Contains("all:", output);
+    }
 }