Skip to content
Draft
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 commands/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ func CreateCli(version string) *cli.App {
},
),
},
MCPCommand(version),
}

return app
Expand Down
54 changes: 30 additions & 24 deletions commands/gcp_commands.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
package commands

import (
"time"

"github.com/gruntwork-io/cloud-nuke/config"
"github.com/gruntwork-io/cloud-nuke/gcp"
"github.com/gruntwork-io/cloud-nuke/logging"
"github.com/gruntwork-io/cloud-nuke/renderers"
"github.com/gruntwork-io/cloud-nuke/reporting"
"github.com/gruntwork-io/cloud-nuke/telemetry"
Expand All @@ -30,11 +27,6 @@ func gcpNuke(c *cli.Context) error {
return handleListGcpResourceTypes()
}

// --resource-type and --exclude-resource-type are not yet supported for GCP
if c.IsSet(FlagResourceType) || c.IsSet(FlagExcludeResourceType) {
logging.Warn("--resource-type and --exclude-resource-type are not yet supported for GCP commands; all GCP resource types will be processed")
}

// Parse and set log level
if err := parseLogLevel(c); err != nil {
return err
Expand All @@ -46,7 +38,11 @@ func gcpNuke(c *cli.Context) error {
return errors.WithStackTrace(err)
}

projectID := c.String(FlagProjectID)
query := &gcp.Query{
ProjectID: c.String(FlagProjectID),
ResourceTypes: c.StringSlice(FlagResourceType),
ExcludeResourceTypes: c.StringSlice(FlagExcludeResourceType),
}

// Apply timeout to config
if err := parseAndApplyTimeout(c, &configObj); err != nil {
Expand All @@ -62,7 +58,11 @@ func gcpNuke(c *cli.Context) error {
outputFormat := c.String(FlagOutputFormat)
outputFile := c.String(FlagOutputFile)

return gcpNukeHelper(c, configObj, projectID, outputFormat, outputFile)
if err := query.Validate(); err != nil {
return err
}

return gcpNukeHelper(c, configObj, query, outputFormat, outputFile)
}

// gcpInspect is the command handler for non-destructive inspection of GCP resources.
Expand All @@ -78,17 +78,17 @@ func gcpInspect(c *cli.Context) error {
return handleListGcpResourceTypes()
}

// --resource-type and --exclude-resource-type are not yet supported for GCP
if c.IsSet(FlagResourceType) || c.IsSet(FlagExcludeResourceType) {
logging.Warn("--resource-type and --exclude-resource-type are not yet supported for GCP commands; all GCP resource types will be processed")
}

// Parse and set log level
if err := parseLogLevel(c); err != nil {
return err
}

projectID := c.String(FlagProjectID)
query := &gcp.Query{
ProjectID: c.String(FlagProjectID),
ResourceTypes: c.StringSlice(FlagResourceType),
ExcludeResourceTypes: c.StringSlice(FlagExcludeResourceType),
}

configObj := config.Config{}

// Apply time filters to config
Expand All @@ -100,8 +100,12 @@ func gcpInspect(c *cli.Context) error {
outputFormat := c.String(FlagOutputFormat)
outputFile := c.String(FlagOutputFile)

if err := query.Validate(); err != nil {
return err
}

// Retrieve and display resources without deleting them
_, err := handleGetGcpResourcesWithFormat(c, configObj, projectID, outputFormat, outputFile)
_, err := handleGetGcpResourcesWithFormat(c, configObj, query, outputFormat, outputFile)
return err
}

Expand All @@ -110,16 +114,16 @@ func gcpInspect(c *cli.Context) error {

// gcpNukeHelper is the core logic for nuking GCP resources.
// It retrieves resources, confirms deletion with the user, and executes the nuke operation.
func gcpNukeHelper(c *cli.Context, configObj config.Config, projectID string, outputFormat string, outputFile string) error {
func gcpNukeHelper(c *cli.Context, configObj config.Config, query *gcp.Query, outputFormat string, outputFile string) error {
// Setup reporting - cleanup calls Complete() and closes writer
collector, cleanup, err := setupGcpReporting(outputFormat, outputFile, projectID)
collector, cleanup, err := setupGcpReporting(outputFormat, outputFile, query.ProjectID)
if err != nil {
return err
}
defer cleanup()

// Retrieve all matching resources (emits ResourceFound events via collector)
account, err := gcp.GetAllResources(projectID, configObj, time.Time{}, time.Time{}, collector)
account, err := gcp.GetAllResources(c.Context, query, configObj, collector)
if err != nil {
telemetry.TrackEvent(commonTelemetry.EventContext{
EventName: "Error getting resources",
Expand All @@ -138,25 +142,27 @@ func gcpNukeHelper(c *cli.Context, configObj config.Config, projectID string, ou

// Execute the nuke operation if confirmed
if shouldProceed {
gcp.NukeAllResources(account, configObj, collector)
if err := gcp.NukeAllResources(c.Context, account, query.Regions, collector); err != nil {
return err
}
}

return nil
}

// handleGetGcpResourcesWithFormat retrieves all GCP resources matching the filters and renders them
// in the specified output format. This is used for inspect operations only.
func handleGetGcpResourcesWithFormat(c *cli.Context, configObj config.Config, projectID string, outputFormat string, outputFile string) (
func handleGetGcpResourcesWithFormat(c *cli.Context, configObj config.Config, query *gcp.Query, outputFormat string, outputFile string) (
*gcp.GcpProjectResources, error) {
// Setup reporting - cleanup calls Complete() and closes writer
collector, cleanup, err := setupGcpReporting(outputFormat, outputFile, projectID)
collector, cleanup, err := setupGcpReporting(outputFormat, outputFile, query.ProjectID)
if err != nil {
return nil, err
}
defer cleanup()

// Retrieve all resources matching the filters (emits ResourceFound events via collector)
accountResources, err := gcp.GetAllResources(projectID, configObj, time.Time{}, time.Time{}, collector)
accountResources, err := gcp.GetAllResources(c.Context, query, configObj, collector)
if err != nil {
telemetry.TrackEvent(commonTelemetry.EventContext{
EventName: "Error inspecting resources",
Expand Down
79 changes: 79 additions & 0 deletions commands/mcp_command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package commands

import (
"github.com/gruntwork-io/cloud-nuke/logging"
"github.com/gruntwork-io/cloud-nuke/mcp"
"github.com/gruntwork-io/go-commons/errors"
"github.com/urfave/cli/v2"
)

// MCP flag names
const (
FlagMCPReadOnly = "read-only"
FlagMCPAllowedRegions = "allowed-regions"
FlagMCPAllowedResourceTypes = "allowed-resource-types"
FlagMCPAllowedProjects = "allowed-projects"
FlagMCPMaxResourcesPerNuke = "max-resources-per-nuke"
)

// MCPCommand returns the mcp-server CLI command definition.
func MCPCommand(version string) *cli.Command {
return &cli.Command{
Name: "mcp-server",
Usage: "Start an MCP server over stdio for AI agent integration",
Action: errors.WithPanicHandling(func(c *cli.Context) error {
// Apply log level from flag/env
if logLevel := c.String(FlagLogLevel); logLevel != "" {
if err := logging.ParseLogLevel(logLevel); err != nil {
return errors.WithStackTrace(err)
}
}

cfg := mcp.DefaultServerConfig()
cfg.ReadOnly = c.Bool(FlagMCPReadOnly)
cfg.AllowedRegions = c.StringSlice(FlagMCPAllowedRegions)
cfg.AllowedResourceTypes = c.StringSlice(FlagMCPAllowedResourceTypes)
cfg.AllowedProjects = c.StringSlice(FlagMCPAllowedProjects)

if c.IsSet(FlagMCPMaxResourcesPerNuke) {
v := c.Int(FlagMCPMaxResourcesPerNuke)
if v < 1 {
v = 1
}
cfg.MaxResourcesPerNuke = v
}

s := mcp.NewServer(version, cfg)
return s.Serve()
}),
Flags: []cli.Flag{
&cli.BoolFlag{
Name: FlagMCPReadOnly,
Usage: "Disable nuke operations (only allow inspect and list)",
},
&cli.StringSliceFlag{
Name: FlagMCPAllowedRegions,
Usage: "Whitelist of allowed AWS regions. If empty, all regions are allowed.",
},
&cli.StringSliceFlag{
Name: FlagMCPAllowedResourceTypes,
Usage: "Whitelist of allowed resource types. If empty, all types are allowed.",
},
&cli.StringSliceFlag{
Name: FlagMCPAllowedProjects,
Usage: "Whitelist of allowed GCP project IDs. If empty, all projects are allowed.",
},
&cli.IntFlag{
Name: FlagMCPMaxResourcesPerNuke,
Value: mcp.DefaultMaxResourcesPerNuke,
Usage: "Maximum number of resources that can be nuked in a single operation (minimum: 1)",
},
&cli.StringFlag{
Name: FlagLogLevel,
Value: "warn",
Usage: "Set log level (logs go to stderr in MCP mode)",
EnvVars: []string{"LOG_LEVEL"},
},
},
}
}
67 changes: 67 additions & 0 deletions gcp/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package gcp

import (
"errors"

"google.golang.org/api/googleapi"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// isServiceDisabledError checks whether the error (or any wrapped cause) is a
// gRPC SERVICE_DISABLED error from googleapis.com. It walks the error chain
// because by the time errors reach this layer they have been wrapped by
// intermediate callers (resource listers, resource.go, etc.) and
// status.FromError only inspects the outermost error.
func isServiceDisabledError(err error) bool {
for current := err; current != nil; current = errors.Unwrap(current) {
s, ok := status.FromError(current)
if !ok || s.Code() == codes.OK {
continue
}
for _, detail := range s.Details() {
if info, ok := detail.(*errdetails.ErrorInfo); ok {
if info.Reason == "SERVICE_DISABLED" && info.Domain == "googleapis.com" {
return true
}
}
}
}
return false
}

// isQuotaExhaustedError checks whether the error represents a GCP quota
// exceeded / rate-limit error using structured gRPC and HTTP error types
// instead of fragile string matching.
func isQuotaExhaustedError(err error) bool {
// Check gRPC status code (ResourceExhausted = quota/rate-limit)
for current := err; current != nil; current = errors.Unwrap(current) {
s, ok := status.FromError(current)
if !ok || s.Code() == codes.OK {
continue
}
if s.Code() == codes.ResourceExhausted {
return true
}
}

// Check Google HTTP API errors:
// - 429 (Too Many Requests) is the standard rate-limit response
// - 403 with rateLimitExceeded/userRateLimitExceeded is also used for quota errors
var apiErr *googleapi.Error
if errors.As(err, &apiErr) {
if apiErr.Code == 429 {
return true
}
if apiErr.Code == 403 {
for _, e := range apiErr.Errors {
if e.Reason == "rateLimitExceeded" || e.Reason == "userRateLimitExceeded" {
return true
}
}
}
}

return false
}
71 changes: 71 additions & 0 deletions gcp/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package gcp

import (
"fmt"
"testing"

"github.com/stretchr/testify/require"
"google.golang.org/api/googleapi"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

func makeServiceDisabledErr() error {
st := status.New(codes.PermissionDenied, "Cloud Functions API has not been used in project 123 before or it is disabled.")
st, _ = st.WithDetails(&errdetails.ErrorInfo{
Reason: "SERVICE_DISABLED",
Domain: "googleapis.com",
Metadata: map[string]string{
"consumer": "projects/123",
"service": "cloudfunctions.googleapis.com",
},
})
return st.Err()
}

func TestIsServiceDisabledError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want bool
}{
{"direct SERVICE_DISABLED", makeServiceDisabledErr(), true},
{"wrapped SERVICE_DISABLED", fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", makeServiceDisabledErr())), true},
{"other gRPC error", status.New(codes.Internal, "fail").Err(), false},
{"plain error", fmt.Errorf("random"), false},
{"nil", nil, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, isServiceDisabledError(tc.err))
})
}
}

func TestIsQuotaExhaustedError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want bool
}{
{"gRPC ResourceExhausted", status.New(codes.ResourceExhausted, "quota").Err(), true},
{"wrapped gRPC ResourceExhausted", fmt.Errorf("nuke: %w", status.New(codes.ResourceExhausted, "quota").Err()), true},
{"HTTP 429", &googleapi.Error{Code: 429}, true},
{"wrapped HTTP 429", fmt.Errorf("fail: %w", &googleapi.Error{Code: 429}), true},
{"HTTP 403 rateLimitExceeded", &googleapi.Error{Code: 403, Errors: []googleapi.ErrorItem{{Reason: "rateLimitExceeded"}}}, true},
{"HTTP 403 userRateLimitExceeded", &googleapi.Error{Code: 403, Errors: []googleapi.ErrorItem{{Reason: "userRateLimitExceeded"}}}, true},
{"HTTP 403 other reason", &googleapi.Error{Code: 403, Errors: []googleapi.ErrorItem{{Reason: "forbidden"}}}, false},
{"other gRPC error", status.New(codes.Internal, "fail").Err(), false},
{"HTTP 500", &googleapi.Error{Code: 500}, false},
{"plain error", fmt.Errorf("random"), false},
{"nil", nil, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, isQuotaExhaustedError(tc.err))
})
}
}
Loading