From ab20bc4bb5cdfff5f7e1385ba6fd2f1a9eedee3b Mon Sep 17 00:00:00 2001 From: Aldo Date: Wed, 11 Feb 2026 08:23:06 +0700 Subject: [PATCH 01/12] feat: Add DigitalOcean integration Add DigitalOcean integration with: - Create Droplet component (with SSH keys, tags, user data support) - On Droplet Event trigger (polls for lifecycle events) - Client with account, regions, sizes, images, actions, and droplet APIs - Frontend mappers with execution details and event rendering - Icon registration in sidebar and component header Signed-off-by: Aldo --- docs/components/DigitalOcean.mdx | 157 +++++++++ pkg/integrations/digitalocean/client.go | 311 ++++++++++++++++++ .../digitalocean/create_droplet.go | 268 +++++++++++++++ .../digitalocean/create_droplet_test.go | 188 +++++++++++ pkg/integrations/digitalocean/digitalocean.go | 204 ++++++++++++ .../digitalocean/digitalocean_test.go | 152 +++++++++ pkg/integrations/digitalocean/example.go | 28 ++ .../example_data_on_droplet_event.json | 19 ++ .../example_output_create_droplet.json | 17 + .../digitalocean/on_droplet_event.go | 253 ++++++++++++++ .../digitalocean/on_droplet_event_test.go | 210 ++++++++++++ pkg/server/server.go | 1 + .../icons/integrations/digitalocean.svg | 5 + .../mappers/digitalocean/create_droplet.ts | 95 ++++++ .../workflowv2/mappers/digitalocean/index.ts | 16 + .../mappers/digitalocean/on_droplet_event.ts | 94 ++++++ web_src/src/pages/workflowv2/mappers/index.ts | 8 + .../src/ui/BuildingBlocksSidebar/index.tsx | 3 + .../ui/componentSidebar/integrationIcons.tsx | 3 + web_src/src/utils/integrationDisplayName.ts | 1 + 20 files changed, 2033 insertions(+) create mode 100644 docs/components/DigitalOcean.mdx create mode 100644 pkg/integrations/digitalocean/client.go create mode 100644 pkg/integrations/digitalocean/create_droplet.go create mode 100644 pkg/integrations/digitalocean/create_droplet_test.go create mode 100644 pkg/integrations/digitalocean/digitalocean.go create mode 100644 pkg/integrations/digitalocean/digitalocean_test.go create mode 100644 pkg/integrations/digitalocean/example.go create mode 100644 pkg/integrations/digitalocean/example_data_on_droplet_event.json create mode 100644 pkg/integrations/digitalocean/example_output_create_droplet.json create mode 100644 pkg/integrations/digitalocean/on_droplet_event.go create mode 100644 pkg/integrations/digitalocean/on_droplet_event_test.go create mode 100644 web_src/src/assets/icons/integrations/digitalocean.svg create mode 100644 web_src/src/pages/workflowv2/mappers/digitalocean/create_droplet.ts create mode 100644 web_src/src/pages/workflowv2/mappers/digitalocean/index.ts create mode 100644 web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts diff --git a/docs/components/DigitalOcean.mdx b/docs/components/DigitalOcean.mdx new file mode 100644 index 0000000000..e957a687f7 --- /dev/null +++ b/docs/components/DigitalOcean.mdx @@ -0,0 +1,157 @@ +--- +title: "DigitalOcean" +--- + +Manage and monitor your DigitalOcean infrastructure + +## Triggers + + + + + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + +## Actions + + + + + +## Instructions + +## Create a DigitalOcean Personal Access Token + +1. Open the [DigitalOcean API Tokens page](https://cloud.digitalocean.com/account/api/tokens) +2. Click **Generate New Token** +3. Configure the token: + - **Token name**: SuperPlane Integration + - **Expiration**: No expiry (or choose an appropriate expiration) + - **Scopes**: Select **Full Access** (or customize as needed) +4. Click **Generate Token** +5. Copy the token and paste it below + +> **Note**: The token is only shown once. Store it securely if needed elsewhere. + + + +## On Droplet Event + +The On Droplet Event trigger polls the DigitalOcean API for droplet lifecycle events. + +### Use Cases + +- **Infrastructure monitoring**: React to droplet creation and destruction events +- **Audit logging**: Track all droplet lifecycle changes +- **Automation**: Trigger workflows when droplets are powered on/off or resized + +### Configuration + +- **Events**: Select which droplet event types to listen for (create, destroy, power_on, power_off, shutdown, reboot, snapshot, rebuild, resize, rename) + +### Polling + +This trigger polls the DigitalOcean Actions API every 60 seconds for new completed droplet events matching the configured event types. + +### Event Data + +Each event includes: +- **action**: The DigitalOcean action object with id, status, type, timestamps, resource_id, and region_slug + +### Example Data + +```json +{ + "action": { + "completed_at": "2026-01-19T12:01:30Z", + "id": 123456789, + "region_slug": "nyc3", + "resource_id": 98765432, + "resource_type": "droplet", + "started_at": "2026-01-19T12:00:00Z", + "status": "completed", + "type": "create" + }, + "droplet": { + "id": 98765432, + "image": { + "name": "Ubuntu 24.04 (LTS) x64", + "slug": "ubuntu-24-04-x64" + }, + "name": "my-droplet", + "region": { + "name": "New York 3", + "slug": "nyc3" + }, + "size_slug": "s-1vcpu-1gb" + } +} +``` + + + +## Create Droplet + +The Create Droplet component creates a new droplet in DigitalOcean. + +### Use Cases + +- **Infrastructure provisioning**: Automatically provision droplets from workflow events +- **Scaling**: Create new instances in response to load or alerts +- **Environment setup**: Spin up droplets for testing or staging environments + +### Configuration + +- **Name**: The hostname for the droplet (required, supports expressions) +- **Region**: Region slug where the droplet will be created (required) +- **Size**: Size slug for the droplet (required) +- **Image**: Image slug or ID for the droplet OS (required) +- **SSH Keys**: SSH key fingerprints or IDs to add to the droplet (optional) +- **Tags**: Tags to apply to the droplet (optional) +- **User Data**: Cloud-init user data script (optional) + +### Output + +Returns the created droplet object including: +- **id**: Droplet ID +- **name**: Droplet hostname +- **status**: Current droplet status +- **region**: Region information +- **networks**: Network information including IP addresses + +### Example Output + +```json +{ + "data": { + "disk": 25, + "id": 98765432, + "image": { + "id": 12345, + "name": "Ubuntu 24.04 (LTS) x64", + "slug": "ubuntu-24-04-x64" + }, + "memory": 1024, + "name": "my-droplet", + "networks": { + "v4": [ + { + "ip_address": "104.131.186.241", + "type": "public" + } + ] + }, + "region": { + "name": "New York 3", + "slug": "nyc3" + }, + "size_slug": "s-1vcpu-1gb", + "status": "new", + "tags": [ + "web" + ], + "vcpus": 1 + } +} +``` + diff --git a/pkg/integrations/digitalocean/client.go b/pkg/integrations/digitalocean/client.go new file mode 100644 index 0000000000..8ff340b170 --- /dev/null +++ b/pkg/integrations/digitalocean/client.go @@ -0,0 +1,311 @@ +package digitalocean + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/superplanehq/superplane/pkg/core" +) + +const baseURL = "https://api.digitalocean.com/v2" + +type Client struct { + Token string + http core.HTTPContext + BaseURL string +} + +type DOAPIError struct { + StatusCode int + Body []byte +} + +func (e *DOAPIError) Error() string { + return fmt.Sprintf("request got %d code: %s", e.StatusCode, string(e.Body)) +} + +func NewClient(http core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { + apiToken, err := ctx.GetConfig("apiToken") + if err != nil { + return nil, fmt.Errorf("error finding API token: %v", err) + } + + return &Client{ + Token: string(apiToken), + http: http, + BaseURL: baseURL, + }, nil +} + +func (c *Client) execRequest(method, url string, body io.Reader) ([]byte, error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("error building request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token)) + + res, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %v", err) + } + defer res.Body.Close() + + responseBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading body: %v", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, &DOAPIError{ + StatusCode: res.StatusCode, + Body: responseBody, + } + } + + return responseBody, nil +} + +// Account represents a DigitalOcean account +type Account struct { + Email string `json:"email"` + UUID string `json:"uuid"` + Status string `json:"status"` + DropletLimit int `json:"droplet_limit"` +} + +// GetAccount validates the API token by fetching account info +func (c *Client) GetAccount() (*Account, error) { + url := fmt.Sprintf("%s/account", c.BaseURL) + responseBody, err := c.execRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + var response struct { + Account Account `json:"account"` + } + + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return &response.Account, nil +} + +// Region represents a DigitalOcean region +type Region struct { + Slug string `json:"slug"` + Name string `json:"name"` + Available bool `json:"available"` +} + +// ListRegions retrieves all available regions +func (c *Client) ListRegions() ([]Region, error) { + url := fmt.Sprintf("%s/regions", c.BaseURL) + responseBody, err := c.execRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + var response struct { + Regions []Region `json:"regions"` + } + + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return response.Regions, nil +} + +// Size represents a DigitalOcean droplet size +type Size struct { + Slug string `json:"slug"` + Memory int `json:"memory"` + VCPUs int `json:"vcpus"` + Disk int `json:"disk"` + PriceMonthly float64 `json:"price_monthly"` + Available bool `json:"available"` +} + +// ListSizes retrieves all available droplet sizes +func (c *Client) ListSizes() ([]Size, error) { + url := fmt.Sprintf("%s/sizes?per_page=200", c.BaseURL) + responseBody, err := c.execRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + var response struct { + Sizes []Size `json:"sizes"` + } + + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return response.Sizes, nil +} + +// Image represents a DigitalOcean image +type Image struct { + ID int `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Type string `json:"type"` + Distribution string `json:"distribution"` +} + +// ListImages retrieves images of a given type (e.g., "distribution") +func (c *Client) ListImages(imageType string) ([]Image, error) { + url := fmt.Sprintf("%s/images?type=%s&per_page=200", c.BaseURL, imageType) + responseBody, err := c.execRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + var response struct { + Images []Image `json:"images"` + } + + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return response.Images, nil +} + +// CreateDropletRequest is the payload for creating a droplet +type CreateDropletRequest struct { + Name string `json:"name"` + Region string `json:"region"` + Size string `json:"size"` + Image string `json:"image"` + SSHKeys []string `json:"ssh_keys,omitempty"` + Tags []string `json:"tags,omitempty"` + UserData string `json:"user_data,omitempty"` +} + +// Droplet represents a DigitalOcean droplet +type Droplet struct { + ID int `json:"id"` + Name string `json:"name"` + Memory int `json:"memory"` + VCPUs int `json:"vcpus"` + Disk int `json:"disk"` + Status string `json:"status"` + Region DropletRegion `json:"region"` + Image DropletImage `json:"image"` + SizeSlug string `json:"size_slug"` + Networks DropletNetworks `json:"networks"` + Tags []string `json:"tags"` +} + +type DropletRegion struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +type DropletImage struct { + ID int `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type DropletNetworks struct { + V4 []DropletNetworkV4 `json:"v4"` +} + +type DropletNetworkV4 struct { + IPAddress string `json:"ip_address"` + Type string `json:"type"` +} + +// CreateDroplet creates a new droplet +func (c *Client) CreateDroplet(req CreateDropletRequest) (*Droplet, error) { + url := fmt.Sprintf("%s/droplets", c.BaseURL) + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %v", err) + } + + responseBody, err := c.execRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + var response struct { + Droplet Droplet `json:"droplet"` + } + + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return &response.Droplet, nil +} + +// GetDroplet retrieves a droplet by its ID +func (c *Client) GetDroplet(dropletID int) (*Droplet, error) { + url := fmt.Sprintf("%s/droplets/%d", c.BaseURL, dropletID) + responseBody, err := c.execRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + var response struct { + Droplet Droplet `json:"droplet"` + } + + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return &response.Droplet, nil +} + +// DOAction represents a DigitalOcean action +type DOAction struct { + ID int `json:"id"` + Status string `json:"status"` + Type string `json:"type"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + ResourceID int `json:"resource_id"` + ResourceType string `json:"resource_type"` + RegionSlug string `json:"region_slug"` +} + +// ListActions retrieves actions filtered by resource type. +// The DigitalOcean /v2/actions API does not support resource_type as a query +// parameter, so we fetch all recent actions and filter client-side. +func (c *Client) ListActions(resourceType string) ([]DOAction, error) { + url := fmt.Sprintf("%s/actions?page=1&per_page=50", c.BaseURL) + responseBody, err := c.execRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + var response struct { + Actions []DOAction `json:"actions"` + } + + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + filtered := make([]DOAction, 0, len(response.Actions)) + for _, a := range response.Actions { + if a.ResourceType == resourceType { + filtered = append(filtered, a) + } + } + + return filtered, nil +} diff --git a/pkg/integrations/digitalocean/create_droplet.go b/pkg/integrations/digitalocean/create_droplet.go new file mode 100644 index 0000000000..6bbcdf079f --- /dev/null +++ b/pkg/integrations/digitalocean/create_droplet.go @@ -0,0 +1,268 @@ +package digitalocean + +import ( + "errors" + "fmt" + "net/http" + "regexp" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +var validHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.\-]*$`) + +type CreateDroplet struct{} + +type CreateDropletSpec struct { + Name string `json:"name"` + Region string `json:"region"` + Size string `json:"size"` + Image string `json:"image"` + SSHKeys []string `json:"sshKeys"` + Tags []string `json:"tags"` + UserData string `json:"userData"` +} + +func (c *CreateDroplet) Name() string { + return "digitalocean.createDroplet" +} + +func (c *CreateDroplet) Label() string { + return "Create Droplet" +} + +func (c *CreateDroplet) Description() string { + return "Create a new DigitalOcean Droplet" +} + +func (c *CreateDroplet) Documentation() string { + return `The Create Droplet component creates a new droplet in DigitalOcean. + +## Use Cases + +- **Infrastructure provisioning**: Automatically provision droplets from workflow events +- **Scaling**: Create new instances in response to load or alerts +- **Environment setup**: Spin up droplets for testing or staging environments + +## Configuration + +- **Name**: The hostname for the droplet (required, supports expressions) +- **Region**: Region slug where the droplet will be created (required) +- **Size**: Size slug for the droplet (required) +- **Image**: Image slug or ID for the droplet OS (required) +- **SSH Keys**: SSH key fingerprints or IDs to add to the droplet (optional) +- **Tags**: Tags to apply to the droplet (optional) +- **User Data**: Cloud-init user data script (optional) + +## Output + +Returns the created droplet object including: +- **id**: Droplet ID +- **name**: Droplet hostname +- **status**: Current droplet status +- **region**: Region information +- **networks**: Network information including IP addresses` +} + +func (c *CreateDroplet) Icon() string { + return "server" +} + +func (c *CreateDroplet) Color() string { + return "gray" +} + +func (c *CreateDroplet) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *CreateDroplet) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "name", + Label: "Droplet Name", + Type: configuration.FieldTypeString, + Required: true, + Description: "The hostname for the droplet", + }, + { + Name: "region", + Label: "Region", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The region where the droplet will be created", + Placeholder: "Select a region", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "region", + }, + }, + }, + { + Name: "size", + Label: "Size", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The size (CPU/RAM) for the droplet", + Placeholder: "Select a size", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "size", + }, + }, + }, + { + Name: "image", + Label: "Image", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The OS image for the droplet", + Placeholder: "Select an image", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "image", + }, + }, + }, + { + Name: "sshKeys", + Label: "SSH Keys", + Type: configuration.FieldTypeList, + Required: false, + Togglable: true, + Description: "SSH key fingerprints or IDs to add to the droplet", + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "SSH Key", + ItemDefinition: &configuration.ListItemDefinition{ + Type: configuration.FieldTypeString, + }, + }, + }, + }, + { + Name: "tags", + Label: "Tags", + Type: configuration.FieldTypeList, + Required: false, + Togglable: true, + Description: "Tags to apply to the droplet", + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "Tag", + ItemDefinition: &configuration.ListItemDefinition{ + Type: configuration.FieldTypeString, + }, + }, + }, + }, + { + Name: "userData", + Label: "User Data", + Type: configuration.FieldTypeText, + Required: false, + Togglable: true, + Description: "Cloud-init user data script", + }, + } +} + +func (c *CreateDroplet) Setup(ctx core.SetupContext) error { + spec := CreateDropletSpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + if spec.Name == "" { + return errors.New("name is required") + } + + // Hostname validation is deferred to Execute because the name field + // supports expressions (e.g. {{ $.trigger.data.hostname }}) that are + // only resolved at execution time. + + if spec.Region == "" { + return errors.New("region is required") + } + + if spec.Size == "" { + return errors.New("size is required") + } + + if spec.Image == "" { + return errors.New("image is required") + } + + return nil +} + +func validateHostname(name string) error { + if !validHostnameRegex.MatchString(name) { + return fmt.Errorf("invalid droplet name %q: only letters (a-z, A-Z), numbers (0-9), hyphens (-) and dots (.) are allowed", name) + } + return nil +} + +func (c *CreateDroplet) Execute(ctx core.ExecutionContext) error { + spec := CreateDropletSpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + if err := validateHostname(spec.Name); err != nil { + return err + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + droplet, err := client.CreateDroplet(CreateDropletRequest{ + Name: spec.Name, + Region: spec.Region, + Size: spec.Size, + Image: spec.Image, + SSHKeys: spec.SSHKeys, + Tags: spec.Tags, + UserData: spec.UserData, + }) + if err != nil { + return fmt.Errorf("failed to create droplet: %v", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "digitalocean.droplet.created", + []any{droplet}, + ) +} + +func (c *CreateDroplet) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *CreateDroplet) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *CreateDroplet) Actions() []core.Action { + return []core.Action{} +} + +func (c *CreateDroplet) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *CreateDroplet) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (c *CreateDroplet) Cleanup(ctx core.SetupContext) error { + return nil +} diff --git a/pkg/integrations/digitalocean/create_droplet_test.go b/pkg/integrations/digitalocean/create_droplet_test.go new file mode 100644 index 0000000000..3d60c695f3 --- /dev/null +++ b/pkg/integrations/digitalocean/create_droplet_test.go @@ -0,0 +1,188 @@ +package digitalocean + +import ( + "io" + "net/http" + "strings" + "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__CreateDroplet__Setup(t *testing.T) { + component := &CreateDroplet{} + + t.Run("missing name returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "region": "nyc3", + "size": "s-1vcpu-1gb", + "image": "ubuntu-24-04-x64", + }, + }) + + require.ErrorContains(t, err, "name is required") + }) + + t.Run("missing region returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "name": "my-droplet", + "size": "s-1vcpu-1gb", + "image": "ubuntu-24-04-x64", + }, + }) + + require.ErrorContains(t, err, "region is required") + }) + + t.Run("missing size returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "name": "my-droplet", + "region": "nyc3", + "image": "ubuntu-24-04-x64", + }, + }) + + require.ErrorContains(t, err, "size is required") + }) + + t.Run("missing image returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "name": "my-droplet", + "region": "nyc3", + "size": "s-1vcpu-1gb", + }, + }) + + require.ErrorContains(t, err, "image is required") + }) + + t.Run("expression name is accepted at setup time", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "name": "{{ $.trigger.data.hostname }}", + "region": "nyc3", + "size": "s-1vcpu-1gb", + "image": "ubuntu-24-04-x64", + }, + }) + + require.NoError(t, err) + }) + + t.Run("valid configuration -> no error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "name": "my-droplet", + "region": "nyc3", + "size": "s-1vcpu-1gb", + "image": "ubuntu-24-04-x64", + }, + }) + + require.NoError(t, err) + }) +} + +func Test__CreateDroplet__Execute(t *testing.T) { + component := &CreateDroplet{} + + t.Run("successful creation -> emits droplet data", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(`{ + "droplet": { + "id": 98765432, + "name": "my-droplet", + "memory": 1024, + "vcpus": 1, + "disk": 25, + "status": "new", + "region": {"name": "New York 3", "slug": "nyc3"}, + "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"}, + "size_slug": "s-1vcpu-1gb", + "networks": {"v4": [{"ip_address": "104.131.186.241", "type": "public"}]}, + "tags": ["web"] + } + }`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + + executionState := &contexts.ExecutionStateContext{ + KVs: map[string]string{}, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "name": "my-droplet", + "region": "nyc3", + "size": "s-1vcpu-1gb", + "image": "ubuntu-24-04-x64", + "tags": []string{"web"}, + }, + HTTP: httpContext, + Integration: integrationCtx, + ExecutionState: executionState, + }) + + require.NoError(t, err) + assert.True(t, executionState.Passed) + assert.Equal(t, "default", executionState.Channel) + assert.Equal(t, "digitalocean.droplet.created", executionState.Type) + + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, "https://api.digitalocean.com/v2/droplets", httpContext.Requests[0].URL.String()) + assert.Equal(t, http.MethodPost, httpContext.Requests[0].Method) + }) + + t.Run("API error -> returns error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusUnprocessableEntity, + Body: io.NopCloser(strings.NewReader(`{"id":"unprocessable_entity","message":"Name is already in use"}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + + executionState := &contexts.ExecutionStateContext{ + KVs: map[string]string{}, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "name": "my-droplet", + "region": "nyc3", + "size": "s-1vcpu-1gb", + "image": "ubuntu-24-04-x64", + }, + HTTP: httpContext, + Integration: integrationCtx, + ExecutionState: executionState, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create droplet") + }) +} diff --git a/pkg/integrations/digitalocean/digitalocean.go b/pkg/integrations/digitalocean/digitalocean.go new file mode 100644 index 0000000000..f88cb4c012 --- /dev/null +++ b/pkg/integrations/digitalocean/digitalocean.go @@ -0,0 +1,204 @@ +package digitalocean + +import ( + "fmt" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +func init() { + registry.RegisterIntegration("digitalocean", &DigitalOcean{}) +} + +type DigitalOcean struct{} + +type Configuration struct { + APIToken string `json:"apiToken"` +} + +type Metadata struct { + AccountEmail string `json:"accountEmail"` + AccountUUID string `json:"accountUUID"` +} + +func (d *DigitalOcean) Name() string { + return "digitalocean" +} + +func (d *DigitalOcean) Label() string { + return "DigitalOcean" +} + +func (d *DigitalOcean) Icon() string { + return "digitalocean" +} + +func (d *DigitalOcean) Description() string { + return "Manage and monitor your DigitalOcean infrastructure" +} + +func (d *DigitalOcean) Instructions() string { + return `## Create a DigitalOcean Personal Access Token + +1. Open the [DigitalOcean API Tokens page](https://cloud.digitalocean.com/account/api/tokens) +2. Click **Generate New Token** +3. Configure the token: + - **Token name**: SuperPlane Integration + - **Expiration**: No expiry (or choose an appropriate expiration) + - **Scopes**: Select **Full Access** (or customize as needed) +4. Click **Generate Token** +5. Copy the token and paste it below + +> **Note**: The token is only shown once. Store it securely if needed elsewhere.` +} + +func (d *DigitalOcean) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "apiToken", + Label: "API Token", + Type: configuration.FieldTypeString, + Required: true, + Sensitive: true, + Description: "DigitalOcean Personal Access Token", + }, + } +} + +func (d *DigitalOcean) Components() []core.Component { + return []core.Component{ + &CreateDroplet{}, + } +} + +func (d *DigitalOcean) Triggers() []core.Trigger { + return []core.Trigger{ + &OnDropletEvent{}, + } +} + +func (d *DigitalOcean) Sync(ctx core.SyncContext) error { + config := Configuration{} + err := mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return fmt.Errorf("failed to decode config: %v", err) + } + + if config.APIToken == "" { + return fmt.Errorf("apiToken is required") + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + account, err := client.GetAccount() + if err != nil { + return fmt.Errorf("error fetching account: %v", err) + } + + ctx.Integration.SetMetadata(Metadata{ + AccountEmail: account.Email, + AccountUUID: account.UUID, + }) + ctx.Integration.Ready() + return nil +} + +func (d *DigitalOcean) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (d *DigitalOcean) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + switch resourceType { + case "region": + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + regions, err := client.ListRegions() + if err != nil { + return nil, fmt.Errorf("error listing regions: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(regions)) + for _, region := range regions { + if region.Available { + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: region.Name, + ID: region.Slug, + }) + } + } + return resources, nil + + case "size": + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + sizes, err := client.ListSizes() + if err != nil { + return nil, fmt.Errorf("error listing sizes: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(sizes)) + for _, size := range sizes { + if size.Available { + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: size.Slug, + ID: size.Slug, + }) + } + } + return resources, nil + + case "image": + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + images, err := client.ListImages("distribution") + if err != nil { + return nil, fmt.Errorf("error listing images: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(images)) + for _, image := range images { + name := image.Name + if image.Distribution != "" { + name = fmt.Sprintf("%s %s", image.Distribution, image.Name) + } + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: name, + ID: image.Slug, + }) + } + return resources, nil + + default: + return []core.IntegrationResource{}, nil + } +} + +func (d *DigitalOcean) HandleRequest(ctx core.HTTPRequestContext) { + // no-op +} + +func (d *DigitalOcean) Actions() []core.Action { + return []core.Action{} +} + +func (d *DigitalOcean) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} diff --git a/pkg/integrations/digitalocean/digitalocean_test.go b/pkg/integrations/digitalocean/digitalocean_test.go new file mode 100644 index 0000000000..39ccb93c72 --- /dev/null +++ b/pkg/integrations/digitalocean/digitalocean_test.go @@ -0,0 +1,152 @@ +package digitalocean + +import ( + "io" + "net/http" + "strings" + "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__DigitalOcean__Sync(t *testing.T) { + d := &DigitalOcean{} + + t.Run("no apiToken -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "", + }, + } + + err := d.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + }) + + require.ErrorContains(t, err, "apiToken is required") + }) + + t.Run("valid token -> fetches account, sets ready and metadata", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "account": { + "email": "user@example.com", + "uuid": "abc-123", + "status": "active", + "droplet_limit": 25 + } + }`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + }, + } + + err := d.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + HTTP: httpContext, + Integration: integrationCtx, + }) + + require.NoError(t, err) + assert.Equal(t, "ready", integrationCtx.State) + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, "https://api.digitalocean.com/v2/account", httpContext.Requests[0].URL.String()) + + metadata := integrationCtx.Metadata.(Metadata) + assert.Equal(t, "user@example.com", metadata.AccountEmail) + assert.Equal(t, "abc-123", metadata.AccountUUID) + }) + + t.Run("invalid token (401) -> error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader(`{"id":"unauthorized","message":"Unable to authenticate you"}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "invalid-token", + }, + } + + err := d.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + HTTP: httpContext, + Integration: integrationCtx, + }) + + require.Error(t, err) + assert.NotEqual(t, "ready", integrationCtx.State) + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, "https://api.digitalocean.com/v2/account", httpContext.Requests[0].URL.String()) + assert.Nil(t, integrationCtx.Metadata) + }) +} + +func Test__DigitalOcean__ListResources(t *testing.T) { + d := &DigitalOcean{} + + t.Run("list regions", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "regions": [ + {"slug": "nyc3", "name": "New York 3", "available": true}, + {"slug": "sfo3", "name": "San Francisco 3", "available": true}, + {"slug": "old1", "name": "Old Region", "available": false} + ] + }`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + }, + } + + resources, err := d.ListResources("region", core.ListResourcesContext{ + HTTP: httpContext, + Integration: integrationCtx, + }) + + require.NoError(t, err) + assert.Len(t, resources, 2) + assert.Equal(t, "region", resources[0].Type) + assert.Equal(t, "New York 3", resources[0].Name) + assert.Equal(t, "nyc3", resources[0].ID) + }) + + t.Run("unknown resource type returns empty list", func(t *testing.T) { + resources, err := d.ListResources("unknown", core.ListResourcesContext{ + HTTP: &contexts.HTTPContext{}, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + }, + }, + }) + + require.NoError(t, err) + assert.Empty(t, resources) + }) +} diff --git a/pkg/integrations/digitalocean/example.go b/pkg/integrations/digitalocean/example.go new file mode 100644 index 0000000000..0932babbb8 --- /dev/null +++ b/pkg/integrations/digitalocean/example.go @@ -0,0 +1,28 @@ +package digitalocean + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_data_on_droplet_event.json +var exampleDataOnDropletEventBytes []byte + +var exampleDataOnDropletEventOnce sync.Once +var exampleDataOnDropletEvent map[string]any + +//go:embed example_output_create_droplet.json +var exampleOutputCreateDropletBytes []byte + +var exampleOutputCreateDropletOnce sync.Once +var exampleOutputCreateDroplet map[string]any + +func (t *OnDropletEvent) ExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleDataOnDropletEventOnce, exampleDataOnDropletEventBytes, &exampleDataOnDropletEvent) +} + +func (c *CreateDroplet) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateDropletOnce, exampleOutputCreateDropletBytes, &exampleOutputCreateDroplet) +} diff --git a/pkg/integrations/digitalocean/example_data_on_droplet_event.json b/pkg/integrations/digitalocean/example_data_on_droplet_event.json new file mode 100644 index 0000000000..fc8df7f301 --- /dev/null +++ b/pkg/integrations/digitalocean/example_data_on_droplet_event.json @@ -0,0 +1,19 @@ +{ + "action": { + "id": 123456789, + "status": "completed", + "type": "create", + "started_at": "2026-01-19T12:00:00Z", + "completed_at": "2026-01-19T12:01:30Z", + "resource_id": 98765432, + "resource_type": "droplet", + "region_slug": "nyc3" + }, + "droplet": { + "id": 98765432, + "name": "my-droplet", + "size_slug": "s-1vcpu-1gb", + "image": { "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64" }, + "region": { "name": "New York 3", "slug": "nyc3" } + } +} diff --git a/pkg/integrations/digitalocean/example_output_create_droplet.json b/pkg/integrations/digitalocean/example_output_create_droplet.json new file mode 100644 index 0000000000..2433eb69bc --- /dev/null +++ b/pkg/integrations/digitalocean/example_output_create_droplet.json @@ -0,0 +1,17 @@ +{ + "data": { + "id": 98765432, + "name": "my-droplet", + "memory": 1024, + "vcpus": 1, + "disk": 25, + "status": "new", + "region": { "name": "New York 3", "slug": "nyc3" }, + "image": { "id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64" }, + "size_slug": "s-1vcpu-1gb", + "networks": { + "v4": [{ "ip_address": "104.131.186.241", "type": "public" }] + }, + "tags": ["web"] + } +} diff --git a/pkg/integrations/digitalocean/on_droplet_event.go b/pkg/integrations/digitalocean/on_droplet_event.go new file mode 100644 index 0000000000..2d324ac3ac --- /dev/null +++ b/pkg/integrations/digitalocean/on_droplet_event.go @@ -0,0 +1,253 @@ +package digitalocean + +import ( + "fmt" + "net/http" + "slices" + "time" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type OnDropletEvent struct{} + +type OnDropletEventConfiguration struct { + Events []string `json:"events"` +} + +type OnDropletEventMetadata struct { + LastPollTime string `json:"lastPollTime"` +} + +func (t *OnDropletEvent) Name() string { + return "digitalocean.onDropletEvent" +} + +func (t *OnDropletEvent) Label() string { + return "On Droplet Event" +} + +func (t *OnDropletEvent) Description() string { + return "Poll for DigitalOcean droplet lifecycle events" +} + +func (t *OnDropletEvent) Documentation() string { + return `The On Droplet Event trigger polls the DigitalOcean API for droplet lifecycle events. + +## Use Cases + +- **Infrastructure monitoring**: React to droplet creation and destruction events +- **Audit logging**: Track all droplet lifecycle changes +- **Automation**: Trigger workflows when droplets are powered on/off or resized + +## Configuration + +- **Events**: Select which droplet event types to listen for (create, destroy, power_on, power_off, shutdown, reboot, snapshot, rebuild, resize, rename) + +## Polling + +This trigger polls the DigitalOcean Actions API every 60 seconds for new completed droplet events matching the configured event types. + +## Event Data + +Each event includes: +- **action**: The DigitalOcean action object with id, status, type, timestamps, resource_id, and region_slug` +} + +func (t *OnDropletEvent) Icon() string { + return "server" +} + +func (t *OnDropletEvent) Color() string { + return "gray" +} + +func (t *OnDropletEvent) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "events", + Label: "Events", + Type: configuration.FieldTypeMultiSelect, + Required: true, + Default: []string{"create", "destroy"}, + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Create", Value: "create"}, + {Label: "Destroy", Value: "destroy"}, + {Label: "Power On", Value: "power_on"}, + {Label: "Power Off", Value: "power_off"}, + {Label: "Shutdown", Value: "shutdown"}, + {Label: "Reboot", Value: "reboot"}, + {Label: "Snapshot", Value: "snapshot"}, + {Label: "Rebuild", Value: "rebuild"}, + {Label: "Resize", Value: "resize"}, + {Label: "Rename", Value: "rename"}, + }, + }, + }, + }, + } +} + +func (t *OnDropletEvent) Setup(ctx core.TriggerContext) error { + config := OnDropletEventConfiguration{} + err := mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if len(config.Events) == 0 { + return fmt.Errorf("at least one event type must be selected") + } + + // Check if already set up + var existingMetadata OnDropletEventMetadata + err = mapstructure.Decode(ctx.Metadata.Get(), &existingMetadata) + if err == nil && existingMetadata.LastPollTime != "" { + return nil + } + + now := time.Now().UTC().Format(time.RFC3339) + err = ctx.Metadata.Set(OnDropletEventMetadata{ + LastPollTime: now, + }) + if err != nil { + return fmt.Errorf("error setting metadata: %v", err) + } + + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, 60*time.Second) +} + +func (t *OnDropletEvent) Actions() []core.Action { + return []core.Action{ + { + Name: "poll", + Description: "Poll for new droplet events", + UserAccessible: false, + }, + } +} + +func (t *OnDropletEvent) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + if ctx.Name != "poll" { + return nil, fmt.Errorf("action %s not supported", ctx.Name) + } + + config := OnDropletEventConfiguration{} + err := mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return nil, fmt.Errorf("failed to decode configuration: %w", err) + } + + var metadata OnDropletEventMetadata + err = mapstructure.Decode(ctx.Metadata.Get(), &metadata) + if err != nil { + return nil, fmt.Errorf("failed to decode metadata: %w", err) + } + + lastPollTime, err := time.Parse(time.RFC3339, metadata.LastPollTime) + if err != nil { + return nil, fmt.Errorf("failed to parse lastPollTime: %w", err) + } + + // Capture the poll timestamp before the API call so events that complete + // between the request and response are not permanently missed. + now := time.Now().UTC().Format(time.RFC3339) + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("error creating client: %v", err) + } + + actions, err := client.ListActions("droplet") + if err != nil { + // On transient API errors, reschedule the poll and return nil so the + // framework does not roll back the scheduled call. + _ = ctx.Requests.ScheduleActionCall("poll", map[string]any{}, 60*time.Second) + return nil, nil + } + + for _, action := range actions { + if action.Status != "completed" { + continue + } + + completedAt, err := time.Parse(time.RFC3339, action.CompletedAt) + if err != nil { + continue + } + + if !completedAt.After(lastPollTime) { + continue + } + + if !slices.Contains(config.Events, action.Type) { + continue + } + + payload := map[string]any{ + "action": map[string]any{ + "id": action.ID, + "status": action.Status, + "type": action.Type, + "started_at": action.StartedAt, + "completed_at": action.CompletedAt, + "resource_id": action.ResourceID, + "resource_type": action.ResourceType, + "region_slug": action.RegionSlug, + }, + } + + // Enrich the payload with droplet details when available. + // For destroy events the droplet may no longer exist. + droplet, err := client.GetDroplet(action.ResourceID) + if err == nil { + payload["droplet"] = map[string]any{ + "id": droplet.ID, + "name": droplet.Name, + "size_slug": droplet.SizeSlug, + "image": map[string]any{ + "name": droplet.Image.Name, + "slug": droplet.Image.Slug, + }, + "region": map[string]any{ + "name": droplet.Region.Name, + "slug": droplet.Region.Slug, + }, + } + } + + err = ctx.Events.Emit( + fmt.Sprintf("digitalocean.droplet.%s", action.Type), + payload, + ) + if err != nil { + return nil, fmt.Errorf("error emitting event: %v", err) + } + } + + err = ctx.Metadata.Set(OnDropletEventMetadata{ + LastPollTime: now, + }) + if err != nil { + return nil, fmt.Errorf("error updating metadata: %v", err) + } + + err = ctx.Requests.ScheduleActionCall("poll", map[string]any{}, 60*time.Second) + if err != nil { + return nil, fmt.Errorf("error scheduling next poll: %v", err) + } + + return nil, nil +} + +func (t *OnDropletEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (t *OnDropletEvent) Cleanup(ctx core.TriggerContext) error { + return nil +} diff --git a/pkg/integrations/digitalocean/on_droplet_event_test.go b/pkg/integrations/digitalocean/on_droplet_event_test.go new file mode 100644 index 0000000000..f2e4fdceba --- /dev/null +++ b/pkg/integrations/digitalocean/on_droplet_event_test.go @@ -0,0 +1,210 @@ +package digitalocean + +import ( + "io" + "net/http" + "strings" + "testing" + "time" + + "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__OnDropletEvent__Setup(t *testing.T) { + trigger := &OnDropletEvent{} + + t.Run("valid config -> stores metadata and schedules poll", func(t *testing.T) { + metadataCtx := &contexts.MetadataContext{} + requestCtx := &contexts.RequestContext{} + + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{ + "events": []string{"create", "destroy"}, + }, + Metadata: metadataCtx, + Requests: requestCtx, + Integration: &contexts.IntegrationContext{}, + }) + + require.NoError(t, err) + require.NotNil(t, metadataCtx.Metadata) + assert.Equal(t, "poll", requestCtx.Action) + assert.Equal(t, 60*time.Second, requestCtx.Duration) + }) + + t.Run("empty events -> error", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{ + "events": []string{}, + }, + Metadata: &contexts.MetadataContext{}, + Requests: &contexts.RequestContext{}, + Integration: &contexts.IntegrationContext{}, + }) + + require.ErrorContains(t, err, "at least one event type must be selected") + }) + + t.Run("metadata already set -> skips setup", func(t *testing.T) { + metadataCtx := &contexts.MetadataContext{ + Metadata: OnDropletEventMetadata{ + LastPollTime: time.Now().UTC().Format(time.RFC3339), + }, + } + requestCtx := &contexts.RequestContext{} + + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{ + "events": []string{"create"}, + }, + Metadata: metadataCtx, + Requests: requestCtx, + Integration: &contexts.IntegrationContext{}, + }) + + require.NoError(t, err) + assert.Empty(t, requestCtx.Action) + }) +} + +func Test__OnDropletEvent__HandleAction(t *testing.T) { + trigger := &OnDropletEvent{} + + t.Run("poll with new matching actions -> emits events and re-schedules", func(t *testing.T) { + pastTime := time.Now().UTC().Add(-5 * time.Minute).Format(time.RFC3339) + futureTime := time.Now().UTC().Add(1 * time.Minute).Format(time.RFC3339) + + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "actions": [ + { + "id": 111, + "status": "completed", + "type": "create", + "started_at": "` + pastTime + `", + "completed_at": "` + futureTime + `", + "resource_id": 222, + "resource_type": "droplet", + "region_slug": "nyc3" + }, + { + "id": 333, + "status": "completed", + "type": "destroy", + "started_at": "` + pastTime + `", + "completed_at": "` + futureTime + `", + "resource_id": 444, + "resource_type": "droplet", + "region_slug": "sfo3" + }, + { + "id": 555, + "status": "completed", + "type": "power_on", + "started_at": "` + pastTime + `", + "completed_at": "` + futureTime + `", + "resource_id": 666, + "resource_type": "droplet", + "region_slug": "ams3" + }, + { + "id": 777, + "status": "completed", + "type": "create", + "started_at": "` + pastTime + `", + "completed_at": "` + futureTime + `", + "resource_id": 888, + "resource_type": "floating_ip", + "region_slug": "nyc3" + } + ] + }`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + + eventCtx := &contexts.EventContext{} + metadataCtx := &contexts.MetadataContext{ + Metadata: OnDropletEventMetadata{ + LastPollTime: pastTime, + }, + } + requestCtx := &contexts.RequestContext{} + + _, err := trigger.HandleAction(core.TriggerActionContext{ + Name: "poll", + Configuration: map[string]any{ + "events": []string{"create", "destroy"}, + }, + HTTP: httpContext, + Integration: integrationCtx, + Events: eventCtx, + Metadata: metadataCtx, + Requests: requestCtx, + }) + + require.NoError(t, err) + assert.Equal(t, 2, eventCtx.Count()) + assert.Equal(t, "digitalocean.droplet.create", eventCtx.Payloads[0].Type) + assert.Equal(t, "digitalocean.droplet.destroy", eventCtx.Payloads[1].Type) + + assert.Equal(t, "poll", requestCtx.Action) + assert.Equal(t, 60*time.Second, requestCtx.Duration) + }) + + t.Run("poll with no new actions -> re-schedules only", func(t *testing.T) { + now := time.Now().UTC().Format(time.RFC3339) + + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"actions": []}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + + eventCtx := &contexts.EventContext{} + metadataCtx := &contexts.MetadataContext{ + Metadata: OnDropletEventMetadata{ + LastPollTime: now, + }, + } + requestCtx := &contexts.RequestContext{} + + _, err := trigger.HandleAction(core.TriggerActionContext{ + Name: "poll", + Configuration: map[string]any{ + "events": []string{"create"}, + }, + HTTP: httpContext, + Integration: integrationCtx, + Events: eventCtx, + Metadata: metadataCtx, + Requests: requestCtx, + }) + + require.NoError(t, err) + assert.Equal(t, 0, eventCtx.Count()) + assert.Equal(t, "poll", requestCtx.Action) + assert.Equal(t, 60*time.Second, requestCtx.Duration) + }) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index f909652ba0..3a444cefce 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -40,6 +40,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/dash0" _ "github.com/superplanehq/superplane/pkg/integrations/datadog" _ "github.com/superplanehq/superplane/pkg/integrations/daytona" + _ "github.com/superplanehq/superplane/pkg/integrations/digitalocean" _ "github.com/superplanehq/superplane/pkg/integrations/discord" _ "github.com/superplanehq/superplane/pkg/integrations/dockerhub" _ "github.com/superplanehq/superplane/pkg/integrations/github" diff --git a/web_src/src/assets/icons/integrations/digitalocean.svg b/web_src/src/assets/icons/integrations/digitalocean.svg new file mode 100644 index 0000000000..1358524e73 --- /dev/null +++ b/web_src/src/assets/icons/integrations/digitalocean.svg @@ -0,0 +1,5 @@ + + DigitalOcean + + + diff --git a/web_src/src/pages/workflowv2/mappers/digitalocean/create_droplet.ts b/web_src/src/pages/workflowv2/mappers/digitalocean/create_droplet.ts new file mode 100644 index 0000000000..5d9e56c192 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/digitalocean/create_droplet.ts @@ -0,0 +1,95 @@ +import { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { getBackgroundColorClass } from "@/utils/colors"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import { MetadataItem } from "@/ui/metadataList"; +import doIcon from "@/assets/icons/integrations/digitalocean.svg"; +import { formatTimeAgo } from "@/utils/date"; + +export const createDropletMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name ?? "digitalocean"; + + return { + iconSrc: doIcon, + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + title: context.node.name || context.componentDefinition.label || "Unnamed component", + eventSections: lastExecution ? baseEventSections(context.nodes, lastExecution, componentName) : undefined, + metadata: metadataList(context.node), + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const details: Record = {}; + + if (context.execution.createdAt) { + details["Created At"] = new Date(context.execution.createdAt).toLocaleString(); + } + + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const droplet = outputs?.default?.[0]?.data as Record | undefined; + if (!droplet) return details; + + const ip = droplet.networks?.v4?.find((n: any) => n.type === "public")?.ip_address; + + details["Droplet ID"] = droplet.id?.toString() || "-"; + details["Name"] = droplet.name || "-"; + details["Region"] = droplet.region?.name || droplet.region?.slug || "-"; + details["Size"] = droplet.size_slug || "-"; + details["OS"] = droplet.image?.name || droplet.image?.slug || "-"; + + if (ip) { + details["IP Address"] = ip; + } + + return details; + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) return ""; + return formatTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as any; + + if (configuration?.region) { + metadata.push({ icon: "map-pin", label: `Region: ${configuration.region}` }); + } + + if (configuration?.size) { + metadata.push({ icon: "hard-drive", label: `Size: ${configuration.size}` }); + } + + return metadata; +} + +function baseEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName!); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent! }); + + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle: formatTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} diff --git a/web_src/src/pages/workflowv2/mappers/digitalocean/index.ts b/web_src/src/pages/workflowv2/mappers/digitalocean/index.ts new file mode 100644 index 0000000000..8047b9a4ec --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/digitalocean/index.ts @@ -0,0 +1,16 @@ +import { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types"; +import { onDropletEventTriggerRenderer } from "./on_droplet_event"; +import { createDropletMapper } from "./create_droplet"; +import { buildActionStateRegistry } from "../utils"; + +export const componentMappers: Record = { + createDroplet: createDropletMapper, +}; + +export const triggerRenderers: Record = { + onDropletEvent: onDropletEventTriggerRenderer, +}; + +export const eventStateRegistry: Record = { + createDroplet: buildActionStateRegistry("created"), +}; diff --git a/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts b/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts new file mode 100644 index 0000000000..290d1dcd50 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts @@ -0,0 +1,94 @@ +import { getBackgroundColorClass } from "@/utils/colors"; +import { formatTimeAgo } from "@/utils/date"; +import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import { TriggerProps } from "@/ui/trigger"; +import doIcon from "@/assets/icons/integrations/digitalocean.svg"; + +interface OnDropletEventData { + action?: { + id: number; + status: string; + type: string; + started_at: string; + completed_at: string; + resource_id: number; + resource_type: string; + region_slug: string; + }; + droplet?: { + id: number; + name: string; + size_slug: string; + image: { + name: string; + slug: string; + }; + region: { + name: string; + slug: string; + }; + }; +} + +export const onDropletEventTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const eventData = context.event?.data as OnDropletEventData; + const action = eventData?.action; + const title = `${action?.type || ""} - Droplet ${action?.resource_id || ""}`; + const subtitle = context.event?.createdAt ? formatTimeAgo(new Date(context.event.createdAt)) : ""; + + return { title, subtitle }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as OnDropletEventData; + const action = eventData?.action; + const droplet = eventData?.droplet; + + return { + "Started At": action?.started_at ? new Date(action.started_at).toLocaleString() : "-", + "Completed At": action?.completed_at ? new Date(action.completed_at).toLocaleString() : "-", + "Droplet ID": action?.resource_id?.toString() || "-", + "Droplet Name": droplet?.name || "-", + "Action Type": action?.type || "-", + Size: droplet?.size_slug || "-", + OS: droplet?.image?.name || droplet?.image?.slug || "-", + Region: droplet?.region?.name || action?.region_slug || "-", + }; + }, + + getTriggerProps: (context: TriggerRendererContext) => { + const { node, definition, lastEvent } = context; + const configuration = node.configuration as any; + const metadataItems = []; + + if (configuration?.events) { + metadataItems.push({ + icon: "funnel", + label: `Events: ${configuration.events.join(", ")}`, + }); + } + + const props: TriggerProps = { + title: node.name || definition.label || "Unnamed trigger", + iconSrc: doIcon, + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const eventData = lastEvent.data as OnDropletEventData; + const action = eventData?.action; + + props.lastEventData = { + title: `${action?.type || ""} - Droplet ${action?.resource_id || ""}`, + subtitle: formatTimeAgo(new Date(lastEvent.createdAt)), + receivedAt: new Date(lastEvent.createdAt), + state: "triggered", + eventId: lastEvent.id, + }; + } + + return props; + }, +}; diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index 6b682b3bc0..8537fbd783 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -87,6 +87,11 @@ import { } from "./aws"; import { componentMappers as hetznerComponentMappers } from "./hetzner/index"; import { timeGateMapper, TIME_GATE_STATE_REGISTRY } from "./timegate"; +import { + componentMappers as digitaloceanComponentMappers, + triggerRenderers as digitaloceanTriggerRenderers, + eventStateRegistry as digitaloceanEventStateRegistry, +} from "./digitalocean/index"; import { componentMappers as discordComponentMappers, triggerRenderers as discordTriggerRenderers, @@ -157,6 +162,7 @@ const componentBaseMappers: Record = { const appMappers: Record> = { cloudflare: cloudflareComponentMappers, + digitalocean: digitaloceanComponentMappers, semaphore: semaphoreComponentMappers, github: githubComponentMappers, gitlab: gitlabComponentMappers, @@ -182,6 +188,7 @@ const appMappers: Record> = { const appTriggerRenderers: Record> = { cloudflare: cloudflareTriggerRenderers, + digitalocean: digitaloceanTriggerRenderers, semaphore: semaphoreTriggerRenderers, github: githubTriggerRenderers, gitlab: gitlabTriggerRenderers, @@ -206,6 +213,7 @@ const appTriggerRenderers: Record> = { const appEventStateRegistries: Record> = { cloudflare: cloudflareEventStateRegistry, + digitalocean: digitaloceanEventStateRegistry, semaphore: semaphoreEventStateRegistry, github: githubEventStateRegistry, pagerduty: pagerdutyEventStateRegistry, diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx index f7254cd022..36eb645d56 100644 --- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx +++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx @@ -18,6 +18,7 @@ import cloudflareIcon from "@/assets/icons/integrations/cloudflare.svg"; import dash0Icon from "@/assets/icons/integrations/dash0.svg"; import daytonaIcon from "@/assets/icons/integrations/daytona.svg"; import datadogIcon from "@/assets/icons/integrations/datadog.svg"; +import digitaloceanIcon from "@/assets/icons/integrations/digitalocean.svg"; import discordIcon from "@/assets/icons/integrations/discord.svg"; import githubIcon from "@/assets/icons/integrations/github.svg"; import gitlabIcon from "@/assets/icons/integrations/gitlab.svg"; @@ -404,6 +405,7 @@ function CategorySection({ dash0: dash0Icon, datadog: datadogIcon, daytona: daytonaIcon, + digitalocean: digitaloceanIcon, discord: discordIcon, github: githubIcon, gitlab: gitlabIcon, @@ -484,6 +486,7 @@ function CategorySection({ dash0: dash0Icon, daytona: daytonaIcon, datadog: datadogIcon, + digitalocean: digitaloceanIcon, discord: discordIcon, github: githubIcon, gitlab: gitlabIcon, diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx index 23f8305436..50b4b12d87 100644 --- a/web_src/src/ui/componentSidebar/integrationIcons.tsx +++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx @@ -9,6 +9,7 @@ import cloudflareIcon from "@/assets/icons/integrations/cloudflare.svg"; import dash0Icon from "@/assets/icons/integrations/dash0.svg"; import datadogIcon from "@/assets/icons/integrations/datadog.svg"; import daytonaIcon from "@/assets/icons/integrations/daytona.svg"; +import digitaloceanIcon from "@/assets/icons/integrations/digitalocean.svg"; import discordIcon from "@/assets/icons/integrations/discord.svg"; import githubIcon from "@/assets/icons/integrations/github.svg"; import gitlabIcon from "@/assets/icons/integrations/gitlab.svg"; @@ -35,6 +36,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = { dash0: dash0Icon, datadog: datadogIcon, daytona: daytonaIcon, + digitalocean: digitaloceanIcon, discord: discordIcon, github: githubIcon, gitlab: gitlabIcon, @@ -62,6 +64,7 @@ export const APP_LOGO_MAP: Record> = { dash0: dash0Icon, datadog: datadogIcon, daytona: daytonaIcon, + digitalocean: digitaloceanIcon, discord: discordIcon, github: githubIcon, gitlab: gitlabIcon, diff --git a/web_src/src/utils/integrationDisplayName.ts b/web_src/src/utils/integrationDisplayName.ts index 1e8f0fb34c..d676d11bbf 100644 --- a/web_src/src/utils/integrationDisplayName.ts +++ b/web_src/src/utils/integrationDisplayName.ts @@ -10,6 +10,7 @@ const INTEGRATION_TYPE_DISPLAY_NAMES: Record = { cursor: "Cursor", pagerduty: "PagerDuty", slack: "Slack", + digitalocean: "DigitalOcean", discord: "Discord", datadog: "DataDog", cloudflare: "Cloudflare", From a279a3934b6a9d965b10ff3d8d80c376b0f5c740 Mon Sep 17 00:00:00 2001 From: Aldo Date: Wed, 11 Feb 2026 11:12:26 +0700 Subject: [PATCH 02/12] fix: add nil check for TypeOptions in validateList The validateList function accessed field.TypeOptions.List without first checking if TypeOptions was nil, unlike every other validator (validateNumber, validateString, validateText, validateSelect, etc). This could cause a nil pointer dereference when validating a FieldTypeList field that has no TypeOptions configured. Signed-off-by: Aldo --- pkg/configuration/validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/configuration/validation.go b/pkg/configuration/validation.go index 392633b9a1..4e4e7f226e 100644 --- a/pkg/configuration/validation.go +++ b/pkg/configuration/validation.go @@ -319,7 +319,7 @@ func validateList(field Field, value any) error { return fmt.Errorf("must contain at least one item") } - if field.TypeOptions.List == nil { + if field.TypeOptions == nil || field.TypeOptions.List == nil { return nil } From 639eb69f407809e97f54016ed592c022afde245c Mon Sep 17 00:00:00 2001 From: Aldo Date: Wed, 11 Feb 2026 11:36:45 +0700 Subject: [PATCH 03/12] fix: cache GetDroplet calls by ResourceID in onDropletEvent Avoid N+1 API calls when multiple actions reference the same droplet by caching GetDroplet results per ResourceID during each poll cycle. Signed-off-by: Aldo --- pkg/integrations/digitalocean/on_droplet_event.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/integrations/digitalocean/on_droplet_event.go b/pkg/integrations/digitalocean/on_droplet_event.go index 2d324ac3ac..b2b036b4c2 100644 --- a/pkg/integrations/digitalocean/on_droplet_event.go +++ b/pkg/integrations/digitalocean/on_droplet_event.go @@ -170,6 +170,10 @@ func (t *OnDropletEvent) HandleAction(ctx core.TriggerActionContext) (map[string return nil, nil } + // Cache droplet lookups by ResourceID to avoid N+1 API calls + // when multiple actions reference the same droplet. + dropletCache := map[int]*Droplet{} + for _, action := range actions { if action.Status != "completed" { continue @@ -203,8 +207,15 @@ func (t *OnDropletEvent) HandleAction(ctx core.TriggerActionContext) (map[string // Enrich the payload with droplet details when available. // For destroy events the droplet may no longer exist. - droplet, err := client.GetDroplet(action.ResourceID) - if err == nil { + droplet, cached := dropletCache[action.ResourceID] + if !cached { + droplet, err = client.GetDroplet(action.ResourceID) + if err != nil { + droplet = nil + } + dropletCache[action.ResourceID] = droplet + } + if droplet != nil { payload["droplet"] = map[string]any{ "id": droplet.ID, "name": droplet.Name, From 29d3080bbb1c06e223f257011e595b05b6fa6341 Mon Sep 17 00:00:00 2001 From: harxhist Date: Thu, 12 Feb 2026 18:30:07 +0530 Subject: [PATCH 04/12] feat: Add Cursor Integration (#2991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2618 This PR introduces the **Cursor** integration to SuperPlane, allowing users to build workflows utilizing Cursor's AI-powered capabilities. It includes the base integration setup and two starter components: 1. **Launch Cloud Agent (Action):** Triggers a Cursor Cloud Agent on a specific repository/branch and tracks the execution state to completion. It links to the Cloud Agent and PR in the output. This agent has no limits(except credit limit). 2. **Get Daily Usage Data (Action):** Fetches daily team usage metrics from the Cursor Admin API for reporting and cost tracking. - **Authentication:** Connects via Cursor BasicAuth (Admin API and Cloud Agents API). - **Code Logic:** The `Launch Cloud Agent` implementation handles significant logic to track the agent's lifecycle (polling status, handling completion, etc.). The code is structured to robustly handle this weight to ensure reliable execution tracking. [Watch the Loom Video](https://www.loom.com/share/2f6f3f98ab6b47ce88444a15f93afe45) - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation (`make gen.components.docs`) - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have signed off my commits (`git commit -s`) --------- Signed-off-by: Harsh Signed-off-by: Igor Šarčević Co-authored-by: Igor Šarčević Co-authored-by: Igor Šarčević Signed-off-by: Aldo --- docs/components/Cursor.mdx | 51 ------------------- pkg/integrations/cursor/client.go | 30 ----------- pkg/integrations/cursor/cursor.go | 1 - pkg/integrations/cursor/cursor_test.go | 3 +- .../pages/workflowv2/mappers/cursor/index.ts | 3 -- 5 files changed, 1 insertion(+), 87 deletions(-) diff --git a/docs/components/Cursor.mdx b/docs/components/Cursor.mdx index 37f746ed29..3c869807c7 100644 --- a/docs/components/Cursor.mdx +++ b/docs/components/Cursor.mdx @@ -10,7 +10,6 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; - @@ -87,56 +86,6 @@ The output includes per-user daily metrics: } ``` - - -## Get Last Message - -The Get Last Message component retrieves the last message from a Cursor Cloud Agent's conversation history. - -### Use Cases - -- **Message tracking**: Get the latest response or prompt from an agent conversation -- **Workflow automation**: Use the last message as input for downstream components -- **Status monitoring**: Check what the agent last communicated - -### How It Works - -1. Fetches the conversation history for the specified agent ID -2. Extracts the last message from the conversation -3. Returns the message details including ID, type (user_message or assistant_message), and text - -### Configuration - -- **Agent ID**: The unique identifier for the cloud agent (e.g., bc_abc123) - -### Output - -The output includes: -- **Agent ID**: The identifier of the agent -- **Message**: The last message object containing: - - **ID**: Unique message identifier - - **Type**: Either "user_message" or "assistant_message" - - **Text**: The message content - -### Notes - -- Requires a valid Cursor Cloud Agent API key configured in the integration -- If the agent has been deleted, the conversation cannot be accessed -- Returns nil if the conversation has no messages - -### Example Output - -```json -{ - "agentId": "bc_abc123", - "message": { - "id": "msg_005", - "text": "I've added a troubleshooting section to the README.", - "type": "assistant_message" - } -} -``` - ## Launch Cloud Agent diff --git a/pkg/integrations/cursor/client.go b/pkg/integrations/cursor/client.go index 4842cbb907..53e3e017dc 100644 --- a/pkg/integrations/cursor/client.go +++ b/pkg/integrations/cursor/client.go @@ -52,17 +52,6 @@ type ModelsResponse struct { Models []string `json:"models"` } -type ConversationMessage struct { - ID string `json:"id"` - Type string `json:"type"` - Text string `json:"text"` -} - -type ConversationResponse struct { - ID string `json:"id"` - Messages []ConversationMessage `json:"messages"` -} - func (c *Client) ListModels() ([]string, error) { if c.LaunchAgentKey == "" { return nil, fmt.Errorf("Cloud Agent API key is not configured") @@ -174,25 +163,6 @@ func (c *Client) CancelAgent(agentID string) error { return err } -func (c *Client) GetAgentConversation(agentID string) (*ConversationResponse, error) { - if c.LaunchAgentKey == "" { - return nil, fmt.Errorf("Cloud Agent API key is not configured") - } - - url := fmt.Sprintf("%s/v0/agents/%s/conversation", c.BaseURL, agentID) - responseBody, err := c.execRequest(http.MethodGet, url, nil, c.LaunchAgentKey) - if err != nil { - return nil, err - } - - var response ConversationResponse - if err := json.Unmarshal(responseBody, &response); err != nil { - return nil, fmt.Errorf("failed to unmarshal conversation response: %w", err) - } - - return &response, nil -} - func (c *Client) execRequest(method, URL string, body io.Reader, apiKey string) ([]byte, error) { req, err := http.NewRequest(method, URL, body) if err != nil { diff --git a/pkg/integrations/cursor/cursor.go b/pkg/integrations/cursor/cursor.go index c24e3743b5..611ad102e7 100644 --- a/pkg/integrations/cursor/cursor.go +++ b/pkg/integrations/cursor/cursor.go @@ -96,7 +96,6 @@ func (i *Cursor) Components() []core.Component { return []core.Component{ &LaunchAgent{}, &GetDailyUsageData{}, - &GetLastMessage{}, } } diff --git a/pkg/integrations/cursor/cursor_test.go b/pkg/integrations/cursor/cursor_test.go index 21e038f4eb..bcb7a4a5d4 100644 --- a/pkg/integrations/cursor/cursor_test.go +++ b/pkg/integrations/cursor/cursor_test.go @@ -177,7 +177,7 @@ func Test__Cursor__Components(t *testing.T) { c := &Cursor{} components := c.Components() - assert.Len(t, components, 3) + assert.Len(t, components, 2) names := make([]string, len(components)) for i, comp := range components { @@ -186,7 +186,6 @@ func Test__Cursor__Components(t *testing.T) { assert.Contains(t, names, "cursor.launchAgent") assert.Contains(t, names, "cursor.getDailyUsageData") - assert.Contains(t, names, "cursor.getLastMessage") } func Test__Cursor__ListResources(t *testing.T) { diff --git a/web_src/src/pages/workflowv2/mappers/cursor/index.ts b/web_src/src/pages/workflowv2/mappers/cursor/index.ts index 455e80de74..d9ea382696 100644 --- a/web_src/src/pages/workflowv2/mappers/cursor/index.ts +++ b/web_src/src/pages/workflowv2/mappers/cursor/index.ts @@ -2,12 +2,10 @@ import { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../typ import { buildActionStateRegistry } from "../utils"; import { launchAgentMapper } from "./launch_agent"; import { getDailyUsageDataMapper } from "./get_daily_usage_data"; -import { getLastMessageMapper } from "./get_last_message"; export const componentMappers: Record = { launchAgent: launchAgentMapper, getDailyUsageData: getDailyUsageDataMapper, - getLastMessage: getLastMessageMapper, }; export const triggerRenderers: Record = {}; @@ -15,5 +13,4 @@ export const triggerRenderers: Record = {}; export const eventStateRegistry: Record = { launchAgent: buildActionStateRegistry("completed"), getDailyUsageData: buildActionStateRegistry("completed"), - getLastMessage: buildActionStateRegistry("completed"), }; From f2c7b94b72dd3395aa041cc3bfc1d52240da6854 Mon Sep 17 00:00:00 2001 From: Aldo Date: Fri, 13 Feb 2026 18:39:20 +0700 Subject: [PATCH 05/12] feat: include IP address in onDropletEvent trigger payload Add networks/v4 data to the droplet enrichment payload so the trigger detail tab can display the droplet's public IP address. Signed-off-by: Aldo --- docs/components/DigitalOcean.mdx | 8 ++++++++ .../digitalocean/example_data_on_droplet_event.json | 1 + pkg/integrations/digitalocean/on_droplet_event.go | 9 +++++++++ .../mappers/digitalocean/on_droplet_event.ts | 10 +++++++++- 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/components/DigitalOcean.mdx b/docs/components/DigitalOcean.mdx index e957a687f7..4a21da5fb9 100644 --- a/docs/components/DigitalOcean.mdx +++ b/docs/components/DigitalOcean.mdx @@ -79,6 +79,14 @@ Each event includes: "slug": "ubuntu-24-04-x64" }, "name": "my-droplet", + "networks": { + "v4": [ + { + "ip_address": "203.0.113.1", + "type": "public" + } + ] + }, "region": { "name": "New York 3", "slug": "nyc3" diff --git a/pkg/integrations/digitalocean/example_data_on_droplet_event.json b/pkg/integrations/digitalocean/example_data_on_droplet_event.json index fc8df7f301..5470db1813 100644 --- a/pkg/integrations/digitalocean/example_data_on_droplet_event.json +++ b/pkg/integrations/digitalocean/example_data_on_droplet_event.json @@ -13,6 +13,7 @@ "id": 98765432, "name": "my-droplet", "size_slug": "s-1vcpu-1gb", + "networks": { "v4": [{ "ip_address": "203.0.113.1", "type": "public" }] }, "image": { "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64" }, "region": { "name": "New York 3", "slug": "nyc3" } } diff --git a/pkg/integrations/digitalocean/on_droplet_event.go b/pkg/integrations/digitalocean/on_droplet_event.go index b2b036b4c2..7033ceef71 100644 --- a/pkg/integrations/digitalocean/on_droplet_event.go +++ b/pkg/integrations/digitalocean/on_droplet_event.go @@ -216,10 +216,19 @@ func (t *OnDropletEvent) HandleAction(ctx core.TriggerActionContext) (map[string dropletCache[action.ResourceID] = droplet } if droplet != nil { + v4Networks := make([]map[string]any, len(droplet.Networks.V4)) + for i, n := range droplet.Networks.V4 { + v4Networks[i] = map[string]any{ + "ip_address": n.IPAddress, + "type": n.Type, + } + } + payload["droplet"] = map[string]any{ "id": droplet.ID, "name": droplet.Name, "size_slug": droplet.SizeSlug, + "networks": map[string]any{"v4": v4Networks}, "image": map[string]any{ "name": droplet.Image.Name, "slug": droplet.Image.Slug, diff --git a/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts b/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts index 290d1dcd50..c02b190d03 100644 --- a/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts +++ b/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts @@ -19,6 +19,9 @@ interface OnDropletEventData { id: number; name: string; size_slug: string; + networks?: { + v4?: { ip_address: string; type: string }[]; + }; image: { name: string; slug: string; @@ -45,7 +48,8 @@ export const onDropletEventTriggerRenderer: TriggerRenderer = { const action = eventData?.action; const droplet = eventData?.droplet; - return { + const ip = droplet?.networks?.v4?.find((n) => n.type === "public")?.ip_address; + const values: Record = { "Started At": action?.started_at ? new Date(action.started_at).toLocaleString() : "-", "Completed At": action?.completed_at ? new Date(action.completed_at).toLocaleString() : "-", "Droplet ID": action?.resource_id?.toString() || "-", @@ -55,6 +59,10 @@ export const onDropletEventTriggerRenderer: TriggerRenderer = { OS: droplet?.image?.name || droplet?.image?.slug || "-", Region: droplet?.region?.name || action?.region_slug || "-", }; + if (ip) { + values["IP Address"] = ip; + } + return values; }, getTriggerProps: (context: TriggerRendererContext) => { From 11549694b704baa1577d2a37fb078ef89207cbc0 Mon Sep 17 00:00:00 2001 From: Aldo Date: Sat, 14 Feb 2026 23:14:21 +0700 Subject: [PATCH 06/12] feat: Add Ip address on create DO Signed-off-by: Aldo --- .../digitalocean/create_droplet.go | 56 +++++++- .../digitalocean/create_droplet_test.go | 134 ++++++++++++++++-- 2 files changed, 175 insertions(+), 15 deletions(-) diff --git a/pkg/integrations/digitalocean/create_droplet.go b/pkg/integrations/digitalocean/create_droplet.go index 6bbcdf079f..ccd476f57e 100644 --- a/pkg/integrations/digitalocean/create_droplet.go +++ b/pkg/integrations/digitalocean/create_droplet.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "regexp" + "time" "github.com/google/uuid" "github.com/mitchellh/mapstructure" @@ -12,6 +13,8 @@ import ( "github.com/superplanehq/superplane/pkg/core" ) +const dropletPollInterval = 10 * time.Second + var validHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.\-]*$`) type CreateDroplet struct{} @@ -236,11 +239,14 @@ func (c *CreateDroplet) Execute(ctx core.ExecutionContext) error { return fmt.Errorf("failed to create droplet: %v", err) } - return ctx.ExecutionState.Emit( - core.DefaultOutputChannel.Name, - "digitalocean.droplet.created", - []any{droplet}, - ) + // Store the droplet ID in metadata and poll until the droplet is active + // with an IP address assigned. + err = ctx.Metadata.Set(map[string]any{"dropletID": droplet.ID}) + if err != nil { + return fmt.Errorf("failed to store metadata: %v", err) + } + + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, dropletPollInterval) } func (c *CreateDroplet) Cancel(ctx core.ExecutionContext) error { @@ -252,11 +258,47 @@ func (c *CreateDroplet) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UU } func (c *CreateDroplet) Actions() []core.Action { - return []core.Action{} + return []core.Action{ + {Name: "poll"}, + } } func (c *CreateDroplet) HandleAction(ctx core.ActionContext) error { - return nil + if ctx.Name != "poll" { + return fmt.Errorf("unknown action: %s", ctx.Name) + } + + if ctx.ExecutionState.IsFinished() { + return nil + } + + var metadata struct { + DropletID int `mapstructure:"dropletID"` + } + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return fmt.Errorf("failed to decode metadata: %v", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + droplet, err := client.GetDroplet(metadata.DropletID) + if err != nil { + return fmt.Errorf("failed to get droplet: %v", err) + } + + // If the droplet is not active yet, poll again. + if droplet.Status != "active" { + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, dropletPollInterval) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "digitalocean.droplet.created", + []any{droplet}, + ) } func (c *CreateDroplet) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { diff --git a/pkg/integrations/digitalocean/create_droplet_test.go b/pkg/integrations/digitalocean/create_droplet_test.go index 3d60c695f3..9c70df077a 100644 --- a/pkg/integrations/digitalocean/create_droplet_test.go +++ b/pkg/integrations/digitalocean/create_droplet_test.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -93,7 +94,7 @@ func Test__CreateDroplet__Setup(t *testing.T) { func Test__CreateDroplet__Execute(t *testing.T) { component := &CreateDroplet{} - t.Run("successful creation -> emits droplet data", func(t *testing.T) { + t.Run("successful creation -> stores metadata and schedules poll", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ { @@ -109,7 +110,7 @@ func Test__CreateDroplet__Execute(t *testing.T) { "region": {"name": "New York 3", "slug": "nyc3"}, "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"}, "size_slug": "s-1vcpu-1gb", - "networks": {"v4": [{"ip_address": "104.131.186.241", "type": "public"}]}, + "networks": {"v4": []}, "tags": ["web"] } }`)), @@ -123,6 +124,8 @@ func Test__CreateDroplet__Execute(t *testing.T) { }, } + metadataCtx := &contexts.MetadataContext{} + requestCtx := &contexts.RequestContext{} executionState := &contexts.ExecutionStateContext{ KVs: map[string]string{}, } @@ -138,16 +141,23 @@ func Test__CreateDroplet__Execute(t *testing.T) { HTTP: httpContext, Integration: integrationCtx, ExecutionState: executionState, + Metadata: metadataCtx, + Requests: requestCtx, }) require.NoError(t, err) - assert.True(t, executionState.Passed) - assert.Equal(t, "default", executionState.Channel) - assert.Equal(t, "digitalocean.droplet.created", executionState.Type) - require.Len(t, httpContext.Requests, 1) - assert.Equal(t, "https://api.digitalocean.com/v2/droplets", httpContext.Requests[0].URL.String()) - assert.Equal(t, http.MethodPost, httpContext.Requests[0].Method) + // Should store droplet ID in metadata + metadata, ok := metadataCtx.Metadata.(map[string]any) + require.True(t, ok) + assert.Equal(t, 98765432, metadata["dropletID"]) + + // Should schedule a poll action + assert.Equal(t, "poll", requestCtx.Action) + assert.Equal(t, 10*time.Second, requestCtx.Duration) + + // Should NOT emit yet (waiting for active status) + assert.False(t, executionState.Passed) }) t.Run("API error -> returns error", func(t *testing.T) { @@ -186,3 +196,111 @@ func Test__CreateDroplet__Execute(t *testing.T) { assert.Contains(t, err.Error(), "failed to create droplet") }) } + +func Test__CreateDroplet__HandleAction(t *testing.T) { + component := &CreateDroplet{} + + t.Run("droplet active -> emits with IP address", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "droplet": { + "id": 98765432, + "name": "my-droplet", + "memory": 1024, + "vcpus": 1, + "disk": 25, + "status": "active", + "region": {"name": "New York 3", "slug": "nyc3"}, + "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"}, + "size_slug": "s-1vcpu-1gb", + "networks": {"v4": [{"ip_address": "104.131.186.241", "type": "public"}]}, + "tags": ["web"] + } + }`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + + metadataCtx := &contexts.MetadataContext{ + Metadata: map[string]any{"dropletID": 98765432}, + } + + executionState := &contexts.ExecutionStateContext{ + KVs: map[string]string{}, + } + + err := component.HandleAction(core.ActionContext{ + Name: "poll", + HTTP: httpContext, + Integration: integrationCtx, + Metadata: metadataCtx, + ExecutionState: executionState, + Requests: &contexts.RequestContext{}, + }) + + require.NoError(t, err) + assert.True(t, executionState.Passed) + assert.Equal(t, "default", executionState.Channel) + assert.Equal(t, "digitalocean.droplet.created", executionState.Type) + }) + + t.Run("droplet still new -> schedules another poll", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "droplet": { + "id": 98765432, + "name": "my-droplet", + "status": "new", + "region": {"name": "New York 3", "slug": "nyc3"}, + "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"}, + "size_slug": "s-1vcpu-1gb", + "networks": {"v4": []}, + "tags": [] + } + }`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + + metadataCtx := &contexts.MetadataContext{ + Metadata: map[string]any{"dropletID": 98765432}, + } + + requestCtx := &contexts.RequestContext{} + executionState := &contexts.ExecutionStateContext{ + KVs: map[string]string{}, + } + + err := component.HandleAction(core.ActionContext{ + Name: "poll", + HTTP: httpContext, + Integration: integrationCtx, + Metadata: metadataCtx, + ExecutionState: executionState, + Requests: requestCtx, + }) + + require.NoError(t, err) + assert.False(t, executionState.Passed) + assert.Equal(t, "poll", requestCtx.Action) + assert.Equal(t, 10*time.Second, requestCtx.Duration) + }) +} From b75fe143afce8264577d4628715b700d383694e5 Mon Sep 17 00:00:00 2001 From: Aldo Date: Wed, 11 Feb 2026 08:23:06 +0700 Subject: [PATCH 07/12] feat: Add DigitalOcean integration Add DigitalOcean integration with: - Create Droplet component (with SSH keys, tags, user data support) - On Droplet Event trigger (polls for lifecycle events) - Client with account, regions, sizes, images, actions, and droplet APIs - Frontend mappers with execution details and event rendering - Icon registration in sidebar and component header Signed-off-by: Aldo --- docs/components/DigitalOcean.mdx | 8 -- .../digitalocean/create_droplet.go | 56 +------- .../digitalocean/create_droplet_test.go | 134 ++---------------- .../example_data_on_droplet_event.json | 1 - .../digitalocean/on_droplet_event.go | 24 +--- .../mappers/digitalocean/on_droplet_event.ts | 10 +- 6 files changed, 18 insertions(+), 215 deletions(-) diff --git a/docs/components/DigitalOcean.mdx b/docs/components/DigitalOcean.mdx index 4a21da5fb9..e957a687f7 100644 --- a/docs/components/DigitalOcean.mdx +++ b/docs/components/DigitalOcean.mdx @@ -79,14 +79,6 @@ Each event includes: "slug": "ubuntu-24-04-x64" }, "name": "my-droplet", - "networks": { - "v4": [ - { - "ip_address": "203.0.113.1", - "type": "public" - } - ] - }, "region": { "name": "New York 3", "slug": "nyc3" diff --git a/pkg/integrations/digitalocean/create_droplet.go b/pkg/integrations/digitalocean/create_droplet.go index ccd476f57e..6bbcdf079f 100644 --- a/pkg/integrations/digitalocean/create_droplet.go +++ b/pkg/integrations/digitalocean/create_droplet.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "regexp" - "time" "github.com/google/uuid" "github.com/mitchellh/mapstructure" @@ -13,8 +12,6 @@ import ( "github.com/superplanehq/superplane/pkg/core" ) -const dropletPollInterval = 10 * time.Second - var validHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.\-]*$`) type CreateDroplet struct{} @@ -239,14 +236,11 @@ func (c *CreateDroplet) Execute(ctx core.ExecutionContext) error { return fmt.Errorf("failed to create droplet: %v", err) } - // Store the droplet ID in metadata and poll until the droplet is active - // with an IP address assigned. - err = ctx.Metadata.Set(map[string]any{"dropletID": droplet.ID}) - if err != nil { - return fmt.Errorf("failed to store metadata: %v", err) - } - - return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, dropletPollInterval) + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "digitalocean.droplet.created", + []any{droplet}, + ) } func (c *CreateDroplet) Cancel(ctx core.ExecutionContext) error { @@ -258,47 +252,11 @@ func (c *CreateDroplet) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UU } func (c *CreateDroplet) Actions() []core.Action { - return []core.Action{ - {Name: "poll"}, - } + return []core.Action{} } func (c *CreateDroplet) HandleAction(ctx core.ActionContext) error { - if ctx.Name != "poll" { - return fmt.Errorf("unknown action: %s", ctx.Name) - } - - if ctx.ExecutionState.IsFinished() { - return nil - } - - var metadata struct { - DropletID int `mapstructure:"dropletID"` - } - if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { - return fmt.Errorf("failed to decode metadata: %v", err) - } - - client, err := NewClient(ctx.HTTP, ctx.Integration) - if err != nil { - return fmt.Errorf("error creating client: %v", err) - } - - droplet, err := client.GetDroplet(metadata.DropletID) - if err != nil { - return fmt.Errorf("failed to get droplet: %v", err) - } - - // If the droplet is not active yet, poll again. - if droplet.Status != "active" { - return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, dropletPollInterval) - } - - return ctx.ExecutionState.Emit( - core.DefaultOutputChannel.Name, - "digitalocean.droplet.created", - []any{droplet}, - ) + return nil } func (c *CreateDroplet) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { diff --git a/pkg/integrations/digitalocean/create_droplet_test.go b/pkg/integrations/digitalocean/create_droplet_test.go index 9c70df077a..3d60c695f3 100644 --- a/pkg/integrations/digitalocean/create_droplet_test.go +++ b/pkg/integrations/digitalocean/create_droplet_test.go @@ -5,7 +5,6 @@ import ( "net/http" "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -94,7 +93,7 @@ func Test__CreateDroplet__Setup(t *testing.T) { func Test__CreateDroplet__Execute(t *testing.T) { component := &CreateDroplet{} - t.Run("successful creation -> stores metadata and schedules poll", func(t *testing.T) { + t.Run("successful creation -> emits droplet data", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ { @@ -110,7 +109,7 @@ func Test__CreateDroplet__Execute(t *testing.T) { "region": {"name": "New York 3", "slug": "nyc3"}, "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"}, "size_slug": "s-1vcpu-1gb", - "networks": {"v4": []}, + "networks": {"v4": [{"ip_address": "104.131.186.241", "type": "public"}]}, "tags": ["web"] } }`)), @@ -124,8 +123,6 @@ func Test__CreateDroplet__Execute(t *testing.T) { }, } - metadataCtx := &contexts.MetadataContext{} - requestCtx := &contexts.RequestContext{} executionState := &contexts.ExecutionStateContext{ KVs: map[string]string{}, } @@ -141,23 +138,16 @@ func Test__CreateDroplet__Execute(t *testing.T) { HTTP: httpContext, Integration: integrationCtx, ExecutionState: executionState, - Metadata: metadataCtx, - Requests: requestCtx, }) require.NoError(t, err) + assert.True(t, executionState.Passed) + assert.Equal(t, "default", executionState.Channel) + assert.Equal(t, "digitalocean.droplet.created", executionState.Type) - // Should store droplet ID in metadata - metadata, ok := metadataCtx.Metadata.(map[string]any) - require.True(t, ok) - assert.Equal(t, 98765432, metadata["dropletID"]) - - // Should schedule a poll action - assert.Equal(t, "poll", requestCtx.Action) - assert.Equal(t, 10*time.Second, requestCtx.Duration) - - // Should NOT emit yet (waiting for active status) - assert.False(t, executionState.Passed) + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, "https://api.digitalocean.com/v2/droplets", httpContext.Requests[0].URL.String()) + assert.Equal(t, http.MethodPost, httpContext.Requests[0].Method) }) t.Run("API error -> returns error", func(t *testing.T) { @@ -196,111 +186,3 @@ func Test__CreateDroplet__Execute(t *testing.T) { assert.Contains(t, err.Error(), "failed to create droplet") }) } - -func Test__CreateDroplet__HandleAction(t *testing.T) { - component := &CreateDroplet{} - - t.Run("droplet active -> emits with IP address", func(t *testing.T) { - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{ - "droplet": { - "id": 98765432, - "name": "my-droplet", - "memory": 1024, - "vcpus": 1, - "disk": 25, - "status": "active", - "region": {"name": "New York 3", "slug": "nyc3"}, - "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"}, - "size_slug": "s-1vcpu-1gb", - "networks": {"v4": [{"ip_address": "104.131.186.241", "type": "public"}]}, - "tags": ["web"] - } - }`)), - }, - }, - } - - integrationCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "apiToken": "test-token", - }, - } - - metadataCtx := &contexts.MetadataContext{ - Metadata: map[string]any{"dropletID": 98765432}, - } - - executionState := &contexts.ExecutionStateContext{ - KVs: map[string]string{}, - } - - err := component.HandleAction(core.ActionContext{ - Name: "poll", - HTTP: httpContext, - Integration: integrationCtx, - Metadata: metadataCtx, - ExecutionState: executionState, - Requests: &contexts.RequestContext{}, - }) - - require.NoError(t, err) - assert.True(t, executionState.Passed) - assert.Equal(t, "default", executionState.Channel) - assert.Equal(t, "digitalocean.droplet.created", executionState.Type) - }) - - t.Run("droplet still new -> schedules another poll", func(t *testing.T) { - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{ - "droplet": { - "id": 98765432, - "name": "my-droplet", - "status": "new", - "region": {"name": "New York 3", "slug": "nyc3"}, - "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"}, - "size_slug": "s-1vcpu-1gb", - "networks": {"v4": []}, - "tags": [] - } - }`)), - }, - }, - } - - integrationCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "apiToken": "test-token", - }, - } - - metadataCtx := &contexts.MetadataContext{ - Metadata: map[string]any{"dropletID": 98765432}, - } - - requestCtx := &contexts.RequestContext{} - executionState := &contexts.ExecutionStateContext{ - KVs: map[string]string{}, - } - - err := component.HandleAction(core.ActionContext{ - Name: "poll", - HTTP: httpContext, - Integration: integrationCtx, - Metadata: metadataCtx, - ExecutionState: executionState, - Requests: requestCtx, - }) - - require.NoError(t, err) - assert.False(t, executionState.Passed) - assert.Equal(t, "poll", requestCtx.Action) - assert.Equal(t, 10*time.Second, requestCtx.Duration) - }) -} diff --git a/pkg/integrations/digitalocean/example_data_on_droplet_event.json b/pkg/integrations/digitalocean/example_data_on_droplet_event.json index 5470db1813..fc8df7f301 100644 --- a/pkg/integrations/digitalocean/example_data_on_droplet_event.json +++ b/pkg/integrations/digitalocean/example_data_on_droplet_event.json @@ -13,7 +13,6 @@ "id": 98765432, "name": "my-droplet", "size_slug": "s-1vcpu-1gb", - "networks": { "v4": [{ "ip_address": "203.0.113.1", "type": "public" }] }, "image": { "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64" }, "region": { "name": "New York 3", "slug": "nyc3" } } diff --git a/pkg/integrations/digitalocean/on_droplet_event.go b/pkg/integrations/digitalocean/on_droplet_event.go index 7033ceef71..2d324ac3ac 100644 --- a/pkg/integrations/digitalocean/on_droplet_event.go +++ b/pkg/integrations/digitalocean/on_droplet_event.go @@ -170,10 +170,6 @@ func (t *OnDropletEvent) HandleAction(ctx core.TriggerActionContext) (map[string return nil, nil } - // Cache droplet lookups by ResourceID to avoid N+1 API calls - // when multiple actions reference the same droplet. - dropletCache := map[int]*Droplet{} - for _, action := range actions { if action.Status != "completed" { continue @@ -207,28 +203,12 @@ func (t *OnDropletEvent) HandleAction(ctx core.TriggerActionContext) (map[string // Enrich the payload with droplet details when available. // For destroy events the droplet may no longer exist. - droplet, cached := dropletCache[action.ResourceID] - if !cached { - droplet, err = client.GetDroplet(action.ResourceID) - if err != nil { - droplet = nil - } - dropletCache[action.ResourceID] = droplet - } - if droplet != nil { - v4Networks := make([]map[string]any, len(droplet.Networks.V4)) - for i, n := range droplet.Networks.V4 { - v4Networks[i] = map[string]any{ - "ip_address": n.IPAddress, - "type": n.Type, - } - } - + droplet, err := client.GetDroplet(action.ResourceID) + if err == nil { payload["droplet"] = map[string]any{ "id": droplet.ID, "name": droplet.Name, "size_slug": droplet.SizeSlug, - "networks": map[string]any{"v4": v4Networks}, "image": map[string]any{ "name": droplet.Image.Name, "slug": droplet.Image.Slug, diff --git a/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts b/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts index c02b190d03..290d1dcd50 100644 --- a/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts +++ b/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts @@ -19,9 +19,6 @@ interface OnDropletEventData { id: number; name: string; size_slug: string; - networks?: { - v4?: { ip_address: string; type: string }[]; - }; image: { name: string; slug: string; @@ -48,8 +45,7 @@ export const onDropletEventTriggerRenderer: TriggerRenderer = { const action = eventData?.action; const droplet = eventData?.droplet; - const ip = droplet?.networks?.v4?.find((n) => n.type === "public")?.ip_address; - const values: Record = { + return { "Started At": action?.started_at ? new Date(action.started_at).toLocaleString() : "-", "Completed At": action?.completed_at ? new Date(action.completed_at).toLocaleString() : "-", "Droplet ID": action?.resource_id?.toString() || "-", @@ -59,10 +55,6 @@ export const onDropletEventTriggerRenderer: TriggerRenderer = { OS: droplet?.image?.name || droplet?.image?.slug || "-", Region: droplet?.region?.name || action?.region_slug || "-", }; - if (ip) { - values["IP Address"] = ip; - } - return values; }, getTriggerProps: (context: TriggerRendererContext) => { From d7c19694a1924ce53ccd5eff5457393cbfbc5723 Mon Sep 17 00:00:00 2001 From: Aldo Date: Sat, 14 Feb 2026 23:40:06 +0700 Subject: [PATCH 08/12] fix: restore Cursor integration files from upstream main The rebase conflict resolution accidentally kept older versions of Cursor integration files, missing ConversationMessage and GetAgentConversation that get_last_message.go depends on. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Aldo --- docs/components/Cursor.mdx | 51 +++++++++++++++++++ pkg/integrations/cursor/client.go | 30 +++++++++++ pkg/integrations/cursor/cursor.go | 1 + pkg/integrations/cursor/cursor_test.go | 3 +- .../pages/workflowv2/mappers/cursor/index.ts | 3 ++ 5 files changed, 87 insertions(+), 1 deletion(-) diff --git a/docs/components/Cursor.mdx b/docs/components/Cursor.mdx index 3c869807c7..37f746ed29 100644 --- a/docs/components/Cursor.mdx +++ b/docs/components/Cursor.mdx @@ -10,6 +10,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + @@ -86,6 +87,56 @@ The output includes per-user daily metrics: } ``` + + +## Get Last Message + +The Get Last Message component retrieves the last message from a Cursor Cloud Agent's conversation history. + +### Use Cases + +- **Message tracking**: Get the latest response or prompt from an agent conversation +- **Workflow automation**: Use the last message as input for downstream components +- **Status monitoring**: Check what the agent last communicated + +### How It Works + +1. Fetches the conversation history for the specified agent ID +2. Extracts the last message from the conversation +3. Returns the message details including ID, type (user_message or assistant_message), and text + +### Configuration + +- **Agent ID**: The unique identifier for the cloud agent (e.g., bc_abc123) + +### Output + +The output includes: +- **Agent ID**: The identifier of the agent +- **Message**: The last message object containing: + - **ID**: Unique message identifier + - **Type**: Either "user_message" or "assistant_message" + - **Text**: The message content + +### Notes + +- Requires a valid Cursor Cloud Agent API key configured in the integration +- If the agent has been deleted, the conversation cannot be accessed +- Returns nil if the conversation has no messages + +### Example Output + +```json +{ + "agentId": "bc_abc123", + "message": { + "id": "msg_005", + "text": "I've added a troubleshooting section to the README.", + "type": "assistant_message" + } +} +``` + ## Launch Cloud Agent diff --git a/pkg/integrations/cursor/client.go b/pkg/integrations/cursor/client.go index 53e3e017dc..4842cbb907 100644 --- a/pkg/integrations/cursor/client.go +++ b/pkg/integrations/cursor/client.go @@ -52,6 +52,17 @@ type ModelsResponse struct { Models []string `json:"models"` } +type ConversationMessage struct { + ID string `json:"id"` + Type string `json:"type"` + Text string `json:"text"` +} + +type ConversationResponse struct { + ID string `json:"id"` + Messages []ConversationMessage `json:"messages"` +} + func (c *Client) ListModels() ([]string, error) { if c.LaunchAgentKey == "" { return nil, fmt.Errorf("Cloud Agent API key is not configured") @@ -163,6 +174,25 @@ func (c *Client) CancelAgent(agentID string) error { return err } +func (c *Client) GetAgentConversation(agentID string) (*ConversationResponse, error) { + if c.LaunchAgentKey == "" { + return nil, fmt.Errorf("Cloud Agent API key is not configured") + } + + url := fmt.Sprintf("%s/v0/agents/%s/conversation", c.BaseURL, agentID) + responseBody, err := c.execRequest(http.MethodGet, url, nil, c.LaunchAgentKey) + if err != nil { + return nil, err + } + + var response ConversationResponse + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal conversation response: %w", err) + } + + return &response, nil +} + func (c *Client) execRequest(method, URL string, body io.Reader, apiKey string) ([]byte, error) { req, err := http.NewRequest(method, URL, body) if err != nil { diff --git a/pkg/integrations/cursor/cursor.go b/pkg/integrations/cursor/cursor.go index 611ad102e7..c24e3743b5 100644 --- a/pkg/integrations/cursor/cursor.go +++ b/pkg/integrations/cursor/cursor.go @@ -96,6 +96,7 @@ func (i *Cursor) Components() []core.Component { return []core.Component{ &LaunchAgent{}, &GetDailyUsageData{}, + &GetLastMessage{}, } } diff --git a/pkg/integrations/cursor/cursor_test.go b/pkg/integrations/cursor/cursor_test.go index bcb7a4a5d4..21e038f4eb 100644 --- a/pkg/integrations/cursor/cursor_test.go +++ b/pkg/integrations/cursor/cursor_test.go @@ -177,7 +177,7 @@ func Test__Cursor__Components(t *testing.T) { c := &Cursor{} components := c.Components() - assert.Len(t, components, 2) + assert.Len(t, components, 3) names := make([]string, len(components)) for i, comp := range components { @@ -186,6 +186,7 @@ func Test__Cursor__Components(t *testing.T) { assert.Contains(t, names, "cursor.launchAgent") assert.Contains(t, names, "cursor.getDailyUsageData") + assert.Contains(t, names, "cursor.getLastMessage") } func Test__Cursor__ListResources(t *testing.T) { diff --git a/web_src/src/pages/workflowv2/mappers/cursor/index.ts b/web_src/src/pages/workflowv2/mappers/cursor/index.ts index d9ea382696..455e80de74 100644 --- a/web_src/src/pages/workflowv2/mappers/cursor/index.ts +++ b/web_src/src/pages/workflowv2/mappers/cursor/index.ts @@ -2,10 +2,12 @@ import { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../typ import { buildActionStateRegistry } from "../utils"; import { launchAgentMapper } from "./launch_agent"; import { getDailyUsageDataMapper } from "./get_daily_usage_data"; +import { getLastMessageMapper } from "./get_last_message"; export const componentMappers: Record = { launchAgent: launchAgentMapper, getDailyUsageData: getDailyUsageDataMapper, + getLastMessage: getLastMessageMapper, }; export const triggerRenderers: Record = {}; @@ -13,4 +15,5 @@ export const triggerRenderers: Record = {}; export const eventStateRegistry: Record = { launchAgent: buildActionStateRegistry("completed"), getDailyUsageData: buildActionStateRegistry("completed"), + getLastMessage: buildActionStateRegistry("completed"), }; From e0639ca3b2f1bc1e811e35962d5e3e5f5d371c90 Mon Sep 17 00:00:00 2001 From: Aldo Date: Sat, 14 Feb 2026 23:42:57 +0700 Subject: [PATCH 09/12] feat: add async polling for createDroplet to get IP address Instead of emitting immediately after creation (when droplet status is "new" and has no IP), store the droplet ID in metadata and schedule a poll action every 10s. Once the droplet reaches "active" status with an assigned IP address, emit the full droplet data. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Aldo --- .../digitalocean/create_droplet.go | 57 +++++++- .../digitalocean/create_droplet_test.go | 134 ++++++++++++++++-- 2 files changed, 176 insertions(+), 15 deletions(-) diff --git a/pkg/integrations/digitalocean/create_droplet.go b/pkg/integrations/digitalocean/create_droplet.go index 6bbcdf079f..ae51532e52 100644 --- a/pkg/integrations/digitalocean/create_droplet.go +++ b/pkg/integrations/digitalocean/create_droplet.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "regexp" + "time" "github.com/google/uuid" "github.com/mitchellh/mapstructure" @@ -12,6 +13,8 @@ import ( "github.com/superplanehq/superplane/pkg/core" ) +const dropletPollInterval = 10 * time.Second + var validHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.\-]*$`) type CreateDroplet struct{} @@ -236,11 +239,12 @@ func (c *CreateDroplet) Execute(ctx core.ExecutionContext) error { return fmt.Errorf("failed to create droplet: %v", err) } - return ctx.ExecutionState.Emit( - core.DefaultOutputChannel.Name, - "digitalocean.droplet.created", - []any{droplet}, - ) + err = ctx.Metadata.Set(map[string]any{"dropletID": droplet.ID}) + if err != nil { + return fmt.Errorf("failed to store metadata: %v", err) + } + + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, dropletPollInterval) } func (c *CreateDroplet) Cancel(ctx core.ExecutionContext) error { @@ -252,11 +256,50 @@ func (c *CreateDroplet) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UU } func (c *CreateDroplet) Actions() []core.Action { - return []core.Action{} + return []core.Action{ + { + Name: "poll", + UserAccessible: false, + }, + } } func (c *CreateDroplet) HandleAction(ctx core.ActionContext) error { - return nil + if ctx.Name != "poll" { + return fmt.Errorf("unknown action: %s", ctx.Name) + } + + if ctx.ExecutionState.IsFinished() { + return nil + } + + var metadata struct { + DropletID int `mapstructure:"dropletID"` + } + + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return fmt.Errorf("failed to decode metadata: %v", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + droplet, err := client.GetDroplet(metadata.DropletID) + if err != nil { + return fmt.Errorf("failed to get droplet: %v", err) + } + + if droplet.Status != "active" { + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, dropletPollInterval) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "digitalocean.droplet.created", + []any{droplet}, + ) } func (c *CreateDroplet) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { diff --git a/pkg/integrations/digitalocean/create_droplet_test.go b/pkg/integrations/digitalocean/create_droplet_test.go index 3d60c695f3..9c70df077a 100644 --- a/pkg/integrations/digitalocean/create_droplet_test.go +++ b/pkg/integrations/digitalocean/create_droplet_test.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -93,7 +94,7 @@ func Test__CreateDroplet__Setup(t *testing.T) { func Test__CreateDroplet__Execute(t *testing.T) { component := &CreateDroplet{} - t.Run("successful creation -> emits droplet data", func(t *testing.T) { + t.Run("successful creation -> stores metadata and schedules poll", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ { @@ -109,7 +110,7 @@ func Test__CreateDroplet__Execute(t *testing.T) { "region": {"name": "New York 3", "slug": "nyc3"}, "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"}, "size_slug": "s-1vcpu-1gb", - "networks": {"v4": [{"ip_address": "104.131.186.241", "type": "public"}]}, + "networks": {"v4": []}, "tags": ["web"] } }`)), @@ -123,6 +124,8 @@ func Test__CreateDroplet__Execute(t *testing.T) { }, } + metadataCtx := &contexts.MetadataContext{} + requestCtx := &contexts.RequestContext{} executionState := &contexts.ExecutionStateContext{ KVs: map[string]string{}, } @@ -138,16 +141,23 @@ func Test__CreateDroplet__Execute(t *testing.T) { HTTP: httpContext, Integration: integrationCtx, ExecutionState: executionState, + Metadata: metadataCtx, + Requests: requestCtx, }) require.NoError(t, err) - assert.True(t, executionState.Passed) - assert.Equal(t, "default", executionState.Channel) - assert.Equal(t, "digitalocean.droplet.created", executionState.Type) - require.Len(t, httpContext.Requests, 1) - assert.Equal(t, "https://api.digitalocean.com/v2/droplets", httpContext.Requests[0].URL.String()) - assert.Equal(t, http.MethodPost, httpContext.Requests[0].Method) + // Should store droplet ID in metadata + metadata, ok := metadataCtx.Metadata.(map[string]any) + require.True(t, ok) + assert.Equal(t, 98765432, metadata["dropletID"]) + + // Should schedule a poll action + assert.Equal(t, "poll", requestCtx.Action) + assert.Equal(t, 10*time.Second, requestCtx.Duration) + + // Should NOT emit yet (waiting for active status) + assert.False(t, executionState.Passed) }) t.Run("API error -> returns error", func(t *testing.T) { @@ -186,3 +196,111 @@ func Test__CreateDroplet__Execute(t *testing.T) { assert.Contains(t, err.Error(), "failed to create droplet") }) } + +func Test__CreateDroplet__HandleAction(t *testing.T) { + component := &CreateDroplet{} + + t.Run("droplet active -> emits with IP address", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "droplet": { + "id": 98765432, + "name": "my-droplet", + "memory": 1024, + "vcpus": 1, + "disk": 25, + "status": "active", + "region": {"name": "New York 3", "slug": "nyc3"}, + "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"}, + "size_slug": "s-1vcpu-1gb", + "networks": {"v4": [{"ip_address": "104.131.186.241", "type": "public"}]}, + "tags": ["web"] + } + }`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + + metadataCtx := &contexts.MetadataContext{ + Metadata: map[string]any{"dropletID": 98765432}, + } + + executionState := &contexts.ExecutionStateContext{ + KVs: map[string]string{}, + } + + err := component.HandleAction(core.ActionContext{ + Name: "poll", + HTTP: httpContext, + Integration: integrationCtx, + Metadata: metadataCtx, + ExecutionState: executionState, + Requests: &contexts.RequestContext{}, + }) + + require.NoError(t, err) + assert.True(t, executionState.Passed) + assert.Equal(t, "default", executionState.Channel) + assert.Equal(t, "digitalocean.droplet.created", executionState.Type) + }) + + t.Run("droplet still new -> schedules another poll", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "droplet": { + "id": 98765432, + "name": "my-droplet", + "status": "new", + "region": {"name": "New York 3", "slug": "nyc3"}, + "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"}, + "size_slug": "s-1vcpu-1gb", + "networks": {"v4": []}, + "tags": [] + } + }`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + + metadataCtx := &contexts.MetadataContext{ + Metadata: map[string]any{"dropletID": 98765432}, + } + + requestCtx := &contexts.RequestContext{} + executionState := &contexts.ExecutionStateContext{ + KVs: map[string]string{}, + } + + err := component.HandleAction(core.ActionContext{ + Name: "poll", + HTTP: httpContext, + Integration: integrationCtx, + Metadata: metadataCtx, + ExecutionState: executionState, + Requests: requestCtx, + }) + + require.NoError(t, err) + assert.False(t, executionState.Passed) + assert.Equal(t, "poll", requestCtx.Action) + assert.Equal(t, 10*time.Second, requestCtx.Duration) + }) +} From 831df54b8c0de12d97dd0491dc2b40c70c83b954 Mon Sep 17 00:00:00 2001 From: Aldo Date: Sun, 15 Feb 2026 00:08:24 +0700 Subject: [PATCH 10/12] fix: address PR review comments for DigitalOcean integration - createDroplet: handle terminal droplet states (off, archive) instead of polling indefinitely. Only continue polling for "new" status. - onDropletEvent: log emit failures instead of returning error, so the trigger keeps polling and doesn't stop permanently. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Aldo --- .../digitalocean/create_droplet.go | 17 ++++--- .../digitalocean/create_droplet_test.go | 49 +++++++++++++++++++ .../digitalocean/on_droplet_event.go | 7 ++- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/pkg/integrations/digitalocean/create_droplet.go b/pkg/integrations/digitalocean/create_droplet.go index ae51532e52..a0461344c9 100644 --- a/pkg/integrations/digitalocean/create_droplet.go +++ b/pkg/integrations/digitalocean/create_droplet.go @@ -291,15 +291,18 @@ func (c *CreateDroplet) HandleAction(ctx core.ActionContext) error { return fmt.Errorf("failed to get droplet: %v", err) } - if droplet.Status != "active" { + switch droplet.Status { + case "active": + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "digitalocean.droplet.created", + []any{droplet}, + ) + case "new": return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, dropletPollInterval) + default: + return fmt.Errorf("droplet reached unexpected status %q", droplet.Status) } - - return ctx.ExecutionState.Emit( - core.DefaultOutputChannel.Name, - "digitalocean.droplet.created", - []any{droplet}, - ) } func (c *CreateDroplet) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { diff --git a/pkg/integrations/digitalocean/create_droplet_test.go b/pkg/integrations/digitalocean/create_droplet_test.go index 9c70df077a..e12624c87b 100644 --- a/pkg/integrations/digitalocean/create_droplet_test.go +++ b/pkg/integrations/digitalocean/create_droplet_test.go @@ -303,4 +303,53 @@ func Test__CreateDroplet__HandleAction(t *testing.T) { assert.Equal(t, "poll", requestCtx.Action) assert.Equal(t, 10*time.Second, requestCtx.Duration) }) + + t.Run("droplet terminal status -> returns error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "droplet": { + "id": 98765432, + "name": "my-droplet", + "status": "off", + "region": {"name": "New York 3", "slug": "nyc3"}, + "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"}, + "size_slug": "s-1vcpu-1gb", + "networks": {"v4": []}, + "tags": [] + } + }`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + + metadataCtx := &contexts.MetadataContext{ + Metadata: map[string]any{"dropletID": 98765432}, + } + + executionState := &contexts.ExecutionStateContext{ + KVs: map[string]string{}, + } + + err := component.HandleAction(core.ActionContext{ + Name: "poll", + HTTP: httpContext, + Integration: integrationCtx, + Metadata: metadataCtx, + ExecutionState: executionState, + Requests: &contexts.RequestContext{}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status") + assert.False(t, executionState.Passed) + }) } diff --git a/pkg/integrations/digitalocean/on_droplet_event.go b/pkg/integrations/digitalocean/on_droplet_event.go index 2d324ac3ac..1767c90953 100644 --- a/pkg/integrations/digitalocean/on_droplet_event.go +++ b/pkg/integrations/digitalocean/on_droplet_event.go @@ -220,12 +220,11 @@ func (t *OnDropletEvent) HandleAction(ctx core.TriggerActionContext) (map[string } } - err = ctx.Events.Emit( + if err := ctx.Events.Emit( fmt.Sprintf("digitalocean.droplet.%s", action.Type), payload, - ) - if err != nil { - return nil, fmt.Errorf("error emitting event: %v", err) + ); err != nil { + ctx.Logger.Errorf("error emitting event for action %d: %v", action.ID, err) } } From cd28902fbf28052789001254d7eb7bdb65125f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0ar=C4=8Devi=C4=87?= Date: Thu, 19 Feb 2026 22:00:30 +0100 Subject: [PATCH 11/12] Remove ondroplet event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Igor Šarčević --- pkg/integrations/digitalocean/digitalocean.go | 4 +- pkg/integrations/digitalocean/example.go | 10 - .../example_data_on_droplet_event.json | 19 -- .../digitalocean/on_droplet_event.go | 252 ------------------ .../digitalocean/on_droplet_event_test.go | 210 --------------- .../workflowv2/mappers/digitalocean/index.ts | 5 +- .../mappers/digitalocean/on_droplet_event.ts | 94 ------- 7 files changed, 2 insertions(+), 592 deletions(-) delete mode 100644 pkg/integrations/digitalocean/example_data_on_droplet_event.json delete mode 100644 pkg/integrations/digitalocean/on_droplet_event.go delete mode 100644 pkg/integrations/digitalocean/on_droplet_event_test.go delete mode 100644 web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts diff --git a/pkg/integrations/digitalocean/digitalocean.go b/pkg/integrations/digitalocean/digitalocean.go index f88cb4c012..36dcbb62d5 100644 --- a/pkg/integrations/digitalocean/digitalocean.go +++ b/pkg/integrations/digitalocean/digitalocean.go @@ -75,9 +75,7 @@ func (d *DigitalOcean) Components() []core.Component { } func (d *DigitalOcean) Triggers() []core.Trigger { - return []core.Trigger{ - &OnDropletEvent{}, - } + return []core.Trigger{} } func (d *DigitalOcean) Sync(ctx core.SyncContext) error { diff --git a/pkg/integrations/digitalocean/example.go b/pkg/integrations/digitalocean/example.go index 0932babbb8..b5441afacc 100644 --- a/pkg/integrations/digitalocean/example.go +++ b/pkg/integrations/digitalocean/example.go @@ -7,22 +7,12 @@ import ( "github.com/superplanehq/superplane/pkg/utils" ) -//go:embed example_data_on_droplet_event.json -var exampleDataOnDropletEventBytes []byte - -var exampleDataOnDropletEventOnce sync.Once -var exampleDataOnDropletEvent map[string]any - //go:embed example_output_create_droplet.json var exampleOutputCreateDropletBytes []byte var exampleOutputCreateDropletOnce sync.Once var exampleOutputCreateDroplet map[string]any -func (t *OnDropletEvent) ExampleData() map[string]any { - return utils.UnmarshalEmbeddedJSON(&exampleDataOnDropletEventOnce, exampleDataOnDropletEventBytes, &exampleDataOnDropletEvent) -} - func (c *CreateDroplet) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateDropletOnce, exampleOutputCreateDropletBytes, &exampleOutputCreateDroplet) } diff --git a/pkg/integrations/digitalocean/example_data_on_droplet_event.json b/pkg/integrations/digitalocean/example_data_on_droplet_event.json deleted file mode 100644 index fc8df7f301..0000000000 --- a/pkg/integrations/digitalocean/example_data_on_droplet_event.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "action": { - "id": 123456789, - "status": "completed", - "type": "create", - "started_at": "2026-01-19T12:00:00Z", - "completed_at": "2026-01-19T12:01:30Z", - "resource_id": 98765432, - "resource_type": "droplet", - "region_slug": "nyc3" - }, - "droplet": { - "id": 98765432, - "name": "my-droplet", - "size_slug": "s-1vcpu-1gb", - "image": { "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64" }, - "region": { "name": "New York 3", "slug": "nyc3" } - } -} diff --git a/pkg/integrations/digitalocean/on_droplet_event.go b/pkg/integrations/digitalocean/on_droplet_event.go deleted file mode 100644 index 1767c90953..0000000000 --- a/pkg/integrations/digitalocean/on_droplet_event.go +++ /dev/null @@ -1,252 +0,0 @@ -package digitalocean - -import ( - "fmt" - "net/http" - "slices" - "time" - - "github.com/mitchellh/mapstructure" - "github.com/superplanehq/superplane/pkg/configuration" - "github.com/superplanehq/superplane/pkg/core" -) - -type OnDropletEvent struct{} - -type OnDropletEventConfiguration struct { - Events []string `json:"events"` -} - -type OnDropletEventMetadata struct { - LastPollTime string `json:"lastPollTime"` -} - -func (t *OnDropletEvent) Name() string { - return "digitalocean.onDropletEvent" -} - -func (t *OnDropletEvent) Label() string { - return "On Droplet Event" -} - -func (t *OnDropletEvent) Description() string { - return "Poll for DigitalOcean droplet lifecycle events" -} - -func (t *OnDropletEvent) Documentation() string { - return `The On Droplet Event trigger polls the DigitalOcean API for droplet lifecycle events. - -## Use Cases - -- **Infrastructure monitoring**: React to droplet creation and destruction events -- **Audit logging**: Track all droplet lifecycle changes -- **Automation**: Trigger workflows when droplets are powered on/off or resized - -## Configuration - -- **Events**: Select which droplet event types to listen for (create, destroy, power_on, power_off, shutdown, reboot, snapshot, rebuild, resize, rename) - -## Polling - -This trigger polls the DigitalOcean Actions API every 60 seconds for new completed droplet events matching the configured event types. - -## Event Data - -Each event includes: -- **action**: The DigitalOcean action object with id, status, type, timestamps, resource_id, and region_slug` -} - -func (t *OnDropletEvent) Icon() string { - return "server" -} - -func (t *OnDropletEvent) Color() string { - return "gray" -} - -func (t *OnDropletEvent) Configuration() []configuration.Field { - return []configuration.Field{ - { - Name: "events", - Label: "Events", - Type: configuration.FieldTypeMultiSelect, - Required: true, - Default: []string{"create", "destroy"}, - TypeOptions: &configuration.TypeOptions{ - MultiSelect: &configuration.MultiSelectTypeOptions{ - Options: []configuration.FieldOption{ - {Label: "Create", Value: "create"}, - {Label: "Destroy", Value: "destroy"}, - {Label: "Power On", Value: "power_on"}, - {Label: "Power Off", Value: "power_off"}, - {Label: "Shutdown", Value: "shutdown"}, - {Label: "Reboot", Value: "reboot"}, - {Label: "Snapshot", Value: "snapshot"}, - {Label: "Rebuild", Value: "rebuild"}, - {Label: "Resize", Value: "resize"}, - {Label: "Rename", Value: "rename"}, - }, - }, - }, - }, - } -} - -func (t *OnDropletEvent) Setup(ctx core.TriggerContext) error { - config := OnDropletEventConfiguration{} - err := mapstructure.Decode(ctx.Configuration, &config) - if err != nil { - return fmt.Errorf("failed to decode configuration: %w", err) - } - - if len(config.Events) == 0 { - return fmt.Errorf("at least one event type must be selected") - } - - // Check if already set up - var existingMetadata OnDropletEventMetadata - err = mapstructure.Decode(ctx.Metadata.Get(), &existingMetadata) - if err == nil && existingMetadata.LastPollTime != "" { - return nil - } - - now := time.Now().UTC().Format(time.RFC3339) - err = ctx.Metadata.Set(OnDropletEventMetadata{ - LastPollTime: now, - }) - if err != nil { - return fmt.Errorf("error setting metadata: %v", err) - } - - return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, 60*time.Second) -} - -func (t *OnDropletEvent) Actions() []core.Action { - return []core.Action{ - { - Name: "poll", - Description: "Poll for new droplet events", - UserAccessible: false, - }, - } -} - -func (t *OnDropletEvent) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { - if ctx.Name != "poll" { - return nil, fmt.Errorf("action %s not supported", ctx.Name) - } - - config := OnDropletEventConfiguration{} - err := mapstructure.Decode(ctx.Configuration, &config) - if err != nil { - return nil, fmt.Errorf("failed to decode configuration: %w", err) - } - - var metadata OnDropletEventMetadata - err = mapstructure.Decode(ctx.Metadata.Get(), &metadata) - if err != nil { - return nil, fmt.Errorf("failed to decode metadata: %w", err) - } - - lastPollTime, err := time.Parse(time.RFC3339, metadata.LastPollTime) - if err != nil { - return nil, fmt.Errorf("failed to parse lastPollTime: %w", err) - } - - // Capture the poll timestamp before the API call so events that complete - // between the request and response are not permanently missed. - now := time.Now().UTC().Format(time.RFC3339) - - client, err := NewClient(ctx.HTTP, ctx.Integration) - if err != nil { - return nil, fmt.Errorf("error creating client: %v", err) - } - - actions, err := client.ListActions("droplet") - if err != nil { - // On transient API errors, reschedule the poll and return nil so the - // framework does not roll back the scheduled call. - _ = ctx.Requests.ScheduleActionCall("poll", map[string]any{}, 60*time.Second) - return nil, nil - } - - for _, action := range actions { - if action.Status != "completed" { - continue - } - - completedAt, err := time.Parse(time.RFC3339, action.CompletedAt) - if err != nil { - continue - } - - if !completedAt.After(lastPollTime) { - continue - } - - if !slices.Contains(config.Events, action.Type) { - continue - } - - payload := map[string]any{ - "action": map[string]any{ - "id": action.ID, - "status": action.Status, - "type": action.Type, - "started_at": action.StartedAt, - "completed_at": action.CompletedAt, - "resource_id": action.ResourceID, - "resource_type": action.ResourceType, - "region_slug": action.RegionSlug, - }, - } - - // Enrich the payload with droplet details when available. - // For destroy events the droplet may no longer exist. - droplet, err := client.GetDroplet(action.ResourceID) - if err == nil { - payload["droplet"] = map[string]any{ - "id": droplet.ID, - "name": droplet.Name, - "size_slug": droplet.SizeSlug, - "image": map[string]any{ - "name": droplet.Image.Name, - "slug": droplet.Image.Slug, - }, - "region": map[string]any{ - "name": droplet.Region.Name, - "slug": droplet.Region.Slug, - }, - } - } - - if err := ctx.Events.Emit( - fmt.Sprintf("digitalocean.droplet.%s", action.Type), - payload, - ); err != nil { - ctx.Logger.Errorf("error emitting event for action %d: %v", action.ID, err) - } - } - - err = ctx.Metadata.Set(OnDropletEventMetadata{ - LastPollTime: now, - }) - if err != nil { - return nil, fmt.Errorf("error updating metadata: %v", err) - } - - err = ctx.Requests.ScheduleActionCall("poll", map[string]any{}, 60*time.Second) - if err != nil { - return nil, fmt.Errorf("error scheduling next poll: %v", err) - } - - return nil, nil -} - -func (t *OnDropletEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { - return http.StatusOK, nil -} - -func (t *OnDropletEvent) Cleanup(ctx core.TriggerContext) error { - return nil -} diff --git a/pkg/integrations/digitalocean/on_droplet_event_test.go b/pkg/integrations/digitalocean/on_droplet_event_test.go deleted file mode 100644 index f2e4fdceba..0000000000 --- a/pkg/integrations/digitalocean/on_droplet_event_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package digitalocean - -import ( - "io" - "net/http" - "strings" - "testing" - "time" - - "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__OnDropletEvent__Setup(t *testing.T) { - trigger := &OnDropletEvent{} - - t.Run("valid config -> stores metadata and schedules poll", func(t *testing.T) { - metadataCtx := &contexts.MetadataContext{} - requestCtx := &contexts.RequestContext{} - - err := trigger.Setup(core.TriggerContext{ - Configuration: map[string]any{ - "events": []string{"create", "destroy"}, - }, - Metadata: metadataCtx, - Requests: requestCtx, - Integration: &contexts.IntegrationContext{}, - }) - - require.NoError(t, err) - require.NotNil(t, metadataCtx.Metadata) - assert.Equal(t, "poll", requestCtx.Action) - assert.Equal(t, 60*time.Second, requestCtx.Duration) - }) - - t.Run("empty events -> error", func(t *testing.T) { - err := trigger.Setup(core.TriggerContext{ - Configuration: map[string]any{ - "events": []string{}, - }, - Metadata: &contexts.MetadataContext{}, - Requests: &contexts.RequestContext{}, - Integration: &contexts.IntegrationContext{}, - }) - - require.ErrorContains(t, err, "at least one event type must be selected") - }) - - t.Run("metadata already set -> skips setup", func(t *testing.T) { - metadataCtx := &contexts.MetadataContext{ - Metadata: OnDropletEventMetadata{ - LastPollTime: time.Now().UTC().Format(time.RFC3339), - }, - } - requestCtx := &contexts.RequestContext{} - - err := trigger.Setup(core.TriggerContext{ - Configuration: map[string]any{ - "events": []string{"create"}, - }, - Metadata: metadataCtx, - Requests: requestCtx, - Integration: &contexts.IntegrationContext{}, - }) - - require.NoError(t, err) - assert.Empty(t, requestCtx.Action) - }) -} - -func Test__OnDropletEvent__HandleAction(t *testing.T) { - trigger := &OnDropletEvent{} - - t.Run("poll with new matching actions -> emits events and re-schedules", func(t *testing.T) { - pastTime := time.Now().UTC().Add(-5 * time.Minute).Format(time.RFC3339) - futureTime := time.Now().UTC().Add(1 * time.Minute).Format(time.RFC3339) - - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{ - "actions": [ - { - "id": 111, - "status": "completed", - "type": "create", - "started_at": "` + pastTime + `", - "completed_at": "` + futureTime + `", - "resource_id": 222, - "resource_type": "droplet", - "region_slug": "nyc3" - }, - { - "id": 333, - "status": "completed", - "type": "destroy", - "started_at": "` + pastTime + `", - "completed_at": "` + futureTime + `", - "resource_id": 444, - "resource_type": "droplet", - "region_slug": "sfo3" - }, - { - "id": 555, - "status": "completed", - "type": "power_on", - "started_at": "` + pastTime + `", - "completed_at": "` + futureTime + `", - "resource_id": 666, - "resource_type": "droplet", - "region_slug": "ams3" - }, - { - "id": 777, - "status": "completed", - "type": "create", - "started_at": "` + pastTime + `", - "completed_at": "` + futureTime + `", - "resource_id": 888, - "resource_type": "floating_ip", - "region_slug": "nyc3" - } - ] - }`)), - }, - }, - } - - integrationCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "apiToken": "test-token", - }, - } - - eventCtx := &contexts.EventContext{} - metadataCtx := &contexts.MetadataContext{ - Metadata: OnDropletEventMetadata{ - LastPollTime: pastTime, - }, - } - requestCtx := &contexts.RequestContext{} - - _, err := trigger.HandleAction(core.TriggerActionContext{ - Name: "poll", - Configuration: map[string]any{ - "events": []string{"create", "destroy"}, - }, - HTTP: httpContext, - Integration: integrationCtx, - Events: eventCtx, - Metadata: metadataCtx, - Requests: requestCtx, - }) - - require.NoError(t, err) - assert.Equal(t, 2, eventCtx.Count()) - assert.Equal(t, "digitalocean.droplet.create", eventCtx.Payloads[0].Type) - assert.Equal(t, "digitalocean.droplet.destroy", eventCtx.Payloads[1].Type) - - assert.Equal(t, "poll", requestCtx.Action) - assert.Equal(t, 60*time.Second, requestCtx.Duration) - }) - - t.Run("poll with no new actions -> re-schedules only", func(t *testing.T) { - now := time.Now().UTC().Format(time.RFC3339) - - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"actions": []}`)), - }, - }, - } - - integrationCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "apiToken": "test-token", - }, - } - - eventCtx := &contexts.EventContext{} - metadataCtx := &contexts.MetadataContext{ - Metadata: OnDropletEventMetadata{ - LastPollTime: now, - }, - } - requestCtx := &contexts.RequestContext{} - - _, err := trigger.HandleAction(core.TriggerActionContext{ - Name: "poll", - Configuration: map[string]any{ - "events": []string{"create"}, - }, - HTTP: httpContext, - Integration: integrationCtx, - Events: eventCtx, - Metadata: metadataCtx, - Requests: requestCtx, - }) - - require.NoError(t, err) - assert.Equal(t, 0, eventCtx.Count()) - assert.Equal(t, "poll", requestCtx.Action) - assert.Equal(t, 60*time.Second, requestCtx.Duration) - }) -} diff --git a/web_src/src/pages/workflowv2/mappers/digitalocean/index.ts b/web_src/src/pages/workflowv2/mappers/digitalocean/index.ts index 8047b9a4ec..0e43cd3bff 100644 --- a/web_src/src/pages/workflowv2/mappers/digitalocean/index.ts +++ b/web_src/src/pages/workflowv2/mappers/digitalocean/index.ts @@ -1,5 +1,4 @@ import { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types"; -import { onDropletEventTriggerRenderer } from "./on_droplet_event"; import { createDropletMapper } from "./create_droplet"; import { buildActionStateRegistry } from "../utils"; @@ -7,9 +6,7 @@ export const componentMappers: Record = { createDroplet: createDropletMapper, }; -export const triggerRenderers: Record = { - onDropletEvent: onDropletEventTriggerRenderer, -}; +export const triggerRenderers: Record = {}; export const eventStateRegistry: Record = { createDroplet: buildActionStateRegistry("created"), diff --git a/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts b/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts deleted file mode 100644 index 290d1dcd50..0000000000 --- a/web_src/src/pages/workflowv2/mappers/digitalocean/on_droplet_event.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { getBackgroundColorClass } from "@/utils/colors"; -import { formatTimeAgo } from "@/utils/date"; -import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; -import { TriggerProps } from "@/ui/trigger"; -import doIcon from "@/assets/icons/integrations/digitalocean.svg"; - -interface OnDropletEventData { - action?: { - id: number; - status: string; - type: string; - started_at: string; - completed_at: string; - resource_id: number; - resource_type: string; - region_slug: string; - }; - droplet?: { - id: number; - name: string; - size_slug: string; - image: { - name: string; - slug: string; - }; - region: { - name: string; - slug: string; - }; - }; -} - -export const onDropletEventTriggerRenderer: TriggerRenderer = { - getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { - const eventData = context.event?.data as OnDropletEventData; - const action = eventData?.action; - const title = `${action?.type || ""} - Droplet ${action?.resource_id || ""}`; - const subtitle = context.event?.createdAt ? formatTimeAgo(new Date(context.event.createdAt)) : ""; - - return { title, subtitle }; - }, - - getRootEventValues: (context: TriggerEventContext): Record => { - const eventData = context.event?.data as OnDropletEventData; - const action = eventData?.action; - const droplet = eventData?.droplet; - - return { - "Started At": action?.started_at ? new Date(action.started_at).toLocaleString() : "-", - "Completed At": action?.completed_at ? new Date(action.completed_at).toLocaleString() : "-", - "Droplet ID": action?.resource_id?.toString() || "-", - "Droplet Name": droplet?.name || "-", - "Action Type": action?.type || "-", - Size: droplet?.size_slug || "-", - OS: droplet?.image?.name || droplet?.image?.slug || "-", - Region: droplet?.region?.name || action?.region_slug || "-", - }; - }, - - getTriggerProps: (context: TriggerRendererContext) => { - const { node, definition, lastEvent } = context; - const configuration = node.configuration as any; - const metadataItems = []; - - if (configuration?.events) { - metadataItems.push({ - icon: "funnel", - label: `Events: ${configuration.events.join(", ")}`, - }); - } - - const props: TriggerProps = { - title: node.name || definition.label || "Unnamed trigger", - iconSrc: doIcon, - collapsedBackground: getBackgroundColorClass(definition.color), - metadata: metadataItems, - }; - - if (lastEvent) { - const eventData = lastEvent.data as OnDropletEventData; - const action = eventData?.action; - - props.lastEventData = { - title: `${action?.type || ""} - Droplet ${action?.resource_id || ""}`, - subtitle: formatTimeAgo(new Date(lastEvent.createdAt)), - receivedAt: new Date(lastEvent.createdAt), - state: "triggered", - eventId: lastEvent.id, - }; - } - - return props; - }, -}; From ccfc7c3945290f7ce0f0368914f71e9079b4879a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0ar=C4=8Devi=C4=87?= Date: Thu, 19 Feb 2026 22:05:59 +0100 Subject: [PATCH 12/12] Update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Igor Šarčević --- docs/components/DigitalOcean.mdx | 61 ------------------- .../digitalocean/create_droplet.go | 4 -- 2 files changed, 65 deletions(-) diff --git a/docs/components/DigitalOcean.mdx b/docs/components/DigitalOcean.mdx index e957a687f7..7edd2b953b 100644 --- a/docs/components/DigitalOcean.mdx +++ b/docs/components/DigitalOcean.mdx @@ -4,12 +4,6 @@ title: "DigitalOcean" Manage and monitor your DigitalOcean infrastructure -## Triggers - - - - - import { CardGrid, LinkCard } from "@astrojs/starlight/components"; ## Actions @@ -33,61 +27,6 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; > **Note**: The token is only shown once. Store it securely if needed elsewhere. - - -## On Droplet Event - -The On Droplet Event trigger polls the DigitalOcean API for droplet lifecycle events. - -### Use Cases - -- **Infrastructure monitoring**: React to droplet creation and destruction events -- **Audit logging**: Track all droplet lifecycle changes -- **Automation**: Trigger workflows when droplets are powered on/off or resized - -### Configuration - -- **Events**: Select which droplet event types to listen for (create, destroy, power_on, power_off, shutdown, reboot, snapshot, rebuild, resize, rename) - -### Polling - -This trigger polls the DigitalOcean Actions API every 60 seconds for new completed droplet events matching the configured event types. - -### Event Data - -Each event includes: -- **action**: The DigitalOcean action object with id, status, type, timestamps, resource_id, and region_slug - -### Example Data - -```json -{ - "action": { - "completed_at": "2026-01-19T12:01:30Z", - "id": 123456789, - "region_slug": "nyc3", - "resource_id": 98765432, - "resource_type": "droplet", - "started_at": "2026-01-19T12:00:00Z", - "status": "completed", - "type": "create" - }, - "droplet": { - "id": 98765432, - "image": { - "name": "Ubuntu 24.04 (LTS) x64", - "slug": "ubuntu-24-04-x64" - }, - "name": "my-droplet", - "region": { - "name": "New York 3", - "slug": "nyc3" - }, - "size_slug": "s-1vcpu-1gb" - } -} -``` - ## Create Droplet diff --git a/pkg/integrations/digitalocean/create_droplet.go b/pkg/integrations/digitalocean/create_droplet.go index a0461344c9..b1927523d5 100644 --- a/pkg/integrations/digitalocean/create_droplet.go +++ b/pkg/integrations/digitalocean/create_droplet.go @@ -184,10 +184,6 @@ func (c *CreateDroplet) Setup(ctx core.SetupContext) error { return errors.New("name is required") } - // Hostname validation is deferred to Execute because the name field - // supports expressions (e.g. {{ $.trigger.data.hostname }}) that are - // only resolved at execution time. - if spec.Region == "" { return errors.New("region is required") }