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 @@ +Icon_24px_SQL_Color \ 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,