Przeglądaj źródła

Added service resource

Tim Jones 2 miesięcy temu
rodzic
commit
303ebb4df1
26 zmienionych plików z 1067 dodań i 5 usunięć
  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

BIN
.DS_Store


+ 134 - 1
COMMANDS.md

@@ -59,6 +59,14 @@
   - [`rpk desktops nic add`](#rpk-desktops-nic-add)
   - [`rpk desktops nic set`](#rpk-desktops-nic-set)
   - [`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 servers`](#rpk-servers)
   - [`rpk servers summary`](#rpk-servers-summary)
@@ -100,7 +108,7 @@ COMMANDS:
     systems         Manage systems                   
     accesspoints    Manage access points             
     ups             Manage UPS units                 
-    desktops                                         
+    services        Manage services                  
     ap              Show access point hardware report
     desktops        Show desktop hardware report     
     ups             Show UPS hardware report         
@@ -918,6 +926,131 @@ OPTIONS:
     -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`
 ```
 DESCRIPTION:

+ 135 - 2
README.md

@@ -6,7 +6,6 @@ It’s designed to help you inventory, configure, and audit your environment in
 # CLI Commands
 
 ## Command Tree
-
 - [RackPeek](#rackpeek)
 - [CLI Commands](#cli-commands)
   - [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 set`](#rpk-desktops-nic-set)
   - [`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 servers`](#rpk-servers)
   - [`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 del`](#rpk-servers-nic-del)
 
+---
 
 ## `rpk`
 ```
@@ -106,7 +114,7 @@ COMMANDS:
     systems         Manage systems                   
     accesspoints    Manage access points             
     ups             Manage UPS units                 
-    desktops                                         
+    services        Manage services                  
     ap              Show access point hardware report
     desktops        Show desktop hardware report     
     ups             Show UPS hardware report         
@@ -924,6 +932,131 @@ OPTIONS:
     -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`
 ```
 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.Gpus;
 using RackPeek.Commands.Servers.Nics;
+using RackPeek.Commands.Services;
 using RackPeek.Commands.Switches;
 using RackPeek.Commands.Systems;
 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.Switches;
 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.UseCases;
 using RackPeek.Yaml;
@@ -56,6 +59,7 @@ public static class CliBootstrap
         // Infrastructure
         services.AddScoped<IHardwareRepository>(_ => new YamlHardwareRepository(collection));
         services.AddScoped<ISystemRepository>(_ => new YamlSystemRepository(collection));
+        services.AddScoped<IServiceRepository>(_ => new YamlServiceRepository(collection));
 
         // Application
         services.AddScoped<ServerHardwareReportUseCase>();
@@ -250,7 +254,24 @@ public static class CliBootstrap
         services.AddScoped<DesktopNicSetCommand>();
         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
         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)

+ 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 RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization.NamingConventions;
@@ -20,6 +21,8 @@ public sealed class YamlResourceCollection
     public IReadOnlyList<SystemResource> SystemResources =>
         _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)
     {
         foreach (var file in filePaths)
@@ -131,6 +134,8 @@ public sealed class YamlResourceCollection
                 AccessPoint => "AccessPoint",
                 Ups => "Ups",
                 SystemResource => "System",
+                Service => "Service",
+
                 _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")
             }
         };
@@ -154,6 +159,7 @@ public sealed class YamlResourceCollection
         return map;
     }
 
+
     private static List<Resource> Deserialize(string yaml)
     {
         if (string.IsNullOrWhiteSpace(yaml))
@@ -192,6 +198,7 @@ public sealed class YamlResourceCollection
                 "AccessPoint" => deserializer.Deserialize<AccessPoint>(typedYaml),
                 "Ups" => deserializer.Deserialize<Ups>(typedYaml),
                 "System" => deserializer.Deserialize<SystemResource>(typedYaml),
+                "Service" => deserializer.Deserialize<Service>(typedYaml),
                 _ => 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;
 
-public class ServiceDeserializationTests
+public class SystemDeserializationTests
 {
     public static ISystemRepository CreateSut(string yaml)
     {