Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 59 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand All @@ -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 <id>` 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

Expand Down Expand Up @@ -809,6 +819,52 @@ compass gcp vpn get <tunnel-name> --type tunnel --region <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.
Expand Down
98 changes: 93 additions & 5 deletions internal/gcp/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gcp
import (
"context"
"fmt"
"strings"

"github.com/kedare/compass/internal/logger"
"google.golang.org/api/compute/v1"
Expand Down Expand Up @@ -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,
})
}
}
Expand Down Expand Up @@ -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,
})
}

Expand Down
15 changes: 14 additions & 1 deletion internal/gcp/search/addresses.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package search
import (
"context"
"fmt"
"strings"

"github.com/kedare/compass/internal/gcp"
)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
78 changes: 78 additions & 0 deletions internal/gcp/search/addresses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
2 changes: 1 addition & 1 deletion internal/gcp/search/backend_services.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading