|
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
|
|
using System.Collections.Specialized;
|
|
using System.Collections.Specialized;
|
|
|
using System.Diagnostics;
|
|
using System.Diagnostics;
|
|
|
using RackPeek.Domain.Discovery;
|
|
using RackPeek.Domain.Discovery;
|
|
|
|
|
+using RackPeek.Domain.Helpers;
|
|
|
using RackPeek.Domain.Resources;
|
|
using RackPeek.Domain.Resources;
|
|
|
using RackPeek.Domain.Resources.AccessPoints;
|
|
using RackPeek.Domain.Resources.AccessPoints;
|
|
|
using RackPeek.Domain.Resources.Connections;
|
|
using RackPeek.Domain.Resources.Connections;
|
|
@@ -33,6 +34,14 @@ public class ResourceCollection {
|
|
|
/// the user's file.
|
|
/// the user's file.
|
|
|
/// </summary>
|
|
/// </summary>
|
|
|
public bool Loaded { get; set; }
|
|
public bool Loaded { get; set; }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// Set when the config exists but could not be understood — damaged, truncated,
|
|
|
|
|
+ /// or not YAML. The process is still allowed to boot (the web UI is how someone
|
|
|
|
|
+ /// fixes the file), but every read and write must fail loudly rather than serve
|
|
|
|
|
+ /// or persist an empty inventory (#337).
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ public ConfigLoadException? LoadFailure { get; set; }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public sealed class YamlResourceCollection(
|
|
public sealed class YamlResourceCollection(
|
|
@@ -45,16 +54,19 @@ public sealed class YamlResourceCollection(
|
|
|
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) {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
return Task.FromResult(resourceCollection.Resources.Exists(r =>
|
|
return Task.FromResult(resourceCollection.Resources.Exists(r =>
|
|
|
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
|
|
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public Task<string?> GetKind(string? name) {
|
|
public Task<string?> GetKind(string? name) {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
|
|
return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
|
|
|
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Kind);
|
|
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Kind);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<(Resource, string)>> GetByLabelAsync(string name) {
|
|
public Task<IReadOnlyList<(Resource, string)>> GetByLabelAsync(string name) {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
ReadOnlyCollection<(Resource r, string)> result = resourceCollection.Resources
|
|
ReadOnlyCollection<(Resource r, string)> result = resourceCollection.Resources
|
|
|
.Where(r => r.Labels != null && r.Labels.TryGetValue(name, out _))
|
|
.Where(r => r.Labels != null && r.Labels.TryGetValue(name, out _))
|
|
|
.Select(r => (r, r.Labels![name]))
|
|
.Select(r => (r, r.Labels![name]))
|
|
@@ -65,6 +77,7 @@ public sealed class YamlResourceCollection(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public Task<Dictionary<string, int>> GetLabelsAsync() {
|
|
public Task<Dictionary<string, int>> GetLabelsAsync() {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
var result = resourceCollection.Resources
|
|
var result = resourceCollection.Resources
|
|
|
.SelectMany(r => r.Labels ?? Enumerable.Empty<KeyValuePair<string, string>>())
|
|
.SelectMany(r => r.Labels ?? Enumerable.Empty<KeyValuePair<string, string>>())
|
|
|
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
|
|
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
|
|
@@ -75,6 +88,7 @@ public sealed class YamlResourceCollection(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync() {
|
|
public Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync() {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
var result = new List<(Resource, string)>();
|
|
var result = new List<(Resource, string)>();
|
|
|
|
|
|
|
|
List<Resource> allResources = resourceCollection.Resources;
|
|
List<Resource> allResources = resourceCollection.Resources;
|
|
@@ -108,6 +122,7 @@ public sealed class YamlResourceCollection(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public Task<Dictionary<string, int>> GetTagsAsync() {
|
|
public Task<Dictionary<string, int>> GetTagsAsync() {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
var result = resourceCollection.Resources
|
|
var result = resourceCollection.Resources
|
|
|
.SelectMany(r => r.Tags) // flatten all tag arrays
|
|
.SelectMany(r => r.Tags) // flatten all tag arrays
|
|
|
.Where(t => !string.IsNullOrWhiteSpace(t))
|
|
.Where(t => !string.IsNullOrWhiteSpace(t))
|
|
@@ -117,10 +132,13 @@ public sealed class YamlResourceCollection(
|
|
|
return Task.FromResult(result);
|
|
return Task.FromResult(result);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- public Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>() =>
|
|
|
|
|
- Task.FromResult<IReadOnlyList<T>>(resourceCollection.Resources.OfType<T>().ToList());
|
|
|
|
|
|
|
+ public Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>() {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
|
|
+ return Task.FromResult<IReadOnlyList<T>>(resourceCollection.Resources.OfType<T>().ToList());
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name) {
|
|
public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name) {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
var result = resourceCollection.Resources
|
|
var result = resourceCollection.Resources
|
|
|
.Where(r => r.RunsOn.Any(p => p.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
|
.Where(r => r.RunsOn.Any(p => p.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
|
|
.ToList();
|
|
.ToList();
|
|
@@ -177,6 +195,7 @@ public sealed class YamlResourceCollection(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<Resource>> GetByTagAsync(string name) {
|
|
public Task<IReadOnlyList<Resource>> GetByTagAsync(string name) {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
return Task.FromResult<IReadOnlyList<Resource>>(
|
|
return Task.FromResult<IReadOnlyList<Resource>>(
|
|
|
resourceCollection.Resources
|
|
resourceCollection.Resources
|
|
|
.Where(r => r.Tags.Contains(name))
|
|
.Where(r => r.Tags.Contains(name))
|
|
@@ -184,27 +203,42 @@ public sealed class YamlResourceCollection(
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- public IReadOnlyList<Hardware> HardwareResources =>
|
|
|
|
|
- resourceCollection.Resources.OfType<Hardware>().ToList();
|
|
|
|
|
|
|
+ public IReadOnlyList<Hardware> HardwareResources {
|
|
|
|
|
+ get {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
|
|
+ return resourceCollection.Resources.OfType<Hardware>().ToList();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- public IReadOnlyList<SystemResource> SystemResources =>
|
|
|
|
|
- resourceCollection.Resources.OfType<SystemResource>().ToList();
|
|
|
|
|
|
|
+ public IReadOnlyList<SystemResource> SystemResources {
|
|
|
|
|
+ get {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
|
|
+ return resourceCollection.Resources.OfType<SystemResource>().ToList();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- public IReadOnlyList<Service> ServiceResources =>
|
|
|
|
|
- resourceCollection.Resources.OfType<Service>().ToList();
|
|
|
|
|
|
|
+ public IReadOnlyList<Service> ServiceResources {
|
|
|
|
|
+ get {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
|
|
+ return resourceCollection.Resources.OfType<Service>().ToList();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
public Task<Resource?> GetByNameAsync(string name) {
|
|
public Task<Resource?> GetByNameAsync(string name) {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
|
|
return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
|
|
|
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
|
|
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public Task<T?> GetByNameAsync<T>(string name) where T : Resource {
|
|
public Task<T?> GetByNameAsync<T>(string name) where T : Resource {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
Resource? resource =
|
|
Resource? resource =
|
|
|
resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
|
return Task.FromResult(resource as T);
|
|
return Task.FromResult(resource as T);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public Resource? GetByName(string name) {
|
|
public Resource? GetByName(string name) {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
return resourceCollection.Resources.FirstOrDefault(r =>
|
|
return resourceCollection.Resources.FirstOrDefault(r =>
|
|
|
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
|
}
|
|
}
|
|
@@ -227,11 +261,42 @@ public sealed class YamlResourceCollection(
|
|
|
private async Task LoadUnderLockAsync() {
|
|
private async Task LoadUnderLockAsync() {
|
|
|
var yaml = await fileStore.ReadAllTextAsync(filePath);
|
|
var yaml = await fileStore.ReadAllTextAsync(filePath);
|
|
|
|
|
|
|
|
- YamlRoot root = await migrationService.DeserializeAsync(
|
|
|
|
|
- yaml,
|
|
|
|
|
- async originalYaml => await BackupOriginalAsync(originalYaml),
|
|
|
|
|
- async migratedRoot => await SaveRootAsync(migratedRoot)
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ YamlRoot root;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ root = await migrationService.DeserializeAsync(
|
|
|
|
|
+ yaml,
|
|
|
|
|
+ async originalYaml => await BackupOriginalAsync(originalYaml),
|
|
|
|
|
+ async migratedRoot => await SaveRootAsync(migratedRoot)
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (Exception ex) when (ex is not ConfigLoadException
|
|
|
|
|
+ and not IOException
|
|
|
|
|
+ and not UnauthorizedAccessException) {
|
|
|
|
|
+ // A file that exists but cannot be understood. Record it so that every
|
|
|
|
|
+ // later read and write refuses, rather than quietly serving — and then
|
|
|
|
|
+ // persisting — an empty inventory over a recoverable file (#337).
|
|
|
|
|
+ resourceCollection.LoadFailure = new ConfigLoadException(
|
|
|
|
|
+ $"The config at {filePath} could not be read: {ex.Message} " +
|
|
|
|
|
+ "Fix or restore the file (recent schema migrations leave .bak copies " +
|
|
|
|
|
+ "beside it); nothing has been changed.",
|
|
|
|
|
+ ex);
|
|
|
|
|
+
|
|
|
|
|
+ throw resourceCollection.LoadFailure;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // A RackPeek document always carries a schema version. Its absence means the
|
|
|
|
|
+ // file was cut before the version line was written, or is not a RackPeek
|
|
|
|
|
+ // config at all — either way the parse "succeeding" with an empty document is
|
|
|
|
|
+ // not evidence of an empty inventory.
|
|
|
|
|
+ if (!string.IsNullOrWhiteSpace(yaml) && root.Version <= 0) {
|
|
|
|
|
+ resourceCollection.LoadFailure = new ConfigLoadException(
|
|
|
|
|
+ $"The config at {filePath} is missing its schema version, so it is " +
|
|
|
|
|
+ "incomplete or not a RackPeek config. Fix or restore the file; " +
|
|
|
|
|
+ "nothing has been changed.");
|
|
|
|
|
+
|
|
|
|
|
+ throw resourceCollection.LoadFailure;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
resourceCollection.Resources.Clear();
|
|
resourceCollection.Resources.Clear();
|
|
|
|
|
|
|
@@ -243,9 +308,21 @@ public sealed class YamlResourceCollection(
|
|
|
if (root.Connections != null)
|
|
if (root.Connections != null)
|
|
|
resourceCollection.Connections.AddRange(root.Connections);
|
|
resourceCollection.Connections.AddRange(root.Connections);
|
|
|
|
|
|
|
|
|
|
+ resourceCollection.LoadFailure = null;
|
|
|
resourceCollection.Loaded = true;
|
|
resourceCollection.Loaded = true;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// Called at the top of every read path. When the config exists but could not be
|
|
|
|
|
+ /// understood, the in-memory collection is empty for a reason that has nothing to
|
|
|
|
|
+ /// do with the user's inventory — serving it would report "0 resources" for a
|
|
|
|
|
+ /// recoverable file, and scripted consumers would treat that as the truth (#337).
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ private void ThrowIfLoadFailed() {
|
|
|
|
|
+ if (resourceCollection.LoadFailure != null)
|
|
|
|
|
+ throw resourceCollection.LoadFailure;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/// <summary>
|
|
/// <summary>
|
|
|
/// Called at the top of every write path, under the lock. Normally a no-op:
|
|
/// Called at the top of every write path, under the lock. Normally a no-op:
|
|
|
/// both the CLI and the web host load at startup. When that startup load failed
|
|
/// both the CLI and the web host load at startup. When that startup load failed
|
|
@@ -302,6 +379,7 @@ public sealed class YamlResourceCollection(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<Connection>> GetConnectionsAsync() {
|
|
public Task<IReadOnlyList<Connection>> GetConnectionsAsync() {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
IReadOnlyList<Connection> result =
|
|
IReadOnlyList<Connection> result =
|
|
|
resourceCollection.Connections
|
|
resourceCollection.Connections
|
|
|
.ToList()
|
|
.ToList()
|
|
@@ -311,6 +389,7 @@ public sealed class YamlResourceCollection(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<Connection>> GetConnectionsForResourceAsync(string resource) {
|
|
public Task<IReadOnlyList<Connection>> GetConnectionsForResourceAsync(string resource) {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
IReadOnlyList<Connection> result =
|
|
IReadOnlyList<Connection> result =
|
|
|
resourceCollection.Connections
|
|
resourceCollection.Connections
|
|
|
.Where(c =>
|
|
.Where(c =>
|
|
@@ -323,6 +402,7 @@ public sealed class YamlResourceCollection(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public Task<Connection?> GetConnectionForPortAsync(PortReference port) {
|
|
public Task<Connection?> GetConnectionForPortAsync(PortReference port) {
|
|
|
|
|
+ ThrowIfLoadFailed();
|
|
|
Connection? connection =
|
|
Connection? connection =
|
|
|
resourceCollection.Connections
|
|
resourceCollection.Connections
|
|
|
.FirstOrDefault(c =>
|
|
.FirstOrDefault(c =>
|