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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ coverage.html
coverage.out

k9s/
PR.md
188 changes: 172 additions & 16 deletions cmd/gcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,20 +98,50 @@ Examples:

if resourceType == "" || project == "" || zone == "" {
logger.Log.Debug("Checking cache for resource information")
cache, err := gcp.LoadCache()
if err == nil && cache != nil {
if cachedInfo, found := cache.Get(instanceName); found {
if resourceType == "" {
cacheStore, err := gcp.LoadCache()
if err == nil && cacheStore != nil {
// Check if this instance exists in multiple projects
if project == "" {
matches := cacheStore.GetAllByName(instanceName)
if len(matches) > 1 {
// Same name exists in multiple projects - prompt for selection
logger.Log.Debugf("Found %d matches for %s across different projects", len(matches), instanceName)
selected, selectErr := promptProjectSelection(ctx, cmd, instanceName, matches)
if selectErr != nil {
if isContextCanceled(ctx, selectErr) {
logger.Log.Info("Project selection canceled")
return
}
logger.Log.Fatalf("Failed to select project: %v", selectErr)
}
cachedProject = selected.Project
cachedResourceType = string(selected.Info.Type)
if selected.Info.Zone != "" {
cachedZone = selected.Info.Zone
} else if selected.Info.Region != "" {
cachedZone = selected.Info.Region
}
logger.Log.Debugf("Selected project %s for resource %s", cachedProject, instanceName)
} else if len(matches) == 1 {
// Single match - use it directly
cachedProject = matches[0].Project
cachedResourceType = string(matches[0].Info.Type)
if matches[0].Info.Zone != "" {
cachedZone = matches[0].Info.Zone
}
logger.Log.Debugf("Found single cached match for %s in project %s", instanceName, cachedProject)
}
}

// If we still don't have cached info and project is specified, try direct lookup
if cachedResourceType == "" && project != "" {
if cachedInfo, found := cacheStore.GetWithProject(instanceName, project); found {
cachedResourceType = string(cachedInfo.Type)
logger.Log.Debugf("Found cached resource type: %s", cachedResourceType)
}
if project == "" && cachedInfo.Project != "" {
cachedProject = cachedInfo.Project
logger.Log.Debugf("Found cached project: %s", cachedProject)
}
if zone == "" && cachedInfo.Zone != "" {
cachedZone = cachedInfo.Zone
logger.Log.Debugf("Found cached zone: %s", cachedZone)
if zone == "" && cachedInfo.Zone != "" {
cachedZone = cachedInfo.Zone
logger.Log.Debugf("Found cached zone: %s", cachedZone)
}
}
}
}
Expand All @@ -134,9 +164,9 @@ Examples:

// Try to get projects from cache (only if cache is enabled)
// Use GetProjectsByUsage() to search recently used projects first for faster lookups
cache, err := gcp.LoadCache()
if err == nil && cache != nil {
cachedProjects = cache.GetProjectsByUsage()
cacheStore, err := gcp.LoadCache()
if err == nil && cacheStore != nil {
cachedProjects = cacheStore.GetProjectsByUsage()
if len(cachedProjects) > 0 {
logger.Log.Debugf("Will search across %d cached projects (ordered by recent usage)", len(cachedProjects))
} else {
Expand Down Expand Up @@ -292,7 +322,7 @@ Examples:

// Mark the instance and project as used for future priority ordering
if cacheStore, cacheErr := gcp.LoadCache(); cacheErr == nil && cacheStore != nil {
_ = cacheStore.MarkInstanceUsed(instance.Name)
_ = cacheStore.MarkInstanceUsed(instance.Name, project)
_ = cacheStore.MarkProjectUsed(project)
}

Expand Down Expand Up @@ -1043,6 +1073,132 @@ func (p *lookupProgressBar) stopLocked() {
p.closed = true
}

// promptProjectSelection prompts the user to select a project when an instance name
// exists in multiple projects.
func promptProjectSelection(ctx context.Context, cmd *cobra.Command, instanceName string, matches []cache.InstanceMatch) (*cache.InstanceMatch, error) {
stdin := cmd.InOrStdin()
stdout := cmd.OutOrStdout()

if isTerminalReader(stdin) && isTerminalWriter(stdout) {
if selected, err := promptProjectSelectionInteractive(ctx, instanceName, matches); err == nil {
return selected, nil
} else if !isContextCanceled(ctx, err) {
logger.Log.Debugf("Interactive selection failed, falling back to text prompt: %v", err)
} else {
return nil, err
}
}

reader := bufio.NewReader(stdin)

return promptProjectSelectionFromReader(reader, stdout, instanceName, matches)
}

func promptProjectSelectionInteractive(ctx context.Context, instanceName string, matches []cache.InstanceMatch) (*cache.InstanceMatch, error) {
if len(matches) == 0 {
return nil, fmt.Errorf("no matches available for %s", instanceName)
}

options := make([]string, len(matches))
for i, match := range matches {
zone := match.Info.Zone
if zone == "" {
zone = match.Info.Region
}
options[i] = fmt.Sprintf("%s (project: %s, zone: %s)", match.Name, match.Project, zone)
}

var interrupted bool

selectedOption, err := pterm.DefaultInteractiveSelect.
WithOptions(options).
WithDefaultText(fmt.Sprintf("'%s' exists in multiple projects. Select one:", instanceName)).
WithDefaultOption(options[0]).
WithOnInterruptFunc(func() {
interrupted = true
}).
Show()
if err != nil {
return nil, err
}

if interrupted || isContextCanceled(ctx, nil) {
return nil, context.Canceled
}

for i, option := range options {
if option == selectedOption {
return &matches[i], nil
}
}

return &matches[0], nil
}

func promptProjectSelectionFromReader(reader *bufio.Reader, out io.Writer, instanceName string, matches []cache.InstanceMatch) (*cache.InstanceMatch, error) {
if _, err := fmt.Fprintf(out, "'%s' exists in multiple projects:\n", instanceName); err != nil {
return nil, err
}

for i, match := range matches {
zone := match.Info.Zone
if zone == "" {
zone = match.Info.Region
}
if _, err := fmt.Fprintf(out, " [%d] %s (project: %s, zone: %s)\n", i+1, match.Name, match.Project, zone); err != nil {
return nil, err
}
}

if _, err := fmt.Fprintf(out, "Select project [default 1]: "); err != nil {
return nil, err
}

selected := 0

for {
input, err := reader.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) {
if _, writeErr := fmt.Fprintln(out); writeErr != nil {
return nil, writeErr
}

break
}

return nil, err
}

input = strings.TrimSpace(input)
if input == "" {
if _, writeErr := fmt.Fprintln(out); writeErr != nil {
return nil, writeErr
}

break
}

value, err := strconv.Atoi(input)
if err != nil || value < 1 || value > len(matches) {
if _, writeErr := fmt.Fprintf(out, "Invalid selection. Enter a value between 1 and %d: ", len(matches)); writeErr != nil {
return nil, writeErr
}

continue
}

if _, writeErr := fmt.Fprintln(out); writeErr != nil {
return nil, writeErr
}
selected = value - 1

break
}

return &matches[selected], nil
}

func init() {
// Use PersistentFlags for project so it's inherited by subcommands like connectivity-test
gcpCmd.PersistentFlags().StringVarP(&project, "project", "p", "", "GCP project ID")
Expand Down
81 changes: 66 additions & 15 deletions cmd/gcp_projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"regexp"
"strings"
"sync"

"github.com/kedare/compass/internal/gcp"
Expand Down Expand Up @@ -71,27 +72,39 @@ Examples:
}

var gcpProjectsRefreshCmd = &cobra.Command{
Use: "refresh <project-name>",
Short: "Refresh cached resources for a GCP project",
Long: `Re-scan and update cached resources for a specific GCP project.
Use: "refresh [project-name]",
Short: "Refresh cached resources for GCP project(s)",
Long: `Re-scan and update cached resources for GCP projects.

When called without arguments, refreshes all currently imported projects.
When called with a project name, refreshes only that specific project.

This will refresh the cache with current data from GCP:
- Zones available in the project
- All compute instances
- All managed instance groups (MIGs)
- All subnets

The project must already be in the cache. Use 'compass gcp projects import' to add new projects.

Examples:
# Refresh all imported projects
compass gcp projects refresh

# Refresh a specific project
compass gcp projects refresh my-project`,
Args: cobra.ExactArgs(1),
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}

runProjectsRefresh(ctx, args[0])
if len(args) == 0 {
runProjectsRefreshAll(ctx)
} else {
runProjectsRefresh(ctx, args[0])
}
},
ValidArgsFunction: projectNameCompletion,
}
Expand Down Expand Up @@ -156,6 +169,25 @@ func runProjectsRefresh(ctx context.Context, projectName string) {
pterm.Success.Printfln("Project '%s' cache refreshed!", projectName)
}

// runProjectsRefreshAll rescans and updates cached resources for all imported projects.
func runProjectsRefreshAll(ctx context.Context) {
cache, err := gcp.LoadCache()
if err != nil {
logger.Log.Fatalf("Failed to load cache: %v", err)
}

projects := cache.GetProjects()
if len(projects) == 0 {
logger.Log.Fatal("No projects in cache. Use 'compass gcp projects import' to add projects first.")
}

logger.Log.Infof("Refreshing cached resources for %d project(s)...", len(projects))

scanProjectResources(ctx, projects)

pterm.Success.Printfln("All %d project(s) cache refreshed!", len(projects))
}

// runProjectsImport discovers all accessible GCP projects and prompts the user to select
// which projects to cache for future use. If regexPattern is provided, projects matching
// the pattern are automatically selected without interactive prompting.
Expand Down Expand Up @@ -235,10 +267,10 @@ func runProjectsImport(ctx context.Context, regexPattern string) {
pterm.Success.Printfln("Projects imported and resources cached! You can now use 'compass gcp ssh' without --project flag.")
}

// scanProjectResources scans zones, instances, and subnets for each project and caches them.
// scanProjectResources scans zones, instances, MIGs, and subnets for each project and caches them.
func scanProjectResources(ctx context.Context, projects []string) {
// 3 API operations per project: zones, instances, subnets
totalAPICalls := len(projects) * 3
// 4 API operations per project: zones, instances, MIGs, subnets
totalAPICalls := len(projects) * 4
var completedAPICalls int
var apiCallsMu sync.Mutex

Expand All @@ -259,16 +291,33 @@ func scanProjectResources(ctx context.Context, projects []string) {
WithShowCount(true).
Start()

// Track max title length to ensure proper clearing of previous text
var maxTitleLen int
var maxTitleMu sync.Mutex

// Function to update progress bar title with current stats
updateTitle := func(currentProject, currentOp string) {
statsMu.Lock()
zones := totalStats.Zones
instances := totalStats.Instances
migs := totalStats.MIGs
subnets := totalStats.Subnets
statsMu.Unlock()

progressBar.UpdateTitle(fmt.Sprintf("%s (%s) | %d zones, %d instances, %d subnets",
currentProject, currentOp, zones, instances, subnets))
title := fmt.Sprintf("%s (%s) | %d zones, %d instances, %d MIGs, %d subnets",
currentProject, currentOp, zones, instances, migs, subnets)

// Pad with spaces to clear any remaining characters from previous longer titles
maxTitleMu.Lock()
if len(title) > maxTitleLen {
maxTitleLen = len(title)
}
if len(title) < maxTitleLen {
title = title + strings.Repeat(" ", maxTitleLen-len(title))
}
maxTitleMu.Unlock()

progressBar.UpdateTitle(title)
}

// Scan each project concurrently with limited parallelism
Expand All @@ -289,10 +338,10 @@ func scanProjectResources(ctx context.Context, projects []string) {
errors = append(errors, fmt.Sprintf("%s: %v", proj, err))
errorsMu.Unlock()

// Count all 3 operations as done (failed)
// Count all 4 operations as done (failed)
apiCallsMu.Lock()
completedAPICalls += 3
progressBar.Add(3)
completedAPICalls += 4
progressBar.Add(4)
apiCallsMu.Unlock()
return
}
Expand All @@ -305,6 +354,8 @@ func scanProjectResources(ctx context.Context, projects []string) {
totalStats.Zones += count
case "instances":
totalStats.Instances += count
case "migs":
totalStats.MIGs += count
case "subnets":
totalStats.Subnets += count
}
Expand Down Expand Up @@ -343,8 +394,8 @@ func scanProjectResources(ctx context.Context, projects []string) {

// Print summary
statsMu.Lock()
pterm.Success.Printfln("Cached: %d zones, %d instances, %d subnets across %d projects",
totalStats.Zones, totalStats.Instances, totalStats.Subnets, len(projects))
pterm.Success.Printfln("Cached: %d zones, %d instances, %d MIGs, %d subnets across %d projects",
totalStats.Zones, totalStats.Instances, totalStats.MIGs, totalStats.Subnets, len(projects))
statsMu.Unlock()

// Print errors if any
Expand Down
Loading