Преглед изворни кода

Merge pull request #185 from Timmoth/reavessm-multi-node-systems

Reavessm multi node systems
Tim Jones пре 1 месец
родитељ
комит
0b01e8731c
75 измењених фајлова са 3357 додато и 913 уклоњено
  1. 30 10
      RackPeek.Domain/Persistence/HardwareRepository.cs
  2. 2 2
      RackPeek.Domain/Persistence/InMemoryResourceCollection.cs
  3. 2 2
      RackPeek.Domain/Persistence/ServiceRepository.cs
  4. 2 2
      RackPeek.Domain/Persistence/SystemRepository.cs
  5. 51 4
      RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs
  6. 9 6
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  7. 6 6
      RackPeek.Domain/Resources/Hardware/GetHardwareSystemTreeUseCase.cs
  8. 2 2
      RackPeek.Domain/Resources/Resource.cs
  9. 16 7
      RackPeek.Domain/Resources/Services/UseCases/DescribeServiceUseCase.cs
  10. 16 7
      RackPeek.Domain/Resources/Services/UseCases/ServiceReportUseCase.cs
  11. 2 2
      RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs
  12. 21 7
      RackPeek.Domain/Resources/Services/UseCases/UpdateServiceUseCase.cs
  13. 1 1
      RackPeek.Domain/Resources/SystemResources/SystemResource.cs
  14. 2 2
      RackPeek.Domain/Resources/SystemResources/UseCases/DescribeSystemUseCase.cs
  15. 2 2
      RackPeek.Domain/Resources/SystemResources/UseCases/SystemReportUseCase.cs
  16. 14 8
      RackPeek.Domain/Resources/SystemResources/UseCases/UpdateSystemUseCase.cs
  17. 15 12
      RackPeek.Domain/UseCases/AddResourceUseCase.cs
  18. 5 13
      RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs
  19. 2 4
      RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGeneratorUseCase.cs
  20. 1 1
      RackPeek.Domain/UseCases/Ports/UpdatePortUseCase.cs
  21. 2 2
      RackPeek.Domain/UseCases/RenameResourceUseCase.cs
  22. 212 152
      RackPeek.Web.Viewer/wwwroot/schemas/v1/schema.v1.json
  23. 336 0
      RackPeek.Web.Viewer/wwwroot/schemas/v2/schema.v2.json
  24. 212 152
      RackPeek.Web/wwwroot/schemas/v1/schema.v1.json
  25. 336 0
      RackPeek.Web/wwwroot/schemas/v2/schema.v2.json
  26. 1 1
      Shared.Rcl/Commands/Ansible/GenerateAnsibleInventoryCommand.cs
  27. 2 2
      Shared.Rcl/Commands/Services/ServiceDescribeCommand.cs
  28. 5 2
      Shared.Rcl/Commands/Services/ServiceGetByNameCommand.cs
  29. 16 2
      Shared.Rcl/Commands/Services/ServiceGetCommand.cs
  30. 16 2
      Shared.Rcl/Commands/Services/ServiceReportCommand.cs
  31. 5 5
      Shared.Rcl/Commands/Services/ServiceSetCommand.cs
  32. 10 2
      Shared.Rcl/Commands/Services/ServiceSubnetsCommand.cs
  33. 1 1
      Shared.Rcl/Commands/Services/ServicesFormatExtensions.cs
  34. 3 2
      Shared.Rcl/Commands/Systems/SystemDescribeCommand.cs
  35. 2 2
      Shared.Rcl/Commands/Systems/SystemGetByNameCommand.cs
  36. 2 2
      Shared.Rcl/Commands/Systems/SystemGetCommand.cs
  37. 2 2
      Shared.Rcl/Commands/Systems/SystemReportCommand.cs
  38. 7 3
      Shared.Rcl/Commands/Systems/SystemSetCommand.cs
  39. 1 1
      Shared.Rcl/Components/AddResourceComponent.razor
  40. 1 2
      Shared.Rcl/Components/AnsibleInventory.razor
  41. 41 0
      Shared.Rcl/Components/CrumbLevel.razor
  42. 59 49
      Shared.Rcl/Components/ResourceBreadCrumbComponent.razor
  43. 4 1
      Shared.Rcl/Hardware/HardwareDetailsPage.razor
  44. 53 19
      Shared.Rcl/Services/ServiceCardComponent.razor
  45. 2 2
      Shared.Rcl/Services/ServiceEditModel.cs
  46. 6 2
      Shared.Rcl/Services/ServicesListPage.razor
  47. 54 22
      Shared.Rcl/Systems/SystemCardComponent.razor
  48. 2 2
      Shared.Rcl/Systems/SystemEditModel.cs
  49. 2 2
      Shared.Rcl/Systems/SystemsDetailsPage.razor
  50. 5 1
      Shared.Rcl/Systems/SystemsListPage.razor
  51. 2 2
      Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs
  52. 2 2
      Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs
  53. 2 2
      Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs
  54. 1 1
      Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs
  55. 4 2
      Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs
  56. 2 2
      Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs
  57. 4 2
      Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs
  58. 2 2
      Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs
  59. 34 0
      Tests/TestConfigs/v2/01-server.yaml
  60. 17 0
      Tests/TestConfigs/v2/02-firewall.yaml
  61. 17 0
      Tests/TestConfigs/v2/03-router.yaml
  62. 17 0
      Tests/TestConfigs/v2/04-switch.yaml
  63. 11 0
      Tests/TestConfigs/v2/05-accesspoint.yaml
  64. 11 0
      Tests/TestConfigs/v2/06-ups.yaml
  65. 25 0
      Tests/TestConfigs/v2/07-desktop.yaml
  66. 18 0
      Tests/TestConfigs/v2/08-laptop.yaml
  67. 12 0
      Tests/TestConfigs/v2/09-service.yaml
  68. 15 0
      Tests/TestConfigs/v2/10-system.yaml
  69. 453 0
      Tests/TestConfigs/v2/11-demo-config.yaml
  70. 2 51
      Tests/Tests.csproj
  71. 9 6
      Tests/Yaml/SchemaTests.cs
  72. 212 152
      Tests/schemas/schema.v1.json
  73. 336 0
      Tests/schemas/schema.v2.json
  74. 212 152
      schemas/v1/schema.v1.json
  75. 336 0
      schemas/v2/schema.v2.json

+ 30 - 10
RackPeek.Domain/Persistence/HardwareRepository.cs

@@ -19,16 +19,36 @@ public class YamlHardwareRepository(IResourceCollection resources) : IHardwareRe
     public Task<List<HardwareTree>> GetTreeAsync()
     {
         var hardwareTree = new List<HardwareTree>();
+        
+            var systemGroups = resources.SystemResources
+                .Where(s => s.RunsOn.Count != 0)
+                .SelectMany(
+                    s => s.RunsOn,
+                    (system, hardwareName) => new
+                    {
+                        Hardware = hardwareName.Trim(),
+                        System = system
+                    })
+                .GroupBy(x => x.Hardware, StringComparer.OrdinalIgnoreCase)
+                .ToDictionary(
+                    g => g.Key,
+                    g => g.Select(x => x.System).ToList(),
+                    StringComparer.OrdinalIgnoreCase);
 
-        var systemGroups = resources.SystemResources
-            .Where(s => !string.IsNullOrWhiteSpace(s.RunsOn))
-            .GroupBy(s => s.RunsOn!.Trim(), StringComparer.OrdinalIgnoreCase)
-            .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
-
-        var serviceGroups = resources.ServiceResources
-            .Where(s => !string.IsNullOrWhiteSpace(s.RunsOn))
-            .GroupBy(s => s.RunsOn!.Trim(), StringComparer.OrdinalIgnoreCase)
-            .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
+            var serviceGroups = resources.ServiceResources
+                .Where(s => s.RunsOn.Count != 0)
+                .SelectMany(
+                    s => s.RunsOn,
+                    (service, systemName) => new
+                    {
+                        System = systemName.Trim(),
+                        Service = service
+                    })
+                .GroupBy(x => x.System, StringComparer.OrdinalIgnoreCase)
+                .ToDictionary(
+                    g => g.Key,
+                    g => g.Select(x => x.Service).ToList(),
+                    StringComparer.OrdinalIgnoreCase);
 
         foreach (var hardware in resources.HardwareResources)
         {
@@ -61,4 +81,4 @@ public class YamlHardwareRepository(IResourceCollection resources) : IHardwareRe
 
         return Task.FromResult(hardwareTree);
     }
-}
+}

+ 2 - 2
RackPeek.Domain/Persistence/InMemoryResourceCollection.cs

@@ -99,7 +99,7 @@ public sealed class InMemoryResourceCollection(IEnumerable<Resource>? seed = nul
         lock (_lock)
         {
             return Task.FromResult<IReadOnlyList<Resource>>(_resources
-                .Where(r => r.RunsOn?.Equals(name, StringComparison.OrdinalIgnoreCase) ?? false).ToList());
+                .Where(r => r.RunsOn.Select(p => p.Equals(name, StringComparison.OrdinalIgnoreCase)).ToList().Count != 0).ToList());
         }
     }
 
@@ -192,4 +192,4 @@ public sealed class InMemoryResourceCollection(IEnumerable<Resource>? seed = nul
                 $"Unknown resource type: {resource.GetType().Name}")
         };
     }
-}
+}

+ 2 - 2
RackPeek.Domain/Persistence/ServiceRepository.cs

@@ -22,7 +22,7 @@ public class ServiceRepository(IResourceCollection resources) : IServiceReposito
     {
         var systemHostNameLower = systemHostName.ToLower().Trim();
         var results = resources.ServiceResources
-            .Where(s => s.RunsOn != null && s.RunsOn.ToLower().Equals(systemHostNameLower)).ToList();
+            .Where(s => s.RunsOn.Select(p => p.ToLower().Equals(systemHostNameLower)).ToList().Count > 0).ToList();
         return Task.FromResult<IReadOnlyList<Service>>(results);
     }
 
@@ -67,4 +67,4 @@ public class ServiceRepository(IResourceCollection resources) : IServiceReposito
 
         await resources.DeleteAsync(name);
     }
-}
+}

+ 2 - 2
RackPeek.Domain/Persistence/SystemRepository.cs

@@ -48,7 +48,7 @@ public class YamlSystemRepository(IResourceCollection resources) : ISystemReposi
     {
         var physicalHostNameLower = physicalHostName.ToLower().Trim();
         var results = resources.SystemResources
-            .Where(s => s.RunsOn != null && s.RunsOn.ToLower().Equals(physicalHostNameLower)).ToList();
+            .Where(s => s.RunsOn.Select(sys => sys.ToLower().Equals(physicalHostNameLower)).ToList().Count > 0).ToList();
         return Task.FromResult<IReadOnlyList<SystemResource>>(results);
     }
 
@@ -99,4 +99,4 @@ public class YamlSystemRepository(IResourceCollection resources) : ISystemReposi
 
         await resources.DeleteAsync(name);
     }
-}
+}

+ 51 - 4
RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs

@@ -18,12 +18,16 @@ namespace RackPeek.Domain.Persistence.Yaml;
 
 public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<YamlRoot>
 {
+    // List migration functions here
+    public static readonly IReadOnlyList<Func<IServiceProvider, Dictionary<object, object>, ValueTask>> ListOfMigrations = new List<Func<IServiceProvider, Dictionary<object,object>, ValueTask>>{
+        EnsureSchemaVersionExists,
+        ConvertScalarRunsOnToList,
+    };
+
     public RackPeekConfigMigrationDeserializer(IServiceProvider serviceProvider,
         ILogger<YamlMigrationDeserializer<YamlRoot>> logger) :
         base(serviceProvider, logger, 
-            new List<Func<IServiceProvider, Dictionary<object,object>, ValueTask>>{
-                EnsureSchemaVersionExists,
-            },
+            ListOfMigrations,
             "version",
             new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance)
                 .WithCaseInsensitivePropertyMatching()
@@ -66,6 +70,49 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
         
         return ValueTask.CompletedTask;
     }
+    public static ValueTask ConvertScalarRunsOnToList(
+        IServiceProvider serviceProvider,
+        Dictionary<object, object> obj)
+    {
+        const string key = "runsOn";
+
+        if (!obj.TryGetValue("resources", out var resourceListObj))
+            return ValueTask.CompletedTask;
+
+        if (resourceListObj is not List<object> resources)
+            return ValueTask.CompletedTask;
+
+        foreach (var resourceObj in resources)
+        {
+            if (resourceObj is not Dictionary<object, object> resourceDict)
+                continue;
+
+            if (!resourceDict.TryGetValue(key, out var runsOn))
+                continue;
 
+            switch (runsOn)
+            {
+                case string single:
+                    resourceDict[key] = new List<string> { single };
+                    break;
+
+                case List<object> list:
+                    resourceDict[key] = list
+                        .OfType<string>()
+                        .ToList();
+                    break;
+
+                case List<string>:
+                    // Already correct
+                    break;
+
+                default:
+                    throw new InvalidCastException(
+                        $"Cannot convert {runsOn.GetType()} to List<string> for resource '{resourceDict}'.");
+            }
+        }
+
+        return ValueTask.CompletedTask;
+    }
     #endregion
-}
+}

+ 9 - 6
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -31,7 +31,7 @@ public sealed class YamlResourceCollection(
     : IResourceCollection
 {
     // Bump this when your YAML schema changes, and add a migration step below.
-    private const int CurrentSchemaVersion = 1;
+    private static readonly int CurrentSchemaVersion = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
 
     public Task<bool> Exists(string name)
     {
@@ -54,12 +54,14 @@ public sealed class YamlResourceCollection(
     {
         return Task.FromResult<IReadOnlyList<T>>(resourceCollection.Resources.OfType<T>().ToList());
     }
-
+    
     public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name)
     {
-        return Task.FromResult<IReadOnlyList<Resource>>(resourceCollection.Resources
-            .Where(r => r.RunsOn?.Equals(name, StringComparison.OrdinalIgnoreCase) ?? false)
-            .ToList());
+        var result = resourceCollection.Resources
+            .Where(r => r.RunsOn.Any(p => p.Equals(name, StringComparison.OrdinalIgnoreCase)))
+            .ToList();
+
+        return Task.FromResult<IReadOnlyList<Resource>>(result);
     }
 
     public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
@@ -123,7 +125,8 @@ public sealed class YamlResourceCollection(
         if (version < CurrentSchemaVersion)
         {
             await BackupOriginalAsync(yaml);
-            root = await _deserializer.Deserialize(yaml);
+
+            root = await _deserializer.Deserialize(yaml) ?? new YamlRoot();
             
             // Ensure we persist the migrated root (with updated version)
             await SaveRootAsync(root);

+ 6 - 6
RackPeek.Domain/Resources/Hardware/GetHardwareSystemTreeUseCase.cs

@@ -12,22 +12,22 @@ public class GetHardwareSystemTreeUseCase(
     {
         ThrowIfInvalid.ResourceName(hardwareName);
 
-        var server = await repo.GetByNameAsync(hardwareName) as Hardware;
-        if (server is null)
+        var hardware = await repo.GetByNameAsync(hardwareName) as Hardware;
+        if (hardware is null)
             throw new NotFoundException($"Hardware '{hardwareName}' not found.");
 
-        return await BuildDependencyTreeAsync(server);
+        return await BuildDependencyTreeAsync(hardware);
     }
 
-    private async Task<HardwareDependencyTree> BuildDependencyTreeAsync(Hardware server)
+    private async Task<HardwareDependencyTree> BuildDependencyTreeAsync(Hardware hardware)
     {
-        var systems = await repo.GetDependantsAsync(server.Name);
+        var systems = await repo.GetDependantsAsync(hardware.Name);
 
         var systemTrees = new List<SystemDependencyTree>();
         foreach (var system in systems.OfType<SystemResource>())
             systemTrees.Add(await BuildSystemDependencyTreeAsync(system));
 
-        return new HardwareDependencyTree(server, systemTrees);
+        return new HardwareDependencyTree(hardware, systemTrees);
     }
 
     private async Task<SystemDependencyTree> BuildSystemDependencyTreeAsync(SystemResource system)

+ 2 - 2
RackPeek.Domain/Resources/Resource.cs

@@ -51,7 +51,7 @@ public abstract class Resource
     public Dictionary<string, string> Labels { get; set; } = new();
     public string? Notes { get; set; }
 
-    public string? RunsOn { get; set; }
+    public List<string> RunsOn { get; set; } = new List<string>();
 
     public static string KindToPlural(string kind)
     {
@@ -82,4 +82,4 @@ public abstract class Resource
 
         return false;
     }
-}
+}

+ 16 - 7
RackPeek.Domain/Resources/Services/UseCases/DescribeServiceUseCase.cs

@@ -10,8 +10,8 @@ public record ServiceDescription(
     int? Port,
     string? Protocol,
     string? Url,
-    string? RunsOnSystemHost,
-    string? RunsOnPhysicalHost,
+    List<string> RunsOnSystemHost,
+    List<string> RunsOnPhysicalHost,
     Dictionary<string, string> Labels
 );
 
@@ -25,11 +25,20 @@ public class DescribeServiceUseCase(IResourceCollection repository) : IUseCase
         if (service is null)
             throw new NotFoundException($"Service '{name}' not found.");
 
-        string? runsOnPhysicalHost = null;
-        if (!string.IsNullOrEmpty(service.RunsOn))
+        List<string> runsOnPhysicalHost = new List<string>();
+        foreach (var systemName in service.RunsOn)
         {
-            var systemResource = await repository.GetByNameAsync(service.RunsOn) as SystemResource;
-            runsOnPhysicalHost = systemResource?.RunsOn;
+            var systemResource = await repository.GetByNameAsync(systemName) as SystemResource;
+            if (systemResource is not null)
+            {
+                foreach(var physicalName in systemResource.RunsOn)
+                {
+                    if (!runsOnPhysicalHost.Contains(physicalName))
+                    {
+                        runsOnPhysicalHost.Add(physicalName);
+                    }
+                }
+            }
         }
 
         return new ServiceDescription(
@@ -43,4 +52,4 @@ public class DescribeServiceUseCase(IResourceCollection repository) : IUseCase
             service.Labels
         );
     }
-}
+}

+ 16 - 7
RackPeek.Domain/Resources/Services/UseCases/ServiceReportUseCase.cs

@@ -12,8 +12,8 @@ public record ServiceReportRow(
     int? Port,
     string? Protocol,
     string? Url,
-    string? RunsOnSystemHost,
-    string? RunsOnPhysicalHost
+    List<string>? RunsOnSystemHost,
+    List<string>? RunsOnPhysicalHost
 );
 
 public class ServiceReportUseCase(IResourceCollection repository) : IUseCase
@@ -24,11 +24,20 @@ public class ServiceReportUseCase(IResourceCollection repository) : IUseCase
 
         var rows = services.Select(async s =>
         {
-            string? runsOnPhysicalHost = null;
-            if (!string.IsNullOrEmpty(s.RunsOn))
+            List<string> runsOnPhysicalHost = new List<string>();
+            if (s.RunsOn is not null)
             {
-                var systemResource = await repository.GetByNameAsync(s.RunsOn);
-                runsOnPhysicalHost = systemResource?.RunsOn;
+                foreach (var system in s.RunsOn)
+                {
+                    var systemResource = await repository.GetByNameAsync(system);
+                    if (systemResource?.RunsOn is not null)
+                    {
+                        foreach (var parent in systemResource.RunsOn)
+                        {
+                            if (!runsOnPhysicalHost.Contains(parent)) runsOnPhysicalHost.Add(parent);
+                        }
+                    }
+                }
             }
 
             return new ServiceReportRow(
@@ -45,4 +54,4 @@ public class ServiceReportUseCase(IResourceCollection repository) : IUseCase
         var result = await Task.WhenAll(rows);
         return new ServiceReport(result);
     }
-}
+}

+ 2 - 2
RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs

@@ -54,7 +54,7 @@ public class ServiceSubnetsUseCase(IResourceCollection repo) : IUseCase
 
 public record SubnetSummary(string Cidr, int Count);
 
-public record ServiceSummary(string Name, string Ip, string? RunsOn);
+public record ServiceSummary(string Name, string Ip, List<string>? RunsOn);
 
 public class ServiceSubnetsResult
 {
@@ -80,4 +80,4 @@ public class ServiceSubnetsResult
     {
         return new ServiceSubnetsResult { Services = services, FilteredCidr = cidr };
     }
-}
+}

+ 21 - 7
RackPeek.Domain/Resources/Services/UseCases/UpdateServiceUseCase.cs

@@ -11,7 +11,7 @@ public class UpdateServiceUseCase(IResourceCollection repository) : IUseCase
         int? port = null,
         string? protocol = null,
         string? url = null,
-        string? runsOn = null,
+        List<string>? runsOn = null,
         string? notes = null
     )
     {
@@ -48,16 +48,30 @@ public class UpdateServiceUseCase(IResourceCollection repository) : IUseCase
             service.Network.Port = port.Value;
         }
 
-        if (!string.IsNullOrWhiteSpace(runsOn))
+        if (runsOn is not null)
         {
-            ThrowIfInvalid.ResourceName(runsOn);
-            var parentSystem = await repository.GetByNameAsync(runsOn);
-            if (parentSystem == null) throw new NotFoundException($"Parent system '{runsOn}' not found.");
-            service.RunsOn = runsOn;
+            var normalizedParents = new List<string>();
+
+            foreach (var parent in runsOn
+                         .Where(p => !string.IsNullOrWhiteSpace(p))
+                         .Select(p => p.Trim())
+                         .Distinct(StringComparer.OrdinalIgnoreCase))
+            {
+                ThrowIfInvalid.ResourceName(parent);
+
+                var parentSystem = await repository.GetByNameAsync(parent);
+
+                if (parentSystem == null)
+                    throw new NotFoundException($"Parent system '{parent}' not found.");
+
+                normalizedParents.Add(parent);
+            }
+
+            service.RunsOn = normalizedParents;
         }
 
         if (notes != null) service.Notes = notes;
 
         await repository.UpdateAsync(service);
     }
-}
+}

+ 1 - 1
RackPeek.Domain/Resources/SystemResources/SystemResource.cs

@@ -23,4 +23,4 @@ public class SystemResource : Resource, IDriveResource
     public int? Cores { get; set; }
     public double? Ram { get; set; }
     public List<Drive>? Drives { get; set; }
-}
+}

+ 2 - 2
RackPeek.Domain/Resources/SystemResources/UseCases/DescribeSystemUseCase.cs

@@ -10,7 +10,7 @@ public record SystemDescription(
     int Cores,
     double RamGb,
     int TotalStorageGb,
-    string? RunsOn,
+    List<string> RunsOn,
     Dictionary<string, string> Labels
 );
 
@@ -35,4 +35,4 @@ public class DescribeSystemUseCase(IResourceCollection repository) : IUseCase
             system.Labels
         );
     }
-}
+}

+ 2 - 2
RackPeek.Domain/Resources/SystemResources/UseCases/SystemReportUseCase.cs

@@ -13,7 +13,7 @@ public record SystemReportRow(
     int Cores,
     double RamGb,
     int TotalStorageGb,
-    string? RunsOn
+    List<string> RunsOn
 );
 
 public class SystemReportUseCase(IResourceCollection repository) : IUseCase
@@ -39,4 +39,4 @@ public class SystemReportUseCase(IResourceCollection repository) : IUseCase
 
         return new SystemReport(rows);
     }
-}
+}

+ 14 - 8
RackPeek.Domain/Resources/SystemResources/UseCases/UpdateSystemUseCase.cs

@@ -11,7 +11,7 @@ public class UpdateSystemUseCase(IResourceCollection repository) : IUseCase
         string? os = null,
         int? cores = null,
         double? ram = null,
-        string? runsOn = null,
+        List<string>? runsOn = null,
         string? notes = null
     )
     {
@@ -44,15 +44,21 @@ public class UpdateSystemUseCase(IResourceCollection repository) : IUseCase
 
         if (notes != null) system.Notes = notes;
 
-        if (!string.IsNullOrWhiteSpace(runsOn))
+        if (runsOn?.Count > 0)
         {
-            ThrowIfInvalid.ResourceName(runsOn);
-            var parentHardware = await repository.GetByNameAsync(runsOn) as Hardware.Hardware;
-            if (parentHardware == null) throw new NotFoundException($"Parent hardware '{runsOn}' not found.");
-            system.RunsOn = runsOn;
-        }
+            foreach(string parent in runsOn) {
+                if (!string.IsNullOrWhiteSpace(parent)) {
+                    ThrowIfInvalid.ResourceName(parent);
+                    var parentHardware = await repository.GetByNameAsync(parent) as Hardware.Hardware;
+
+                    if (parentHardware == null) throw new NotFoundException($"Parent hardware '{parent}' not found.");
 
+                    if (!system.RunsOn.Contains(parent)) system.RunsOn.Add(parent);
+
+                }
+            }
+        }
 
         await repository.UpdateAsync(system);
     }
-}
+}

+ 15 - 12
RackPeek.Domain/UseCases/AddResourceUseCase.cs

@@ -7,12 +7,12 @@ namespace RackPeek.Domain.UseCases;
 public interface IAddResourceUseCase<T> : IResourceUseCase<T>
     where T : Resource
 {
-    Task ExecuteAsync(string name, string? runsOn = null);
+    Task ExecuteAsync(string name, List<string>? runsOn = null);
 }
 
 public class AddResourceUseCase<T>(IResourceCollection repo) : IAddResourceUseCase<T> where T : Resource
 {
-    public async Task ExecuteAsync(string name, string? runsOn = null)
+    public async Task ExecuteAsync(string name, List<string>? runsOn = null)
     {
         name = Normalize.HardwareName(name);
         ThrowIfInvalid.ResourceName(name);
@@ -23,20 +23,23 @@ public class AddResourceUseCase<T>(IResourceCollection repo) : IAddResourceUseCa
 
         if (runsOn != null)
         {
-            runsOn = Normalize.HardwareName(runsOn);
-            ThrowIfInvalid.ResourceName(runsOn);
-            var parentResource = await repo.GetByNameAsync(runsOn);
-            if (parentResource == null) throw new NotFoundException($"Resource '{runsOn}' not found.");
-
-            if (!Resource.CanRunOn<T>(parentResource))
-                throw new InvalidOperationException(
-                    $" {Resource.GetKind<T>()} cannot run on {parentResource.Kind} '{runsOn}'.");
+
+            foreach (var parent in runsOn) {
+                var normalizedParent = Normalize.HardwareName(parent);
+                ThrowIfInvalid.ResourceName(normalizedParent);
+                var parentResource = await repo.GetByNameAsync(normalizedParent);
+                if (parentResource == null) throw new NotFoundException($"Resource '{normalizedParent}' not found.");
+
+                if (!Resource.CanRunOn<T>(parentResource))
+                    throw new InvalidOperationException(
+                        $" {Resource.GetKind<T>()} cannot run on {parentResource.Kind} '{normalizedParent}'.");
+            }
         }
 
         var resource = Activator.CreateInstance<T>();
         resource.Name = name;
-        resource.RunsOn = runsOn;
+        resource.RunsOn = runsOn ?? new List<string>();
 
         await repo.AddAsync(resource);
     }
-}
+}

+ 5 - 13
RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs

@@ -1,7 +1,7 @@
-namespace RackPeek.Domain.Ansible;
-
 using System.Text;
-using Resources;
+using RackPeek.Domain.Resources;
+
+namespace RackPeek.Domain.UseCases.Ansible;
 
 public sealed record InventoryOptions
 {
@@ -166,20 +166,12 @@ public static class AnsibleInventoryGenerator
             }
         }
 
-        // Record your relationship info if present
-        if (!string.IsNullOrWhiteSpace(r.RunsOn))
-            vars["rackpeek_runs_on"] = r.RunsOn!;
-
-        // If you want tags/labels available to playbooks, export them too:
-        // vars["rackpeek_kind"] = r.Kind;
-        // vars["rackpeek_tags"] = string.Join(",", r.Tags ?? Array.Empty<string>());
-
         return vars;
     }
 
     private static string SanitizeGroup(string s)
     {
-        // Ansible group names: keep it simple: letters/digits/underscore
+        // Ansible group names: letters/digits/underscore
         var sb = new StringBuilder(s.Length);
         foreach (var ch in s.Trim().ToLowerInvariant())
         {
@@ -196,7 +188,7 @@ public static class AnsibleInventoryGenerator
 
     private static string EscapeIniValue(string value)
     {
-        // Keep simple: quote if it contains spaces or special chars
+        // quote if it contains spaces or special chars
         if (string.IsNullOrEmpty(value)) return "\"\"";
 
         var needsQuotes = value.Any(ch => char.IsWhiteSpace(ch) || ch is '"' or '\'' or '=');

+ 2 - 4
RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGeneratorUseCase.cs

@@ -1,9 +1,7 @@
-using RackPeek.Domain.Ansible;
-using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Persistence;
-using RackPeek.Domain.Resources.SubResources;
+using RackPeek.Domain.Resources;
 
-namespace RackPeek.Domain.Resources.Desktops;
+namespace RackPeek.Domain.UseCases.Ansible;
 
 public class AnsibleInventoryGeneratorUseCase(IResourceCollection repository) : IUseCase
 {

+ 1 - 1
RackPeek.Domain/UseCases/Ports/UpdatePortUseCase.cs

@@ -49,4 +49,4 @@ public class UpdatePortUseCase<T>(IResourceCollection repository) : IUpdatePortU
 
         await repository.UpdateAsync(resource);
     }
-}
+}

+ 2 - 2
RackPeek.Domain/UseCases/RenameResourceUseCase.cs

@@ -33,8 +33,8 @@ public class RenameResourceUseCase<T>(IResourceCollection repo) : IRenameResourc
         var children = await repo.GetDependantsAsync(originalName);
         foreach (var child in children)
         {
-            child.RunsOn = newName;
+            child.RunsOn = child.RunsOn.ConvertAll<string>(p => p == originalName ? newName : p);
             await repo.UpdateAsync(child);
         }
     }
-}
+}

+ 212 - 152
RackPeek.Web.Viewer/wwwroot/schemas/v1/schema.v1.json

@@ -1,34 +1,54 @@
 {
-  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
   "$id": "https://timmoth.github.io/RackPeek/schemas/v1/schema.v1.json",
   "title": "RackPeek Infrastructure Specification",
   "type": "object",
   "additionalProperties": false,
   "required": ["version", "resources"],
   "properties": {
-    "version": {
-      "type": "integer",
-      "const": 1
-    },
+    "version": { "type": "integer", "const": 1 },
     "resources": {
       "type": "array",
-      "items": {
-        "oneOf": [
-          { "$ref": "#/$defs/server" },
-          { "$ref": "#/$defs/firewall" },
-          { "$ref": "#/$defs/router" },
-          { "$ref": "#/$defs/switch" },
-          { "$ref": "#/$defs/accessPoint" },
-          { "$ref": "#/$defs/ups" },
-          { "$ref": "#/$defs/desktop" },
-          { "$ref": "#/$defs/laptop" },
-          { "$ref": "#/$defs/service" },
-          { "$ref": "#/$defs/system" }
-        ]
-      }
+      "items": { "$ref": "#/$defs/resource" }
     }
   },
+
   "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": { "type": "string" }
+    },
+
+    "resourceBase": {
+      "type": "object",
+      "required": ["kind", "name"],
+      "properties": {
+        "kind": { "type": "string" },
+        "name": { "type": "string", "minLength": 1 },
+
+        "tags": { "type": "array", "items": { "type": "string" }, "default": [] },
+        "labels": { "$ref": "#/$defs/labels", "default": {} },
+        "notes": { "type": ["string", "null"] },
+
+        "runsOn": { "type": ["string", "null"] }
+      }
+    },
+
+    "resource": {
+      "oneOf": [
+        { "$ref": "#/$defs/server" },
+        { "$ref": "#/$defs/firewall" },
+        { "$ref": "#/$defs/router" },
+        { "$ref": "#/$defs/switch" },
+        { "$ref": "#/$defs/accessPoint" },
+        { "$ref": "#/$defs/ups" },
+        { "$ref": "#/$defs/desktop" },
+        { "$ref": "#/$defs/laptop" },
+        { "$ref": "#/$defs/service" },
+        { "$ref": "#/$defs/system" }
+      ]
+    },
+
     "ram": {
       "type": "object",
       "required": ["size"],
@@ -38,6 +58,7 @@
         "mts": { "type": "integer", "minimum": 0 }
       }
     },
+
     "cpu": {
       "type": "object",
       "additionalProperties": false,
@@ -47,6 +68,7 @@
         "threads": { "type": "integer", "minimum": 1 }
       }
     },
+
     "drive": {
       "type": "object",
       "required": ["size"],
@@ -54,11 +76,12 @@
       "properties": {
         "type": {
           "type": "string",
-          "enum": ["nvme","ssd","hdd","sas","sata","usb","sdcard","micro-sd"]
+          "enum": ["nvme", "ssd", "hdd", "sas", "sata", "usb", "sdcard", "micro-sd"]
         },
         "size": { "type": "number", "minimum": 1 }
       }
     },
+
     "gpu": {
       "type": "object",
       "additionalProperties": false,
@@ -67,6 +90,7 @@
         "vram": { "type": "number", "minimum": 0 }
       }
     },
+
     "nic": {
       "type": "object",
       "additionalProperties": false,
@@ -74,18 +98,19 @@
         "type": {
           "type": "string",
           "enum": [
-            "rj45","sfp","sfp+","sfp28","sfp56",
-            "qsfp+","qsfp28","qsfp56","qsfp-dd",
-            "osfp","xfp","cx4","mgmt"
+            "rj45", "sfp", "sfp+", "sfp28", "sfp56",
+            "qsfp+", "qsfp28", "qsfp56", "qsfp-dd",
+            "osfp", "xfp", "cx4", "mgmt"
           ]
         },
         "speed": { "type": "number", "minimum": 0 },
         "ports": { "type": "integer", "minimum": 1 }
       }
     },
+
     "port": {
       "type": "object",
-      "required": ["type","speed","count"],
+      "required": ["type", "speed", "count"],
       "additionalProperties": false,
       "properties": {
         "type": { "type": "string" },
@@ -93,173 +118,208 @@
         "count": { "type": "integer", "minimum": 1 }
       }
     },
+
     "network": {
       "type": "object",
-      "required": ["ip","port","protocol"],
+      "required": ["ip", "port", "protocol"],
       "additionalProperties": false,
       "properties": {
         "ip": {
           "type": "string",
-          "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}$"
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
         },
         "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
-        "protocol": { "type": "string", "enum": ["TCP","UDP"] },
-        "url": { "type": "string" }
+        "protocol": { "type": "string", "enum": ["TCP", "UDP"] },
+        "url": { "type": "string", "format": "uri" }
       }
     },
 
     "server": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Server" },
-        "name": { "type": "string" },
-        "tags": { "type": "array", "items": { "type": "string" } },
-        "notes": { "type": "string" },
-        "runsOn": { "type": "string" },
-        "ram": { "$ref": "#/$defs/ram" },
-        "ipmi": { "type": "boolean" },
-        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
-        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
-        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Server" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "ipmi": { "type": "boolean" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "desktop": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Desktop" },
-        "name": { "type": "string" },
-        "notes": { "type": "string" },
-        "ram": { "$ref": "#/$defs/ram" },
-        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
-        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
-        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Desktop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "laptop": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Laptop" },
-        "name": { "type": "string" },
-        "notes": { "type": "string" },
-        "ram": { "$ref": "#/$defs/ram" },
-        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Laptop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "firewall": {
-      "type": "object",
-      "required": ["kind","name","ports"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Firewall" },
-        "name": { "type": "string" },
-        "model": { "type": "string" },
-        "managed": { "type": "boolean" },
-        "poe": { "type": "boolean" },
-        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Firewall" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "router": {
-      "type": "object",
-      "required": ["kind","name","ports"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Router" },
-        "name": { "type": "string" },
-        "model": { "type": "string" },
-        "managed": { "type": "boolean" },
-        "poe": { "type": "boolean" },
-        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Router" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "switch": {
-      "type": "object",
-      "required": ["kind","name","ports"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Switch" },
-        "name": { "type": "string" },
-        "model": { "type": "string" },
-        "managed": { "type": "boolean" },
-        "poe": { "type": "boolean" },
-        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Switch" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "accessPoint": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "AccessPoint" },
-        "name": { "type": "string" },
-        "tags": { "type": "array", "items": { "type": "string" } },
-        "model": { "type": "string" },
-        "speed": { "type": "number" }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "AccessPoint" },
+
+            "model": { "type": "string" },
+            "speed": { "type": "number", "minimum": 0 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "ups": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Ups" },
-        "name": { "type": "string" },
-        "tags": { "type": "array", "items": { "type": "string" } },
-        "model": { "type": "string" },
-        "va": { "type": "integer", "minimum": 1 }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Ups" },
+
+            "model": { "type": "string" },
+            "va": { "type": "integer", "minimum": 1 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "service": {
-      "type": "object",
-      "required": ["kind","name","network","runsOn"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Service" },
-        "name": { "type": "string" },
-        "runsOn": { "type": "string" },
-        "network": { "$ref": "#/$defs/network" }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["network"],
+          "properties": {
+            "kind": { "const": "Service" },
+            "network": { "$ref": "#/$defs/network" }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "system": {
-      "type": "object",
-      "required": ["kind","name","type","os","cores","ram"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "System" },
-        "name": { "type": "string" },
-        "notes": { "type": "string" },
-        "runsOn": { "type": "string" },
-        "type": {
-          "type": "string",
-          "enum": [
-            "baremetal","Baremetal",
-            "hypervisor","Hypervisor",
-            "vm","VM",
-            "container","embedded","cloud","other"
-          ]
-        },
-        "os": { "type": "string" },
-        "cores": { "type": "integer", "minimum": 1 },
-        "ram": { "type": "number", "minimum": 0 },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["type", "os", "cores", "ram"],
+          "properties": {
+            "kind": { "const": "System" },
+
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal", "Baremetal",
+                "hypervisor", "Hypervisor",
+                "vm", "VM",
+                "container", "embedded", "cloud", "other"
+              ]
+            },
+            "os": { "type": "string" },
+            "cores": { "type": "integer", "minimum": 1 },
+            "ram": { "type": "number", "minimum": 0 },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     }
   }
 }

+ 336 - 0
RackPeek.Web.Viewer/wwwroot/schemas/v2/schema.v2.json

@@ -0,0 +1,336 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v2/schema.v2.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["version", "resources"],
+  "properties": {
+    "version": { "type": "integer", "const": 2 },
+    "resources": {
+      "type": "array",
+      "items": { "$ref": "#/$defs/resource" }
+    }
+  },
+
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": { "type": "string" }
+    },
+
+    "runsOn": {
+      "type": ["array", "null"],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+
+    "resourceBase": {
+      "type": "object",
+      "required": ["kind", "name"],
+      "properties": {
+        "kind": { "type": "string" },
+        "name": { "type": "string", "minLength": 1 },
+
+        "tags": { "type": "array", "items": { "type": "string" }, "default": [] },
+        "labels": { "$ref": "#/$defs/labels", "default": {} },
+        "notes": { "type": ["string", "null"] },
+
+        "runsOn": {
+          "type": ["array", "null"],
+          "items": { "type": "string" }
+        }
+      }
+    },
+
+    "resource": {
+      "oneOf": [
+        { "$ref": "#/$defs/server" },
+        { "$ref": "#/$defs/firewall" },
+        { "$ref": "#/$defs/router" },
+        { "$ref": "#/$defs/switch" },
+        { "$ref": "#/$defs/accessPoint" },
+        { "$ref": "#/$defs/ups" },
+        { "$ref": "#/$defs/desktop" },
+        { "$ref": "#/$defs/laptop" },
+        { "$ref": "#/$defs/service" },
+        { "$ref": "#/$defs/system" }
+      ]
+    },
+
+    "ram": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "size": { "type": "number", "minimum": 0 },
+        "mts": { "type": "integer", "minimum": 0 }
+      }
+    },
+
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "cores": { "type": "integer", "minimum": 1 },
+        "threads": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "drive": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": ["nvme", "ssd", "hdd", "sas", "sata", "usb", "sdcard", "micro-sd"]
+        },
+        "size": { "type": "number", "minimum": 1 }
+      }
+    },
+
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "vram": { "type": "number", "minimum": 0 }
+      }
+    },
+
+    "nic": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45", "sfp", "sfp+", "sfp28", "sfp56",
+            "qsfp+", "qsfp28", "qsfp56", "qsfp-dd",
+            "osfp", "xfp", "cx4", "mgmt"
+          ]
+        },
+        "speed": { "type": "number", "minimum": 0 },
+        "ports": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "port": {
+      "type": "object",
+      "required": ["type", "speed", "count"],
+      "additionalProperties": false,
+      "properties": {
+        "type": { "type": "string" },
+        "speed": { "type": "number", "minimum": 0 },
+        "count": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "network": {
+      "type": "object",
+      "required": ["ip", "port", "protocol"],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
+        "protocol": { "type": "string", "enum": ["TCP", "UDP"] },
+        "url": { "type": "string", "format": "uri" }
+      }
+    },
+
+    "server": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Server" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "ipmi": { "type": "boolean" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "desktop": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Desktop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "laptop": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Laptop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "firewall": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Firewall" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "router": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Router" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "switch": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Switch" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "accessPoint": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "AccessPoint" },
+
+            "model": { "type": "string" },
+            "speed": { "type": "number", "minimum": 0 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "ups": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Ups" },
+
+            "model": { "type": "string" },
+            "va": { "type": "integer", "minimum": 1 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "service": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["network"],
+          "properties": {
+            "kind": { "const": "Service" },
+            "network": { "$ref": "#/$defs/network" }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "system": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["type", "os", "cores", "ram"],
+          "properties": {
+            "kind": { "const": "System" },
+
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal", "Baremetal",
+                "hypervisor", "Hypervisor",
+                "vm", "VM",
+                "container", "embedded", "cloud", "other"
+              ]
+            },
+            "os": { "type": "string" },
+            "cores": { "type": "integer", "minimum": 1 },
+            "ram": { "type": "number", "minimum": 0 },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}

+ 212 - 152
RackPeek.Web/wwwroot/schemas/v1/schema.v1.json

@@ -1,34 +1,54 @@
 {
-  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
   "$id": "https://timmoth.github.io/RackPeek/schemas/v1/schema.v1.json",
   "title": "RackPeek Infrastructure Specification",
   "type": "object",
   "additionalProperties": false,
   "required": ["version", "resources"],
   "properties": {
-    "version": {
-      "type": "integer",
-      "const": 1
-    },
+    "version": { "type": "integer", "const": 1 },
     "resources": {
       "type": "array",
-      "items": {
-        "oneOf": [
-          { "$ref": "#/$defs/server" },
-          { "$ref": "#/$defs/firewall" },
-          { "$ref": "#/$defs/router" },
-          { "$ref": "#/$defs/switch" },
-          { "$ref": "#/$defs/accessPoint" },
-          { "$ref": "#/$defs/ups" },
-          { "$ref": "#/$defs/desktop" },
-          { "$ref": "#/$defs/laptop" },
-          { "$ref": "#/$defs/service" },
-          { "$ref": "#/$defs/system" }
-        ]
-      }
+      "items": { "$ref": "#/$defs/resource" }
     }
   },
+
   "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": { "type": "string" }
+    },
+
+    "resourceBase": {
+      "type": "object",
+      "required": ["kind", "name"],
+      "properties": {
+        "kind": { "type": "string" },
+        "name": { "type": "string", "minLength": 1 },
+
+        "tags": { "type": "array", "items": { "type": "string" }, "default": [] },
+        "labels": { "$ref": "#/$defs/labels", "default": {} },
+        "notes": { "type": ["string", "null"] },
+
+        "runsOn": { "type": ["string", "null"] }
+      }
+    },
+
+    "resource": {
+      "oneOf": [
+        { "$ref": "#/$defs/server" },
+        { "$ref": "#/$defs/firewall" },
+        { "$ref": "#/$defs/router" },
+        { "$ref": "#/$defs/switch" },
+        { "$ref": "#/$defs/accessPoint" },
+        { "$ref": "#/$defs/ups" },
+        { "$ref": "#/$defs/desktop" },
+        { "$ref": "#/$defs/laptop" },
+        { "$ref": "#/$defs/service" },
+        { "$ref": "#/$defs/system" }
+      ]
+    },
+
     "ram": {
       "type": "object",
       "required": ["size"],
@@ -38,6 +58,7 @@
         "mts": { "type": "integer", "minimum": 0 }
       }
     },
+
     "cpu": {
       "type": "object",
       "additionalProperties": false,
@@ -47,6 +68,7 @@
         "threads": { "type": "integer", "minimum": 1 }
       }
     },
+
     "drive": {
       "type": "object",
       "required": ["size"],
@@ -54,11 +76,12 @@
       "properties": {
         "type": {
           "type": "string",
-          "enum": ["nvme","ssd","hdd","sas","sata","usb","sdcard","micro-sd"]
+          "enum": ["nvme", "ssd", "hdd", "sas", "sata", "usb", "sdcard", "micro-sd"]
         },
         "size": { "type": "number", "minimum": 1 }
       }
     },
+
     "gpu": {
       "type": "object",
       "additionalProperties": false,
@@ -67,6 +90,7 @@
         "vram": { "type": "number", "minimum": 0 }
       }
     },
+
     "nic": {
       "type": "object",
       "additionalProperties": false,
@@ -74,18 +98,19 @@
         "type": {
           "type": "string",
           "enum": [
-            "rj45","sfp","sfp+","sfp28","sfp56",
-            "qsfp+","qsfp28","qsfp56","qsfp-dd",
-            "osfp","xfp","cx4","mgmt"
+            "rj45", "sfp", "sfp+", "sfp28", "sfp56",
+            "qsfp+", "qsfp28", "qsfp56", "qsfp-dd",
+            "osfp", "xfp", "cx4", "mgmt"
           ]
         },
         "speed": { "type": "number", "minimum": 0 },
         "ports": { "type": "integer", "minimum": 1 }
       }
     },
+
     "port": {
       "type": "object",
-      "required": ["type","speed","count"],
+      "required": ["type", "speed", "count"],
       "additionalProperties": false,
       "properties": {
         "type": { "type": "string" },
@@ -93,173 +118,208 @@
         "count": { "type": "integer", "minimum": 1 }
       }
     },
+
     "network": {
       "type": "object",
-      "required": ["ip","port","protocol"],
+      "required": ["ip", "port", "protocol"],
       "additionalProperties": false,
       "properties": {
         "ip": {
           "type": "string",
-          "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}$"
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
         },
         "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
-        "protocol": { "type": "string", "enum": ["TCP","UDP"] },
-        "url": { "type": "string" }
+        "protocol": { "type": "string", "enum": ["TCP", "UDP"] },
+        "url": { "type": "string", "format": "uri" }
       }
     },
 
     "server": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Server" },
-        "name": { "type": "string" },
-        "tags": { "type": "array", "items": { "type": "string" } },
-        "notes": { "type": "string" },
-        "runsOn": { "type": "string" },
-        "ram": { "$ref": "#/$defs/ram" },
-        "ipmi": { "type": "boolean" },
-        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
-        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
-        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Server" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "ipmi": { "type": "boolean" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "desktop": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Desktop" },
-        "name": { "type": "string" },
-        "notes": { "type": "string" },
-        "ram": { "$ref": "#/$defs/ram" },
-        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
-        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
-        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Desktop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "laptop": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Laptop" },
-        "name": { "type": "string" },
-        "notes": { "type": "string" },
-        "ram": { "$ref": "#/$defs/ram" },
-        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Laptop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "firewall": {
-      "type": "object",
-      "required": ["kind","name","ports"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Firewall" },
-        "name": { "type": "string" },
-        "model": { "type": "string" },
-        "managed": { "type": "boolean" },
-        "poe": { "type": "boolean" },
-        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Firewall" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "router": {
-      "type": "object",
-      "required": ["kind","name","ports"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Router" },
-        "name": { "type": "string" },
-        "model": { "type": "string" },
-        "managed": { "type": "boolean" },
-        "poe": { "type": "boolean" },
-        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Router" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "switch": {
-      "type": "object",
-      "required": ["kind","name","ports"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Switch" },
-        "name": { "type": "string" },
-        "model": { "type": "string" },
-        "managed": { "type": "boolean" },
-        "poe": { "type": "boolean" },
-        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Switch" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "accessPoint": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "AccessPoint" },
-        "name": { "type": "string" },
-        "tags": { "type": "array", "items": { "type": "string" } },
-        "model": { "type": "string" },
-        "speed": { "type": "number" }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "AccessPoint" },
+
+            "model": { "type": "string" },
+            "speed": { "type": "number", "minimum": 0 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "ups": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Ups" },
-        "name": { "type": "string" },
-        "tags": { "type": "array", "items": { "type": "string" } },
-        "model": { "type": "string" },
-        "va": { "type": "integer", "minimum": 1 }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Ups" },
+
+            "model": { "type": "string" },
+            "va": { "type": "integer", "minimum": 1 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "service": {
-      "type": "object",
-      "required": ["kind","name","network","runsOn"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Service" },
-        "name": { "type": "string" },
-        "runsOn": { "type": "string" },
-        "network": { "$ref": "#/$defs/network" }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["network"],
+          "properties": {
+            "kind": { "const": "Service" },
+            "network": { "$ref": "#/$defs/network" }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "system": {
-      "type": "object",
-      "required": ["kind","name","type","os","cores","ram"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "System" },
-        "name": { "type": "string" },
-        "notes": { "type": "string" },
-        "runsOn": { "type": "string" },
-        "type": {
-          "type": "string",
-          "enum": [
-            "baremetal","Baremetal",
-            "hypervisor","Hypervisor",
-            "vm","VM",
-            "container","embedded","cloud","other"
-          ]
-        },
-        "os": { "type": "string" },
-        "cores": { "type": "integer", "minimum": 1 },
-        "ram": { "type": "number", "minimum": 0 },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["type", "os", "cores", "ram"],
+          "properties": {
+            "kind": { "const": "System" },
+
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal", "Baremetal",
+                "hypervisor", "Hypervisor",
+                "vm", "VM",
+                "container", "embedded", "cloud", "other"
+              ]
+            },
+            "os": { "type": "string" },
+            "cores": { "type": "integer", "minimum": 1 },
+            "ram": { "type": "number", "minimum": 0 },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     }
   }
 }

+ 336 - 0
RackPeek.Web/wwwroot/schemas/v2/schema.v2.json

@@ -0,0 +1,336 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v2/schema.v2.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["version", "resources"],
+  "properties": {
+    "version": { "type": "integer", "const": 2 },
+    "resources": {
+      "type": "array",
+      "items": { "$ref": "#/$defs/resource" }
+    }
+  },
+
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": { "type": "string" }
+    },
+
+    "runsOn": {
+      "type": ["array", "null"],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+
+    "resourceBase": {
+      "type": "object",
+      "required": ["kind", "name"],
+      "properties": {
+        "kind": { "type": "string" },
+        "name": { "type": "string", "minLength": 1 },
+
+        "tags": { "type": "array", "items": { "type": "string" }, "default": [] },
+        "labels": { "$ref": "#/$defs/labels", "default": {} },
+        "notes": { "type": ["string", "null"] },
+
+        "runsOn": {
+          "type": ["array", "null"],
+          "items": { "type": "string" }
+        }
+      }
+    },
+
+    "resource": {
+      "oneOf": [
+        { "$ref": "#/$defs/server" },
+        { "$ref": "#/$defs/firewall" },
+        { "$ref": "#/$defs/router" },
+        { "$ref": "#/$defs/switch" },
+        { "$ref": "#/$defs/accessPoint" },
+        { "$ref": "#/$defs/ups" },
+        { "$ref": "#/$defs/desktop" },
+        { "$ref": "#/$defs/laptop" },
+        { "$ref": "#/$defs/service" },
+        { "$ref": "#/$defs/system" }
+      ]
+    },
+
+    "ram": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "size": { "type": "number", "minimum": 0 },
+        "mts": { "type": "integer", "minimum": 0 }
+      }
+    },
+
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "cores": { "type": "integer", "minimum": 1 },
+        "threads": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "drive": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": ["nvme", "ssd", "hdd", "sas", "sata", "usb", "sdcard", "micro-sd"]
+        },
+        "size": { "type": "number", "minimum": 1 }
+      }
+    },
+
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "vram": { "type": "number", "minimum": 0 }
+      }
+    },
+
+    "nic": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45", "sfp", "sfp+", "sfp28", "sfp56",
+            "qsfp+", "qsfp28", "qsfp56", "qsfp-dd",
+            "osfp", "xfp", "cx4", "mgmt"
+          ]
+        },
+        "speed": { "type": "number", "minimum": 0 },
+        "ports": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "port": {
+      "type": "object",
+      "required": ["type", "speed", "count"],
+      "additionalProperties": false,
+      "properties": {
+        "type": { "type": "string" },
+        "speed": { "type": "number", "minimum": 0 },
+        "count": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "network": {
+      "type": "object",
+      "required": ["ip", "port", "protocol"],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
+        "protocol": { "type": "string", "enum": ["TCP", "UDP"] },
+        "url": { "type": "string", "format": "uri" }
+      }
+    },
+
+    "server": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Server" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "ipmi": { "type": "boolean" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "desktop": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Desktop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "laptop": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Laptop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "firewall": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Firewall" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "router": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Router" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "switch": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Switch" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "accessPoint": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "AccessPoint" },
+
+            "model": { "type": "string" },
+            "speed": { "type": "number", "minimum": 0 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "ups": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Ups" },
+
+            "model": { "type": "string" },
+            "va": { "type": "integer", "minimum": 1 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "service": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["network"],
+          "properties": {
+            "kind": { "const": "Service" },
+            "network": { "$ref": "#/$defs/network" }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "system": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["type", "os", "cores", "ram"],
+          "properties": {
+            "kind": { "const": "System" },
+
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal", "Baremetal",
+                "hypervisor", "Hypervisor",
+                "vm", "VM",
+                "container", "embedded", "cloud", "other"
+              ]
+            },
+            "os": { "type": "string" },
+            "cores": { "type": "integer", "minimum": 1 },
+            "ram": { "type": "number", "minimum": 0 },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}

+ 1 - 1
Shared.Rcl/Commands/Ansible/GenerateAnsibleInventoryCommand.cs

@@ -1,9 +1,9 @@
 using RackPeek.Domain.Resources.Desktops;
 using Microsoft.Extensions.DependencyInjection;
-using RackPeek.Domain.Ansible;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using System.ComponentModel;
+using RackPeek.Domain.UseCases.Ansible;
 
 namespace Shared.Rcl.Commands.Ansible;
 

+ 2 - 2
Shared.Rcl/Commands/Services/ServiceDescribeCommand.cs

@@ -33,7 +33,7 @@ public class ServiceDescribeCommand(
         grid.AddRow("Protocol:", service.Protocol ?? "Unknown");
         grid.AddRow("Url:", service.Url ?? "Unknown");
         grid.AddRow("Runs On:",
-            ServicesFormatExtensions.FormatRunsOn(service.RunsOnSystemHost, service.RunsOnPhysicalHost));
+            ServicesFormatExtensions.FormatRunsOn(string.Join(", ", service.RunsOnSystemHost), string.Join(", ", service.RunsOnPhysicalHost)));
 
         if (service.Labels.Count > 0)
             grid.AddRow("Labels:", string.Join(", ", service.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
@@ -45,4 +45,4 @@ public class ServiceDescribeCommand(
 
         return 0;
     }
-}
+}

+ 5 - 2
Shared.Rcl/Commands/Services/ServiceGetByNameCommand.cs

@@ -19,8 +19,11 @@ public class ServiceGetByNameCommand(
 
         var service = await useCase.ExecuteAsync(settings.Name);
 
+        var sys = string.Join(", ", service.RunsOnSystemHost);
+        var phys = string.Join(", ", service.RunsOnPhysicalHost);
+
         AnsiConsole.MarkupLine(
-            $"[green]{service.Name}[/]  Ip: {service.Ip ?? "Unknown"}, Port: {service.Port.ToString() ?? "Unknown"}, Protocol: {service.Protocol ?? "Unknown"}, Url: {service.Url ?? "Unknown"}, RunsOn: {ServicesFormatExtensions.FormatRunsOn(service.RunsOnSystemHost, service.RunsOnPhysicalHost)}");
+            $"[green]{service.Name}[/]  Ip: {service.Ip ?? "Unknown"}, Port: {service.Port.ToString() ?? "Unknown"}, Protocol: {service.Protocol ?? "Unknown"}, Url: {service.Url ?? "Unknown"}, RunsOn: {ServicesFormatExtensions.FormatRunsOn(sys, phys)}");
         return 0;
     }
-}
+}

+ 16 - 2
Shared.Rcl/Commands/Services/ServiceGetCommand.cs

@@ -34,16 +34,30 @@ public class ServiceGetCommand(
             .AddColumn("Runs On");
 
         foreach (var s in report.Services)
+        {
+            string? sys = null;
+            string? phys = null;
+            
+            if (s.RunsOnSystemHost is not null)
+            {
+                sys = string.Join(", ", s.RunsOnSystemHost);
+            }
+            if (s.RunsOnPhysicalHost is not null)
+            {
+                phys = string.Join(", ", s.RunsOnPhysicalHost);
+            }
+
             table.AddRow(
                 s.Name,
                 s.Ip ?? "",
                 s.Port.ToString() ?? "",
                 s.Protocol ?? "",
                 s.Url ?? "",
-                ServicesFormatExtensions.FormatRunsOn(s.RunsOnSystemHost, s.RunsOnPhysicalHost)
+                ServicesFormatExtensions.FormatRunsOn(sys, phys)
             );
+        }
 
         AnsiConsole.Write(table);
         return 0;
     }
-}
+}

+ 16 - 2
Shared.Rcl/Commands/Services/ServiceReportCommand.cs

@@ -34,16 +34,30 @@ public class ServiceReportCommand(
             .AddColumn("Runs On");
 
         foreach (var s in report.Services)
+        {
+            string? sys = null;
+            string? phys = null;
+
+            if (s.RunsOnSystemHost?.Count > 0 )
+            {
+                sys = string.Join(", ", s.RunsOnSystemHost);
+            }
+            if (s.RunsOnPhysicalHost?.Count > 0 )
+            {
+                phys = string.Join(", ", s.RunsOnPhysicalHost);
+            }
+
             table.AddRow(
                 s.Name,
                 s.Ip ?? "",
                 s.Port.ToString() ?? "",
                 s.Protocol ?? "",
                 s.Url ?? "",
-                ServicesFormatExtensions.FormatRunsOn(s.RunsOnSystemHost, s.RunsOnPhysicalHost)
+                ServicesFormatExtensions.FormatRunsOn(sys, phys)
             );
+        }
 
         AnsiConsole.Write(table);
         return 0;
     }
-}
+}

+ 5 - 5
Shared.Rcl/Commands/Services/ServiceSetCommand.cs

@@ -25,9 +25,9 @@ public class ServiceSetSettings : ServerNameSettings
     [Description("The service URL.")]
     public string? Url { get; set; }
 
-    [CommandOption("--runs-on")]
-    [Description("The system the service is running on.")]
-    public string? RunsOn { get; set; }
+    [CommandOption("--runs-on <RUNSON>")]
+    [Description("The system(s) the service is running on.")]
+    public string[]? RunsOn { get; set; }
 }
 
 public class ServiceSetCommand(
@@ -48,10 +48,10 @@ public class ServiceSetCommand(
             settings.Port,
             settings.Protocol,
             settings.Url,
-            settings.RunsOn
+            settings.RunsOn?.ToList()
         );
 
         AnsiConsole.MarkupLine($"[green]Service '{settings.Name}' updated.[/]");
         return 0;
     }
-}
+}

+ 10 - 2
Shared.Rcl/Commands/Services/ServiceSubnetsCommand.cs

@@ -79,7 +79,15 @@ public class ServiceSubnetsCommand(
                 .AddColumn("Runs On");
 
             foreach (var s in services)
-                table.AddRow(s.Name, s.Ip, s.RunsOn ?? "Unknown");
+            {
+                var runsOn = "";
+                if (s.RunsOn?.Count > 0)
+                {
+                    runsOn = string.Join(", ", s.RunsOn);
+                }
+
+                table.AddRow(s.Name, s.Ip, runsOn);
+            }
 
             AnsiConsole.MarkupLine($"[green]Services in {result.FilteredCidr}[/]");
             AnsiConsole.Write(table);
@@ -137,4 +145,4 @@ public class ServiceSubnetsCommand(
 
         [CommandOption("--prefix <PREFIX>")] public int? Prefix { get; set; }
     }
-}
+}

+ 1 - 1
Shared.Rcl/Commands/Services/ServicesFormatExtensions.cs

@@ -4,7 +4,7 @@ public static class ServicesFormatExtensions
 {
     public static string FormatRunsOn(string? runsOn, string? runsOnHost)
     {
-        if (string.IsNullOrEmpty(runsOn) && string.IsNullOrEmpty(runsOnHost)) return "Unknown";
+        if (string.IsNullOrEmpty(runsOn) && string.IsNullOrEmpty(runsOnHost)) return "";
 
         if (string.IsNullOrEmpty(runsOn)) return runsOnHost!;
 

+ 3 - 2
Shared.Rcl/Commands/Systems/SystemDescribeCommand.cs

@@ -29,7 +29,8 @@ public class SystemDescribeCommand(
         grid.AddRow("Cores:", system.Cores.ToString());
         grid.AddRow("RAM (GB):", system.RamGb.ToString());
         grid.AddRow("Total Storage (GB):", system.TotalStorageGb.ToString());
-        grid.AddRow("Runs On:", system.RunsOn ?? "Unknown");
+        grid.AddRow("Runs On:", string.Join(", ", system.RunsOn) ?? "Unknown");
+                
 
         if (system.Labels.Count > 0)
             grid.AddRow("Labels:", string.Join(", ", system.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
@@ -41,4 +42,4 @@ public class SystemDescribeCommand(
 
         return 0;
     }
-}
+}

+ 2 - 2
Shared.Rcl/Commands/Systems/SystemGetByNameCommand.cs

@@ -20,8 +20,8 @@ public class SystemGetByNameCommand(
         var system = await useCase.ExecuteAsync(settings.Name);
         AnsiConsole.MarkupLine(
             $"[green]{system.Name}[/]  Type: {system.Type ?? "Unknown"}, OS: {system.Os ?? "Unknown"}, " +
-            $"Cores: {system.Cores}, RAM: {system.RamGb}GB, Storage: {system.TotalStorageGb}GB, RunsOn: {system.RunsOn ?? "Unknown"}");
+            $"Cores: {system.Cores}, RAM: {system.RamGb}GB, Storage: {system.TotalStorageGb}GB, RunsOn: {string.Join(", ", system.RunsOn!)}");
 
         return 0;
     }
-}
+}

+ 2 - 2
Shared.Rcl/Commands/Systems/SystemGetCommand.cs

@@ -42,10 +42,10 @@ public class SystemGetCommand(
                 s.Cores.ToString(),
                 s.RamGb.ToString(),
                 s.TotalStorageGb.ToString(),
-                s.RunsOn ?? "Unknown"
+                string.Join(", ", s.RunsOn) ?? "Unkown"
             );
 
         AnsiConsole.Write(table);
         return 0;
     }
-}
+}

+ 2 - 2
Shared.Rcl/Commands/Systems/SystemReportCommand.cs

@@ -42,10 +42,10 @@ public class SystemReportCommand(
                 s.Cores.ToString(),
                 s.RamGb.ToString(),
                 s.TotalStorageGb.ToString(),
-                s.RunsOn ?? "Unknown"
+                string.Join(", ", s.RunsOn) ?? "Unknown"
             );
 
         AnsiConsole.Write(table);
         return 0;
     }
-}
+}

+ 7 - 3
Shared.Rcl/Commands/Systems/SystemSetCommand.cs

@@ -1,3 +1,4 @@
+using System.ComponentModel;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.SystemResources.UseCases;
 using Shared.Rcl.Commands.Servers;
@@ -16,7 +17,10 @@ public class SystemSetSettings : ServerNameSettings
 
     [CommandOption("--ram")] public int? Ram { get; set; }
 
-    [CommandOption("--runs-on")] public string? RunsOn { get; set; }
+    [CommandOption("--runs-on <RUNSON>")]
+    [Description("The physical machine(s) the service is running on.")]
+    public string[]? RunsOn { get; set; }
+    
 }
 
 public class SystemSetCommand(
@@ -37,10 +41,10 @@ public class SystemSetCommand(
             settings.Os,
             settings.Cores,
             settings.Ram,
-            settings.RunsOn
+            settings.RunsOn?.ToList()
         );
 
         AnsiConsole.MarkupLine($"[green]System '{settings.Name}' updated.[/]");
         return 0;
     }
-}
+}

+ 1 - 1
Shared.Rcl/Components/AddResourceComponent.razor

@@ -46,7 +46,7 @@
 
     [Parameter] public EventCallback<string> OnCreated { get; set; }
     [Parameter] public string? Placeholder { get; set; }
-    [Parameter] public string? RunsOn { get; set; }
+    [Parameter] public List<string>? RunsOn { get; set; }
 
     private string _name = string.Empty;
     private string? _error;

+ 1 - 2
Shared.Rcl/Components/AnsibleInventory.razor

@@ -1,7 +1,6 @@
 @page "/ansible/inventory"
-
-@using RackPeek.Domain.Ansible
 @using RackPeek.Domain.Resources.Desktops
+@using RackPeek.Domain.UseCases.Ansible
 @inject AnsibleInventoryGeneratorUseCase InventoryUseCase
 
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900 max-w-5xl mx-auto"

+ 41 - 0
Shared.Rcl/Components/CrumbLevel.razor

@@ -0,0 +1,41 @@
+<div class="flex flex-col justify-center">
+    @if (Items is null || Items.Count == 0)
+    {
+        
+    }
+    else if (Items.Count == 1)
+    {
+        <div class="flex items-center h-full">
+            <a class="hover:text-white transition-colors"
+               href="@Items[0].Href">
+                @Items[0].Label
+            </a>
+        </div>
+    }
+    else
+    {
+        <!-- Multi-parent stack -->
+        <div class="flex flex-col gap-0.5">
+            @for (var i = 0; i < Items.Count; i++)
+            {
+                var crumb = Items[i];
+                var isLast = i == Items.Count - 1;
+
+                <div class="flex items-center gap-1">
+                    <span class="text-zinc-600 select-none">
+                        @(isLast ? "└─" : "├─")
+                    </span>
+
+                    <a class="hover:text-white transition-colors"
+                       href="@crumb.Href">
+                        @crumb.Label
+                    </a>
+                </div>
+            }
+        </div>
+    }
+</div>
+
+@code {
+    [Parameter, EditorRequired] public List<ResourceBreadCrumbComponent.Breadcrumb> Items { get; set; } = new();
+}

+ 59 - 49
Shared.Rcl/Components/ResourceBreadCrumbComponent.razor

@@ -1,95 +1,105 @@
 @using RackPeek.Domain.Persistence
 @inject IResourceCollection Repo
 
-<div class="text-sm text-zinc-300 flex gap-1 items-center">
-    @foreach (var crumb in Breadcrumbs)
+<div class="text-sm text-zinc-300 flex items-stretch gap-3">
+    @for (var i = 0; i < Levels.Count; i++)
     {
-        <span class="text-zinc-500">/</span>
-        <a class="hover:text-white transition-colors"
-           href="@crumb.Href">
-            @crumb.Label
-        </a>
+        if (i > 0)
+        {
+            <div class="flex items-center text-zinc-500">/</div>
+        }
+
+        <div class="flex">
+            <CrumbLevel Items="Levels[i]" />
+        </div>
     }
 </div>
 
 @code {
     [Parameter] [EditorRequired] public ResourceType ResourceType { get; set; }
-
     [Parameter] [EditorRequired] public string ResourceName { get; set; } = default!;
-
-    private List<Breadcrumb> Breadcrumbs { get; } = new();
+    
+    private List<List<Breadcrumb>> Levels { get; } = new();
 
     protected override async Task OnParametersSetAsync()
     {
-        Breadcrumbs.Clear();
+        Levels.Clear();
 
         switch (ResourceType)
         {
             case ResourceType.Hardware:
-                await BuildHardwarePath(ResourceName);
+                AddLevel(new Breadcrumb(ResourceName, $"resources/hardware/{Uri.EscapeDataString(ResourceName)}"));
                 break;
 
             case ResourceType.System:
-                await BuildSystemPath(ResourceName);
+                await BuildSystem(ResourceName);
                 break;
 
             case ResourceType.Service:
-                await BuildServicePath(ResourceName);
+                await BuildService(ResourceName);
                 break;
         }
     }
 
-    private async Task BuildHardwarePath(string hardwareName)
+    private void AddLevel(params Breadcrumb[] items)
+        => Levels.Add(items.ToList());
+
+    private void AddLevel(IEnumerable<Breadcrumb> items)
     {
-        Breadcrumbs.Add(new Breadcrumb(hardwareName, $"resources/hardware/{hardwareName}"));
+        var list = items.ToList();
+        if (list.Count > 0)
+            Levels.Add(list);
     }
 
-    private async Task BuildSystemPath(string systemName)
+    private async Task BuildSystem(string systemName)
     {
         var system = await Repo.GetByNameAsync(systemName);
 
-        if (system?.RunsOn is not null)
-        {
-            Breadcrumbs.Add(new Breadcrumb(
-                system.RunsOn,
-                $"resources/hardware/{system.RunsOn}"
-            ));
-        }
+        var hwParents = (system?.RunsOn ?? new List<string>())
+            .Where(x => !string.IsNullOrWhiteSpace(x))
+            .Distinct(StringComparer.OrdinalIgnoreCase)
+            .Select(hw => new Breadcrumb(hw, $"resources/hardware/{Uri.EscapeDataString(hw)}"))
+            .OrderBy(b => b.Label, StringComparer.OrdinalIgnoreCase);
 
-        Breadcrumbs.Add(new Breadcrumb(
-            systemName,
-            $"resources/systems/{systemName}"
-        ));
+        AddLevel(hwParents);
+
+        AddLevel(new Breadcrumb(systemName, $"resources/systems/{Uri.EscapeDataString(systemName)}"));
     }
 
-    private async Task BuildServicePath(string serviceName)
+    private async Task BuildService(string serviceName)
     {
         var service = await Repo.GetByNameAsync(serviceName);
 
-        if (service?.RunsOn is not null)
-        {
-            var system = await Repo.GetByNameAsync(service.RunsOn);
+        var systemParents = (service?.RunsOn ?? new List<string>())
+            .Where(x => !string.IsNullOrWhiteSpace(x))
+            .Distinct(StringComparer.OrdinalIgnoreCase)
+            .ToList();
+        
+        var hwSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 
-            if (system?.RunsOn is not null)
+        foreach (var sysName in systemParents)
+        {
+            var sys = await Repo.GetByNameAsync(sysName);
+            if (sys?.RunsOn is { Count: > 0 })
             {
-                Breadcrumbs.Add(new Breadcrumb(
-                    system.RunsOn,
-                    $"resources/hardware/{system.RunsOn}"
-                ));
+                foreach (var hw in sys.RunsOn.Where(x => !string.IsNullOrWhiteSpace(x)))
+                    hwSet.Add(hw);
             }
-
-            Breadcrumbs.Add(new Breadcrumb(
-                service.RunsOn,
-                $"resources/systems/{service.RunsOn}"
-            ));
         }
 
-        Breadcrumbs.Add(new Breadcrumb(
-            serviceName,
-            $"resources/services/{serviceName}"
-        ));
-    }
+        var hwParents = hwSet
+            .Select(hw => new Breadcrumb(hw, $"resources/hardware/{Uri.EscapeDataString(hw)}"))
+            .OrderBy(b => b.Label, StringComparer.OrdinalIgnoreCase);
+
+        var sysCrumbs = systemParents
+            .Select(sys => new Breadcrumb(sys, $"resources/systems/{Uri.EscapeDataString(sys)}"))
+            .OrderBy(b => b.Label, StringComparer.OrdinalIgnoreCase);
 
-    private record Breadcrumb(string Label, string Href);
+        AddLevel(hwParents);
+        AddLevel(sysCrumbs);
+
+        AddLevel(new Breadcrumb(serviceName, $"resources/services/{Uri.EscapeDataString(serviceName)}"));
+    }
 
-}
+    public record Breadcrumb(string Label, string Href);
+}

+ 4 - 1
Shared.Rcl/Hardware/HardwareDetailsPage.razor

@@ -94,12 +94,15 @@
             </div>
         }
 
+<!---
+TODO: Figure out how lists work
         <div class="m-4">
             <AddResourceComponent TResource="SystemResource"
                                   Placeholder="System name"
                                   OnCreated="NavigateToNewResource"
                                   RunsOn="@HardwareName"/>
         </div>
+--->
     }
 </div>
 
@@ -137,4 +140,4 @@
         return Task.CompletedTask;
     }
 
-}
+}

+ 53 - 19
Shared.Rcl/Services/ServiceCardComponent.razor

@@ -159,31 +159,49 @@
 
         <!-- Runs On -->
         <div data-testid="service-runson-section">
-            <div class="text-zinc-400 mb-1">Runs On</div>
+            <div class="text-zinc-400 mb-1">
+              Runs On
+
+              <button
+                  data-testid="service-runson-button"
+                  class="hover:text-emerald-400 pr-4"
+                  title="Add Runs On"
+                  @onclick="() => _selectParentOpen = true">
+                      @("+")
+              </button>
+            </div>
 
             @if (_isEditing)
             {
-                <button
-                    data-testid="service-runson-button"
-                    class="hover:text-emerald-400"
-                    @onclick="() => _selectParentOpen = true">
-                    @if (!string.IsNullOrWhiteSpace(Service.RunsOn))
+                    @if (Service.RunsOn?.Count > 0)
                     {
-                        @($"{Service.RunsOn} +")
+                      @foreach(var parent in Service.RunsOn)
+                      {
+                        <button
+                          class="hover:text-emerald-400"
+                          title="Edit Runs On"
+                          @onclick="() => _selectParentOpen = true">
+                            @($"{parent}")
+                        </button>
+                        <button
+                          class="text-red-400 hover:text-red-300 pr-4"
+                          title="Remove"
+                          @onclick="() => HandleParentDeleted(parent)">
+                            @($"✕")
+                        </button>
+                      }
                     }
-                    else
-                    {
-                        @("Edit parent")
-                    }
-                </button>
             }
-            else if (!string.IsNullOrWhiteSpace(Service.RunsOn))
+            else if (Service.RunsOn?.Count > 0)
             {
-                <NavLink href="@($"resources/systems/{Uri.EscapeDataString(Service.RunsOn)}")"
-                         data-testid="service-runson-link"
-                         class="text-emerald-400">
-                    @Service.RunsOn
-                </NavLink>
+                @foreach(var parent in Service.RunsOn)
+                {
+                  <NavLink href="@($"resources/systems/{Uri.EscapeDataString(parent)}")"
+                           data-testid="service-runson-link"
+                           class="text-emerald-400 pr-4">
+                      @parent
+                  </NavLink>
+                }
             }
         </div>
 
@@ -322,7 +340,23 @@
             Service.Network?.Port,
             Service.Network?.Protocol,
             Service.Network?.Url,
-            name,
+            new List<string>{name},
+            Service.Notes);
+        Service = await GetByNameUseCase.ExecuteAsync(Service.Name);
+        _edit = ServiceEditModel.From(Service);
+    }
+
+    async Task HandleParentDeleted(string? name)
+    {
+        SelectedParentName = name;
+        Service.RunsOn.Remove(SelectedParentName);
+        await UpdateUseCase.ExecuteAsync(
+            Service.Name,
+            Service.Network?.Ip,
+            Service.Network?.Port,
+            Service.Network?.Protocol,
+            Service.Network?.Url,
+            Service.RunsOn,
             Service.Notes);
         Service = await GetByNameUseCase.ExecuteAsync(Service.Name);
         _edit = ServiceEditModel.From(Service);

+ 2 - 2
Shared.Rcl/Services/ServiceEditModel.cs

@@ -9,7 +9,7 @@ public sealed class ServiceEditModel
     public int? Port { get; set; }
     public string? Protocol { get; set; }
     public string? Url { get; set; }
-    public string? RunsOn { get; set; }
+    public List<string>? RunsOn { get; set; }
     public string? Notes { get; set; }
 
     public static ServiceEditModel From(Service s)
@@ -25,4 +25,4 @@ public sealed class ServiceEditModel
             Notes = s.Notes
         };
     }
-}
+}

+ 6 - 2
Shared.Rcl/Services/ServicesListPage.razor

@@ -2,10 +2,14 @@
 @using RackPeek.Domain.Persistence
 @inject NavigationManager Nav
 
+<!-- TODO: Get rid of First -->
 <ResourcesListComponent TResource="Service"
                         Title="Services"
                         TestId="services"
-                        GroupBy="@(s => s.RunsOn)"
+                        GroupBy="@(s => {
+                          if (s.RunsOn is null) return "Unkown";
+                          return s.RunsOn.FirstOrDefault();
+                        })"
                         ShouldGroup="true"
                         OnCreated="NavigateToNewResource">
 
@@ -33,4 +37,4 @@
         await Repo.GetAllOfTypeAsync<Service>();
     }
 
-}
+}

+ 54 - 22
Shared.Rcl/Systems/SystemCardComponent.razor

@@ -167,31 +167,47 @@
 
         <!-- Runs On -->
         <div>
-            <div class="text-zinc-400 mb-1">Runs On</div>
+            <div class="text-zinc-400 mb-1">
+              Runs On
+
+              <button
+                  class="hover:text-emerald-400 pr-4"
+                  title="Add Runs On"
+                  @onclick="() => _selectParentOpen = true">
+                      @("+")
+              </button>
+            </div>
+
             @if (_isEditing)
             {
-                <button
-                    class="hover:text-emerald-400"
-                    title="Edit Runs On"
-                    data-testid="system-runs-on-button"
-                    @onclick="() => _selectParentOpen = true">
-                    @if (!string.IsNullOrWhiteSpace(System.RunsOn))
-                    {
-                        @($"{System.RunsOn} +")
-                    }
-                    else
-                    {
-                        @("Edit parent")
-                    }
-
-                </button>
+                  @if (System.RunsOn?.Count > 0)
+                  {
+                      @foreach(var parent in System.RunsOn)
+                      {
+                        <button
+                            class="hover:text-emerald-400"
+                            title="Edit Runs On"
+                            @onclick="() => _selectParentOpen = true">
+                                  @($"{parent}")
+                        </button>
+                        <button
+                            class="text-red-400 hover:text-red-300 pr-4"
+                            title="Remove"
+                            @onclick="() => HandleParentDeleted(parent)">
+                                  @($"✕")
+                        </button>
+                      }
+                  }
             }
-            else if (!string.IsNullOrWhiteSpace(System.RunsOn))
+            else if (System.RunsOn?.Count > 0)
             {
-                <NavLink href="@($"resources/hardware/{Uri.EscapeDataString(System.RunsOn)}")"
-                         class="text-emerald-400 text-sm">
-                    @System.RunsOn
-                </NavLink>
+                @foreach(var parent in System.RunsOn)
+                {
+                  <NavLink href="@($"resources/hardware/{Uri.EscapeDataString(parent)}")"
+                           class="text-emerald-400 text-sm pr-4">
+                      @parent
+                  </NavLink>
+                }
             }
         </div>
 
@@ -334,7 +350,23 @@
             System.Os,
             System.Cores,
             System.Ram,
-            name,
+            new List<string>{name},
+            System.Notes);
+        System = await GetByNameUseCase.ExecuteAsync(System.Name);
+        _edit = SystemEditModel.From(System);
+    }
+
+    async Task HandleParentDeleted(string? name)
+    {
+        SelectedParentName = name;
+        System.RunsOn.Remove(SelectedParentName);
+        await UpdateUseCase.ExecuteAsync(
+            System.Name,
+            System.Type,
+            System.Os,
+            System.Cores,
+            System.Ram,
+            System.RunsOn,
             System.Notes);
         System = await GetByNameUseCase.ExecuteAsync(System.Name);
         _edit = SystemEditModel.From(System);

+ 2 - 2
Shared.Rcl/Systems/SystemEditModel.cs

@@ -9,7 +9,7 @@ public sealed class SystemEditModel
     public string? Os { get; set; }
     public int? Cores { get; set; }
     public double? Ram { get; set; }
-    public string? RunsOn { get; set; }
+    public List<string> RunsOn { get; set; } = new List<string>();
     public string? Notes { get; set; }
 
     public static SystemEditModel From(SystemResource system)
@@ -25,4 +25,4 @@ public sealed class SystemEditModel
             Notes = system.Notes
         };
     }
-}
+}

+ 2 - 2
Shared.Rcl/Systems/SystemsDetailsPage.razor

@@ -46,7 +46,7 @@
             <AddResourceComponent TResource="Service"
                                   Placeholder="Service name"
                                   OnCreated="NavigateToNewResource"
-                                  RunsOn="@SystemName"/>
+                                  RunsOn="@(new List<string>{SystemName})"/>
         </div>
     }
 </div>
@@ -95,4 +95,4 @@
         return Task.CompletedTask;
     }
 
-}
+}

+ 5 - 1
Shared.Rcl/Systems/SystemsListPage.razor

@@ -4,12 +4,16 @@
 @using RackPeek.Domain.Resources.SystemResources.UseCases
 @inject NavigationManager Nav
 
+<!-- TODO: Get rid of First -->
 <ResourcesListComponent TResource="SystemResource"
                         Title="@PageTitle"
                         TestId="systems"
                         Resources="@Systems"
                         ShouldGroup="true"
-                        GroupBy="@(s => s.RunsOn)"
+                        GroupBy="@(s => {
+                          if (s.RunsOn is null) return "Unkown";
+                          return s.RunsOn.FirstOrDefault();
+                        })"
                         OnCreated="NavigateToNewResource">
 
     <ItemTemplate Context="systemResource">

+ 2 - 2
Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs

@@ -38,7 +38,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
         Assert.Equal("Access Point 'ap01' updated.\n", output);
 
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: AccessPoint
                        model: Unifi-U6-Lite
@@ -58,7 +58,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
         Assert.Equal("Access Point 'ap02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: AccessPoint
                        model: Unifi-U6-Lite

+ 2 - 2
Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs

@@ -44,7 +44,7 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
         Assert.Equal("Firewall 'fw01' updated.\n", output);
 
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: Firewall
                        model: Fortinet FG-60F
@@ -67,7 +67,7 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
         Assert.Equal("Firewall 'fw02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: Firewall
                        model: Fortinet FG-60F

+ 2 - 2
Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs

@@ -44,7 +44,7 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Router 'rt01' updated.\n", output);
 
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: Router
                        model: Ubiquiti EdgeRouter 4
@@ -67,7 +67,7 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Router 'rt02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: Router
                        model: Ubiquiti EdgeRouter 4

+ 1 - 1
Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs

@@ -43,7 +43,7 @@ public class ServerWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Server 'srv01' updated.\n", output);
 
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: Server
                        ram:

+ 4 - 2
Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs

@@ -46,9 +46,10 @@ public class ServiceWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outpu
             "--runs-on", "sys01"
         );
         Assert.Equal("Service 'svc01' updated.\n", output);
+        outputHelper.WriteLine(yaml);
 
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: System
                        name: sys01
@@ -59,7 +60,8 @@ public class ServiceWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outpu
                          protocol: http
                          url: http://10.0.0.5:8080
                        name: svc01
-                       runsOn: sys01
+                       runsOn:
+                       - sys01
 
                      """, yaml);
 

+ 2 - 2
Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs

@@ -45,7 +45,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Switch 'sw01' updated.\n", output);
 
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: Switch
                        model: Netgear GS108
@@ -69,7 +69,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Switch 'sw02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: Switch
                        model: Netgear GS108

+ 4 - 2
Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs

@@ -47,8 +47,9 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         );
         Assert.Equal("System 'sys01' updated.\n", output);
 
+        outputHelper.WriteLine(yaml);
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: Server
                        name: proxmox-node01
@@ -58,7 +59,8 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        cores: 2
                        ram: 4
                        name: sys01
-                       runsOn: proxmox-node01
+                       runsOn:
+                       - proxmox-node01
 
                      """, yaml);
 

+ 2 - 2
Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs

@@ -40,7 +40,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
         Assert.Equal("UPS 'ups01' updated.\n", output);
 
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: Ups
                        model: APC-SmartUPS-1500
@@ -61,7 +61,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
         Assert.Equal("UPS 'ups02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 1
+                     version: 2
                      resources:
                      - kind: Ups
                        model: APC-SmartUPS-1500

+ 34 - 0
Tests/TestConfigs/v2/01-server.yaml

@@ -0,0 +1,34 @@
+version: 2
+resources:
+  - kind: Server
+    name: example-server
+    tags:
+      - production
+      - compute
+    notes: Primary hypervisor host
+    runsOn:
+      - rack-a1
+      - rack-a2
+    ram:
+      size: 128
+      mts: 3200
+    ipmi: true
+    cpus:
+      - model: AMD EPYC 7302P
+        cores: 16
+        threads: 32
+    drives:
+      - type: nvme
+        size: 1024
+      - type: ssd
+        size: 2048
+    gpus:
+      - model: NVIDIA RTX 4000
+        vram: 16
+    nics:
+      - type: rj45
+        speed: 1
+        ports: 2
+      - type: sfp+
+        speed: 10
+        ports: 2

+ 17 - 0
Tests/TestConfigs/v2/02-firewall.yaml

@@ -0,0 +1,17 @@
+version: 2
+resources:
+  - kind: Firewall
+    name: example-firewall
+    model: Netgate-6100
+    managed: true
+    poe: false
+    ports:
+      - type: rj45
+        speed: 1
+        count: 4
+      - type: sfp+
+        speed: 10
+        count: 2
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 17 - 0
Tests/TestConfigs/v2/03-router.yaml

@@ -0,0 +1,17 @@
+version: 2
+resources:
+  - kind: Router
+    name: example-router
+    model: Ubiquiti-ER-4
+    managed: true
+    poe: false
+    ports:
+      - type: rj45
+        speed: 1
+        count: 4
+      - type: sfp
+        speed: 10
+        count: 1
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 17 - 0
Tests/TestConfigs/v2/04-switch.yaml

@@ -0,0 +1,17 @@
+version: 2
+resources:
+  - kind: Switch
+    name: example-switch
+    model: UniFi-USW-Enterprise-24
+    managed: true
+    poe: true
+    ports:
+      - type: rj45
+        speed: 1
+        count: 12
+      - type: sfp+
+        speed: 10
+        count: 4
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 11 - 0
Tests/TestConfigs/v2/05-accesspoint.yaml

@@ -0,0 +1,11 @@
+version: 2
+resources:
+  - kind: AccessPoint
+    name: example-accesspoint
+    tags:
+      - wireless
+    model: UniFi-U6-Pro
+    speed: 2.5
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 11 - 0
Tests/TestConfigs/v2/06-ups.yaml

@@ -0,0 +1,11 @@
+version: 2
+resources:
+  - kind: Ups
+    name: example-ups
+    tags:
+      - power
+    model: APC-SmartUPS-2200
+    va: 2200
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 25 - 0
Tests/TestConfigs/v2/07-desktop.yaml

@@ -0,0 +1,25 @@
+version: 2
+resources:
+  - kind: Desktop
+    name: example-desktop
+    notes: Engineering workstation
+    ram:
+      size: 64
+      mts: 3600
+    cpus:
+      - model: Intel Core i9-13900K
+        cores: 24
+        threads: 32
+    drives:
+      - type: ssd
+        size: 2048
+    gpus:
+      - model: NVIDIA RTX 4090
+        vram: 24
+    nics:
+      - type: rj45
+        speed: 1
+        ports: 1
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 18 - 0
Tests/TestConfigs/v2/08-laptop.yaml

@@ -0,0 +1,18 @@
+version: 2
+resources:
+  - kind: Laptop
+    name: example-laptop
+    notes: Developer machine
+    ram:
+      size: 32
+      mts: 5200
+    cpus:
+      - model: Intel Core i7-1260P
+        cores: 12
+        threads: 16
+    drives:
+      - type: ssd
+        size: 1024
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 12 - 0
Tests/TestConfigs/v2/09-service.yaml

@@ -0,0 +1,12 @@
+version: 2
+resources:
+  - kind: Service
+    name: example-service
+    runsOn:
+      - rack-a1
+      - rack-a2
+    network:
+      ip: 192.168.1.10
+      port: 8080
+      protocol: TCP
+      url: http://example.local:8080

+ 15 - 0
Tests/TestConfigs/v2/10-system.yaml

@@ -0,0 +1,15 @@
+version: 2
+resources:
+  - kind: System
+    name: example-system
+    notes: Virtual machine instance
+    runsOn:
+      - rack-a1
+      - rack-a2
+    type: VM
+    os: ubuntu-22.04
+    cores: 4
+    ram: 8
+    drives:
+      - size: 128
+      - size: 256

+ 453 - 0
Tests/TestConfigs/v2/11-demo-config.yaml

@@ -0,0 +1,453 @@
+version: 2
+resources:
+  - kind: Server
+    ram:
+      size: 128
+      mts: 3200
+    ipmi: true
+    cpus:
+      - model: AMD EPYC 7302P
+        cores: 16
+        threads: 32
+    drives:
+      - type: ssd
+        size: 1024
+      - type: ssd
+        size: 1024
+    nics:
+      - type: rj45
+        speed: 1
+        ports: 2
+      - type: sfp+
+        speed: 10
+        ports: 2
+    name: proxmox-node01
+  - kind: Server
+    ram:
+      size: 96
+      mts: 2666
+    ipmi: true
+    cpus:
+      - model: Intel Xeon Silver 4210
+        cores: 10
+        threads: 20
+    drives:
+      - type: ssd
+        size: 1024
+      - type: hdd
+        size: 4096
+    nics:
+      - type: rj45
+        speed: 1
+        ports: 2
+      - type: sfp+
+        speed: 10
+        ports: 1
+    name: proxmox-node02
+  - kind: Server
+    ram:
+      size: 64
+      mts: 2666
+    ipmi: true
+    cpus:
+      - model: Intel Xeon E-2236
+        cores: 6
+        threads: 12
+    drives:
+      - type: hdd
+        size: 8192
+      - type: hdd
+        size: 8192
+      - type: hdd
+        size: 8192
+      - type: hdd
+        size: 8192
+    nics:
+      - type: rj45
+        speed: 1
+        ports: 1
+      - type: sfp+
+        speed: 10
+        ports: 1
+    name: truenas-storage
+  - kind: Firewall
+    model: Netgate-6100
+    managed: true
+    poe: false
+    ports:
+      - type: rj45
+        speed: 1
+        count: 4
+      - type: sfp+
+        speed: 10
+        count: 2
+    name: pfsense-fw
+  - kind: Router
+    model: Ubiquiti-ER-4
+    managed: true
+    poe: false
+    ports:
+      - type: rj45
+        speed: 1
+        count: 4
+      - type: sfp
+        speed: 10
+        count: 1
+    name: core-router
+  - kind: Switch
+    model: UniFi-USW-Enterprise-24
+    managed: true
+    poe: true
+    ports:
+      - type: rj45
+        speed: 1
+        count: 12
+      - type: rj45
+        speed: 2.5
+        count: 8
+      - type: sfp+
+        speed: 10
+        count: 4
+    name: core-switch
+  - kind: Switch
+    model: UniFi-USW-16-PoE
+    managed: true
+    poe: true
+    ports:
+      - type: rj45
+        speed: 1
+        count: 16
+      - type: sfp
+        speed: 1
+        count: 2
+    name: access-switch
+  - kind: AccessPoint
+    model: UniFi-U6-Pro
+    speed: 2.5
+    name: lounge-ap
+  - kind: Ups
+    model: APC-SmartUPS-2200
+    va: 2200
+    name: rack-ups
+  - kind: Desktop
+    ram:
+      size: 64
+      mts: 3600
+    cpus:
+      - model: AMD Ryzen 9 5900X
+        cores: 12
+        threads: 24
+    drives:
+      - type: ssd
+        size: 1024
+      - type: ssd
+        size: 2048
+    gpus:
+      - model: NVIDIA RTX 3080
+        vram: 10
+    nics:
+      - type: rj45
+        speed: 1
+        ports: 1
+    name: workstation-linux
+  - kind: Desktop
+    ram:
+      size: 32
+      mts: 3200
+    cpus:
+      - model: Intel Core i7-12700K
+        cores: 12
+        threads: 20
+    drives:
+      - type: ssd
+        size: 1024
+    gpus:
+      - model: NVIDIA RTX 3070
+        vram: 8
+    nics:
+      - type: rj45
+        speed: 1
+        ports: 1
+    name: gaming-pc
+  - kind: Laptop
+    ram:
+      size: 32
+      mts: 5200
+    cpus:
+      - model: Intel Core i7-1260P
+        cores: 12
+        threads: 16
+    drives:
+      - type: ssd
+        size: 1024
+    name: dev-laptop
+  - kind: Service
+    network:
+      ip: 192.168.0.10
+      port: 8123
+      protocol: TCP
+      url: http://homeassistant.lan:8123
+    name: home-assistant
+    runsOn: 
+      - vm-home-assistant
+  - kind: Service
+    network:
+      ip: 192.168.0.20
+      port: 32400
+      protocol: TCP
+      url: http://plex.lan:32400
+    name: plex
+    runsOn: 
+      - vm-media-server
+      - vm-home-assistant
+  - kind: Service
+    network:
+      ip: 192.168.0.21
+      port: 8096
+      protocol: TCP
+      url: http://jellyfin.lan:8096
+    name: jellyfin
+    runsOn: 
+      - vm-media-server
+  - kind: Service
+    network:
+      ip: 192.168.0.22
+      port: 8080
+      protocol: TCP
+      url: http://immich.lan:8080
+    name: immich
+    runsOn: 
+     - vm-media-server
+  - kind: Service
+    network:
+      ip: 192.168.0.30
+      port: 443
+      protocol: TCP
+      url: https://truenas.lan
+    name: truenas-webui
+    runsOn: 
+      - truenas-core-os
+  - kind: Service
+    network:
+      ip: 192.168.0.31
+      port: 9000
+      protocol: TCP
+      url: http://minio.lan:9000
+    name: minio
+    runsOn: 
+      - vm-media-server
+  - kind: Service
+    network:
+      ip: 192.168.0.40
+      port: 9090
+      protocol: TCP
+      url: http://prometheus.lan:9090
+    name: prometheus
+    runsOn: 
+      - vm-monitoring
+  - kind: Service
+    network:
+      ip: 192.168.0.41
+      port: 3000
+      protocol: TCP
+      url: http://grafana.lan:3000
+    name: grafana
+  - kind: Service
+    network:
+      ip: 192.168.0.42
+      port: 9093
+      protocol: TCP
+      url: http://alertmanager.lan:9093
+    name: alertmanager
+  - kind: Service
+    network:
+      ip: 192.168.0.50
+      port: 3001
+      protocol: TCP
+      url: http://git.lan:3001
+    name: gitea
+    runsOn: 
+      - vm-monitoring
+  - kind: Service
+    network:
+      ip: 192.168.0.51
+      port: 5000
+      protocol: TCP
+      url: http://registry.lan:5000
+    name: docker-registry
+    runsOn: 
+      - vm-monitoring
+  - kind: Service
+    network:
+      ip: 192.168.0.52
+      port: 9000
+      protocol: TCP
+      url: http://portainer.lan:9000
+    name: portainer
+    runsOn: 
+      - vm-monitoring
+      - vm-logging
+  - kind: Service
+    network:
+      ip: 192.168.0.53
+      port: 80
+      protocol: TCP
+      url: http://pihole.lan
+    name: pihole
+  - kind: Service
+    network:
+      ip: 192.168.0.1
+      port: 443
+      protocol: TCP
+      url: https://firewall.lan
+    name: firewall-webui
+    runsOn: 
+      - firewall-os
+  - kind: Service
+    network:
+      ip: 192.168.0.254
+      port: 443
+      protocol: TCP
+      url: https://router.lan
+    name: router-webui
+    runsOn: 
+      - router-os
+  - kind: System
+    type: Hypervisor
+    os: proxmox
+    cores: 16
+    ram: 128
+    drives:
+      - size: 1024
+      - size: 1024
+    name: proxmox-cluster-node01
+    runsOn: 
+      - proxmox-node01
+  - kind: System
+    type: Hypervisor
+    os: proxmox
+    cores: 10
+    ram: 96
+    drives:
+      - size: 1024
+      - size: 4096
+    name: proxmox-cluster-node02
+    runsOn: 
+      - proxmox-node02
+  - kind: System
+    type: Baremetal
+    os: truenas
+    cores: 6
+    ram: 64
+    drives:
+      - size: 8192
+      - size: 8192
+      - size: 8192
+      - size: 8192
+    name: truenas-core-os
+    runsOn: 
+      - truenas-storage
+  - kind: System
+    type: Baremetal
+    os: idrac
+    cores: 1
+    ram: 1
+    name: ipmi-proxmox-node01
+    runsOn: 
+      - proxmox-node01
+  - kind: System
+    type: Baremetal
+    os: ipmi
+    cores: 1
+    ram: 1
+    name: ipmi-proxmox-node02
+    runsOn: 
+      - proxmox-node02
+  - kind: System
+    type: Baremetal
+    os: ipmi
+    cores: 1
+    ram: 1
+    name: ipmi-truenas-storage
+    runsOn: 
+      - truenas-storage
+  - kind: System
+    type: Baremetal
+    os: pfsense
+    cores: 4
+    ram: 8
+    drives:
+      - size: 32
+    name: firewall-os
+    runsOn: 
+      - pfsense-fw
+  - kind: System
+    type: Baremetal
+    os: edgeos
+    cores: 4
+    ram: 4
+    drives:
+      - size: 4
+    name: router-os
+    runsOn: 
+      - core-router
+  - kind: System
+    type: Baremetal
+    os: unifi-os
+    cores: 2
+    ram: 2
+    drives:
+      - size: 8
+    name: unifi-core-switch-os
+    runsOn: 
+      - core-switch
+  - kind: System
+    type: Baremetal
+    os: unifi-os
+    cores: 2
+    ram: 2
+    drives:
+      - size: 8
+    name: unifi-access-switch-os
+    runsOn: 
+      - access-switch
+  - kind: System
+    type: Baremetal
+    os: unifi-firmware
+    cores: 2
+    ram: 1
+    drives:
+      - size: 4
+    name: unifi-lounge-ap-os
+    runsOn: 
+      - lounge-ap
+  - kind: System
+    type: VM
+    os: hassos
+    cores: 2
+    ram: 4
+    drives:
+      - size: 64
+    name: vm-home-assistant
+    runsOn:
+      - proxmox-node01
+  - kind: System
+    type: VM
+    os: ubuntu-22.04
+    cores: 4
+    ram: 8
+    drives:
+      - size: 500
+    name: vm-media-server
+    runsOn: 
+      - proxmox-node02
+  - kind: System
+    type: VM
+    os: debian-12
+    cores: 2
+    ram: 4
+    drives:
+      - size: 64
+    name: vm-monitoring
+    runsOn: 
+      - proxmox-node01

+ 2 - 51
Tests/Tests.csproj

@@ -34,56 +34,7 @@
       <Folder Include="EndToEnd\ServiceTests\" />
     </ItemGroup>
     <ItemGroup>
-        <None Include="TestConfigs\**\*.yaml">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
-
-        <None Include="schemas\schema.v1.json">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
-
-        <None Update="TestConfigs\v1\valid-config-1.yaml">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
-
-        <None Update="TestConfigs\v1\01-server.yaml">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
-
-        <None Update="TestConfigs\v1\02-firewall.yaml">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
-
-        <None Update="TestConfigs\v1\03-router.yaml">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
-
-        <None Update="TestConfigs\v1\04-switch.yaml">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
-
-        <None Update="TestConfigs\v1\05-accesspoint.yaml">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
-
-        <None Update="TestConfigs\v1\06-ups.yaml">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
-
-        <None Update="TestConfigs\v1\07-desktop.yaml">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
-
-        <None Update="TestConfigs\v1\08-laptop.yaml">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
-
-        <None Update="TestConfigs\v1\09-service.yaml">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
-
-        <None Update="TestConfigs\v1\10-system.yaml">
-            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-        </None>
+        <None Include="schemas\**\*.json" CopyToOutputDirectory="PreserveNewest" />
+        <None Include="TestConfigs\**\*.yaml" CopyToOutputDirectory="PreserveNewest" />
     </ItemGroup>
 </Project>

+ 9 - 6
Tests/Yaml/SchemaTests.cs

@@ -6,9 +6,9 @@ namespace Tests.Yaml;
 
 public class SchemaConformanceTests
 {
-    private static JsonSchema LoadSchema()
+    private static JsonSchema LoadSchema(int version)
     {
-        var schemaText = File.ReadAllText("schemas/schema.v1.json");
+        var schemaText = File.ReadAllText($"schemas/schema.v{version}.json");
         return JsonSchema.FromText(schemaText);
     }
     private static JsonElement ConvertYamlToJsonElement(string yaml)
@@ -65,16 +65,19 @@ public class SchemaConformanceTests
 
         return "null";
     }
-    [Fact]
-    public void All_v1_yaml_files_conform_to_schema()
+    
+    [Theory]
+    [InlineData(1)]
+    [InlineData(2)]
+    public void All_yaml_files_conform_to_schema(int version)
     {
         // Arrange
-        var schema = LoadSchema();
+        var schema = LoadSchema(version);
         
         var yamlFolder = Path.Combine(
             AppContext.BaseDirectory,
             "TestConfigs",
-            "v1");
+            $"v{version}");
 
         var yamlFiles = Directory
             .EnumerateFiles(yamlFolder, "*.yaml", SearchOption.AllDirectories)

+ 212 - 152
Tests/schemas/schema.v1.json

@@ -1,34 +1,54 @@
 {
-  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
   "$id": "https://timmoth.github.io/RackPeek/schemas/v1/schema.v1.json",
   "title": "RackPeek Infrastructure Specification",
   "type": "object",
   "additionalProperties": false,
   "required": ["version", "resources"],
   "properties": {
-    "version": {
-      "type": "integer",
-      "const": 1
-    },
+    "version": { "type": "integer", "const": 1 },
     "resources": {
       "type": "array",
-      "items": {
-        "oneOf": [
-          { "$ref": "#/$defs/server" },
-          { "$ref": "#/$defs/firewall" },
-          { "$ref": "#/$defs/router" },
-          { "$ref": "#/$defs/switch" },
-          { "$ref": "#/$defs/accessPoint" },
-          { "$ref": "#/$defs/ups" },
-          { "$ref": "#/$defs/desktop" },
-          { "$ref": "#/$defs/laptop" },
-          { "$ref": "#/$defs/service" },
-          { "$ref": "#/$defs/system" }
-        ]
-      }
+      "items": { "$ref": "#/$defs/resource" }
     }
   },
+
   "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": { "type": "string" }
+    },
+
+    "resourceBase": {
+      "type": "object",
+      "required": ["kind", "name"],
+      "properties": {
+        "kind": { "type": "string" },
+        "name": { "type": "string", "minLength": 1 },
+
+        "tags": { "type": "array", "items": { "type": "string" }, "default": [] },
+        "labels": { "$ref": "#/$defs/labels", "default": {} },
+        "notes": { "type": ["string", "null"] },
+
+        "runsOn": { "type": ["string", "null"] }
+      }
+    },
+
+    "resource": {
+      "oneOf": [
+        { "$ref": "#/$defs/server" },
+        { "$ref": "#/$defs/firewall" },
+        { "$ref": "#/$defs/router" },
+        { "$ref": "#/$defs/switch" },
+        { "$ref": "#/$defs/accessPoint" },
+        { "$ref": "#/$defs/ups" },
+        { "$ref": "#/$defs/desktop" },
+        { "$ref": "#/$defs/laptop" },
+        { "$ref": "#/$defs/service" },
+        { "$ref": "#/$defs/system" }
+      ]
+    },
+
     "ram": {
       "type": "object",
       "required": ["size"],
@@ -38,6 +58,7 @@
         "mts": { "type": "integer", "minimum": 0 }
       }
     },
+
     "cpu": {
       "type": "object",
       "additionalProperties": false,
@@ -47,6 +68,7 @@
         "threads": { "type": "integer", "minimum": 1 }
       }
     },
+
     "drive": {
       "type": "object",
       "required": ["size"],
@@ -54,11 +76,12 @@
       "properties": {
         "type": {
           "type": "string",
-          "enum": ["nvme","ssd","hdd","sas","sata","usb","sdcard","micro-sd"]
+          "enum": ["nvme", "ssd", "hdd", "sas", "sata", "usb", "sdcard", "micro-sd"]
         },
         "size": { "type": "number", "minimum": 1 }
       }
     },
+
     "gpu": {
       "type": "object",
       "additionalProperties": false,
@@ -67,6 +90,7 @@
         "vram": { "type": "number", "minimum": 0 }
       }
     },
+
     "nic": {
       "type": "object",
       "additionalProperties": false,
@@ -74,18 +98,19 @@
         "type": {
           "type": "string",
           "enum": [
-            "rj45","sfp","sfp+","sfp28","sfp56",
-            "qsfp+","qsfp28","qsfp56","qsfp-dd",
-            "osfp","xfp","cx4","mgmt"
+            "rj45", "sfp", "sfp+", "sfp28", "sfp56",
+            "qsfp+", "qsfp28", "qsfp56", "qsfp-dd",
+            "osfp", "xfp", "cx4", "mgmt"
           ]
         },
         "speed": { "type": "number", "minimum": 0 },
         "ports": { "type": "integer", "minimum": 1 }
       }
     },
+
     "port": {
       "type": "object",
-      "required": ["type","speed","count"],
+      "required": ["type", "speed", "count"],
       "additionalProperties": false,
       "properties": {
         "type": { "type": "string" },
@@ -93,173 +118,208 @@
         "count": { "type": "integer", "minimum": 1 }
       }
     },
+
     "network": {
       "type": "object",
-      "required": ["ip","port","protocol"],
+      "required": ["ip", "port", "protocol"],
       "additionalProperties": false,
       "properties": {
         "ip": {
           "type": "string",
-          "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}$"
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
         },
         "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
-        "protocol": { "type": "string", "enum": ["TCP","UDP"] },
-        "url": { "type": "string" }
+        "protocol": { "type": "string", "enum": ["TCP", "UDP"] },
+        "url": { "type": "string", "format": "uri" }
       }
     },
 
     "server": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Server" },
-        "name": { "type": "string" },
-        "tags": { "type": "array", "items": { "type": "string" } },
-        "notes": { "type": "string" },
-        "runsOn": { "type": "string" },
-        "ram": { "$ref": "#/$defs/ram" },
-        "ipmi": { "type": "boolean" },
-        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
-        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
-        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Server" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "ipmi": { "type": "boolean" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "desktop": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Desktop" },
-        "name": { "type": "string" },
-        "notes": { "type": "string" },
-        "ram": { "$ref": "#/$defs/ram" },
-        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
-        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
-        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Desktop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "laptop": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Laptop" },
-        "name": { "type": "string" },
-        "notes": { "type": "string" },
-        "ram": { "$ref": "#/$defs/ram" },
-        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Laptop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "firewall": {
-      "type": "object",
-      "required": ["kind","name","ports"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Firewall" },
-        "name": { "type": "string" },
-        "model": { "type": "string" },
-        "managed": { "type": "boolean" },
-        "poe": { "type": "boolean" },
-        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Firewall" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "router": {
-      "type": "object",
-      "required": ["kind","name","ports"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Router" },
-        "name": { "type": "string" },
-        "model": { "type": "string" },
-        "managed": { "type": "boolean" },
-        "poe": { "type": "boolean" },
-        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Router" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "switch": {
-      "type": "object",
-      "required": ["kind","name","ports"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Switch" },
-        "name": { "type": "string" },
-        "model": { "type": "string" },
-        "managed": { "type": "boolean" },
-        "poe": { "type": "boolean" },
-        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Switch" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "accessPoint": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "AccessPoint" },
-        "name": { "type": "string" },
-        "tags": { "type": "array", "items": { "type": "string" } },
-        "model": { "type": "string" },
-        "speed": { "type": "number" }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "AccessPoint" },
+
+            "model": { "type": "string" },
+            "speed": { "type": "number", "minimum": 0 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "ups": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Ups" },
-        "name": { "type": "string" },
-        "tags": { "type": "array", "items": { "type": "string" } },
-        "model": { "type": "string" },
-        "va": { "type": "integer", "minimum": 1 }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Ups" },
+
+            "model": { "type": "string" },
+            "va": { "type": "integer", "minimum": 1 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "service": {
-      "type": "object",
-      "required": ["kind","name","network","runsOn"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Service" },
-        "name": { "type": "string" },
-        "runsOn": { "type": "string" },
-        "network": { "$ref": "#/$defs/network" }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["network"],
+          "properties": {
+            "kind": { "const": "Service" },
+            "network": { "$ref": "#/$defs/network" }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "system": {
-      "type": "object",
-      "required": ["kind","name","type","os","cores","ram"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "System" },
-        "name": { "type": "string" },
-        "notes": { "type": "string" },
-        "runsOn": { "type": "string" },
-        "type": {
-          "type": "string",
-          "enum": [
-            "baremetal","Baremetal",
-            "hypervisor","Hypervisor",
-            "vm","VM",
-            "container","embedded","cloud","other"
-          ]
-        },
-        "os": { "type": "string" },
-        "cores": { "type": "integer", "minimum": 1 },
-        "ram": { "type": "number", "minimum": 0 },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["type", "os", "cores", "ram"],
+          "properties": {
+            "kind": { "const": "System" },
+
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal", "Baremetal",
+                "hypervisor", "Hypervisor",
+                "vm", "VM",
+                "container", "embedded", "cloud", "other"
+              ]
+            },
+            "os": { "type": "string" },
+            "cores": { "type": "integer", "minimum": 1 },
+            "ram": { "type": "number", "minimum": 0 },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     }
   }
 }

+ 336 - 0
Tests/schemas/schema.v2.json

@@ -0,0 +1,336 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v2/schema.v2.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["version", "resources"],
+  "properties": {
+    "version": { "type": "integer", "const": 2 },
+    "resources": {
+      "type": "array",
+      "items": { "$ref": "#/$defs/resource" }
+    }
+  },
+
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": { "type": "string" }
+    },
+
+    "runsOn": {
+      "type": ["array", "null"],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+
+    "resourceBase": {
+      "type": "object",
+      "required": ["kind", "name"],
+      "properties": {
+        "kind": { "type": "string" },
+        "name": { "type": "string", "minLength": 1 },
+
+        "tags": { "type": "array", "items": { "type": "string" }, "default": [] },
+        "labels": { "$ref": "#/$defs/labels", "default": {} },
+        "notes": { "type": ["string", "null"] },
+
+        "runsOn": {
+          "type": ["array", "null"],
+          "items": { "type": "string" }
+        }
+      }
+    },
+
+    "resource": {
+      "oneOf": [
+        { "$ref": "#/$defs/server" },
+        { "$ref": "#/$defs/firewall" },
+        { "$ref": "#/$defs/router" },
+        { "$ref": "#/$defs/switch" },
+        { "$ref": "#/$defs/accessPoint" },
+        { "$ref": "#/$defs/ups" },
+        { "$ref": "#/$defs/desktop" },
+        { "$ref": "#/$defs/laptop" },
+        { "$ref": "#/$defs/service" },
+        { "$ref": "#/$defs/system" }
+      ]
+    },
+
+    "ram": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "size": { "type": "number", "minimum": 0 },
+        "mts": { "type": "integer", "minimum": 0 }
+      }
+    },
+
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "cores": { "type": "integer", "minimum": 1 },
+        "threads": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "drive": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": ["nvme", "ssd", "hdd", "sas", "sata", "usb", "sdcard", "micro-sd"]
+        },
+        "size": { "type": "number", "minimum": 1 }
+      }
+    },
+
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "vram": { "type": "number", "minimum": 0 }
+      }
+    },
+
+    "nic": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45", "sfp", "sfp+", "sfp28", "sfp56",
+            "qsfp+", "qsfp28", "qsfp56", "qsfp-dd",
+            "osfp", "xfp", "cx4", "mgmt"
+          ]
+        },
+        "speed": { "type": "number", "minimum": 0 },
+        "ports": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "port": {
+      "type": "object",
+      "required": ["type", "speed", "count"],
+      "additionalProperties": false,
+      "properties": {
+        "type": { "type": "string" },
+        "speed": { "type": "number", "minimum": 0 },
+        "count": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "network": {
+      "type": "object",
+      "required": ["ip", "port", "protocol"],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
+        "protocol": { "type": "string", "enum": ["TCP", "UDP"] },
+        "url": { "type": "string", "format": "uri" }
+      }
+    },
+
+    "server": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Server" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "ipmi": { "type": "boolean" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "desktop": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Desktop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "laptop": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Laptop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "firewall": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Firewall" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "router": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Router" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "switch": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Switch" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "accessPoint": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "AccessPoint" },
+
+            "model": { "type": "string" },
+            "speed": { "type": "number", "minimum": 0 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "ups": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Ups" },
+
+            "model": { "type": "string" },
+            "va": { "type": "integer", "minimum": 1 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "service": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["network"],
+          "properties": {
+            "kind": { "const": "Service" },
+            "network": { "$ref": "#/$defs/network" }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "system": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["type", "os", "cores", "ram"],
+          "properties": {
+            "kind": { "const": "System" },
+
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal", "Baremetal",
+                "hypervisor", "Hypervisor",
+                "vm", "VM",
+                "container", "embedded", "cloud", "other"
+              ]
+            },
+            "os": { "type": "string" },
+            "cores": { "type": "integer", "minimum": 1 },
+            "ram": { "type": "number", "minimum": 0 },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}

+ 212 - 152
schemas/v1/schema.v1.json

@@ -1,34 +1,54 @@
 {
-  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
   "$id": "https://timmoth.github.io/RackPeek/schemas/v1/schema.v1.json",
   "title": "RackPeek Infrastructure Specification",
   "type": "object",
   "additionalProperties": false,
   "required": ["version", "resources"],
   "properties": {
-    "version": {
-      "type": "integer",
-      "const": 1
-    },
+    "version": { "type": "integer", "const": 1 },
     "resources": {
       "type": "array",
-      "items": {
-        "oneOf": [
-          { "$ref": "#/$defs/server" },
-          { "$ref": "#/$defs/firewall" },
-          { "$ref": "#/$defs/router" },
-          { "$ref": "#/$defs/switch" },
-          { "$ref": "#/$defs/accessPoint" },
-          { "$ref": "#/$defs/ups" },
-          { "$ref": "#/$defs/desktop" },
-          { "$ref": "#/$defs/laptop" },
-          { "$ref": "#/$defs/service" },
-          { "$ref": "#/$defs/system" }
-        ]
-      }
+      "items": { "$ref": "#/$defs/resource" }
     }
   },
+
   "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": { "type": "string" }
+    },
+
+    "resourceBase": {
+      "type": "object",
+      "required": ["kind", "name"],
+      "properties": {
+        "kind": { "type": "string" },
+        "name": { "type": "string", "minLength": 1 },
+
+        "tags": { "type": "array", "items": { "type": "string" }, "default": [] },
+        "labels": { "$ref": "#/$defs/labels", "default": {} },
+        "notes": { "type": ["string", "null"] },
+
+        "runsOn": { "type": ["string", "null"] }
+      }
+    },
+
+    "resource": {
+      "oneOf": [
+        { "$ref": "#/$defs/server" },
+        { "$ref": "#/$defs/firewall" },
+        { "$ref": "#/$defs/router" },
+        { "$ref": "#/$defs/switch" },
+        { "$ref": "#/$defs/accessPoint" },
+        { "$ref": "#/$defs/ups" },
+        { "$ref": "#/$defs/desktop" },
+        { "$ref": "#/$defs/laptop" },
+        { "$ref": "#/$defs/service" },
+        { "$ref": "#/$defs/system" }
+      ]
+    },
+
     "ram": {
       "type": "object",
       "required": ["size"],
@@ -38,6 +58,7 @@
         "mts": { "type": "integer", "minimum": 0 }
       }
     },
+
     "cpu": {
       "type": "object",
       "additionalProperties": false,
@@ -47,6 +68,7 @@
         "threads": { "type": "integer", "minimum": 1 }
       }
     },
+
     "drive": {
       "type": "object",
       "required": ["size"],
@@ -54,11 +76,12 @@
       "properties": {
         "type": {
           "type": "string",
-          "enum": ["nvme","ssd","hdd","sas","sata","usb","sdcard","micro-sd"]
+          "enum": ["nvme", "ssd", "hdd", "sas", "sata", "usb", "sdcard", "micro-sd"]
         },
         "size": { "type": "number", "minimum": 1 }
       }
     },
+
     "gpu": {
       "type": "object",
       "additionalProperties": false,
@@ -67,6 +90,7 @@
         "vram": { "type": "number", "minimum": 0 }
       }
     },
+
     "nic": {
       "type": "object",
       "additionalProperties": false,
@@ -74,18 +98,19 @@
         "type": {
           "type": "string",
           "enum": [
-            "rj45","sfp","sfp+","sfp28","sfp56",
-            "qsfp+","qsfp28","qsfp56","qsfp-dd",
-            "osfp","xfp","cx4","mgmt"
+            "rj45", "sfp", "sfp+", "sfp28", "sfp56",
+            "qsfp+", "qsfp28", "qsfp56", "qsfp-dd",
+            "osfp", "xfp", "cx4", "mgmt"
           ]
         },
         "speed": { "type": "number", "minimum": 0 },
         "ports": { "type": "integer", "minimum": 1 }
       }
     },
+
     "port": {
       "type": "object",
-      "required": ["type","speed","count"],
+      "required": ["type", "speed", "count"],
       "additionalProperties": false,
       "properties": {
         "type": { "type": "string" },
@@ -93,173 +118,208 @@
         "count": { "type": "integer", "minimum": 1 }
       }
     },
+
     "network": {
       "type": "object",
-      "required": ["ip","port","protocol"],
+      "required": ["ip", "port", "protocol"],
       "additionalProperties": false,
       "properties": {
         "ip": {
           "type": "string",
-          "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}$"
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
         },
         "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
-        "protocol": { "type": "string", "enum": ["TCP","UDP"] },
-        "url": { "type": "string" }
+        "protocol": { "type": "string", "enum": ["TCP", "UDP"] },
+        "url": { "type": "string", "format": "uri" }
       }
     },
 
     "server": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Server" },
-        "name": { "type": "string" },
-        "tags": { "type": "array", "items": { "type": "string" } },
-        "notes": { "type": "string" },
-        "runsOn": { "type": "string" },
-        "ram": { "$ref": "#/$defs/ram" },
-        "ipmi": { "type": "boolean" },
-        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
-        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
-        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Server" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "ipmi": { "type": "boolean" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "desktop": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Desktop" },
-        "name": { "type": "string" },
-        "notes": { "type": "string" },
-        "ram": { "$ref": "#/$defs/ram" },
-        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
-        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
-        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Desktop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "laptop": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Laptop" },
-        "name": { "type": "string" },
-        "notes": { "type": "string" },
-        "ram": { "$ref": "#/$defs/ram" },
-        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Laptop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "firewall": {
-      "type": "object",
-      "required": ["kind","name","ports"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Firewall" },
-        "name": { "type": "string" },
-        "model": { "type": "string" },
-        "managed": { "type": "boolean" },
-        "poe": { "type": "boolean" },
-        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Firewall" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "router": {
-      "type": "object",
-      "required": ["kind","name","ports"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Router" },
-        "name": { "type": "string" },
-        "model": { "type": "string" },
-        "managed": { "type": "boolean" },
-        "poe": { "type": "boolean" },
-        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Router" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "switch": {
-      "type": "object",
-      "required": ["kind","name","ports"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Switch" },
-        "name": { "type": "string" },
-        "model": { "type": "string" },
-        "managed": { "type": "boolean" },
-        "poe": { "type": "boolean" },
-        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Switch" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "accessPoint": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "AccessPoint" },
-        "name": { "type": "string" },
-        "tags": { "type": "array", "items": { "type": "string" } },
-        "model": { "type": "string" },
-        "speed": { "type": "number" }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "AccessPoint" },
+
+            "model": { "type": "string" },
+            "speed": { "type": "number", "minimum": 0 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "ups": {
-      "type": "object",
-      "required": ["kind","name"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Ups" },
-        "name": { "type": "string" },
-        "tags": { "type": "array", "items": { "type": "string" } },
-        "model": { "type": "string" },
-        "va": { "type": "integer", "minimum": 1 }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Ups" },
+
+            "model": { "type": "string" },
+            "va": { "type": "integer", "minimum": 1 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "service": {
-      "type": "object",
-      "required": ["kind","name","network","runsOn"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "Service" },
-        "name": { "type": "string" },
-        "runsOn": { "type": "string" },
-        "network": { "$ref": "#/$defs/network" }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["network"],
+          "properties": {
+            "kind": { "const": "Service" },
+            "network": { "$ref": "#/$defs/network" }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     },
 
     "system": {
-      "type": "object",
-      "required": ["kind","name","type","os","cores","ram"],
-      "additionalProperties": false,
-      "properties": {
-        "kind": { "const": "System" },
-        "name": { "type": "string" },
-        "notes": { "type": "string" },
-        "runsOn": { "type": "string" },
-        "type": {
-          "type": "string",
-          "enum": [
-            "baremetal","Baremetal",
-            "hypervisor","Hypervisor",
-            "vm","VM",
-            "container","embedded","cloud","other"
-          ]
-        },
-        "os": { "type": "string" },
-        "cores": { "type": "integer", "minimum": 1 },
-        "ram": { "type": "number", "minimum": 0 },
-        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
-      }
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["type", "os", "cores", "ram"],
+          "properties": {
+            "kind": { "const": "System" },
+
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal", "Baremetal",
+                "hypervisor", "Hypervisor",
+                "vm", "VM",
+                "container", "embedded", "cloud", "other"
+              ]
+            },
+            "os": { "type": "string" },
+            "cores": { "type": "integer", "minimum": 1 },
+            "ram": { "type": "number", "minimum": 0 },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
     }
   }
 }

+ 336 - 0
schemas/v2/schema.v2.json

@@ -0,0 +1,336 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v2/schema.v2.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["version", "resources"],
+  "properties": {
+    "version": { "type": "integer", "const": 2 },
+    "resources": {
+      "type": "array",
+      "items": { "$ref": "#/$defs/resource" }
+    }
+  },
+
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": { "type": "string" }
+    },
+
+    "runsOn": {
+      "type": ["array", "null"],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+
+    "resourceBase": {
+      "type": "object",
+      "required": ["kind", "name"],
+      "properties": {
+        "kind": { "type": "string" },
+        "name": { "type": "string", "minLength": 1 },
+
+        "tags": { "type": "array", "items": { "type": "string" }, "default": [] },
+        "labels": { "$ref": "#/$defs/labels", "default": {} },
+        "notes": { "type": ["string", "null"] },
+
+        "runsOn": {
+          "type": ["array", "null"],
+          "items": { "type": "string" }
+        }
+      }
+    },
+
+    "resource": {
+      "oneOf": [
+        { "$ref": "#/$defs/server" },
+        { "$ref": "#/$defs/firewall" },
+        { "$ref": "#/$defs/router" },
+        { "$ref": "#/$defs/switch" },
+        { "$ref": "#/$defs/accessPoint" },
+        { "$ref": "#/$defs/ups" },
+        { "$ref": "#/$defs/desktop" },
+        { "$ref": "#/$defs/laptop" },
+        { "$ref": "#/$defs/service" },
+        { "$ref": "#/$defs/system" }
+      ]
+    },
+
+    "ram": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "size": { "type": "number", "minimum": 0 },
+        "mts": { "type": "integer", "minimum": 0 }
+      }
+    },
+
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "cores": { "type": "integer", "minimum": 1 },
+        "threads": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "drive": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": ["nvme", "ssd", "hdd", "sas", "sata", "usb", "sdcard", "micro-sd"]
+        },
+        "size": { "type": "number", "minimum": 1 }
+      }
+    },
+
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "vram": { "type": "number", "minimum": 0 }
+      }
+    },
+
+    "nic": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45", "sfp", "sfp+", "sfp28", "sfp56",
+            "qsfp+", "qsfp28", "qsfp56", "qsfp-dd",
+            "osfp", "xfp", "cx4", "mgmt"
+          ]
+        },
+        "speed": { "type": "number", "minimum": 0 },
+        "ports": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "port": {
+      "type": "object",
+      "required": ["type", "speed", "count"],
+      "additionalProperties": false,
+      "properties": {
+        "type": { "type": "string" },
+        "speed": { "type": "number", "minimum": 0 },
+        "count": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "network": {
+      "type": "object",
+      "required": ["ip", "port", "protocol"],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
+        "protocol": { "type": "string", "enum": ["TCP", "UDP"] },
+        "url": { "type": "string", "format": "uri" }
+      }
+    },
+
+    "server": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Server" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "ipmi": { "type": "boolean" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "desktop": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Desktop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "laptop": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Laptop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "firewall": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Firewall" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "router": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Router" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "switch": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Switch" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "accessPoint": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "AccessPoint" },
+
+            "model": { "type": "string" },
+            "speed": { "type": "number", "minimum": 0 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "ups": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Ups" },
+
+            "model": { "type": "string" },
+            "va": { "type": "integer", "minimum": 1 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "service": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["network"],
+          "properties": {
+            "kind": { "const": "Service" },
+            "network": { "$ref": "#/$defs/network" }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "system": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["type", "os", "cores", "ram"],
+          "properties": {
+            "kind": { "const": "System" },
+
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal", "Baremetal",
+                "hypervisor", "Hypervisor",
+                "vm", "VM",
+                "container", "embedded", "cloud", "other"
+              ]
+            },
+            "os": { "type": "string" },
+            "cores": { "type": "integer", "minimum": 1 },
+            "ram": { "type": "number", "minimum": 0 },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}