YamlResourceCollection.cs 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. using System.Collections.Specialized;
  2. using RackPeek.Domain.Resources;
  3. using RackPeek.Domain.Resources.AccessPoints;
  4. using RackPeek.Domain.Resources.Desktops;
  5. using RackPeek.Domain.Resources.Firewalls;
  6. using RackPeek.Domain.Resources.Hardware;
  7. using RackPeek.Domain.Resources.Laptops;
  8. using RackPeek.Domain.Resources.Routers;
  9. using RackPeek.Domain.Resources.Servers;
  10. using RackPeek.Domain.Resources.Services;
  11. using RackPeek.Domain.Resources.Switches;
  12. using RackPeek.Domain.Resources.SystemResources;
  13. using RackPeek.Domain.Resources.UpsUnits;
  14. using YamlDotNet.Core;
  15. using YamlDotNet.Serialization;
  16. using YamlDotNet.Serialization.NamingConventions;
  17. namespace RackPeek.Domain.Persistence.Yaml;
  18. public class ResourceCollection
  19. {
  20. public readonly SemaphoreSlim FileLock = new(1, 1);
  21. public List<Resource> Resources { get; } = new();
  22. }
  23. public sealed class YamlResourceCollection(
  24. string filePath,
  25. ITextFileStore fileStore,
  26. ResourceCollection resourceCollection,
  27. RackPeekConfigMigrationDeserializer _deserializer)
  28. : IResourceCollection
  29. {
  30. // Bump this when your YAML schema changes, and add a migration step below.
  31. private static readonly int CurrentSchemaVersion = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
  32. public Task<bool> Exists(string name)
  33. {
  34. return Task.FromResult(resourceCollection.Resources.Exists(r =>
  35. r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
  36. }
  37. public Task<Dictionary<string, int>> GetTagsAsync()
  38. {
  39. var result = resourceCollection.Resources
  40. .SelectMany(r => r.Tags) // flatten all tag arrays
  41. .Where(t => !string.IsNullOrWhiteSpace(t))
  42. .GroupBy(t => t)
  43. .ToDictionary(g => g.Key, g => g.Count());
  44. return Task.FromResult(result);
  45. }
  46. public Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>()
  47. {
  48. return Task.FromResult<IReadOnlyList<T>>(resourceCollection.Resources.OfType<T>().ToList());
  49. }
  50. public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name)
  51. {
  52. var result = resourceCollection.Resources
  53. .Where(r => r.RunsOn.Any(p => p.Equals(name, StringComparison.OrdinalIgnoreCase)))
  54. .ToList();
  55. return Task.FromResult<IReadOnlyList<Resource>>(result);
  56. }
  57. public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
  58. {
  59. return Task.FromResult<IReadOnlyList<Resource>>(
  60. resourceCollection.Resources
  61. .Where(r => r.Tags.Contains(name))
  62. .ToList()
  63. );
  64. }
  65. public IReadOnlyList<Hardware> HardwareResources =>
  66. resourceCollection.Resources.OfType<Hardware>().ToList();
  67. public IReadOnlyList<SystemResource> SystemResources =>
  68. resourceCollection.Resources.OfType<SystemResource>().ToList();
  69. public IReadOnlyList<Service> ServiceResources =>
  70. resourceCollection.Resources.OfType<Service>().ToList();
  71. public Task<Resource?> GetByNameAsync(string name)
  72. {
  73. return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
  74. r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
  75. }
  76. public Task<T?> GetByNameAsync<T>(string name) where T : Resource
  77. {
  78. var resource =
  79. resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
  80. return Task.FromResult(resource as T);
  81. }
  82. public Resource? GetByName(string name)
  83. {
  84. return resourceCollection.Resources.FirstOrDefault(r =>
  85. r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
  86. }
  87. public async Task LoadAsync()
  88. {
  89. // Read raw YAML so we can back it up exactly before any migration writes.
  90. var yaml = await fileStore.ReadAllTextAsync(filePath);
  91. if (string.IsNullOrWhiteSpace(yaml))
  92. {
  93. resourceCollection.Resources.Clear();
  94. return;
  95. }
  96. var version = _deserializer.GetSchemaVersion(yaml);
  97. // Guard: config is newer than this app understands.
  98. if (version > CurrentSchemaVersion)
  99. {
  100. throw new InvalidOperationException(
  101. $"Config schema version {version} is newer than this application supports ({CurrentSchemaVersion}).");
  102. }
  103. YamlRoot? root;
  104. // If older, backup first, then migrate step-by-step, then save.
  105. if (version < CurrentSchemaVersion)
  106. {
  107. await BackupOriginalAsync(yaml);
  108. root = await _deserializer.Deserialize(yaml) ?? new YamlRoot();
  109. // Ensure we persist the migrated root (with updated version)
  110. await SaveRootAsync(root);
  111. }
  112. else
  113. {
  114. root = await _deserializer.Deserialize(yaml);
  115. }
  116. resourceCollection.Resources.Clear();
  117. if (root?.Resources != null)
  118. {
  119. resourceCollection.Resources.AddRange(root.Resources);
  120. }
  121. }
  122. public Task AddAsync(Resource resource)
  123. {
  124. return UpdateWithLockAsync(list =>
  125. {
  126. if (list.Any(r => r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase)))
  127. throw new InvalidOperationException($"'{resource.Name}' already exists.");
  128. resource.Kind = GetKind(resource);
  129. list.Add(resource);
  130. });
  131. }
  132. public Task UpdateAsync(Resource resource)
  133. {
  134. return UpdateWithLockAsync(list =>
  135. {
  136. var index = list.FindIndex(r => r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase));
  137. if (index == -1) throw new InvalidOperationException("Not found.");
  138. resource.Kind = GetKind(resource);
  139. list[index] = resource;
  140. });
  141. }
  142. public Task DeleteAsync(string name)
  143. {
  144. return UpdateWithLockAsync(list =>
  145. list.RemoveAll(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
  146. }
  147. private async Task UpdateWithLockAsync(Action<List<Resource>> action)
  148. {
  149. await resourceCollection.FileLock.WaitAsync();
  150. try
  151. {
  152. action(resourceCollection.Resources);
  153. // Always write current schema version when app writes the file.
  154. var root = new YamlRoot
  155. {
  156. Version = CurrentSchemaVersion,
  157. Resources = resourceCollection.Resources
  158. };
  159. await SaveRootAsync(root);
  160. }
  161. finally
  162. {
  163. resourceCollection.FileLock.Release();
  164. }
  165. }
  166. // ----------------------------
  167. // Versioning + migration
  168. // ----------------------------
  169. private async Task BackupOriginalAsync(string originalYaml)
  170. {
  171. // Timestamped backup for safe rollback
  172. var backupPath = $"{filePath}.bak.{DateTime.UtcNow:yyyyMMddHHmmss}";
  173. await fileStore.WriteAllTextAsync(backupPath, originalYaml);
  174. }
  175. private async Task SaveRootAsync(YamlRoot? root)
  176. {
  177. var serializer = new SerializerBuilder()
  178. .WithNamingConvention(CamelCaseNamingConvention.Instance)
  179. .WithTypeConverter(new StorageSizeYamlConverter())
  180. .WithTypeConverter(new NotesStringYamlConverter())
  181. .ConfigureDefaultValuesHandling(
  182. DefaultValuesHandling.OmitNull |
  183. DefaultValuesHandling.OmitEmptyCollections
  184. )
  185. .Build();
  186. // Preserve ordering: version first, then resources
  187. var payload = new OrderedDictionary
  188. {
  189. ["version"] = root.Version,
  190. ["resources"] = (root.Resources ?? new List<Resource>()).Select(SerializeResource).ToList()
  191. };
  192. await fileStore.WriteAllTextAsync(filePath, serializer.Serialize(payload));
  193. }
  194. private string GetKind(Resource resource)
  195. {
  196. return resource switch
  197. {
  198. Server => "Server",
  199. Switch => "Switch",
  200. Firewall => "Firewall",
  201. Router => "Router",
  202. Desktop => "Desktop",
  203. Laptop => "Laptop",
  204. AccessPoint => "AccessPoint",
  205. Ups => "Ups",
  206. SystemResource => "System",
  207. Service => "Service",
  208. _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")
  209. };
  210. }
  211. private OrderedDictionary SerializeResource(Resource resource)
  212. {
  213. var map = new OrderedDictionary
  214. {
  215. ["kind"] = GetKind(resource)
  216. };
  217. var serializer = new SerializerBuilder()
  218. .WithNamingConvention(CamelCaseNamingConvention.Instance)
  219. .WithTypeConverter(new NotesStringYamlConverter())
  220. .ConfigureDefaultValuesHandling(
  221. DefaultValuesHandling.OmitNull |
  222. DefaultValuesHandling.OmitEmptyCollections
  223. )
  224. .Build();
  225. var yaml = serializer.Serialize(resource);
  226. var props = new DeserializerBuilder()
  227. .Build()
  228. .Deserialize<Dictionary<string, object?>>(yaml);
  229. foreach (var (key, value) in props)
  230. if (!string.Equals(key, "kind", StringComparison.OrdinalIgnoreCase))
  231. map[key] = value;
  232. return map;
  233. }
  234. }
  235. public class YamlRoot
  236. {
  237. public int Version { get; set; }
  238. public List<Resource>? Resources { get; set; }
  239. }