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
10 changes: 6 additions & 4 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,11 @@ type Config struct {
VPCPeeringConnection ResourceType `yaml:"VPCPeeringConnection"`

// GCP Resources
GCSBucket ResourceType `yaml:"GCSBucket"`
CloudFunction ResourceType `yaml:"CloudFunction"`
ArtifactRegistry ResourceType `yaml:"ArtifactRegistry"`
GcpPubSubTopic ResourceType `yaml:"GcpPubSubTopic"`
GCSBucket ResourceType `yaml:"GCSBucket"`
CloudFunction ResourceType `yaml:"CloudFunction"`
ArtifactRegistry ResourceType `yaml:"ArtifactRegistry"`
GcpPubSubTopic ResourceType `yaml:"GcpPubSubTopic"`
GcpCloudSQLInstance ResourceType `yaml:"GcpCloudSQLInstance"`
}

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

Expand Down
9 changes: 5 additions & 4 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,11 @@ func emptyConfig() *Config {
VPCPeeringConnection: ResourceType{FilterRule{}, FilterRule{}, "", false},

// GCP Resources
GCSBucket: ResourceType{FilterRule{}, FilterRule{}, "", false},
CloudFunction: ResourceType{FilterRule{}, FilterRule{}, "", false},
ArtifactRegistry: ResourceType{FilterRule{}, FilterRule{}, "", false},
GcpPubSubTopic: ResourceType{FilterRule{}, FilterRule{}, "", false},
GCSBucket: ResourceType{FilterRule{}, FilterRule{}, "", false},
CloudFunction: ResourceType{FilterRule{}, FilterRule{}, "", false},
ArtifactRegistry: ResourceType{FilterRule{}, FilterRule{}, "", false},
GcpPubSubTopic: ResourceType{FilterRule{}, FilterRule{}, "", false},
GcpCloudSQLInstance: ResourceType{FilterRule{}, FilterRule{}, "", false},
}
}

Expand Down
2 changes: 2 additions & 0 deletions docs/supported-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ cloud-nuke supports inspecting and deleting the following GCP resources. The **C
|---|---|
| `artifact-registry` | Artifact Registry Repository |
| `cloud-function` | Cloud Functions (Gen2) |
| `cloud-sql-instance` | Cloud SQL Instance |
| `gcs-bucket` | Google Cloud Storage Bucket |
| `gcp-pubsub-topic` | Pub/Sub Topic |

Expand All @@ -289,6 +290,7 @@ cloud-nuke supports inspecting and deleting the following GCP resources. The **C
|---|---|---|---|---|
| artifact-registry | ArtifactRegistry | ✓ | ✓ | ✓ |
| cloud-function | CloudFunction | ✓ | ✓ | ✓ |
| cloud-sql-instance | GcpCloudSQLInstance | ✓ | ✓ | ✓ |
| gcs-bucket | GCSBucket | ✓ | ✓ | ✓ |
| gcp-pubsub-topic | GcpPubSubTopic | ✓ | ✓ | ✓ |

Expand Down
1 change: 1 addition & 0 deletions gcp/resource_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func getRegisteredGlobalResources() []GcpResource {
resources.NewCloudFunctions(),
resources.NewArtifactRegistryRepositories(),
resources.NewPubSubTopics(),
resources.NewCloudSQLInstances(),
}
}

Expand Down
195 changes: 195 additions & 0 deletions gcp/resources/cloudsql.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package resources

import (
"context"
"errors"
"fmt"
"strings"
"time"

"github.com/gruntwork-io/cloud-nuke/config"
"github.com/gruntwork-io/cloud-nuke/logging"
"github.com/gruntwork-io/cloud-nuke/resource"
goerrors "github.com/gruntwork-io/go-commons/errors"
"google.golang.org/api/googleapi"
sqladmin "google.golang.org/api/sqladmin/v1beta4"
)

// CloudSQLInstancesAPI defines the interface for Cloud SQL instance operations.
type CloudSQLInstancesAPI interface {
ListInstancePages(ctx context.Context, projectID string, fn func(*sqladmin.InstancesListResponse) error) error
DeleteInstance(ctx context.Context, project, name string) (*sqladmin.Operation, error)
GetOperation(ctx context.Context, project, opName string) (*sqladmin.Operation, error)
}

// cloudSQLClient wraps *sqladmin.Service to implement CloudSQLInstancesAPI.
type cloudSQLClient struct{ svc *sqladmin.Service }

func (c *cloudSQLClient) ListInstancePages(ctx context.Context, projectID string, fn func(*sqladmin.InstancesListResponse) error) error {
return c.svc.Instances.List(projectID).Context(ctx).Pages(ctx, fn)
}

func (c *cloudSQLClient) DeleteInstance(ctx context.Context, project, name string) (*sqladmin.Operation, error) {
return c.svc.Instances.Delete(project, name).Context(ctx).Do()
}

func (c *cloudSQLClient) GetOperation(ctx context.Context, project, opName string) (*sqladmin.Operation, error) {
return c.svc.Operations.Get(project, opName).Context(ctx).Do()
}

// NewCloudSQLInstances creates a new Cloud SQL instance resource using the generic resource pattern.
func NewCloudSQLInstances() GcpResource {
return NewGcpResource(&resource.Resource[CloudSQLInstancesAPI]{
ResourceTypeName: "cloud-sql-instance",
BatchSize: DefaultBatchSize,
InitClient: WrapGcpInitClient(func(r *resource.Resource[CloudSQLInstancesAPI], cfg GcpConfig) {
r.Scope.ProjectID = cfg.ProjectID
svc, err := sqladmin.NewService(context.Background())
if err != nil {
panic(fmt.Sprintf("failed to create Cloud SQL client: %v", err))
}
r.Client = &cloudSQLClient{svc: svc}
}),
ConfigGetter: func(c config.Config) config.ResourceType {
return c.GcpCloudSQLInstance
},
Lister: listCloudSQLInstances,
Nuker: resource.SequentialDeleter(deleteCloudSQLInstance),
})
}

// instanceSkipStates contains Cloud SQL instance states in which deletion is not possible.
// RUNNABLE, STOPPED, FAILED, and SUSPENDED instances can all be deleted.
var instanceSkipStates = map[string]bool{
"PENDING_CREATE": true, // creation in progress — API rejects delete
"PENDING_DELETE": true, // already being deleted
"MAINTENANCE": true, // instance is offline for maintenance
"ONLINE_MAINTENANCE": true, // deprecated, but guard against it
"REPAIRING": true, // read pool node being repaired — not safe to delete
}

// listCloudSQLInstances retrieves all Cloud SQL instances in the project that match the config filters.
//
// Read replicas and read pool instances are returned before primary instances to ensure
// they are deleted first — the API rejects deletion of a primary that still has replicas.
func listCloudSQLInstances(ctx context.Context, client CloudSQLInstancesAPI, scope resource.Scope, cfg config.ResourceType) ([]*string, error) {
var replicas []*string
var primaries []*string

err := client.ListInstancePages(ctx, scope.ProjectID, func(page *sqladmin.InstancesListResponse) error {
for _, instance := range page.Items {
// Skip instances not managed by Cloud SQL (external or on-premises).
if instance.BackendType == "EXTERNAL" || instance.InstanceType == "ON_PREMISES_INSTANCE" {
logging.Debugf("Skipping externally managed Cloud SQL instance: %s", instance.Name)
continue
}

// Skip instances in states where deletion is not currently possible.
if instanceSkipStates[instance.State] {
logging.Warnf("Skipping Cloud SQL instance %s: instance is in state %s", instance.Name, instance.State)
continue
}

// Skip instances with deletion protection enabled.
if instance.Settings != nil && instance.Settings.DeletionProtectionEnabled {
logging.Warnf("Skipping Cloud SQL instance %s: deletion protection is enabled", instance.Name)
continue
}

createdAt, err := time.Parse(time.RFC3339, instance.CreateTime)
if err != nil {
logging.Warnf("Skipping Cloud SQL instance %s: failed to parse creation timestamp: %v", instance.Name, err)
continue
}

var labels map[string]string
if instance.Settings != nil {
labels = instance.Settings.UserLabels
}
if labels == nil {
labels = map[string]string{}
}

resourceValue := config.ResourceValue{
Name: &instance.Name,
Time: &createdAt,
Tags: labels,
}

if cfg.ShouldInclude(resourceValue) {
id := fmt.Sprintf("%s/%s", scope.ProjectID, instance.Name)
// Replicas and read pool nodes must be deleted before their primary.
if instance.InstanceType == "READ_REPLICA_INSTANCE" || instance.InstanceType == "READ_POOL_INSTANCE" {
replicas = append(replicas, &id)
} else {
primaries = append(primaries, &id)
}
}
}
return nil
})
if err != nil {
return nil, goerrors.WithStackTrace(fmt.Errorf("error listing Cloud SQL instances: %w", err))
}

// Replicas are listed first to ensure correct deletion order.
return append(replicas, primaries...), nil
}

// deleteCloudSQLInstance deletes a single Cloud SQL instance and waits for the operation to complete.
func deleteCloudSQLInstance(ctx context.Context, client CloudSQLInstancesAPI, id *string) error {
project, name, err := parseCloudSQLInstanceID(*id)
if err != nil {
return goerrors.WithStackTrace(err)
}

op, err := client.DeleteInstance(ctx, project, name)
if err != nil {
var apiErr *googleapi.Error
if errors.As(err, &apiErr) && apiErr.Code == 404 {
logging.Debugf("Cloud SQL instance %s already deleted, skipping", *id)
return nil
}
return goerrors.WithStackTrace(fmt.Errorf("error deleting Cloud SQL instance %s: %w", *id, err))
}

if err := waitForCloudSQLOperation(ctx, client, project, op.Name); err != nil {
return goerrors.WithStackTrace(fmt.Errorf("error waiting for deletion of Cloud SQL instance %s: %w", *id, err))
}

logging.Debugf("Deleted Cloud SQL instance: %s", *id)
return nil
}

// waitForCloudSQLOperation polls a Cloud SQL long-running operation until it completes
// or the context is cancelled.
func waitForCloudSQLOperation(ctx context.Context, client CloudSQLInstancesAPI, project, opName string) error {
for {
op, err := client.GetOperation(ctx, project, opName)
if err != nil {
return goerrors.WithStackTrace(fmt.Errorf("error polling Cloud SQL operation %s: %w", opName, err))
}

if op.Status == "DONE" {
if op.Error != nil && len(op.Error.Errors) > 0 {
return goerrors.WithStackTrace(fmt.Errorf("cloud SQL operation %s failed: %s", opName, op.Error.Errors[0].Message))
}
return nil
}

select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return goerrors.WithStackTrace(ctx.Err())
}
}
}

// parseCloudSQLInstanceID parses a composite ID of the form "project/instance".
func parseCloudSQLInstanceID(id string) (project, name string, err error) {
parts := strings.SplitN(id, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", goerrors.WithStackTrace(fmt.Errorf("invalid Cloud SQL instance ID %q: expected format project/instance", id))
}
return parts[0], parts[1], nil
}
81 changes: 81 additions & 0 deletions gcp/resources/cloudsql_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package resources

import (
"context"
"testing"
"time"

"github.com/gruntwork-io/cloud-nuke/config"
"github.com/gruntwork-io/cloud-nuke/resource"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sqladmin "google.golang.org/api/sqladmin/v1beta4"
)

// mockCloudSQLClient implements CloudSQLInstancesAPI for testing.
type mockCloudSQLClient struct {
instances []*sqladmin.DatabaseInstance
listErr error
}

func (m *mockCloudSQLClient) ListInstancePages(_ context.Context, _ string, fn func(*sqladmin.InstancesListResponse) error) error {
if m.listErr != nil {
return m.listErr
}
return fn(&sqladmin.InstancesListResponse{Items: m.instances})
}

func (m *mockCloudSQLClient) DeleteInstance(_ context.Context, _, _ string) (*sqladmin.Operation, error) {
return &sqladmin.Operation{Name: "op-1", Status: "DONE"}, nil
}

func (m *mockCloudSQLClient) GetOperation(_ context.Context, _, _ string) (*sqladmin.Operation, error) {
return &sqladmin.Operation{Status: "DONE"}, nil
}

func TestCloudSQLInstances_ResourceName(t *testing.T) {
t.Parallel()
cs := NewCloudSQLInstances()
assert.Equal(t, "cloud-sql-instance", cs.ResourceName())
}

func TestCloudSQLInstances_MaxBatchSize(t *testing.T) {
t.Parallel()
cs := NewCloudSQLInstances()
assert.Equal(t, 50, cs.MaxBatchSize())
}

// TestCloudSQLInstances_ReplicaOrdering verifies that read replicas and read pool
// instances are always returned before primary instances. The Cloud SQL API rejects
// deletion of a primary that still has live replicas, so this ordering is a
// correctness invariant.
func TestCloudSQLInstances_ReplicaOrdering(t *testing.T) {
t.Parallel()

now := time.Now().UTC().Format(time.RFC3339)
mock := &mockCloudSQLClient{
instances: []*sqladmin.DatabaseInstance{
{Name: "primary-1", InstanceType: "CLOUD_SQL_INSTANCE", CreateTime: now, State: "RUNNABLE"},
{Name: "replica-1", InstanceType: "READ_REPLICA_INSTANCE", CreateTime: now, State: "RUNNABLE"},
{Name: "primary-2", InstanceType: "CLOUD_SQL_INSTANCE", CreateTime: now, State: "RUNNABLE"},
{Name: "replica-2", InstanceType: "READ_REPLICA_INSTANCE", CreateTime: now, State: "RUNNABLE"},
},
}

ids, err := listCloudSQLInstances(context.Background(), mock, resource.Scope{ProjectID: "my-project"}, config.ResourceType{})
require.NoError(t, err)
require.Len(t, ids, 4)

var names []string
for _, id := range ids {
names = append(names, *id)
}

// Replicas must come before primaries regardless of the order returned by the API.
assert.Equal(t, []string{
"my-project/replica-1",
"my-project/replica-2",
"my-project/primary-1",
"my-project/primary-2",
}, names)
}