using System.Text; using RackPeek.Domain.Resources; namespace RackPeek.Domain.UseCases.Ansible; public enum InventoryFormat { Ini, Yaml } public sealed record InventoryOptions { /// /// Output format (default: INI) /// public InventoryFormat Format { get; init; } = InventoryFormat.Ini; /// /// If set, create groups based on these tags. /// Example: ["prod", "staging"] -> [prod], [staging] /// public IReadOnlyList GroupByTags { get; init; } = []; /// /// If set, create groups based on these label keys. /// Example: ["env"] -> [env_prod] /// public IReadOnlyList GroupByLabelKeys { get; init; } = []; /// /// If set, emitted under [all:vars] (INI) or all.vars (YAML). /// public IDictionary GlobalVars { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); } public sealed record InventoryResult(string InventoryText, IReadOnlyList Warnings); public static class AnsibleInventoryGenerator { public static InventoryResult ToAnsibleInventory( this IReadOnlyList resources, InventoryOptions? options = null) { options ??= new InventoryOptions(); InventoryModel model = BuildInventoryModel(resources, options); return options.Format switch { InventoryFormat.Yaml => RenderYaml(model, options), _ => RenderIni(model, options) }; } private static InventoryModel BuildInventoryModel( IReadOnlyList resources, InventoryOptions options) { var warnings = new List(); var hosts = new List(); foreach (Resource r in resources) { var address = GetAddress(r); if (string.IsNullOrWhiteSpace(address)) continue; Dictionary vars = BuildHostVars(r, address); hosts.Add(new HostEntry(r.Name, vars, r)); } var groupToHosts = new Dictionary>(StringComparer.OrdinalIgnoreCase); void AddToGroup(string groupName, HostEntry h) { if (string.IsNullOrWhiteSpace(groupName)) return; groupName = SanitizeGroup(groupName); if (!groupToHosts.TryGetValue(groupName, out List? list)) groupToHosts[groupName] = list = new List(); if (!list.Any(x => string.Equals(x.Name, h.Name, StringComparison.OrdinalIgnoreCase))) list.Add(h); } foreach (HostEntry h in hosts) { // Tag-based groups var matchingTags = options.GroupByTags .Intersect(h.Resource.Tags ?? []) .ToArray(); foreach (var tag in matchingTags) AddToGroup(tag, h); // Label-based groups foreach (var key in options.GroupByLabelKeys) if (h.Resource.Labels.TryGetValue(key, out var val) && !string.IsNullOrWhiteSpace(val)) AddToGroup($"{key}_{val}", h); } return new InventoryModel(groupToHosts, warnings); } private static InventoryResult RenderIni( InventoryModel model, InventoryOptions options) { var sb = new StringBuilder(); if (options.GlobalVars.Count > 0) { sb.AppendLine("[all:vars]"); foreach (KeyValuePair kvp in options.GlobalVars .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) sb.AppendLine($"{kvp.Key}={EscapeIniValue(kvp.Value)}"); sb.AppendLine(); } foreach (var group in model.Groups.Keys .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) { sb.AppendLine($"[{group}]"); foreach (HostEntry host in model.Groups[group] .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)) { sb.Append(host.Name); foreach (KeyValuePair kvp in host.Vars .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) sb.Append($" {kvp.Key}={EscapeIniValue(kvp.Value)}"); sb.AppendLine(); } sb.AppendLine(); } return new InventoryResult(sb.ToString().TrimEnd(), model.Warnings); } private static InventoryResult RenderYaml( InventoryModel model, InventoryOptions options) { var sb = new StringBuilder(); sb.AppendLine("all:"); if (options.GlobalVars.Count > 0) { sb.AppendLine(" vars:"); foreach (KeyValuePair 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 (HostEntry host in model.Groups[group] .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)) { sb.AppendLine($" {host.Name}:"); foreach (KeyValuePair kvp in host.Vars .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) sb.AppendLine($" {kvp.Key}: {kvp.Value}"); } } return new InventoryResult(sb.ToString().TrimEnd(), model.Warnings); } private static string? GetAddress(Resource r) { if (r.Labels.TryGetValue("ansible_host", out var ah) && !string.IsNullOrWhiteSpace(ah)) return ah; if (r.Labels.TryGetValue("ip", out var ip) && !string.IsNullOrWhiteSpace(ip)) return ip; if (r.Labels.TryGetValue("hostname", out var hn) && !string.IsNullOrWhiteSpace(hn)) return hn; return null; } private static Dictionary BuildHostVars(Resource r, string address) { var vars = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["ansible_host"] = address }; foreach ((var k, var v) in r.Labels) { if (string.IsNullOrWhiteSpace(k) || string.IsNullOrWhiteSpace(v)) continue; // Custom host variables via ansible_var_* if (k.StartsWith("ansible_var_", StringComparison.OrdinalIgnoreCase)) { var varName = k.Substring("ansible_var_".Length); if (!string.IsNullOrWhiteSpace(varName)) vars[varName] = v; continue; } // Standard ansible_* variables if (k.StartsWith("ansible_", StringComparison.OrdinalIgnoreCase)) vars[k] = v; } return vars; } private static string SanitizeGroup(string s) { 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('_'); var result = sb.ToString(); return string.IsNullOrWhiteSpace(result) ? "group" : result; } private static string EscapeIniValue(string value) { if (string.IsNullOrEmpty(value)) return "\"\""; var needsQuotes = value.Any(ch => char.IsWhiteSpace(ch) || ch is '"' or '\'' or '='); if (!needsQuotes) return value; return "\"" + value.Replace("\"", "\\\"") + "\""; } private sealed record HostEntry( string Name, Dictionary Vars, Resource Resource); private sealed record InventoryModel( Dictionary> Groups, List Warnings); }