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
100 changes: 100 additions & 0 deletions cmd/gcp_projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,112 @@ Examples:
},
}

var gcpProjectsRemoveCmd = &cobra.Command{
Use: "remove <project-name>",
Short: "Remove a GCP project from cache",
Long: `Remove a GCP project and all its associated cached resources.

This removes the project from the cache along with all related data:
- The project entry itself
- All cached instances for this project
- All cached zones for this project
- All cached subnets for this project

Examples:
compass gcp projects remove my-project`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runProjectsRemove(args[0])
},
ValidArgsFunction: projectNameCompletion,
}

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.

This will refresh the cache with current data from GCP:
- Zones available in the project
- All compute instances
- All subnets

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

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

runProjectsRefresh(ctx, args[0])
},
ValidArgsFunction: projectNameCompletion,
}

func init() {
gcpCmd.AddCommand(gcpProjectsCmd)
gcpProjectsCmd.AddCommand(gcpProjectsImportCmd)
gcpProjectsCmd.AddCommand(gcpProjectsRemoveCmd)
gcpProjectsCmd.AddCommand(gcpProjectsRefreshCmd)
gcpProjectsImportCmd.Flags().StringVarP(&regexFilter, "regex", "r", "", "Regex pattern to filter projects (bypasses interactive selection)")
}

// projectNameCompletion provides shell completion for cached project names.
func projectNameCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}

cache, err := gcp.LoadCache()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}

projects := cache.GetProjects()

return projects, cobra.ShellCompDirectiveNoFileComp
}

// runProjectsRemove removes a project and all its cached resources.
func runProjectsRemove(projectName string) {
cache, err := gcp.LoadCache()
if err != nil {
logger.Log.Fatalf("Failed to load cache: %v", err)
}

if !cache.HasProject(projectName) {
logger.Log.Fatalf("Project '%s' is not in the cache", projectName)
}

if err := cache.DeleteProject(projectName); err != nil {
logger.Log.Fatalf("Failed to remove project: %v", err)
}

pterm.Success.Printfln("Project '%s' removed from cache", projectName)
}

// runProjectsRefresh rescans and updates cached resources for a specific project.
func runProjectsRefresh(ctx context.Context, projectName string) {
cache, err := gcp.LoadCache()
if err != nil {
logger.Log.Fatalf("Failed to load cache: %v", err)
}

if !cache.HasProject(projectName) {
logger.Log.Fatalf("Project '%s' is not in the cache. Use 'compass gcp projects import' to add it first.", projectName)
}

logger.Log.Infof("Refreshing cached resources for project: %s", projectName)

scanProjectResources(ctx, []string{projectName})

pterm.Success.Printfln("Project '%s' cache refreshed!", projectName)
}

// 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
115 changes: 115 additions & 0 deletions cmd/gcp_projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -244,3 +245,117 @@ func TestGcpProjectsImportHelp(t *testing.T) {
err := rootCmd.Execute()
require.NoError(t, err)
}

func TestGcpProjectsRemoveCommand(t *testing.T) {
require.NotNil(t, gcpProjectsRemoveCmd)
require.Equal(t, "remove <project-name>", gcpProjectsRemoveCmd.Use)
require.NotEmpty(t, gcpProjectsRemoveCmd.Short)
require.NotEmpty(t, gcpProjectsRemoveCmd.Long)
require.NotNil(t, gcpProjectsRemoveCmd.Run)
require.NotNil(t, gcpProjectsRemoveCmd.ValidArgsFunction)
}

func TestGcpProjectsRemoveCommandStructure(t *testing.T) {
// Check remove command is under projects
foundRemove := false
for _, cmd := range gcpProjectsCmd.Commands() {
if cmd.Name() == "remove" {
foundRemove = true
break
}
}
require.True(t, foundRemove, "remove command not found under projects command")
}

func TestGcpProjectsRemoveHelp(t *testing.T) {
rootCmd.SetArgs([]string{"gcp", "projects", "remove", "--help"})
defer rootCmd.SetArgs([]string{})

err := rootCmd.Execute()
require.NoError(t, err)
}

func TestGcpProjectsRemoveRequiresArg(t *testing.T) {
// Remove command should require exactly 1 argument
require.NotNil(t, gcpProjectsRemoveCmd.Args)

// Test that Args is set to ExactArgs(1)
err := gcpProjectsRemoveCmd.Args(gcpProjectsRemoveCmd, []string{})
require.Error(t, err, "remove command should require an argument")

err = gcpProjectsRemoveCmd.Args(gcpProjectsRemoveCmd, []string{"project-name"})
require.NoError(t, err, "remove command should accept one argument")

err = gcpProjectsRemoveCmd.Args(gcpProjectsRemoveCmd, []string{"project1", "project2"})
require.Error(t, err, "remove command should not accept multiple arguments")
}

func TestGcpProjectsRefreshCommand(t *testing.T) {
require.NotNil(t, gcpProjectsRefreshCmd)
require.Equal(t, "refresh <project-name>", gcpProjectsRefreshCmd.Use)
require.NotEmpty(t, gcpProjectsRefreshCmd.Short)
require.NotEmpty(t, gcpProjectsRefreshCmd.Long)
require.NotNil(t, gcpProjectsRefreshCmd.Run)
require.NotNil(t, gcpProjectsRefreshCmd.ValidArgsFunction)
}

func TestGcpProjectsRefreshCommandStructure(t *testing.T) {
// Check refresh command is under projects
foundRefresh := false
for _, cmd := range gcpProjectsCmd.Commands() {
if cmd.Name() == "refresh" {
foundRefresh = true
break
}
}
require.True(t, foundRefresh, "refresh command not found under projects command")
}

func TestGcpProjectsRefreshHelp(t *testing.T) {
rootCmd.SetArgs([]string{"gcp", "projects", "refresh", "--help"})
defer rootCmd.SetArgs([]string{})

err := rootCmd.Execute()
require.NoError(t, err)
}

func TestGcpProjectsRefreshRequiresArg(t *testing.T) {
// Refresh command should require exactly 1 argument
require.NotNil(t, gcpProjectsRefreshCmd.Args)

// Test that Args is set to ExactArgs(1)
err := gcpProjectsRefreshCmd.Args(gcpProjectsRefreshCmd, []string{})
require.Error(t, err, "refresh command should require an argument")

err = gcpProjectsRefreshCmd.Args(gcpProjectsRefreshCmd, []string{"project-name"})
require.NoError(t, err, "refresh command should accept one argument")

err = gcpProjectsRefreshCmd.Args(gcpProjectsRefreshCmd, []string{"project1", "project2"})
require.Error(t, err, "refresh command should not accept multiple arguments")
}

func TestGcpProjectsCommandHasAllSubcommands(t *testing.T) {
expectedCommands := []string{"import", "remove", "refresh"}

for _, expected := range expectedCommands {
found := false
for _, cmd := range gcpProjectsCmd.Commands() {
if cmd.Name() == expected {
found = true
break
}
}
require.True(t, found, "%s command not found under projects command", expected)
}
}

func TestProjectNameCompletion(t *testing.T) {
// Test that completion function exists and returns no file completion
require.NotNil(t, gcpProjectsRemoveCmd.ValidArgsFunction)
require.NotNil(t, gcpProjectsRefreshCmd.ValidArgsFunction)

// Test that completion doesn't return suggestions when args already provided
suggestions, directive := gcpProjectsRemoveCmd.ValidArgsFunction(gcpProjectsRemoveCmd, []string{"existing-arg"}, "")
require.Nil(t, suggestions)
require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive)
}
75 changes: 75 additions & 0 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,81 @@ func (c *Cache) Delete(resourceName string) error {
return nil
}

// DeleteProject removes a project and all its associated resources from the cache.
// This includes entries from the projects, instances, zones, and subnets tables.
func (c *Cache) DeleteProject(projectName string) error {
if c.isNoOp() {
return nil
}

if projectName == "" {
return nil
}

start := time.Now()
defer func() {
c.stats.recordOperation("DeleteProject", time.Since(start))
}()

tx, err := c.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}

defer func() {
if err != nil {
_ = tx.Rollback()
}
}()

tables := []struct {
name string
column string
}{
{"instances", "project"},
{"zones", "project"},
{"subnets", "project"},
{"projects", "name"},
}

for _, table := range tables {
query := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", table.name, table.column)
logSQL(query, projectName)

result, err := tx.Exec(query, projectName)
if err != nil {
return fmt.Errorf("failed to delete from %s: %w", table.name, err)
}

count, _ := result.RowsAffected()
if count > 0 {
logger.Log.Debugf("Deleted %d entries from %s for project %s", count, table.name, projectName)
}
}

if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}

logger.Log.Debugf("Deleted project from cache: %s", projectName)

return nil
}

// HasProject checks if a project exists in the cache.
func (c *Cache) HasProject(projectName string) bool {
if c.isNoOp() || projectName == "" {
return false
}

var timestamp int64
logSQL("getProject", projectName)

err := c.stmts.getProject.QueryRow(projectName).Scan(&timestamp)

return err == nil
}

// Clear removes all entries from the cache.
func (c *Cache) Clear() error {
if c.isNoOp() {
Expand Down
Loading