YamlResourceCollection.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. using System.Collections.Specialized;
  2. using System.Reflection;
  3. using Microsoft.Extensions.Logging;
  4. using Microsoft.Extensions.DependencyInjection;
  5. using RackPeek.Domain.Resources;
  6. using RackPeek.Domain.Resources.AccessPoints;
  7. using RackPeek.Domain.Resources.Desktops;
  8. using RackPeek.Domain.Resources.Firewalls;
  9. using RackPeek.Domain.Resources.Hardware;
  10. using RackPeek.Domain.Resources.Laptops;
  11. using RackPeek.Domain.Resources.Routers;
  12. using RackPeek.Domain.Resources.Servers;
  13. using RackPeek.Domain.Resources.Services;
  14. using RackPeek.Domain.Resources.Switches;
  15. using RackPeek.Domain.Resources.SystemResources;
  16. using RackPeek.Domain.Resources.UpsUnits;
  17. using DocMigrator.Yaml;
  18. using YamlDotNet.Core;
  19. using YamlDotNet.Serialization;
  20. using YamlDotNet.Serialization.NamingConventions;
  21. namespace RackPeek.Domain.Persistence.Yaml;
  22. public class ResourceCollection
  23. {
  24. public readonly SemaphoreSlim FileLock = new(1, 1);
  25. public List<Resource> Resources { get; } = new();
  26. }
  27. public sealed class YamlResourceCollection(
  28. string filePath,
  29. ITextFileStore fileStore,
  30. ResourceCollection resourceCollection)
  31. : IResourceCollection
  32. {
  33. // Bump this when your YAML schema changes, and add a migration step below.
  34. private const int CurrentSchemaVersion = 1;
  35. public Task<bool> Exists(string name)
  36. {
  37. return Task.FromResult(resourceCollection.Resources.Exists(r =>
  38. r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
  39. }
  40. public Task<Dictionary<string, int>> GetTagsAsync()
  41. {
  42. var result = resourceCollection.Resources
  43. .SelectMany(r => r.Tags) // flatten all tag arrays
  44. .Where(t => !string.IsNullOrWhiteSpace(t))
  45. .GroupBy(t => t)
  46. .ToDictionary(g => g.Key, g => g.Count());
  47. return Task.FromResult(result);
  48. }
  49. public Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>()
  50. {
  51. return Task.FromResult<IReadOnlyList<T>>(resourceCollection.Resources.OfType<T>().ToList());
  52. }
  53. public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name)
  54. {
  55. return Task.FromResult<IReadOnlyList<Resource>>(resourceCollection.Resources
  56. .Where(r => r.RunsOn?.Equals(name, StringComparison.OrdinalIgnoreCase) ?? false)
  57. .ToList());
  58. }
  59. public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
  60. {
  61. return Task.FromResult<IReadOnlyList<Resource>>(
  62. resourceCollection.Resources
  63. .Where(r => r.Tags.Contains(name))
  64. .ToList()
  65. );
  66. }
  67. public IReadOnlyList<Hardware> HardwareResources =>
  68. resourceCollection.Resources.OfType<Hardware>().ToList();
  69. public IReadOnlyList<SystemResource> SystemResources =>
  70. resourceCollection.Resources.OfType<SystemResource>().ToList();
  71. public IReadOnlyList<Service> ServiceResources =>
  72. resourceCollection.Resources.OfType<Service>().ToList();
  73. public Task<Resource?> GetByNameAsync(string name)
  74. {
  75. return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
  76. r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
  77. }
  78. public Task<T?> GetByNameAsync<T>(string name) where T : Resource
  79. {
  80. var resource =
  81. resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
  82. return Task.FromResult(resource as T);
  83. }
  84. public Resource? GetByName(string name)
  85. {
  86. return resourceCollection.Resources.FirstOrDefault(r =>
  87. r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
  88. }
  89. public async Task LoadAsync()
  90. {
  91. // Read raw YAML so we can back it up exactly before any migration writes.
  92. var yaml = await fileStore.ReadAllTextAsync(filePath);
  93. if (string.IsNullOrWhiteSpace(yaml))
  94. {
  95. resourceCollection.Resources.Clear();
  96. return;
  97. }
  98. var root = DeserializeRoot(yaml);
  99. if (root == null)
  100. {
  101. // Keep behavior aligned with your previous code: if YAML is invalid, treat as empty.
  102. resourceCollection.Resources.Clear();
  103. return;
  104. }
  105. // Guard: config is newer than this app understands.
  106. if (root.SchemaVersion > CurrentSchemaVersion)
  107. {
  108. throw new InvalidOperationException(
  109. $"Config schema version {root.SchemaVersion} is newer than this application supports ({CurrentSchemaVersion}).");
  110. }
  111. // If older, backup first, then migrate step-by-step, then save.
  112. if (root.SchemaVersion < CurrentSchemaVersion)
  113. {
  114. await BackupOriginalAsync(yaml);
  115. root = await MigrateAsync(root);
  116. // Ensure we persist the migrated root (with updated version)
  117. await SaveRootAsync(root);
  118. }
  119. resourceCollection.Resources.Clear();
  120. resourceCollection.Resources.AddRange(root.Resources ?? []);
  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. SchemaVersion = 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 Task<YamlRoot> MigrateAsync(YamlRoot root)
  176. {
  177. // Step-by-step migrations until we reach CurrentSchemaVersion
  178. while (root.SchemaVersion < CurrentSchemaVersion)
  179. {
  180. root = root.SchemaVersion switch
  181. {
  182. 0 => MigrateV0ToV1(root),
  183. _ => throw new InvalidOperationException(
  184. $"No migration is defined from version {root.SchemaVersion} to {root.SchemaVersion + 1}.")
  185. };
  186. }
  187. return Task.FromResult(root);
  188. }
  189. private YamlRoot MigrateV0ToV1(YamlRoot root)
  190. {
  191. // V0 -> V1 example migration:
  192. // - Ensure 'kind' is normalized on all resources
  193. // - Ensure tags collections aren’t null
  194. if (root.Resources != null)
  195. {
  196. foreach (var r in root.Resources)
  197. {
  198. r.Kind = GetKind(r);
  199. r.Tags ??= [];
  200. }
  201. }
  202. root.SchemaVersion = 1;
  203. return root;
  204. }
  205. // ----------------------------
  206. // YAML read/write
  207. // ----------------------------
  208. private YamlRoot? DeserializeRoot(string yaml)
  209. {
  210. var deserializer = new DeserializerBuilder()
  211. .WithNamingConvention(CamelCaseNamingConvention.Instance)
  212. .WithCaseInsensitivePropertyMatching()
  213. .WithTypeConverter(new StorageSizeYamlConverter())
  214. .WithTypeConverter(new NotesStringYamlConverter())
  215. .WithTypeDiscriminatingNodeDeserializer(options =>
  216. {
  217. options.AddKeyValueTypeDiscriminator<Resource>("kind", new Dictionary<string, Type>
  218. {
  219. { Server.KindLabel, typeof(Server) },
  220. { Switch.KindLabel, typeof(Switch) },
  221. { Firewall.KindLabel, typeof(Firewall) },
  222. { Router.KindLabel, typeof(Router) },
  223. { Desktop.KindLabel, typeof(Desktop) },
  224. { Laptop.KindLabel, typeof(Laptop) },
  225. { AccessPoint.KindLabel, typeof(AccessPoint) },
  226. { Ups.KindLabel, typeof(Ups) },
  227. { SystemResource.KindLabel, typeof(SystemResource) },
  228. { Service.KindLabel, typeof(Service) }
  229. });
  230. })
  231. .Build();
  232. // var rootDeserializer = Setup();
  233. // var rootDeserializer = new YamlRootDeserializer();
  234. try
  235. {
  236. // If 'version' is missing, int defaults to 0 => treated as V0.
  237. var root = deserializer.Deserialize<YamlRoot>(yaml);
  238. // If YAML had only "resources:" previously, this will still work.
  239. root ??= new YamlRoot { SchemaVersion = 0, Resources = new List<Resource>() };
  240. root.Resources ??= new List<Resource>();
  241. return root;
  242. }
  243. catch (YamlException)
  244. {
  245. return null;
  246. }
  247. }
  248. private async Task SaveRootAsync(YamlRoot root)
  249. {
  250. var serializer = new SerializerBuilder()
  251. .WithNamingConvention(CamelCaseNamingConvention.Instance)
  252. .WithTypeConverter(new StorageSizeYamlConverter())
  253. .WithTypeConverter(new NotesStringYamlConverter())
  254. .ConfigureDefaultValuesHandling(
  255. DefaultValuesHandling.OmitNull |
  256. DefaultValuesHandling.OmitEmptyCollections
  257. )
  258. .Build();
  259. // Preserve ordering: version first, then resources
  260. var payload = new OrderedDictionary
  261. {
  262. ["schemaVersion"] = root.SchemaVersion,
  263. ["resources"] = (root.Resources ?? new List<Resource>()).Select(SerializeResource).ToList()
  264. };
  265. await fileStore.WriteAllTextAsync(filePath, serializer.Serialize(payload));
  266. }
  267. private string GetKind(Resource resource)
  268. {
  269. return resource switch
  270. {
  271. Server => "Server",
  272. Switch => "Switch",
  273. Firewall => "Firewall",
  274. Router => "Router",
  275. Desktop => "Desktop",
  276. Laptop => "Laptop",
  277. AccessPoint => "AccessPoint",
  278. Ups => "Ups",
  279. SystemResource => "System",
  280. Service => "Service",
  281. _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")
  282. };
  283. }
  284. private OrderedDictionary SerializeResource(Resource resource)
  285. {
  286. var map = new OrderedDictionary
  287. {
  288. ["kind"] = GetKind(resource)
  289. };
  290. var serializer = new SerializerBuilder()
  291. .WithNamingConvention(CamelCaseNamingConvention.Instance)
  292. .WithTypeConverter(new NotesStringYamlConverter())
  293. .ConfigureDefaultValuesHandling(
  294. DefaultValuesHandling.OmitNull |
  295. DefaultValuesHandling.OmitEmptyCollections
  296. )
  297. .Build();
  298. var yaml = serializer.Serialize(resource);
  299. var props = new DeserializerBuilder()
  300. .Build()
  301. .Deserialize<Dictionary<string, object?>>(yaml);
  302. foreach (var (key, value) in props)
  303. if (!string.Equals(key, "kind", StringComparison.OrdinalIgnoreCase))
  304. map[key] = value;
  305. return map;
  306. }
  307. public YamlMigrator Setup(DeserializerBuilder deserializerBuilder)
  308. {
  309. var services = new ServiceCollection();
  310. services.AddLogging();
  311. services.AddYamlMigrator(Assembly.GetExecutingAssembly());
  312. var scope = services.BuildServiceProvider().CreateScope();
  313. return scope.ServiceProvider.GetRequiredService<YamlMigrator>();
  314. // return new YamlRootDeserializer(scope.ServiceProvider, scope.ServiceProvider.GetRequiredService<ILogger<YamlRootDeserializer>>(), deserializerBuilder);
  315. }
  316. // TODO: Wrap this in an 'instance' so we don't needlessly rebuild this?
  317. // Similar to CamelCaseNamingConvention.instance
  318. public class YamlRootDeserializer : YamlMigrationDeserializer<YamlRoot>
  319. {
  320. public YamlRootDeserializer(IServiceProvider serviceProvider,
  321. ILogger<YamlRootDeserializer> logger,
  322. DeserializerBuilder deserializerBuilder) :
  323. base(serviceProvider, logger, new List<Func<IServiceProvider, Dictionary<object,object>, ValueTask>>{
  324. // List migrations here
  325. EnsureSchemaVersionExists,
  326. },
  327. deserializerBuilder) {}
  328. #region Migrations
  329. // Define migration functions here
  330. public static ValueTask EnsureSchemaVersionExists(IServiceProvider serviceProvider, Dictionary<object, object> obj)
  331. {
  332. if (!obj.ContainsKey("schemaVersion"))
  333. {
  334. obj["schemaVersion"] = 0;
  335. if (obj.ContainsKey("version"))
  336. {
  337. obj["schemaVersion"] = obj["version"];
  338. }
  339. }
  340. obj.Remove("version");
  341. return ValueTask.CompletedTask;
  342. }
  343. #endregion
  344. }
  345. }
  346. public class YamlRoot
  347. {
  348. public int SchemaVersion { get; set; } // <- NEW: YAML schema version
  349. public List<Resource>? Resources { get; set; }
  350. }