diff --git a/docs/components/GoogleCloud.mdx b/docs/components/GoogleCloud.mdx
index cf9d1691e3..f2ebf6ed10 100644
--- a/docs/components/GoogleCloud.mdx
+++ b/docs/components/GoogleCloud.mdx
@@ -29,6 +29,9 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";
+
+
+
@@ -76,7 +79,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";
- `roles/logging.configWriter` — create logging sinks for event triggers
- `roles/pubsub.admin` — manage Pub/Sub topics, subscriptions, and IAM policies for event delivery
-- Additional roles depending on which components you use (e.g. `roles/compute.admin` for VM management, `roles/monitoring.viewer` to read VM metrics)
+- Additional roles depending on which components you use (e.g. `roles/compute.admin` for VM management, `roles/monitoring.viewer` to read VM metrics, `roles/cloudsql.admin` to manage Cloud SQL databases)
@@ -813,6 +816,138 @@ The invocation result, including:
}
```
+
+
+## Cloud SQL • Create Database
+
+**Component key:** `gcp.cloudsql.createDatabase`
+
+The Create Database component adds a new logical database to an existing Cloud SQL instance.
+
+### Use Cases
+
+- **Application bootstrap**: Create an application-specific database as part of environment setup
+- **Tenant provisioning**: Add a dedicated database for a new customer or workspace
+- **Migration workflows**: Prepare a destination database before importing data
+
+### Configuration
+
+- **Instance**: The Cloud SQL instance that will contain the new database (required)
+- **Database Name**: The name of the database to create (required, supports expressions)
+
+### Output
+
+Emits a `gcp.cloudsql.database` payload with the created database's `name`, `instance`, `project`, `charset`, `collation`, and `selfLink`.
+
+### Important Notes
+
+- Requires the `roles/cloudsql.admin` (or `roles/cloudsql.editor`) IAM role on the integration's service account, and the **Cloud SQL Admin API** enabled
+- Cloud SQL database creation is asynchronous; this component waits for the operation to finish before emitting
+
+### Example Output
+
+```json
+{
+ "data": {
+ "charset": "UTF8",
+ "collation": "en_US.UTF8",
+ "instance": "my-instance",
+ "name": "app_db",
+ "project": "my-project",
+ "selfLink": "https://sqladmin.googleapis.com/v1/projects/my-project/instances/my-instance/databases/app_db"
+ },
+ "timestamp": "2025-01-01T00:00:00Z",
+ "type": "gcp.cloudsql.database"
+}
+```
+
+
+
+## Cloud SQL • Delete Database
+
+**Component key:** `gcp.cloudsql.deleteDatabase`
+
+The Delete Database component permanently deletes a logical database from a Cloud SQL instance.
+
+### Use Cases
+
+- **Teardown**: Remove a database as part of decommissioning an environment
+- **Tenant offboarding**: Delete a customer's dedicated database
+- **Cleanup**: Drop temporary databases created during a workflow
+
+### Configuration
+
+- **Instance**: The Cloud SQL instance that contains the database (required)
+- **Database**: The database to delete (required)
+
+### Output
+
+Emits a `gcp.cloudsql.database` payload with the deleted database's `name` and `instance`, and `deleted: true`.
+
+### Important Notes
+
+- **This permanently deletes the database and all its data — it is irreversible.**
+- Requires the `roles/cloudsql.admin` (or `roles/cloudsql.editor`) IAM role on the integration's service account, and the **Cloud SQL Admin API** enabled
+- Cloud SQL database deletion is asynchronous; this component waits for the operation to finish before emitting
+
+### Example Output
+
+```json
+{
+ "data": {
+ "deleted": true,
+ "instance": "my-instance",
+ "name": "app_db"
+ },
+ "timestamp": "2025-01-01T00:00:00Z",
+ "type": "gcp.cloudsql.database"
+}
+```
+
+
+
+## Cloud SQL • Get Database
+
+**Component key:** `gcp.cloudsql.getDatabase`
+
+The Get Database component retrieves a logical database from a Cloud SQL instance.
+
+### Use Cases
+
+- **Existence checks**: Confirm a database is present before acting on it
+- **Enrichment**: Read a database's charset/collation to feed a downstream step
+- **Auditing**: Capture database details as part of a workflow
+
+### Configuration
+
+- **Instance**: The Cloud SQL instance that contains the database (required)
+- **Database**: The database to fetch (required)
+
+### Output
+
+Emits a `gcp.cloudsql.database` payload with the database's `name`, `instance`, `project`, `charset`, `collation`, and `selfLink`.
+
+### Important Notes
+
+- Requires the `roles/cloudsql.viewer` (or `roles/cloudsql.admin`) IAM role on the integration's service account, and the **Cloud SQL Admin API** enabled
+
+### Example Output
+
+```json
+{
+ "data": {
+ "charset": "UTF8",
+ "collation": "en_US.UTF8",
+ "instance": "my-instance",
+ "name": "app_db",
+ "project": "my-project",
+ "selfLink": "https://sqladmin.googleapis.com/v1/projects/my-project/instances/my-instance/databases/app_db"
+ },
+ "timestamp": "2025-01-01T00:00:00Z",
+ "type": "gcp.cloudsql.database"
+}
+```
+
## Compute • Create Static IP
diff --git a/pkg/integrations/gcp/cloudsql/client.go b/pkg/integrations/gcp/cloudsql/client.go
new file mode 100644
index 0000000000..632a26b43f
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/client.go
@@ -0,0 +1,42 @@
+package cloudsql
+
+import (
+ "context"
+ "sync"
+
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+// sqlAdminBaseURL is the host+version for the Cloud SQL Admin API. Cloud SQL is
+// hosted on sqladmin.googleapis.com (a different host than Compute), so every
+// call uses the fully-qualified *URL helpers.
+const sqlAdminBaseURL = "https://sqladmin.googleapis.com/v1"
+
+// Client is the interface used by the Cloud SQL components.
+type Client interface {
+ GetURL(ctx context.Context, fullURL string) ([]byte, error)
+ PostURL(ctx context.Context, fullURL string, body any) ([]byte, error)
+ DeleteURL(ctx context.Context, fullURL string) ([]byte, error)
+ ProjectID() string
+}
+
+var (
+ clientFactoryMu sync.RWMutex
+ clientFactory func(httpCtx core.HTTPContext, integration core.IntegrationContext) (Client, error)
+)
+
+func SetClientFactory(fn func(httpCtx core.HTTPContext, integration core.IntegrationContext) (Client, error)) {
+ clientFactoryMu.Lock()
+ defer clientFactoryMu.Unlock()
+ clientFactory = fn
+}
+
+func getClient(httpCtx core.HTTPContext, integration core.IntegrationContext) (Client, error) {
+ clientFactoryMu.RLock()
+ fn := clientFactory
+ clientFactoryMu.RUnlock()
+ if fn == nil {
+ panic("gcp cloudsql: SetClientFactory was not called by the gcp integration")
+ }
+ return fn(httpCtx, integration)
+}
diff --git a/pkg/integrations/gcp/cloudsql/common.go b/pkg/integrations/gcp/cloudsql/common.go
new file mode 100644
index 0000000000..6dbc207a6c
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/common.go
@@ -0,0 +1,234 @@
+package cloudsql
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+
+ gcpcommon "github.com/superplanehq/superplane/pkg/integrations/gcp/common"
+)
+
+// roleHintAdmin is the IAM role required to manage Cloud SQL databases.
+const roleHintAdmin = "roles/cloudsql.admin (or roles/cloudsql.editor)"
+
+const (
+ operationPollInterval = 2 * time.Second
+ operationWaitTimeout = 5 * time.Minute
+ operationStatusDone = "DONE"
+)
+
+// Database models a Cloud SQL database resource.
+type Database struct {
+ Kind string `json:"kind"`
+ Name string `json:"name"`
+ Instance string `json:"instance"`
+ Project string `json:"project"`
+ SelfLink string `json:"selfLink"`
+ Charset string `json:"charset"`
+ Collation string `json:"collation"`
+ Etag string `json:"etag"`
+}
+
+// Instance is the subset of a Cloud SQL instance used to populate the dropdown.
+type Instance struct {
+ Name string `json:"name"`
+ ConnectionName string `json:"connectionName"`
+ Region string `json:"region"`
+ DatabaseVersion string `json:"databaseVersion"`
+ State string `json:"state"`
+}
+
+// DatabaseNodeMetadata is the node metadata shared by the create/get/delete
+// database components so the collapsed node can show what it targets.
+type DatabaseNodeMetadata struct {
+ Instance string `json:"instance,omitempty" mapstructure:"instance"`
+ Database string `json:"database,omitempty" mapstructure:"database"`
+}
+
+type operation struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ OperationType string `json:"operationType"`
+ Error *operationError `json:"error"`
+}
+
+type operationError struct {
+ Errors []struct {
+ Kind string `json:"kind"`
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"errors"`
+}
+
+func databasesURL(project, instance string) string {
+ return fmt.Sprintf("%s/projects/%s/instances/%s/databases", sqlAdminBaseURL, project, instance)
+}
+
+func databaseURL(project, instance, database string) string {
+ return fmt.Sprintf("%s/projects/%s/instances/%s/databases/%s", sqlAdminBaseURL, project, instance, database)
+}
+
+func instancesURL(project string) string {
+ return fmt.Sprintf("%s/projects/%s/instances", sqlAdminBaseURL, project)
+}
+
+func operationURL(project, operationName string) string {
+ return fmt.Sprintf("%s/projects/%s/operations/%s", sqlAdminBaseURL, project, operationName)
+}
+
+// createDatabase creates a logical database in the instance, waits for the
+// returned operation to finish, and returns the created database.
+func createDatabase(ctx context.Context, client Client, project, instance, name string) (*Database, error) {
+ body := map[string]any{"name": name, "project": project, "instance": instance}
+ respBody, err := client.PostURL(ctx, databasesURL(project, instance), body)
+ if err != nil {
+ return nil, err
+ }
+ if err := waitForOperation(ctx, client, project, respBody); err != nil {
+ return nil, err
+ }
+ return getDatabase(ctx, client, project, instance, name)
+}
+
+// getDatabase fetches a single logical database.
+func getDatabase(ctx context.Context, client Client, project, instance, name string) (*Database, error) {
+ respBody, err := client.GetURL(ctx, databaseURL(project, instance, name))
+ if err != nil {
+ return nil, err
+ }
+ var db Database
+ if err := json.Unmarshal(respBody, &db); err != nil {
+ return nil, fmt.Errorf("parse database response: %w", err)
+ }
+ return &db, nil
+}
+
+// deleteDatabase deletes a logical database and waits for the operation to finish.
+func deleteDatabase(ctx context.Context, client Client, project, instance, name string) error {
+ respBody, err := client.DeleteURL(ctx, databaseURL(project, instance, name))
+ if err != nil {
+ return err
+ }
+ return waitForOperation(ctx, client, project, respBody)
+}
+
+// ListDatabases lists the databases in an instance.
+func ListDatabases(ctx context.Context, client Client, project, instance string) ([]Database, error) {
+ respBody, err := client.GetURL(ctx, databasesURL(project, instance))
+ if err != nil {
+ return nil, err
+ }
+ var resp struct {
+ Items []Database `json:"items"`
+ }
+ if err := json.Unmarshal(respBody, &resp); err != nil {
+ return nil, fmt.Errorf("parse databases list: %w", err)
+ }
+ return resp.Items, nil
+}
+
+// ListInstances lists the Cloud SQL instances in the project, following
+// pagination so projects with more instances than one page are fully listed.
+func ListInstances(ctx context.Context, client Client, project string) ([]Instance, error) {
+ var all []Instance
+ pageToken := ""
+ for {
+ u := instancesURL(project)
+ if pageToken != "" {
+ u += "?pageToken=" + url.QueryEscape(pageToken)
+ }
+ respBody, err := client.GetURL(ctx, u)
+ if err != nil {
+ return nil, err
+ }
+ var resp struct {
+ Items []Instance `json:"items"`
+ NextPageToken string `json:"nextPageToken"`
+ }
+ if err := json.Unmarshal(respBody, &resp); err != nil {
+ return nil, fmt.Errorf("parse instances list: %w", err)
+ }
+ all = append(all, resp.Items...)
+ if resp.NextPageToken == "" {
+ return all, nil
+ }
+ pageToken = resp.NextPageToken
+ }
+}
+
+// databasePayload converts a Database into the component output payload.
+func databasePayload(db *Database) map[string]any {
+ return map[string]any{
+ "name": db.Name,
+ "instance": db.Instance,
+ "project": db.Project,
+ "charset": db.Charset,
+ "collation": db.Collation,
+ "selfLink": db.SelfLink,
+ }
+}
+
+// waitForOperation polls the operation referenced by the API response until it
+// reaches DONE (or the timeout elapses). Cloud SQL database create/delete are
+// asynchronous, so a caller that didn't wait could observe a not-yet-applied
+// state.
+func waitForOperation(ctx context.Context, client Client, project string, opBody []byte) error {
+ var op operation
+ if err := json.Unmarshal(opBody, &op); err != nil {
+ return fmt.Errorf("parse operation response: %w", err)
+ }
+ // Some responses are not long-running operations; nothing to wait on.
+ if op.Name == "" {
+ return nil
+ }
+
+ deadline := time.Now().Add(operationWaitTimeout)
+ ticker := time.NewTicker(operationPollInterval)
+ defer ticker.Stop()
+ for {
+ if op.Status == operationStatusDone {
+ return operationResultError(op)
+ }
+ if time.Now().After(deadline) {
+ return fmt.Errorf("timeout waiting for Cloud SQL operation %s", op.Name)
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-ticker.C:
+ }
+ body, err := client.GetURL(ctx, operationURL(project, op.Name))
+ if err != nil {
+ return err
+ }
+ if err := json.Unmarshal(body, &op); err != nil {
+ return fmt.Errorf("parse operation poll response: %w", err)
+ }
+ }
+}
+
+func operationResultError(op operation) error {
+ if op.Error == nil || len(op.Error.Errors) == 0 {
+ return nil
+ }
+ e := op.Error.Errors[0]
+ msg := e.Message
+ if msg == "" {
+ msg = e.Code
+ }
+ return fmt.Errorf("Cloud SQL operation failed: %s", msg)
+}
+
+// apiErrorMessage formats an API error for the execution state, appending an IAM
+// hint on 403 since a missing Cloud SQL admin role is the most common cause.
+func apiErrorMessage(action string, err error) string {
+ var apiErr *gcpcommon.GCPAPIError
+ if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden {
+ return fmt.Sprintf("%s: %v — ensure the integration's service account has the %s IAM role", action, err, roleHintAdmin)
+ }
+ return fmt.Sprintf("%s: %v", action, err)
+}
diff --git a/pkg/integrations/gcp/cloudsql/common_test.go b/pkg/integrations/gcp/cloudsql/common_test.go
new file mode 100644
index 0000000000..1d2a446a86
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/common_test.go
@@ -0,0 +1,60 @@
+package cloudsql
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+// mockClient is a configurable cloudsql.Client used by the component tests.
+type mockClient struct {
+ projectID string
+ getFunc func(ctx context.Context, url string) ([]byte, error)
+ postFunc func(ctx context.Context, url string, body any) ([]byte, error)
+ deleteFunc func(ctx context.Context, url string) ([]byte, error)
+}
+
+func (m *mockClient) GetURL(ctx context.Context, url string) ([]byte, error) {
+ if m.getFunc != nil {
+ return m.getFunc(ctx, url)
+ }
+ return nil, fmt.Errorf("unexpected GetURL(%s)", url)
+}
+
+func (m *mockClient) PostURL(ctx context.Context, url string, body any) ([]byte, error) {
+ if m.postFunc != nil {
+ return m.postFunc(ctx, url, body)
+ }
+ return nil, fmt.Errorf("unexpected PostURL(%s)", url)
+}
+
+func (m *mockClient) DeleteURL(ctx context.Context, url string) ([]byte, error) {
+ if m.deleteFunc != nil {
+ return m.deleteFunc(ctx, url)
+ }
+ return nil, fmt.Errorf("unexpected DeleteURL(%s)", url)
+}
+
+func (m *mockClient) ProjectID() string { return m.projectID }
+
+// withFactory installs a mock client for the duration of a component test.
+func withFactory(mc *mockClient) {
+ SetClientFactory(func(httpCtx core.HTTPContext, integration core.IntegrationContext) (Client, error) {
+ return mc, nil
+ })
+}
+
+// firstData returns the data map of the first emitted payload.
+func firstData(t *testing.T, state *contexts.ExecutionStateContext) map[string]any {
+ t.Helper()
+ require.NotEmpty(t, state.Payloads)
+ return state.Payloads[0].(map[string]any)["data"].(map[string]any)
+}
+
+// doneOperation is an operation response that is already finished, so the
+// component's waitForOperation returns without polling/sleeping.
+const doneOperation = `{"name":"op-1","status":"DONE"}`
diff --git a/pkg/integrations/gcp/cloudsql/create_database.go b/pkg/integrations/gcp/cloudsql/create_database.go
new file mode 100644
index 0000000000..aa056f1185
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/create_database.go
@@ -0,0 +1,162 @@
+package cloudsql
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type CreateDatabase struct{}
+
+type CreateDatabaseSpec struct {
+ Instance string `json:"instance" mapstructure:"instance"`
+ Name string `json:"name" mapstructure:"name"`
+}
+
+func (c *CreateDatabase) Name() string {
+ return "gcp.cloudsql.createDatabase"
+}
+
+func (c *CreateDatabase) Label() string {
+ return "Cloud SQL • Create Database"
+}
+
+func (c *CreateDatabase) Description() string {
+ return "Create a logical database inside a Cloud SQL instance"
+}
+
+func (c *CreateDatabase) Documentation() string {
+ return `The Create Database component adds a new logical database to an existing Cloud SQL instance.
+
+## Use Cases
+
+- **Application bootstrap**: Create an application-specific database as part of environment setup
+- **Tenant provisioning**: Add a dedicated database for a new customer or workspace
+- **Migration workflows**: Prepare a destination database before importing data
+
+## Configuration
+
+- **Instance**: The Cloud SQL instance that will contain the new database (required)
+- **Database Name**: The name of the database to create (required, supports expressions)
+
+## Output
+
+Emits a ` + "`gcp.cloudsql.database`" + ` payload with the created database's ` + "`name`" + `, ` + "`instance`" + `, ` + "`project`" + `, ` + "`charset`" + `, ` + "`collation`" + `, and ` + "`selfLink`" + `.
+
+## Important Notes
+
+- Requires the ` + "`roles/cloudsql.admin`" + ` (or ` + "`roles/cloudsql.editor`" + `) IAM role on the integration's service account, and the **Cloud SQL Admin API** enabled
+- Cloud SQL database creation is asynchronous; this component waits for the operation to finish before emitting`
+}
+
+func (c *CreateDatabase) Icon() string {
+ return "database"
+}
+
+func (c *CreateDatabase) Color() string {
+ return "blue"
+}
+
+func (c *CreateDatabase) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (c *CreateDatabase) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "instance",
+ Label: "Instance",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The Cloud SQL instance to create the database in",
+ Placeholder: "Select an instance",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: ResourceTypeInstance,
+ },
+ },
+ },
+ {
+ Name: "name",
+ Label: "Database Name",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Description: "The name of the database to create",
+ Placeholder: "app_db",
+ },
+ }
+}
+
+func (c *CreateDatabase) Setup(ctx core.SetupContext) error {
+ spec := CreateDatabaseSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return fmt.Errorf("error decoding configuration: %v", err)
+ }
+ if strings.TrimSpace(spec.Instance) == "" {
+ return fmt.Errorf("instance is required")
+ }
+ if strings.TrimSpace(spec.Name) == "" {
+ return fmt.Errorf("name is required")
+ }
+ return ctx.Metadata.Set(DatabaseNodeMetadata{
+ Instance: strings.TrimSpace(spec.Instance),
+ Database: strings.TrimSpace(spec.Name),
+ })
+}
+
+func (c *CreateDatabase) Execute(ctx core.ExecutionContext) error {
+ spec := CreateDatabaseSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return ctx.ExecutionState.Fail("error", fmt.Sprintf("failed to decode configuration: %v", err))
+ }
+ instance := strings.TrimSpace(spec.Instance)
+ name := strings.TrimSpace(spec.Name)
+ if instance == "" {
+ return ctx.ExecutionState.Fail("error", "instance is required")
+ }
+ if name == "" {
+ return ctx.ExecutionState.Fail("error", "name is required")
+ }
+
+ client, err := getClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return ctx.ExecutionState.Fail("error", fmt.Sprintf("failed to create GCP client: %v", err))
+ }
+
+ db, err := createDatabase(context.Background(), client, client.ProjectID(), instance, name)
+ if err != nil {
+ return ctx.ExecutionState.Fail("error", apiErrorMessage("failed to create database", err))
+ }
+
+ return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, "gcp.cloudsql.database", []any{databasePayload(db)})
+}
+
+func (c *CreateDatabase) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (c *CreateDatabase) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (c *CreateDatabase) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) {
+ return http.StatusOK, nil, nil
+}
+
+func (c *CreateDatabase) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (c *CreateDatabase) Hooks() []core.Hook {
+ return []core.Hook{}
+}
+
+func (c *CreateDatabase) HandleHook(ctx core.ActionHookContext) error {
+ return nil
+}
diff --git a/pkg/integrations/gcp/cloudsql/create_database_test.go b/pkg/integrations/gcp/cloudsql/create_database_test.go
new file mode 100644
index 0000000000..40b40aebc3
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/create_database_test.go
@@ -0,0 +1,100 @@
+package cloudsql
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__CreateDatabase__Setup(t *testing.T) {
+ c := &CreateDatabase{}
+ setup := func(cfg map[string]any) error {
+ return c.Setup(core.SetupContext{Configuration: cfg, Metadata: &contexts.MetadataContext{}})
+ }
+
+ t.Run("missing instance -> error", func(t *testing.T) {
+ require.ErrorContains(t, setup(map[string]any{"name": "app_db"}), "instance is required")
+ })
+
+ t.Run("missing name -> error", func(t *testing.T) {
+ require.ErrorContains(t, setup(map[string]any{"instance": "my-instance"}), "name is required")
+ })
+
+ t.Run("valid -> ok", func(t *testing.T) {
+ require.NoError(t, setup(map[string]any{"instance": "my-instance", "name": "app_db"}))
+ })
+}
+
+func Test__CreateDatabase__Execute(t *testing.T) {
+ c := &CreateDatabase{}
+
+ t.Run("creates the database and emits its details", func(t *testing.T) {
+ var postURL string
+ var postBody map[string]any
+ mc := &mockClient{
+ projectID: "my-project",
+ postFunc: func(ctx context.Context, url string, body any) ([]byte, error) {
+ postURL = url
+ postBody, _ = body.(map[string]any)
+ return []byte(doneOperation), nil
+ },
+ getFunc: func(ctx context.Context, url string) ([]byte, error) {
+ return []byte(`{"name":"app_db","instance":"my-instance","project":"my-project","charset":"UTF8","collation":"en_US.UTF8","selfLink":"https://x/app_db"}`), nil
+ },
+ }
+ withFactory(mc)
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := c.Execute(core.ExecutionContext{
+ Configuration: map[string]any{"instance": "my-instance", "name": "app_db"},
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.True(t, state.Passed)
+ assert.Equal(t, "gcp.cloudsql.database", state.Type)
+ assert.Contains(t, postURL, "/projects/my-project/instances/my-instance/databases")
+ assert.Equal(t, "app_db", postBody["name"])
+
+ data := firstData(t, state)
+ assert.Equal(t, "app_db", data["name"])
+ assert.Equal(t, "my-instance", data["instance"])
+ assert.Equal(t, "UTF8", data["charset"])
+ })
+
+ t.Run("surfaces a failed operation", func(t *testing.T) {
+ mc := &mockClient{
+ projectID: "my-project",
+ postFunc: func(ctx context.Context, url string, body any) ([]byte, error) {
+ return []byte(`{"name":"op-1","status":"DONE","error":{"errors":[{"message":"database already exists"}]}}`), nil
+ },
+ }
+ withFactory(mc)
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := c.Execute(core.ExecutionContext{
+ Configuration: map[string]any{"instance": "my-instance", "name": "app_db"},
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.False(t, state.Passed)
+ assert.Contains(t, state.FailureMessage, "database already exists")
+ })
+
+ t.Run("missing instance fails the execution", func(t *testing.T) {
+ withFactory(&mockClient{projectID: "my-project"})
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := c.Execute(core.ExecutionContext{
+ Configuration: map[string]any{"name": "app_db"},
+ ExecutionState: state,
+ })
+ require.NoError(t, err)
+ assert.False(t, state.Passed)
+ assert.Contains(t, state.FailureMessage, "instance is required")
+ })
+}
diff --git a/pkg/integrations/gcp/cloudsql/delete_database.go b/pkg/integrations/gcp/cloudsql/delete_database.go
new file mode 100644
index 0000000000..a5e684c1c7
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/delete_database.go
@@ -0,0 +1,176 @@
+package cloudsql
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type DeleteDatabase struct{}
+
+type DeleteDatabaseSpec struct {
+ Instance string `json:"instance" mapstructure:"instance"`
+ Database string `json:"database" mapstructure:"database"`
+}
+
+func (d *DeleteDatabase) Name() string {
+ return "gcp.cloudsql.deleteDatabase"
+}
+
+func (d *DeleteDatabase) Label() string {
+ return "Cloud SQL • Delete Database"
+}
+
+func (d *DeleteDatabase) Description() string {
+ return "Delete a logical database from a Cloud SQL instance"
+}
+
+func (d *DeleteDatabase) Documentation() string {
+ return `The Delete Database component permanently deletes a logical database from a Cloud SQL instance.
+
+## Use Cases
+
+- **Teardown**: Remove a database as part of decommissioning an environment
+- **Tenant offboarding**: Delete a customer's dedicated database
+- **Cleanup**: Drop temporary databases created during a workflow
+
+## Configuration
+
+- **Instance**: The Cloud SQL instance that contains the database (required)
+- **Database**: The database to delete (required)
+
+## Output
+
+Emits a ` + "`gcp.cloudsql.database`" + ` payload with the deleted database's ` + "`name`" + ` and ` + "`instance`" + `, and ` + "`deleted: true`" + `.
+
+## Important Notes
+
+- **This permanently deletes the database and all its data — it is irreversible.**
+- Requires the ` + "`roles/cloudsql.admin`" + ` (or ` + "`roles/cloudsql.editor`" + `) IAM role on the integration's service account, and the **Cloud SQL Admin API** enabled
+- Cloud SQL database deletion is asynchronous; this component waits for the operation to finish before emitting`
+}
+
+func (d *DeleteDatabase) Icon() string {
+ return "database"
+}
+
+func (d *DeleteDatabase) Color() string {
+ return "red"
+}
+
+func (d *DeleteDatabase) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (d *DeleteDatabase) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "instance",
+ Label: "Instance",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The Cloud SQL instance that contains the database",
+ Placeholder: "Select an instance",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: ResourceTypeInstance,
+ },
+ },
+ },
+ {
+ Name: "database",
+ Label: "Database",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The database to delete",
+ Placeholder: "Select a database",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: ResourceTypeDatabase,
+ Parameters: []configuration.ParameterRef{
+ {Name: "instance", ValueFrom: &configuration.ParameterValueFrom{Field: "instance"}},
+ },
+ },
+ },
+ },
+ }
+}
+
+func (d *DeleteDatabase) Setup(ctx core.SetupContext) error {
+ spec := DeleteDatabaseSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return fmt.Errorf("error decoding configuration: %v", err)
+ }
+ if strings.TrimSpace(spec.Instance) == "" {
+ return fmt.Errorf("instance is required")
+ }
+ if strings.TrimSpace(spec.Database) == "" {
+ return fmt.Errorf("database is required")
+ }
+ return ctx.Metadata.Set(DatabaseNodeMetadata{
+ Instance: strings.TrimSpace(spec.Instance),
+ Database: strings.TrimSpace(spec.Database),
+ })
+}
+
+func (d *DeleteDatabase) Execute(ctx core.ExecutionContext) error {
+ spec := DeleteDatabaseSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return ctx.ExecutionState.Fail("error", fmt.Sprintf("failed to decode configuration: %v", err))
+ }
+ instance := strings.TrimSpace(spec.Instance)
+ database := strings.TrimSpace(spec.Database)
+ if instance == "" {
+ return ctx.ExecutionState.Fail("error", "instance is required")
+ }
+ if database == "" {
+ return ctx.ExecutionState.Fail("error", "database is required")
+ }
+
+ client, err := getClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return ctx.ExecutionState.Fail("error", fmt.Sprintf("failed to create GCP client: %v", err))
+ }
+
+ if err := deleteDatabase(context.Background(), client, client.ProjectID(), instance, database); err != nil {
+ return ctx.ExecutionState.Fail("error", apiErrorMessage("failed to delete database", err))
+ }
+
+ return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, "gcp.cloudsql.database", []any{
+ map[string]any{
+ "name": database,
+ "instance": instance,
+ "deleted": true,
+ },
+ })
+}
+
+func (d *DeleteDatabase) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (d *DeleteDatabase) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (d *DeleteDatabase) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) {
+ return http.StatusOK, nil, nil
+}
+
+func (d *DeleteDatabase) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (d *DeleteDatabase) Hooks() []core.Hook {
+ return []core.Hook{}
+}
+
+func (d *DeleteDatabase) HandleHook(ctx core.ActionHookContext) error {
+ return nil
+}
diff --git a/pkg/integrations/gcp/cloudsql/delete_database_test.go b/pkg/integrations/gcp/cloudsql/delete_database_test.go
new file mode 100644
index 0000000000..1b81cf7be9
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/delete_database_test.go
@@ -0,0 +1,82 @@
+package cloudsql
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__DeleteDatabase__Setup(t *testing.T) {
+ d := &DeleteDatabase{}
+ setup := func(cfg map[string]any) error {
+ return d.Setup(core.SetupContext{Configuration: cfg, Metadata: &contexts.MetadataContext{}})
+ }
+
+ t.Run("missing instance -> error", func(t *testing.T) {
+ require.ErrorContains(t, setup(map[string]any{"database": "app_db"}), "instance is required")
+ })
+
+ t.Run("missing database -> error", func(t *testing.T) {
+ require.ErrorContains(t, setup(map[string]any{"instance": "my-instance"}), "database is required")
+ })
+
+ t.Run("valid -> ok", func(t *testing.T) {
+ require.NoError(t, setup(map[string]any{"instance": "my-instance", "database": "app_db"}))
+ })
+}
+
+func Test__DeleteDatabase__Execute(t *testing.T) {
+ d := &DeleteDatabase{}
+
+ t.Run("deletes the database and emits a confirmation", func(t *testing.T) {
+ var deleteURL string
+ mc := &mockClient{
+ projectID: "my-project",
+ deleteFunc: func(ctx context.Context, url string) ([]byte, error) {
+ deleteURL = url
+ return []byte(doneOperation), nil
+ },
+ }
+ withFactory(mc)
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := d.Execute(core.ExecutionContext{
+ Configuration: map[string]any{"instance": "my-instance", "database": "app_db"},
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.True(t, state.Passed)
+ assert.Equal(t, "gcp.cloudsql.database", state.Type)
+ assert.Contains(t, deleteURL, "/projects/my-project/instances/my-instance/databases/app_db")
+
+ data := firstData(t, state)
+ assert.Equal(t, "app_db", data["name"])
+ assert.Equal(t, "my-instance", data["instance"])
+ assert.Equal(t, true, data["deleted"])
+ })
+
+ t.Run("surfaces a failed delete operation", func(t *testing.T) {
+ mc := &mockClient{
+ projectID: "my-project",
+ deleteFunc: func(ctx context.Context, url string) ([]byte, error) {
+ return []byte(`{"name":"op-2","status":"DONE","error":{"errors":[{"message":"database is in use"}]}}`), nil
+ },
+ }
+ withFactory(mc)
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := d.Execute(core.ExecutionContext{
+ Configuration: map[string]any{"instance": "my-instance", "database": "app_db"},
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.False(t, state.Passed)
+ assert.Contains(t, state.FailureMessage, "database is in use")
+ })
+}
diff --git a/pkg/integrations/gcp/cloudsql/example.go b/pkg/integrations/gcp/cloudsql/example.go
new file mode 100644
index 0000000000..fa9e74caa7
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/example.go
@@ -0,0 +1,40 @@
+package cloudsql
+
+import (
+ _ "embed"
+ "sync"
+
+ "github.com/superplanehq/superplane/pkg/utils"
+)
+
+//go:embed example_output_create_database.json
+var exampleOutputCreateBytes []byte
+
+//go:embed example_output_get_database.json
+var exampleOutputGetBytes []byte
+
+//go:embed example_output_delete_database.json
+var exampleOutputDeleteBytes []byte
+
+var (
+ exampleOutputCreateOnce sync.Once
+ exampleOutputCreate map[string]any
+
+ exampleOutputGetOnce sync.Once
+ exampleOutputGet map[string]any
+
+ exampleOutputDeleteOnce sync.Once
+ exampleOutputDelete map[string]any
+)
+
+func (c *CreateDatabase) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateOnce, exampleOutputCreateBytes, &exampleOutputCreate)
+}
+
+func (g *GetDatabase) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleOutputGetOnce, exampleOutputGetBytes, &exampleOutputGet)
+}
+
+func (d *DeleteDatabase) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleOutputDeleteOnce, exampleOutputDeleteBytes, &exampleOutputDelete)
+}
diff --git a/pkg/integrations/gcp/cloudsql/example_output_create_database.json b/pkg/integrations/gcp/cloudsql/example_output_create_database.json
new file mode 100644
index 0000000000..2fde51ce4e
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/example_output_create_database.json
@@ -0,0 +1,12 @@
+{
+ "data": {
+ "name": "app_db",
+ "instance": "my-instance",
+ "project": "my-project",
+ "charset": "UTF8",
+ "collation": "en_US.UTF8",
+ "selfLink": "https://sqladmin.googleapis.com/v1/projects/my-project/instances/my-instance/databases/app_db"
+ },
+ "timestamp": "2025-01-01T00:00:00Z",
+ "type": "gcp.cloudsql.database"
+}
diff --git a/pkg/integrations/gcp/cloudsql/example_output_delete_database.json b/pkg/integrations/gcp/cloudsql/example_output_delete_database.json
new file mode 100644
index 0000000000..a3b909f7df
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/example_output_delete_database.json
@@ -0,0 +1,9 @@
+{
+ "data": {
+ "name": "app_db",
+ "instance": "my-instance",
+ "deleted": true
+ },
+ "timestamp": "2025-01-01T00:00:00Z",
+ "type": "gcp.cloudsql.database"
+}
diff --git a/pkg/integrations/gcp/cloudsql/example_output_get_database.json b/pkg/integrations/gcp/cloudsql/example_output_get_database.json
new file mode 100644
index 0000000000..2fde51ce4e
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/example_output_get_database.json
@@ -0,0 +1,12 @@
+{
+ "data": {
+ "name": "app_db",
+ "instance": "my-instance",
+ "project": "my-project",
+ "charset": "UTF8",
+ "collation": "en_US.UTF8",
+ "selfLink": "https://sqladmin.googleapis.com/v1/projects/my-project/instances/my-instance/databases/app_db"
+ },
+ "timestamp": "2025-01-01T00:00:00Z",
+ "type": "gcp.cloudsql.database"
+}
diff --git a/pkg/integrations/gcp/cloudsql/get_database.go b/pkg/integrations/gcp/cloudsql/get_database.go
new file mode 100644
index 0000000000..a23f5c8125
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/get_database.go
@@ -0,0 +1,169 @@
+package cloudsql
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type GetDatabase struct{}
+
+type GetDatabaseSpec struct {
+ Instance string `json:"instance" mapstructure:"instance"`
+ Database string `json:"database" mapstructure:"database"`
+}
+
+func (g *GetDatabase) Name() string {
+ return "gcp.cloudsql.getDatabase"
+}
+
+func (g *GetDatabase) Label() string {
+ return "Cloud SQL • Get Database"
+}
+
+func (g *GetDatabase) Description() string {
+ return "Fetch a logical database from a Cloud SQL instance"
+}
+
+func (g *GetDatabase) Documentation() string {
+ return `The Get Database component retrieves a logical database from a Cloud SQL instance.
+
+## Use Cases
+
+- **Existence checks**: Confirm a database is present before acting on it
+- **Enrichment**: Read a database's charset/collation to feed a downstream step
+- **Auditing**: Capture database details as part of a workflow
+
+## Configuration
+
+- **Instance**: The Cloud SQL instance that contains the database (required)
+- **Database**: The database to fetch (required)
+
+## Output
+
+Emits a ` + "`gcp.cloudsql.database`" + ` payload with the database's ` + "`name`" + `, ` + "`instance`" + `, ` + "`project`" + `, ` + "`charset`" + `, ` + "`collation`" + `, and ` + "`selfLink`" + `.
+
+## Important Notes
+
+- Requires the ` + "`roles/cloudsql.viewer`" + ` (or ` + "`roles/cloudsql.admin`" + `) IAM role on the integration's service account, and the **Cloud SQL Admin API** enabled`
+}
+
+func (g *GetDatabase) Icon() string {
+ return "database"
+}
+
+func (g *GetDatabase) Color() string {
+ return "blue"
+}
+
+func (g *GetDatabase) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (g *GetDatabase) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "instance",
+ Label: "Instance",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The Cloud SQL instance that contains the database",
+ Placeholder: "Select an instance",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: ResourceTypeInstance,
+ },
+ },
+ },
+ {
+ Name: "database",
+ Label: "Database",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The database to fetch",
+ Placeholder: "Select a database",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: ResourceTypeDatabase,
+ Parameters: []configuration.ParameterRef{
+ {Name: "instance", ValueFrom: &configuration.ParameterValueFrom{Field: "instance"}},
+ },
+ },
+ },
+ },
+ }
+}
+
+func (g *GetDatabase) Setup(ctx core.SetupContext) error {
+ spec := GetDatabaseSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return fmt.Errorf("error decoding configuration: %v", err)
+ }
+ if strings.TrimSpace(spec.Instance) == "" {
+ return fmt.Errorf("instance is required")
+ }
+ if strings.TrimSpace(spec.Database) == "" {
+ return fmt.Errorf("database is required")
+ }
+ return ctx.Metadata.Set(DatabaseNodeMetadata{
+ Instance: strings.TrimSpace(spec.Instance),
+ Database: strings.TrimSpace(spec.Database),
+ })
+}
+
+func (g *GetDatabase) Execute(ctx core.ExecutionContext) error {
+ spec := GetDatabaseSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return ctx.ExecutionState.Fail("error", fmt.Sprintf("failed to decode configuration: %v", err))
+ }
+ instance := strings.TrimSpace(spec.Instance)
+ database := strings.TrimSpace(spec.Database)
+ if instance == "" {
+ return ctx.ExecutionState.Fail("error", "instance is required")
+ }
+ if database == "" {
+ return ctx.ExecutionState.Fail("error", "database is required")
+ }
+
+ client, err := getClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return ctx.ExecutionState.Fail("error", fmt.Sprintf("failed to create GCP client: %v", err))
+ }
+
+ db, err := getDatabase(context.Background(), client, client.ProjectID(), instance, database)
+ if err != nil {
+ return ctx.ExecutionState.Fail("error", apiErrorMessage("failed to get database", err))
+ }
+
+ return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, "gcp.cloudsql.database", []any{databasePayload(db)})
+}
+
+func (g *GetDatabase) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (g *GetDatabase) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (g *GetDatabase) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) {
+ return http.StatusOK, nil, nil
+}
+
+func (g *GetDatabase) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (g *GetDatabase) Hooks() []core.Hook {
+ return []core.Hook{}
+}
+
+func (g *GetDatabase) HandleHook(ctx core.ActionHookContext) error {
+ return nil
+}
diff --git a/pkg/integrations/gcp/cloudsql/get_database_test.go b/pkg/integrations/gcp/cloudsql/get_database_test.go
new file mode 100644
index 0000000000..aeb9d31fbe
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/get_database_test.go
@@ -0,0 +1,82 @@
+package cloudsql
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__GetDatabase__Setup(t *testing.T) {
+ g := &GetDatabase{}
+ setup := func(cfg map[string]any) error {
+ return g.Setup(core.SetupContext{Configuration: cfg, Metadata: &contexts.MetadataContext{}})
+ }
+
+ t.Run("missing instance -> error", func(t *testing.T) {
+ require.ErrorContains(t, setup(map[string]any{"database": "app_db"}), "instance is required")
+ })
+
+ t.Run("missing database -> error", func(t *testing.T) {
+ require.ErrorContains(t, setup(map[string]any{"instance": "my-instance"}), "database is required")
+ })
+
+ t.Run("valid -> ok", func(t *testing.T) {
+ require.NoError(t, setup(map[string]any{"instance": "my-instance", "database": "app_db"}))
+ })
+}
+
+func Test__GetDatabase__Execute(t *testing.T) {
+ g := &GetDatabase{}
+
+ t.Run("fetches the database and emits its details", func(t *testing.T) {
+ var getURL string
+ mc := &mockClient{
+ projectID: "my-project",
+ getFunc: func(ctx context.Context, url string) ([]byte, error) {
+ getURL = url
+ return []byte(`{"name":"app_db","instance":"my-instance","project":"my-project","charset":"UTF8","collation":"en_US.UTF8","selfLink":"https://x/app_db"}`), nil
+ },
+ }
+ withFactory(mc)
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := g.Execute(core.ExecutionContext{
+ Configuration: map[string]any{"instance": "my-instance", "database": "app_db"},
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.True(t, state.Passed)
+ assert.Equal(t, "gcp.cloudsql.database", state.Type)
+ assert.Contains(t, getURL, "/projects/my-project/instances/my-instance/databases/app_db")
+
+ data := firstData(t, state)
+ assert.Equal(t, "app_db", data["name"])
+ assert.Equal(t, "en_US.UTF8", data["collation"])
+ })
+
+ t.Run("surfaces an API error", func(t *testing.T) {
+ mc := &mockClient{
+ projectID: "my-project",
+ getFunc: func(ctx context.Context, url string) ([]byte, error) {
+ return nil, fmt.Errorf("not found")
+ },
+ }
+ withFactory(mc)
+
+ state := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := g.Execute(core.ExecutionContext{
+ Configuration: map[string]any{"instance": "my-instance", "database": "missing"},
+ ExecutionState: state,
+ })
+
+ require.NoError(t, err)
+ assert.False(t, state.Passed)
+ assert.Contains(t, state.FailureMessage, "failed to get database")
+ })
+}
diff --git a/pkg/integrations/gcp/cloudsql/resources.go b/pkg/integrations/gcp/cloudsql/resources.go
new file mode 100644
index 0000000000..cc1682357f
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/resources.go
@@ -0,0 +1,49 @@
+package cloudsql
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const (
+ // ResourceTypeInstance lists the Cloud SQL instances in the project.
+ ResourceTypeInstance = "cloudsqlInstance"
+ // ResourceTypeDatabase lists the databases in a selected instance.
+ ResourceTypeDatabase = "cloudsqlDatabase"
+)
+
+// ListInstanceResources lists Cloud SQL instances for the instance dropdown.
+func ListInstanceResources(ctx context.Context, client Client) ([]core.IntegrationResource, error) {
+ instances, err := ListInstances(ctx, client, client.ProjectID())
+ if err != nil {
+ return nil, err
+ }
+ out := make([]core.IntegrationResource, 0, len(instances))
+ for _, inst := range instances {
+ label := inst.Name
+ if inst.DatabaseVersion != "" {
+ label = fmt.Sprintf("%s (%s)", inst.Name, inst.DatabaseVersion)
+ }
+ out = append(out, core.IntegrationResource{Type: ResourceTypeInstance, Name: label, ID: inst.Name})
+ }
+ return out, nil
+}
+
+// ListDatabaseResources lists the databases in the selected instance for the
+// database dropdown.
+func ListDatabaseResources(ctx context.Context, client Client, instance string) ([]core.IntegrationResource, error) {
+ if instance == "" {
+ return nil, nil
+ }
+ databases, err := ListDatabases(ctx, client, client.ProjectID(), instance)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]core.IntegrationResource, 0, len(databases))
+ for _, db := range databases {
+ out = append(out, core.IntegrationResource{Type: ResourceTypeDatabase, Name: db.Name, ID: db.Name})
+ }
+ return out, nil
+}
diff --git a/pkg/integrations/gcp/cloudsql/resources_test.go b/pkg/integrations/gcp/cloudsql/resources_test.go
new file mode 100644
index 0000000000..0c8fc835b1
--- /dev/null
+++ b/pkg/integrations/gcp/cloudsql/resources_test.go
@@ -0,0 +1,34 @@
+package cloudsql
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test__ListInstances__FollowsPagination(t *testing.T) {
+ calls := 0
+ mc := &mockClient{
+ projectID: "my-project",
+ getFunc: func(ctx context.Context, u string) ([]byte, error) {
+ calls++
+ // The first request has no pageToken; the second carries the token
+ // returned by the first page.
+ if !strings.Contains(u, "pageToken=") {
+ return []byte(`{"items":[{"name":"instance-a"}],"nextPageToken":"tok2"}`), nil
+ }
+ assert.Contains(t, u, "pageToken=tok2")
+ return []byte(`{"items":[{"name":"instance-b"}]}`), nil
+ },
+ }
+
+ instances, err := ListInstances(context.Background(), mc, "my-project")
+ require.NoError(t, err)
+ assert.Equal(t, 2, calls, "should fetch both pages")
+ require.Len(t, instances, 2)
+ assert.Equal(t, "instance-a", instances[0].Name)
+ assert.Equal(t, "instance-b", instances[1].Name)
+}
diff --git a/pkg/integrations/gcp/gcp.go b/pkg/integrations/gcp/gcp.go
index 6b548fc145..7cdace6fd6 100644
--- a/pkg/integrations/gcp/gcp.go
+++ b/pkg/integrations/gcp/gcp.go
@@ -18,6 +18,7 @@ import (
"github.com/superplanehq/superplane/pkg/integrations/gcp/cloudbuild"
"github.com/superplanehq/superplane/pkg/integrations/gcp/clouddns"
"github.com/superplanehq/superplane/pkg/integrations/gcp/cloudfunctions"
+ "github.com/superplanehq/superplane/pkg/integrations/gcp/cloudsql"
gcpcommon "github.com/superplanehq/superplane/pkg/integrations/gcp/common"
"github.com/superplanehq/superplane/pkg/integrations/gcp/compute"
"github.com/superplanehq/superplane/pkg/integrations/gcp/monitoring"
@@ -43,6 +44,9 @@ func init() {
clouddns.SetClientFactory(func(httpCtx core.HTTPContext, integration core.IntegrationContext) (clouddns.Client, error) {
return gcpcommon.NewClient(httpCtx, integration)
})
+ cloudsql.SetClientFactory(func(httpCtx core.HTTPContext, integration core.IntegrationContext) (cloudsql.Client, error) {
+ return gcpcommon.NewClient(httpCtx, integration)
+ })
monitoring.SetClientFactory(func(httpCtx core.HTTPContext, integration core.IntegrationContext) (monitoring.Client, error) {
return gcpcommon.NewClient(httpCtx, integration)
})
@@ -110,7 +114,7 @@ func (g *GCP) Instructions() string {
- ` + "`roles/logging.configWriter`" + ` — create logging sinks for event triggers
- ` + "`roles/pubsub.admin`" + ` — manage Pub/Sub topics, subscriptions, and IAM policies for event delivery
-- Additional roles depending on which components you use (e.g. ` + "`roles/compute.admin`" + ` for VM management, ` + "`roles/monitoring.viewer`" + ` to read VM metrics)`
+- Additional roles depending on which components you use (e.g. ` + "`roles/compute.admin`" + ` for VM management, ` + "`roles/monitoring.viewer`" + ` to read VM metrics, ` + "`roles/cloudsql.admin`" + ` to manage Cloud SQL databases)`
}
func (g *GCP) Configuration() []configuration.Field {
@@ -199,6 +203,9 @@ func (g *GCP) Actions() []core.Action {
&monitoring.GetAlertingPolicy{},
&monitoring.DeleteAlertingPolicy{},
&monitoring.UpdateAlertingPolicy{},
+ &cloudsql.CreateDatabase{},
+ &cloudsql.GetDatabase{},
+ &cloudsql.DeleteDatabase{},
&gcpprometheus.Query{},
&gcpprometheus.QueryRange{},
}
@@ -1001,6 +1008,10 @@ func (g *GCP) ListResources(resourceType string, ctx core.ListResourcesContext)
return gcppubsub.ListTopicResources(reqCtx, client)
case gcppubsub.ResourceTypeSubscription:
return gcppubsub.ListSubscriptionResources(reqCtx, client, p["topic"])
+ case cloudsql.ResourceTypeInstance:
+ return cloudsql.ListInstanceResources(reqCtx, client)
+ case cloudsql.ResourceTypeDatabase:
+ return cloudsql.ListDatabaseResources(reqCtx, client, p["instance"])
default:
return nil, nil
}
diff --git a/web_src/src/assets/icons/integrations/gcp.cloudsql.svg b/web_src/src/assets/icons/integrations/gcp.cloudsql.svg
new file mode 100644
index 0000000000..9f29818450
--- /dev/null
+++ b/web_src/src/assets/icons/integrations/gcp.cloudsql.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.spec.ts b/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.spec.ts
new file mode 100644
index 0000000000..a889c840b7
--- /dev/null
+++ b/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.spec.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+import { createDatabaseMapper, getDatabaseMapper, deleteDatabaseMapper } from "./cloudsql_mapper";
+import { buildDetailsCtx, buildOutput } from "./vm_mapper_test_helpers";
+
+describe("cloudsql mappers getExecutionDetails", () => {
+ it("createDatabase surfaces the created database details", () => {
+ const ctx = buildDetailsCtx({
+ execution: {
+ outputs: {
+ default: [buildOutput({ name: "app_db", instance: "my-instance", charset: "UTF8", collation: "en_US.UTF8" })],
+ },
+ },
+ });
+ const details = createDatabaseMapper.getExecutionDetails(ctx);
+ expect(details["Database"]).toBe("app_db");
+ expect(details["Instance"]).toBe("my-instance");
+ expect(details["Charset"]).toBe("UTF8");
+ expect(details["Completed At"]).toBeDefined();
+ });
+
+ it("getDatabase surfaces the fetched database details", () => {
+ const ctx = buildDetailsCtx({
+ execution: { outputs: { default: [buildOutput({ name: "app_db", instance: "my-instance" })] } },
+ });
+ const details = getDatabaseMapper.getExecutionDetails(ctx);
+ expect(details["Database"]).toBe("app_db");
+ expect(details["Instance"]).toBe("my-instance");
+ });
+
+ it("deleteDatabase marks the database as deleted", () => {
+ const ctx = buildDetailsCtx({
+ execution: { outputs: { default: [buildOutput({ name: "app_db", instance: "my-instance", deleted: true })] } },
+ });
+ const details = deleteDatabaseMapper.getExecutionDetails(ctx);
+ expect(details["Database"]).toBe("app_db");
+ expect(details["Deleted"]).toBe("true");
+ });
+
+ it("does not throw when outputs are missing", () => {
+ const ctx = buildDetailsCtx({ execution: { outputs: undefined } });
+ expect(() => getDatabaseMapper.getExecutionDetails(ctx)).not.toThrow();
+ });
+});
+
+describe("cloudsql mappers props metadata", () => {
+ function propsCtx(configuration: Record) {
+ return {
+ node: {
+ id: "n1",
+ name: "Create Database",
+ componentName: "gcp.cloudsql.createDatabase",
+ isCollapsed: false,
+ configuration,
+ metadata: {},
+ },
+ nodes: [],
+ lastExecutions: [],
+ componentDefinition: { name: "gcp.cloudsql.createDatabase", label: "Create Database", icon: "database" },
+ } as unknown as Parameters[0];
+ }
+
+ it("shows the instance and database as chips", () => {
+ const props = createDatabaseMapper.props(propsCtx({ instance: "my-instance", name: "app_db" }));
+ expect(props.metadata?.some((m) => m.label === "my-instance")).toBe(true);
+ expect(props.metadata?.some((m) => m.label === "app_db")).toBe(true);
+ });
+
+ it("hides unresolved expression values instead of rendering them raw", () => {
+ const props = getDatabaseMapper.props(propsCtx({ instance: "{{ $.inputs.instance }}", database: "app_db" }));
+ // The expression instance is hidden, leaving only the database chip.
+ expect(props.metadata?.length).toBe(1);
+ expect(props.metadata?.some((m) => m.label === "app_db")).toBe(true);
+ });
+});
diff --git a/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts b/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts
new file mode 100644
index 0000000000..0ad4ba5bc0
--- /dev/null
+++ b/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts
@@ -0,0 +1,102 @@
+import type React from "react";
+import type {
+ ComponentBaseMapper,
+ ComponentBaseContext,
+ EventStateRegistry,
+ ExecutionDetailsContext,
+ NodeInfo,
+ SubtitleContext,
+} from "../types";
+import type { ComponentBaseProps } from "@/ui/componentBase";
+import { baseMapper } from "./base";
+import { buildActionStateRegistry } from "../utils";
+import { renderTimeAgo } from "@/components/TimeAgo";
+import cloudSqlIcon from "@/assets/icons/integrations/gcp.cloudsql.svg";
+import type { MetadataItem } from "@/ui/metadataList";
+
+function cloudsqlProps(context: ComponentBaseContext): ComponentBaseProps {
+ return {
+ ...baseMapper.props(context),
+ iconSrc: cloudSqlIcon,
+ metadata: cloudsqlMetadataList(context.node),
+ };
+}
+
+function cloudsqlSubtitle(context: SubtitleContext): string | React.ReactNode {
+ const timestamp = context.execution.updatedAt || context.execution.createdAt;
+ return timestamp ? renderTimeAgo(new Date(timestamp)) : "";
+}
+
+type CloudSQLOutputs = {
+ default?: Array<{
+ data?: {
+ name?: string;
+ instance?: string;
+ charset?: string;
+ collation?: string;
+ deleted?: boolean;
+ };
+ }>;
+};
+
+function formatLocalDateTime(value?: string): string | undefined {
+ return value ? new Date(value).toLocaleString() : undefined;
+}
+
+function databaseDetails(context: ExecutionDetailsContext): Record {
+ const details: Record = {};
+ const completedAt = formatLocalDateTime(context.execution.updatedAt || context.execution.createdAt);
+ if (completedAt) details["Completed At"] = completedAt;
+
+ const item = (context.execution.outputs as CloudSQLOutputs | undefined)?.default?.[0]?.data;
+ if (!item) return details;
+ if (item.name) details["Database"] = item.name;
+ if (item.instance) details["Instance"] = item.instance;
+ if (item.charset) details["Charset"] = item.charset;
+ if (item.collation) details["Collation"] = item.collation;
+ if (item.deleted) details["Deleted"] = "true";
+ return details;
+}
+
+// displayValue returns a trimmed string only when it is safe to show on the
+// node: empty values and unresolved expressions (e.g. "{{ $.x }}") are hidden,
+// matching the other GCP mappers.
+function displayValue(value: unknown): string | undefined {
+ if (value === undefined || value === null) return undefined;
+ const trimmed = String(value).trim();
+ if (!trimmed || trimmed.includes("{{")) return undefined;
+ return trimmed;
+}
+
+function cloudsqlMetadataList(node: NodeInfo): MetadataItem[] {
+ const config = (node.configuration as Record | undefined) ?? {};
+ const metadata: MetadataItem[] = [];
+ const instance = displayValue(config.instance);
+ if (instance) metadata.push({ icon: "server", label: instance });
+ const db = displayValue(config.name ?? config.database);
+ if (db) metadata.push({ icon: "database", label: db });
+ return metadata;
+}
+
+export const createDatabaseMapper: ComponentBaseMapper = {
+ props: cloudsqlProps,
+ getExecutionDetails: databaseDetails,
+ subtitle: cloudsqlSubtitle,
+};
+
+export const getDatabaseMapper: ComponentBaseMapper = {
+ props: cloudsqlProps,
+ getExecutionDetails: databaseDetails,
+ subtitle: cloudsqlSubtitle,
+};
+
+export const deleteDatabaseMapper: ComponentBaseMapper = {
+ props: cloudsqlProps,
+ getExecutionDetails: databaseDetails,
+ subtitle: cloudsqlSubtitle,
+};
+
+// Per-action success labels so the node badge says what the component did.
+export const CLOUDSQL_CREATED_STATE_REGISTRY: EventStateRegistry = buildActionStateRegistry("created");
+export const CLOUDSQL_FETCHED_STATE_REGISTRY: EventStateRegistry = buildActionStateRegistry("fetched");
+export const CLOUDSQL_DELETED_STATE_REGISTRY: EventStateRegistry = buildActionStateRegistry("deleted");
diff --git a/web_src/src/pages/app/mappers/gcp/index.ts b/web_src/src/pages/app/mappers/gcp/index.ts
index 80b0dc06c5..57c52459c9 100644
--- a/web_src/src/pages/app/mappers/gcp/index.ts
+++ b/web_src/src/pages/app/mappers/gcp/index.ts
@@ -36,6 +36,14 @@ import { createImageMapper } from "./create_image";
import { updateImageMapper } from "./update_image";
import { deleteImageMapper } from "./delete_image";
import { createStaticIPMapper, deleteStaticIPMapper, manageStaticIPMapper } from "./static_ip";
+import {
+ createDatabaseMapper,
+ getDatabaseMapper,
+ deleteDatabaseMapper,
+ CLOUDSQL_CREATED_STATE_REGISTRY,
+ CLOUDSQL_FETCHED_STATE_REGISTRY,
+ CLOUDSQL_DELETED_STATE_REGISTRY,
+} from "./cloudsql_mapper";
export const componentMappers: Record = {
createVM: computeBaseMapper,
@@ -70,6 +78,9 @@ export const componentMappers: Record = {
"compute.createStaticIP": createStaticIPMapper,
"compute.deleteStaticIP": deleteStaticIPMapper,
"compute.manageStaticIP": manageStaticIPMapper,
+ "cloudsql.createDatabase": createDatabaseMapper,
+ "cloudsql.getDatabase": getDatabaseMapper,
+ "cloudsql.deleteDatabase": deleteDatabaseMapper,
};
export const triggerRenderers: Record = {
@@ -114,6 +125,9 @@ export const eventStateRegistry: Record = {
"compute.createStaticIP": buildActionStateRegistry("completed"),
"compute.deleteStaticIP": buildActionStateRegistry("completed"),
"compute.manageStaticIP": buildActionStateRegistry("completed"),
+ "cloudsql.createDatabase": CLOUDSQL_CREATED_STATE_REGISTRY,
+ "cloudsql.getDatabase": CLOUDSQL_FETCHED_STATE_REGISTRY,
+ "cloudsql.deleteDatabase": CLOUDSQL_DELETED_STATE_REGISTRY,
};
export const customFieldRenderers: Record = {};
diff --git a/web_src/src/ui/componentSidebar/integrationIconMaps.ts b/web_src/src/ui/componentSidebar/integrationIconMaps.ts
index b134fc026b..33d154095e 100644
--- a/web_src/src/ui/componentSidebar/integrationIconMaps.ts
+++ b/web_src/src/ui/componentSidebar/integrationIconMaps.ts
@@ -37,6 +37,7 @@ import gcpArtifactRegistryIcon from "@/assets/icons/integrations/gcp.artifactreg
import gcpPubSubIcon from "@/assets/icons/integrations/gcp.pubsub.svg";
import gcpCloudDNSIcon from "@/assets/icons/integrations/gcp.clouddns.svg";
import gcpComputeIcon from "@/assets/icons/integrations/gcp.compute.svg";
+import gcpCloudSqlIcon from "@/assets/icons/integrations/gcp.cloudsql.svg";
import gcpMonitoringIcon from "@/assets/icons/integrations/gcp.monitoring.svg";
import cursorIcon from "@/assets/icons/integrations/cursor.svg";
import perplexityIcon from "@/assets/icons/integrations/perplexity.svg";
@@ -178,6 +179,7 @@ export const APP_LOGO_MAP: Record> = {
artifactregistry: gcpArtifactRegistryIcon,
pubsub: gcpPubSubIcon,
clouddns: gcpCloudDNSIcon,
+ cloudsql: gcpCloudSqlIcon,
compute: gcpComputeIcon,
createVM: gcpComputeIcon,
deleteVMInstance: gcpComputeIcon,