Browse Source

Merge pull request #2 from Timmoth/Server-CRUD-Commands

Added basic server CRUD commands
Tim Jones 2 months ago
parent
commit
1800cc28be
51 changed files with 2041 additions and 546 deletions
  1. 4 0
      RackPeek.Domain/Resources/Hardware/IHardwareRepository.cs
  2. 1 1
      RackPeek.Domain/Resources/Hardware/Reports/ServerHardwareReport.cs
  3. 19 0
      RackPeek.Domain/Resources/Hardware/Server/AddServerUseCase.cs
  4. 29 0
      RackPeek.Domain/Resources/Hardware/Server/Cpu/AddCpuUseCase.cs
  5. 24 0
      RackPeek.Domain/Resources/Hardware/Server/Cpu/RemoveCpuUseCase.cs
  6. 31 0
      RackPeek.Domain/Resources/Hardware/Server/Cpu/UpdateCpuUseCase.cs
  7. 13 0
      RackPeek.Domain/Resources/Hardware/Server/DeleteServerUseCase.cs
  8. 40 0
      RackPeek.Domain/Resources/Hardware/Server/DescribeServerUseCase.cs
  9. 10 0
      RackPeek.Domain/Resources/Hardware/Server/GetServerUseCase.cs
  10. 10 0
      RackPeek.Domain/Resources/Hardware/Server/GetServersUseCase.cs
  11. 32 0
      RackPeek.Domain/Resources/Hardware/Server/UpdateServerUseCase.cs
  12. 1 1
      RackPeek/Commands/AccessPointReportCommand.cs
  13. 1 1
      RackPeek/Commands/DesktopReportCommand.cs
  14. 40 0
      RackPeek/Commands/Server/Cpus/ServerCpuAddCommand.cs
  15. 29 0
      RackPeek/Commands/Server/Cpus/ServerCpuRemoveCommand.cs
  16. 41 0
      RackPeek/Commands/Server/Cpus/ServerCpuSetCommand.cs
  17. 32 0
      RackPeek/Commands/Server/ServerAddCommand.cs
  18. 9 0
      RackPeek/Commands/Server/ServerCommands.cs
  19. 25 0
      RackPeek/Commands/Server/ServerDeleteCommand.cs
  20. 49 0
      RackPeek/Commands/Server/ServerDescribeCommand.cs
  21. 33 0
      RackPeek/Commands/Server/ServerGetByNameCommand.cs
  22. 53 0
      RackPeek/Commands/Server/ServerGetCommand.cs
  23. 1 1
      RackPeek/Commands/Server/ServerReportCommand.cs
  24. 36 0
      RackPeek/Commands/Server/ServerSetCommand.cs
  25. 1 1
      RackPeek/Commands/SwitchReportCommand.cs
  26. 1 1
      RackPeek/Commands/UpsReportCommand.cs
  27. 101 29
      RackPeek/Program.cs
  28. 1 1
      RackPeek/Yaml/Converters.cs
  29. 59 4
      RackPeek/Yaml/YamlHardwareRepository.cs
  30. 146 19
      RackPeek/Yaml/YamlResourceCollection.cs
  31. 1 1
      RackPeek/Yaml/YamlSystemRepository.cs
  32. 30 28
      RackPeek/aps.yaml
  33. 20 19
      RackPeek/desktops.yaml
  34. 13 12
      RackPeek/firewalls.yaml
  35. 16 15
      RackPeek/laptops.yaml
  36. 13 12
      RackPeek/routers.yaml
  37. 396 379
      RackPeek/servers.yaml
  38. 13 12
      RackPeek/switches.yaml
  39. 5 4
      RackPeek/ups.yaml
  40. 101 0
      Tests/Hardware/AddCpuUseCaseTests.cs
  41. 49 0
      Tests/Hardware/AddServerUseCaseTests.cs
  42. 43 0
      Tests/Hardware/DeleteServerUseCaseTests.cs
  43. 68 0
      Tests/Hardware/DescribeServerUseCaseTests.cs
  44. 59 0
      Tests/Hardware/GetServerUseCaseTests.cs
  45. 52 0
      Tests/Hardware/GetServersUseCaseTests.cs
  46. 85 0
      Tests/Hardware/RemoveCpuUseCaseTests.cs
  47. 1 1
      Tests/Hardware/ServerHardwareReportTests.cs
  48. 105 0
      Tests/Hardware/UpdateCpuUseCaseTests.cs
  49. 93 0
      Tests/Hardware/UpdateServerUseCaseTests.cs
  50. 3 2
      Tests/Yaml/HardwareDeserializationTests.cs
  51. 3 2
      Tests/Yaml/SystemDeserializationTests.cs

+ 4 - 0
RackPeek.Domain/Resources/Hardware/IHardwareRepository.cs

@@ -3,4 +3,8 @@ namespace RackPeek.Domain.Resources.Hardware;
 public interface IHardwareRepository
 {
     Task<IReadOnlyList<Models.Hardware>> GetAllAsync();
+    Task AddAsync(Models.Hardware hardware);
+    Task UpdateAsync(Models.Hardware hardware);
+    Task DeleteAsync(string name);
+    Task<Models.Hardware?> GetByNameAsync(string name);
 }

+ 1 - 1
RackPeek.Domain/Resources/Hardware/Reports/ServerHardwareReport.cs

@@ -29,7 +29,7 @@ public class ServerHardwareReportUseCase(IHardwareRepository repository)
     public async Task<ServerHardwareReport> ExecuteAsync()
     {
         var hardware = await repository.GetAllAsync();
-        var servers = hardware.OfType<Server>();
+        var servers = hardware.OfType<Models.Server>();
 
         var rows = servers.Select(server =>
         {

+ 19 - 0
RackPeek.Domain/Resources/Hardware/Server/AddServerUseCase.cs

@@ -0,0 +1,19 @@
+namespace RackPeek.Domain.Resources.Hardware.Server;
+
+public class AddServerUseCase(IHardwareRepository repository)
+{
+    public async Task ExecuteAsync(string name)
+    {
+        // basic guard rails
+        var existing = await repository.GetByNameAsync(name);
+        if (existing != null)
+            throw new InvalidOperationException($"Server '{name}' already exists.");
+
+        var server = new Models.Server
+        {
+            Name = name,
+        };
+
+        await repository.AddAsync(server);
+    }
+}

+ 29 - 0
RackPeek.Domain/Resources/Hardware/Server/Cpu/AddCpuUseCase.cs

@@ -0,0 +1,29 @@
+namespace RackPeek.Domain.Resources.Hardware.Server.Cpu;
+
+public class AddCpuUseCase(IHardwareRepository repository)
+{
+    public async Task ExecuteAsync(
+        string serverName,
+        string model,
+        int cores,
+        int threads)
+    {
+        var hardware = await repository.GetByNameAsync(serverName);
+
+        if (hardware is not Models.Server server)
+        {
+            return;
+        }
+
+        server.Cpus ??= [];
+        
+        server.Cpus.Add(new Models.Cpu
+        {
+            Model = model,
+            Cores = cores,
+            Threads = threads
+        });
+
+        await repository.UpdateAsync(server);
+    }
+}

+ 24 - 0
RackPeek.Domain/Resources/Hardware/Server/Cpu/RemoveCpuUseCase.cs

@@ -0,0 +1,24 @@
+namespace RackPeek.Domain.Resources.Hardware.Server.Cpu;
+
+public class RemoveCpuUseCase(IHardwareRepository repository)
+{
+    public async Task ExecuteAsync(
+        string serverName,
+        int index)
+    {
+        var hardware = await repository.GetByNameAsync(serverName);
+        if (hardware is not Models.Server server)
+        {
+            return;
+        }
+        
+        server.Cpus ??= [];
+
+        if (index < 0 || index >= server.Cpus.Count)
+            throw new ArgumentOutOfRangeException(nameof(index), "CPU index out of range.");
+
+        server.Cpus.RemoveAt(index);
+
+        await repository.UpdateAsync(server);
+    }
+}

+ 31 - 0
RackPeek.Domain/Resources/Hardware/Server/Cpu/UpdateCpuUseCase.cs

@@ -0,0 +1,31 @@
+namespace RackPeek.Domain.Resources.Hardware.Server.Cpu;
+
+public class UpdateCpuUseCase(IHardwareRepository repository)
+{
+    public async Task ExecuteAsync(
+        string serverName,
+        int index,
+        string model,
+        int cores,
+        int threads)
+    {
+        var hardware = await repository.GetByNameAsync(serverName);
+
+        if (hardware is not Models.Server server)
+        {
+            return;
+        }
+        
+        server.Cpus ??= [];
+        
+        if (index < 0 || index >= server.Cpus.Count)
+            throw new ArgumentOutOfRangeException(nameof(index), "CPU index out of range.");
+
+        var cpu = server.Cpus[index];
+        cpu.Model = model;
+        cpu.Cores = cores;
+        cpu.Threads = threads;
+
+        await repository.UpdateAsync(server);
+    }
+}

+ 13 - 0
RackPeek.Domain/Resources/Hardware/Server/DeleteServerUseCase.cs

@@ -0,0 +1,13 @@
+namespace RackPeek.Domain.Resources.Hardware.Server;
+
+public class DeleteServerUseCase(IHardwareRepository repository)
+{
+    public async Task ExecuteAsync(string name)
+    {
+        var hardware = await repository.GetByNameAsync(name);
+        if (hardware == null)
+            throw new InvalidOperationException($"Server '{name}' not found.");
+
+        await repository.DeleteAsync(name);
+    }
+}

+ 40 - 0
RackPeek.Domain/Resources/Hardware/Server/DescribeServerUseCase.cs

@@ -0,0 +1,40 @@
+namespace RackPeek.Domain.Resources.Hardware.Server;
+
+public record ServerDescription(
+    string Name,
+    string CpuSummary,
+    int TotalCores,
+    int TotalThreads,
+    int RamGb,
+    int TotalStorageGb,
+    int NicPorts,
+    bool Ipmi
+);
+
+public class DescribeServerUseCase(IHardwareRepository repository)
+{
+    public async Task<ServerDescription?> ExecuteAsync(string name)
+    {
+        var server = await repository.GetByNameAsync(name) as Models.Server;
+        if (server == null)
+            return null;
+
+        var cpuSummary = server.Cpus == null
+            ? "Unknown"
+            : string.Join(", ",
+                server.Cpus
+                    .GroupBy(c => c.Model)
+                    .Select(g => $"{g.Count()}× {g.Key}"));
+
+        return new ServerDescription(
+            Name: server.Name,
+            CpuSummary: cpuSummary,
+            TotalCores: server.Cpus?.Sum(c => c.Cores) ?? 0,
+            TotalThreads: server.Cpus?.Sum(c => c.Threads) ?? 0,
+            RamGb: server.Ram?.Size ?? 0,
+            TotalStorageGb: server.Drives?.Sum(d => d.Size) ?? 0,
+            NicPorts: server.Nics?.Sum(n => n.Ports) ?? 0,
+            Ipmi: server.Ipmi ?? false
+        );
+    }
+}

+ 10 - 0
RackPeek.Domain/Resources/Hardware/Server/GetServerUseCase.cs

@@ -0,0 +1,10 @@
+namespace RackPeek.Domain.Resources.Hardware.Server;
+
+public class GetServerUseCase(IHardwareRepository repository)
+{
+    public async Task<Models.Server?> ExecuteAsync(string name)
+    {
+        var hardware = await repository.GetByNameAsync(name);
+        return hardware as Models.Server;
+    }
+}

+ 10 - 0
RackPeek.Domain/Resources/Hardware/Server/GetServersUseCase.cs

@@ -0,0 +1,10 @@
+namespace RackPeek.Domain.Resources.Hardware.Server;
+
+public class GetServersUseCase(IHardwareRepository repository)
+{
+    public async Task<IReadOnlyList<Models.Server>> ExecuteAsync()
+    {
+        var hardware = await repository.GetAllAsync();
+        return hardware.OfType<Models.Server>().ToList();
+    }
+}

+ 32 - 0
RackPeek.Domain/Resources/Hardware/Server/UpdateServerUseCase.cs

@@ -0,0 +1,32 @@
+using RackPeek.Domain.Resources.Hardware.Models;
+
+namespace RackPeek.Domain.Resources.Hardware.Server;
+
+public class UpdateServerUseCase(IHardwareRepository repository)
+{
+    public async Task ExecuteAsync(
+        string name,
+        int? ramGb = null,
+        bool? ipmi = null
+    )
+    {
+        var server = await repository.GetByNameAsync(name) as Models.Server;
+        if (server == null)
+            throw new InvalidOperationException($"Server '{name}' not found.");
+
+        // ---- RAM ----
+        if (ramGb.HasValue)
+        {
+            server.Ram ??= new Ram();
+            server.Ram.Size = ramGb.Value;
+        }
+
+        // ---- IPMI ----
+        if (ipmi.HasValue)
+        {
+            server.Ipmi = ipmi.Value;
+        }
+
+        await repository.UpdateAsync(server);
+    }
+}

+ 1 - 1
RackPeek/Commands/AccessPointReportCommand.cs

@@ -4,7 +4,7 @@ using RackPeek.Domain.Resources.Hardware.Reports;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
-namespace RackPeek;
+namespace RackPeek.Commands;
 
 public class AccessPointReportCommand(
     ILogger<AccessPointReportCommand> logger,

+ 1 - 1
RackPeek/Commands/DesktopReportCommand.cs

@@ -4,7 +4,7 @@ using RackPeek.Domain.Resources.Hardware.Reports;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
-namespace RackPeek;
+namespace RackPeek.Commands;
 
 public class DesktopReportCommand(
     ILogger<DesktopReportCommand> logger,

+ 40 - 0
RackPeek/Commands/Server/Cpus/ServerCpuAddCommand.cs

@@ -0,0 +1,40 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Hardware.Server.Cpu;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server.Cpus;
+public class ServerCpuAddSettings : ServerNameSettings
+{
+    [CommandOption("--model <MODEL>")]
+    public string Model { get; set; }
+
+    [CommandOption("--cores <CORES>")]
+    public int Cores { get; set; }
+    
+    [CommandOption("--threads <THREADS>")]
+    public int Threads { get; set; }
+}
+
+public class ServerCpuAddCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServerCpuAddSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServerCpuAddSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<AddCpuUseCase>();
+
+        await useCase.ExecuteAsync(
+            settings.Name,
+            settings.Model,
+            settings.Cores,
+            settings.Threads);
+
+        AnsiConsole.MarkupLine($"[green]CPU added to '{settings.Name}'.[/]");
+        return 0;
+    }
+}

+ 29 - 0
RackPeek/Commands/Server/Cpus/ServerCpuRemoveCommand.cs

@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Hardware.Server.Cpu;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server.Cpus;
+public class ServerCpuRemoveSettings : ServerNameSettings
+{
+    [CommandOption("--index <INDEX>")]
+    public int Index { get; set; }
+}
+public class ServerCpuRemoveCommand(IServiceProvider serviceProvider) : AsyncCommand<ServerCpuRemoveSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServerCpuRemoveSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<RemoveCpuUseCase>();
+
+        await useCase.ExecuteAsync(
+            settings.Name,
+            settings.Index);
+
+        AnsiConsole.MarkupLine($"[green]CPU {settings.Index} removed from '{settings.Name}'.[/]");
+        return 0;
+    }
+}

+ 41 - 0
RackPeek/Commands/Server/Cpus/ServerCpuSetCommand.cs

@@ -0,0 +1,41 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Hardware.Server.Cpu;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server.Cpus;
+public class ServerCpuSetSettings : ServerNameSettings
+{
+    [CommandOption("--index <INDEX>")]
+    public int Index { get; set; }
+
+    [CommandOption("--model <MODEL>")]
+    public string Model { get; set; }
+
+    [CommandOption("--cores <CORES>")]
+    public int Cores { get; set; }
+    
+    [CommandOption("--threads <THREADS>")]
+    public int Threads { get; set; }
+}
+public class ServerCpuSetCommand(IServiceProvider serviceProvider) : AsyncCommand<ServerCpuSetSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServerCpuSetSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<UpdateCpuUseCase>();
+
+        await useCase.ExecuteAsync(
+            settings.Name,
+            settings.Index,
+            settings.Model,
+            settings.Cores,
+            settings.Threads);
+
+        AnsiConsole.MarkupLine($"[green]CPU {settings.Index} updated on '{settings.Name}'.[/]");
+        return 0;
+    }
+}

+ 32 - 0
RackPeek/Commands/Server/ServerAddCommand.cs

@@ -0,0 +1,32 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Hardware.Server;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server;
+public class ServerAddSettings : CommandSettings
+{
+    [CommandArgument(0, "<name>")]
+    public string Name { get; set; } = default!;
+}
+
+public class ServerAddCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServerAddSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServerAddSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<AddServerUseCase>();
+
+        await useCase.ExecuteAsync(
+            settings.Name
+        );
+
+        AnsiConsole.MarkupLine($"[green]Server '{settings.Name}' added.[/]");
+        return 0;
+    }
+}

+ 9 - 0
RackPeek/Commands/Server/ServerCommands.cs

@@ -0,0 +1,9 @@
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server;
+
+public class ServerNameSettings : CommandSettings
+{
+    [CommandArgument(0, "<name>")]
+    public string Name { get; set; } = default!;
+}

+ 25 - 0
RackPeek/Commands/Server/ServerDeleteCommand.cs

@@ -0,0 +1,25 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Hardware.Server;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server;
+
+public class ServerDeleteCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServerNameSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServerNameSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<DeleteServerUseCase>();
+
+        await useCase.ExecuteAsync(settings.Name);
+
+        AnsiConsole.MarkupLine($"[green]Server '{settings.Name}' deleted.[/]");
+        return 0;
+    }
+}

+ 49 - 0
RackPeek/Commands/Server/ServerDescribeCommand.cs

@@ -0,0 +1,49 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Hardware.Server;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server;
+
+public class ServerDescribeCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServerNameSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServerNameSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<GetServerUseCase>();
+
+        var server = await useCase.ExecuteAsync(settings.Name);
+
+        if (server == null)
+        {
+            AnsiConsole.MarkupLine($"[red]Server '{settings.Name}' not found.[/]");
+            return 1;
+        }
+
+        var grid = new Grid()
+            .AddColumn()
+            .AddColumn();
+
+        grid.AddRow("Name", server.Name);
+        grid.AddRow("IPMI", server.Ipmi == true ? "yes" : "no");
+        grid.AddRow("RAM", $"{server.Ram?.Size ?? 0} GB");
+
+        if (server.Cpus != null)
+        {
+            foreach (var cpu in server.Cpus)
+                grid.AddRow("CPU", $"{cpu.Model} ({cpu.Cores}/{cpu.Threads})");
+        }
+
+        AnsiConsole.Write(
+            new Panel(grid)
+                .Header("Server")
+                .Border(BoxBorder.Rounded));
+
+        return 0;
+    }
+}

+ 33 - 0
RackPeek/Commands/Server/ServerGetByNameCommand.cs

@@ -0,0 +1,33 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Hardware.Server;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server;
+
+public class ServerGetByNameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServerNameSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServerNameSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<GetServerUseCase>();
+
+        var server = await useCase.ExecuteAsync(settings.Name);
+
+        if (server == null)
+        {
+            AnsiConsole.MarkupLine($"[red]Server '{settings.Name}' not found.[/]");
+            return 1;
+        }
+
+        AnsiConsole.MarkupLine(
+            $"[green]{server.Name}[/]  RAM: {server.Ram?.Size} GB, IPMI: {(server.Ipmi == true ? "yes" : "no")}");
+
+        return 0;
+    }
+}

+ 53 - 0
RackPeek/Commands/Server/ServerGetCommand.cs

@@ -0,0 +1,53 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Hardware.Reports;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server;
+
+public class ServerGetCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<ServerHardwareReportUseCase>();
+
+        var report = await useCase.ExecuteAsync();
+
+        if (report.Servers.Count == 0)
+        {
+            AnsiConsole.MarkupLine("[yellow]No servers found.[/]");
+            return 0;
+        }
+
+        var table = new Table()
+            .Border(TableBorder.Rounded)
+            .AddColumn("Name")
+            .AddColumn("CPU")
+            .AddColumn("C/T")
+            .AddColumn("RAM")
+            .AddColumn("Storage")
+            .AddColumn("NICs")
+            .AddColumn("IPMI");
+
+        foreach (var s in report.Servers)
+        {
+            table.AddRow(
+                s.Name,
+                s.CpuSummary,
+                $"{s.TotalCores}/{s.TotalThreads}",
+                $"{s.RamGb} GB",
+                $"{s.TotalStorageGb} GB",
+                $"{s.TotalNicPorts}×{s.MaxNicSpeedGb}G",
+                s.Ipmi ? "[green]yes[/]" : "[red]no[/]"
+            );
+        }
+
+        AnsiConsole.Write(table);
+        return 0;
+    }
+}

+ 1 - 1
RackPeek/Commands/ServerReportCommand.cs → RackPeek/Commands/Server/ServerReportCommand.cs

@@ -4,7 +4,7 @@ using RackPeek.Domain.Resources.Hardware.Reports;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
-namespace RackPeek;
+namespace RackPeek.Commands.Server;
 public class ServerReportCommand(ILogger<ServerReportCommand> logger, IServiceProvider serviceProvider)
     : AsyncCommand
 {

+ 36 - 0
RackPeek/Commands/Server/ServerSetCommand.cs

@@ -0,0 +1,36 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Hardware.Server;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server;
+public class ServerSetSettings : ServerNameSettings
+{
+    [CommandOption("--ram <GB>")]
+    public int RamGb { get; set; }
+
+    [CommandOption("--ipmi")]
+    public bool Ipmi { get; set; }
+}
+
+public class ServerSetCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServerSetSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServerSetSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<UpdateServerUseCase>();
+
+        await useCase.ExecuteAsync(
+            settings.Name,
+            settings.RamGb,
+            settings.Ipmi);
+
+        AnsiConsole.MarkupLine($"[green]Server '{settings.Name}' updated.[/]");
+        return 0;
+    }
+}

+ 1 - 1
RackPeek/Commands/SwitchReportCommand.cs

@@ -4,7 +4,7 @@ using RackPeek.Domain.Resources.Hardware.Reports;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
-namespace RackPeek;
+namespace RackPeek.Commands;
 
 public class SwitchReportCommand(
     ILogger<SwitchReportCommand> logger,

+ 1 - 1
RackPeek/Commands/UpsReportCommand.cs

@@ -4,7 +4,7 @@ using RackPeek.Domain.Resources.Hardware.Reports;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
-namespace RackPeek;
+namespace RackPeek.Commands;
 
 public class UpsReportCommand(
     ILogger<UpsReportCommand> logger,

+ 101 - 29
RackPeek/Program.cs

@@ -4,7 +4,13 @@ using RackPeek.Domain.Resources.Hardware;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Microsoft.Extensions.Logging;
+using RackPeek.Commands;
+using RackPeek.Commands.Server;
+using RackPeek.Commands.Server.Cpus;
 using RackPeek.Domain.Resources.Hardware.Reports;
+using RackPeek.Domain.Resources.Hardware.Server;
+using RackPeek.Domain.Resources.Hardware.Server.Cpu;
+using RackPeek.Yaml;
 
 namespace RackPeek;
 
@@ -23,33 +29,21 @@ public static class Program
 
         services.AddSingleton<IConfiguration>(configuration);
 
-        // Application
-        services.AddScoped<ServerHardwareReportUseCase>();
-        services.AddScoped<ServerReportCommand>();
-        services.AddScoped<AccessPointHardwareReportUseCase>();
-        services.AddScoped<AccessPointReportCommand>();
-        services.AddScoped<SwitchHardwareReportUseCase>();
-        services.AddScoped<SwitchReportCommand>();
-        services.AddScoped<UpsHardwareReportUseCase>();
-        services.AddScoped<UpsReportCommand>();
-        services.AddScoped<DesktopHardwareReportUseCase>();
-        services.AddScoped<DesktopReportCommand>();
-
         // Infrastructure
         services.AddScoped<IHardwareRepository>(_ =>
         {
             var path = configuration["HardwareFile"] ?? "hardware.yaml";
             
             var collection = new YamlResourceCollection();
-            collection.Load([
-                File.ReadAllText("servers.yaml"),
-                File.ReadAllText("aps.yaml"),
-                File.ReadAllText("desktops.yaml"),
-                File.ReadAllText("switches.yaml"),
-                File.ReadAllText("ups.yaml"),
-                File.ReadAllText("firewalls.yaml"),
-                File.ReadAllText("laptops.yaml"),
-                File.ReadAllText("routers.yaml")]);
+            collection.LoadFiles([
+                "servers.yaml",
+                "aps.yaml",
+                "desktops.yaml",
+                "switches.yaml",
+                "ups.yaml",
+                "firewalls.yaml",
+                "laptops.yaml",
+                "routers.yaml"]);
 
             return new YamlHardwareRepository(collection);
         });
@@ -59,6 +53,44 @@ public static class Program
                 .AddSimpleConsole(opts => { opts.TimestampFormat = "yyyy-MM-dd HH:mm:ss "; }));
 
 
+        
+        // Application
+        services.AddScoped<ServerHardwareReportUseCase>();
+        services.AddScoped<ServerReportCommand>();
+        services.AddScoped<AccessPointHardwareReportUseCase>();
+        services.AddScoped<AccessPointReportCommand>();
+        services.AddScoped<SwitchHardwareReportUseCase>();
+        services.AddScoped<SwitchReportCommand>();
+        services.AddScoped<UpsHardwareReportUseCase>();
+        services.AddScoped<UpsReportCommand>();
+        services.AddScoped<DesktopHardwareReportUseCase>();
+        services.AddScoped<DesktopReportCommand>();
+        
+        services.AddScoped<AddServerUseCase>();
+        services.AddScoped<ServerAddCommand>();
+        
+        services.AddScoped<DeleteServerUseCase>();
+        services.AddScoped<ServerDeleteCommand>();
+
+        services.AddScoped<DescribeServerUseCase>();
+        services.AddScoped<ServerDescribeCommand>();
+        
+        services.AddScoped<GetServerUseCase>();
+        services.AddScoped<ServerGetByNameCommand>();
+        
+        services.AddScoped<UpdateServerUseCase>();
+        services.AddScoped<ServerSetCommand>();
+        
+        // CPU use cases
+        services.AddScoped<AddCpuUseCase>();
+        services.AddScoped<UpdateCpuUseCase>();
+        services.AddScoped<RemoveCpuUseCase>();
+
+        // CPU commands
+        services.AddScoped<ServerCpuAddCommand>();
+        services.AddScoped<ServerCpuSetCommand>();
+        services.AddScoped<ServerCpuRemoveCommand>();
+        
         // Spectre bootstrap
         var registrar = new TypeRegistrar(services);
         var app = new CommandApp(registrar);
@@ -67,21 +99,61 @@ public static class Program
         {
             config.SetApplicationName("rackpeek");
 
-            config.AddCommand<ServerReportCommand>("servers")
-                .WithDescription("Show server hardware report");
-
+            // ----------------------------
+            // Server commands (CRUD-style)
+            // ----------------------------
+            config.AddBranch("servers", server =>
+            {
+                server.SetDescription("Manage servers");
+                
+                server.AddCommand<ServerReportCommand>("summary")
+                    .WithDescription("Show server hardware report");
+                
+                server.AddCommand<ServerAddCommand>("add")
+                    .WithDescription("Add a new server");
+
+                server.AddCommand<ServerGetByNameCommand>("get")
+                    .WithDescription("List servers or get a server by name");
+
+                server.AddCommand<ServerDescribeCommand>("describe")
+                    .WithDescription("Show detailed information about a server");
+
+                server.AddCommand<ServerSetCommand>("set")
+                    .WithDescription("Update server properties");
+
+                server.AddCommand<ServerDeleteCommand>("del")
+                    .WithDescription("Delete a server");
+                
+                server.AddBranch("cpu", cpu =>
+                {
+                    cpu.SetDescription("Manage server CPUs");
+
+                    cpu.AddCommand<ServerCpuAddCommand>("add")
+                        .WithDescription("Add a CPU to a server");
+
+                    cpu.AddCommand<ServerCpuSetCommand>("set")
+                        .WithDescription("Update a CPU on a server");
+
+                    cpu.AddCommand<ServerCpuRemoveCommand>("del")
+                        .WithDescription("Remove a CPU from a server");
+                });
+            });
+
+            // ----------------------------
+            // Reports (read-only summaries)
+            // ----------------------------
             config.AddCommand<AccessPointReportCommand>("ap")
                 .WithDescription("Show access point hardware report");
-            
+
             config.AddCommand<DesktopReportCommand>("desktops")
                 .WithDescription("Show desktop hardware report");
-            
+
             config.AddCommand<SwitchReportCommand>("switches")
                 .WithDescription("Show switch hardware report");
-            
+
             config.AddCommand<UpsReportCommand>("ups")
-                .WithDescription("Show ups hardware report");
-            
+                .WithDescription("Show UPS hardware report");
+
             config.ValidateExamples();
         });
 

+ 1 - 1
RackPeek/Yaml/Converters.cs

@@ -4,7 +4,7 @@ using YamlDotNet.Core;
 using YamlDotNet.Core.Events;
 using YamlDotNet.Serialization;
 
-namespace RackPeek;
+namespace RackPeek.Yaml;
 
 public static class StorageSizeParser
 {

+ 59 - 4
RackPeek/Yaml/YamlHardwareRepository.cs

@@ -1,12 +1,67 @@
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Hardware.Models;
 
-namespace RackPeek;
+namespace RackPeek.Yaml;
 
-public class YamlHardwareRepository(YamlResourceCollection resourceCollection) : IHardwareRepository
+public class YamlHardwareRepository : IHardwareRepository
 {
+    private readonly YamlResourceCollection _resources;
+
+    public YamlHardwareRepository(YamlResourceCollection resources)
+    {
+        _resources = resources;
+    }
+
     public Task<IReadOnlyList<Hardware>> GetAllAsync()
+        => Task.FromResult(_resources.HardwareResources);
+
+    public Task<Hardware?> GetByNameAsync(string name)
+        => Task.FromResult(_resources.GetByName(name) as Hardware);
+
+    public Task AddAsync(Hardware hardware)
     {
-        return Task.FromResult(resourceCollection.HardwareResources);
+        if (_resources.HardwareResources.Any(r =>
+            r.Name.Equals(hardware.Name, StringComparison.OrdinalIgnoreCase)))
+        {
+            throw new InvalidOperationException(
+                $"Hardware with name '{hardware.Name}' already exists.");
+        }
+
+        // Use first file as default for new resources
+        var targetFile = _resources.SourceFiles.FirstOrDefault()
+                         ?? throw new InvalidOperationException("No YAML file loaded.");
+
+        _resources.Add(hardware, targetFile);
+        _resources.SaveAll();
+
+        return Task.CompletedTask;
+    }
+
+    public Task UpdateAsync(Hardware hardware)
+    {
+        var existing = _resources.HardwareResources
+            .FirstOrDefault(r => r.Name.Equals(hardware.Name, StringComparison.OrdinalIgnoreCase));
+
+        if (existing == null)
+            throw new InvalidOperationException($"Hardware '{hardware.Name}' not found.");
+
+        _resources.Update(hardware);
+        _resources.SaveAll();
+
+        return Task.CompletedTask;
+    }
+
+    public Task DeleteAsync(string name)
+    {
+        var existing = _resources.HardwareResources
+            .FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+
+        if (existing == null)
+            throw new InvalidOperationException($"Hardware '{name}' not found.");
+
+        _resources.Delete(name);
+        _resources.SaveAll();
+
+        return Task.CompletedTask;
     }
-}
+}

+ 146 - 19
RackPeek/Yaml/YamlResourceCollection.cs

@@ -1,26 +1,149 @@
+using System.Collections.Specialized;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.Hardware.Models;
 using RackPeek.Domain.Resources.SystemResources;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization.NamingConventions;
 
-namespace RackPeek;
+namespace RackPeek.Yaml;
 
-public class YamlResourceCollection
+public sealed class YamlResourceCollection
 {
-    private readonly List<Resource> _resources = new();
-    
-    public IReadOnlyList<Hardware> HardwareResources => _resources.OfType<Hardware>().ToList();
-    public IReadOnlyList<SystemResource> SystemResources => _resources.OfType<SystemResource>().ToList();
+    private readonly List<ResourceEntry> _entries = [];
+    public IReadOnlyList<string> SourceFiles => _entries.Select(e => e.SourceFile).Distinct().ToList();
 
-    public void Load(List<string> yamlContents)
+    public IReadOnlyList<Hardware> HardwareResources =>
+        _entries.Select(e => e.Resource).OfType<Hardware>().ToList();
+
+    public IReadOnlyList<SystemResource> SystemResources =>
+        _entries.Select(e => e.Resource).OfType<SystemResource>().ToList();
+
+    public void LoadFiles(IEnumerable<string> filePaths)
+    {
+        foreach (var file in filePaths)
+        {
+            var yaml = File.ReadAllText(file);
+            foreach (var resource in Deserialize(yaml))
+            {
+                _entries.Add(new ResourceEntry(resource, file));
+            }
+        }
+    }
+
+    public void Load(string yaml, string file)
+    {
+        foreach (var resource in Deserialize(yaml))
+        {
+            _entries.Add(new ResourceEntry(resource, file));
+        }
+    }
+
+    public void SaveAll()
+    {
+        foreach (var group in _entries.GroupBy(e => e.SourceFile))
+        {
+            SaveToFile(group.Key, group.Select(e => e.Resource));
+        }
+    }
+
+    // ----------------------------
+    // CRUD operations
+    // ----------------------------
+
+    public void Add(Resource resource, string sourceFile)
+    {
+        _entries.Add(new ResourceEntry(resource, sourceFile));
+    }
+
+    public void Update(Resource resource)
+    {
+        var existing = _entries.FirstOrDefault(e =>
+            e.Resource.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase));
+
+        if (existing == null)
+            throw new InvalidOperationException($"Resource '{resource.Name}' not found.");
+
+        // keep file ownership
+        _entries.Remove(existing);
+        _entries.Add(new ResourceEntry(resource, existing.SourceFile));
+    }
+
+    public void Delete(string name)
+    {
+        var existing = _entries.FirstOrDefault(e =>
+            e.Resource.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+
+        if (existing == null)
+            throw new InvalidOperationException($"Resource '{name}' not found.");
+
+        _entries.Remove(existing);
+    }
+
+    public Resource? GetByName(string name)
+    {
+        return _entries
+            .Select(e => e.Resource)
+            .FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+    }
+
+    // ----------------------------
+    // Serialization helpers
+    // ----------------------------
+
+    private static void SaveToFile(string filePath, IEnumerable<Resource> resources)
+    {
+        var serializer = new SerializerBuilder()
+            .WithNamingConvention(CamelCaseNamingConvention.Instance)
+            .Build();
+
+        var payload = new OrderedDictionary
+        {
+            ["resources"] = resources
+                .Select(SerializeResource)
+                .ToList()
+        };
+
+        File.WriteAllText(filePath, serializer.Serialize(payload));
+    }
+
+    private static OrderedDictionary SerializeResource(Resource resource)
     {
-        foreach (var yamlContent in yamlContents)
+        var map = new OrderedDictionary
+        {
+            ["kind"] = resource switch
+            {
+                Server => "Server",
+                Switch => "Switch",
+                Firewall => "Firewall",
+                Router => "Router",
+                Desktop => "Desktop",
+                Laptop => "Laptop",
+                AccessPoint => "AccessPoint",
+                Ups => "Ups",
+                SystemResource => "System",
+                _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")
+            }
+        };
+
+        var serializer = new SerializerBuilder()
+            .WithNamingConvention(CamelCaseNamingConvention.Instance)
+            .Build();
+
+        var yaml = serializer.Serialize(resource);
+
+        var props = new DeserializerBuilder()
+            .Build()
+            .Deserialize<Dictionary<string, object?>>(yaml);
+
+        foreach (var (key, value) in props)
         {
-            _resources.AddRange(Deserialize(yamlContent));
+            if (key == "kind") continue;
+            map[key] = value;
         }
+
+        return map;
     }
-    
+
     private static List<Resource> Deserialize(string yaml)
     {
         var deserializer = new DeserializerBuilder()
@@ -29,19 +152,24 @@ public class YamlResourceCollection
             .WithTypeConverter(new StorageSizeYamlConverter())
             .Build();
 
-        var raw = deserializer.Deserialize<Dictionary<string, List<Dictionary<string, object>>>>(yaml);
+        var raw = deserializer.Deserialize<
+            Dictionary<string, List<Dictionary<string, object>>>>(yaml);
+
+        if (!raw.TryGetValue("resources", out var items))
+            return [];
 
         var resources = new List<Resource>();
 
-        foreach (var item in raw["resources"])
+        foreach (var item in items)
         {
             var kind = item["kind"].ToString();
+            var typedYaml = new SerializerBuilder()
+                .WithNamingConvention(CamelCaseNamingConvention.Instance)
+                .Build()
+                .Serialize(item);
 
-            var typedYaml = new SerializerBuilder().Build().Serialize(item);
-    
             Resource resource = kind switch
             {
-                // Hardware
                 "Server" => deserializer.Deserialize<Server>(typedYaml),
                 "Switch" => deserializer.Deserialize<Switch>(typedYaml),
                 "Firewall" => deserializer.Deserialize<Firewall>(typedYaml),
@@ -50,8 +178,6 @@ public class YamlResourceCollection
                 "Laptop" => deserializer.Deserialize<Laptop>(typedYaml),
                 "AccessPoint" => deserializer.Deserialize<AccessPoint>(typedYaml),
                 "Ups" => deserializer.Deserialize<Ups>(typedYaml),
-                
-                // System
                 "System" => deserializer.Deserialize<SystemResource>(typedYaml),
                 _ => throw new InvalidOperationException($"Unknown kind: {kind}")
             };
@@ -61,5 +187,6 @@ public class YamlResourceCollection
 
         return resources;
     }
-    
-}
+
+    private sealed record ResourceEntry(Resource Resource, string SourceFile);
+}

+ 1 - 1
RackPeek/Yaml/YamlSystemRepository.cs

@@ -1,6 +1,6 @@
 using RackPeek.Domain.Resources.SystemResources;
 
-namespace RackPeek;
+namespace RackPeek.Yaml;
 
 public class YamlSystemRepository(YamlResourceCollection resourceCollection) : ISystemRepository
 {

+ 30 - 28
RackPeek/aps.yaml

@@ -1,29 +1,31 @@
 resources:
-  - kind: AccessPoint
-    name: lounge-ap
-    model: Unifi-Ap-Pro
-    speed: 1gb
-  - kind: AccessPoint
-    name: office-ap
-    model: Unifi-U6-Lite
-    speed: 1gb
-
-  - kind: AccessPoint
-    name: garage-ap
-    model: TP-Link-EAP245
-    speed: 1gb
-
-  - kind: AccessPoint
-    name: upstairs-ap
-    model: Aruba-AP-515
-    speed: 2.5gb
-
-  - kind: AccessPoint
-    name: guest-ap
-    model: Unifi-U6-Mesh
-    speed: 1gb
-
-  - kind: AccessPoint
-    name: warehouse-ap
-    model: Cisco-Aironet-1832i
-    speed: 1gb
+- kind: AccessPoint
+  model: Unifi-Ap-Pro
+  speed: 1
+  name: lounge-ap
+  tags: 
+- kind: AccessPoint
+  model: Unifi-U6-Lite
+  speed: 1
+  name: office-ap
+  tags: 
+- kind: AccessPoint
+  model: TP-Link-EAP245
+  speed: 1
+  name: garage-ap
+  tags: 
+- kind: AccessPoint
+  model: Aruba-AP-515
+  speed: 2.5
+  name: upstairs-ap
+  tags: 
+- kind: AccessPoint
+  model: Unifi-U6-Mesh
+  speed: 1
+  name: guest-ap
+  tags: 
+- kind: AccessPoint
+  model: Cisco-Aironet-1832i
+  speed: 1
+  name: warehouse-ap
+  tags: 

+ 20 - 19
RackPeek/desktops.yaml

@@ -1,20 +1,21 @@
 resources:
-  - kind: Desktop
-    name: dell-optiplex
-    cpus:
-      - model: Intel(R) Core(TM) i5-9500
-        cores: 6
-        threads: 6
-    ram:
-      size: 16gb
-      mts: 2666
-    drives:
-      - type: ssd
-        size: 512gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 1
-    gpus:
-      - model: RTX 3080
-        vram: 12gb
+- kind: Desktop
+  cpus:
+  - model: Intel(R) Core(TM) i5-9500
+    cores: 6
+    threads: 6
+  ram:
+    size: 16
+    mts: 2666
+  drives:
+  - type: ssd
+    size: 512
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 1
+  gpus:
+  - model: RTX 3080
+    vram: 12
+  name: dell-optiplex
+  tags: 

+ 13 - 12
RackPeek/firewalls.yaml

@@ -1,13 +1,14 @@
 resources:
-  - kind: Firewall
-    name: pfsense
-    model: pfSense-1100
-    ports:
-      - type: rj45
-        speed: 1gb
-        count: 8
-      - type: sfp
-        speed: 10gb
-        count: 2
-    managed: true
-    poe: true
+- kind: Firewall
+  model: pfSense-1100
+  managed: true
+  poe: true
+  ports:
+  - type: rj45
+    speed: 1
+    count: 8
+  - type: sfp
+    speed: 10
+    count: 2
+  name: pfsense
+  tags: 

+ 16 - 15
RackPeek/laptops.yaml

@@ -1,16 +1,17 @@
 resources:
-  - kind: Laptop
-    name: thinkpad-x1
-    cpus:
-      - model: Intel(R) Core(TM) i7-10510U
-        cores: 4
-        threads: 8
-    ram:
-      size: 16gb
-      mts: 2666
-    drives:
-      - type: ssd
-        size: 1tb
-    gpus:
-      - model: RTX 3080
-        vram: 12gb
+- kind: Laptop
+  cpus:
+  - model: Intel(R) Core(TM) i7-10510U
+    cores: 4
+    threads: 8
+  ram:
+    size: 16
+    mts: 2666
+  drives:
+  - type: ssd
+    size: 1024
+  gpus:
+  - model: RTX 3080
+    vram: 12
+  name: thinkpad-x1
+  tags: 

+ 13 - 12
RackPeek/routers.yaml

@@ -1,13 +1,14 @@
 resources:
-  - kind: Router
-    name: ubiquiti-edge-router
-    model: ER-4
-    ports:
-      - type: rj45
-        speed: 1gb
-        count: 8
-      - type: sfp
-        speed: 10gb
-        count: 2
-    managed: true
-    poe: true
+- kind: Router
+  model: ER-4
+  managed: true
+  poe: true
+  ports:
+  - type: rj45
+    speed: 1
+    count: 8
+  - type: sfp
+    speed: 10
+    count: 2
+  name: ubiquiti-edge-router
+  tags: 

+ 396 - 379
RackPeek/servers.yaml

@@ -1,380 +1,397 @@
 resources:
-  - kind: Server
-    name: dell-c6400-node01
-    cpus:
-      - model: Intel(R) Xeon(R) Silver 4110
-        cores: 8
-        threads: 16
-    ram:
-      size: 64gb
-      mts: 2400
-    drives:
-      - type: ssd
-        size: 480gb
-      - type: ssd
-        size: 480gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-      - type: sfp+
-        speed: 10gb
-        ports: 2
-    ipmi: true
-
-  - kind: Server
-    name: dell-c6400-node02
-    cpus:
-      - model: Intel(R) Xeon(R) Silver 4110
-        cores: 8
-        threads: 16
-    ram:
-      size: 128gb
-      mts: 2400
-    drives:
-      - type: ssd
-        size: 960gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-      - type: sfp+
-        speed: 10gb
-        ports: 2
-    ipmi: true
-
-  - kind: Server
-    name: dell-c6400-node03
-    cpus:
-      - model: Intel(R) Xeon(R) Silver 4110
-        cores: 8
-        threads: 16
-    ram:
-      size: 64gb
-      mts: 2400
-    drives:
-      - type: ssd
-        size: 480gb
-      - type: ssd
-        size: 480gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-      - type: sfp+
-        speed: 10gb
-        ports: 2
-    ipmi: true
-
-  - kind: Server
-    name: dell-c6400-node04
-    cpus:
-      - model: Intel(R) Xeon(R) Silver 4110
-        cores: 8
-        threads: 16
-    ram:
-      size: 128gb
-      mts: 2400
-    drives:
-      - type: ssd
-        size: 960gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-      - type: sfp+
-        speed: 10gb
-        ports: 2
-    ipmi: true
-
-  - kind: Server
-    name: truenas-storage01
-    cpus:
-      - model: Intel(R) Xeon(R) E5-2620 v4
-        cores: 8
-        threads: 16
-    ram:
-      size: 64gb
-      mts: 2133
-    drives:
-      - type: hdd
-        size: 8tb
-      - type: hdd
-        size: 8tb
-      - type: hdd
-        size: 8tb
-      - type: hdd
-        size: 8tb
-      - type: ssd
-        size: 120gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 1
-      - type: sfp+
-        speed: 10gb
-        ports: 1
-    ipmi: true
-
-  - kind: Server
-    name: proxmox-edge01
-    cpus:
-      - model: Intel(R) Core(TM) i5-8500
-        cores: 6
-        threads: 6
-    ram:
-      size: 32gb
-      mts: 2666
-    drives:
-      - type: ssd
-        size: 512gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 4
-    ipmi: false
-
-  - kind: Server
-    name: opnsense-fw01
-    cpus:
-      - model: Intel(R) Celeron(R) J4125
-        cores: 4
-        threads: 4
-    ram:
-      size: 8gb
-      mts: 2400
-    drives:
-      - type: ssd
-        size: 64gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 4
-    ipmi: false
-
-  - kind: Server
-    name: mgmt-bastion01
-    cpus:
-      - model: Intel(R) Xeon(R) E3-1270 v6
-        cores: 4
-        threads: 8
-    ram:
-      size: 16gb
-      mts: 2400
-    drives:
-      - type: ssd
-        size: 256gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 1
-    ipmi: true
-  
-  - kind: Server
-    name: truenas-backup01
-    cpus:
-      - model: Intel(R) Xeon(R) E5-2630 v4
-        cores: 10
-        threads: 20
-    ram:
-      size: 64gb
-      mts: 2133
-    drives:
-      - type: hdd
-        size: 6tb
-      - type: hdd
-        size: 6tb
-      - type: hdd
-        size: 6tb
-      - type: hdd
-        size: 6tb
-      - type: ssd
-        size: 240gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-      - type: sfp+
-        speed: 10gb
-        ports: 1
-    ipmi: true
-
-  - kind: Server
-    name: compute-gpu01
-    cpus:
-      - model: Intel(R) Xeon(R) Silver 4214
-        cores: 12
-        threads: 24
-    ram:
-      size: 128gb
-      mts: 2666
-    drives:
-      - type: ssd
-        size: 1tb
-    gpus:
-      - model: NVIDIA Tesla P40
-        vram: 24gb
-      - model: NVIDIA Tesla P40
-        vram: 24gb  
-      - model: NVIDIA Tesla P4
-        vram: 8Gb  
-    nics:
-      - type: sfp+
-        speed: 10gb
-        ports: 2
-    ipmi: true
-
-  - kind: Server
-    name: proxmox-lab01
-    cpus:
-      - model: Intel(R) Xeon(R) E3-1240 v5
-        cores: 4
-        threads: 8
-    ram:
-      size: 32gb
-      mts: 2133
-    drives:
-      - type: ssd
-        size: 512gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-    ipmi: true
-
-  - kind: Server
-    name: k8s-control01
-    cpus:
-      - model: Intel(R) Xeon(R) E-2224
-        cores: 4
-        threads: 4
-    ram:
-      size: 16gb
-      mts: 2666
-    drives:
-      - type: ssd
-        size: 256gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-    ipmi: true
-
-  - kind: Server
-    name: k8s-control02
-    cpus:
-      - model: Intel(R) Xeon(R) E-2224
-        cores: 4
-        threads: 4
-    ram:
-      size: 16gb
-      mts: 2666
-    drives:
-      - type: ssd
-        size: 256gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-    ipmi: true
-
-  - kind: Server
-    name: elk-logging01
-    cpus:
-      - model: Intel(R) Xeon(R) Silver 4108
-        cores: 8
-        threads: 16
-    ram:
-      size: 64gb
-      mts: 2400
-    drives:
-      - type: ssd
-        size: 1tb
-      - type: ssd
-        size: 1tb
-    nics:
-      - type: sfp+
-        speed: 10gb
-        ports: 1
-    ipmi: true
-
-  - kind: Server
-    name: edge-node01
-    cpus:
-      - model: Intel(R) Core(TM) i3-8100
-        cores: 4
-        threads: 4
-    ram:
-      size: 16gb
-      mts: 2400
-    drives:
-      - type: ssd
-        size: 256gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-    ipmi: false
-
-  - kind: Server
-    name: backup-proxmox01
-    cpus:
-      - model: Intel(R) Xeon(R) E5-1650 v3
-        cores: 6
-        threads: 12
-    ram:
-      size: 64gb
-      mts: 2133
-    drives:
-      - type: ssd
-        size: 480gb
-      - type: hdd
-        size: 4tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 4
-    ipmi: true
-
-  - kind: Server
-    name: lab-general01
-    cpus:
-      - model: Intel(R) Core(TM) i7-8700
-        cores: 6
-        threads: 12
-    ram:
-      size: 32gb
-      mts: 2666
-    drives:
-      - type: ssd
-        size: 512gb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 1
-    ipmi: false
-
-  - kind: Server
-    name: dell-r730-archive01
-    cpus:
-      - model: Intel(R) Xeon(R) E5-2650 v3
-        cores: 10
-        threads: 20
-    ram:
-      size: 128gb
-      mts: 2133
-    drives:
-      - type: hdd
-        size: 4tb
-      - type: hdd
-        size: 4tb
-      - type: hdd
-        size: 4tb
-      - type: hdd
-        size: 4tb
-    nics:
-      - type: sfp+
-        speed: 10gb
-        ports: 2
-    ipmi: true
-
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) Silver 4110
+    cores: 8
+    threads: 16
+  ram:
+    size: 64
+    mts: 2400
+  drives:
+  - type: ssd
+    size: 480
+  - type: ssd
+    size: 480
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 2
+  - type: sfp+
+    speed: 10
+    ports: 2
+  gpus: 
+  ipmi: true
+  name: dell-c6400-node01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) Silver 4110
+    cores: 8
+    threads: 16
+  ram:
+    size: 128
+    mts: 2400
+  drives:
+  - type: ssd
+    size: 960
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 2
+  - type: sfp+
+    speed: 10
+    ports: 2
+  gpus: 
+  ipmi: true
+  name: dell-c6400-node02
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) Silver 4110
+    cores: 8
+    threads: 16
+  ram:
+    size: 64
+    mts: 2400
+  drives:
+  - type: ssd
+    size: 480
+  - type: ssd
+    size: 480
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 2
+  - type: sfp+
+    speed: 10
+    ports: 2
+  gpus: 
+  ipmi: true
+  name: dell-c6400-node03
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) Silver 4110
+    cores: 8
+    threads: 16
+  ram:
+    size: 128
+    mts: 2400
+  drives:
+  - type: ssd
+    size: 960
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 2
+  - type: sfp+
+    speed: 10
+    ports: 2
+  gpus: 
+  ipmi: true
+  name: dell-c6400-node04
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) E5-2620 v4
+    cores: 8
+    threads: 16
+  ram:
+    size: 64
+    mts: 2133
+  drives:
+  - type: hdd
+    size: 8192
+  - type: hdd
+    size: 8192
+  - type: hdd
+    size: 8192
+  - type: hdd
+    size: 8192
+  - type: ssd
+    size: 120
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 1
+  - type: sfp+
+    speed: 10
+    ports: 1
+  gpus: 
+  ipmi: true
+  name: truenas-storage01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Core(TM) i5-8500
+    cores: 6
+    threads: 6
+  ram:
+    size: 32
+    mts: 2666
+  drives:
+  - type: ssd
+    size: 512
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 4
+  gpus: 
+  ipmi: false
+  name: proxmox-edge01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Celeron(R) J4125
+    cores: 4
+    threads: 4
+  ram:
+    size: 8
+    mts: 2400
+  drives:
+  - type: ssd
+    size: 64
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 4
+  gpus: 
+  ipmi: false
+  name: opnsense-fw01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) E3-1270 v6
+    cores: 4
+    threads: 8
+  ram:
+    size: 16
+    mts: 2400
+  drives:
+  - type: ssd
+    size: 256
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 1
+  gpus: 
+  ipmi: true
+  name: mgmt-bastion01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) E5-2630 v4
+    cores: 10
+    threads: 20
+  ram:
+    size: 64
+    mts: 2133
+  drives:
+  - type: hdd
+    size: 6144
+  - type: hdd
+    size: 6144
+  - type: hdd
+    size: 6144
+  - type: hdd
+    size: 6144
+  - type: ssd
+    size: 240
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 2
+  - type: sfp+
+    speed: 10
+    ports: 1
+  gpus: 
+  ipmi: true
+  name: truenas-backup01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) Silver 4214
+    cores: 12
+    threads: 24
+  ram:
+    size: 128
+    mts: 2666
+  drives:
+  - type: ssd
+    size: 1024
+  nics:
+  - type: sfp+
+    speed: 10
+    ports: 2
+  gpus:
+  - model: NVIDIA Tesla P40
+    vram: 24
+  - model: NVIDIA Tesla P40
+    vram: 24
+  - model: NVIDIA Tesla P4
+    vram: 8
+  ipmi: true
+  name: compute-gpu01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) E3-1240 v5
+    cores: 4
+    threads: 8
+  ram:
+    size: 32
+    mts: 2133
+  drives:
+  - type: ssd
+    size: 512
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 2
+  gpus: 
+  ipmi: true
+  name: proxmox-lab01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) E-2224
+    cores: 4
+    threads: 4
+  ram:
+    size: 16
+    mts: 2666
+  drives:
+  - type: ssd
+    size: 256
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 2
+  gpus: 
+  ipmi: true
+  name: k8s-control01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) E-2224
+    cores: 4
+    threads: 4
+  ram:
+    size: 16
+    mts: 2666
+  drives:
+  - type: ssd
+    size: 256
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 2
+  gpus: 
+  ipmi: true
+  name: k8s-control02
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) Silver 4108
+    cores: 8
+    threads: 16
+  ram:
+    size: 64
+    mts: 2400
+  drives:
+  - type: ssd
+    size: 1024
+  - type: ssd
+    size: 1024
+  nics:
+  - type: sfp+
+    speed: 10
+    ports: 1
+  gpus: 
+  ipmi: true
+  name: elk-logging01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Core(TM) i3-8100
+    cores: 4
+    threads: 4
+  ram:
+    size: 16
+    mts: 2400
+  drives:
+  - type: ssd
+    size: 256
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 2
+  gpus: 
+  ipmi: false
+  name: edge-node01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) E5-1650 v3
+    cores: 6
+    threads: 12
+  ram:
+    size: 64
+    mts: 2133
+  drives:
+  - type: ssd
+    size: 480
+  - type: hdd
+    size: 4096
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 4
+  gpus: 
+  ipmi: true
+  name: backup-proxmox01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Core(TM) i7-8700
+    cores: 6
+    threads: 12
+  ram:
+    size: 32
+    mts: 2666
+  drives:
+  - type: ssd
+    size: 512
+  nics:
+  - type: rj45
+    speed: 1
+    ports: 1
+  gpus: 
+  ipmi: false
+  name: lab-general01
+  tags: 
+- kind: Server
+  cpus:
+  - model: Intel(R) Xeon(R) E5-2650 v3
+    cores: 10
+    threads: 20
+  ram:
+    size: 128
+    mts: 2133
+  drives:
+  - type: hdd
+    size: 4096
+  - type: hdd
+    size: 4096
+  - type: hdd
+    size: 4096
+  - type: hdd
+    size: 4096
+  nics:
+  - type: sfp+
+    speed: 10
+    ports: 2
+  gpus: 
+  ipmi: true
+  name: dell-r730-archive01
+  tags: 

+ 13 - 12
RackPeek/switches.yaml

@@ -1,13 +1,14 @@
 resources:
-  - kind: Switch
-    name: netgear-s24
-    model: GS324
-    ports:
-      - type: rj45
-        speed: 1gb
-        count: 8
-      - type: sfp
-        speed: 10gb
-        count: 2
-    managed: true
-    poe: true
+- kind: Switch
+  model: GS324
+  managed: true
+  poe: true
+  ports:
+  - type: rj45
+    speed: 1
+    count: 8
+  - type: sfp
+    speed: 10
+    count: 2
+  name: netgear-s24
+  tags: 

+ 5 - 4
RackPeek/ups.yaml

@@ -1,5 +1,6 @@
 resources:
-  - kind: Ups
-    name: rack-ups
-    model: Volta
-    va: 2200
+- kind: Ups
+  model: Volta
+  va: 2200
+  name: rack-ups
+  tags: 

+ 101 - 0
Tests/Hardware/AddCpuUseCaseTests.cs

@@ -0,0 +1,101 @@
+using NSubstitute;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Hardware.Server.Cpu;
+
+namespace Tests.Hardware;
+
+public class AddCpuUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Adds_cpu_when_server_exists()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        var server = new RackPeek.Domain.Resources.Hardware.Models.Server
+        {
+            Name = "node01",
+            Cpus = new List<RackPeek.Domain.Resources.Hardware.Models.Cpu>()
+        };
+
+        repo.GetByNameAsync("node01").Returns(server);
+
+        var sut = new AddCpuUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            serverName: "node01",
+            model: "7950x",
+            cores: 8,
+            threads: 16
+        );
+
+        // Assert
+        Assert.Single(server.Cpus);
+        Assert.Equal("7950x", server.Cpus[0].Model);
+        Assert.Equal(8, server.Cpus[0].Cores);
+        Assert.Equal(16, server.Cpus[0].Threads);
+
+        await repo.Received(1).UpdateAsync(Arg.Is<RackPeek.Domain.Resources.Hardware.Models.Server>(s =>
+            s.Name == "node01" &&
+            s.Cpus.Count == 1 &&
+            s.Cpus[0].Model == "7950x" &&
+            s.Cpus[0].Cores == 8 &&
+            s.Cpus[0].Threads == 16
+        ));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Initializes_cpu_list_when_null()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        var server = new RackPeek.Domain.Resources.Hardware.Models.Server
+        {
+            Name = "node01",
+            Cpus = null
+        };
+
+        repo.GetByNameAsync("node01").Returns(server);
+
+        var sut = new AddCpuUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            serverName: "node01",
+            model: "7950x",
+            cores: 8,
+            threads: 16
+        );
+
+        // Assert
+        Assert.NotNull(server.Cpus);
+        Assert.Single(server.Cpus);
+
+        await repo.Received(1).UpdateAsync(Arg.Is<RackPeek.Domain.Resources.Hardware.Models.Server>(s =>
+            s.Cpus != null &&
+            s.Cpus.Count == 1 &&
+            s.Cpus[0].Model == "7950x"
+        ));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Does_nothing_when_server_does_not_exist()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns((RackPeek.Domain.Resources.Hardware.Models.Hardware?)null);
+
+        var sut = new AddCpuUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            serverName: "node01",
+            model: "7950x",
+            cores: 8,
+            threads: 16
+        );
+
+        // Assert
+        await repo.DidNotReceive().UpdateAsync(Arg.Any<RackPeek.Domain.Resources.Hardware.Models.Server>());
+    }
+}

+ 49 - 0
Tests/Hardware/AddServerUseCaseTests.cs

@@ -0,0 +1,49 @@
+using NSubstitute;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Hardware.Server;
+
+namespace Tests.Hardware;
+
+public class AddServerUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Adds_new_server_when_not_exists()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns((RackPeek.Domain.Resources.Hardware.Models.Hardware?)null);
+
+        var sut = new AddServerUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            name: "node01"
+        );
+
+        // Assert
+        await repo.Received(1).AddAsync(Arg.Is<RackPeek.Domain.Resources.Hardware.Models.Server>(s =>
+            s.Name == "node01"
+        ));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_if_server_already_exists()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns(new RackPeek.Domain.Resources.Hardware.Models.Server { Name = "node01" });
+
+        var sut = new AddServerUseCase(repo);
+
+        // Act
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
+            await sut.ExecuteAsync(
+                name: "node01"
+            )
+        );
+
+        // Assert
+        Assert.Equal("Server 'node01' already exists.", ex.Message);
+        await repo.DidNotReceive().AddAsync(Arg.Any<RackPeek.Domain.Resources.Hardware.Models.Server>());
+    }
+}

+ 43 - 0
Tests/Hardware/DeleteServerUseCaseTests.cs

@@ -0,0 +1,43 @@
+using NSubstitute;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Hardware.Server;
+
+namespace Tests.Hardware;
+
+public class DeleteServerUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Deletes_server_when_exists()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns(new RackPeek.Domain.Resources.Hardware.Models.Server { Name = "node01" });
+
+        var sut = new DeleteServerUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync("node01");
+
+        // Assert
+        await repo.Received(1).DeleteAsync("node01");
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_when_server_not_found()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns((RackPeek.Domain.Resources.Hardware.Models.Hardware?)null);
+
+        var sut = new DeleteServerUseCase(repo);
+
+        // Act
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
+            sut.ExecuteAsync("node01")
+        );
+
+        // Assert
+        Assert.Equal("Server 'node01' not found.", ex.Message);
+        await repo.DidNotReceive().DeleteAsync(Arg.Any<string>());
+    }
+}

+ 68 - 0
Tests/Hardware/DescribeServerUseCaseTests.cs

@@ -0,0 +1,68 @@
+using NSubstitute;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.Hardware.Server;
+
+namespace Tests.Hardware;
+
+public class DescribeServerUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Returns_description_when_server_exists()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns(new RackPeek.Domain.Resources.Hardware.Models.Server
+        {
+            Name = "node01",
+            Ipmi = true,
+            Cpus = new()
+            {
+                new Cpu { Model = "Xeon", Cores = 4, Threads = 8 },
+                new Cpu { Model = "Xeon", Cores = 4, Threads = 8 }
+            },
+            Ram = new Ram { Size = 32 },
+            Drives = new()
+            {
+                new Drive { Type = "ssd", Size = 256 },
+                new Drive { Type = "hdd", Size = 2048 }
+            },
+            Nics = new()
+            {
+                new Nic { Speed = 10, Ports = 2 }
+            }
+        });
+
+        var sut = new DescribeServerUseCase(repo);
+
+        // Act
+        var description = await sut.ExecuteAsync("node01");
+
+        // Assert
+        Assert.NotNull(description);
+        Assert.Equal("node01", description!.Name);
+        Assert.Equal("2× Xeon", description.CpuSummary);
+        Assert.Equal(8, description.TotalCores);
+        Assert.Equal(16, description.TotalThreads);
+        Assert.Equal(32, description.RamGb);
+        Assert.Equal(2304, description.TotalStorageGb);
+        Assert.Equal(2, description.NicPorts);
+        Assert.True(description.Ipmi);
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Returns_null_when_server_not_found()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns((RackPeek.Domain.Resources.Hardware.Models.Hardware?)null);
+
+        var sut = new DescribeServerUseCase(repo);
+
+        // Act
+        var description = await sut.ExecuteAsync("node01");
+
+        // Assert
+        Assert.Null(description);
+    }
+}

+ 59 - 0
Tests/Hardware/GetServerUseCaseTests.cs

@@ -0,0 +1,59 @@
+using NSubstitute;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.Hardware.Server;
+
+namespace Tests.Hardware;
+
+public class GetServerUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Returns_server_when_exists()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns(new RackPeek.Domain.Resources.Hardware.Models.Server { Name = "node01" });
+
+        var sut = new GetServerUseCase(repo);
+
+        // Act
+        var server = await sut.ExecuteAsync("node01");
+
+        // Assert
+        Assert.NotNull(server);
+        Assert.IsType<RackPeek.Domain.Resources.Hardware.Models.Server>(server);
+        Assert.Equal("node01", server!.Name);
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Returns_null_when_not_found()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns((RackPeek.Domain.Resources.Hardware.Models.Hardware?)null);
+
+        var sut = new GetServerUseCase(repo);
+
+        // Act
+        var server = await sut.ExecuteAsync("node01");
+
+        // Assert
+        Assert.Null(server);
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Returns_null_when_found_is_not_server()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns(new Desktop { Name = "desktop1" });
+
+        var sut = new GetServerUseCase(repo);
+
+        // Act
+        var server = await sut.ExecuteAsync("node01");
+
+        // Assert
+        Assert.Null(server);
+    }
+}

+ 52 - 0
Tests/Hardware/GetServersUseCaseTests.cs

@@ -0,0 +1,52 @@
+using NSubstitute;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.Hardware.Server;
+
+namespace Tests.Hardware;
+
+public class GetServersUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Returns_only_servers()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetAllAsync().Returns(new List<RackPeek.Domain.Resources.Hardware.Models.Hardware>
+        {
+            new RackPeek.Domain.Resources.Hardware.Models.Server { Name = "server1" },
+            new Desktop { Name = "desktop1" },
+            new RackPeek.Domain.Resources.Hardware.Models.Server { Name = "server2" }
+        });
+
+        var sut = new GetServersUseCase(repo);
+
+        // Act
+        var servers = await sut.ExecuteAsync();
+
+        // Assert
+        Assert.Equal(2, servers.Count);
+        Assert.All(servers, s => Assert.IsType<RackPeek.Domain.Resources.Hardware.Models.Server>(s));
+        Assert.Contains(servers, s => s.Name == "server1");
+        Assert.Contains(servers, s => s.Name == "server2");
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Returns_empty_when_no_servers()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetAllAsync().Returns(new List<RackPeek.Domain.Resources.Hardware.Models.Hardware>
+        {
+            new Desktop { Name = "desktop1" }
+        });
+
+        var sut = new GetServersUseCase(repo);
+
+        // Act
+        var servers = await sut.ExecuteAsync();
+
+        // Assert
+        Assert.Empty(servers);
+    }
+}

+ 85 - 0
Tests/Hardware/RemoveCpuUseCaseTests.cs

@@ -0,0 +1,85 @@
+using NSubstitute;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.Hardware.Server.Cpu;
+using Xunit;
+
+namespace Tests.Hardware;
+
+public class RemoveCpuUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Removes_cpu_when_index_is_valid()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        var server = new Server
+        {
+            Name = "node01",
+            Cpus = new List<Cpu>
+            {
+                new Cpu { Model = "7950x", Cores = 8, Threads = 16 },
+                new Cpu { Model = "7900x", Cores = 12, Threads = 24 }
+            }
+        };
+
+        repo.GetByNameAsync("node01").Returns(server);
+
+        var sut = new RemoveCpuUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync("node01", index: 0);
+
+        // Assert
+        Assert.Single(server.Cpus);
+        Assert.Equal("7900x", server.Cpus[0].Model);
+
+        await repo.Received(1).UpdateAsync(Arg.Is<Server>(s =>
+            s.Name == "node01" &&
+            s.Cpus.Count == 1 &&
+            s.Cpus[0].Model == "7900x"
+        ));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_if_index_out_of_range()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        var server = new Server
+        {
+            Name = "node01",
+            Cpus = new List<Cpu>
+            {
+                new Cpu { Model = "7950x", Cores = 8, Threads = 16 }
+            }
+        };
+
+        repo.GetByNameAsync("node01").Returns(server);
+
+        var sut = new RemoveCpuUseCase(repo);
+
+        // Act & Assert
+        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
+            await sut.ExecuteAsync("node01", index: 1)
+        );
+
+        await repo.DidNotReceive().UpdateAsync(Arg.Any<Server>());
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Does_nothing_when_server_does_not_exist()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns((RackPeek.Domain.Resources.Hardware.Models.Hardware?)null);
+
+        var sut = new RemoveCpuUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync("node01", index: 0);
+
+        // Assert
+        await repo.DidNotReceive().UpdateAsync(Arg.Any<RackPeek.Domain.Resources.Hardware.Models.Server>());
+    }
+}

+ 1 - 1
Tests/Hardware/ServerHardwareReportTests.cs

@@ -12,7 +12,7 @@ public class ServerHardwareReportTests
     {
         var repo = Substitute.For<IHardwareRepository>();
         repo.GetAllAsync().Returns([
-            new Server
+            new RackPeek.Domain.Resources.Hardware.Models.Server
             {
                 Name = "srv1",
                 Ipmi = true,

+ 105 - 0
Tests/Hardware/UpdateCpuUseCaseTests.cs

@@ -0,0 +1,105 @@
+using NSubstitute;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.Hardware.Server.Cpu;
+using Xunit;
+
+namespace Tests.Hardware;
+
+public class UpdateCpuUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Updates_cpu_when_index_is_valid()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        var server = new Server
+        {
+            Name = "node01",
+            Cpus = new List<Cpu>
+            {
+                new Cpu { Model = "7950x", Cores = 8, Threads = 16 }
+            }
+        };
+
+        repo.GetByNameAsync("node01").Returns(server);
+
+        var sut = new UpdateCpuUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            serverName: "node01",
+            index: 0,
+            model: "7900x",
+            cores: 12,
+            threads: 24
+        );
+
+        // Assert
+        Assert.Equal("7900x", server.Cpus[0].Model);
+        Assert.Equal(12, server.Cpus[0].Cores);
+        Assert.Equal(24, server.Cpus[0].Threads);
+
+        await repo.Received(1).UpdateAsync(Arg.Is<Server>(s =>
+            s.Name == "node01" &&
+            s.Cpus.Count == 1 &&
+            s.Cpus[0].Model == "7900x" &&
+            s.Cpus[0].Cores == 12 &&
+            s.Cpus[0].Threads == 24
+        ));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_if_index_out_of_range()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        var server = new Server
+        {
+            Name = "node01",
+            Cpus = new List<Cpu>
+            {
+                new Cpu { Model = "7950x", Cores = 8, Threads = 16 }
+            }
+        };
+
+        repo.GetByNameAsync("node01").Returns(server);
+
+        var sut = new UpdateCpuUseCase(repo);
+
+        // Act & Assert
+        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
+            await sut.ExecuteAsync(
+                serverName: "node01",
+                index: 1,
+                model: "7900x",
+                cores: 12,
+                threads: 24
+            )
+        );
+
+        await repo.DidNotReceive().UpdateAsync(Arg.Any<Server>());
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Does_nothing_when_server_does_not_exist()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns((RackPeek.Domain.Resources.Hardware.Models.Hardware?)null);
+
+        var sut = new UpdateCpuUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            serverName: "node01",
+            index: 0,
+            model: "7900x",
+            cores: 12,
+            threads: 24
+        );
+
+        // Assert
+        await repo.DidNotReceive().UpdateAsync(Arg.Any<Server>());
+    }
+}

+ 93 - 0
Tests/Hardware/UpdateServerUseCaseTests.cs

@@ -0,0 +1,93 @@
+using NSubstitute;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.Hardware.Server;
+
+namespace Tests.Hardware;
+
+public class UpdateServerUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Updates_ram_ipmi_and_cpu_when_provided()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns(new RackPeek.Domain.Resources.Hardware.Models.Server
+        {
+            Name = "node01",
+            Ipmi = false,
+            Ram = new Ram { Size = 32 },
+            Cpus = new List<Cpu>
+            {
+                new Cpu { Model = "Old", Cores = 2, Threads = 4 }
+            }
+        });
+
+        var sut = new UpdateServerUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            name: "node01",
+            ramGb: 64,
+            ipmi: true
+        );
+
+        // Assert
+        await repo.Received(1).UpdateAsync(Arg.Is<RackPeek.Domain.Resources.Hardware.Models.Server>(s =>
+            s.Name == "node01" &&
+            s.Ram.Size == 64 &&
+            s.Ipmi == true
+        ));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_if_server_not_found()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns((RackPeek.Domain.Resources.Hardware.Models.Hardware?)null);
+
+        var sut = new UpdateServerUseCase(repo);
+
+        // Act
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
+            sut.ExecuteAsync("node01", ramGb: 64)
+        );
+
+        // Assert
+        Assert.Equal("Server 'node01' not found.", ex.Message);
+        await repo.DidNotReceive().UpdateAsync(Arg.Any<RackPeek.Domain.Resources.Hardware.Models.Server>());
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Preserves_existing_values_when_not_provided()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns(new RackPeek.Domain.Resources.Hardware.Models.Server
+        {
+            Name = "node01",
+            Ipmi = false,
+            Ram = new Ram { Size = 32 },
+            Cpus = new List<Cpu>
+            {
+                new Cpu { Model = "Old", Cores = 2, Threads = 4 }
+            }
+        });
+
+        var sut = new UpdateServerUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            name: "node01",
+            ramGb: null,
+            ipmi: null
+        );
+
+        // Assert
+        await repo.Received(1).UpdateAsync(Arg.Is<RackPeek.Domain.Resources.Hardware.Models.Server>(s =>
+            s.Ram.Size == 32 &&
+            s.Ipmi == false
+        ));
+    }
+}

+ 3 - 2
Tests/Yaml/HardwareDeserializationTests.cs

@@ -1,15 +1,16 @@
 using RackPeek;
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Yaml;
 
-namespace Tests;
+namespace Tests.Yaml;
 
 public class HardwareDeserializationTests
 {
     public static IHardwareRepository CreateSut(string yaml)
     {
         var yamlResourceCollection = new YamlResourceCollection();
-        yamlResourceCollection.Load([yaml]);
+        yamlResourceCollection.Load(yaml, "test.yaml");
         return new YamlHardwareRepository(yamlResourceCollection);
     }
     

+ 3 - 2
Tests/Yaml/SystemDeserializationTests.cs

@@ -1,14 +1,15 @@
 using RackPeek;
 using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Yaml;
 
-namespace Tests;
+namespace Tests.Yaml;
 
 public class ServiceDeserializationTests
 {
     public static ISystemRepository CreateSut(string yaml)
     {
         var yamlResourceCollection = new YamlResourceCollection();
-        yamlResourceCollection.Load([yaml]);
+        yamlResourceCollection.Load(yaml, "test.yaml");
         return new YamlSystemRepository(yamlResourceCollection);
     }