Tim Jones 2 месяцев назад
Родитель
Сommit
303ebb4df1
26 измененных файлов с 1067 добавлено и 5 удалено
  1. BIN
      .DS_Store
  2. 134 1
      COMMANDS.md
  3. 135 2
      README.md
  4. 11 0
      RackPeek.Domain/Resources/Services/IServiceRepository.cs
  5. 15 0
      RackPeek.Domain/Resources/Services/Service.cs
  6. 21 0
      RackPeek.Domain/Resources/Services/UseCases/AddSystemUseCase.cs
  7. 14 0
      RackPeek.Domain/Resources/Services/UseCases/DeleteSystemUseCase.cs
  8. 31 0
      RackPeek.Domain/Resources/Services/UseCases/DescribeSystemUseCase.cs
  9. 11 0
      RackPeek.Domain/Resources/Services/UseCases/GetSystemUseCase.cs
  10. 11 0
      RackPeek.Domain/Resources/Services/UseCases/GetSystemsUseCase.cs
  11. 38 0
      RackPeek.Domain/Resources/Services/UseCases/SystemReportUseCase.cs
  12. 46 0
      RackPeek.Domain/Resources/Services/UseCases/UpdateSystemUseCase.cs
  13. 49 1
      RackPeek/CliBootstrap.cs
  14. 35 0
      RackPeek/Commands/Services/ServiceAddCommand.cs
  15. 25 0
      RackPeek/Commands/Services/ServiceDeleteCommand.cs
  16. 50 0
      RackPeek/Commands/Services/ServiceDescribeCommand.cs
  17. 32 0
      RackPeek/Commands/Services/ServiceGetByNameCommand.cs
  18. 49 0
      RackPeek/Commands/Services/ServiceGetCommand.cs
  19. 11 0
      RackPeek/Commands/Services/ServiceNameSettings.cs
  20. 49 0
      RackPeek/Commands/Services/ServiceReportCommand.cs
  21. 57 0
      RackPeek/Commands/Services/ServiceSetCommand.cs
  22. 7 0
      RackPeek/Yaml/YamlResourceCollection.cs
  23. 71 0
      RackPeek/Yaml/YamlServiceRepository.cs
  24. 114 0
      Tests/EndToEnd/ServiceYamlE2ETests.cs
  25. 50 0
      Tests/Yaml/ServiceDeserializationTests.cs
  26. 1 1
      Tests/Yaml/SystemDeserializationTests.cs

+ 134 - 1
COMMANDS.md

@@ -59,6 +59,14 @@
   - [`rpk desktops nic add`](#rpk-desktops-nic-add)
   - [`rpk desktops nic add`](#rpk-desktops-nic-add)
   - [`rpk desktops nic set`](#rpk-desktops-nic-set)
   - [`rpk desktops nic set`](#rpk-desktops-nic-set)
   - [`rpk desktops nic del`](#rpk-desktops-nic-del)
   - [`rpk desktops nic del`](#rpk-desktops-nic-del)
+  - [`rpk services`](#rpk-services)
+  - [`rpk services summary`](#rpk-services-summary)
+  - [`rpk services add`](#rpk-services-add)
+  - [`rpk services list`](#rpk-services-list)
+  - [`rpk services get`](#rpk-services-get)
+  - [`rpk services describe`](#rpk-services-describe)
+  - [`rpk services set`](#rpk-services-set)
+  - [`rpk services del`](#rpk-services-del)
   - [`rpk ap`](#rpk-ap)
   - [`rpk ap`](#rpk-ap)
   - [`rpk servers`](#rpk-servers)
   - [`rpk servers`](#rpk-servers)
   - [`rpk servers summary`](#rpk-servers-summary)
   - [`rpk servers summary`](#rpk-servers-summary)
@@ -100,7 +108,7 @@ COMMANDS:
     systems         Manage systems                   
     systems         Manage systems                   
     accesspoints    Manage access points             
     accesspoints    Manage access points             
     ups             Manage UPS units                 
     ups             Manage UPS units                 
-    desktops                                         
+    services        Manage services                  
     ap              Show access point hardware report
     ap              Show access point hardware report
     desktops        Show desktop hardware report     
     desktops        Show desktop hardware report     
     ups             Show UPS hardware report         
     ups             Show UPS hardware report         
@@ -918,6 +926,131 @@ OPTIONS:
     -h, --help    Prints help information
     -h, --help    Prints help information
 ```
 ```
 
 
+## `rpk services`
+```
+DESCRIPTION:
+Manage services
+
+USAGE:
+    rpk services [OPTIONS] <COMMAND>
+
+OPTIONS:
+    -h, --help    Prints help information
+
+COMMANDS:
+    summary            Show service summary report              
+    add <name>         Add a new service                        
+    list               List all services                        
+    get <name>         Get a service by name                    
+    describe <name>    Show detailed information about a service
+    set <name>         Update service properties                
+    del <name>         Delete a service                         
+```
+
+## `rpk services summary`
+```
+DESCRIPTION:
+Show service summary report
+
+USAGE:
+    rpk services summary [OPTIONS]
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk services add`
+```
+DESCRIPTION:
+Add a new service
+
+USAGE:
+    rpk services add <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>    The name of the service
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk services list`
+```
+DESCRIPTION:
+List all services
+
+USAGE:
+    rpk services list [OPTIONS]
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk services get`
+```
+DESCRIPTION:
+Get a service by name
+
+USAGE:
+    rpk services get <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>    The name of the service
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk services describe`
+```
+DESCRIPTION:
+Show detailed information about a service
+
+USAGE:
+    rpk services describe <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>    The name of the service
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk services set`
+```
+DESCRIPTION:
+Update service properties
+
+USAGE:
+    rpk services set <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help        Prints help information             
+        --ip          The ip address of the service       
+        --port        The port the service is running on  
+        --protocol    The service protocol                
+        --url         The service URL                     
+        --runs-on     The system the service is running on
+```
+
+## `rpk services del`
+```
+DESCRIPTION:
+Delete a service
+
+USAGE:
+    rpk services del <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>    The name of the service
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
 ## `rpk ap`
 ## `rpk ap`
 ```
 ```
 DESCRIPTION:
 DESCRIPTION:

+ 135 - 2
README.md

@@ -6,7 +6,6 @@ It’s designed to help you inventory, configure, and audit your environment in
 # CLI Commands
 # CLI Commands
 
 
 ## Command Tree
 ## Command Tree
-
 - [RackPeek](#rackpeek)
 - [RackPeek](#rackpeek)
 - [CLI Commands](#cli-commands)
 - [CLI Commands](#cli-commands)
   - [Command Tree](#command-tree)
   - [Command Tree](#command-tree)
@@ -66,6 +65,14 @@ It’s designed to help you inventory, configure, and audit your environment in
   - [`rpk desktops nic add`](#rpk-desktops-nic-add)
   - [`rpk desktops nic add`](#rpk-desktops-nic-add)
   - [`rpk desktops nic set`](#rpk-desktops-nic-set)
   - [`rpk desktops nic set`](#rpk-desktops-nic-set)
   - [`rpk desktops nic del`](#rpk-desktops-nic-del)
   - [`rpk desktops nic del`](#rpk-desktops-nic-del)
+  - [`rpk services`](#rpk-services)
+  - [`rpk services summary`](#rpk-services-summary)
+  - [`rpk services add`](#rpk-services-add)
+  - [`rpk services list`](#rpk-services-list)
+  - [`rpk services get`](#rpk-services-get)
+  - [`rpk services describe`](#rpk-services-describe)
+  - [`rpk services set`](#rpk-services-set)
+  - [`rpk services del`](#rpk-services-del)
   - [`rpk ap`](#rpk-ap)
   - [`rpk ap`](#rpk-ap)
   - [`rpk servers`](#rpk-servers)
   - [`rpk servers`](#rpk-servers)
   - [`rpk servers summary`](#rpk-servers-summary)
   - [`rpk servers summary`](#rpk-servers-summary)
@@ -92,6 +99,7 @@ It’s designed to help you inventory, configure, and audit your environment in
   - [`rpk servers nic set`](#rpk-servers-nic-set)
   - [`rpk servers nic set`](#rpk-servers-nic-set)
   - [`rpk servers nic del`](#rpk-servers-nic-del)
   - [`rpk servers nic del`](#rpk-servers-nic-del)
 
 
+---
 
 
 ## `rpk`
 ## `rpk`
 ```
 ```
@@ -106,7 +114,7 @@ COMMANDS:
     systems         Manage systems                   
     systems         Manage systems                   
     accesspoints    Manage access points             
     accesspoints    Manage access points             
     ups             Manage UPS units                 
     ups             Manage UPS units                 
-    desktops                                         
+    services        Manage services                  
     ap              Show access point hardware report
     ap              Show access point hardware report
     desktops        Show desktop hardware report     
     desktops        Show desktop hardware report     
     ups             Show UPS hardware report         
     ups             Show UPS hardware report         
@@ -924,6 +932,131 @@ OPTIONS:
     -h, --help    Prints help information
     -h, --help    Prints help information
 ```
 ```
 
 
+## `rpk services`
+```
+DESCRIPTION:
+Manage services
+
+USAGE:
+    rpk services [OPTIONS] <COMMAND>
+
+OPTIONS:
+    -h, --help    Prints help information
+
+COMMANDS:
+    summary            Show service summary report              
+    add <name>         Add a new service                        
+    list               List all services                        
+    get <name>         Get a service by name                    
+    describe <name>    Show detailed information about a service
+    set <name>         Update service properties                
+    del <name>         Delete a service                         
+```
+
+## `rpk services summary`
+```
+DESCRIPTION:
+Show service summary report
+
+USAGE:
+    rpk services summary [OPTIONS]
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk services add`
+```
+DESCRIPTION:
+Add a new service
+
+USAGE:
+    rpk services add <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>    The name of the service
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk services list`
+```
+DESCRIPTION:
+List all services
+
+USAGE:
+    rpk services list [OPTIONS]
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk services get`
+```
+DESCRIPTION:
+Get a service by name
+
+USAGE:
+    rpk services get <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>    The name of the service
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk services describe`
+```
+DESCRIPTION:
+Show detailed information about a service
+
+USAGE:
+    rpk services describe <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>    The name of the service
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk services set`
+```
+DESCRIPTION:
+Update service properties
+
+USAGE:
+    rpk services set <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help        Prints help information             
+        --ip          The ip address of the service       
+        --port        The port the service is running on  
+        --protocol    The service protocol                
+        --url         The service URL                     
+        --runs-on     The system the service is running on
+```
+
+## `rpk services del`
+```
+DESCRIPTION:
+Delete a service
+
+USAGE:
+    rpk services del <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>    The name of the service
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
 ## `rpk ap`
 ## `rpk ap`
 ```
 ```
 DESCRIPTION:
 DESCRIPTION:

+ 11 - 0
RackPeek.Domain/Resources/Services/IServiceRepository.cs

@@ -0,0 +1,11 @@
+namespace RackPeek.Domain.Resources.Services;
+
+public interface IServiceRepository
+{
+    Task<IReadOnlyList<Service>> GetAllAsync();
+    Task AddAsync(Service service);
+    Task UpdateAsync(Service service);
+    Task DeleteAsync(string name);
+    Task<Service?> GetByNameAsync(string name);
+    Task<IReadOnlyList<Service>> GetBySystemHostAsync(string name);
+}

+ 15 - 0
RackPeek.Domain/Resources/Services/Service.cs

@@ -0,0 +1,15 @@
+namespace RackPeek.Domain.Resources.Services;
+
+public class Service : Resource
+{
+    public Network? Network { get; set; }
+    public string? RunsOn { get; set; }
+}
+
+public class Network
+{
+    public string? Ip { get; set; }
+    public int? Port { get; set; }
+    public string? Protocol { get; set; }
+    public string? Url { get; set; }
+}

+ 21 - 0
RackPeek.Domain/Resources/Services/UseCases/AddSystemUseCase.cs

@@ -0,0 +1,21 @@
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Resources.Services.UseCases;
+
+public class AddServiceUseCase(IServiceRepository repository)
+{
+    public async Task ExecuteAsync(string name)
+    {
+        // basic guard rails
+        var existing = await repository.GetByNameAsync(name);
+        if (existing != null)
+            throw new InvalidOperationException($"Service '{name}' already exists.");
+
+        var service = new Service
+        {
+            Name = name
+        };
+
+        await repository.AddAsync(service);
+    }
+}

+ 14 - 0
RackPeek.Domain/Resources/Services/UseCases/DeleteSystemUseCase.cs

@@ -0,0 +1,14 @@
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Resources.Services.UseCases;
+
+public class DeleteServiceUseCase(IServiceRepository repository)
+{
+    public async Task ExecuteAsync(string name)
+    {
+        if (await repository.GetByNameAsync(name) is not Service)
+            throw new InvalidOperationException($"Service '{name}' not found.");
+
+        await repository.DeleteAsync(name);
+    }
+}

+ 31 - 0
RackPeek.Domain/Resources/Services/UseCases/DescribeSystemUseCase.cs

@@ -0,0 +1,31 @@
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Resources.Services.UseCases;
+
+public record ServiceDescription(
+    string Name,
+    string? Ip,
+    int? Port,
+    string? Protocol,
+    string? Url,
+    string? RunsOn
+);
+
+public class DescribeServiceUseCase(IServiceRepository repository)
+{
+    public async Task<ServiceDescription?> ExecuteAsync(string name)
+    {
+        var service = await repository.GetByNameAsync(name);
+        if (service is null)
+            return null;
+
+        return new ServiceDescription(
+            service.Name,
+            service.Network?.Ip,
+            service.Network?.Port,
+            service.Network?.Protocol,
+            service.Network?.Url,
+            service.RunsOn
+        );
+    }
+}

+ 11 - 0
RackPeek.Domain/Resources/Services/UseCases/GetSystemUseCase.cs

@@ -0,0 +1,11 @@
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Resources.Services.UseCases;
+
+public class GetServiceUseCase(IServiceRepository repository)
+{
+    public async Task<Service?> ExecuteAsync(string name)
+    {
+        return await repository.GetByNameAsync(name);
+    }
+}

+ 11 - 0
RackPeek.Domain/Resources/Services/UseCases/GetSystemsUseCase.cs

@@ -0,0 +1,11 @@
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Resources.Services.UseCases;
+
+public class GetServicesUseCase(IServiceRepository repository)
+{
+    public async Task<IReadOnlyList<Service>> ExecuteAsync()
+    {
+        return await repository.GetAllAsync();
+    }
+}

+ 38 - 0
RackPeek.Domain/Resources/Services/UseCases/SystemReportUseCase.cs

@@ -0,0 +1,38 @@
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Resources.Services.UseCases;
+
+public record ServiceReport(
+    IReadOnlyList<ServiceReportRow> Services
+);
+
+public record ServiceReportRow(
+    string Name,
+    string? Ip,
+    int? Port,
+    string? Protocol,
+    string? Url,
+    string? RunsOn
+);
+
+public class ServiceReportUseCase(IServiceRepository repository)
+{
+    public async Task<ServiceReport> ExecuteAsync()
+    {
+        var services = await repository.GetAllAsync();
+
+        var rows = services.Select(s =>
+        {
+            return new ServiceReportRow(
+                s.Name,
+                s.Network?.Ip,
+                s.Network?.Port,
+                s.Network?.Protocol,
+                s.Network?.Url,
+                s.RunsOn
+            );
+        }).ToList();
+
+        return new ServiceReport(rows);
+    }
+}

+ 46 - 0
RackPeek.Domain/Resources/Services/UseCases/UpdateSystemUseCase.cs

@@ -0,0 +1,46 @@
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Resources.Services.UseCases;
+
+public class UpdateServiceUseCase(IServiceRepository repository)
+{
+    public async Task ExecuteAsync(
+        string name,
+        string? ip = null,
+        int? port = null,
+        string? protocol = null,
+        string? url = null,
+        string? runsOn = null
+    )
+    {
+        var service = await repository.GetByNameAsync(name);
+        if (service is null)
+            throw new InvalidOperationException($"Service '{name}' not found.");
+
+        if (!string.IsNullOrWhiteSpace(ip))
+        {
+            service.Network ??= new Network();      
+            service.Network.Ip = ip;
+        }
+        if (!string.IsNullOrWhiteSpace(protocol))
+        {
+            service.Network ??= new Network();      
+            service.Network.Protocol = protocol;
+        }
+        if (!string.IsNullOrWhiteSpace(url))
+        {
+            service.Network ??= new Network();      
+            service.Network.Url = url;
+        }
+        if (port.HasValue)
+        {
+            service.Network ??= new Network();      
+            service.Network.Port = port.Value;
+        }
+
+        if (!string.IsNullOrWhiteSpace(runsOn))
+            service.RunsOn = runsOn;
+
+        await repository.UpdateAsync(service);
+    }
+}

+ 49 - 1
RackPeek/CliBootstrap.cs

@@ -12,6 +12,7 @@ using RackPeek.Commands.Servers.Cpus;
 using RackPeek.Commands.Servers.Drives;
 using RackPeek.Commands.Servers.Drives;
 using RackPeek.Commands.Servers.Gpus;
 using RackPeek.Commands.Servers.Gpus;
 using RackPeek.Commands.Servers.Nics;
 using RackPeek.Commands.Servers.Nics;
+using RackPeek.Commands.Services;
 using RackPeek.Commands.Switches;
 using RackPeek.Commands.Switches;
 using RackPeek.Commands.Systems;
 using RackPeek.Commands.Systems;
 using RackPeek.Commands.Ups;
 using RackPeek.Commands.Ups;
@@ -30,6 +31,8 @@ using RackPeek.Domain.Resources.Hardware.Servers.Gpus;
 using RackPeek.Domain.Resources.Hardware.Servers.Nics;
 using RackPeek.Domain.Resources.Hardware.Servers.Nics;
 using RackPeek.Domain.Resources.Hardware.Switches;
 using RackPeek.Domain.Resources.Hardware.Switches;
 using RackPeek.Domain.Resources.Hardware.UpsUnits;
 using RackPeek.Domain.Resources.Hardware.UpsUnits;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.Services.UseCases;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.SystemResources.UseCases;
 using RackPeek.Domain.Resources.SystemResources.UseCases;
 using RackPeek.Yaml;
 using RackPeek.Yaml;
@@ -56,6 +59,7 @@ public static class CliBootstrap
         // Infrastructure
         // Infrastructure
         services.AddScoped<IHardwareRepository>(_ => new YamlHardwareRepository(collection));
         services.AddScoped<IHardwareRepository>(_ => new YamlHardwareRepository(collection));
         services.AddScoped<ISystemRepository>(_ => new YamlSystemRepository(collection));
         services.AddScoped<ISystemRepository>(_ => new YamlSystemRepository(collection));
+        services.AddScoped<IServiceRepository>(_ => new YamlServiceRepository(collection));
 
 
         // Application
         // Application
         services.AddScoped<ServerHardwareReportUseCase>();
         services.AddScoped<ServerHardwareReportUseCase>();
@@ -250,7 +254,24 @@ public static class CliBootstrap
         services.AddScoped<DesktopNicSetCommand>();
         services.AddScoped<DesktopNicSetCommand>();
         services.AddScoped<DesktopNicRemoveCommand>();
         services.AddScoped<DesktopNicRemoveCommand>();
 
 
-
+        // Service use cases
+        services.AddScoped<AddServiceUseCase>();
+        services.AddScoped<DeleteServiceUseCase>();
+        services.AddScoped<DescribeServiceUseCase>();
+        services.AddScoped<GetServiceUseCase>();
+        services.AddScoped<GetServiceUseCase>();
+        services.AddScoped<UpdateServiceUseCase>();
+        services.AddScoped<ServiceReportUseCase>();
+
+        // Service commands
+        services.AddScoped<ServiceSetCommand>();
+        services.AddScoped<ServiceGetCommand>();
+        services.AddScoped<ServiceGetByNameCommand>();
+        services.AddScoped<ServiceDescribeCommand>();
+        services.AddScoped<ServiceDeleteCommand>();
+        services.AddScoped<ServiceAddCommand>();
+        services.AddScoped<ServiceReportCommand>();
+        
         // Spectre bootstrap
         // Spectre bootstrap
         app.Configure(config =>
         app.Configure(config =>
         {
         {
@@ -485,6 +506,33 @@ public static class CliBootstrap
                     });
                     });
                 });
                 });
 
 
+                config.AddBranch("services", service =>
+                {
+                    service.SetDescription(
+                        "Manage services."
+                    );
+
+                    service.AddCommand<ServiceReportCommand>("summary")
+                        .WithDescription("Show service summary report");
+
+                    service.AddCommand<ServiceAddCommand>("add")
+                        .WithDescription("Add a new service");
+
+                    service.AddCommand<ServiceGetCommand>("list")
+                        .WithDescription("List all services");
+
+                    service.AddCommand<ServiceGetByNameCommand>("get")
+                        .WithDescription("Get a service by name");
+
+                    service.AddCommand<ServiceDescribeCommand>("describe")
+                        .WithDescription("Show detailed information about a service");
+
+                    service.AddCommand<ServiceSetCommand>("set")
+                        .WithDescription("Update service properties");
+
+                    service.AddCommand<ServiceDeleteCommand>("del")
+                        .WithDescription("Delete a service");
+                });
 
 
                 // ----------------------------
                 // ----------------------------
                 // Reports (read-only summaries)
                 // Reports (read-only summaries)

+ 35 - 0
RackPeek/Commands/Services/ServiceAddCommand.cs

@@ -0,0 +1,35 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Services.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Services;
+
+public class ServiceAddSettings : CommandSettings
+{
+    [CommandArgument(0, "<name>")] 
+    [Description("The name of the service.")]
+    public string Name { get; set; } = default!;
+}
+
+public class ServiceAddCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServiceAddSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServiceAddSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<AddServiceUseCase>();
+
+        await useCase.ExecuteAsync(
+            settings.Name
+        );
+
+        AnsiConsole.MarkupLine($"[green]Service '{settings.Name}' added.[/]");
+        return 0;
+    }
+}

+ 25 - 0
RackPeek/Commands/Services/ServiceDeleteCommand.cs

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

+ 50 - 0
RackPeek/Commands/Services/ServiceDescribeCommand.cs

@@ -0,0 +1,50 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Services.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Services;
+
+public class ServiceDescribeCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServiceNameSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServiceNameSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<DescribeServiceUseCase>();
+
+        var service = await useCase.ExecuteAsync(settings.Name);
+
+        if (service == null)
+        {
+            AnsiConsole.MarkupLine($"[red]Service '{settings.Name}' not found.[/]");
+            return 1;
+        }
+
+        var grid = new Grid()
+            .AddColumn(new GridColumn().NoWrap())
+            .AddColumn(new GridColumn().NoWrap())
+            .AddColumn(new GridColumn().NoWrap())
+            .AddColumn(new GridColumn().NoWrap())
+            .AddColumn(new GridColumn().NoWrap())
+            .AddColumn(new GridColumn().NoWrap());
+
+        grid.AddRow("Name:", service.Name);
+        grid.AddRow("Ip:", service.Ip ?? "Unknown");
+        grid.AddRow("Port:", service.Port?.ToString() ?? "Unknown");
+        grid.AddRow("Protocol:", service.Protocol ?? "Unknown");
+        grid.AddRow("Url:", service.Url ?? "Unknown");
+        grid.AddRow("Runs On:", service.RunsOn ?? "Unknown");
+
+        AnsiConsole.Write(
+            new Panel(grid)
+                .Header("Service")
+                .Border(BoxBorder.Rounded));
+
+        return 0;
+    }
+}

+ 32 - 0
RackPeek/Commands/Services/ServiceGetByNameCommand.cs

@@ -0,0 +1,32 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Services.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Services;
+
+public class ServiceGetByNameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServiceNameSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServiceNameSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<DescribeServiceUseCase>();
+
+        var Service = await useCase.ExecuteAsync(settings.Name);
+
+        if (Service == null)
+        {
+            AnsiConsole.MarkupLine($"[red]Service '{settings.Name}' not found.[/]");
+            return 1;
+        }
+
+        AnsiConsole.MarkupLine(
+            $"[green]{Service.Name}[/]  Ip: {Service.Ip ?? "Unknown"}, Port: {Service.Port.ToString() ?? "Unknown"}, Protocol: {Service.Protocol ?? "Unknown"}, Url: {Service.Url ?? "Unknown"}, RunsOn: {Service.RunsOn ?? "Unknown"}");
+        return 0;
+    }
+}

+ 49 - 0
RackPeek/Commands/Services/ServiceGetCommand.cs

@@ -0,0 +1,49 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Services.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Services;
+
+public class ServiceGetCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<ServiceReportUseCase>();
+
+        var report = await useCase.ExecuteAsync();
+
+        if (report.Services.Count == 0)
+        {
+            AnsiConsole.MarkupLine("[yellow]No Services found.[/]");
+            return 0;
+        }
+
+        var table = new Table()
+            .Border(TableBorder.Rounded)
+            .AddColumn("Name")
+            .AddColumn("Ip")
+            .AddColumn("Port")
+            .AddColumn("Protocol")
+            .AddColumn("Url")
+            .AddColumn("Runs On");
+
+        foreach (var s in report.Services)
+            table.AddRow(
+                s.Name,
+                s.Ip ?? "",
+                s.Port.ToString() ?? "",
+                s.Protocol ?? "",
+                s.Url ?? "",
+                s.RunsOn ?? "Unknown"
+            );
+
+        AnsiConsole.Write(table);
+        return 0;
+    }
+}

+ 11 - 0
RackPeek/Commands/Services/ServiceNameSettings.cs

@@ -0,0 +1,11 @@
+using System.ComponentModel;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Services;
+
+public class ServiceNameSettings : CommandSettings
+{
+    [CommandArgument(0, "<name>")] 
+    [Description("The name of the service.")]
+    public string Name { get; set; } = default!;
+}

+ 49 - 0
RackPeek/Commands/Services/ServiceReportCommand.cs

@@ -0,0 +1,49 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using RackPeek.Domain.Resources.Services.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Services;
+
+public class ServiceReportCommand(
+    ILogger<ServiceReportCommand> logger,
+    IServiceProvider serviceProvider
+) : AsyncCommand
+{
+    public override async Task<int> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<ServiceReportUseCase>();
+
+        var report = await useCase.ExecuteAsync();
+
+        if (report.Services.Count == 0)
+        {
+            AnsiConsole.MarkupLine("[yellow]No Services found.[/]");
+            return 0;
+        }
+
+        var table = new Table()
+            .Border(TableBorder.Rounded)
+            .AddColumn("Name")
+            .AddColumn("Ip")
+            .AddColumn("Port")
+            .AddColumn("Protocol")
+            .AddColumn("Url")
+            .AddColumn("Runs On");
+
+        foreach (var s in report.Services)
+            table.AddRow(
+                s.Name,
+                s.Ip ?? "",
+                s.Port.ToString() ?? "",
+                s.Protocol ?? "",
+                s.Url ?? "",
+                s.RunsOn ?? "Unknown"
+            );
+
+        AnsiConsole.Write(table);
+        return 0;
+    }
+}

+ 57 - 0
RackPeek/Commands/Services/ServiceSetCommand.cs

@@ -0,0 +1,57 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Commands.Servers;
+using RackPeek.Domain.Resources.Services.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Services;
+
+public class ServiceSetSettings : ServerNameSettings
+{
+    [CommandOption("--ip")] 
+    [Description("The ip address of the service.")]
+    public string? Ip { get; set; }
+
+    [CommandOption("--port")]
+    [Description("The port the service is running on.")]
+    public int? Port { get; set; }
+
+    [CommandOption("--protocol")]
+    [Description("The service protocol.")]
+    public string? Protocol { get; set; }
+
+    [CommandOption("--url")] 
+    [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; }
+}
+
+public class ServiceSetCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServiceSetSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServiceSetSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<UpdateServiceUseCase>();
+
+        await useCase.ExecuteAsync(
+            settings.Name,
+            settings.Ip,
+            settings.Port,
+            settings.Protocol,
+            settings.Url,
+            settings.RunsOn
+        );
+
+        AnsiConsole.MarkupLine($"[green]Service '{settings.Name}' updated.[/]");
+        return 0;
+    }
+}

+ 7 - 0
RackPeek/Yaml/YamlResourceCollection.cs

@@ -1,6 +1,7 @@
 using System.Collections.Specialized;
 using System.Collections.Specialized;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.Hardware.Models;
 using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.SystemResources;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization.NamingConventions;
 using YamlDotNet.Serialization.NamingConventions;
@@ -20,6 +21,8 @@ public sealed class YamlResourceCollection
     public IReadOnlyList<SystemResource> SystemResources =>
     public IReadOnlyList<SystemResource> SystemResources =>
         _entries.Select(e => e.Resource).OfType<SystemResource>().ToList();
         _entries.Select(e => e.Resource).OfType<SystemResource>().ToList();
 
 
+    public IReadOnlyList<Service> ServiceResources =>
+        _entries.Select(e => e.Resource).OfType<Service>().ToList();
     public void LoadFiles(IEnumerable<string> filePaths)
     public void LoadFiles(IEnumerable<string> filePaths)
     {
     {
         foreach (var file in filePaths)
         foreach (var file in filePaths)
@@ -131,6 +134,8 @@ public sealed class YamlResourceCollection
                 AccessPoint => "AccessPoint",
                 AccessPoint => "AccessPoint",
                 Ups => "Ups",
                 Ups => "Ups",
                 SystemResource => "System",
                 SystemResource => "System",
+                Service => "Service",
+
                 _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")
                 _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")
             }
             }
         };
         };
@@ -154,6 +159,7 @@ public sealed class YamlResourceCollection
         return map;
         return map;
     }
     }
 
 
+
     private static List<Resource> Deserialize(string yaml)
     private static List<Resource> Deserialize(string yaml)
     {
     {
         if (string.IsNullOrWhiteSpace(yaml))
         if (string.IsNullOrWhiteSpace(yaml))
@@ -192,6 +198,7 @@ public sealed class YamlResourceCollection
                 "AccessPoint" => deserializer.Deserialize<AccessPoint>(typedYaml),
                 "AccessPoint" => deserializer.Deserialize<AccessPoint>(typedYaml),
                 "Ups" => deserializer.Deserialize<Ups>(typedYaml),
                 "Ups" => deserializer.Deserialize<Ups>(typedYaml),
                 "System" => deserializer.Deserialize<SystemResource>(typedYaml),
                 "System" => deserializer.Deserialize<SystemResource>(typedYaml),
+                "Service" => deserializer.Deserialize<Service>(typedYaml),
                 _ => throw new InvalidOperationException($"Unknown kind: {kind}")
                 _ => throw new InvalidOperationException($"Unknown kind: {kind}")
             };
             };
 
 

+ 71 - 0
RackPeek/Yaml/YamlServiceRepository.cs

@@ -0,0 +1,71 @@
+
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Yaml;
+
+public class YamlServiceRepository(YamlResourceCollection resources) : IServiceRepository
+{
+    public Task<IReadOnlyList<Service>> GetAllAsync()
+    {
+        return Task.FromResult(resources.ServiceResources);
+    }
+
+    public Task<Service?> GetByNameAsync(string name)
+    {
+        return Task.FromResult(resources.GetByName(name) as Service);
+    }
+
+    public Task<IReadOnlyList<Service>> GetBySystemHostAsync(string systemHostName)
+    {
+        var systemHostNameLower = systemHostName.ToLower().Trim();
+        var results = resources.ServiceResources
+            .Where(s => s.RunsOn != null && s.RunsOn.ToLower().Equals(systemHostNameLower)).ToList();
+        return Task.FromResult<IReadOnlyList<Service>>(results);
+    }
+
+    public Task AddAsync(Service service)
+    {
+        if (resources.ServiceResources.Any(r =>
+                r.Name.Equals(service.Name, StringComparison.OrdinalIgnoreCase)))
+            throw new InvalidOperationException(
+                $"Service with name '{service.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(service, targetFile);
+        resources.SaveAll();
+
+        return Task.CompletedTask;
+    }
+
+    public Task UpdateAsync(Service service)
+    {
+        var existing = resources.ServiceResources
+            .FirstOrDefault(r => r.Name.Equals(service.Name, StringComparison.OrdinalIgnoreCase));
+
+        if (existing == null)
+            throw new InvalidOperationException($"Service '{service.Name}' not found.");
+
+        resources.Update(service);
+        resources.SaveAll();
+
+        return Task.CompletedTask;
+    }
+
+    public Task DeleteAsync(string name)
+    {
+        var existing = resources.ServiceResources
+            .FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+
+        if (existing == null)
+            throw new InvalidOperationException($"Service '{name}' not found.");
+
+        resources.Delete(name);
+        resources.SaveAll();
+
+        return Task.CompletedTask;
+    }
+}

+ 114 - 0
Tests/EndToEnd/ServiceYamlE2ETests.cs

@@ -0,0 +1,114 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd;
+
+[Collection("Yaml CLI tests")]
+public class ServiceYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture>
+{
+    private async Task<(string, string)> ExecuteAsync(params string[] args)
+    {
+        outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
+
+        var inputArgs = args.ToArray();
+        var output = await YamlCliTestHost.RunAsync(
+            inputArgs,
+            fs.Root,
+            outputHelper,
+            "config.yaml"
+        );
+
+        outputHelper.WriteLine(output);
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        return (output, yaml);
+    }
+
+    [Fact]
+    public async Task systems_cli_workflow_test()
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        // Add system
+        var (output, yaml) = await ExecuteAsync("services", "add", "immich");
+        Assert.Equal("Service 'immich' added.\n", output);
+        Assert.Equal("""
+                     resources:
+                     - kind: Service
+                       network: 
+                       runsOn: 
+                       name: immich
+                       tags: 
+
+                     """, yaml);
+
+        // Update system
+        (output, yaml) = await ExecuteAsync(
+            "services", "set", "immich",
+            "--ip", "192.168.10.14",
+            "--port", "80",
+            "--protocol", "TCP",
+            "--url", "http://timmoth.lan:80",
+            "--runs-on", "vm01"
+        );
+
+        Assert.Equal("Service 'immich' updated.\n", output);
+
+        Assert.Equal("""
+                     resources:
+                     - kind: Service
+                       network:
+                         ip: 192.168.10.14
+                         port: 80
+                         protocol: TCP
+                         url: http://timmoth.lan:80
+                       runsOn: vm01
+                       name: immich
+                       tags: 
+
+                     """, yaml);
+
+        // Get system by name
+        (output, yaml) = await ExecuteAsync("services", "get", "immich");
+        Assert.Equal(
+            "immich  Ip: 192.168.10.14, Port: 80, Protocol: TCP, Url: http://timmoth.lan:80, \nRunsOn: vm01\n",
+            output);
+
+        // List systems
+        (output, yaml) = await ExecuteAsync("services", "list");
+        Assert.Equal("""
+                     ╭────────┬───────────────┬──────┬──────────┬───────────────────────┬─────────╮
+                     │ Name   │ Ip            │ Port │ Protocol │ Url                   │ Runs On │
+                     ├────────┼───────────────┼──────┼──────────┼───────────────────────┼─────────┤
+                     │ immich │ 192.168.10.14 │ 80   │ TCP      │ http://timmoth.lan:80 │ vm01    │
+                     ╰────────┴───────────────┴──────┴──────────┴───────────────────────┴─────────╯
+                     
+                     """, output);
+
+        // Report systems
+        (output, yaml) = await ExecuteAsync("services", "summary");
+        Assert.Equal("""
+                     ╭────────┬───────────────┬──────┬──────────┬───────────────────────┬─────────╮
+                     │ Name   │ Ip            │ Port │ Protocol │ Url                   │ Runs On │
+                     ├────────┼───────────────┼──────┼──────────┼───────────────────────┼─────────┤
+                     │ immich │ 192.168.10.14 │ 80   │ TCP      │ http://timmoth.lan:80 │ vm01    │
+                     ╰────────┴───────────────┴──────┴──────────┴───────────────────────┴─────────╯
+                     
+                     """, output);
+
+        // Delete system
+        (output, yaml) = await ExecuteAsync("services", "del", "immich");
+        Assert.Equal("""
+                     Service 'immich' deleted.
+
+                     """, output);
+
+        // Ensure list is empty
+        (output, yaml) = await ExecuteAsync("services", "list");
+        Assert.Equal("""
+                     No Services found.
+
+                     """, output);
+    }
+}

+ 50 - 0
Tests/Yaml/ServiceDeserializationTests.cs

@@ -0,0 +1,50 @@
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Yaml;
+
+namespace Tests.Yaml;
+
+public class ServiceDeserializationTests
+{
+    public static IServiceRepository CreateSut(string yaml)
+    {
+        var yamlResourceCollection = new YamlResourceCollection();
+        yamlResourceCollection.Load(yaml, "test.yaml");
+        return new YamlServiceRepository(yamlResourceCollection);
+    }
+    [Fact]
+    public async Task deserialize_yaml_kind_Service()
+    {
+        // Given
+        var yaml = @"
+resources:
+  - kind: Service
+    name: immich
+    network:
+      ip: 192.168.0.4
+      port: 8080
+      protocol: TCP
+      url: http://immich.lan:8080
+    runsOn: proxmox-host
+";
+
+        var sut = CreateSut(yaml);
+
+        // When
+        var resources = await sut.GetAllAsync();
+
+        // Then
+        var resource = Assert.Single(resources);
+        var service = Assert.IsType<Service>(resource);
+
+        Assert.Equal("immich", service.Name);
+        Assert.Equal("Service", service.Kind);
+        Assert.Equal("proxmox-host", service.RunsOn);
+
+        Assert.NotNull(service.Network);
+        Assert.Equal("192.168.0.4", service.Network.Ip);
+        Assert.Equal(8080, service.Network.Port);
+        Assert.Equal("TCP", service.Network.Protocol);
+        Assert.Equal("http://immich.lan:8080", service.Network.Url);
+    }
+}

+ 1 - 1
Tests/Yaml/SystemDeserializationTests.cs

@@ -3,7 +3,7 @@ using RackPeek.Yaml;
 
 
 namespace Tests.Yaml;
 namespace Tests.Yaml;
 
 
-public class ServiceDeserializationTests
+public class SystemDeserializationTests
 {
 {
     public static ISystemRepository CreateSut(string yaml)
     public static ISystemRepository CreateSut(string yaml)
     {
     {