From cb7448cb6bc4744107751733b1c54ab6394a7e58 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Wed, 10 Jun 2026 12:41:06 +0300 Subject: [PATCH 1/8] feat: add GCP Cloud SQL database components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add create / get / delete database actions for Cloud SQL, managing logical databases inside an existing Cloud SQL instance: - gcp.cloudsql.createDatabase — create a database in an instance (waits for the async operation to finish), emitting the created database's details. - gcp.cloudsql.getDatabase — fetch a database's name/instance/charset/ collation/selfLink. - gcp.cloudsql.deleteDatabase — delete a database (waits for the operation), emitting a deletion confirmation. The instance and database fields are integration-resource dropdowns (the database dropdown is scoped to the selected instance). Calls the Cloud SQL Admin API on sqladmin.googleapis.com via a package-local Client interface, wired into the gcp integration (client factory, Actions, ListResources, IAM note). Adds frontend mappers, unit tests, example outputs, and regenerated component docs. Requires roles/cloudsql.admin (or cloudsql.editor) and the Cloud SQL Admin API enabled. Signed-off-by: WashingtonKK --- docs/components/GoogleCloud.mdx | 137 ++++++++++- pkg/integrations/gcp/cloudsql/client.go | 42 ++++ pkg/integrations/gcp/cloudsql/common.go | 212 ++++++++++++++++++ pkg/integrations/gcp/cloudsql/common_test.go | 60 +++++ .../gcp/cloudsql/create_database.go | 167 ++++++++++++++ .../gcp/cloudsql/create_database_test.go | 100 +++++++++ .../gcp/cloudsql/delete_database.go | 176 +++++++++++++++ .../gcp/cloudsql/delete_database_test.go | 82 +++++++ pkg/integrations/gcp/cloudsql/example.go | 40 ++++ .../example_output_create_database.json | 12 + .../example_output_delete_database.json | 9 + .../cloudsql/example_output_get_database.json | 12 + pkg/integrations/gcp/cloudsql/get_database.go | 169 ++++++++++++++ .../gcp/cloudsql/get_database_test.go | 82 +++++++ pkg/integrations/gcp/cloudsql/resources.go | 49 ++++ pkg/integrations/gcp/gcp.go | 13 +- .../app/mappers/gcp/cloudsql_mapper.spec.ts | 43 ++++ .../pages/app/mappers/gcp/cloudsql_mapper.ts | 88 ++++++++ web_src/src/pages/app/mappers/gcp/index.ts | 12 + 19 files changed, 1503 insertions(+), 2 deletions(-) create mode 100644 pkg/integrations/gcp/cloudsql/client.go create mode 100644 pkg/integrations/gcp/cloudsql/common.go create mode 100644 pkg/integrations/gcp/cloudsql/common_test.go create mode 100644 pkg/integrations/gcp/cloudsql/create_database.go create mode 100644 pkg/integrations/gcp/cloudsql/create_database_test.go create mode 100644 pkg/integrations/gcp/cloudsql/delete_database.go create mode 100644 pkg/integrations/gcp/cloudsql/delete_database_test.go create mode 100644 pkg/integrations/gcp/cloudsql/example.go create mode 100644 pkg/integrations/gcp/cloudsql/example_output_create_database.json create mode 100644 pkg/integrations/gcp/cloudsql/example_output_delete_database.json create mode 100644 pkg/integrations/gcp/cloudsql/example_output_get_database.json create mode 100644 pkg/integrations/gcp/cloudsql/get_database.go create mode 100644 pkg/integrations/gcp/cloudsql/get_database_test.go create mode 100644 pkg/integrations/gcp/cloudsql/resources.go create mode 100644 web_src/src/pages/app/mappers/gcp/cloudsql_mapper.spec.ts create mode 100644 web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts diff --git a/docs/components/GoogleCloud.mdx b/docs/components/GoogleCloud.mdx index 173255e77e..12e258d814 100644 --- a/docs/components/GoogleCloud.mdx +++ b/docs/components/GoogleCloud.mdx @@ -28,6 +28,9 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + + + @@ -73,7 +76,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) @@ -758,6 +761,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..a50399cfb8 --- /dev/null +++ b/pkg/integrations/gcp/cloudsql/common.go @@ -0,0 +1,212 @@ +package cloudsql + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "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"` +} + +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. +func ListInstances(ctx context.Context, client Client, project string) ([]Instance, error) { + respBody, err := client.GetURL(ctx, instancesURL(project)) + if err != nil { + return nil, err + } + var resp struct { + Items []Instance `json:"items"` + } + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("parse instances list: %w", err) + } + return resp.Items, nil +} + +// 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..a20723e408 --- /dev/null +++ b/pkg/integrations/gcp/cloudsql/create_database.go @@ -0,0 +1,167 @@ +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"` +} + +type DatabaseNodeMetadata struct { + Instance string `json:"instance,omitempty" mapstructure:"instance"` + Database string `json:"database,omitempty" mapstructure:"database"` +} + +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/gcp.go b/pkg/integrations/gcp/gcp.go index 83006d59bf..22465ddf20 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" @@ -42,6 +43,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) }) @@ -106,7 +110,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 { @@ -195,6 +199,9 @@ func (g *GCP) Actions() []core.Action { &monitoring.GetAlertingPolicy{}, &monitoring.DeleteAlertingPolicy{}, &monitoring.UpdateAlertingPolicy{}, + &cloudsql.CreateDatabase{}, + &cloudsql.GetDatabase{}, + &cloudsql.DeleteDatabase{}, } } @@ -994,6 +1001,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/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..e06a3077d2 --- /dev/null +++ b/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.spec.ts @@ -0,0 +1,43 @@ +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(); + }); +}); 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..8a5d0dabfb --- /dev/null +++ b/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts @@ -0,0 +1,88 @@ +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 gcpIcon from "@/assets/icons/integrations/gcp.svg"; +import type { MetadataItem } from "@/ui/metadataList"; + +function cloudsqlProps(context: ComponentBaseContext): ComponentBaseProps { + return { + ...baseMapper.props(context), + iconSrc: gcpIcon, + 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; +} + +function cloudsqlMetadataList(node: NodeInfo): MetadataItem[] { + const config = (node.configuration as Record | undefined) ?? {}; + const metadata: MetadataItem[] = []; + if (config.instance) metadata.push({ icon: "server", label: String(config.instance) }); + const db = config.name || config.database; + if (db) metadata.push({ icon: "database", label: String(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, +}; + +export const CLOUDSQL_ACTION_STATE_REGISTRY: EventStateRegistry = buildActionStateRegistry("completed"); diff --git a/web_src/src/pages/app/mappers/gcp/index.ts b/web_src/src/pages/app/mappers/gcp/index.ts index 47b6791e65..cafb50ac44 100644 --- a/web_src/src/pages/app/mappers/gcp/index.ts +++ b/web_src/src/pages/app/mappers/gcp/index.ts @@ -34,6 +34,12 @@ 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_ACTION_STATE_REGISTRY, +} from "./cloudsql_mapper"; export const componentMappers: Record = { createVM: computeBaseMapper, @@ -66,6 +72,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 = { @@ -107,6 +116,9 @@ export const eventStateRegistry: Record = { "compute.createStaticIP": buildActionStateRegistry("completed"), "compute.deleteStaticIP": buildActionStateRegistry("completed"), "compute.manageStaticIP": buildActionStateRegistry("completed"), + "cloudsql.createDatabase": CLOUDSQL_ACTION_STATE_REGISTRY, + "cloudsql.getDatabase": CLOUDSQL_ACTION_STATE_REGISTRY, + "cloudsql.deleteDatabase": CLOUDSQL_ACTION_STATE_REGISTRY, }; export const customFieldRenderers: Record = {}; From 53cba40f30a0654c56ba783250a056578a0850e0 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Wed, 10 Jun 2026 12:48:38 +0300 Subject: [PATCH 2/8] fix: use the official Cloud SQL icon for the database nodes Add the official Google Cloud 'Cloud SQL' product icon (gcp.cloudsql.svg, same official 24px icon set as the other gcp.* icons) and point the create/ get/delete database node mappers at it instead of the generic GCP icon. Signed-off-by: WashingtonKK --- web_src/src/assets/icons/integrations/gcp.cloudsql.svg | 1 + web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 web_src/src/assets/icons/integrations/gcp.cloudsql.svg 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 @@ +Icon_24px_SQL_Color \ No newline at end of file diff --git a/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts b/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts index 8a5d0dabfb..c8df1de6ff 100644 --- a/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts +++ b/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts @@ -11,13 +11,13 @@ import type { ComponentBaseProps } from "@/ui/componentBase"; import { baseMapper } from "./base"; import { buildActionStateRegistry } from "../utils"; import { renderTimeAgo } from "@/components/TimeAgo"; -import gcpIcon from "@/assets/icons/integrations/gcp.svg"; +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: gcpIcon, + iconSrc: cloudSqlIcon, metadata: cloudsqlMetadataList(context.node), }; } From 29912e39a63663a99e78a3ca8656069e1dc55dd5 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Wed, 10 Jun 2026 12:59:06 +0300 Subject: [PATCH 3/8] refactor: address skills/standards review on cloudsql components - Frontend: hide unresolved expression values ({{ ... }}) and blanks from the node's metadata chips, matching the convention used by the other GCP mappers (event_helpers, image_helpers, monitoring, static_ip). - Move the shared DatabaseNodeMetadata type to common.go (it is used by the create/get/delete actions), improving package cohesion. - Add a props/metadata frontend test covering the chips and the expression guard. Signed-off-by: WashingtonKK --- pkg/integrations/gcp/cloudsql/common.go | 7 +++++ .../gcp/cloudsql/create_database.go | 5 --- .../app/mappers/gcp/cloudsql_mapper.spec.ts | 31 +++++++++++++++++++ .../pages/app/mappers/gcp/cloudsql_mapper.ts | 17 ++++++++-- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/pkg/integrations/gcp/cloudsql/common.go b/pkg/integrations/gcp/cloudsql/common.go index a50399cfb8..0f392cf3e2 100644 --- a/pkg/integrations/gcp/cloudsql/common.go +++ b/pkg/integrations/gcp/cloudsql/common.go @@ -41,6 +41,13 @@ type Instance struct { 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"` diff --git a/pkg/integrations/gcp/cloudsql/create_database.go b/pkg/integrations/gcp/cloudsql/create_database.go index a20723e408..aa056f1185 100644 --- a/pkg/integrations/gcp/cloudsql/create_database.go +++ b/pkg/integrations/gcp/cloudsql/create_database.go @@ -19,11 +19,6 @@ type CreateDatabaseSpec struct { Name string `json:"name" mapstructure:"name"` } -type DatabaseNodeMetadata struct { - Instance string `json:"instance,omitempty" mapstructure:"instance"` - Database string `json:"database,omitempty" mapstructure:"database"` -} - func (c *CreateDatabase) Name() string { return "gcp.cloudsql.createDatabase" } 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 index e06a3077d2..a889c840b7 100644 --- a/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.spec.ts +++ b/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.spec.ts @@ -41,3 +41,34 @@ describe("cloudsql mappers getExecutionDetails", () => { 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 index c8df1de6ff..e184fe9fe3 100644 --- a/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts +++ b/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts @@ -58,12 +58,23 @@ function databaseDetails(context: ExecutionDetailsContext): Record | undefined) ?? {}; const metadata: MetadataItem[] = []; - if (config.instance) metadata.push({ icon: "server", label: String(config.instance) }); - const db = config.name || config.database; - if (db) metadata.push({ icon: "database", label: String(db) }); + 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; } From b79e61fdd6d2976cdea454ab9fb7705dcf6f0f1e Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Wed, 10 Jun 2026 13:08:17 +0300 Subject: [PATCH 4/8] fix: paginate Cloud SQL instances list ListInstances issued a single instances.list request and ignored nextPageToken, so projects with more instances than one page (500 by default) had later instances omitted from the picker. Follow pageToken until the response has no nextPageToken. Adds a pagination test. (databases.list is not paginated, so it is left as-is.) Signed-off-by: WashingtonKK --- pkg/integrations/gcp/cloudsql/common.go | 37 +++++++++++++------ .../gcp/cloudsql/resources_test.go | 34 +++++++++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 pkg/integrations/gcp/cloudsql/resources_test.go diff --git a/pkg/integrations/gcp/cloudsql/common.go b/pkg/integrations/gcp/cloudsql/common.go index 0f392cf3e2..6dbc207a6c 100644 --- a/pkg/integrations/gcp/cloudsql/common.go +++ b/pkg/integrations/gcp/cloudsql/common.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "time" gcpcommon "github.com/superplanehq/superplane/pkg/integrations/gcp/common" @@ -130,19 +131,33 @@ func ListDatabases(ctx context.Context, client Client, project, instance string) return resp.Items, nil } -// ListInstances lists the Cloud SQL instances in the project. +// 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) { - respBody, err := client.GetURL(ctx, instancesURL(project)) - if err != nil { - return nil, err - } - var resp struct { - Items []Instance `json:"items"` - } - if err := json.Unmarshal(respBody, &resp); err != nil { - return nil, fmt.Errorf("parse instances list: %w", err) + 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 } - return resp.Items, nil } // databasePayload converts a Database into the component output payload. 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) +} From 886d858d72f86d0afe0997499fbfcbc55e2abd88 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Wed, 10 Jun 2026 16:38:37 +0300 Subject: [PATCH 5/8] fix: show the Cloud SQL icon in the component list getHeaderIconSrc looked up APP_LOGO_MAP.gcp["cloudsql"], which had no entry, so the create/get/delete database components fell back to the base GCP icon in the component list and header. Map gcp.cloudsql.* to the official Cloud SQL icon (the canvas node already used it via the mapper). Signed-off-by: WashingtonKK --- web_src/src/ui/componentSidebar/integrationIconMaps.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web_src/src/ui/componentSidebar/integrationIconMaps.ts b/web_src/src/ui/componentSidebar/integrationIconMaps.ts index 32b9a65b1d..668774d466 100644 --- a/web_src/src/ui/componentSidebar/integrationIconMaps.ts +++ b/web_src/src/ui/componentSidebar/integrationIconMaps.ts @@ -36,6 +36,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 cursorIcon from "@/assets/icons/integrations/cursor.svg"; import perplexityIcon from "@/assets/icons/integrations/perplexity.svg"; import pagerDutyIcon from "@/assets/icons/integrations/pagerduty.svg"; @@ -175,6 +176,7 @@ export const APP_LOGO_MAP: Record> = { artifactregistry: gcpArtifactRegistryIcon, pubsub: gcpPubSubIcon, clouddns: gcpCloudDNSIcon, + cloudsql: gcpCloudSqlIcon, compute: gcpComputeIcon, createVM: gcpComputeIcon, deleteVMInstance: gcpComputeIcon, From a1c45c866cf6950e28874eb4d568d6daf02975ff Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Fri, 12 Jun 2026 10:45:01 +0300 Subject: [PATCH 6/8] feat: add charset and collation to Create Database Match the console's create-database dialog by letting workflows set the character set and collation for a new logical database. Both fields are optional and omitted from the request when blank, so the database engine's defaults apply as before. The Cloud SQL Database resource has no labels field, so instance-level labels remain the only labeling option. Signed-off-by: WashingtonKK --- docs/components/GoogleCloud.mdx | 2 ++ pkg/integrations/gcp/cloudsql/common.go | 11 ++++-- .../gcp/cloudsql/create_database.go | 27 +++++++++++++-- .../gcp/cloudsql/create_database_test.go | 34 +++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/docs/components/GoogleCloud.mdx b/docs/components/GoogleCloud.mdx index 12e258d814..0f7cae3e35 100644 --- a/docs/components/GoogleCloud.mdx +++ b/docs/components/GoogleCloud.mdx @@ -779,6 +779,8 @@ The Create Database component adds a new logical database to an existing Cloud S - **Instance**: The Cloud SQL instance that will contain the new database (required) - **Database Name**: The name of the database to create (required, supports expressions) +- **Character Set**: The character set for the new database (optional, engine default when empty) +- **Collation**: The collation for the new database (optional, engine default when empty) ### Output diff --git a/pkg/integrations/gcp/cloudsql/common.go b/pkg/integrations/gcp/cloudsql/common.go index 6dbc207a6c..9fc09b63ea 100644 --- a/pkg/integrations/gcp/cloudsql/common.go +++ b/pkg/integrations/gcp/cloudsql/common.go @@ -81,9 +81,16 @@ func operationURL(project, operationName string) string { } // 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) { +// returned operation to finish, and returns the created database. Charset and +// collation are optional; when blank the database engine's defaults apply. +func createDatabase(ctx context.Context, client Client, project, instance, name, charset, collation string) (*Database, error) { body := map[string]any{"name": name, "project": project, "instance": instance} + if charset != "" { + body["charset"] = charset + } + if collation != "" { + body["collation"] = collation + } respBody, err := client.PostURL(ctx, databasesURL(project, instance), body) if err != nil { return nil, err diff --git a/pkg/integrations/gcp/cloudsql/create_database.go b/pkg/integrations/gcp/cloudsql/create_database.go index aa056f1185..5eab654b94 100644 --- a/pkg/integrations/gcp/cloudsql/create_database.go +++ b/pkg/integrations/gcp/cloudsql/create_database.go @@ -15,8 +15,10 @@ import ( type CreateDatabase struct{} type CreateDatabaseSpec struct { - Instance string `json:"instance" mapstructure:"instance"` - Name string `json:"name" mapstructure:"name"` + Instance string `json:"instance" mapstructure:"instance"` + Name string `json:"name" mapstructure:"name"` + Charset string `json:"charset" mapstructure:"charset"` + Collation string `json:"collation" mapstructure:"collation"` } func (c *CreateDatabase) Name() string { @@ -44,6 +46,8 @@ func (c *CreateDatabase) Documentation() string { - **Instance**: The Cloud SQL instance that will contain the new database (required) - **Database Name**: The name of the database to create (required, supports expressions) +- **Character Set**: The character set for the new database (optional, engine default when empty) +- **Collation**: The collation for the new database (optional, engine default when empty) ## Output @@ -90,6 +94,22 @@ func (c *CreateDatabase) Configuration() []configuration.Field { Description: "The name of the database to create", Placeholder: "app_db", }, + { + Name: "charset", + Label: "Character Set", + Type: configuration.FieldTypeString, + Required: false, + Description: "The character set (e.g. UTF8 for PostgreSQL, utf8mb4 for MySQL). Leave empty for the engine default.", + Placeholder: "e.g. utf8mb4", + }, + { + Name: "collation", + Label: "Collation", + Type: configuration.FieldTypeString, + Required: false, + Description: "The collation (e.g. en_US.UTF8 for PostgreSQL, utf8mb4_general_ci for MySQL). Leave empty for the engine default.", + Placeholder: "e.g. utf8mb4_general_ci", + }, } } @@ -129,7 +149,8 @@ func (c *CreateDatabase) Execute(ctx core.ExecutionContext) error { return ctx.ExecutionState.Fail("error", fmt.Sprintf("failed to create GCP client: %v", err)) } - db, err := createDatabase(context.Background(), client, client.ProjectID(), instance, name) + db, err := createDatabase(context.Background(), client, client.ProjectID(), instance, name, + strings.TrimSpace(spec.Charset), strings.TrimSpace(spec.Collation)) if err != nil { return ctx.ExecutionState.Fail("error", apiErrorMessage("failed to create database", err)) } diff --git a/pkg/integrations/gcp/cloudsql/create_database_test.go b/pkg/integrations/gcp/cloudsql/create_database_test.go index 40b40aebc3..cace232a78 100644 --- a/pkg/integrations/gcp/cloudsql/create_database_test.go +++ b/pkg/integrations/gcp/cloudsql/create_database_test.go @@ -64,6 +64,40 @@ func Test__CreateDatabase__Execute(t *testing.T) { assert.Equal(t, "app_db", data["name"]) assert.Equal(t, "my-instance", data["instance"]) assert.Equal(t, "UTF8", data["charset"]) + // No charset/collation configured -> omitted so the engine defaults apply. + _, hasCharset := postBody["charset"] + assert.False(t, hasCharset) + _, hasCollation := postBody["collation"] + assert.False(t, hasCollation) + }) + + t.Run("passes charset and collation through to the API", func(t *testing.T) { + var postBody map[string]any + mc := &mockClient{ + projectID: "my-project", + postFunc: func(ctx context.Context, url string, body any) ([]byte, error) { + 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":"utf8mb4","collation":"utf8mb4_general_ci"}`), 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", + "charset": "utf8mb4", "collation": "utf8mb4_general_ci", + }, + ExecutionState: state, + }) + + require.NoError(t, err) + assert.True(t, state.Passed) + assert.Equal(t, "utf8mb4", postBody["charset"]) + assert.Equal(t, "utf8mb4_general_ci", postBody["collation"]) }) t.Run("surfaces a failed operation", func(t *testing.T) { From 1bc792f7c41005ea3961841b6412cb20bbdd5708 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Fri, 12 Jun 2026 11:26:18 +0300 Subject: [PATCH 7/8] feat: label database executions by action instead of COMPLETED Replace the shared "completed" success state with per-action registries so the node badge reflects what the component did: Create Database shows CREATED, Get Database shows FETCHED, and Delete Database shows DELETED, matching the per-action labels used by other integrations. Signed-off-by: WashingtonKK --- web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts | 5 ++++- web_src/src/pages/app/mappers/gcp/index.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts b/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts index e184fe9fe3..0ad4ba5bc0 100644 --- a/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts +++ b/web_src/src/pages/app/mappers/gcp/cloudsql_mapper.ts @@ -96,4 +96,7 @@ export const deleteDatabaseMapper: ComponentBaseMapper = { subtitle: cloudsqlSubtitle, }; -export const CLOUDSQL_ACTION_STATE_REGISTRY: EventStateRegistry = buildActionStateRegistry("completed"); +// 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 cafb50ac44..0a9e4cdd3b 100644 --- a/web_src/src/pages/app/mappers/gcp/index.ts +++ b/web_src/src/pages/app/mappers/gcp/index.ts @@ -38,7 +38,9 @@ import { createDatabaseMapper, getDatabaseMapper, deleteDatabaseMapper, - CLOUDSQL_ACTION_STATE_REGISTRY, + CLOUDSQL_CREATED_STATE_REGISTRY, + CLOUDSQL_FETCHED_STATE_REGISTRY, + CLOUDSQL_DELETED_STATE_REGISTRY, } from "./cloudsql_mapper"; export const componentMappers: Record = { @@ -116,9 +118,9 @@ export const eventStateRegistry: Record = { "compute.createStaticIP": buildActionStateRegistry("completed"), "compute.deleteStaticIP": buildActionStateRegistry("completed"), "compute.manageStaticIP": buildActionStateRegistry("completed"), - "cloudsql.createDatabase": CLOUDSQL_ACTION_STATE_REGISTRY, - "cloudsql.getDatabase": CLOUDSQL_ACTION_STATE_REGISTRY, - "cloudsql.deleteDatabase": CLOUDSQL_ACTION_STATE_REGISTRY, + "cloudsql.createDatabase": CLOUDSQL_CREATED_STATE_REGISTRY, + "cloudsql.getDatabase": CLOUDSQL_FETCHED_STATE_REGISTRY, + "cloudsql.deleteDatabase": CLOUDSQL_DELETED_STATE_REGISTRY, }; export const customFieldRenderers: Record = {}; From 42bbf95ebb1210dabb0fa4404c11f864cc09eb00 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Fri, 12 Jun 2026 12:05:01 +0300 Subject: [PATCH 8/8] Revert "feat: add charset and collation to Create Database" This reverts commit a1c45c866cf6950e28874eb4d568d6daf02975ff. Signed-off-by: WashingtonKK --- docs/components/GoogleCloud.mdx | 2 -- pkg/integrations/gcp/cloudsql/common.go | 11 ++---- .../gcp/cloudsql/create_database.go | 27 ++------------- .../gcp/cloudsql/create_database_test.go | 34 ------------------- 4 files changed, 5 insertions(+), 69 deletions(-) diff --git a/docs/components/GoogleCloud.mdx b/docs/components/GoogleCloud.mdx index 0f7cae3e35..12e258d814 100644 --- a/docs/components/GoogleCloud.mdx +++ b/docs/components/GoogleCloud.mdx @@ -779,8 +779,6 @@ The Create Database component adds a new logical database to an existing Cloud S - **Instance**: The Cloud SQL instance that will contain the new database (required) - **Database Name**: The name of the database to create (required, supports expressions) -- **Character Set**: The character set for the new database (optional, engine default when empty) -- **Collation**: The collation for the new database (optional, engine default when empty) ### Output diff --git a/pkg/integrations/gcp/cloudsql/common.go b/pkg/integrations/gcp/cloudsql/common.go index 9fc09b63ea..6dbc207a6c 100644 --- a/pkg/integrations/gcp/cloudsql/common.go +++ b/pkg/integrations/gcp/cloudsql/common.go @@ -81,16 +81,9 @@ func operationURL(project, operationName string) string { } // createDatabase creates a logical database in the instance, waits for the -// returned operation to finish, and returns the created database. Charset and -// collation are optional; when blank the database engine's defaults apply. -func createDatabase(ctx context.Context, client Client, project, instance, name, charset, collation string) (*Database, error) { +// 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} - if charset != "" { - body["charset"] = charset - } - if collation != "" { - body["collation"] = collation - } respBody, err := client.PostURL(ctx, databasesURL(project, instance), body) if err != nil { return nil, err diff --git a/pkg/integrations/gcp/cloudsql/create_database.go b/pkg/integrations/gcp/cloudsql/create_database.go index 5eab654b94..aa056f1185 100644 --- a/pkg/integrations/gcp/cloudsql/create_database.go +++ b/pkg/integrations/gcp/cloudsql/create_database.go @@ -15,10 +15,8 @@ import ( type CreateDatabase struct{} type CreateDatabaseSpec struct { - Instance string `json:"instance" mapstructure:"instance"` - Name string `json:"name" mapstructure:"name"` - Charset string `json:"charset" mapstructure:"charset"` - Collation string `json:"collation" mapstructure:"collation"` + Instance string `json:"instance" mapstructure:"instance"` + Name string `json:"name" mapstructure:"name"` } func (c *CreateDatabase) Name() string { @@ -46,8 +44,6 @@ func (c *CreateDatabase) Documentation() string { - **Instance**: The Cloud SQL instance that will contain the new database (required) - **Database Name**: The name of the database to create (required, supports expressions) -- **Character Set**: The character set for the new database (optional, engine default when empty) -- **Collation**: The collation for the new database (optional, engine default when empty) ## Output @@ -94,22 +90,6 @@ func (c *CreateDatabase) Configuration() []configuration.Field { Description: "The name of the database to create", Placeholder: "app_db", }, - { - Name: "charset", - Label: "Character Set", - Type: configuration.FieldTypeString, - Required: false, - Description: "The character set (e.g. UTF8 for PostgreSQL, utf8mb4 for MySQL). Leave empty for the engine default.", - Placeholder: "e.g. utf8mb4", - }, - { - Name: "collation", - Label: "Collation", - Type: configuration.FieldTypeString, - Required: false, - Description: "The collation (e.g. en_US.UTF8 for PostgreSQL, utf8mb4_general_ci for MySQL). Leave empty for the engine default.", - Placeholder: "e.g. utf8mb4_general_ci", - }, } } @@ -149,8 +129,7 @@ func (c *CreateDatabase) Execute(ctx core.ExecutionContext) error { return ctx.ExecutionState.Fail("error", fmt.Sprintf("failed to create GCP client: %v", err)) } - db, err := createDatabase(context.Background(), client, client.ProjectID(), instance, name, - strings.TrimSpace(spec.Charset), strings.TrimSpace(spec.Collation)) + db, err := createDatabase(context.Background(), client, client.ProjectID(), instance, name) if err != nil { return ctx.ExecutionState.Fail("error", apiErrorMessage("failed to create database", err)) } diff --git a/pkg/integrations/gcp/cloudsql/create_database_test.go b/pkg/integrations/gcp/cloudsql/create_database_test.go index cace232a78..40b40aebc3 100644 --- a/pkg/integrations/gcp/cloudsql/create_database_test.go +++ b/pkg/integrations/gcp/cloudsql/create_database_test.go @@ -64,40 +64,6 @@ func Test__CreateDatabase__Execute(t *testing.T) { assert.Equal(t, "app_db", data["name"]) assert.Equal(t, "my-instance", data["instance"]) assert.Equal(t, "UTF8", data["charset"]) - // No charset/collation configured -> omitted so the engine defaults apply. - _, hasCharset := postBody["charset"] - assert.False(t, hasCharset) - _, hasCollation := postBody["collation"] - assert.False(t, hasCollation) - }) - - t.Run("passes charset and collation through to the API", func(t *testing.T) { - var postBody map[string]any - mc := &mockClient{ - projectID: "my-project", - postFunc: func(ctx context.Context, url string, body any) ([]byte, error) { - 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":"utf8mb4","collation":"utf8mb4_general_ci"}`), 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", - "charset": "utf8mb4", "collation": "utf8mb4_general_ci", - }, - ExecutionState: state, - }) - - require.NoError(t, err) - assert.True(t, state.Passed) - assert.Equal(t, "utf8mb4", postBody["charset"]) - assert.Equal(t, "utf8mb4_general_ci", postBody["collation"]) }) t.Run("surfaces a failed operation", func(t *testing.T) {