Tim Jones пре 2 недеља
родитељ
комит
9071d809ea

+ 93 - 0
Shared.Rcl/CliBootstrap.cs

@@ -5,6 +5,15 @@ using RackPeek.Domain;
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
+using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.Desktops;
+using RackPeek.Domain.Resources.Firewalls;
+using RackPeek.Domain.Resources.Laptops;
+using RackPeek.Domain.Resources.Routers;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.Resources.SystemResources;
 using Shared.Rcl.Commands;
 using Shared.Rcl.Commands.AccessPoints;
 using Shared.Rcl.Commands.AccessPoints.Labels;
@@ -49,6 +58,7 @@ using Shared.Rcl.Commands.Switches.Rename;
 using Shared.Rcl.Commands.Systems;
 using Shared.Rcl.Commands.Systems.Labels;
 using Shared.Rcl.Commands.Systems.Rename;
+using Shared.Rcl.Commands.Tags;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups.Labels;
 using Shared.Rcl.Commands.Ups.Rename;
@@ -204,6 +214,13 @@ public static class CliBootstrap {
                     label.AddCommand<ServerLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a server.");
                 });
+
+                // Server Tags
+                server.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a server.");
+                    tag.AddCommand<TagAddCommand<Server>>("add").WithDescription("Add a tag to a server.");
+                    tag.AddCommand<TagRemoveCommand<Server>>("remove").WithDescription("Remove a tag from a server.");
+                });
             });
 
             // ----------------------------
@@ -249,6 +266,12 @@ public static class CliBootstrap {
                     label.AddCommand<SwitchLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a switch.");
                 });
+
+                switches.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a switch.");
+                    tag.AddCommand<TagAddCommand<Switch>>("add").WithDescription("Add a tag to a switch.");
+                    tag.AddCommand<TagRemoveCommand<Switch>>("remove").WithDescription("Remove a tag from a switch.");
+                });
             });
 
             // ----------------------------
@@ -294,6 +317,12 @@ public static class CliBootstrap {
                     label.AddCommand<RouterLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a router.");
                 });
+
+                routers.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a router.");
+                    tag.AddCommand<TagAddCommand<Router>>("add").WithDescription("Add a tag to a router.");
+                    tag.AddCommand<TagRemoveCommand<Router>>("remove").WithDescription("Remove a tag from a router.");
+                });
             });
 
             // ----------------------------
@@ -339,6 +368,13 @@ public static class CliBootstrap {
                     label.AddCommand<FirewallLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a firewall.");
                 });
+
+                firewalls.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a firewall.");
+                    tag.AddCommand<TagAddCommand<Firewall>>("add").WithDescription("Add a tag to a firewall.");
+                    tag.AddCommand<TagRemoveCommand<Firewall>>("remove")
+                        .WithDescription("Remove a tag from a firewall.");
+                });
             });
 
             // ----------------------------
@@ -375,6 +411,13 @@ public static class CliBootstrap {
                     label.AddCommand<SystemLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a system.");
                 });
+
+                system.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a system.");
+                    tag.AddCommand<TagAddCommand<SystemResource>>("add").WithDescription("Add a tag to a system.");
+                    tag.AddCommand<TagRemoveCommand<SystemResource>>("remove")
+                        .WithDescription("Remove a tag from a system.");
+                });
             });
 
             // ----------------------------
@@ -409,6 +452,14 @@ public static class CliBootstrap {
                     label.AddCommand<AccessPointLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from an access point.");
                 });
+
+                ap.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on an access point.");
+                    tag.AddCommand<TagAddCommand<AccessPoint>>("add")
+                        .WithDescription("Add a tag to an access point.");
+                    tag.AddCommand<TagRemoveCommand<AccessPoint>>("remove")
+                        .WithDescription("Remove a tag from an access point.");
+                });
             });
 
             // ----------------------------
@@ -442,6 +493,14 @@ public static class CliBootstrap {
                     label.AddCommand<UpsLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a UPS unit.");
                 });
+
+                ups.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a UPS unit.");
+                    tag.AddCommand<TagAddCommand<RackPeek.Domain.Resources.UpsUnits.Ups>>("add")
+                        .WithDescription("Add a tag to a UPS unit.");
+                    tag.AddCommand<TagRemoveCommand<RackPeek.Domain.Resources.UpsUnits.Ups>>("remove")
+                        .WithDescription("Remove a tag from a UPS unit.");
+                });
             });
 
             // ----------------------------
@@ -507,6 +566,13 @@ public static class CliBootstrap {
                     label.AddCommand<DesktopLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a desktop.");
                 });
+
+                desktops.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a desktop.");
+                    tag.AddCommand<TagAddCommand<Desktop>>("add").WithDescription("Add a tag to a desktop.");
+                    tag.AddCommand<TagRemoveCommand<Desktop>>("remove")
+                        .WithDescription("Remove a tag from a desktop.");
+                });
             });
 
             // ----------------------------
@@ -562,6 +628,13 @@ public static class CliBootstrap {
                     label.AddCommand<LaptopLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a laptop.");
                 });
+
+                laptops.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a laptop.");
+                    tag.AddCommand<TagAddCommand<Laptop>>("add").WithDescription("Add a tag to a laptop.");
+                    tag.AddCommand<TagRemoveCommand<Laptop>>("remove")
+                        .WithDescription("Remove a tag from a laptop.");
+                });
             });
 
             // ----------------------------
@@ -598,6 +671,13 @@ public static class CliBootstrap {
                     label.AddCommand<ServiceLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a service.");
                 });
+
+                service.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a service.");
+                    tag.AddCommand<TagAddCommand<Service>>("add").WithDescription("Add a tag to a service.");
+                    tag.AddCommand<TagRemoveCommand<Service>>("remove")
+                        .WithDescription("Remove a tag from a service.");
+                });
             });
 
             // ----------------------------
@@ -624,6 +704,19 @@ public static class CliBootstrap {
                     .WithDescription("Generate a /etc/hosts compatible file.");
             });
 
+            // ----------------------------
+            // Tags discovery
+            // ----------------------------
+            config.AddBranch("tags", tags => {
+                tags.SetDescription("Discover tags across resources.");
+
+                tags.AddCommand<TagsListCommand>("list")
+                    .WithDescription("List all tags in use with usage counts.");
+
+                tags.AddCommand<TagsShowCommand>("show")
+                    .WithDescription("List resources carrying a specific tag.");
+            });
+
             config.AddBranch("connections", connections => {
                 connections.SetDescription("Manage physical or logical port connections.");
 

+ 9 - 0
Shared.Rcl/Commands/Tags/ResourceTagSettings.cs

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

+ 24 - 0
Shared.Rcl/Commands/Tags/TagAddCommand.cs

@@ -0,0 +1,24 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.UseCases.Tags;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Tags;
+
+public class TagAddCommand<T>(IServiceProvider serviceProvider)
+    : AsyncCommand<ResourceTagSettings>
+    where T : Resource {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ResourceTagSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IAddTagUseCase<T> useCase = scope.ServiceProvider.GetRequiredService<IAddTagUseCase<T>>();
+
+        await useCase.ExecuteAsync(settings.Name, settings.Tag);
+
+        AnsiConsole.MarkupLine($"[green]Tag '{settings.Tag}' added to '{settings.Name}'.[/]");
+        return 0;
+    }
+}

+ 24 - 0
Shared.Rcl/Commands/Tags/TagRemoveCommand.cs

@@ -0,0 +1,24 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.UseCases.Tags;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Tags;
+
+public class TagRemoveCommand<T>(IServiceProvider serviceProvider)
+    : AsyncCommand<ResourceTagSettings>
+    where T : Resource {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ResourceTagSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRemoveTagUseCase<T> useCase = scope.ServiceProvider.GetRequiredService<IRemoveTagUseCase<T>>();
+
+        await useCase.ExecuteAsync(settings.Name, settings.Tag);
+
+        AnsiConsole.MarkupLine($"[green]Tag '{settings.Tag}' removed from '{settings.Name}'.[/]");
+        return 0;
+    }
+}

+ 34 - 0
Shared.Rcl/Commands/Tags/TagsListCommand.cs

@@ -0,0 +1,34 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Persistence;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Tags;
+
+public class TagsListCommand(IServiceProvider serviceProvider) : AsyncCommand {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IResourceCollection repo = scope.ServiceProvider.GetRequiredService<IResourceCollection>();
+
+        Dictionary<string, int> tags = await repo.GetTagsAsync();
+
+        if (tags.Count == 0) {
+            AnsiConsole.MarkupLine("[grey]No tags in use.[/]");
+            return 0;
+        }
+
+        Table table = new Table()
+            .Border(TableBorder.Rounded)
+            .Title("[bold]Tags[/]")
+            .AddColumn("Tag")
+            .AddColumn(new TableColumn("Count").RightAligned());
+
+        foreach ((var tag, var count) in tags.OrderByDescending(t => t.Value).ThenBy(t => t.Key))
+            table.AddRow(tag.EscapeMarkup(), count.ToString());
+
+        AnsiConsole.Write(table);
+        return 0;
+    }
+}

+ 40 - 0
Shared.Rcl/Commands/Tags/TagsShowCommand.cs

@@ -0,0 +1,40 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Tags;
+
+public class TagsShowSettings : CommandSettings {
+    [CommandArgument(0, "<tag>")] public string Tag { get; set; } = default!;
+}
+
+public class TagsShowCommand(IServiceProvider serviceProvider) : AsyncCommand<TagsShowSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        TagsShowSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IResourceCollection repo = scope.ServiceProvider.GetRequiredService<IResourceCollection>();
+
+        IReadOnlyList<Resource> resources = await repo.GetByTagAsync(settings.Tag);
+
+        if (resources.Count == 0) {
+            AnsiConsole.MarkupLine($"[grey]No resources tagged '{settings.Tag.EscapeMarkup()}'.[/]");
+            return 0;
+        }
+
+        Table table = new Table()
+            .Border(TableBorder.Rounded)
+            .Title($"[bold]Resources tagged '{settings.Tag.EscapeMarkup()}'[/]")
+            .AddColumn("Name")
+            .AddColumn("Kind");
+
+        foreach (Resource resource in resources.OrderBy(r => r.Kind).ThenBy(r => r.Name))
+            table.AddRow(resource.Name.EscapeMarkup(), resource.Kind.EscapeMarkup());
+
+        AnsiConsole.Write(table);
+        return 0;
+    }
+}

+ 74 - 0
Tests/EndToEnd/Tags/TagsWorkflowTests.cs

@@ -0,0 +1,74 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.Tags;
+
+[Collection("Yaml CLI tests")]
+public class TagsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) {
+        outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
+
+        var output = await YamlCliTestHost.RunAsync(
+            args,
+            fs.Root,
+            outputHelper,
+            "config.yaml");
+
+        outputHelper.WriteLine(output);
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        return (output, yaml);
+    }
+
+    [Theory]
+    [InlineData("servers")]
+    [InlineData("switches")]
+    [InlineData("routers")]
+    [InlineData("firewalls")]
+    [InlineData("accesspoints")]
+    [InlineData("ups")]
+    [InlineData("desktops")]
+    [InlineData("laptops")]
+    [InlineData("services")]
+    [InlineData("systems")]
+    public async Task tags_cli_workflow_test(string resourceCommand) {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        (_, var yaml) = await ExecuteAsync(resourceCommand, "add", "web-01");
+        Assert.Contains("web-01", yaml);
+
+        (var output, yaml) = await ExecuteAsync(resourceCommand, "tag", "add", "web-01", "homelab");
+        Assert.Contains("Tag 'homelab' added", output);
+        Assert.Contains("homelab", yaml);
+
+        (output, _) = await ExecuteAsync("tags", "show", "homelab");
+        Assert.Contains("web-01", output);
+
+        (output, yaml) = await ExecuteAsync(resourceCommand, "tag", "remove", "web-01", "homelab");
+        Assert.Contains("Tag 'homelab' removed", output);
+        Assert.DoesNotContain("homelab", yaml);
+    }
+
+    [Fact]
+    public async Task tags_discovery_test() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("servers", "add", "srv-01");
+        await ExecuteAsync("services", "add", "svc-01");
+        await ExecuteAsync("servers", "add", "srv-02");
+
+        await ExecuteAsync("servers", "tag", "add", "srv-01", "homelab");
+        await ExecuteAsync("services", "tag", "add", "svc-01", "homelab");
+        await ExecuteAsync("servers", "tag", "add", "srv-02", "prod");
+
+        (var output, _) = await ExecuteAsync("tags", "list");
+        Assert.Contains("homelab", output);
+        Assert.Contains("prod", output);
+
+        (output, _) = await ExecuteAsync("tags", "show", "homelab");
+        Assert.Contains("srv-01", output);
+        Assert.Contains("svc-01", output);
+        Assert.DoesNotContain("srv-02", output);
+    }
+}