DiscoveryIdResolver.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. using System.ComponentModel.DataAnnotations;
  2. using RackPeek.Domain.Resources;
  3. using RackPeek.Domain.Resources.Connections;
  4. namespace RackPeek.Domain.Discovery;
  5. /// <summary>
  6. /// Reconciles incoming discovered resources against what is already stored, by
  7. /// <see cref="Resource.DiscoveryId" /> rather than by name.
  8. /// <para>
  9. /// Runs immediately before the merge, and only ever rewrites the *incoming*
  10. /// names. The invariant it exists to protect: discovery never renames a
  11. /// resource the user already has. Names are user-owned, ids are machine-owned.
  12. /// </para>
  13. /// </summary>
  14. public static class DiscoveryIdResolver {
  15. /// <summary>
  16. /// Rewrites <paramref name="incoming" /> in place so that its names line up with
  17. /// the stored resources the ids point at. Also rewrites <c>runsOn</c> references
  18. /// between incoming resources — and the payload's <paramref name="connections" />,
  19. /// which name resources the same way — so a rename does not break the tree.
  20. /// </summary>
  21. public static void ResolveNames(
  22. IReadOnlyList<Resource> existing,
  23. IReadOnlyList<Resource> incoming,
  24. IReadOnlyList<Connection>? connections = null) {
  25. var incomingWithId = incoming
  26. .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId))
  27. .ToList();
  28. if (incomingWithId.Count == 0)
  29. return;
  30. GuardAgainstDuplicates(incomingWithId, "payload");
  31. GuardAgainstDuplicates(existing.Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId)), "inventory");
  32. var existingById = existing
  33. .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId))
  34. .ToDictionary(r => r.DiscoveryId!, r => r, StringComparer.OrdinalIgnoreCase);
  35. Dictionary<string, Resource> existingByMac = BuildMacMap(existing);
  36. // Tolerant of a hand-edited file that managed to get two resources of the
  37. // same name: the first wins, rather than crashing the import.
  38. var existingByName = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
  39. foreach (Resource resource in existing)
  40. existingByName.TryAdd(resource.Name, resource);
  41. var renames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  42. foreach (Resource resource in incomingWithId) {
  43. var resolved = ResolveName(resource, existingById, existingByName, existingByMac);
  44. if (resolved.Equals(resource.Name, StringComparison.OrdinalIgnoreCase))
  45. continue;
  46. renames[resource.Name] = resolved;
  47. resource.Name = resolved;
  48. }
  49. if (renames.Count > 0) {
  50. RewriteRunsOn(incoming, renames);
  51. RewriteConnections(connections, renames);
  52. }
  53. PreserveStoredRunsOn(incomingWithId, incoming, existingById, existingByName);
  54. }
  55. /// <summary>
  56. /// A collector that cannot see its host — docker discovery over TCP — sends
  57. /// <c>runsOn</c> as a bare hostname it cannot reconcile after the user renames
  58. /// that host. When an update's runsOn points at nothing at all while the stored
  59. /// resource already points at something real, the stored link is the user's truth
  60. /// and re-discovery must not tear it up. A runsOn that resolves — even to a
  61. /// resource arriving in the same payload — is left alone: that is a genuine move.
  62. /// </summary>
  63. private static void PreserveStoredRunsOn(
  64. IReadOnlyList<Resource> incomingWithId,
  65. IReadOnlyList<Resource> incoming,
  66. Dictionary<string, Resource> existingById,
  67. Dictionary<string, Resource> existingByName) {
  68. var incomingNames = new HashSet<string>(incoming.Select(r => r.Name), StringComparer.OrdinalIgnoreCase);
  69. foreach (Resource resource in incomingWithId) {
  70. // MAC unification may have nulled a scan card's id so the merge cannot
  71. // downgrade the stored identity — such a card has nothing to look up here.
  72. if (resource.RunsOn.Count == 0
  73. || string.IsNullOrWhiteSpace(resource.DiscoveryId)
  74. || !existingById.TryGetValue(resource.DiscoveryId, out Resource? stored)
  75. || stored.RunsOn.Count == 0)
  76. continue;
  77. var anchored = resource.RunsOn.Any(name =>
  78. existingByName.ContainsKey(name) || incomingNames.Contains(name));
  79. if (!anchored)
  80. resource.RunsOn = [.. stored.RunsOn];
  81. }
  82. }
  83. private static string ResolveName(
  84. Resource resource,
  85. Dictionary<string, Resource> existingById,
  86. Dictionary<string, Resource> existingByName,
  87. Dictionary<string, Resource> existingByMac) {
  88. // Known id: the stored resource wins on name, whatever the user has renamed it to.
  89. if (existingById.TryGetValue(resource.DiscoveryId!, out Resource? matched))
  90. return matched.Name;
  91. // Unknown id, but a MAC in common with exactly one stored card: the same
  92. // physical machine seen by two collectors, unified onto the stored card.
  93. if (TryUnifyByMac(resource, existingByMac, out var unifiedName))
  94. return unifiedName;
  95. // Unknown id and the name is free: nothing to reconcile.
  96. if (!existingByName.TryGetValue(resource.Name, out Resource? sameName))
  97. return resource.Name;
  98. // Taken by something carrying a different id — another machine's resource.
  99. var belongsToAnotherMachine = !string.IsNullOrWhiteSpace(sameName.DiscoveryId)
  100. && !sameName.DiscoveryId.Equals(
  101. resource.DiscoveryId,
  102. StringComparison.OrdinalIgnoreCase);
  103. // Taken by a different kind of thing. Very common: the box is documented as a
  104. // Server by hand and discovery reports the operating system on it as a System.
  105. // The merge replaces on a type change, so adopting here would delete the
  106. // hardware the user wrote.
  107. var describesSomethingElse = sameName.GetType() != resource.GetType();
  108. if (belongsToAnotherMachine || describesSomethingElse)
  109. return DiscoveryNaming.WithSuffix(
  110. resource.Name,
  111. DiscoveryId.ShortSuffix(resource.DiscoveryId!));
  112. // Same kind, no competing id: this is the adoption case, where the merge stamps
  113. // the id onto the resource the user already wrote and keeps everything in it.
  114. return resource.Name;
  115. }
  116. /// <summary>
  117. /// The bridge between collectors that cannot derive each other's ids: the agent
  118. /// records the machine's MACs (a "macs" label), the scan identifies it by one (a
  119. /// "mac" label). A shared MAC on a stored card of the same kind means the same
  120. /// box — the incoming card adopts the stored card's name so the merge lands on
  121. /// it, and the stronger identity wins: an agent id replaces a scan id, a scan id
  122. /// never replaces anything (it is nulled here so the merge cannot downgrade).
  123. /// Ids from two agent-grade collectors sharing a MAC (cloned VMs, or Proxmox's
  124. /// view of a guest) are never unified — that is what machine-ids are for.
  125. /// </summary>
  126. private static bool TryUnifyByMac(
  127. Resource resource,
  128. Dictionary<string, Resource> existingByMac,
  129. out string unifiedName) {
  130. unifiedName = string.Empty;
  131. foreach (var mac in MacsOf(resource)) {
  132. if (!existingByMac.TryGetValue(mac, out Resource? stored))
  133. continue;
  134. // The box the user documented as a Server and the OS a scan saw on it are
  135. // different cards on purpose; unification is for same-kind cards only.
  136. if (stored.GetType() != resource.GetType())
  137. continue;
  138. var incomingIsNet = DiscoveryId.Scheme(resource.DiscoveryId) == DiscoveryId.NetworkScheme;
  139. // A stored card with a MAC but no id yet: adoption, same as the name-based
  140. // adoption case — the incoming id gets stamped onto it by the merge.
  141. if (string.IsNullOrWhiteSpace(stored.DiscoveryId)) {
  142. unifiedName = stored.Name;
  143. return true;
  144. }
  145. var storedIsNet = DiscoveryId.Scheme(stored.DiscoveryId) == DiscoveryId.NetworkScheme;
  146. // Both scan-grade or both agent-grade: not safe to unify on a MAC alone.
  147. if (incomingIsNet == storedIsNet)
  148. continue;
  149. if (incomingIsNet)
  150. resource.DiscoveryId = null;
  151. unifiedName = stored.Name;
  152. return true;
  153. }
  154. return false;
  155. }
  156. /// <summary>The MACs a resource claims, from its "mac" and "macs" labels, normalised.</summary>
  157. private static IEnumerable<string> MacsOf(Resource resource) {
  158. IEnumerable<string?> raw = [
  159. resource.Labels.GetValueOrDefault("mac"),
  160. .. (resource.Labels.GetValueOrDefault("macs") ?? string.Empty).Split(
  161. ',',
  162. StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  163. ];
  164. return raw
  165. .Select(ArpTableParser.NormaliseMac)
  166. .Where(mac => mac != null)
  167. .Select(mac => mac!)
  168. .Distinct();
  169. }
  170. /// <summary>
  171. /// mac → the one stored resource claiming it. A MAC claimed by two stored
  172. /// resources identifies nothing and is dropped: ambiguity never unifies.
  173. /// </summary>
  174. private static Dictionary<string, Resource> BuildMacMap(IReadOnlyList<Resource> existing) {
  175. var map = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
  176. var ambiguous = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  177. foreach (Resource resource in existing)
  178. foreach (var mac in MacsOf(resource))
  179. if (!ambiguous.Contains(mac) && !map.TryAdd(mac, resource) && !ReferenceEquals(map[mac], resource)) {
  180. map.Remove(mac);
  181. ambiguous.Add(mac);
  182. }
  183. return map;
  184. }
  185. private static void RewriteRunsOn(IReadOnlyList<Resource> incoming, Dictionary<string, string> renames) {
  186. foreach (Resource resource in incoming)
  187. for (var i = 0; i < resource.RunsOn.Count; i++)
  188. if (renames.TryGetValue(resource.RunsOn[i], out var renamed))
  189. resource.RunsOn[i] = renamed;
  190. }
  191. private static void RewriteConnections(IReadOnlyList<Connection>? connections, Dictionary<string, string> renames) {
  192. if (connections == null)
  193. return;
  194. foreach (Connection connection in connections) {
  195. if (connection.A?.Resource != null && renames.TryGetValue(connection.A.Resource, out var a))
  196. connection.A.Resource = a;
  197. if (connection.B?.Resource != null && renames.TryGetValue(connection.B.Resource, out var b))
  198. connection.B.Resource = b;
  199. }
  200. }
  201. private static void GuardAgainstDuplicates(IEnumerable<Resource> resources, string scope) {
  202. IGrouping<string, Resource>? duplicate = resources
  203. .GroupBy(r => r.DiscoveryId!, StringComparer.OrdinalIgnoreCase)
  204. .FirstOrDefault(g => g.Count() > 1);
  205. if (duplicate == null)
  206. return;
  207. var names = string.Join(", ", duplicate.Select(r => r.Name));
  208. throw new ValidationException(
  209. $"Duplicate discoveryId '{duplicate.Key}' in the {scope} ({names}). " +
  210. "Machines cloned from a VM template often share /etc/machine-id; " +
  211. "run 'systemd-machine-id-setup' on the clones to give them distinct identities.");
  212. }
  213. }