From 120c9d63d044796c893033d33f565dfb55051acd Mon Sep 17 00:00:00 2001 From: Amit2465 Date: Sun, 15 Mar 2026 18:29:51 +0530 Subject: [PATCH 1/2] feat(gcp): add Cloud Run service resource Add support for nuking GCP Cloud Run services using the generic resource pattern. Services are discovered per region and deleted sequentially with long-running operation polling. Safety: - Skip services in locations where Cloud Run is not enabled - Already-deleted services (404) are handled gracefully - Delete failures do not affect other services (SequentialDeleter) --- config/config.go | 4 +- config/config_test.go | 3 +- docs/supported-resources.md | 2 + gcp/resource_registry.go | 1 + gcp/resources/cloudrun.go | 32 +++++++ gcp/resources/cloudrun_service.go | 117 +++++++++++++++++++++++++ gcp/resources/cloudrun_service_test.go | 17 ++++ go.mod | 3 +- go.sum | 2 + 9 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 gcp/resources/cloudrun.go create mode 100644 gcp/resources/cloudrun_service.go create mode 100644 gcp/resources/cloudrun_service_test.go diff --git a/config/config.go b/config/config.go index 6c172e9b..5cfc3c8a 100644 --- a/config/config.go +++ b/config/config.go @@ -154,7 +154,8 @@ type Config struct { GCSBucket ResourceType `yaml:"GCSBucket"` CloudFunction ResourceType `yaml:"CloudFunction"` ArtifactRegistry ResourceType `yaml:"ArtifactRegistry"` - GcpPubSubTopic ResourceType `yaml:"GcpPubSubTopic"` + GcpPubSubTopic ResourceType `yaml:"GcpPubSubTopic"` + GcpCloudRunService ResourceType `yaml:"GcpCloudRunService"` } // allResourceTypes returns pointers to the embedded ResourceType for every @@ -295,6 +296,7 @@ func (c *Config) allResourceTypes() []*ResourceType { &c.CloudFunction, &c.ArtifactRegistry, &c.GcpPubSubTopic, + &c.GcpCloudRunService, } } diff --git a/config/config_test.go b/config/config_test.go index 0bdf28be..14db543e 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -141,7 +141,8 @@ func emptyConfig() *Config { GCSBucket: ResourceType{FilterRule{}, FilterRule{}, "", false}, CloudFunction: ResourceType{FilterRule{}, FilterRule{}, "", false}, ArtifactRegistry: ResourceType{FilterRule{}, FilterRule{}, "", false}, - GcpPubSubTopic: ResourceType{FilterRule{}, FilterRule{}, "", false}, + GcpPubSubTopic: ResourceType{FilterRule{}, FilterRule{}, "", false}, + GcpCloudRunService: ResourceType{FilterRule{}, FilterRule{}, "", false}, } } diff --git a/docs/supported-resources.md b/docs/supported-resources.md index 6b0cd5ec..ae927a82 100644 --- a/docs/supported-resources.md +++ b/docs/supported-resources.md @@ -282,6 +282,7 @@ cloud-nuke supports inspecting and deleting the following GCP resources. The **C | `cloud-function` | Cloud Functions (Gen2) | | `gcs-bucket` | Google Cloud Storage Bucket | | `gcp-pubsub-topic` | Pub/Sub Topic | +| `cloud-run-service` | Cloud Run Service | ### GCP Config Support Matrix @@ -291,6 +292,7 @@ cloud-nuke supports inspecting and deleting the following GCP resources. The **C | cloud-function | CloudFunction | ✓ | ✓ | ✓ | | gcs-bucket | GCSBucket | ✓ | ✓ | ✓ | | gcp-pubsub-topic | GcpPubSubTopic | ✓ | ✓ | ✓ | +| cloud-run-service | GcpCloudRunService | ✓ | ✓ | ✓ | ## IsNukable Permission Check diff --git a/gcp/resource_registry.go b/gcp/resource_registry.go index a6e7c449..5998951d 100644 --- a/gcp/resource_registry.go +++ b/gcp/resource_registry.go @@ -14,6 +14,7 @@ func getRegisteredGlobalResources() []GcpResource { resources.NewCloudFunctions(), resources.NewArtifactRegistryRepositories(), resources.NewPubSubTopics(), + resources.NewCloudRunServices(), } } diff --git a/gcp/resources/cloudrun.go b/gcp/resources/cloudrun.go new file mode 100644 index 00000000..c50b1bc8 --- /dev/null +++ b/gcp/resources/cloudrun.go @@ -0,0 +1,32 @@ +package resources + +import ( + "context" + "fmt" + + runv1 "google.golang.org/api/run/v1" +) + +// listCloudRunLocations returns all Cloud Run locations available for the given project +// using the Cloud Run Admin API v1 REST client. +func listCloudRunLocations(ctx context.Context, projectID string) ([]string, error) { + svc, err := runv1.NewService(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create Cloud Run v1 service: %w", err) + } + + var locations []string + parent := fmt.Sprintf("projects/%s", projectID) + + err = svc.Projects.Locations.List(parent).Pages(ctx, func(page *runv1.ListLocationsResponse) error { + for _, loc := range page.Locations { + locations = append(locations, loc.LocationId) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("error listing Cloud Run locations: %w", err) + } + + return locations, nil +} diff --git a/gcp/resources/cloudrun_service.go b/gcp/resources/cloudrun_service.go new file mode 100644 index 00000000..0bc666a4 --- /dev/null +++ b/gcp/resources/cloudrun_service.go @@ -0,0 +1,117 @@ +package resources + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + run "cloud.google.com/go/run/apiv2" + "cloud.google.com/go/run/apiv2/runpb" + "github.com/gruntwork-io/cloud-nuke/config" + "github.com/gruntwork-io/cloud-nuke/logging" + "github.com/gruntwork-io/cloud-nuke/resource" + "google.golang.org/api/iterator" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// NewCloudRunServices creates a new Cloud Run service resource using the generic resource pattern. +func NewCloudRunServices() GcpResource { + return NewGcpResource(&resource.Resource[*run.ServicesClient]{ + ResourceTypeName: "cloud-run-service", + BatchSize: DefaultBatchSize, + InitClient: WrapGcpInitClient(func(r *resource.Resource[*run.ServicesClient], cfg GcpConfig) { + r.Scope.ProjectID = cfg.ProjectID + client, err := run.NewServicesClient(context.Background()) + if err != nil { + panic(fmt.Sprintf("failed to create Cloud Run services client: %v", err)) + } + r.Client = client + }), + ConfigGetter: func(c config.Config) config.ResourceType { + return c.GcpCloudRunService + }, + Lister: listCloudRunServices, + Nuker: resource.SequentialDeleter(deleteCloudRunService), + }) +} + +// listCloudRunServices retrieves all Cloud Run services across all regions in the project +// that match the config filters. Locations are enumerated via listCloudRunLocations and +// queried individually — the Cloud Run API does not support the "locations/-" wildcard. +func listCloudRunServices(ctx context.Context, client *run.ServicesClient, scope resource.Scope, cfg config.ResourceType) ([]*string, error) { + locations, err := listCloudRunLocations(ctx, scope.ProjectID) + if err != nil { + return nil, err + } + + var result []*string + + for _, location := range locations { + parent := fmt.Sprintf("projects/%s/locations/%s", scope.ProjectID, location) + + it := client.ListServices(ctx, &runpb.ListServicesRequest{Parent: parent}) + for { + svc, err := it.Next() + if errors.Is(err, iterator.Done) { + break + } + if err != nil { + // Some locations may not have Cloud Run enabled — skip them rather than + // aborting the entire listing. + logging.Debugf("error listing Cloud Run services in %s, skipping location: %v", location, err) + break + } + + // Extract the short name for config filtering; the full resource name is + // retained as the delete identifier since the API requires it. + shortName := svc.Name[strings.LastIndex(svc.Name, "/")+1:] + + var resourceTime time.Time + if svc.GetCreateTime() != nil { + resourceTime = svc.GetCreateTime().AsTime() + } + + labels := svc.GetLabels() + if labels == nil { + labels = map[string]string{} + } + + resourceValue := config.ResourceValue{ + Name: &shortName, + Time: &resourceTime, + Tags: labels, + } + + if cfg.ShouldInclude(resourceValue) { + name := svc.Name + result = append(result, &name) + } + } + } + + return result, nil +} + +// deleteCloudRunService deletes a single Cloud Run service and waits for the operation to complete. +func deleteCloudRunService(ctx context.Context, client *run.ServicesClient, name *string) error { + serviceName := *name + + op, err := client.DeleteService(ctx, &runpb.DeleteServiceRequest{Name: serviceName}) + if err != nil { + if status.Code(err) == codes.NotFound { + logging.Debugf("Cloud Run service %s already deleted, skipping", serviceName) + return nil + } + return fmt.Errorf("error deleting Cloud Run service %s: %w", serviceName, err) + } + + if _, err := op.Wait(ctx); err != nil { + return fmt.Errorf("error waiting for Cloud Run service %s deletion: %w", serviceName, err) + } + + logging.Debugf("Deleted Cloud Run service: %s", serviceName) + return nil +} diff --git a/gcp/resources/cloudrun_service_test.go b/gcp/resources/cloudrun_service_test.go new file mode 100644 index 00000000..e6778afa --- /dev/null +++ b/gcp/resources/cloudrun_service_test.go @@ -0,0 +1,17 @@ +package resources + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCloudRunServices_ResourceName(t *testing.T) { + svc := NewCloudRunServices() + assert.Equal(t, "cloud-run-service", svc.ResourceName()) +} + +func TestCloudRunServices_MaxBatchSize(t *testing.T) { + svc := NewCloudRunServices() + assert.Equal(t, 50, svc.MaxBatchSize()) +} diff --git a/go.mod b/go.mod index 0370287a..66659a4f 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( cloud.google.com/go/artifactregistry v1.17.1 cloud.google.com/go/functions v1.19.7 cloud.google.com/go/pubsub v1.49.0 + cloud.google.com/go/run v1.10.0 cloud.google.com/go/storage v1.50.0 github.com/aws/aws-sdk-go-v2 v1.41.3 github.com/aws/aws-sdk-go-v2/config v1.29.5 @@ -76,6 +77,7 @@ require ( google.golang.org/genproto v0.0.0-20250603155806-513f23925822 google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a google.golang.org/grpc v1.74.2 + google.golang.org/protobuf v1.36.7 gopkg.in/yaml.v2 v2.4.0 ) @@ -153,6 +155,5 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect - google.golang.org/protobuf v1.36.7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9948682b..7b658f74 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7d cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= cloud.google.com/go/pubsub v1.49.0 h1:5054IkbslnrMCgA2MAEPcsN3Ky+AyMpEZcii/DoySPo= cloud.google.com/go/pubsub v1.49.0/go.mod h1:K1FswTWP+C1tI/nfi3HQecoVeFvL4HUOB1tdaNXKhUY= +cloud.google.com/go/run v1.10.0 h1:CDhz0PPzI/cVpmNFyHe3Yp21jNpiAqtkfRxuoLi+JU0= +cloud.google.com/go/run v1.10.0/go.mod h1:z7/ZidaHOCjdn5dV0eojRbD+p8RczMk3A7Qi2L+koHg= cloud.google.com/go/storage v1.50.0 h1:3TbVkzTooBvnZsk7WaAQfOsNrdoM8QHusXA1cpk6QJs= cloud.google.com/go/storage v1.50.0/go.mod h1:l7XeiD//vx5lfqE3RavfmU9yvk5Pp0Zhcv482poyafY= cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= From fd50d416f03dc3091232f75c7da0248733dfef4c Mon Sep 17 00:00:00 2001 From: Amit2465 Date: Sun, 15 Mar 2026 19:59:43 +0530 Subject: [PATCH 2/2] feat(gcp): add Cloud Run job resource Add support for nuking GCP Cloud Run jobs using the generic resource pattern. Jobs are discovered per region and deleted sequentially with long-running operation polling. Safety: - Skip jobs in locations where Cloud Run is not enabled - Already-deleted jobs (404) are handled gracefully - Delete failures do not affect other jobs (SequentialDeleter) --- config/config.go | 2 + config/config_test.go | 1 + docs/supported-resources.md | 2 + gcp/resource_registry.go | 1 + gcp/resources/cloudrun_job.go | 118 +++++++++++++++++++++++++++++ gcp/resources/cloudrun_job_test.go | 17 +++++ 6 files changed, 141 insertions(+) create mode 100644 gcp/resources/cloudrun_job.go create mode 100644 gcp/resources/cloudrun_job_test.go diff --git a/config/config.go b/config/config.go index 5cfc3c8a..4bb1bcec 100644 --- a/config/config.go +++ b/config/config.go @@ -156,6 +156,7 @@ type Config struct { ArtifactRegistry ResourceType `yaml:"ArtifactRegistry"` GcpPubSubTopic ResourceType `yaml:"GcpPubSubTopic"` GcpCloudRunService ResourceType `yaml:"GcpCloudRunService"` + GcpCloudRunJob ResourceType `yaml:"GcpCloudRunJob"` } // allResourceTypes returns pointers to the embedded ResourceType for every @@ -297,6 +298,7 @@ func (c *Config) allResourceTypes() []*ResourceType { &c.ArtifactRegistry, &c.GcpPubSubTopic, &c.GcpCloudRunService, + &c.GcpCloudRunJob, } } diff --git a/config/config_test.go b/config/config_test.go index 14db543e..f61b4608 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -143,6 +143,7 @@ func emptyConfig() *Config { ArtifactRegistry: ResourceType{FilterRule{}, FilterRule{}, "", false}, GcpPubSubTopic: ResourceType{FilterRule{}, FilterRule{}, "", false}, GcpCloudRunService: ResourceType{FilterRule{}, FilterRule{}, "", false}, + GcpCloudRunJob: ResourceType{FilterRule{}, FilterRule{}, "", false}, } } diff --git a/docs/supported-resources.md b/docs/supported-resources.md index ae927a82..00c0d7df 100644 --- a/docs/supported-resources.md +++ b/docs/supported-resources.md @@ -283,6 +283,7 @@ cloud-nuke supports inspecting and deleting the following GCP resources. The **C | `gcs-bucket` | Google Cloud Storage Bucket | | `gcp-pubsub-topic` | Pub/Sub Topic | | `cloud-run-service` | Cloud Run Service | +| `cloud-run-job` | Cloud Run Job | ### GCP Config Support Matrix @@ -293,6 +294,7 @@ cloud-nuke supports inspecting and deleting the following GCP resources. The **C | gcs-bucket | GCSBucket | ✓ | ✓ | ✓ | | gcp-pubsub-topic | GcpPubSubTopic | ✓ | ✓ | ✓ | | cloud-run-service | GcpCloudRunService | ✓ | ✓ | ✓ | +| cloud-run-job | GcpCloudRunJob | ✓ | ✓ | ✓ | ## IsNukable Permission Check diff --git a/gcp/resource_registry.go b/gcp/resource_registry.go index 5998951d..43fad5e2 100644 --- a/gcp/resource_registry.go +++ b/gcp/resource_registry.go @@ -15,6 +15,7 @@ func getRegisteredGlobalResources() []GcpResource { resources.NewArtifactRegistryRepositories(), resources.NewPubSubTopics(), resources.NewCloudRunServices(), + resources.NewCloudRunJobs(), } } diff --git a/gcp/resources/cloudrun_job.go b/gcp/resources/cloudrun_job.go new file mode 100644 index 00000000..3a5dca99 --- /dev/null +++ b/gcp/resources/cloudrun_job.go @@ -0,0 +1,118 @@ +package resources + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + run "cloud.google.com/go/run/apiv2" + "cloud.google.com/go/run/apiv2/runpb" + "github.com/gruntwork-io/cloud-nuke/config" + "github.com/gruntwork-io/cloud-nuke/logging" + "github.com/gruntwork-io/cloud-nuke/resource" + "google.golang.org/api/iterator" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// NewCloudRunJobs creates a new Cloud Run job resource using the generic resource pattern. +func NewCloudRunJobs() GcpResource { + return NewGcpResource(&resource.Resource[*run.JobsClient]{ + ResourceTypeName: "cloud-run-job", + BatchSize: DefaultBatchSize, + InitClient: WrapGcpInitClient(func(r *resource.Resource[*run.JobsClient], cfg GcpConfig) { + r.Scope.ProjectID = cfg.ProjectID + client, err := run.NewJobsClient(context.Background()) + if err != nil { + panic(fmt.Sprintf("failed to create Cloud Run jobs client: %v", err)) + } + r.Client = client + }), + ConfigGetter: func(c config.Config) config.ResourceType { + return c.GcpCloudRunJob + }, + Lister: listCloudRunJobs, + Nuker: resource.SequentialDeleter(deleteCloudRunJob), + }) +} + +// listCloudRunJobs retrieves all Cloud Run jobs across all regions in the project +// that match the config filters. Locations are enumerated via listCloudRunLocations and +// queried individually — the Cloud Run API does not support the "locations/-" wildcard. +func listCloudRunJobs(ctx context.Context, client *run.JobsClient, scope resource.Scope, cfg config.ResourceType) ([]*string, error) { + locations, err := listCloudRunLocations(ctx, scope.ProjectID) + if err != nil { + return nil, err + } + + var result []*string + + for _, location := range locations { + parent := fmt.Sprintf("projects/%s/locations/%s", scope.ProjectID, location) + + it := client.ListJobs(ctx, &runpb.ListJobsRequest{Parent: parent}) + for { + job, err := it.Next() + if errors.Is(err, iterator.Done) { + break + } + if err != nil { + // Some locations may not have Cloud Run enabled — skip them rather than + // aborting the entire listing. + logging.Debugf("error listing Cloud Run jobs in %s, skipping location: %v", location, err) + break + } + + // Extract the short name for config filtering; the full resource name is + // retained as the delete identifier since the API requires it. + shortName := job.Name[strings.LastIndex(job.Name, "/")+1:] + + var resourceTime time.Time + if job.GetCreateTime() != nil { + resourceTime = job.GetCreateTime().AsTime() + } + + labels := job.GetLabels() + if labels == nil { + labels = map[string]string{} + } + + resourceValue := config.ResourceValue{ + Name: &shortName, + Time: &resourceTime, + Tags: labels, + } + + if cfg.ShouldInclude(resourceValue) { + name := job.Name + result = append(result, &name) + } + } + } + + return result, nil +} + +// deleteCloudRunJob deletes a single Cloud Run job and waits for the operation to complete. +// Any active executions are automatically cancelled by the API before the job is deleted. +func deleteCloudRunJob(ctx context.Context, client *run.JobsClient, name *string) error { + jobName := *name + + op, err := client.DeleteJob(ctx, &runpb.DeleteJobRequest{Name: jobName}) + if err != nil { + if status.Code(err) == codes.NotFound { + logging.Debugf("Cloud Run job %s already deleted, skipping", jobName) + return nil + } + return fmt.Errorf("error deleting Cloud Run job %s: %w", jobName, err) + } + + if _, err := op.Wait(ctx); err != nil { + return fmt.Errorf("error waiting for Cloud Run job %s deletion: %w", jobName, err) + } + + logging.Debugf("Deleted Cloud Run job: %s", jobName) + return nil +} diff --git a/gcp/resources/cloudrun_job_test.go b/gcp/resources/cloudrun_job_test.go new file mode 100644 index 00000000..d7f1184f --- /dev/null +++ b/gcp/resources/cloudrun_job_test.go @@ -0,0 +1,17 @@ +package resources + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCloudRunJobs_ResourceName(t *testing.T) { + j := NewCloudRunJobs() + assert.Equal(t, "cloud-run-job", j.ResourceName()) +} + +func TestCloudRunJobs_MaxBatchSize(t *testing.T) { + j := NewCloudRunJobs() + assert.Equal(t, 50, j.MaxBatchSize()) +}