YamlResourceCollection.cs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  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 const int CurrentSchemaVersion = 1;
  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. return Task.FromResult<IReadOnlyList<Resource>>(resourceCollection.Resources
  53. .Where(r => r.RunsOn?.Equals(name, StringComparison.OrdinalIgnoreCase) ?? false)
  54. .ToList());
  55. }
  56. public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
  57. {
  58. return Task.FromResult<IReadOnlyList<Resource>>(
  59. resourceCollection.Resources
  60. .Where(r => r.Tags.Contains(name))
  61. .ToList()
  62. );
  63. }
  64. public IReadOnlyList<Hardware> HardwareResources =>
  65. resourceCollection.Resources.OfType<Hardware>().ToList();
  66. public IReadOnlyList<SystemResource> SystemResources =>
  67. resourceCollection.Resources.OfType<SystemResource>().ToList();
  68. public IReadOnlyList<Service> ServiceResources =>
  69. resourceCollection.Resources.OfType<Service>().ToList();
  70. public Task<Resource?> GetByNameAsync(string name)
  71. {
  72. return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
  73. r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
  74. }
  75. public Task<T?> GetByNameAsync<T>(string name) where T : Resource
  76. {
  77. var resource =
  78. resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
  79. return Task.FromResult(resource as T);
  80. }
  81. public Resource? GetByName(string name)
  82. {
  83. return resourceCollection.Resources.FirstOrDefault(r =>
  84. r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
  85. }
  86. public async Task LoadAsync()
  87. {
  88. // Read raw YAML so we can back it up exactly before any migration writes.
  89. var yaml = await fileStore.ReadAllTextAsync(filePath);
  90. if (string.IsNullOrWhiteSpace(yaml))
  91. {
  92. resourceCollection.Resources.Clear();
  93. return;
  94. }
  95. var version = _deserializer.GetSchemaVersion(yaml);
  96. // Guard: config is newer than this app understands.
  97. if (version > CurrentSchemaVersion)
  98. {
  99. throw new InvalidOperationException(
  100. $"Config schema version {version} is newer than this application supports ({CurrentSchemaVersion}).");
  101. }
  102. YamlRoot? root;
  103. // If older, backup first, then migrate step-by-step, then save.
  104. if (version < CurrentSchemaVersion)
  105. {
  106. await BackupOriginalAsync(yaml);
  107. root = await _deserializer.Deserialize(yaml);
  108. // Ensure we persist the migrated root (with updated version)
  109. await SaveRootAsync(root);
  110. }
  111. else
  112. {
  113. root = await _deserializer.Deserialize(yaml);
  114. }
  115. resourceCollection.Resources.Clear();
  116. if (root?.Resources != null)
  117. {
  118. resourceCollection.Resources.AddRange(root.Resources);
  119. }
  120. }
  121. public Task AddAsync(Resource resource)
  122. {
  123. return UpdateWithLockAsync(list =>
  124. {
  125. if (list.Any(r => r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase)))
  126. throw new InvalidOperationException($"'{resource.Name}' already exists.");
  127. resource.Kind = GetKind(resource);
  128. list.Add(resource);
  129. });
  130. }
  131. public Task UpdateAsync(Resource resource)
  132. {
  133. return UpdateWithLockAsync(list =>
  134. {
  135. var index = list.FindIndex(r => r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase));
  136. if (index == -1) throw new InvalidOperationException("Not found.");
  137. resource.Kind = GetKind(resource);
  138. list[index] = resource;
  139. });
  140. }
  141. public Task DeleteAsync(string name)
  142. {
  143. return UpdateWithLockAsync(list =>
  144. list.RemoveAll(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
  145. }
  146. private async Task UpdateWithLockAsync(Action<List<Resource>> action)
  147. {
  148. await resourceCollection.FileLock.WaitAsync();
  149. try
  150. {
  151. action(resourceCollection.Resources);
  152. // Always write current schema version when app writes the file.
  153. var root = new YamlRoot
  154. {
  155. Version = CurrentSchemaVersion,
  156. Resources = resourceCollection.Resources
  157. };
  158. await SaveRootAsync(root);
  159. }
  160. finally
  161. {
  162. resourceCollection.FileLock.Release();
  163. }
  164. }
  165. // ----------------------------
  166. // Versioning + migration
  167. // ----------------------------
  168. private async Task BackupOriginalAsync(string originalYaml)
  169. {
  170. // Timestamped backup for safe rollback
  171. var backupPath = $"{filePath}.bak.{DateTime.UtcNow:yyyyMMddHHmmss}";
  172. await fileStore.WriteAllTextAsync(backupPath, originalYaml);
  173. }
  174. private async Task SaveRootAsync(YamlRoot? root)
  175. {
  176. var serializer = new SerializerBuilder()
  177. .WithNamingConvention(CamelCaseNamingConvention.Instance)
  178. .WithTypeConverter(new StorageSizeYamlConverter())
  179. .WithTypeConverter(new NotesStringYamlConverter())
  180. .ConfigureDefaultValuesHandling(
  181. DefaultValuesHandling.OmitNull |
  182. DefaultValuesHandling.OmitEmptyCollections
  183. )
  184. .Build();
  185. // Preserve ordering: version first, then resources
  186. var payload = new OrderedDictionary
  187. {
  188. ["version"] = root.Version,
  189. ["resources"] = (root.Resources ?? new List<Resource>()).Select(SerializeResource).ToList()
  190. };
  191. await fileStore.WriteAllTextAsync(filePath, serializer.Serialize(payload));
  192. }
  193. private string GetKind(Resource resource)
  194. {
  195. return resource switch
  196. {
  197. Server => "Server",
  198. Switch => "Switch",
  199. Firewall => "Firewall",
  200. Router => "Router",
  201. Desktop => "Desktop",
  202. Laptop => "Laptop",
  203. AccessPoint => "AccessPoint",
  204. Ups => "Ups",
  205. SystemResource => "System",
  206. Service => "Service",
  207. _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")
  208. };
  209. }
  210. private OrderedDictionary SerializeResource(Resource resource)
  211. {
  212. var map = new OrderedDictionary
  213. {
  214. ["kind"] = GetKind(resource)
  215. };
  216. var serializer = new SerializerBuilder()
  217. .WithNamingConvention(CamelCaseNamingConvention.Instance)
  218. .WithTypeConverter(new NotesStringYamlConverter())
  219. .ConfigureDefaultValuesHandling(
  220. DefaultValuesHandling.OmitNull |
  221. DefaultValuesHandling.OmitEmptyCollections
  222. )
  223. .Build();
  224. var yaml = serializer.Serialize(resource);
  225. var props = new DeserializerBuilder()
  226. .Build()
  227. .Deserialize<Dictionary<string, object?>>(yaml);
  228. foreach (var (key, value) in props)
  229. if (!string.Equals(key, "kind", StringComparison.OrdinalIgnoreCase))
  230. map[key] = value;
  231. return map;
  232. }
  233. }
  234. public class YamlRoot
  235. {
  236. public int Version { get; set; }
  237. public List<Resource>? Resources { get; set; }
  238. }