From ad4cc14f33ff64f184acc0bc2be8a69ea11170f5 Mon Sep 17 00:00:00 2001 From: Mathieu Poussin Date: Tue, 10 Feb 2026 11:13:24 +0100 Subject: [PATCH 1/3] Fuzzy search, add routes and firewall search --- internal/gcp/resources.go | 98 +++++++++- internal/gcp/search/addresses.go | 15 +- internal/gcp/search/addresses_test.go | 78 ++++++++ internal/gcp/search/backend_services.go | 2 +- internal/gcp/search/backend_services_test.go | 29 +++ internal/gcp/search/buckets.go | 2 +- internal/gcp/search/buckets_test.go | 19 ++ internal/gcp/search/cloud_run.go | 2 +- internal/gcp/search/cloud_run_test.go | 29 +++ internal/gcp/search/cloudsql.go | 2 +- internal/gcp/search/cloudsql_test.go | 29 +++ internal/gcp/search/disks.go | 2 +- internal/gcp/search/disks_test.go | 19 ++ internal/gcp/search/firewall_rules.go | 26 ++- internal/gcp/search/firewall_rules_test.go | 120 ++++++++++++ internal/gcp/search/forwarding_rules.go | 2 +- internal/gcp/search/forwarding_rules_test.go | 38 ++++ internal/gcp/search/gke_clusters.go | 2 +- internal/gcp/search/gke_clusters_test.go | 19 ++ internal/gcp/search/gke_node_pools.go | 2 +- internal/gcp/search/gke_node_pools_test.go | 29 +++ internal/gcp/search/health_checks.go | 2 +- internal/gcp/search/health_checks_test.go | 19 ++ internal/gcp/search/instance_templates.go | 2 +- .../gcp/search/instance_templates_test.go | 29 +++ internal/gcp/search/instances.go | 2 +- internal/gcp/search/instances_test.go | 38 ++++ .../gcp/search/managed_instance_groups.go | 2 +- .../search/managed_instance_groups_test.go | 38 ++++ internal/gcp/search/routes.go | 93 +++++++++ internal/gcp/search/routes_test.go | 183 ++++++++++++++++++ internal/gcp/search/secrets.go | 2 +- internal/gcp/search/secrets_test.go | 19 ++ internal/gcp/search/snapshots.go | 2 +- internal/gcp/search/snapshots_test.go | 19 ++ internal/gcp/search/subnets.go | 2 +- internal/gcp/search/subnets_test.go | 29 +++ internal/gcp/search/target_pools.go | 2 +- internal/gcp/search/target_pools_test.go | 19 ++ internal/gcp/search/types.go | 35 ++++ internal/gcp/search/types_test.go | 39 ++++ internal/gcp/search/url_maps.go | 2 +- internal/gcp/search/url_maps_test.go | 19 ++ internal/gcp/search/vpc_networks.go | 2 +- internal/gcp/search/vpn_gateways.go | 2 +- internal/gcp/search/vpn_gateways_test.go | 19 ++ internal/gcp/search/vpn_tunnels.go | 2 +- internal/gcp/search/vpn_tunnels_test.go | 29 +++ internal/gcp/types.go | 29 ++- internal/tui/search_view.go | 78 ++++++-- internal/tui/search_view_test.go | 30 +++ 51 files changed, 1309 insertions(+), 43 deletions(-) create mode 100644 internal/gcp/search/routes.go create mode 100644 internal/gcp/search/routes_test.go create mode 100644 internal/tui/search_view_test.go diff --git a/internal/gcp/resources.go b/internal/gcp/resources.go index 3729903..07671fb 100644 --- a/internal/gcp/resources.go +++ b/internal/gcp/resources.go @@ -3,6 +3,7 @@ package gcp import ( "context" "fmt" + "strings" "github.com/kedare/compass/internal/logger" "google.golang.org/api/compute/v1" @@ -169,12 +170,21 @@ func (c *Client) ListAddresses(ctx context.Context) ([]*Address, error) { continue } + // Extract user resource names from full URLs + users := make([]string, 0, len(addr.Users)) + for _, u := range addr.Users { + users = append(users, extractResourceName(u)) + } + results = append(results, &Address{ Name: addr.Name, Address: addr.Address, Region: regionName, AddressType: addr.AddressType, Status: addr.Status, + Description: addr.Description, + Subnetwork: extractResourceName(addr.Subnetwork), + Users: users, }) } } @@ -919,12 +929,90 @@ func (c *Client) ListFirewallRules(ctx context.Context) ([]*FirewallRule, error) continue } + // Format allowed rules as "protocol:ports" strings + allowed := make([]string, 0, len(rule.Allowed)) + for _, a := range rule.Allowed { + entry := a.IPProtocol + if len(a.Ports) > 0 { + entry += ":" + strings.Join(a.Ports, ",") + } + allowed = append(allowed, entry) + } + results = append(results, &FirewallRule{ - Name: rule.Name, - Network: extractResourceName(rule.Network), - Direction: rule.Direction, - Priority: rule.Priority, - Disabled: rule.Disabled, + Name: rule.Name, + Network: extractResourceName(rule.Network), + Direction: rule.Direction, + Priority: rule.Priority, + Disabled: rule.Disabled, + Description: rule.Description, + SourceRanges: rule.SourceRanges, + TargetTags: rule.TargetTags, + Allowed: allowed, + }) + } + + if resp.NextPageToken == "" { + break + } + + pageToken = resp.NextPageToken + } + + return results, nil +} + +// ListRoutes returns the VPC routes available in the project. +func (c *Client) ListRoutes(ctx context.Context) ([]*Route, error) { + logger.Log.Debug("Listing routes") + + pageToken := "" + var results []*Route + + for { + call := c.service.Routes.List(c.project).Context(ctx) + if pageToken != "" { + call = call.PageToken(pageToken) + } + + resp, err := call.Do() + if err != nil { + logger.Log.Errorf("Failed to list routes: %v", err) + + return nil, fmt.Errorf("failed to list routes: %w", err) + } + + for _, route := range resp.Items { + if route == nil { + continue + } + + // Determine next hop + nextHop := "" + switch { + case route.NextHopGateway != "": + nextHop = extractResourceName(route.NextHopGateway) + case route.NextHopInstance != "": + nextHop = extractResourceName(route.NextHopInstance) + case route.NextHopIp != "": + nextHop = route.NextHopIp + case route.NextHopVpnTunnel != "": + nextHop = extractResourceName(route.NextHopVpnTunnel) + case route.NextHopIlb != "": + nextHop = route.NextHopIlb + case route.NextHopPeering != "": + nextHop = route.NextHopPeering + } + + results = append(results, &Route{ + Name: route.Name, + Description: route.Description, + DestRange: route.DestRange, + Network: extractResourceName(route.Network), + Priority: route.Priority, + NextHop: nextHop, + RouteType: route.RouteType, + Tags: route.Tags, }) } diff --git a/internal/gcp/search/addresses.go b/internal/gcp/search/addresses.go index cb4b13e..19deb60 100644 --- a/internal/gcp/search/addresses.go +++ b/internal/gcp/search/addresses.go @@ -3,6 +3,7 @@ package search import ( "context" "fmt" + "strings" "github.com/kedare/compass/internal/gcp" ) @@ -43,7 +44,7 @@ func (p *AddressProvider) Search(ctx context.Context, project string, query Quer matches := make([]Result, 0, len(addresses)) for _, addr := range addresses { - if addr == nil || !query.Matches(addr.Name) { + if addr == nil || !query.MatchesAny(addr.Name, addr.Address, addr.Description) { continue } @@ -75,5 +76,17 @@ func addressDetails(addr *gcp.Address) map[string]string { details["status"] = addr.Status } + if addr.Description != "" { + details["description"] = addr.Description + } + + if addr.Subnetwork != "" { + details["subnetwork"] = addr.Subnetwork + } + + if len(addr.Users) > 0 { + details["users"] = strings.Join(addr.Users, ", ") + } + return details } diff --git a/internal/gcp/search/addresses_test.go b/internal/gcp/search/addresses_test.go index 787a833..87f3655 100644 --- a/internal/gcp/search/addresses_test.go +++ b/internal/gcp/search/addresses_test.go @@ -65,6 +65,84 @@ func TestAddressProviderPropagatesErrors(t *testing.T) { } } +func TestAddressProviderMatchesByDescription(t *testing.T) { + client := &fakeAddressClient{addresses: []*gcp.Address{ + {Name: "lb-frontend-ip", Address: "35.1.2.3", Region: "us-central1", AddressType: "EXTERNAL", Status: "IN_USE", Description: "frontend load balancer VIP"}, + {Name: "nat-ip", Address: "34.5.6.7", Region: "us-central1", AddressType: "EXTERNAL", Status: "IN_USE"}, + }} + + provider := &AddressProvider{NewClient: func(ctx context.Context, project string) (AddressClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "load balancer"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "lb-frontend-ip" { + t.Fatalf("expected 1 result for description search, got %d", len(results)) + } +} + +func TestAddressProviderMatchesByIP(t *testing.T) { + client := &fakeAddressClient{addresses: []*gcp.Address{ + {Name: "web-ip", Address: "35.1.2.3", Region: "us-central1", AddressType: "EXTERNAL", Status: "IN_USE"}, + {Name: "db-ip", Address: "10.0.0.5", Region: "us-central1", AddressType: "INTERNAL", Status: "IN_USE"}, + }} + + provider := &AddressProvider{NewClient: func(ctx context.Context, project string) (AddressClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "10.0.0"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "db-ip" { + t.Fatalf("expected 1 result for IP search, got %d", len(results)) + } +} + +func TestAddressDetails(t *testing.T) { + addr := &gcp.Address{ + Name: "my-ip", + Address: "35.1.2.3", + AddressType: "EXTERNAL", + Status: "IN_USE", + Description: "web frontend IP", + Subnetwork: "default", + Users: []string{"forwarding-rule-1", "forwarding-rule-2"}, + } + + details := addressDetails(addr) + if details["address"] != "35.1.2.3" { + t.Errorf("expected address 35.1.2.3, got %q", details["address"]) + } + if details["description"] != "web frontend IP" { + t.Errorf("expected description 'web frontend IP', got %q", details["description"]) + } + if details["subnetwork"] != "default" { + t.Errorf("expected subnetwork 'default', got %q", details["subnetwork"]) + } + if details["users"] != "forwarding-rule-1, forwarding-rule-2" { + t.Errorf("expected users list, got %q", details["users"]) + } +} + +func TestAddressDetailsEmpty(t *testing.T) { + addr := &gcp.Address{Name: "minimal-ip"} + details := addressDetails(addr) + if _, ok := details["description"]; ok { + t.Error("expected no description key for empty description") + } + if _, ok := details["subnetwork"]; ok { + t.Error("expected no subnetwork key for empty subnetwork") + } + if _, ok := details["users"]; ok { + t.Error("expected no users key for empty users") + } +} + func TestAddressProviderNilProvider(t *testing.T) { var provider *AddressProvider _, err := provider.Search(context.Background(), "proj-a", Query{Term: "foo"}) diff --git a/internal/gcp/search/backend_services.go b/internal/gcp/search/backend_services.go index 18bb377..c9fdb60 100644 --- a/internal/gcp/search/backend_services.go +++ b/internal/gcp/search/backend_services.go @@ -43,7 +43,7 @@ func (p *BackendServiceProvider) Search(ctx context.Context, project string, que matches := make([]Result, 0, len(services)) for _, svc := range services { - if svc == nil || !query.Matches(svc.Name) { + if svc == nil || !query.MatchesAny(svc.Name, svc.Protocol, svc.LoadBalancingScheme) { continue } diff --git a/internal/gcp/search/backend_services_test.go b/internal/gcp/search/backend_services_test.go index 1e649ea..79fc19c 100644 --- a/internal/gcp/search/backend_services_test.go +++ b/internal/gcp/search/backend_services_test.go @@ -47,6 +47,35 @@ func TestBackendServiceProviderReturnsMatches(t *testing.T) { } } +func TestBackendServiceProviderMatchesByDetailFields(t *testing.T) { + client := &fakeBackendServiceClient{services: []*gcp.BackendService{ + {Name: "web-backend", Region: "us-central1", Protocol: "HTTP", LoadBalancingScheme: "EXTERNAL"}, + {Name: "grpc-backend", Region: "us-central1", Protocol: "GRPC", LoadBalancingScheme: "INTERNAL"}, + }} + + provider := &BackendServiceProvider{NewClient: func(ctx context.Context, project string) (BackendServiceClient, error) { + return client, nil + }} + + // Search by protocol + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "GRPC"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "grpc-backend" { + t.Fatalf("expected 1 result for protocol search, got %d", len(results)) + } + + // Search by load balancing scheme + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "EXTERNAL"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "web-backend" { + t.Fatalf("expected 1 result for LB scheme search, got %d", len(results)) + } +} + func TestBackendServiceProviderPropagatesErrors(t *testing.T) { provider := &BackendServiceProvider{NewClient: func(context.Context, string) (BackendServiceClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/buckets.go b/internal/gcp/search/buckets.go index 163f1dd..79836d9 100644 --- a/internal/gcp/search/buckets.go +++ b/internal/gcp/search/buckets.go @@ -43,7 +43,7 @@ func (p *BucketProvider) Search(ctx context.Context, project string, query Query matches := make([]Result, 0, len(buckets)) for _, bucket := range buckets { - if bucket == nil || !query.Matches(bucket.Name) { + if bucket == nil || !query.MatchesAny(bucket.Name, bucket.StorageClass) { continue } diff --git a/internal/gcp/search/buckets_test.go b/internal/gcp/search/buckets_test.go index 0a51ac3..dfa656b 100644 --- a/internal/gcp/search/buckets_test.go +++ b/internal/gcp/search/buckets_test.go @@ -47,6 +47,25 @@ func TestBucketProviderReturnsMatches(t *testing.T) { } } +func TestBucketProviderMatchesByStorageClass(t *testing.T) { + client := &fakeBucketClient{buckets: []*gcp.Bucket{ + {Name: "archive-bucket", Location: "US", StorageClass: "COLDLINE"}, + {Name: "hot-bucket", Location: "US", StorageClass: "STANDARD"}, + }} + + provider := &BucketProvider{NewClient: func(ctx context.Context, project string) (BucketClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "COLDLINE"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "archive-bucket" { + t.Fatalf("expected 1 result for storage class search, got %d", len(results)) + } +} + func TestBucketProviderPropagatesErrors(t *testing.T) { provider := &BucketProvider{NewClient: func(context.Context, string) (BucketClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/cloud_run.go b/internal/gcp/search/cloud_run.go index a8be6e0..8247dd8 100644 --- a/internal/gcp/search/cloud_run.go +++ b/internal/gcp/search/cloud_run.go @@ -43,7 +43,7 @@ func (p *CloudRunProvider) Search(ctx context.Context, project string, query Que matches := make([]Result, 0, len(services)) for _, svc := range services { - if svc == nil || !query.Matches(svc.Name) { + if svc == nil || !query.MatchesAny(svc.Name, svc.URL, svc.LatestRevision) { continue } diff --git a/internal/gcp/search/cloud_run_test.go b/internal/gcp/search/cloud_run_test.go index ad7d8eb..3760ce7 100644 --- a/internal/gcp/search/cloud_run_test.go +++ b/internal/gcp/search/cloud_run_test.go @@ -47,6 +47,35 @@ func TestCloudRunProviderReturnsMatches(t *testing.T) { } } +func TestCloudRunProviderMatchesByDetailFields(t *testing.T) { + client := &fakeCloudRunClient{services: []*gcp.CloudRunService{ + {Name: "api-service", Region: "us-central1", URL: "https://api-service-xyz.run.app", LatestRevision: "api-service-00005"}, + {Name: "web-frontend", Region: "us-central1", URL: "https://web-frontend-abc.run.app", LatestRevision: "web-frontend-00002"}, + }} + + provider := &CloudRunProvider{NewClient: func(ctx context.Context, project string) (CloudRunClient, error) { + return client, nil + }} + + // Search by URL + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "xyz.run.app"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "api-service" { + t.Fatalf("expected 1 result for URL search, got %d", len(results)) + } + + // Search by revision + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "web-frontend-00002"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "web-frontend" { + t.Fatalf("expected 1 result for revision search, got %d", len(results)) + } +} + func TestCloudRunProviderPropagatesErrors(t *testing.T) { provider := &CloudRunProvider{NewClient: func(context.Context, string) (CloudRunClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/cloudsql.go b/internal/gcp/search/cloudsql.go index bdac68e..629cece 100644 --- a/internal/gcp/search/cloudsql.go +++ b/internal/gcp/search/cloudsql.go @@ -43,7 +43,7 @@ func (p *CloudSQLProvider) Search(ctx context.Context, project string, query Que matches := make([]Result, 0, len(instances)) for _, inst := range instances { - if inst == nil || !query.Matches(inst.Name) { + if inst == nil || !query.MatchesAny(inst.Name, inst.DatabaseVersion, inst.Tier) { continue } diff --git a/internal/gcp/search/cloudsql_test.go b/internal/gcp/search/cloudsql_test.go index e8ad40c..c285ed8 100644 --- a/internal/gcp/search/cloudsql_test.go +++ b/internal/gcp/search/cloudsql_test.go @@ -47,6 +47,35 @@ func TestCloudSQLProviderReturnsMatches(t *testing.T) { } } +func TestCloudSQLProviderMatchesByDetailFields(t *testing.T) { + client := &fakeCloudSQLClient{instances: []*gcp.CloudSQLInstance{ + {Name: "my-postgres", Region: "us-central1", DatabaseVersion: "POSTGRES_14", Tier: "db-custom-4-16384", State: "RUNNABLE"}, + {Name: "my-mysql", Region: "us-central1", DatabaseVersion: "MYSQL_8_0", Tier: "db-f1-micro", State: "RUNNABLE"}, + }} + + provider := &CloudSQLProvider{NewClient: func(ctx context.Context, project string) (CloudSQLClient, error) { + return client, nil + }} + + // Search by database version + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "POSTGRES"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "my-postgres" { + t.Fatalf("expected 1 result for database version search, got %d", len(results)) + } + + // Search by tier + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "db-f1-micro"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "my-mysql" { + t.Fatalf("expected 1 result for tier search, got %d", len(results)) + } +} + func TestCloudSQLProviderPropagatesErrors(t *testing.T) { provider := &CloudSQLProvider{NewClient: func(context.Context, string) (CloudSQLClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/disks.go b/internal/gcp/search/disks.go index 2142da3..ff199cf 100644 --- a/internal/gcp/search/disks.go +++ b/internal/gcp/search/disks.go @@ -43,7 +43,7 @@ func (p *DiskProvider) Search(ctx context.Context, project string, query Query) matches := make([]Result, 0, len(disks)) for _, disk := range disks { - if disk == nil || !query.Matches(disk.Name) { + if disk == nil || !query.MatchesAny(disk.Name, disk.Type) { continue } diff --git a/internal/gcp/search/disks_test.go b/internal/gcp/search/disks_test.go index 448b487..195afe9 100644 --- a/internal/gcp/search/disks_test.go +++ b/internal/gcp/search/disks_test.go @@ -51,6 +51,25 @@ func TestDiskProviderReturnsMatches(t *testing.T) { } } +func TestDiskProviderMatchesByType(t *testing.T) { + client := &fakeDiskClient{disks: []*gcp.Disk{ + {Name: "fast-disk", Zone: "us-central1-a", Type: "pd-ssd", SizeGb: 500, Status: "READY"}, + {Name: "cheap-disk", Zone: "us-central1-a", Type: "pd-standard", SizeGb: 1000, Status: "READY"}, + }} + + provider := &DiskProvider{NewClient: func(ctx context.Context, project string) (DiskClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "pd-ssd"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "fast-disk" { + t.Fatalf("expected 1 result for disk type search, got %d", len(results)) + } +} + func TestDiskProviderPropagatesErrors(t *testing.T) { provider := &DiskProvider{NewClient: func(context.Context, string) (DiskClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/firewall_rules.go b/internal/gcp/search/firewall_rules.go index 8f1fa4d..cf0ff73 100644 --- a/internal/gcp/search/firewall_rules.go +++ b/internal/gcp/search/firewall_rules.go @@ -3,6 +3,7 @@ package search import ( "context" "fmt" + "strings" "github.com/kedare/compass/internal/gcp" ) @@ -43,7 +44,14 @@ func (p *FirewallRuleProvider) Search(ctx context.Context, project string, query matches := make([]Result, 0, len(rules)) for _, rule := range rules { - if rule == nil || !query.Matches(rule.Name) { + if rule == nil { + continue + } + searchFields := []string{rule.Name, rule.Direction, rule.Network, rule.Description} + searchFields = append(searchFields, rule.SourceRanges...) + searchFields = append(searchFields, rule.TargetTags...) + searchFields = append(searchFields, rule.Allowed...) + if !query.MatchesAny(searchFields...) { continue } @@ -79,5 +87,21 @@ func firewallRuleDetails(rule *gcp.FirewallRule) map[string]string { details["disabled"] = "true" } + if rule.Description != "" { + details["description"] = rule.Description + } + + if len(rule.SourceRanges) > 0 { + details["sourceRanges"] = strings.Join(rule.SourceRanges, ", ") + } + + if len(rule.TargetTags) > 0 { + details["targetTags"] = strings.Join(rule.TargetTags, ", ") + } + + if len(rule.Allowed) > 0 { + details["allowed"] = strings.Join(rule.Allowed, ", ") + } + return details } diff --git a/internal/gcp/search/firewall_rules_test.go b/internal/gcp/search/firewall_rules_test.go index 426cf60..b40e229 100644 --- a/internal/gcp/search/firewall_rules_test.go +++ b/internal/gcp/search/firewall_rules_test.go @@ -69,6 +69,126 @@ func TestFirewallRuleProviderPropagatesErrors(t *testing.T) { } } +func TestFirewallRuleProviderMatchesByDescription(t *testing.T) { + client := &fakeFirewallRuleClient{rules: []*gcp.FirewallRule{ + {Name: "allow-internal", Direction: "INGRESS", Network: "default", Priority: 1000, Description: "Allow internal traffic between subnets"}, + {Name: "deny-all", Direction: "INGRESS", Network: "default", Priority: 65535}, + }} + + provider := &FirewallRuleProvider{NewClient: func(ctx context.Context, project string) (FirewallRuleClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "internal traffic"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "allow-internal" { + t.Fatalf("expected 1 result for description search, got %d", len(results)) + } +} + +func TestFirewallRuleProviderMatchesBySourceRange(t *testing.T) { + client := &fakeFirewallRuleClient{rules: []*gcp.FirewallRule{ + {Name: "allow-office", Direction: "INGRESS", Network: "default", Priority: 1000, SourceRanges: []string{"203.0.113.0/24"}}, + {Name: "allow-vpn", Direction: "INGRESS", Network: "default", Priority: 1000, SourceRanges: []string{"10.8.0.0/16"}}, + }} + + provider := &FirewallRuleProvider{NewClient: func(ctx context.Context, project string) (FirewallRuleClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "203.0.113"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "allow-office" { + t.Fatalf("expected 1 result for source range search, got %d", len(results)) + } +} + +func TestFirewallRuleProviderMatchesByTargetTag(t *testing.T) { + client := &fakeFirewallRuleClient{rules: []*gcp.FirewallRule{ + {Name: "allow-http-tagged", Direction: "INGRESS", Network: "default", Priority: 1000, TargetTags: []string{"http-server", "https-server"}}, + {Name: "allow-ssh", Direction: "INGRESS", Network: "default", Priority: 1000, TargetTags: []string{"ssh-server"}}, + }} + + provider := &FirewallRuleProvider{NewClient: func(ctx context.Context, project string) (FirewallRuleClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "https-server"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "allow-http-tagged" { + t.Fatalf("expected 1 result for target tag search, got %d", len(results)) + } +} + +func TestFirewallRuleProviderMatchesByAllowed(t *testing.T) { + client := &fakeFirewallRuleClient{rules: []*gcp.FirewallRule{ + {Name: "allow-http", Direction: "INGRESS", Network: "default", Priority: 1000, Allowed: []string{"tcp:80,443"}}, + {Name: "allow-icmp", Direction: "INGRESS", Network: "default", Priority: 1000, Allowed: []string{"icmp"}}, + }} + + provider := &FirewallRuleProvider{NewClient: func(ctx context.Context, project string) (FirewallRuleClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "tcp:80"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "allow-http" { + t.Fatalf("expected 1 result for allowed search, got %d", len(results)) + } +} + +func TestFirewallRuleDetailsNewFields(t *testing.T) { + rule := &gcp.FirewallRule{ + Name: "test-rule", + Direction: "INGRESS", + Network: "my-vpc", + Priority: 1000, + Description: "Allow HTTP from office", + SourceRanges: []string{"203.0.113.0/24", "198.51.100.0/24"}, + TargetTags: []string{"web-server"}, + Allowed: []string{"tcp:80,443"}, + } + + details := firewallRuleDetails(rule) + if details["description"] != "Allow HTTP from office" { + t.Errorf("expected description, got %q", details["description"]) + } + if details["sourceRanges"] != "203.0.113.0/24, 198.51.100.0/24" { + t.Errorf("expected source ranges, got %q", details["sourceRanges"]) + } + if details["targetTags"] != "web-server" { + t.Errorf("expected target tags, got %q", details["targetTags"]) + } + if details["allowed"] != "tcp:80,443" { + t.Errorf("expected allowed, got %q", details["allowed"]) + } +} + +func TestFirewallRuleDetailsEmptyNewFields(t *testing.T) { + rule := &gcp.FirewallRule{Name: "minimal-rule"} + details := firewallRuleDetails(rule) + if _, ok := details["description"]; ok { + t.Error("expected no description key for empty description") + } + if _, ok := details["sourceRanges"]; ok { + t.Error("expected no sourceRanges key for empty source ranges") + } + if _, ok := details["targetTags"]; ok { + t.Error("expected no targetTags key for empty target tags") + } + if _, ok := details["allowed"]; ok { + t.Error("expected no allowed key for empty allowed") + } +} + func TestFirewallRuleProviderNilProvider(t *testing.T) { var provider *FirewallRuleProvider _, err := provider.Search(context.Background(), "proj-a", Query{Term: "foo"}) diff --git a/internal/gcp/search/forwarding_rules.go b/internal/gcp/search/forwarding_rules.go index 331224e..a871b9b 100644 --- a/internal/gcp/search/forwarding_rules.go +++ b/internal/gcp/search/forwarding_rules.go @@ -43,7 +43,7 @@ func (p *ForwardingRuleProvider) Search(ctx context.Context, project string, que matches := make([]Result, 0, len(rules)) for _, rule := range rules { - if rule == nil || !query.Matches(rule.Name) { + if rule == nil || !query.MatchesAny(rule.Name, rule.IPAddress, rule.IPProtocol, rule.LoadBalancingScheme) { continue } diff --git a/internal/gcp/search/forwarding_rules_test.go b/internal/gcp/search/forwarding_rules_test.go index 5c9f10e..dbcba26 100644 --- a/internal/gcp/search/forwarding_rules_test.go +++ b/internal/gcp/search/forwarding_rules_test.go @@ -47,6 +47,44 @@ func TestForwardingRuleProviderReturnsMatches(t *testing.T) { } } +func TestForwardingRuleProviderMatchesByDetailFields(t *testing.T) { + client := &fakeForwardingRuleClient{rules: []*gcp.ForwardingRule{ + {Name: "ext-lb", Region: "us-central1", IPAddress: "35.1.2.3", IPProtocol: "TCP", LoadBalancingScheme: "EXTERNAL"}, + {Name: "int-lb", Region: "us-central1", IPAddress: "10.0.0.100", IPProtocol: "UDP", LoadBalancingScheme: "INTERNAL"}, + }} + + provider := &ForwardingRuleProvider{NewClient: func(ctx context.Context, project string) (ForwardingRuleClient, error) { + return client, nil + }} + + // Search by IP address + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "35.1.2"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "ext-lb" { + t.Fatalf("expected 1 result for IP search, got %d", len(results)) + } + + // Search by protocol + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "UDP"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "int-lb" { + t.Fatalf("expected 1 result for protocol search, got %d", len(results)) + } + + // Search by load balancing scheme + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "INTERNAL"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "int-lb" { + t.Fatalf("expected 1 result for LB scheme search, got %d", len(results)) + } +} + func TestForwardingRuleProviderPropagatesErrors(t *testing.T) { provider := &ForwardingRuleProvider{NewClient: func(context.Context, string) (ForwardingRuleClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/gke_clusters.go b/internal/gcp/search/gke_clusters.go index 20b9ad1..2034faa 100644 --- a/internal/gcp/search/gke_clusters.go +++ b/internal/gcp/search/gke_clusters.go @@ -43,7 +43,7 @@ func (p *GKEClusterProvider) Search(ctx context.Context, project string, query Q matches := make([]Result, 0, len(clusters)) for _, cluster := range clusters { - if cluster == nil || !query.Matches(cluster.Name) { + if cluster == nil || !query.MatchesAny(cluster.Name, cluster.CurrentMasterVersion) { continue } diff --git a/internal/gcp/search/gke_clusters_test.go b/internal/gcp/search/gke_clusters_test.go index 83173a6..9191c75 100644 --- a/internal/gcp/search/gke_clusters_test.go +++ b/internal/gcp/search/gke_clusters_test.go @@ -47,6 +47,25 @@ func TestGKEClusterProviderReturnsMatches(t *testing.T) { } } +func TestGKEClusterProviderMatchesByVersion(t *testing.T) { + client := &fakeGKEClusterClient{clusters: []*gcp.GKECluster{ + {Name: "cluster-a", Location: "us-central1", CurrentMasterVersion: "1.27.3-gke.1700", NodeCount: 10}, + {Name: "cluster-b", Location: "us-central1", CurrentMasterVersion: "1.26.5-gke.1400", NodeCount: 5}, + }} + + provider := &GKEClusterProvider{NewClient: func(ctx context.Context, project string) (GKEClusterClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "1.27.3"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "cluster-a" { + t.Fatalf("expected 1 result for version search, got %d", len(results)) + } +} + func TestGKEClusterProviderPropagatesErrors(t *testing.T) { provider := &GKEClusterProvider{NewClient: func(context.Context, string) (GKEClusterClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/gke_node_pools.go b/internal/gcp/search/gke_node_pools.go index 2bb5b8c..91d3a74 100644 --- a/internal/gcp/search/gke_node_pools.go +++ b/internal/gcp/search/gke_node_pools.go @@ -43,7 +43,7 @@ func (p *GKENodePoolProvider) Search(ctx context.Context, project string, query matches := make([]Result, 0, len(pools)) for _, pool := range pools { - if pool == nil || !query.Matches(pool.Name) { + if pool == nil || !query.MatchesAny(pool.Name, pool.ClusterName, pool.MachineType) { continue } diff --git a/internal/gcp/search/gke_node_pools_test.go b/internal/gcp/search/gke_node_pools_test.go index 3bf06c3..ab96c1c 100644 --- a/internal/gcp/search/gke_node_pools_test.go +++ b/internal/gcp/search/gke_node_pools_test.go @@ -51,6 +51,35 @@ func TestGKENodePoolProviderReturnsMatches(t *testing.T) { } } +func TestGKENodePoolProviderMatchesByDetailFields(t *testing.T) { + client := &fakeGKENodePoolClient{pools: []*gcp.GKENodePool{ + {Name: "pool-a", ClusterName: "web-cluster", MachineType: "e2-standard-4", NodeCount: 5}, + {Name: "pool-b", ClusterName: "api-cluster", MachineType: "n2-highmem-8", NodeCount: 3}, + }} + + provider := &GKENodePoolProvider{NewClient: func(ctx context.Context, project string) (GKENodePoolClient, error) { + return client, nil + }} + + // Search by cluster name + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "web-cluster"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "pool-a" { + t.Fatalf("expected 1 result for cluster name search, got %d", len(results)) + } + + // Search by machine type + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "n2-highmem"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "pool-b" { + t.Fatalf("expected 1 result for machine type search, got %d", len(results)) + } +} + func TestGKENodePoolProviderPropagatesErrors(t *testing.T) { provider := &GKENodePoolProvider{NewClient: func(context.Context, string) (GKENodePoolClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/health_checks.go b/internal/gcp/search/health_checks.go index 39a2713..e0ee521 100644 --- a/internal/gcp/search/health_checks.go +++ b/internal/gcp/search/health_checks.go @@ -43,7 +43,7 @@ func (p *HealthCheckProvider) Search(ctx context.Context, project string, query matches := make([]Result, 0, len(checks)) for _, check := range checks { - if check == nil || !query.Matches(check.Name) { + if check == nil || !query.MatchesAny(check.Name, check.Type) { continue } diff --git a/internal/gcp/search/health_checks_test.go b/internal/gcp/search/health_checks_test.go index 7920c5f..35e2666 100644 --- a/internal/gcp/search/health_checks_test.go +++ b/internal/gcp/search/health_checks_test.go @@ -51,6 +51,25 @@ func TestHealthCheckProviderReturnsMatches(t *testing.T) { } } +func TestHealthCheckProviderMatchesByType(t *testing.T) { + client := &fakeHealthCheckClient{checks: []*gcp.HealthCheck{ + {Name: "http-check", Type: "HTTP", Port: 80}, + {Name: "grpc-check", Type: "GRPC", Port: 50051}, + }} + + provider := &HealthCheckProvider{NewClient: func(ctx context.Context, project string) (HealthCheckClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "GRPC"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "grpc-check" { + t.Fatalf("expected 1 result for type search, got %d", len(results)) + } +} + func TestHealthCheckProviderPropagatesErrors(t *testing.T) { provider := &HealthCheckProvider{NewClient: func(context.Context, string) (HealthCheckClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/instance_templates.go b/internal/gcp/search/instance_templates.go index b2ed07b..4f99e3c 100644 --- a/internal/gcp/search/instance_templates.go +++ b/internal/gcp/search/instance_templates.go @@ -44,7 +44,7 @@ func (p *InstanceTemplateProvider) Search(ctx context.Context, project string, q matches := make([]Result, 0, len(templates)) for _, tmpl := range templates { - if tmpl == nil || !query.Matches(tmpl.Name) { + if tmpl == nil || !query.MatchesAny(tmpl.Name, tmpl.Description, tmpl.MachineType) { continue } diff --git a/internal/gcp/search/instance_templates_test.go b/internal/gcp/search/instance_templates_test.go index 1ceb8b0..8c36002 100644 --- a/internal/gcp/search/instance_templates_test.go +++ b/internal/gcp/search/instance_templates_test.go @@ -56,6 +56,35 @@ func TestInstanceTemplateProviderReturnsMatches(t *testing.T) { } } +func TestInstanceTemplateProviderMatchesByDetailFields(t *testing.T) { + client := &fakeInstanceTemplateClient{templates: []*gcp.InstanceTemplate{ + {Name: "tmpl-a", Description: "Web server template", MachineType: "e2-standard-4"}, + {Name: "tmpl-b", Description: "Database template", MachineType: "n2-highmem-16"}, + }} + + provider := &InstanceTemplateProvider{NewClient: func(ctx context.Context, project string) (InstanceTemplateClient, error) { + return client, nil + }} + + // Search by description + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "Database"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "tmpl-b" { + t.Fatalf("expected 1 result for description search, got %d", len(results)) + } + + // Search by machine type + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "e2-standard"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "tmpl-a" { + t.Fatalf("expected 1 result for machine type search, got %d", len(results)) + } +} + func TestInstanceTemplateProviderPropagatesErrors(t *testing.T) { provider := &InstanceTemplateProvider{NewClient: func(context.Context, string) (InstanceTemplateClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/instances.go b/internal/gcp/search/instances.go index 11e3426..83da90b 100644 --- a/internal/gcp/search/instances.go +++ b/internal/gcp/search/instances.go @@ -43,7 +43,7 @@ func (p *InstanceProvider) Search(ctx context.Context, project string, query Que matches := make([]Result, 0, len(instances)) for _, inst := range instances { - if inst == nil || !query.Matches(inst.Name) { + if inst == nil || !query.MatchesAny(inst.Name, inst.InternalIP, inst.ExternalIP, inst.MachineType) { continue } diff --git a/internal/gcp/search/instances_test.go b/internal/gcp/search/instances_test.go index 9e8bc39..47a6dad 100644 --- a/internal/gcp/search/instances_test.go +++ b/internal/gcp/search/instances_test.go @@ -35,6 +35,44 @@ func TestInstanceProviderReturnsMatches(t *testing.T) { } } +func TestInstanceProviderMatchesByIP(t *testing.T) { + client := &fakeInstanceClient{instances: []*gcp.Instance{ + {Name: "web-server-01", Project: "proj-a", Zone: "us-central1-a", Status: "RUNNING", InternalIP: "10.0.0.1", ExternalIP: "35.1.2.3"}, + {Name: "db-server-01", Project: "proj-a", Zone: "us-central1-b", Status: "RUNNING", InternalIP: "10.0.0.2", MachineType: "n2-standard-4"}, + }} + + provider := &InstanceProvider{NewClient: func(ctx context.Context, project string) (InstanceClient, error) { + return client, nil + }} + + // Search by internal IP + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "10.0.0.1"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "web-server-01" { + t.Fatalf("expected 1 result for IP search, got %d", len(results)) + } + + // Search by external IP + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "35.1.2"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "web-server-01" { + t.Fatalf("expected 1 result for external IP search, got %d", len(results)) + } + + // Search by machine type + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "n2-standard"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "db-server-01" { + t.Fatalf("expected 1 result for machine type search, got %d", len(results)) + } +} + func TestInstanceProviderPropagatesErrors(t *testing.T) { provider := &InstanceProvider{NewClient: func(context.Context, string) (InstanceClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/managed_instance_groups.go b/internal/gcp/search/managed_instance_groups.go index b349a02..8edb8d1 100644 --- a/internal/gcp/search/managed_instance_groups.go +++ b/internal/gcp/search/managed_instance_groups.go @@ -44,7 +44,7 @@ func (p *MIGProvider) Search(ctx context.Context, project string, query Query) ( matches := make([]Result, 0, len(migs)) for _, mig := range migs { - if !query.Matches(mig.Name) { + if !query.MatchesAny(mig.Name, mig.Description, mig.BaseInstanceName, mig.InstanceTemplate) { continue } diff --git a/internal/gcp/search/managed_instance_groups_test.go b/internal/gcp/search/managed_instance_groups_test.go index 10fc66c..61b0a72 100644 --- a/internal/gcp/search/managed_instance_groups_test.go +++ b/internal/gcp/search/managed_instance_groups_test.go @@ -63,6 +63,44 @@ func TestMIGProviderReturnsMatches(t *testing.T) { } } +func TestMIGProviderMatchesByDetailFields(t *testing.T) { + client := &fakeMIGClient{migs: []gcp.ManagedInstanceGroup{ + {Name: "mig-a", Description: "Web servers", BaseInstanceName: "web-instance", InstanceTemplate: "web-template"}, + {Name: "mig-b", Description: "API servers", BaseInstanceName: "api-instance", InstanceTemplate: "api-template"}, + }} + + provider := &MIGProvider{NewClient: func(ctx context.Context, project string) (MIGClient, error) { + return client, nil + }} + + // Search by description + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "API servers"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "mig-b" { + t.Fatalf("expected 1 result for description search, got %d", len(results)) + } + + // Search by base instance name + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "web-instance"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "mig-a" { + t.Fatalf("expected 1 result for base instance name search, got %d", len(results)) + } + + // Search by instance template + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "api-template"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "mig-b" { + t.Fatalf("expected 1 result for template search, got %d", len(results)) + } +} + func TestMIGProviderPropagatesErrors(t *testing.T) { provider := &MIGProvider{NewClient: func(context.Context, string) (MIGClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/routes.go b/internal/gcp/search/routes.go new file mode 100644 index 0000000..154b140 --- /dev/null +++ b/internal/gcp/search/routes.go @@ -0,0 +1,93 @@ +package search + +import ( + "context" + "fmt" + "strings" + + "github.com/kedare/compass/internal/gcp" +) + +// RouteClient exposes the subset of gcp.Client used by the route searcher. +type RouteClient interface { + ListRoutes(ctx context.Context) ([]*gcp.Route, error) +} + +// RouteProvider searches VPC routes for query matches. +type RouteProvider struct { + NewClient func(ctx context.Context, project string) (RouteClient, error) +} + +// Kind returns the resource kind this provider handles. +func (p *RouteProvider) Kind() ResourceKind { + return KindRoute +} + +// Search implements the Provider interface. +func (p *RouteProvider) Search(ctx context.Context, project string, query Query) ([]Result, error) { + if p == nil || p.NewClient == nil { + return nil, fmt.Errorf("%s: %w", project, ErrNoProviders) + } + + client, err := p.NewClient(ctx, project) + if err != nil { + return nil, fmt.Errorf("failed to create client for %s: %w", project, err) + } + + routes, err := client.ListRoutes(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list routes in %s: %w", project, err) + } + + matches := make([]Result, 0, len(routes)) + for _, route := range routes { + if route == nil || !query.MatchesAny(route.Name, route.Description, route.DestRange, route.Network, route.NextHop) { + continue + } + + matches = append(matches, Result{ + Type: KindRoute, + Name: route.Name, + Project: project, + Location: "global", + Details: routeDetails(route), + }) + } + + return matches, nil +} + +// routeDetails extracts display metadata for a VPC route. +func routeDetails(route *gcp.Route) map[string]string { + details := make(map[string]string) + + if route.DestRange != "" { + details["destRange"] = route.DestRange + } + + if route.Network != "" { + details["network"] = route.Network + } + + if route.NextHop != "" { + details["nextHop"] = route.NextHop + } + + if route.Priority > 0 { + details["priority"] = fmt.Sprintf("%d", route.Priority) + } + + if route.RouteType != "" { + details["routeType"] = route.RouteType + } + + if len(route.Tags) > 0 { + details["tags"] = strings.Join(route.Tags, ", ") + } + + if route.Description != "" { + details["description"] = route.Description + } + + return details +} diff --git a/internal/gcp/search/routes_test.go b/internal/gcp/search/routes_test.go new file mode 100644 index 0000000..959682a --- /dev/null +++ b/internal/gcp/search/routes_test.go @@ -0,0 +1,183 @@ +package search + +import ( + "context" + "errors" + "testing" + + "github.com/kedare/compass/internal/gcp" +) + +func TestRouteProviderReturnsMatches(t *testing.T) { + client := &fakeRouteClient{routes: []*gcp.Route{ + {Name: "default-route", DestRange: "0.0.0.0/0", Network: "default", NextHop: "default-internet-gateway", Priority: 1000}, + {Name: "peering-route", DestRange: "10.128.0.0/20", Network: "my-vpc", NextHop: "peering-connection", Priority: 100, Description: "peering to shared vpc"}, + }} + + provider := &RouteProvider{NewClient: func(ctx context.Context, project string) (RouteClient, error) { + return client, nil + }} + + // Search by name + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "peering"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "peering-route" { + t.Fatalf("expected 1 result for name search, got %d", len(results)) + } +} + +func TestRouteProviderMatchesByDestRange(t *testing.T) { + client := &fakeRouteClient{routes: []*gcp.Route{ + {Name: "default-route", DestRange: "0.0.0.0/0", Network: "default", NextHop: "default-internet-gateway"}, + {Name: "subnet-route", DestRange: "10.128.0.0/20", Network: "my-vpc", NextHop: "my-vpc"}, + }} + + provider := &RouteProvider{NewClient: func(ctx context.Context, project string) (RouteClient, error) { + return client, nil + }} + + // Search by dest range + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "10.128"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "subnet-route" { + t.Fatalf("expected 1 result for dest range search, got %d", len(results)) + } + + // Search by next hop + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "internet-gateway"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "default-route" { + t.Fatalf("expected 1 result for next hop search, got %d", len(results)) + } +} + +func TestRouteProviderMatchesByDescription(t *testing.T) { + client := &fakeRouteClient{routes: []*gcp.Route{ + {Name: "custom-route", Description: "route to on-prem datacenter", DestRange: "192.168.0.0/16", Network: "prod-vpc", NextHop: "vpn-tunnel-1"}, + }} + + provider := &RouteProvider{NewClient: func(ctx context.Context, project string) (RouteClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "datacenter"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "custom-route" { + t.Fatalf("expected 1 result for description search, got %d", len(results)) + } +} + +func TestRouteProviderMatchesByNetwork(t *testing.T) { + client := &fakeRouteClient{routes: []*gcp.Route{ + {Name: "route-a", DestRange: "10.0.0.0/8", Network: "prod-vpc", NextHop: "default-internet-gateway"}, + {Name: "route-b", DestRange: "10.0.0.0/8", Network: "dev-vpc", NextHop: "default-internet-gateway"}, + }} + + provider := &RouteProvider{NewClient: func(ctx context.Context, project string) (RouteClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "prod-vpc"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "route-a" { + t.Fatalf("expected 1 result for network search, got %d", len(results)) + } +} + +func TestRouteDetails(t *testing.T) { + route := &gcp.Route{ + Name: "custom-route", + Description: "route to on-prem", + DestRange: "192.168.0.0/16", + Network: "prod-vpc", + NextHop: "vpn-tunnel-1", + Priority: 100, + RouteType: "STATIC", + Tags: []string{"vpn-client", "on-prem"}, + } + + details := routeDetails(route) + if details["destRange"] != "192.168.0.0/16" { + t.Errorf("expected destRange, got %q", details["destRange"]) + } + if details["network"] != "prod-vpc" { + t.Errorf("expected network, got %q", details["network"]) + } + if details["nextHop"] != "vpn-tunnel-1" { + t.Errorf("expected nextHop, got %q", details["nextHop"]) + } + if details["priority"] != "100" { + t.Errorf("expected priority 100, got %q", details["priority"]) + } + if details["routeType"] != "STATIC" { + t.Errorf("expected routeType STATIC, got %q", details["routeType"]) + } + if details["tags"] != "vpn-client, on-prem" { + t.Errorf("expected tags, got %q", details["tags"]) + } + if details["description"] != "route to on-prem" { + t.Errorf("expected description, got %q", details["description"]) + } +} + +func TestRouteDetailsEmpty(t *testing.T) { + route := &gcp.Route{Name: "minimal-route"} + details := routeDetails(route) + if _, ok := details["description"]; ok { + t.Error("expected no description key for empty description") + } + if _, ok := details["tags"]; ok { + t.Error("expected no tags key for empty tags") + } + if _, ok := details["routeType"]; ok { + t.Error("expected no routeType key for empty route type") + } +} + +func TestRouteProviderNilProvider(t *testing.T) { + var provider *RouteProvider + _, err := provider.Search(context.Background(), "proj-a", Query{Term: "foo"}) + if err == nil { + t.Fatal("expected error for nil provider") + } +} + +func TestRouteProviderPropagatesErrors(t *testing.T) { + provider := &RouteProvider{NewClient: func(context.Context, string) (RouteClient, error) { + return nil, errors.New("client boom") + }} + + if _, err := provider.Search(context.Background(), "proj-a", Query{Term: "foo"}); err == nil { + t.Fatal("expected error") + } + + provider = &RouteProvider{NewClient: func(context.Context, string) (RouteClient, error) { + return &fakeRouteClient{err: errors.New("list boom")}, nil + }} + + if _, err := provider.Search(context.Background(), "proj-a", Query{Term: "foo"}); err == nil { + t.Fatal("expected list error") + } +} + +type fakeRouteClient struct { + routes []*gcp.Route + err error +} + +func (f *fakeRouteClient) ListRoutes(context.Context) ([]*gcp.Route, error) { + if f.err != nil { + return nil, f.err + } + return f.routes, nil +} diff --git a/internal/gcp/search/secrets.go b/internal/gcp/search/secrets.go index 8e24037..bf4eacc 100644 --- a/internal/gcp/search/secrets.go +++ b/internal/gcp/search/secrets.go @@ -43,7 +43,7 @@ func (p *SecretProvider) Search(ctx context.Context, project string, query Query matches := make([]Result, 0, len(secrets)) for _, secret := range secrets { - if secret == nil || !query.Matches(secret.Name) { + if secret == nil || !query.MatchesAny(secret.Name, secret.Replication) { continue } diff --git a/internal/gcp/search/secrets_test.go b/internal/gcp/search/secrets_test.go index 380a9cc..a900e65 100644 --- a/internal/gcp/search/secrets_test.go +++ b/internal/gcp/search/secrets_test.go @@ -51,6 +51,25 @@ func TestSecretProviderReturnsMatches(t *testing.T) { } } +func TestSecretProviderMatchesByReplication(t *testing.T) { + client := &fakeSecretClient{secrets: []*gcp.Secret{ + {Name: "auto-secret", Replication: "AUTOMATIC", VersionCount: 1}, + {Name: "managed-secret", Replication: "USER_MANAGED", VersionCount: 2}, + }} + + provider := &SecretProvider{NewClient: func(ctx context.Context, project string) (SecretClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "USER_MANAGED"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "managed-secret" { + t.Fatalf("expected 1 result for replication search, got %d", len(results)) + } +} + func TestSecretProviderPropagatesErrors(t *testing.T) { provider := &SecretProvider{NewClient: func(context.Context, string) (SecretClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/snapshots.go b/internal/gcp/search/snapshots.go index 89f2d58..44fb2e9 100644 --- a/internal/gcp/search/snapshots.go +++ b/internal/gcp/search/snapshots.go @@ -43,7 +43,7 @@ func (p *SnapshotProvider) Search(ctx context.Context, project string, query Que matches := make([]Result, 0, len(snapshots)) for _, snapshot := range snapshots { - if snapshot == nil || !query.Matches(snapshot.Name) { + if snapshot == nil || !query.MatchesAny(snapshot.Name, snapshot.SourceDisk) { continue } diff --git a/internal/gcp/search/snapshots_test.go b/internal/gcp/search/snapshots_test.go index 5c9f5f4..9457196 100644 --- a/internal/gcp/search/snapshots_test.go +++ b/internal/gcp/search/snapshots_test.go @@ -55,6 +55,25 @@ func TestSnapshotProviderReturnsMatches(t *testing.T) { } } +func TestSnapshotProviderMatchesBySourceDisk(t *testing.T) { + client := &fakeSnapshotClient{snapshots: []*gcp.Snapshot{ + {Name: "snap-a", SourceDisk: "boot-disk-prod", Status: "READY"}, + {Name: "snap-b", SourceDisk: "data-disk-dev", Status: "READY"}, + }} + + provider := &SnapshotProvider{NewClient: func(ctx context.Context, project string) (SnapshotClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "data-disk-dev"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "snap-b" { + t.Fatalf("expected 1 result for source disk search, got %d", len(results)) + } +} + func TestSnapshotProviderPropagatesErrors(t *testing.T) { provider := &SnapshotProvider{NewClient: func(context.Context, string) (SnapshotClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/subnets.go b/internal/gcp/search/subnets.go index 9b691a5..127f9b6 100644 --- a/internal/gcp/search/subnets.go +++ b/internal/gcp/search/subnets.go @@ -43,7 +43,7 @@ func (p *SubnetProvider) Search(ctx context.Context, project string, query Query matches := make([]Result, 0, len(subnets)) for _, subnet := range subnets { - if subnet == nil || !query.Matches(subnet.Name) { + if subnet == nil || !query.MatchesAny(subnet.Name, subnet.IPCidrRange, subnet.Network) { continue } diff --git a/internal/gcp/search/subnets_test.go b/internal/gcp/search/subnets_test.go index 52140a3..3e153a7 100644 --- a/internal/gcp/search/subnets_test.go +++ b/internal/gcp/search/subnets_test.go @@ -51,6 +51,35 @@ func TestSubnetProviderReturnsMatches(t *testing.T) { } } +func TestSubnetProviderMatchesByDetailFields(t *testing.T) { + client := &fakeSubnetClient{subnets: []*gcp.Subnet{ + {Name: "subnet-a", Region: "us-central1", IPCidrRange: "10.0.0.0/24", Network: "vpc-alpha"}, + {Name: "subnet-b", Region: "us-central1", IPCidrRange: "10.1.0.0/24", Network: "vpc-beta"}, + }} + + provider := &SubnetProvider{NewClient: func(ctx context.Context, project string) (SubnetClient, error) { + return client, nil + }} + + // Search by CIDR + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "10.1.0"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "subnet-b" { + t.Fatalf("expected 1 result for CIDR search, got %d", len(results)) + } + + // Search by network + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "vpc-alpha"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "subnet-a" { + t.Fatalf("expected 1 result for network search, got %d", len(results)) + } +} + func TestSubnetProviderPropagatesErrors(t *testing.T) { provider := &SubnetProvider{NewClient: func(context.Context, string) (SubnetClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/target_pools.go b/internal/gcp/search/target_pools.go index 05d5829..1757fbd 100644 --- a/internal/gcp/search/target_pools.go +++ b/internal/gcp/search/target_pools.go @@ -43,7 +43,7 @@ func (p *TargetPoolProvider) Search(ctx context.Context, project string, query Q matches := make([]Result, 0, len(pools)) for _, pool := range pools { - if pool == nil || !query.Matches(pool.Name) { + if pool == nil || !query.MatchesAny(pool.Name, pool.SessionAffinity) { continue } diff --git a/internal/gcp/search/target_pools_test.go b/internal/gcp/search/target_pools_test.go index 05eef28..b2eea23 100644 --- a/internal/gcp/search/target_pools_test.go +++ b/internal/gcp/search/target_pools_test.go @@ -51,6 +51,25 @@ func TestTargetPoolProviderReturnsMatches(t *testing.T) { } } +func TestTargetPoolProviderMatchesBySessionAffinity(t *testing.T) { + client := &fakeTargetPoolClient{pools: []*gcp.TargetPool{ + {Name: "pool-a", Region: "us-central1", SessionAffinity: "NONE", InstanceCount: 5}, + {Name: "pool-b", Region: "us-central1", SessionAffinity: "CLIENT_IP_PROTO", InstanceCount: 3}, + }} + + provider := &TargetPoolProvider{NewClient: func(ctx context.Context, project string) (TargetPoolClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "CLIENT_IP_PROTO"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "pool-b" { + t.Fatalf("expected 1 result for session affinity search, got %d", len(results)) + } +} + func TestTargetPoolProviderPropagatesErrors(t *testing.T) { provider := &TargetPoolProvider{NewClient: func(context.Context, string) (TargetPoolClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/types.go b/internal/gcp/search/types.go index 2a08bca..eb8d15e 100644 --- a/internal/gcp/search/types.go +++ b/internal/gcp/search/types.go @@ -4,6 +4,8 @@ package search import ( "context" "strings" + + "github.com/lithammer/fuzzysearch/fuzzy" ) // ResourceKind identifies the category of a searchable resource. @@ -54,6 +56,8 @@ const ( KindVPNGateway ResourceKind = "compute.vpnGateway" // KindVPNTunnel represents a Cloud VPN tunnel. KindVPNTunnel ResourceKind = "compute.vpnTunnel" + // KindRoute represents a VPC route. + KindRoute ResourceKind = "compute.route" ) // AllResourceKinds returns all available resource kind values for use in validation and completion. @@ -81,6 +85,7 @@ func AllResourceKinds() []ResourceKind { KindSecret, KindVPNGateway, KindVPNTunnel, + KindRoute, } } @@ -97,6 +102,7 @@ func IsValidResourceKind(kind string) bool { // Query captures the user supplied search term and matching behaviour. type Query struct { Term string + Fuzzy bool // Use fuzzy matching instead of substring matching Types []ResourceKind // Filter results to these types (empty means all types) } @@ -112,9 +118,38 @@ func (q Query) Matches(value string) bool { return false } + if q.Fuzzy { + return fuzzy.MatchFold(normalized, value) + } + return strings.Contains(strings.ToLower(value), normalized) } +// MatchesAny reports whether any of the provided values satisfies the query. +func (q Query) MatchesAny(values ...string) bool { + normalized := q.NormalizedTerm() + if normalized == "" { + return false + } + + for _, v := range values { + if v == "" { + continue + } + if q.Fuzzy { + if fuzzy.MatchFold(normalized, v) { + return true + } + } else { + if strings.Contains(strings.ToLower(v), normalized) { + return true + } + } + } + + return false +} + // MatchesType reports whether the provided resource kind is included in the query's type filter. // Returns true if no type filter is set (empty Types slice). func (q Query) MatchesType(kind ResourceKind) bool { diff --git a/internal/gcp/search/types_test.go b/internal/gcp/search/types_test.go index 4aff131..a812d4f 100644 --- a/internal/gcp/search/types_test.go +++ b/internal/gcp/search/types_test.go @@ -12,6 +12,14 @@ func TestQueryMatches(t *testing.T) { {"empty term", Query{Term: " "}, "instance-a", false}, {"case insensitive", Query{Term: "PiOu"}, "my-piou-instance", true}, {"no match", Query{Term: "foo"}, "bar", false}, + // Fuzzy matching + {"fuzzy chars in order", Query{Term: "prd", Fuzzy: true}, "production", true}, + {"fuzzy not in exact mode", Query{Term: "prd"}, "production", false}, + {"fuzzy case insensitive", Query{Term: "PRD", Fuzzy: true}, "production", true}, + {"fuzzy with separators", Query{Term: "abc", Fuzzy: true}, "a-big-cat", true}, + {"fuzzy substring still works", Query{Term: "prod", Fuzzy: true}, "my-production-vm", true}, + {"fuzzy no match", Query{Term: "xyz", Fuzzy: true}, "production", false}, + {"fuzzy empty term", Query{Term: " ", Fuzzy: true}, "production", false}, } for _, tt := range tests { @@ -20,3 +28,34 @@ func TestQueryMatches(t *testing.T) { } } } + +func TestQueryMatchesAny(t *testing.T) { + tests := []struct { + name string + query Query + values []string + result bool + }{ + {"empty term", Query{Term: " "}, []string{"instance-a"}, false}, + {"match first value", Query{Term: "alpha"}, []string{"alpha-instance", "beta-instance"}, true}, + {"match second value", Query{Term: "beta"}, []string{"alpha-instance", "beta-instance"}, true}, + {"match on IP", Query{Term: "10.0.0"}, []string{"my-instance", "10.0.0.1", ""}, true}, + {"no match", Query{Term: "gamma"}, []string{"alpha", "beta"}, false}, + {"empty values", Query{Term: "foo"}, []string{}, false}, + {"skips empty strings", Query{Term: "foo"}, []string{"", "", ""}, false}, + {"case insensitive", Query{Term: "PROD"}, []string{"", "my-production-vm"}, true}, + {"single value match", Query{Term: "web"}, []string{"web-server-01"}, true}, + {"single value no match", Query{Term: "api"}, []string{"web-server-01"}, false}, + // Fuzzy matching + {"fuzzy match on name", Query{Term: "prd", Fuzzy: true}, []string{"production-vm", "10.0.0.1"}, true}, + {"fuzzy match on second value", Query{Term: "abc", Fuzzy: true}, []string{"xyz", "a-big-cat"}, true}, + {"fuzzy no match any", Query{Term: "zzz", Fuzzy: true}, []string{"alpha", "beta"}, false}, + {"fuzzy not in exact mode", Query{Term: "prd"}, []string{"production-vm"}, false}, + } + + for _, tt := range tests { + if got := tt.query.MatchesAny(tt.values...); got != tt.result { + t.Errorf("%s: expected %v got %v", tt.name, tt.result, got) + } + } +} diff --git a/internal/gcp/search/url_maps.go b/internal/gcp/search/url_maps.go index c177fef..bfda0f7 100644 --- a/internal/gcp/search/url_maps.go +++ b/internal/gcp/search/url_maps.go @@ -43,7 +43,7 @@ func (p *URLMapProvider) Search(ctx context.Context, project string, query Query matches := make([]Result, 0, len(urlMaps)) for _, urlMap := range urlMaps { - if urlMap == nil || !query.Matches(urlMap.Name) { + if urlMap == nil || !query.MatchesAny(urlMap.Name, urlMap.DefaultService) { continue } diff --git a/internal/gcp/search/url_maps_test.go b/internal/gcp/search/url_maps_test.go index 5bfd6f8..57471f3 100644 --- a/internal/gcp/search/url_maps_test.go +++ b/internal/gcp/search/url_maps_test.go @@ -51,6 +51,25 @@ func TestURLMapProviderReturnsMatches(t *testing.T) { } } +func TestURLMapProviderMatchesByDefaultService(t *testing.T) { + client := &fakeURLMapClient{urlMaps: []*gcp.URLMap{ + {Name: "map-a", DefaultService: "frontend-backend-svc", HostRuleCount: 2}, + {Name: "map-b", DefaultService: "api-backend-svc", HostRuleCount: 1}, + }} + + provider := &URLMapProvider{NewClient: func(ctx context.Context, project string) (URLMapClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "api-backend-svc"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "map-b" { + t.Fatalf("expected 1 result for default service search, got %d", len(results)) + } +} + func TestURLMapProviderPropagatesErrors(t *testing.T) { provider := &URLMapProvider{NewClient: func(context.Context, string) (URLMapClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/vpc_networks.go b/internal/gcp/search/vpc_networks.go index bf87707..4802246 100644 --- a/internal/gcp/search/vpc_networks.go +++ b/internal/gcp/search/vpc_networks.go @@ -43,7 +43,7 @@ func (p *VPCNetworkProvider) Search(ctx context.Context, project string, query Q matches := make([]Result, 0, len(networks)) for _, network := range networks { - if network == nil || !query.Matches(network.Name) { + if network == nil || !query.MatchesAny(network.Name) { continue } diff --git a/internal/gcp/search/vpn_gateways.go b/internal/gcp/search/vpn_gateways.go index d9779ca..ed5fcaa 100644 --- a/internal/gcp/search/vpn_gateways.go +++ b/internal/gcp/search/vpn_gateways.go @@ -41,7 +41,7 @@ func (p *VPNGatewayProvider) Search(ctx context.Context, project string, query Q matches := make([]Result, 0, len(gateways)) for _, gw := range gateways { - if gw == nil || !query.Matches(gw.Name) { + if gw == nil || !query.MatchesAny(gw.Name, gw.Network) { continue } diff --git a/internal/gcp/search/vpn_gateways_test.go b/internal/gcp/search/vpn_gateways_test.go index 36fe000..a0e2b04 100644 --- a/internal/gcp/search/vpn_gateways_test.go +++ b/internal/gcp/search/vpn_gateways_test.go @@ -48,6 +48,25 @@ func TestVPNGatewayProviderReturnsMatches(t *testing.T) { } } +func TestVPNGatewayProviderMatchesByNetwork(t *testing.T) { + client := &fakeVPNGatewayClient{gateways: []*gcp.VPNGatewayInfo{ + {Name: "gw-a", Region: "us-central1", Network: "prod-vpc"}, + {Name: "gw-b", Region: "us-central1", Network: "staging-vpc"}, + }} + + provider := &VPNGatewayProvider{NewClient: func(ctx context.Context, project string) (VPNGatewayClient, error) { + return client, nil + }} + + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "staging-vpc"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "gw-b" { + t.Fatalf("expected 1 result for network search, got %d", len(results)) + } +} + func TestVPNGatewayProviderPropagatesErrors(t *testing.T) { provider := &VPNGatewayProvider{NewClient: func(context.Context, string) (VPNGatewayClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/search/vpn_tunnels.go b/internal/gcp/search/vpn_tunnels.go index 58447ff..a5c208e 100644 --- a/internal/gcp/search/vpn_tunnels.go +++ b/internal/gcp/search/vpn_tunnels.go @@ -41,7 +41,7 @@ func (p *VPNTunnelProvider) Search(ctx context.Context, project string, query Qu matches := make([]Result, 0, len(tunnels)) for _, tunnel := range tunnels { - if tunnel == nil || !query.Matches(tunnel.Name) { + if tunnel == nil || !query.MatchesAny(tunnel.Name, tunnel.PeerIP, tunnel.LocalGatewayIP) { continue } diff --git a/internal/gcp/search/vpn_tunnels_test.go b/internal/gcp/search/vpn_tunnels_test.go index 163e83f..8d99fac 100644 --- a/internal/gcp/search/vpn_tunnels_test.go +++ b/internal/gcp/search/vpn_tunnels_test.go @@ -73,6 +73,35 @@ func TestVPNTunnelProviderReturnsMatches(t *testing.T) { } } +func TestVPNTunnelProviderMatchesByDetailFields(t *testing.T) { + client := &fakeVPNTunnelClient{tunnels: []*gcp.VPNTunnelInfo{ + {Name: "tunnel-a", Region: "us-central1", PeerIP: "203.0.113.1", LocalGatewayIP: "35.1.2.3"}, + {Name: "tunnel-b", Region: "us-central1", PeerIP: "198.51.100.1", LocalGatewayIP: "35.4.5.6"}, + }} + + provider := &VPNTunnelProvider{NewClient: func(ctx context.Context, project string) (VPNTunnelClient, error) { + return client, nil + }} + + // Search by peer IP + results, err := provider.Search(context.Background(), "proj-a", Query{Term: "203.0.113"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "tunnel-a" { + t.Fatalf("expected 1 result for peer IP search, got %d", len(results)) + } + + // Search by local gateway IP + results, err = provider.Search(context.Background(), "proj-a", Query{Term: "35.4.5"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 || results[0].Name != "tunnel-b" { + t.Fatalf("expected 1 result for local IP search, got %d", len(results)) + } +} + func TestVPNTunnelProviderPropagatesErrors(t *testing.T) { provider := &VPNTunnelProvider{NewClient: func(context.Context, string) (VPNTunnelClient, error) { return nil, errors.New("client boom") diff --git a/internal/gcp/types.go b/internal/gcp/types.go index 67a35b1..7d8ec6f 100644 --- a/internal/gcp/types.go +++ b/internal/gcp/types.go @@ -163,6 +163,9 @@ type Address struct { Region string AddressType string Status string + Description string + Subnetwork string + Users []string } // Disk represents a Compute Engine persistent disk. @@ -288,11 +291,15 @@ type CloudRunService struct { // FirewallRule represents a VPC firewall rule. type FirewallRule struct { - Name string - Network string - Direction string - Priority int64 - Disabled bool + Name string + Network string + Direction string + Priority int64 + Disabled bool + Description string + SourceRanges []string + TargetTags []string + Allowed []string } // Secret represents a Secret Manager secret. @@ -301,3 +308,15 @@ type Secret struct { Replication string VersionCount int } + +// Route represents a VPC route. +type Route struct { + Name string + Description string + DestRange string + Network string + Priority int64 + NextHop string + RouteType string + Tags []string +} diff --git a/internal/tui/search_view.go b/internal/tui/search_view.go index fdc3ee2..c665b23 100644 --- a/internal/tui/search_view.go +++ b/internal/tui/search_view.go @@ -36,6 +36,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, var modalOpen bool var currentFilter string var filterMode bool + var fuzzyMode bool var currentSearchTerm string // Track current search term for affinity reinforcement var recordedAffinity = make(map[string]struct{}) // Track what's been recorded this search session @@ -84,7 +85,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, // Status bar status := tview.NewTextView(). SetDynamicColors(true). - SetText(" [yellow]Enter[-] search [yellow]Esc[-] back [yellow]/[-] filter results [yellow]d[-] details [yellow]?[-] help") + SetText(" [yellow]Tab[-] fuzzy [yellow]Enter[-] search [yellow]Esc[-] back [yellow]/[-] filter results [yellow]d[-] details [yellow]?[-] help") // Progress indicator progressText := tview.NewTextView(). @@ -205,9 +206,9 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, typeColor := getTypeColor(entry.Type) table.SetCell(currentRow, 0, tview.NewTableCell(fmt.Sprintf("[%s]%s[-]", typeColor, entry.Type)).SetExpansion(1)) - table.SetCell(currentRow, 1, tview.NewTableCell(entry.Name).SetExpansion(1)) - table.SetCell(currentRow, 2, tview.NewTableCell(entry.Project).SetExpansion(1)) - table.SetCell(currentRow, 3, tview.NewTableCell(entry.Location).SetExpansion(1)) + table.SetCell(currentRow, 1, tview.NewTableCell(highlightMatch(entry.Name, currentSearchTerm)).SetExpansion(1)) + table.SetCell(currentRow, 2, tview.NewTableCell(highlightMatch(entry.Project, currentSearchTerm)).SetExpansion(1)) + table.SetCell(currentRow, 3, tview.NewTableCell(highlightMatch(entry.Location, currentSearchTerm)).SetExpansion(1)) if selectedKey != "" && newSelectedRow == -1 { rowKey := entry.Name + "|" + entry.Project + "|" + entry.Location @@ -299,29 +300,37 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, actionStr = FormatActionsStatusBar(actions) } + // Build fuzzy indicator and toggle hint + fuzzyBadge := "" + fuzzyToggle := "[yellow]Tab[-] fuzzy" + if fuzzyMode { + fuzzyBadge = " [green::b]FUZZY[-:-:-] " + fuzzyToggle = "[yellow]Tab[-] exact" + } + // During search, show search-specific status with context actions if isSearching { if actionStr != "" { - status.SetText(fmt.Sprintf(" [yellow]Searching...[-] %s [yellow]Esc[-] cancel", actionStr)) + status.SetText(fmt.Sprintf("%s [yellow]Searching...[-] %s [yellow]Esc[-] cancel", fuzzyBadge, actionStr)) } else { - status.SetText(" [yellow]Searching...[-] [yellow]Esc[-] cancel") + status.SetText(fmt.Sprintf("%s [yellow]Searching...[-] [yellow]Esc[-] cancel", fuzzyBadge)) } return } if entry == nil { if count > 0 { - status.SetText(fmt.Sprintf(" [green]%d results[-] [yellow]Enter[-] search [yellow]/[-] filter [yellow]Esc[-] back [yellow]?[-] help", count)) + status.SetText(fmt.Sprintf("%s[green]%d results[-] %s [yellow]Enter[-] search [yellow]/[-] filter [yellow]Esc[-] back [yellow]?[-] help", fuzzyBadge, count, fuzzyToggle)) } else { - status.SetText(" [yellow]Enter[-] search [yellow]/[-] filter [yellow]Esc[-] back [yellow]?[-] help") + status.SetText(fmt.Sprintf("%s%s [yellow]Enter[-] search [yellow]/[-] filter [yellow]Esc[-] back [yellow]?[-] help", fuzzyBadge, fuzzyToggle)) } return } if currentFilter != "" { - status.SetText(fmt.Sprintf(" [green]Filter: %s[-] %s [yellow]/[-] edit [yellow]Esc[-] clear [yellow]?[-] help", currentFilter, actionStr)) + status.SetText(fmt.Sprintf("%s[green]Filter: %s[-] %s [yellow]/[-] edit [yellow]Esc[-] clear [yellow]?[-] help", fuzzyBadge, currentFilter, actionStr)) } else { - status.SetText(fmt.Sprintf(" %s [yellow]Enter[-] search [yellow]/[-] filter [yellow]Esc[-] back [yellow]?[-] help", actionStr)) + status.SetText(fmt.Sprintf("%s%s %s [yellow]Enter[-] search [yellow]/[-] filter [yellow]Esc[-] back [yellow]?[-] help", fuzzyBadge, actionStr, fuzzyToggle)) } } @@ -419,7 +428,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, }) // Run search - searchQuery := search.Query{Term: query} + searchQuery := search.Query{Term: query, Fuzzy: fuzzyMode} callback := func(results []search.Result, progress search.SearchProgress) error { // Check if cancelled @@ -574,6 +583,22 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, return event } + // Tab toggles fuzzy mode globally, regardless of focus + if event.Key() == tcell.KeyTab { + fuzzyMode = !fuzzyMode + if fuzzyMode { + searchInput.SetLabel(" Search (fuzzy): ") + } else { + searchInput.SetLabel(" Search: ") + } + updateStatusWithActions() + // Re-run current search if there is one + if currentSearchTerm != "" { + go performSearch(currentSearchTerm) + } + return nil + } + // If in filter mode, let the input field handle it if filterMode { return event @@ -1067,6 +1092,11 @@ func createSearchEngine(parallelism int) *search.Engine { return gcp.NewClient(ctx, project) }, }, + &search.RouteProvider{ + NewClient: func(ctx context.Context, project string) (search.RouteClient, error) { + return gcp.NewClient(ctx, project) + }, + }, ) engine.MaxConcurrentProjects = parallelism return engine @@ -1201,6 +1231,7 @@ func showSearchHelp(app *tview.Application, table *tview.Table, mainFlex *tview. [yellow]Search[-] [white]Enter[-] Start search / Focus search input + [white]Tab[-] Toggle fuzzy matching [white]Esc[-] Cancel search (if running) / Clear filter / Go back [yellow]Navigation[-] @@ -1221,6 +1252,7 @@ func showSearchHelp(app *tview.Application, table *tview.Table, mainFlex *tview. • Search can be cancelled at any time with Esc • Cancelled searches keep existing results • Filter (/) narrows displayed results without new search + • Tab toggles fuzzy mode (matches characters in order, e.g. "prd" matches "production") • Context-aware actions based on resource type [darkgray]Press Esc or ? to close this help[-]` @@ -1261,3 +1293,27 @@ func showSearchHelp(app *tview.Application, table *tview.Table, mainFlex *tview. *modalOpen = true app.SetRoot(helpFlex, true).SetFocus(helpView) } + +// highlightMatch wraps the first occurrence of term in text with tview bold+yellow color tags. +// The match is case-insensitive but preserves the original case in the output. +func highlightMatch(text, term string) string { + if term == "" || text == "" { + return text + } + + termLower := strings.ToLower(strings.TrimSpace(term)) + if termLower == "" { + return text + } + + idx := strings.Index(strings.ToLower(text), termLower) + if idx < 0 { + return text + } + + before := text[:idx] + matched := text[idx : idx+len(termLower)] + after := text[idx+len(termLower):] + + return before + "[yellow::b]" + matched + "[-:-:-]" + after +} diff --git a/internal/tui/search_view_test.go b/internal/tui/search_view_test.go new file mode 100644 index 0000000..58a731d --- /dev/null +++ b/internal/tui/search_view_test.go @@ -0,0 +1,30 @@ +package tui + +import "testing" + +func TestHighlightMatch(t *testing.T) { + tests := []struct { + name string + text string + term string + expected string + }{ + {"empty term", "hello", "", "hello"}, + {"empty text", "", "hello", ""}, + {"exact match", "hello", "hello", "[yellow::b]hello[-:-:-]"}, + {"substring match", "my-web-server", "web", "my-[yellow::b]web[-:-:-]-server"}, + {"case insensitive", "MyWebServer", "web", "My[yellow::b]Web[-:-:-]Server"}, + {"no match", "hello", "xyz", "hello"}, + {"match at start", "web-server", "web", "[yellow::b]web[-:-:-]-server"}, + {"match at end", "my-web", "web", "my-[yellow::b]web[-:-:-]"}, + {"whitespace term", "hello", " ", "hello"}, + {"term with spaces trimmed", "hello", " hell ", "[yellow::b]hell[-:-:-]o"}, + } + + for _, tt := range tests { + got := highlightMatch(tt.text, tt.term) + if got != tt.expected { + t.Errorf("%s: highlightMatch(%q, %q) = %q, want %q", tt.name, tt.text, tt.term, got, tt.expected) + } + } +} From 60d672ba013c491613b457bcae8fad9d2372bb37 Mon Sep 17 00:00:00 2001 From: Mathieu Poussin Date: Tue, 10 Feb 2026 11:28:53 +0100 Subject: [PATCH 2/3] Better filtering logics --- internal/tui/connectivity_view.go | 18 +--- internal/tui/direct.go | 18 +--- internal/tui/filter.go | 96 +++++++++++++++++++ internal/tui/filter_test.go | 151 ++++++++++++++++++++++++++++++ internal/tui/instance_view.go | 7 +- internal/tui/ip_lookup_view.go | 26 ++--- internal/tui/project_selector.go | 7 +- internal/tui/search_view.go | 27 ++---- internal/tui/vpn_view.go | 16 +--- 9 files changed, 281 insertions(+), 85 deletions(-) create mode 100644 internal/tui/filter.go create mode 100644 internal/tui/filter_test.go diff --git a/internal/tui/connectivity_view.go b/internal/tui/connectivity_view.go index d7f1c9f..8cde622 100644 --- a/internal/tui/connectivity_view.go +++ b/internal/tui/connectivity_view.go @@ -214,21 +214,11 @@ func showConnectivityViewUI(ctx context.Context, connClient *gcp.ConnectivityCli } // Filter entries - filterLower := strings.ToLower(filter) + expr := parseFilter(filter) var filteredEntries []connectivityEntry - if filter == "" { - filteredEntries = allEntries - } else { - for _, entry := range allEntries { - // Match against name, source, destination, protocol, result - if strings.Contains(strings.ToLower(entry.DisplayName), filterLower) || - strings.Contains(strings.ToLower(entry.Name), filterLower) || - strings.Contains(strings.ToLower(entry.Source), filterLower) || - strings.Contains(strings.ToLower(entry.Destination), filterLower) || - strings.Contains(strings.ToLower(entry.Protocol), filterLower) || - strings.Contains(strings.ToLower(entry.Result), filterLower) { - filteredEntries = append(filteredEntries, entry) - } + for _, entry := range allEntries { + if expr.matches(entry.DisplayName, entry.Name, entry.Source, entry.Destination, entry.Protocol, entry.Result) { + filteredEntries = append(filteredEntries, entry) } } diff --git a/internal/tui/direct.go b/internal/tui/direct.go index bb5de7e..3353ad0 100644 --- a/internal/tui/direct.go +++ b/internal/tui/direct.go @@ -67,7 +67,7 @@ func (r *outputRedirector) OrigStderr() *os.File { const ( statusDefault = " [yellow]s[-] SSH [yellow]d[-] details [yellow]b[-] browser [yellow]/[-] filter [yellow]Shift+R[-] refresh [yellow]v[-] VPN [yellow]c[-] connectivity [yellow]Shift+S[-] search [yellow]i[-] IP lookup [yellow]Esc[-] quit [yellow]?[-] help" statusFilterActive = " [green]Filter active: '%s'[-] [yellow]Esc[-] clear [yellow]s[-] SSH [yellow]d[-] details [yellow]b[-] browser [yellow]/[-] edit" - statusFilterMode = " [yellow]Type to filter, Enter to apply, Esc to cancel[-]" + statusFilterMode = " [yellow]Filter: spaces=AND |=OR -=NOT (e.g. \"web|api -dev\") Enter to apply, Esc to cancel[-]" statusNoSelection = " [red]No instance selected[-]" statusDisconnected = " [green]Disconnected from %s[-]" statusLoadingDetails = " [yellow]Loading instance details...[-]" @@ -223,20 +223,10 @@ func (s *tuiState) updateTable() { currentRow := 1 matchCount := 0 + expr := parseFilter(filter) for _, inst := range s.allInstances { - if filter != "" { - filterLower := strings.ToLower(filter) - nameLower := strings.ToLower(inst.Name) - projectLower := strings.ToLower(inst.Project) - zoneLower := strings.ToLower(inst.Zone) - - nameMatch := strings.Contains(nameLower, filterLower) - projectMatch := strings.Contains(projectLower, filterLower) - zoneMatch := strings.Contains(zoneLower, filterLower) - - if !nameMatch && !projectMatch && !zoneMatch { - continue - } + if !expr.matches(inst.Name, inst.Project, inst.Zone) { + continue } s.table.SetCell(currentRow, 0, tview.NewTableCell(inst.Name).SetExpansion(1)) diff --git a/internal/tui/filter.go b/internal/tui/filter.go new file mode 100644 index 0000000..fdc91ec --- /dev/null +++ b/internal/tui/filter.go @@ -0,0 +1,96 @@ +package tui + +import "strings" + +// filterTerm is a single token: a literal (possibly OR-grouped) with optional negation. +type filterTerm struct { + alternatives []string // OR-separated values, e.g. "prod|staging" → ["prod", "staging"] + negate bool // true if prefixed with "-" +} + +// filterExpr is a parsed filter: all terms must match (AND logic). +type filterExpr struct { + terms []filterTerm +} + +// parseFilter splits a raw filter string into an expression. +// Spaces separate AND terms. "|" separates OR alternatives within a term. +// "-" prefix negates a term. +// +// Examples: +// +// "web prod" → must contain "web" AND "prod" +// "web|api" → must contain "web" OR "api" +// "-dev" → must NOT contain "dev" +// "compute -dev|staging" → must contain "compute" AND must NOT contain "dev" or "staging" +func parseFilter(raw string) filterExpr { + var terms []filterTerm + + for _, token := range strings.Fields(raw) { + if token == "-" { + continue + } + + negate := false + if strings.HasPrefix(token, "-") && len(token) > 1 { + negate = true + token = token[1:] + } + + alts := strings.Split(strings.ToLower(token), "|") + + // Remove empty alternatives + var cleaned []string + for _, a := range alts { + if a != "" { + cleaned = append(cleaned, a) + } + } + + if len(cleaned) > 0 { + terms = append(terms, filterTerm{alternatives: cleaned, negate: negate}) + } + } + + return filterExpr{terms: terms} +} + +// matches reports whether any of the provided values satisfy the entire filter expression. +// All terms must be satisfied (AND). Within a term, any alternative suffices (OR). +func (f filterExpr) matches(values ...string) bool { + if len(f.terms) == 0 { + return true + } + + // Pre-lowercase all values once + lower := make([]string, len(values)) + for i, v := range values { + lower[i] = strings.ToLower(v) + } + + for _, term := range f.terms { + found := false + + for _, alt := range term.alternatives { + for _, v := range lower { + if strings.Contains(v, alt) { + found = true + break + } + } + if found { + break + } + } + + if term.negate && found { + return false + } + + if !term.negate && !found { + return false + } + } + + return true +} diff --git a/internal/tui/filter_test.go b/internal/tui/filter_test.go new file mode 100644 index 0000000..03c3bf9 --- /dev/null +++ b/internal/tui/filter_test.go @@ -0,0 +1,151 @@ +package tui + +import "testing" + +func TestParseFilterEmpty(t *testing.T) { + expr := parseFilter("") + if len(expr.terms) != 0 { + t.Fatalf("expected 0 terms, got %d", len(expr.terms)) + } +} + +func TestParseFilterWhitespace(t *testing.T) { + expr := parseFilter(" ") + if len(expr.terms) != 0 { + t.Fatalf("expected 0 terms for whitespace, got %d", len(expr.terms)) + } +} + +func TestParseFilterSingleTerm(t *testing.T) { + expr := parseFilter("web") + if len(expr.terms) != 1 { + t.Fatalf("expected 1 term, got %d", len(expr.terms)) + } + if expr.terms[0].negate { + t.Fatal("expected non-negated term") + } + if len(expr.terms[0].alternatives) != 1 || expr.terms[0].alternatives[0] != "web" { + t.Fatalf("expected [web], got %v", expr.terms[0].alternatives) + } +} + +func TestParseFilterNegation(t *testing.T) { + expr := parseFilter("-dev") + if len(expr.terms) != 1 { + t.Fatalf("expected 1 term, got %d", len(expr.terms)) + } + if !expr.terms[0].negate { + t.Fatal("expected negated term") + } + if expr.terms[0].alternatives[0] != "dev" { + t.Fatalf("expected [dev], got %v", expr.terms[0].alternatives) + } +} + +func TestParseFilterOR(t *testing.T) { + expr := parseFilter("web|api") + if len(expr.terms) != 1 { + t.Fatalf("expected 1 term, got %d", len(expr.terms)) + } + if len(expr.terms[0].alternatives) != 2 { + t.Fatalf("expected 2 alternatives, got %d", len(expr.terms[0].alternatives)) + } +} + +func TestParseFilterAND(t *testing.T) { + expr := parseFilter("web prod") + if len(expr.terms) != 2 { + t.Fatalf("expected 2 terms, got %d", len(expr.terms)) + } +} + +func TestParseFilterDashAlone(t *testing.T) { + // A bare "-" should not produce a term (no content after dash) + expr := parseFilter("-") + if len(expr.terms) != 0 { + t.Fatalf("expected 0 terms for bare dash, got %d", len(expr.terms)) + } +} + +func TestParseFilterTrailingPipe(t *testing.T) { + expr := parseFilter("web|") + if len(expr.terms) != 1 { + t.Fatalf("expected 1 term, got %d", len(expr.terms)) + } + if len(expr.terms[0].alternatives) != 1 { + t.Fatalf("expected 1 alternative (empty filtered out), got %d", len(expr.terms[0].alternatives)) + } +} + +func TestFilterExprMatches(t *testing.T) { + tests := []struct { + name string + filter string + values []string + expected bool + }{ + // Empty filter matches everything + {"empty filter", "", []string{"anything"}, true}, + {"whitespace filter", " ", []string{"anything"}, true}, + + // Simple single term + {"simple match", "web", []string{"web-server", "proj-a"}, true}, + {"simple no match", "api", []string{"web-server", "proj-a"}, false}, + {"case insensitive", "WEB", []string{"web-server", "proj-a"}, true}, + + // AND (space separated) + {"AND both match", "web prod", []string{"web-server", "prod-project", "us-central1"}, true}, + {"AND first fails", "api prod", []string{"web-server", "prod-project", "us-central1"}, false}, + {"AND second fails", "web staging", []string{"web-server", "prod-project", "us-central1"}, false}, + {"AND both fail", "api staging", []string{"web-server", "prod-project", "us-central1"}, false}, + {"AND same value", "web server", []string{"web-server"}, true}, + + // OR (pipe separated) + {"OR first match", "web|api", []string{"web-server"}, true}, + {"OR second match", "web|api", []string{"api-server"}, true}, + {"OR no match", "web|api", []string{"db-server"}, false}, + {"OR three alternatives", "web|api|db", []string{"db-server"}, true}, + + // NOT (dash prefix) + {"NOT excluded", "-dev", []string{"web-dev", "proj-a"}, false}, + {"NOT not excluded", "-dev", []string{"web-prod", "proj-a"}, true}, + {"NOT case insensitive", "-DEV", []string{"my-dev-server"}, false}, + + // Combined: AND + OR + {"AND+OR match", "compute web|api", []string{"compute.instance", "web-server"}, true}, + {"AND+OR second alt", "compute web|api", []string{"compute.instance", "api-gateway"}, true}, + {"AND+OR no match", "compute web|api", []string{"compute.instance", "db-server"}, false}, + + // Combined: AND + NOT + {"AND+NOT match", "compute -dev", []string{"compute.instance", "prod-vm", "us-central1"}, true}, + {"AND+NOT excluded", "compute -dev", []string{"compute.instance", "dev-vm", "us-central1"}, false}, + {"AND+NOT first fails", "storage -dev", []string{"compute.instance", "prod-vm"}, false}, + + // Combined: NOT + OR (negated OR group) + {"NOT+OR first excluded", "-dev|staging", []string{"web-dev", "proj-a"}, false}, + {"NOT+OR second excluded", "-dev|staging", []string{"web-staging", "proj-a"}, false}, + {"NOT+OR not excluded", "-dev|staging", []string{"web-prod", "proj-a"}, true}, + + // Full combo: AND + OR + NOT + {"full combo match", "compute prod|live -test", []string{"compute.instance", "prod-vm", "us-central1"}, true}, + {"full combo NOT fails", "compute prod|live -test", []string{"compute.instance", "prod-test-vm", "us-central1"}, false}, + {"full combo OR alt", "compute prod|live -test", []string{"compute.instance", "live-vm", "us-central1"}, true}, + + // Edge: match across different values + {"term in different columns", "compute us-central", []string{"compute.instance", "my-vm", "us-central1"}, true}, + + // Edge: empty values + {"empty values", "web", []string{"", "", ""}, false}, + {"some empty values", "web", []string{"", "web-server", ""}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expr := parseFilter(tt.filter) + got := expr.matches(tt.values...) + if got != tt.expected { + t.Errorf("parseFilter(%q).matches(%v) = %v, want %v", tt.filter, tt.values, got, tt.expected) + } + }) + } +} diff --git a/internal/tui/instance_view.go b/internal/tui/instance_view.go index cf596f6..90648d1 100644 --- a/internal/tui/instance_view.go +++ b/internal/tui/instance_view.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "os/exec" - "strings" "sync" "time" @@ -226,10 +225,8 @@ func (v *InstanceView) updateTable() { for _, instance := range v.instances { // Apply filter if v.filterText != "" { - match := strings.Contains(strings.ToLower(instance.Name), strings.ToLower(v.filterText)) || - strings.Contains(strings.ToLower(instance.Project), strings.ToLower(v.filterText)) || - strings.Contains(strings.ToLower(instance.Zone), strings.ToLower(v.filterText)) - if !match { + expr := parseFilter(v.filterText) + if !expr.matches(instance.Name, instance.Project, instance.Zone) { continue } } diff --git a/internal/tui/ip_lookup_view.go b/internal/tui/ip_lookup_view.go index 4c49846..6a6c0ac 100644 --- a/internal/tui/ip_lookup_view.go +++ b/internal/tui/ip_lookup_view.go @@ -140,20 +140,14 @@ func RunIPLookupView(ctx context.Context, c *cache.Cache, app *tview.Application table.RemoveRow(row) } - filterLower := strings.ToLower(filter) + filterExpr := parseFilter(filter) currentRow := 1 matchCount := 0 newSelectedRow := -1 for _, entry := range results { - if filter != "" { - if !strings.Contains(strings.ToLower(entry.Resource), filterLower) && - !strings.Contains(strings.ToLower(entry.Project), filterLower) && - !strings.Contains(strings.ToLower(entry.Location), filterLower) && - !strings.Contains(strings.ToLower(entry.Kind), filterLower) && - !strings.Contains(strings.ToLower(entry.Details), filterLower) { - continue - } + if !filterExpr.matches(entry.Resource, entry.Project, entry.Location, entry.Kind, entry.Details) { + continue } kindColor := getKindColor(entry.Kind) @@ -222,18 +216,12 @@ func RunIPLookupView(ctx context.Context, c *cache.Cache, app *tview.Application resultsMu.Lock() defer resultsMu.Unlock() - filterLower := strings.ToLower(currentFilter) + expr := parseFilter(currentFilter) visibleIdx := 0 for i := range allResults { entry := &allResults[i] - if currentFilter != "" { - if !strings.Contains(strings.ToLower(entry.Resource), filterLower) && - !strings.Contains(strings.ToLower(entry.Project), filterLower) && - !strings.Contains(strings.ToLower(entry.Location), filterLower) && - !strings.Contains(strings.ToLower(entry.Kind), filterLower) && - !strings.Contains(strings.ToLower(entry.Details), filterLower) { - continue - } + if !expr.matches(entry.Resource, entry.Project, entry.Location, entry.Kind, entry.Details) { + continue } visibleIdx++ if visibleIdx == row { @@ -543,7 +531,7 @@ func RunIPLookupView(ctx context.Context, c *cache.Cache, app *tview.Application filterInput.SetText(currentFilter) rebuildLayout(true, false) app.SetFocus(filterInput) - status.SetText(" [yellow]Type to filter results, Enter to apply, Esc to cancel[-]") + status.SetText(" [yellow]Filter: spaces=AND |=OR -=NOT (e.g. \"web|api -dev\") Enter to apply, Esc to cancel[-]") return nil case 'd': diff --git a/internal/tui/project_selector.go b/internal/tui/project_selector.go index cd5d1fc..e20281c 100644 --- a/internal/tui/project_selector.go +++ b/internal/tui/project_selector.go @@ -2,7 +2,6 @@ package tui import ( "fmt" - "strings" "github.com/gdamore/tcell/v2" "github.com/kedare/compass/internal/cache" @@ -70,10 +69,10 @@ func ShowProjectSelector(app *tview.Application, c *cache.Cache, title string, o } // Filter and add projects - filterLower := strings.ToLower(filter) + expr := parseFilter(filter) row := 1 for _, project := range projects { - if filter == "" || strings.Contains(strings.ToLower(project), filterLower) { + if expr.matches(project) { table.SetCell(row, 0, tview.NewTableCell(project).SetExpansion(1)) row++ } @@ -203,7 +202,7 @@ func ShowProjectSelector(app *tview.Application, c *cache.Cache, title string, o flex.AddItem(table, 0, 1, false) flex.AddItem(status, 1, 0, false) app.SetFocus(filterInput) - status.SetText(" [yellow]Type to filter, Enter to apply, Esc to cancel[-]") + status.SetText(" [yellow]Filter: spaces=AND |=OR -=NOT (e.g. \"web|api -dev\") Enter to apply, Esc to cancel[-]") return nil } } diff --git a/internal/tui/search_view.go b/internal/tui/search_view.go index c665b23..402087d 100644 --- a/internal/tui/search_view.go +++ b/internal/tui/search_view.go @@ -188,20 +188,15 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, table.RemoveRow(row) } - filterLower := strings.ToLower(filter) + filterExpr := parseFilter(filter) currentRow := 1 matchCount := 0 newSelectedRow := -1 for _, entry := range results { - if filter != "" { - if !strings.Contains(strings.ToLower(entry.Name), filterLower) && - !strings.Contains(strings.ToLower(entry.Project), filterLower) && - !strings.Contains(strings.ToLower(entry.Location), filterLower) && - !strings.Contains(strings.ToLower(entry.Type), filterLower) { - continue - } + if !filterExpr.matches(entry.Name, entry.Project, entry.Location, entry.Type) { + continue } typeColor := getTypeColor(entry.Type) @@ -267,17 +262,12 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, resultsMu.Lock() defer resultsMu.Unlock() - filterLower := strings.ToLower(currentFilter) + expr := parseFilter(currentFilter) visibleIdx := 0 for i := range allResults { entry := &allResults[i] - if currentFilter != "" { - if !strings.Contains(strings.ToLower(entry.Name), filterLower) && - !strings.Contains(strings.ToLower(entry.Project), filterLower) && - !strings.Contains(strings.ToLower(entry.Location), filterLower) && - !strings.Contains(strings.ToLower(entry.Type), filterLower) { - continue - } + if !expr.matches(entry.Name, entry.Project, entry.Location, entry.Type) { + continue } visibleIdx++ if visibleIdx == row { @@ -649,7 +639,7 @@ func RunSearchView(ctx context.Context, c *cache.Cache, app *tview.Application, filterInput.SetText(currentFilter) rebuildLayout(true, false) app.SetFocus(filterInput) - status.SetText(" [yellow]Type to filter results, Enter to apply, Esc to cancel[-]") + status.SetText(" [yellow]Filter: spaces=AND |=OR -=NOT (e.g. \"web|api -dev\") Enter to apply, Esc to cancel[-]") return nil case 's': @@ -1252,6 +1242,9 @@ func showSearchHelp(app *tview.Application, table *tview.Table, mainFlex *tview. • Search can be cancelled at any time with Esc • Cancelled searches keep existing results • Filter (/) narrows displayed results without new search + • Filter supports: spaces (AND), | (OR), - (NOT) + Example: "compute.instance prod" = instances in prod projects + Example: "web|api -dev" = web or api resources, excluding dev • Tab toggles fuzzy mode (matches characters in order, e.g. "prd" matches "production") • Context-aware actions based on resource type diff --git a/internal/tui/vpn_view.go b/internal/tui/vpn_view.go index 2d9d628..fd9b9e9 100644 --- a/internal/tui/vpn_view.go +++ b/internal/tui/vpn_view.go @@ -292,19 +292,11 @@ func showVPNViewUI(ctx context.Context, gcpClient *gcp.Client, selectedProject s } // Filter entries - filterLower := strings.ToLower(filter) + expr := parseFilter(filter) var filteredEntries []vpnEntry - if filter == "" { - filteredEntries = allEntries - } else { - for _, entry := range allEntries { - // Match against name, region, network, status - if strings.Contains(strings.ToLower(entry.Name), filterLower) || - strings.Contains(strings.ToLower(entry.Region), filterLower) || - strings.Contains(strings.ToLower(entry.Network), filterLower) || - strings.Contains(strings.ToLower(entry.Status), filterLower) { - filteredEntries = append(filteredEntries, entry) - } + for _, entry := range allEntries { + if expr.matches(entry.Name, entry.Region, entry.Network, entry.Status) { + filteredEntries = append(filteredEntries, entry) } } From 64468472c6a6a34608ba5c728b59366cca7913d5 Mon Sep 17 00:00:00 2001 From: Mathieu Poussin Date: Tue, 10 Feb 2026 11:32:37 +0100 Subject: [PATCH 3/3] README.md update --- README.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bc9f2ba..c463a9e 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ - [Smart Search Learning](#smart-search-learning) - [Connectivity Tests](#connectivity-tests) - [VPN Inspection](#vpn-inspection) +- [Interactive TUI](#interactive-tui) - [Development](#development) - [CI/CD](#cicd) - [Roadmap](#roadmap) @@ -54,6 +55,9 @@ - 🔭 Cloud VPN inventory across gateways, tunnels, and BGP peers - 💾 Intelligent local cache with smart search learning for instant connections - 🧠 Search affinity system that learns your patterns and prioritizes relevant projects +- 🔎 Global resource search across 22 GCP resource types with fuzzy matching and highlight +- 🖥️ Interactive TUI (terminal UI) with keyboard-driven navigation (k9s-style) +- 🧮 Advanced filtering with AND (spaces), OR (`|`), and NOT (`-`) operators across all views - 📊 Structured logging with configurable verbosity and clean spinner-based progress - ⚡ Zero configuration—relies on existing `gcloud` authentication - 🔁 In-place upgrades via `compass update` to pull the latest GitHub release @@ -182,6 +186,9 @@ compass gcp search piou # Inspect VPN gateways compass gcp vpn list --project prod +# Launch the interactive TUI +compass interactive + # Update to the latest published release compass update @@ -270,14 +277,14 @@ TYPE PROJECT LOCATION NAME DETAILS compute.instance prod-project us-central1-b piou-runner status=RUNNING, machineType=e2-medium ``` -**Searchable resource types:** +**Searchable resource types (22):** | Type | Kind | Details shown | |------|------|---------------| | Compute Engine instances | `compute.instance` | Status, machine type | | Managed Instance Groups | `compute.mig` | Location, regional/zonal | | Instance templates | `compute.instanceTemplate` | Machine type | -| IP address reservations | `compute.address` | Address, type, status | +| IP address reservations | `compute.address` | Address, type, status, description, subnetwork, users | | Persistent disks | `compute.disk` | Size, type, status | | Disk snapshots | `compute.snapshot` | Size, source disk, status | | Cloud Storage buckets | `storage.bucket` | Location, storage class | @@ -292,13 +299,16 @@ compute.instance prod-project us-central1-b piou-runner status=RUNNING, m | VPC networks | `compute.network` | Auto-create subnets, subnet count | | VPC subnets | `compute.subnet` | Region, network, CIDR, purpose | | Cloud Run services | `run.service` | Region, URL, latest revision | -| Firewall rules | `compute.firewall` | Network, direction, priority | +| Firewall rules | `compute.firewall` | Network, direction, priority, description, source ranges, target tags, allowed protocols | | Secret Manager secrets | `secretmanager.secret` | Replication type | | HA VPN gateways | `compute.vpnGateway` | Network, interface count, IPs | | VPN tunnels | `compute.vpnTunnel` | Status, peer IP, IKE version, gateway | +| VPC routes | `compute.route` | Destination range, network, priority, next hop, route type, tags | - Run `compass gcp projects import` first so the search knows which projects to inspect. - Use `--project ` when you want to bypass the cache and only inspect a single project. +- Search matches against resource names and detail fields (e.g. description, IP addresses, tags). +- In the TUI, press `Tab` to toggle fuzzy matching and `/` to filter results with AND/OR/NOT operators. ### IP Lookup Examples @@ -809,6 +819,52 @@ compass gcp vpn get --type tunnel --region **See [VPN Inspection Examples](#vpn-inspection-examples) for detailed output examples.** +## Interactive TUI + +Compass includes a full terminal UI for interactive exploration, accessible via: + +```bash +compass interactive # or: compass i / compass tui +``` + +The TUI provides keyboard-driven navigation similar to [k9s](https://k9scli.io/): + +**Main view — Instance browser:** +- `s` — SSH to the selected instance +- `d` — Show instance details +- `b` — Open in Cloud Console (browser) +- `/` — Filter the displayed list +- `Shift+S` — Global resource search +- `Shift+R` — Refresh instance list +- `v` — VPN view +- `c` — Connectivity tests view +- `i` — IP lookup view +- `?` — Help +- `Esc` — Quit (or clear active filter) + +**Global search (`Shift+S`):** +- Searches across all 22 resource types in cached projects +- Results appear progressively as they're found +- `Tab` — Toggle fuzzy matching (e.g. "prd" matches "production") +- `/` — Filter displayed results +- `d` — Show details for a result +- `s` — SSH to an instance result +- `b` / `o` — Open in browser + +### Filtering + +All TUI views share the same filter syntax. Press `/` to enter filter mode, then type your query: + +| Operator | Syntax | Example | Meaning | +|----------|--------|---------|---------| +| AND | spaces | `web prod` | Must contain both "web" AND "prod" | +| OR | pipe `\|` | `web\|api` | Must contain "web" OR "api" | +| NOT | dash `-` | `-dev` | Must NOT contain "dev" | + +Operators can be combined: `web|api prod -staging` matches resources containing ("web" or "api") AND "prod" but NOT "staging". + +Press `Enter` to apply the filter, `Esc` to cancel. + ## Development Use the `Taskfile.yml` to build, lint, and test consistently.