AnsibleInventoryGenerator.cs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. using System.Text;
  2. using RackPeek.Domain.Resources;
  3. namespace RackPeek.Domain.UseCases.Ansible;
  4. public enum InventoryFormat {
  5. Ini,
  6. Yaml
  7. }
  8. public sealed record InventoryOptions {
  9. /// <summary>
  10. /// Output format (default: INI)
  11. /// </summary>
  12. public InventoryFormat Format { get; init; } = InventoryFormat.Ini;
  13. /// <summary>
  14. /// If set, create groups based on these tags.
  15. /// Example: ["prod", "staging"] -> [prod], [staging]
  16. /// </summary>
  17. public IReadOnlyList<string> GroupByTags { get; init; } = [];
  18. /// <summary>
  19. /// If set, create groups based on these label keys.
  20. /// Example: ["env"] -> [env_prod]
  21. /// </summary>
  22. public IReadOnlyList<string> GroupByLabelKeys { get; init; } = [];
  23. /// <summary>
  24. /// If set, emitted under [all:vars] (INI) or all.vars (YAML).
  25. /// </summary>
  26. public IDictionary<string, string> GlobalVars { get; init; } =
  27. new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  28. }
  29. public sealed record InventoryResult(string InventoryText, IReadOnlyList<string> Warnings);
  30. public static class AnsibleInventoryGenerator {
  31. public static InventoryResult ToAnsibleInventory(
  32. this IReadOnlyList<Resource> resources,
  33. InventoryOptions? options = null) {
  34. options ??= new InventoryOptions();
  35. InventoryModel model = BuildInventoryModel(resources, options);
  36. return options.Format switch {
  37. InventoryFormat.Yaml => RenderYaml(model, options),
  38. _ => RenderIni(model, options)
  39. };
  40. }
  41. private static InventoryModel BuildInventoryModel(
  42. IReadOnlyList<Resource> resources,
  43. InventoryOptions options) {
  44. var warnings = new List<string>();
  45. var hosts = new List<HostEntry>();
  46. foreach (Resource r in resources) {
  47. var address = GetAddress(r);
  48. if (string.IsNullOrWhiteSpace(address))
  49. continue;
  50. Dictionary<string, string> vars = BuildHostVars(r, address);
  51. hosts.Add(new HostEntry(r.Name, vars, r));
  52. }
  53. var groupToHosts =
  54. new Dictionary<string, List<HostEntry>>(StringComparer.OrdinalIgnoreCase);
  55. void AddToGroup(string groupName, HostEntry h) {
  56. if (string.IsNullOrWhiteSpace(groupName))
  57. return;
  58. groupName = SanitizeGroup(groupName);
  59. if (!groupToHosts.TryGetValue(groupName, out List<HostEntry>? list))
  60. groupToHosts[groupName] = list = new List<HostEntry>();
  61. if (!list.Any(x => string.Equals(x.Name, h.Name, StringComparison.OrdinalIgnoreCase)))
  62. list.Add(h);
  63. }
  64. foreach (HostEntry h in hosts) {
  65. // Tag-based groups
  66. var matchingTags = options.GroupByTags
  67. .Intersect(h.Resource.Tags ?? [])
  68. .ToArray();
  69. foreach (var tag in matchingTags)
  70. AddToGroup(tag, h);
  71. // Label-based groups
  72. foreach (var key in options.GroupByLabelKeys)
  73. if (h.Resource.Labels.TryGetValue(key, out var val)
  74. && !string.IsNullOrWhiteSpace(val))
  75. AddToGroup($"{key}_{val}", h);
  76. }
  77. return new InventoryModel(groupToHosts, warnings);
  78. }
  79. private static InventoryResult RenderIni(
  80. InventoryModel model,
  81. InventoryOptions options) {
  82. var sb = new StringBuilder();
  83. if (options.GlobalVars.Count > 0) {
  84. sb.AppendLine("[all:vars]");
  85. foreach (KeyValuePair<string, string> kvp in options.GlobalVars
  86. .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
  87. sb.AppendLine($"{kvp.Key}={EscapeIniValue(kvp.Value)}");
  88. sb.AppendLine();
  89. }
  90. foreach (var group in model.Groups.Keys
  91. .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) {
  92. sb.AppendLine($"[{group}]");
  93. foreach (HostEntry host in model.Groups[group]
  94. .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)) {
  95. sb.Append(host.Name);
  96. foreach (KeyValuePair<string, string> kvp in host.Vars
  97. .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
  98. sb.Append($" {kvp.Key}={EscapeIniValue(kvp.Value)}");
  99. sb.AppendLine();
  100. }
  101. sb.AppendLine();
  102. }
  103. return new InventoryResult(sb.ToString().TrimEnd(), model.Warnings);
  104. }
  105. private static InventoryResult RenderYaml(
  106. InventoryModel model,
  107. InventoryOptions options) {
  108. var sb = new StringBuilder();
  109. sb.AppendLine("all:");
  110. if (options.GlobalVars.Count > 0) {
  111. sb.AppendLine(" vars:");
  112. foreach (KeyValuePair<string, string> kvp in options.GlobalVars
  113. .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
  114. sb.AppendLine($" {kvp.Key}: {kvp.Value}");
  115. }
  116. sb.AppendLine(" children:");
  117. foreach (var group in model.Groups.Keys
  118. .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) {
  119. sb.AppendLine($" {group}:");
  120. sb.AppendLine(" hosts:");
  121. foreach (HostEntry host in model.Groups[group]
  122. .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)) {
  123. sb.AppendLine($" {host.Name}:");
  124. foreach (KeyValuePair<string, string> kvp in host.Vars
  125. .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
  126. sb.AppendLine($" {kvp.Key}: {kvp.Value}");
  127. }
  128. }
  129. return new InventoryResult(sb.ToString().TrimEnd(), model.Warnings);
  130. }
  131. private static string? GetAddress(Resource r) {
  132. if (r.Labels.TryGetValue("ansible_host", out var ah) && !string.IsNullOrWhiteSpace(ah))
  133. return ah;
  134. if (r.Labels.TryGetValue("ip", out var ip) && !string.IsNullOrWhiteSpace(ip))
  135. return ip;
  136. if (r.Labels.TryGetValue("hostname", out var hn) && !string.IsNullOrWhiteSpace(hn))
  137. return hn;
  138. return null;
  139. }
  140. private static Dictionary<string, string> BuildHostVars(Resource r, string address) {
  141. var vars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) {
  142. ["ansible_host"] = address
  143. };
  144. foreach ((var k, var v) in r.Labels) {
  145. if (string.IsNullOrWhiteSpace(k) || string.IsNullOrWhiteSpace(v))
  146. continue;
  147. if (k.StartsWith("ansible_", StringComparison.OrdinalIgnoreCase))
  148. vars[k] = v;
  149. }
  150. return vars;
  151. }
  152. private static string SanitizeGroup(string s) {
  153. var sb = new StringBuilder();
  154. foreach (var ch in s.Trim().ToLowerInvariant())
  155. if (char.IsLetterOrDigit(ch) || ch == '_')
  156. sb.Append(ch);
  157. else if (ch == '-' || ch == '.' || ch == ' ')
  158. sb.Append('_');
  159. var result = sb.ToString();
  160. return string.IsNullOrWhiteSpace(result) ? "group" : result;
  161. }
  162. private static string EscapeIniValue(string value) {
  163. if (string.IsNullOrEmpty(value))
  164. return "\"\"";
  165. var needsQuotes = value.Any(ch =>
  166. char.IsWhiteSpace(ch) || ch is '"' or '\'' or '=');
  167. if (!needsQuotes)
  168. return value;
  169. return "\"" + value.Replace("\"", "\\\"") + "\"";
  170. }
  171. private sealed record HostEntry(
  172. string Name,
  173. Dictionary<string, string> Vars,
  174. Resource Resource);
  175. private sealed record InventoryModel(
  176. Dictionary<string, List<HostEntry>> Groups,
  177. List<string> Warnings);
  178. }