|
|
@@ -1,4 +1,6 @@
|
|
|
+using System.Collections.ObjectModel;
|
|
|
using System.Collections.Specialized;
|
|
|
+using System.Diagnostics;
|
|
|
using RackPeek.Domain.Resources;
|
|
|
using RackPeek.Domain.Resources.AccessPoints;
|
|
|
using RackPeek.Domain.Resources.Desktops;
|
|
|
@@ -8,18 +10,15 @@ using RackPeek.Domain.Resources.Laptops;
|
|
|
using RackPeek.Domain.Resources.Routers;
|
|
|
using RackPeek.Domain.Resources.Servers;
|
|
|
using RackPeek.Domain.Resources.Services;
|
|
|
-using RackPeek.Domain.Resources.Switches;
|
|
|
using RackPeek.Domain.Resources.SystemResources;
|
|
|
using RackPeek.Domain.Resources.UpsUnits;
|
|
|
-using YamlDotNet.Core;
|
|
|
using YamlDotNet.Serialization;
|
|
|
using YamlDotNet.Serialization.NamingConventions;
|
|
|
+using Switch = RackPeek.Domain.Resources.Switches.Switch;
|
|
|
|
|
|
namespace RackPeek.Domain.Persistence.Yaml;
|
|
|
|
|
|
-
|
|
|
-public class ResourceCollection
|
|
|
-{
|
|
|
+public class ResourceCollection {
|
|
|
public readonly SemaphoreSlim FileLock = new(1, 1);
|
|
|
public List<Resource> Resources { get; } = new();
|
|
|
}
|
|
|
@@ -29,26 +28,22 @@ public sealed class YamlResourceCollection(
|
|
|
ITextFileStore fileStore,
|
|
|
ResourceCollection resourceCollection,
|
|
|
IResourceYamlMigrationService migrationService)
|
|
|
- : IResourceCollection
|
|
|
-{
|
|
|
+ : IResourceCollection {
|
|
|
// Bump this when your YAML schema changes, and add a migration step below.
|
|
|
- private static readonly int CurrentSchemaVersion = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
|
|
|
+ private static readonly int _currentSchemaVersion = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
|
|
|
|
|
|
- public Task<bool> Exists(string name)
|
|
|
- {
|
|
|
+ public Task<bool> Exists(string name) {
|
|
|
return Task.FromResult(resourceCollection.Resources.Exists(r =>
|
|
|
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
|
|
|
}
|
|
|
|
|
|
- public Task<string?> GetKind(string? name)
|
|
|
- {
|
|
|
+ public Task<string?> GetKind(string? name) {
|
|
|
return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
|
|
|
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Kind);
|
|
|
-
|
|
|
}
|
|
|
- public Task<IReadOnlyList<(Resource, string)>> GetByLabelAsync(string name)
|
|
|
- {
|
|
|
- var result = resourceCollection.Resources
|
|
|
+
|
|
|
+ public Task<IReadOnlyList<(Resource, string)>> GetByLabelAsync(string name) {
|
|
|
+ ReadOnlyCollection<(Resource r, string)> result = resourceCollection.Resources
|
|
|
.Where(r => r.Labels != null && r.Labels.TryGetValue(name, out _))
|
|
|
.Select(r => (r, r.Labels![name]))
|
|
|
.ToList()
|
|
|
@@ -56,8 +51,8 @@ public sealed class YamlResourceCollection(
|
|
|
|
|
|
return Task.FromResult<IReadOnlyList<(Resource, string)>>(result);
|
|
|
}
|
|
|
- public Task<Dictionary<string, int>> GetLabelsAsync()
|
|
|
- {
|
|
|
+
|
|
|
+ public Task<Dictionary<string, int>> GetLabelsAsync() {
|
|
|
var result = resourceCollection.Resources
|
|
|
.SelectMany(r => r.Labels ?? Enumerable.Empty<KeyValuePair<string, string>>())
|
|
|
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
|
|
|
@@ -66,11 +61,11 @@ public sealed class YamlResourceCollection(
|
|
|
|
|
|
return Task.FromResult(result);
|
|
|
}
|
|
|
- public Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync()
|
|
|
- {
|
|
|
+
|
|
|
+ public Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync() {
|
|
|
var result = new List<(Resource, string)>();
|
|
|
|
|
|
- var allResources = resourceCollection.Resources;
|
|
|
+ List<Resource> allResources = resourceCollection.Resources;
|
|
|
|
|
|
// Build fast lookup for systems
|
|
|
var systemsByName = allResources
|
|
|
@@ -80,88 +75,27 @@ public sealed class YamlResourceCollection(
|
|
|
// Cache resolved system IPs (prevents repeated recursion)
|
|
|
var resolvedSystemIps = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
|
- foreach (var resource in allResources)
|
|
|
- {
|
|
|
- switch (resource)
|
|
|
- {
|
|
|
- case SystemResource system:
|
|
|
- {
|
|
|
- var ip = ResolveSystemIp(system, systemsByName, resolvedSystemIps);
|
|
|
- if (!string.IsNullOrWhiteSpace(ip))
|
|
|
- result.Add((system, ip));
|
|
|
- break;
|
|
|
- }
|
|
|
-
|
|
|
- case Service service:
|
|
|
- {
|
|
|
- var ip = ResolveServiceIp(service, systemsByName, resolvedSystemIps);
|
|
|
- if (!string.IsNullOrWhiteSpace(ip))
|
|
|
- result.Add((service, ip));
|
|
|
- break;
|
|
|
- }
|
|
|
+ foreach (Resource resource in allResources)
|
|
|
+ switch (resource) {
|
|
|
+ case SystemResource system: {
|
|
|
+ var ip = ResolveSystemIp(system, systemsByName, resolvedSystemIps);
|
|
|
+ if (!string.IsNullOrWhiteSpace(ip))
|
|
|
+ result.Add((system, ip));
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ case Service service: {
|
|
|
+ var ip = ResolveServiceIp(service, systemsByName, resolvedSystemIps);
|
|
|
+ if (!string.IsNullOrWhiteSpace(ip))
|
|
|
+ result.Add((service, ip));
|
|
|
+ break;
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
return Task.FromResult((IReadOnlyList<(Resource, string)>)result);
|
|
|
}
|
|
|
- private string? ResolveSystemIp(
|
|
|
- SystemResource system,
|
|
|
- Dictionary<string, SystemResource> systemsByName,
|
|
|
- Dictionary<string, string?> cache)
|
|
|
- {
|
|
|
- // Return cached result if already resolved
|
|
|
- if (cache.TryGetValue(system.Name, out var cached))
|
|
|
- return cached;
|
|
|
-
|
|
|
- // Direct IP wins
|
|
|
- if (!string.IsNullOrWhiteSpace(system.Ip))
|
|
|
- {
|
|
|
- cache[system.Name] = system.Ip;
|
|
|
- return system.Ip;
|
|
|
- }
|
|
|
-
|
|
|
- // Must have exactly one parent
|
|
|
- if (system.RunsOn?.Count != 1)
|
|
|
- {
|
|
|
- cache[system.Name] = null;
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- var parentName = system.RunsOn.First();
|
|
|
-
|
|
|
- if (!systemsByName.TryGetValue(parentName, out var parent))
|
|
|
- {
|
|
|
- cache[system.Name] = null;
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- var resolved = ResolveSystemIp(parent, systemsByName, cache);
|
|
|
- cache[system.Name] = resolved;
|
|
|
-
|
|
|
- return resolved;
|
|
|
- }
|
|
|
- private string? ResolveServiceIp(
|
|
|
- Service service,
|
|
|
- Dictionary<string, SystemResource> systemsByName,
|
|
|
- Dictionary<string, string?> cache)
|
|
|
- {
|
|
|
- // Direct IP wins
|
|
|
- if (!string.IsNullOrWhiteSpace(service.Network?.Ip))
|
|
|
- return service.Network!.Ip;
|
|
|
-
|
|
|
- // Must have exactly one parent
|
|
|
- if (service.RunsOn?.Count != 1)
|
|
|
- return null;
|
|
|
-
|
|
|
- var parentName = service.RunsOn.First();
|
|
|
|
|
|
- if (!systemsByName.TryGetValue(parentName, out var parent))
|
|
|
- return null;
|
|
|
-
|
|
|
- return ResolveSystemIp(parent, systemsByName, cache);
|
|
|
- }
|
|
|
- public Task<Dictionary<string, int>> GetTagsAsync()
|
|
|
- {
|
|
|
+ public Task<Dictionary<string, int>> GetTagsAsync() {
|
|
|
var result = resourceCollection.Resources
|
|
|
.SelectMany(r => r.Tags) // flatten all tag arrays
|
|
|
.Where(t => !string.IsNullOrWhiteSpace(t))
|
|
|
@@ -171,13 +105,10 @@ public sealed class YamlResourceCollection(
|
|
|
return Task.FromResult(result);
|
|
|
}
|
|
|
|
|
|
- public Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>()
|
|
|
- {
|
|
|
- return Task.FromResult<IReadOnlyList<T>>(resourceCollection.Resources.OfType<T>().ToList());
|
|
|
- }
|
|
|
-
|
|
|
- public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name)
|
|
|
- {
|
|
|
+ public Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>() =>
|
|
|
+ Task.FromResult<IReadOnlyList<T>>(resourceCollection.Resources.OfType<T>().ToList());
|
|
|
+
|
|
|
+ public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name) {
|
|
|
var result = resourceCollection.Resources
|
|
|
.Where(r => r.RunsOn.Any(p => p.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
|
|
.ToList();
|
|
|
@@ -185,18 +116,16 @@ public sealed class YamlResourceCollection(
|
|
|
return Task.FromResult<IReadOnlyList<Resource>>(result);
|
|
|
}
|
|
|
|
|
|
- public async Task Merge(string incomingYaml, MergeMode mode)
|
|
|
- {
|
|
|
+ public async Task Merge(string incomingYaml, MergeMode mode) {
|
|
|
if (string.IsNullOrWhiteSpace(incomingYaml))
|
|
|
return;
|
|
|
|
|
|
await resourceCollection.FileLock.WaitAsync();
|
|
|
- try
|
|
|
- {
|
|
|
- var incomingRoot = await migrationService.DeserializeAsync(incomingYaml);
|
|
|
+ try {
|
|
|
+ YamlRoot incomingRoot = await migrationService.DeserializeAsync(incomingYaml);
|
|
|
|
|
|
- var incomingResources = incomingRoot.Resources ?? new List<Resource>();
|
|
|
- var merged = ResourceCollectionMerger.Merge(
|
|
|
+ List<Resource> incomingResources = incomingRoot.Resources ?? new List<Resource>();
|
|
|
+ List<Resource> merged = ResourceCollectionMerger.Merge(
|
|
|
resourceCollection.Resources,
|
|
|
incomingResources,
|
|
|
mode);
|
|
|
@@ -204,22 +133,19 @@ public sealed class YamlResourceCollection(
|
|
|
resourceCollection.Resources.Clear();
|
|
|
resourceCollection.Resources.AddRange(merged);
|
|
|
|
|
|
- var rootToSave = new YamlRoot
|
|
|
- {
|
|
|
+ var rootToSave = new YamlRoot {
|
|
|
Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
|
|
|
Resources = resourceCollection.Resources
|
|
|
};
|
|
|
|
|
|
await SaveRootAsync(rootToSave);
|
|
|
}
|
|
|
- finally
|
|
|
- {
|
|
|
+ finally {
|
|
|
resourceCollection.FileLock.Release();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
|
|
|
- {
|
|
|
+ public Task<IReadOnlyList<Resource>> GetByTagAsync(string name) {
|
|
|
return Task.FromResult<IReadOnlyList<Resource>>(
|
|
|
resourceCollection.Resources
|
|
|
.Where(r => r.Tags.Contains(name))
|
|
|
@@ -236,30 +162,26 @@ public sealed class YamlResourceCollection(
|
|
|
public IReadOnlyList<Service> ServiceResources =>
|
|
|
resourceCollection.Resources.OfType<Service>().ToList();
|
|
|
|
|
|
- public Task<Resource?> GetByNameAsync(string name)
|
|
|
- {
|
|
|
+ public Task<Resource?> GetByNameAsync(string name) {
|
|
|
return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
|
|
|
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
|
|
|
}
|
|
|
|
|
|
- public Task<T?> GetByNameAsync<T>(string name) where T : Resource
|
|
|
- {
|
|
|
- var resource =
|
|
|
+ public Task<T?> GetByNameAsync<T>(string name) where T : Resource {
|
|
|
+ Resource? resource =
|
|
|
resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
|
return Task.FromResult(resource as T);
|
|
|
}
|
|
|
|
|
|
- public Resource? GetByName(string name)
|
|
|
- {
|
|
|
+ public Resource? GetByName(string name) {
|
|
|
return resourceCollection.Resources.FirstOrDefault(r =>
|
|
|
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
|
}
|
|
|
|
|
|
- public async Task LoadAsync()
|
|
|
- {
|
|
|
+ public async Task LoadAsync() {
|
|
|
var yaml = await fileStore.ReadAllTextAsync(filePath);
|
|
|
|
|
|
- var root = await migrationService.DeserializeAsync(
|
|
|
+ YamlRoot root = await migrationService.DeserializeAsync(
|
|
|
yaml,
|
|
|
async originalYaml => await BackupOriginalAsync(originalYaml),
|
|
|
async migratedRoot => await SaveRootAsync(migratedRoot)
|
|
|
@@ -270,11 +192,9 @@ public sealed class YamlResourceCollection(
|
|
|
if (root.Resources != null)
|
|
|
resourceCollection.Resources.AddRange(root.Resources);
|
|
|
}
|
|
|
-
|
|
|
- public Task AddAsync(Resource resource)
|
|
|
- {
|
|
|
- return UpdateWithLockAsync(list =>
|
|
|
- {
|
|
|
+
|
|
|
+ public Task AddAsync(Resource resource) {
|
|
|
+ return UpdateWithLockAsync(list => {
|
|
|
if (list.Any(r => r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase)))
|
|
|
throw new InvalidOperationException($"'{resource.Name}' already exists.");
|
|
|
|
|
|
@@ -283,10 +203,8 @@ public sealed class YamlResourceCollection(
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- public Task UpdateAsync(Resource resource)
|
|
|
- {
|
|
|
- return UpdateWithLockAsync(list =>
|
|
|
- {
|
|
|
+ public Task UpdateAsync(Resource resource) {
|
|
|
+ return UpdateWithLockAsync(list => {
|
|
|
var index = list.FindIndex(r => r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase));
|
|
|
if (index == -1) throw new InvalidOperationException("Not found.");
|
|
|
|
|
|
@@ -295,30 +213,78 @@ public sealed class YamlResourceCollection(
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- public Task DeleteAsync(string name)
|
|
|
- {
|
|
|
+ public Task DeleteAsync(string name) {
|
|
|
return UpdateWithLockAsync(list =>
|
|
|
list.RemoveAll(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
|
|
|
}
|
|
|
|
|
|
- private async Task UpdateWithLockAsync(Action<List<Resource>> action)
|
|
|
- {
|
|
|
+ private string? ResolveSystemIp(
|
|
|
+ SystemResource system,
|
|
|
+ Dictionary<string, SystemResource> systemsByName,
|
|
|
+ Dictionary<string, string?> cache) {
|
|
|
+ // Return cached result if already resolved
|
|
|
+ if (cache.TryGetValue(system.Name, out var cached))
|
|
|
+ return cached;
|
|
|
+
|
|
|
+ // Direct IP wins
|
|
|
+ if (!string.IsNullOrWhiteSpace(system.Ip)) {
|
|
|
+ cache[system.Name] = system.Ip;
|
|
|
+ return system.Ip;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Must have exactly one parent
|
|
|
+ if (system.RunsOn?.Count != 1) {
|
|
|
+ cache[system.Name] = null;
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ var parentName = system.RunsOn.First();
|
|
|
+
|
|
|
+ if (!systemsByName.TryGetValue(parentName, out SystemResource? parent)) {
|
|
|
+ cache[system.Name] = null;
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ var resolved = ResolveSystemIp(parent, systemsByName, cache);
|
|
|
+ cache[system.Name] = resolved;
|
|
|
+
|
|
|
+ return resolved;
|
|
|
+ }
|
|
|
+
|
|
|
+ private string? ResolveServiceIp(
|
|
|
+ Service service,
|
|
|
+ Dictionary<string, SystemResource> systemsByName,
|
|
|
+ Dictionary<string, string?> cache) {
|
|
|
+ // Direct IP wins
|
|
|
+ if (!string.IsNullOrWhiteSpace(service.Network?.Ip))
|
|
|
+ return service.Network!.Ip;
|
|
|
+
|
|
|
+ // Must have exactly one parent
|
|
|
+ if (service.RunsOn?.Count != 1)
|
|
|
+ return null;
|
|
|
+
|
|
|
+ var parentName = service.RunsOn.First();
|
|
|
+
|
|
|
+ if (!systemsByName.TryGetValue(parentName, out SystemResource? parent))
|
|
|
+ return null;
|
|
|
+
|
|
|
+ return ResolveSystemIp(parent, systemsByName, cache);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task UpdateWithLockAsync(Action<List<Resource>> action) {
|
|
|
await resourceCollection.FileLock.WaitAsync();
|
|
|
- try
|
|
|
- {
|
|
|
+ try {
|
|
|
action(resourceCollection.Resources);
|
|
|
|
|
|
// Always write current schema version when app writes the file.
|
|
|
- var root = new YamlRoot
|
|
|
- {
|
|
|
- Version = CurrentSchemaVersion,
|
|
|
+ var root = new YamlRoot {
|
|
|
+ Version = _currentSchemaVersion,
|
|
|
Resources = resourceCollection.Resources
|
|
|
};
|
|
|
|
|
|
await SaveRootAsync(root);
|
|
|
}
|
|
|
- finally
|
|
|
- {
|
|
|
+ finally {
|
|
|
resourceCollection.FileLock.Release();
|
|
|
}
|
|
|
}
|
|
|
@@ -327,16 +293,14 @@ public sealed class YamlResourceCollection(
|
|
|
// Versioning + migration
|
|
|
// ----------------------------
|
|
|
|
|
|
- private async Task BackupOriginalAsync(string originalYaml)
|
|
|
- {
|
|
|
+ private async Task BackupOriginalAsync(string originalYaml) {
|
|
|
// Timestamped backup for safe rollback
|
|
|
var backupPath = $"{filePath}.bak.{DateTime.UtcNow:yyyyMMddHHmmss}";
|
|
|
await fileStore.WriteAllTextAsync(backupPath, originalYaml);
|
|
|
}
|
|
|
-
|
|
|
- private async Task SaveRootAsync(YamlRoot? root)
|
|
|
- {
|
|
|
- var serializer = new SerializerBuilder()
|
|
|
+
|
|
|
+ private async Task SaveRootAsync(YamlRoot? root) {
|
|
|
+ ISerializer serializer = new SerializerBuilder()
|
|
|
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
|
|
.WithTypeConverter(new StorageSizeYamlConverter())
|
|
|
.WithTypeConverter(new NotesStringYamlConverter())
|
|
|
@@ -347,8 +311,8 @@ public sealed class YamlResourceCollection(
|
|
|
.Build();
|
|
|
|
|
|
// Preserve ordering: version first, then resources
|
|
|
- var payload = new OrderedDictionary
|
|
|
- {
|
|
|
+ Debug.Assert(root != null, nameof(root) + " != null");
|
|
|
+ var payload = new OrderedDictionary {
|
|
|
["version"] = root.Version,
|
|
|
["resources"] = (root.Resources ?? new List<Resource>()).Select(SerializeResource).ToList()
|
|
|
};
|
|
|
@@ -356,10 +320,8 @@ public sealed class YamlResourceCollection(
|
|
|
await fileStore.WriteAllTextAsync(filePath, serializer.Serialize(payload));
|
|
|
}
|
|
|
|
|
|
- private string GetKind(Resource resource)
|
|
|
- {
|
|
|
- return resource switch
|
|
|
- {
|
|
|
+ private string GetKind(Resource resource) {
|
|
|
+ return resource switch {
|
|
|
Server => "Server",
|
|
|
Switch => "Switch",
|
|
|
Firewall => "Firewall",
|
|
|
@@ -374,14 +336,12 @@ public sealed class YamlResourceCollection(
|
|
|
};
|
|
|
}
|
|
|
|
|
|
- private OrderedDictionary SerializeResource(Resource resource)
|
|
|
- {
|
|
|
- var map = new OrderedDictionary
|
|
|
- {
|
|
|
+ private OrderedDictionary SerializeResource(Resource resource) {
|
|
|
+ var map = new OrderedDictionary {
|
|
|
["kind"] = GetKind(resource)
|
|
|
};
|
|
|
|
|
|
- var serializer = new SerializerBuilder()
|
|
|
+ ISerializer serializer = new SerializerBuilder()
|
|
|
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
|
|
.WithTypeConverter(new NotesStringYamlConverter())
|
|
|
.ConfigureDefaultValuesHandling(
|
|
|
@@ -392,21 +352,19 @@ public sealed class YamlResourceCollection(
|
|
|
|
|
|
var yaml = serializer.Serialize(resource);
|
|
|
|
|
|
- var props = new DeserializerBuilder()
|
|
|
+ Dictionary<string, object?> props = new DeserializerBuilder()
|
|
|
.Build()
|
|
|
.Deserialize<Dictionary<string, object?>>(yaml);
|
|
|
|
|
|
- foreach (var (key, value) in props)
|
|
|
+ foreach ((var key, var value) in props)
|
|
|
if (!string.Equals(key, "kind", StringComparison.OrdinalIgnoreCase))
|
|
|
map[key] = value;
|
|
|
|
|
|
return map;
|
|
|
}
|
|
|
-
|
|
|
}
|
|
|
|
|
|
-public class YamlRoot
|
|
|
-{
|
|
|
+public class YamlRoot {
|
|
|
public int Version { get; set; }
|
|
|
public List<Resource>? Resources { get; set; }
|
|
|
}
|