Skip to content
Open
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
6 changes: 5 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ 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"`
GcpCloudRunJob ResourceType `yaml:"GcpCloudRunJob"`
}

// allResourceTypes returns pointers to the embedded ResourceType for every
Expand Down Expand Up @@ -295,6 +297,8 @@ func (c *Config) allResourceTypes() []*ResourceType {
&c.CloudFunction,
&c.ArtifactRegistry,
&c.GcpPubSubTopic,
&c.GcpCloudRunService,
&c.GcpCloudRunJob,
}
}

Expand Down
4 changes: 3 additions & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@ 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},
GcpCloudRunJob: ResourceType{FilterRule{}, FilterRule{}, "", false},
}
}

Expand Down
4 changes: 4 additions & 0 deletions docs/supported-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ 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 |
| `cloud-run-job` | Cloud Run Job |

### GCP Config Support Matrix

Expand All @@ -291,6 +293,8 @@ 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 | ✓ | ✓ | ✓ |
| cloud-run-job | GcpCloudRunJob | ✓ | ✓ | ✓ |

## IsNukable Permission Check

Expand Down
2 changes: 2 additions & 0 deletions gcp/resource_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ func getRegisteredGlobalResources() []GcpResource {
resources.NewCloudFunctions(),
resources.NewArtifactRegistryRepositories(),
resources.NewPubSubTopics(),
resources.NewCloudRunServices(),
resources.NewCloudRunJobs(),
}
}

Expand Down
32 changes: 32 additions & 0 deletions gcp/resources/cloudrun.go
Original file line number Diff line number Diff line change
@@ -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
}
118 changes: 118 additions & 0 deletions gcp/resources/cloudrun_job.go
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions gcp/resources/cloudrun_job_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
117 changes: 117 additions & 0 deletions gcp/resources/cloudrun_service.go
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions gcp/resources/cloudrun_service_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -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
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down