diff --git a/docs/components/AWS.mdx b/docs/components/AWS.mdx index d79672bd00..c6d6885e43 100644 --- a/docs/components/AWS.mdx +++ b/docs/components/AWS.mdx @@ -73,6 +73,9 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + + + @@ -3021,6 +3024,98 @@ The Upsert DNS Record component creates or updates a DNS record in an AWS Route } ``` + + +## S3 • Create Bucket + +**Component key:** `aws.s3.createBucket` + +The Create Bucket component creates a new AWS S3 bucket. + +### Configuration + +- **Region**: AWS region where the bucket will be created +- **Bucket Name**: Globally unique name for the new bucket (3-63 characters, lowercase) + +### Output + +Emits the created bucket's name, region, and ARN on the default channel. + +### Example Output + +```json +{ + "data": { + "arn": "arn:aws:s3:::my-example-bucket", + "bucketName": "my-example-bucket", + "region": "us-east-1" + }, + "timestamp": "2026-02-11T12:00:00Z", + "type": "aws.s3.bucket" +} +``` + + + +## S3 • Delete Bucket + +**Component key:** `aws.s3.deleteBucket` + +The Delete Bucket component deletes an AWS S3 bucket. The bucket must be empty. + +### Configuration + +- **Region**: AWS region of the S3 bucket +- **Bucket**: Target S3 bucket to delete + +### Output + +Emits the deleted bucket's name and a deletion confirmation on the default channel. + +### Example Output + +```json +{ + "data": { + "bucketName": "my-example-bucket", + "deleted": true + }, + "timestamp": "2026-02-11T12:00:00Z", + "type": "aws.s3.bucket.deleted" +} +``` + + + +## S3 • Get Bucket + +**Component key:** `aws.s3.getBucket` + +The Get Bucket component retrieves metadata for an AWS S3 bucket. + +### Configuration + +- **Region**: AWS region of the S3 bucket +- **Bucket**: Target S3 bucket + +### Output + +Emits the bucket's name, resolved region, and ARN on the default channel. + +### Example Output + +```json +{ + "data": { + "arn": "arn:aws:s3:::my-example-bucket", + "bucketName": "my-example-bucket", + "region": "eu-west-1" + }, + "timestamp": "2026-02-11T12:00:00Z", + "type": "aws.s3.bucket" +} +``` + ## SNS • Create Topic diff --git a/pkg/integrations/aws/aws.go b/pkg/integrations/aws/aws.go index a5d563743a..b399be7c35 100644 --- a/pkg/integrations/aws/aws.go +++ b/pkg/integrations/aws/aws.go @@ -28,6 +28,7 @@ import ( "github.com/superplanehq/superplane/pkg/integrations/aws/lambda" "github.com/superplanehq/superplane/pkg/integrations/aws/prometheus" "github.com/superplanehq/superplane/pkg/integrations/aws/route53" + "github.com/superplanehq/superplane/pkg/integrations/aws/s3" "github.com/superplanehq/superplane/pkg/integrations/aws/sns" "github.com/superplanehq/superplane/pkg/integrations/aws/sqs" "github.com/superplanehq/superplane/pkg/registry" @@ -198,6 +199,9 @@ func (a *AWS) Actions() []core.Action { &route53.CreateRecord{}, &route53.UpsertRecord{}, &route53.DeleteRecord{}, + &s3.CreateBucket{}, + &s3.GetBucket{}, + &s3.DeleteBucket{}, } } diff --git a/pkg/integrations/aws/resources.go b/pkg/integrations/aws/resources.go index 572c9a1037..e08d21f23c 100644 --- a/pkg/integrations/aws/resources.go +++ b/pkg/integrations/aws/resources.go @@ -10,6 +10,7 @@ import ( "github.com/superplanehq/superplane/pkg/integrations/aws/lambda" "github.com/superplanehq/superplane/pkg/integrations/aws/prometheus" "github.com/superplanehq/superplane/pkg/integrations/aws/route53" + "github.com/superplanehq/superplane/pkg/integrations/aws/s3" "github.com/superplanehq/superplane/pkg/integrations/aws/sns" "github.com/superplanehq/superplane/pkg/integrations/aws/sqs" ) @@ -103,6 +104,9 @@ func (a *AWS) ListResources(resourceType string, ctx core.ListResourcesContext) case "sqs.queue": return sqs.ListQueues(ctx, resourceType) + case "s3.bucket": + return s3.ListBuckets(ctx, resourceType) + case "prometheus.workspace": return prometheus.ListWorkspaces(ctx, resourceType) diff --git a/pkg/integrations/aws/s3/client.go b/pkg/integrations/aws/s3/client.go new file mode 100644 index 0000000000..258af659c1 --- /dev/null +++ b/pkg/integrations/aws/s3/client.go @@ -0,0 +1,185 @@ +package s3 + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/superplanehq/superplane/pkg/core" +) + +// emptyPayloadHash is the SHA-256 of an empty body, required when signing +// S3 requests that carry no payload (GET/DELETE/HEAD). +const emptyPayloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + +// defaultRegion is the region whose buckets report an empty LocationConstraint. +const defaultRegion = "us-east-1" + +type Client struct { + http core.HTTPContext + region string + credentials *aws.Credentials + signer *v4.Signer +} + +func NewClient(httpCtx core.HTTPContext, credentials *aws.Credentials, region string) *Client { + return &Client{ + http: httpCtx, + region: strings.TrimSpace(region), + credentials: credentials, + signer: v4.NewSigner(), + } +} + +type Bucket struct { + Name string + CreationDate string +} + +type listAllMyBucketsResult struct { + XMLName xml.Name `xml:"ListAllMyBucketsResult"` + Buckets []bucketElement `xml:"Buckets>Bucket"` +} + +type bucketElement struct { + Name string `xml:"Name"` + CreationDate string `xml:"CreationDate"` +} + +type locationConstraint struct { + XMLName xml.Name `xml:"LocationConstraint"` + Value string `xml:",chardata"` +} + +// bucketARN returns the canonical S3 bucket ARN. S3 bucket ARNs omit region +// and account ID. +func bucketARN(name string) string { + return fmt.Sprintf("arn:aws:s3:::%s", strings.TrimSpace(name)) +} + +// endpoint returns the regional path-style S3 endpoint. +func (c *Client) endpoint() string { + return fmt.Sprintf("https://s3.%s.amazonaws.com", c.region) +} + +func (c *Client) bucketURL(bucket string) string { + return fmt.Sprintf("%s/%s", c.endpoint(), url.PathEscape(strings.TrimSpace(bucket))) +} + +// CreateBucket creates a new bucket in the client's region. Regions other than +// us-east-1 require a CreateBucketConfiguration body with the LocationConstraint. +func (c *Client) CreateBucket(name string) error { + var body []byte + if c.region != "" && c.region != defaultRegion { + body = []byte(fmt.Sprintf( + `%s`, + c.region, + )) + } + + _, err := c.do(http.MethodPut, c.bucketURL(name), body) + return err +} + +// GetBucketLocation returns the region a bucket resides in. An empty +// LocationConstraint maps to us-east-1. +func (c *Client) GetBucketLocation(name string) (string, error) { + body, err := c.do(http.MethodGet, c.bucketURL(name)+"?location=", nil) + if err != nil { + return "", err + } + + var location locationConstraint + if err := xml.Unmarshal(body, &location); err != nil { + return "", fmt.Errorf("failed to decode GetBucketLocation response: %w", err) + } + + region := strings.TrimSpace(location.Value) + if region == "" { + return defaultRegion, nil + } + + return region, nil +} + +// DeleteBucket deletes an (empty) bucket. +func (c *Client) DeleteBucket(name string) error { + _, err := c.do(http.MethodDelete, c.bucketURL(name), nil) + return err +} + +// ListBuckets returns every bucket owned by the authenticated account. +func (c *Client) ListBuckets() ([]Bucket, error) { + body, err := c.do(http.MethodGet, c.endpoint()+"/", nil) + if err != nil { + return nil, err + } + + var result listAllMyBucketsResult + if err := xml.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to decode ListBuckets response: %w", err) + } + + buckets := make([]Bucket, 0, len(result.Buckets)) + for _, b := range result.Buckets { + buckets = append(buckets, Bucket{ + Name: b.Name, + CreationDate: b.CreationDate, + }) + } + + return buckets, nil +} + +func (c *Client) do(method, requestURL string, body []byte) ([]byte, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + + req, err := http.NewRequest(method, requestURL, reader) + if err != nil { + return nil, fmt.Errorf("failed to build S3 request: %w", err) + } + + if err := c.signRequest(req, body); err != nil { + return nil, err + } + + res, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("S3 request failed: %w", err) + } + defer res.Body.Close() + + responseBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read S3 response: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("S3 API request failed with %d: %s", res.StatusCode, string(responseBody)) + } + + return responseBody, nil +} + +func (c *Client) signRequest(req *http.Request, payload []byte) error { + payloadHash := emptyPayloadHash + if payload != nil { + hash := sha256.Sum256(payload) + payloadHash = hex.EncodeToString(hash[:]) + } + + return c.signer.SignHTTP(context.Background(), *c.credentials, req, payloadHash, "s3", c.region, time.Now()) +} diff --git a/pkg/integrations/aws/s3/create_bucket.go b/pkg/integrations/aws/s3/create_bucket.go new file mode 100644 index 0000000000..37502274b2 --- /dev/null +++ b/pkg/integrations/aws/s3/create_bucket.go @@ -0,0 +1,171 @@ +package s3 + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/integrations/aws/common" +) + +type CreateBucket struct{} + +type CreateBucketConfiguration struct { + Region string `json:"region" mapstructure:"region"` + BucketName string `json:"bucketName" mapstructure:"bucketName"` +} + +func (c *CreateBucket) Name() string { + return "aws.s3.createBucket" +} + +func (c *CreateBucket) Label() string { + return "S3 • Create Bucket" +} + +func (c *CreateBucket) Description() string { + return "Create a new S3 bucket" +} + +func (c *CreateBucket) Documentation() string { + return `The Create Bucket component creates a new AWS S3 bucket. + +## Configuration + +- **Region**: AWS region where the bucket will be created +- **Bucket Name**: Globally unique name for the new bucket (3-63 characters, lowercase) + +## Output + +Emits the created bucket's name, region, and ARN on the default channel.` +} + +func (c *CreateBucket) Icon() string { + return "aws" +} + +func (c *CreateBucket) Color() string { + return "gray" +} + +func (c *CreateBucket) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *CreateBucket) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "region", + Label: "Region", + Type: configuration.FieldTypeSelect, + Required: true, + Default: "us-east-1", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: common.AllRegions, + }, + }, + }, + { + Name: "bucketName", + Label: "Bucket Name", + Type: configuration.FieldTypeString, + Required: true, + Description: "Globally unique name for the new bucket", + VisibilityConditions: []configuration.VisibilityCondition{ + { + Field: "region", + Values: []string{"*"}, + }, + }, + }, + } +} + +func (c *CreateBucket) Setup(ctx core.SetupContext) error { + var config CreateBucketConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + config.Region = strings.TrimSpace(config.Region) + config.BucketName = strings.TrimSpace(config.BucketName) + + if config.Region == "" { + return fmt.Errorf("region is required") + } + + if config.BucketName == "" { + return fmt.Errorf("bucket name is required") + } + + return nil +} + +func (c *CreateBucket) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *CreateBucket) Execute(ctx core.ExecutionContext) error { + var config CreateBucketConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + config.Region = strings.TrimSpace(config.Region) + config.BucketName = strings.TrimSpace(config.BucketName) + + if config.Region == "" { + return fmt.Errorf("region is required") + } + + if config.BucketName == "" { + return fmt.Errorf("bucket name is required") + } + + creds, err := common.CredentialsFromInstallation(ctx.Integration) + if err != nil { + return fmt.Errorf("failed to get AWS credentials: %w", err) + } + + client := NewClient(ctx.HTTP, creds, config.Region) + if err := client.CreateBucket(config.BucketName); err != nil { + return fmt.Errorf("failed to create S3 bucket: %w", err) + } + + output := map[string]any{ + "bucketName": config.BucketName, + "region": config.Region, + "arn": bucketARN(config.BucketName), + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "aws.s3.bucket", + []any{output}, + ) +} + +func (c *CreateBucket) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *CreateBucket) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *CreateBucket) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *CreateBucket) Hooks() []core.Hook { + return []core.Hook{} +} + +func (c *CreateBucket) HandleHook(ctx core.ActionHookContext) error { + return nil +} diff --git a/pkg/integrations/aws/s3/create_bucket_test.go b/pkg/integrations/aws/s3/create_bucket_test.go new file mode 100644 index 0000000000..bf56609b4f --- /dev/null +++ b/pkg/integrations/aws/s3/create_bucket_test.go @@ -0,0 +1,139 @@ +package s3 + +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 awsSecrets() map[string]core.IntegrationSecret { + return map[string]core.IntegrationSecret{ + "accessKeyId": {Name: "accessKeyId", Value: []byte("key")}, + "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")}, + "sessionToken": {Name: "sessionToken", Value: []byte("token")}, + } +} + +func Test__CreateBucket__Setup(t *testing.T) { + component := &CreateBucket{} + + t.Run("invalid configuration -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{Configuration: "invalid"}) + require.ErrorContains(t, err, "failed to decode configuration") + }) + + t.Run("missing region -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"region": " ", "bucketName": "my-bucket"}, + }) + require.ErrorContains(t, err, "region is required") + }) + + t.Run("missing bucket name -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"region": "us-east-1", "bucketName": " "}, + }) + require.ErrorContains(t, err, "bucket name is required") + }) + + t.Run("valid configuration -> ok", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"region": "us-east-1", "bucketName": "my-bucket"}, + }) + require.NoError(t, err) + }) +} + +func Test__CreateBucket__Execute(t *testing.T) { + component := &CreateBucket{} + + t.Run("invalid configuration -> error", func(t *testing.T) { + err := component.Execute(core.ExecutionContext{ + Configuration: "invalid", + ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}}, + }) + require.ErrorContains(t, err, "failed to decode configuration") + }) + + t.Run("missing credentials -> error", func(t *testing.T) { + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"region": "us-east-1", "bucketName": "my-bucket"}, + Integration: &contexts.IntegrationContext{}, + ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}}, + }) + require.ErrorContains(t, err, "AWS session credentials are missing") + }) + + t.Run("us-east-1 -> creates bucket without location constraint", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{{StatusCode: http.StatusOK, Body: http.NoBody}}, + } + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"region": "us-east-1", "bucketName": "my-bucket"}, + HTTP: httpContext, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{CurrentSecrets: awsSecrets()}, + }) + + require.NoError(t, err) + assert.True(t, execState.Passed) + assert.Equal(t, "aws.s3.bucket", execState.Type) + + require.Len(t, execState.Payloads, 1) + data := execState.Payloads[0].(map[string]any)["data"].(map[string]any) + assert.Equal(t, "my-bucket", data["bucketName"]) + assert.Equal(t, "us-east-1", data["region"]) + assert.Equal(t, "arn:aws:s3:::my-bucket", data["arn"]) + + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, http.MethodPut, httpContext.Requests[0].Method) + assert.Equal(t, "https://s3.us-east-1.amazonaws.com/my-bucket", httpContext.Requests[0].URL.String()) + }) + + t.Run("non us-east-1 -> sends location constraint", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{{StatusCode: http.StatusOK, Body: http.NoBody}}, + } + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"region": "eu-west-1", "bucketName": "my-bucket"}, + HTTP: httpContext, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{CurrentSecrets: awsSecrets()}, + }) + + require.NoError(t, err) + require.Len(t, httpContext.Requests, 1) + bodyBytes, _ := io.ReadAll(httpContext.Requests[0].Body) + assert.Contains(t, string(bodyBytes), "eu-west-1") + }) + + t.Run("API error -> error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{{ + StatusCode: http.StatusConflict, + Body: io.NopCloser(strings.NewReader(`BucketAlreadyExists`)), + }}, + } + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"region": "us-east-1", "bucketName": "my-bucket"}, + HTTP: httpContext, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{CurrentSecrets: awsSecrets()}, + }) + + require.ErrorContains(t, err, "failed to create S3 bucket") + assert.False(t, execState.Passed) + }) +} diff --git a/pkg/integrations/aws/s3/delete_bucket.go b/pkg/integrations/aws/s3/delete_bucket.go new file mode 100644 index 0000000000..35b3958e12 --- /dev/null +++ b/pkg/integrations/aws/s3/delete_bucket.go @@ -0,0 +1,183 @@ +package s3 + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/integrations/aws/common" +) + +type DeleteBucket struct{} + +type DeleteBucketConfiguration struct { + Region string `json:"region" mapstructure:"region"` + Bucket string `json:"bucket" mapstructure:"bucket"` +} + +func (c *DeleteBucket) Name() string { + return "aws.s3.deleteBucket" +} + +func (c *DeleteBucket) Label() string { + return "S3 • Delete Bucket" +} + +func (c *DeleteBucket) Description() string { + return "Delete an S3 bucket" +} + +func (c *DeleteBucket) Documentation() string { + return `The Delete Bucket component deletes an AWS S3 bucket. The bucket must be empty. + +## Configuration + +- **Region**: AWS region of the S3 bucket +- **Bucket**: Target S3 bucket to delete + +## Output + +Emits the deleted bucket's name and a deletion confirmation on the default channel.` +} + +func (c *DeleteBucket) Icon() string { + return "aws" +} + +func (c *DeleteBucket) Color() string { + return "gray" +} + +func (c *DeleteBucket) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *DeleteBucket) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "region", + Label: "Region", + Type: configuration.FieldTypeSelect, + Required: true, + Default: "us-east-1", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: common.AllRegions, + }, + }, + }, + { + Name: "bucket", + Label: "Bucket", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Target S3 bucket to delete", + VisibilityConditions: []configuration.VisibilityCondition{ + { + Field: "region", + Values: []string{"*"}, + }, + }, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "s3.bucket", + Parameters: []configuration.ParameterRef{ + { + Name: "region", + ValueFrom: &configuration.ParameterValueFrom{ + Field: "region", + }, + }, + }, + }, + }, + }, + } +} + +func (c *DeleteBucket) Setup(ctx core.SetupContext) error { + var config DeleteBucketConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + config.Region = strings.TrimSpace(config.Region) + config.Bucket = strings.TrimSpace(config.Bucket) + + if config.Region == "" { + return fmt.Errorf("region is required") + } + + if config.Bucket == "" { + return fmt.Errorf("bucket is required") + } + + return nil +} + +func (c *DeleteBucket) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *DeleteBucket) Execute(ctx core.ExecutionContext) error { + var config DeleteBucketConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + config.Region = strings.TrimSpace(config.Region) + config.Bucket = strings.TrimSpace(config.Bucket) + + if config.Region == "" { + return fmt.Errorf("region is required") + } + + if config.Bucket == "" { + return fmt.Errorf("bucket is required") + } + + creds, err := common.CredentialsFromInstallation(ctx.Integration) + if err != nil { + return fmt.Errorf("failed to get AWS credentials: %w", err) + } + + client := NewClient(ctx.HTTP, creds, config.Region) + if err := client.DeleteBucket(config.Bucket); err != nil { + return fmt.Errorf("failed to delete S3 bucket: %w", err) + } + + output := map[string]any{ + "bucketName": config.Bucket, + "deleted": true, + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "aws.s3.bucket.deleted", + []any{output}, + ) +} + +func (c *DeleteBucket) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *DeleteBucket) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *DeleteBucket) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *DeleteBucket) Hooks() []core.Hook { + return []core.Hook{} +} + +func (c *DeleteBucket) HandleHook(ctx core.ActionHookContext) error { + return nil +} diff --git a/pkg/integrations/aws/s3/delete_bucket_test.go b/pkg/integrations/aws/s3/delete_bucket_test.go new file mode 100644 index 0000000000..b8e673cf8b --- /dev/null +++ b/pkg/integrations/aws/s3/delete_bucket_test.go @@ -0,0 +1,103 @@ +package s3 + +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__DeleteBucket__Setup(t *testing.T) { + component := &DeleteBucket{} + + t.Run("invalid configuration -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{Configuration: "invalid"}) + require.ErrorContains(t, err, "failed to decode configuration") + }) + + t.Run("missing region -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"region": " ", "bucket": "my-bucket"}, + }) + require.ErrorContains(t, err, "region is required") + }) + + t.Run("missing bucket -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"region": "us-east-1", "bucket": " "}, + }) + require.ErrorContains(t, err, "bucket is required") + }) + + t.Run("valid configuration -> ok", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"region": "us-east-1", "bucket": "my-bucket"}, + }) + require.NoError(t, err) + }) +} + +func Test__DeleteBucket__Execute(t *testing.T) { + component := &DeleteBucket{} + + t.Run("missing credentials -> error", func(t *testing.T) { + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"region": "us-east-1", "bucket": "my-bucket"}, + Integration: &contexts.IntegrationContext{}, + ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}}, + }) + require.ErrorContains(t, err, "AWS session credentials are missing") + }) + + t.Run("valid request -> emits deletion confirmation", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{{StatusCode: http.StatusNoContent, Body: http.NoBody}}, + } + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"region": "us-east-1", "bucket": "my-bucket"}, + HTTP: httpContext, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{CurrentSecrets: awsSecrets()}, + }) + + require.NoError(t, err) + assert.True(t, execState.Passed) + assert.Equal(t, "aws.s3.bucket.deleted", execState.Type) + + require.Len(t, execState.Payloads, 1) + data := execState.Payloads[0].(map[string]any)["data"].(map[string]any) + assert.Equal(t, "my-bucket", data["bucketName"]) + assert.Equal(t, true, data["deleted"]) + + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, http.MethodDelete, httpContext.Requests[0].Method) + assert.Equal(t, "https://s3.us-east-1.amazonaws.com/my-bucket", httpContext.Requests[0].URL.String()) + }) + + t.Run("API error -> error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{{ + StatusCode: http.StatusConflict, + Body: io.NopCloser(strings.NewReader(`BucketNotEmpty`)), + }}, + } + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"region": "us-east-1", "bucket": "my-bucket"}, + HTTP: httpContext, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{CurrentSecrets: awsSecrets()}, + }) + + require.ErrorContains(t, err, "failed to delete S3 bucket") + assert.False(t, execState.Passed) + }) +} diff --git a/pkg/integrations/aws/s3/example.go b/pkg/integrations/aws/s3/example.go new file mode 100644 index 0000000000..d8511b33b6 --- /dev/null +++ b/pkg/integrations/aws/s3/example.go @@ -0,0 +1,38 @@ +package s3 + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_output_create_bucket.json +var exampleOutputCreateBucketBytes []byte + +var exampleOutputCreateBucketOnce sync.Once +var exampleOutputCreateBucket map[string]any + +//go:embed example_output_get_bucket.json +var exampleOutputGetBucketBytes []byte + +var exampleOutputGetBucketOnce sync.Once +var exampleOutputGetBucket map[string]any + +//go:embed example_output_delete_bucket.json +var exampleOutputDeleteBucketBytes []byte + +var exampleOutputDeleteBucketOnce sync.Once +var exampleOutputDeleteBucket map[string]any + +func (c *CreateBucket) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateBucketOnce, exampleOutputCreateBucketBytes, &exampleOutputCreateBucket) +} + +func (c *GetBucket) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputGetBucketOnce, exampleOutputGetBucketBytes, &exampleOutputGetBucket) +} + +func (c *DeleteBucket) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputDeleteBucketOnce, exampleOutputDeleteBucketBytes, &exampleOutputDeleteBucket) +} diff --git a/pkg/integrations/aws/s3/example_output_create_bucket.json b/pkg/integrations/aws/s3/example_output_create_bucket.json new file mode 100644 index 0000000000..aa59387d88 --- /dev/null +++ b/pkg/integrations/aws/s3/example_output_create_bucket.json @@ -0,0 +1,9 @@ +{ + "type": "aws.s3.bucket", + "timestamp": "2026-02-11T12:00:00Z", + "data": { + "bucketName": "my-example-bucket", + "region": "us-east-1", + "arn": "arn:aws:s3:::my-example-bucket" + } +} diff --git a/pkg/integrations/aws/s3/example_output_delete_bucket.json b/pkg/integrations/aws/s3/example_output_delete_bucket.json new file mode 100644 index 0000000000..82d93b3266 --- /dev/null +++ b/pkg/integrations/aws/s3/example_output_delete_bucket.json @@ -0,0 +1,8 @@ +{ + "type": "aws.s3.bucket.deleted", + "timestamp": "2026-02-11T12:00:00Z", + "data": { + "bucketName": "my-example-bucket", + "deleted": true + } +} diff --git a/pkg/integrations/aws/s3/example_output_get_bucket.json b/pkg/integrations/aws/s3/example_output_get_bucket.json new file mode 100644 index 0000000000..45962eb333 --- /dev/null +++ b/pkg/integrations/aws/s3/example_output_get_bucket.json @@ -0,0 +1,9 @@ +{ + "type": "aws.s3.bucket", + "timestamp": "2026-02-11T12:00:00Z", + "data": { + "bucketName": "my-example-bucket", + "region": "eu-west-1", + "arn": "arn:aws:s3:::my-example-bucket" + } +} diff --git a/pkg/integrations/aws/s3/get_bucket.go b/pkg/integrations/aws/s3/get_bucket.go new file mode 100644 index 0000000000..774771bb9b --- /dev/null +++ b/pkg/integrations/aws/s3/get_bucket.go @@ -0,0 +1,185 @@ +package s3 + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/integrations/aws/common" +) + +type GetBucket struct{} + +type GetBucketConfiguration struct { + Region string `json:"region" mapstructure:"region"` + Bucket string `json:"bucket" mapstructure:"bucket"` +} + +func (c *GetBucket) Name() string { + return "aws.s3.getBucket" +} + +func (c *GetBucket) Label() string { + return "S3 • Get Bucket" +} + +func (c *GetBucket) Description() string { + return "Get metadata for an S3 bucket" +} + +func (c *GetBucket) Documentation() string { + return `The Get Bucket component retrieves metadata for an AWS S3 bucket. + +## Configuration + +- **Region**: AWS region of the S3 bucket +- **Bucket**: Target S3 bucket + +## Output + +Emits the bucket's name, resolved region, and ARN on the default channel.` +} + +func (c *GetBucket) Icon() string { + return "aws" +} + +func (c *GetBucket) Color() string { + return "gray" +} + +func (c *GetBucket) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *GetBucket) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "region", + Label: "Region", + Type: configuration.FieldTypeSelect, + Required: true, + Default: "us-east-1", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: common.AllRegions, + }, + }, + }, + { + Name: "bucket", + Label: "Bucket", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Target S3 bucket", + VisibilityConditions: []configuration.VisibilityCondition{ + { + Field: "region", + Values: []string{"*"}, + }, + }, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "s3.bucket", + Parameters: []configuration.ParameterRef{ + { + Name: "region", + ValueFrom: &configuration.ParameterValueFrom{ + Field: "region", + }, + }, + }, + }, + }, + }, + } +} + +func (c *GetBucket) Setup(ctx core.SetupContext) error { + var config GetBucketConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + config.Region = strings.TrimSpace(config.Region) + config.Bucket = strings.TrimSpace(config.Bucket) + + if config.Region == "" { + return fmt.Errorf("region is required") + } + + if config.Bucket == "" { + return fmt.Errorf("bucket is required") + } + + return nil +} + +func (c *GetBucket) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *GetBucket) Execute(ctx core.ExecutionContext) error { + var config GetBucketConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + config.Region = strings.TrimSpace(config.Region) + config.Bucket = strings.TrimSpace(config.Bucket) + + if config.Region == "" { + return fmt.Errorf("region is required") + } + + if config.Bucket == "" { + return fmt.Errorf("bucket is required") + } + + creds, err := common.CredentialsFromInstallation(ctx.Integration) + if err != nil { + return fmt.Errorf("failed to get AWS credentials: %w", err) + } + + client := NewClient(ctx.HTTP, creds, config.Region) + location, err := client.GetBucketLocation(config.Bucket) + if err != nil { + return fmt.Errorf("failed to get S3 bucket: %w", err) + } + + output := map[string]any{ + "bucketName": config.Bucket, + "region": location, + "arn": bucketARN(config.Bucket), + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "aws.s3.bucket", + []any{output}, + ) +} + +func (c *GetBucket) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *GetBucket) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *GetBucket) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *GetBucket) Hooks() []core.Hook { + return []core.Hook{} +} + +func (c *GetBucket) HandleHook(ctx core.ActionHookContext) error { + return nil +} diff --git a/pkg/integrations/aws/s3/get_bucket_test.go b/pkg/integrations/aws/s3/get_bucket_test.go new file mode 100644 index 0000000000..bd798c0d31 --- /dev/null +++ b/pkg/integrations/aws/s3/get_bucket_test.go @@ -0,0 +1,132 @@ +package s3 + +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__GetBucket__Setup(t *testing.T) { + component := &GetBucket{} + + t.Run("invalid configuration -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{Configuration: "invalid"}) + require.ErrorContains(t, err, "failed to decode configuration") + }) + + t.Run("missing region -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"region": " ", "bucket": "my-bucket"}, + }) + require.ErrorContains(t, err, "region is required") + }) + + t.Run("missing bucket -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"region": "us-east-1", "bucket": " "}, + }) + require.ErrorContains(t, err, "bucket is required") + }) + + t.Run("valid configuration -> ok", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"region": "us-east-1", "bucket": "my-bucket"}, + }) + require.NoError(t, err) + }) +} + +func Test__GetBucket__Execute(t *testing.T) { + component := &GetBucket{} + + t.Run("missing credentials -> error", func(t *testing.T) { + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"region": "us-east-1", "bucket": "my-bucket"}, + Integration: &contexts.IntegrationContext{}, + ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}}, + }) + require.ErrorContains(t, err, "AWS session credentials are missing") + }) + + t.Run("valid request -> emits resolved region", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + `eu-west-1`, + )), + }}, + } + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"region": "eu-west-1", "bucket": "my-bucket"}, + HTTP: httpContext, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{CurrentSecrets: awsSecrets()}, + }) + + require.NoError(t, err) + assert.True(t, execState.Passed) + assert.Equal(t, "aws.s3.bucket", execState.Type) + + require.Len(t, execState.Payloads, 1) + data := execState.Payloads[0].(map[string]any)["data"].(map[string]any) + assert.Equal(t, "my-bucket", data["bucketName"]) + assert.Equal(t, "eu-west-1", data["region"]) + assert.Equal(t, "arn:aws:s3:::my-bucket", data["arn"]) + + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, http.MethodGet, httpContext.Requests[0].Method) + assert.Contains(t, httpContext.Requests[0].URL.String(), "location") + }) + + t.Run("empty location constraint -> defaults to us-east-1", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + ``, + )), + }}, + } + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"region": "us-east-1", "bucket": "my-bucket"}, + HTTP: httpContext, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{CurrentSecrets: awsSecrets()}, + }) + + require.NoError(t, err) + data := execState.Payloads[0].(map[string]any)["data"].(map[string]any) + assert.Equal(t, "us-east-1", data["region"]) + }) + + t.Run("API error -> error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`NoSuchBucket`)), + }}, + } + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"region": "us-east-1", "bucket": "my-bucket"}, + HTTP: httpContext, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{CurrentSecrets: awsSecrets()}, + }) + + require.ErrorContains(t, err, "failed to get S3 bucket") + assert.False(t, execState.Passed) + }) +} diff --git a/pkg/integrations/aws/s3/resources.go b/pkg/integrations/aws/s3/resources.go new file mode 100644 index 0000000000..205a171572 --- /dev/null +++ b/pkg/integrations/aws/s3/resources.go @@ -0,0 +1,37 @@ +package s3 + +import ( + "fmt" + + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/integrations/aws/common" +) + +func ListBuckets(ctx core.ListResourcesContext, resourceType string) ([]core.IntegrationResource, error) { + credentials, err := common.CredentialsFromInstallation(ctx.Integration) + if err != nil { + return nil, err + } + + region := ctx.Parameters["region"] + if region == "" { + return nil, fmt.Errorf("region is required") + } + + client := NewClient(ctx.HTTP, credentials, region) + buckets, err := client.ListBuckets() + if err != nil { + return nil, fmt.Errorf("failed to list S3 buckets: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(buckets)) + for _, bucket := range buckets { + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: bucket.Name, + ID: bucket.Name, + }) + } + + return resources, nil +} diff --git a/web_src/src/pages/app/mappers/aws/index.ts b/web_src/src/pages/app/mappers/aws/index.ts index cd5448f759..85c56e290a 100644 --- a/web_src/src/pages/app/mappers/aws/index.ts +++ b/web_src/src/pages/app/mappers/aws/index.ts @@ -9,6 +9,7 @@ import { scanImageMapper } from "./ecr/scan_image"; import { onPackageVersionTriggerRenderer } from "./codeartifact/on_package_version"; import { getPackageVersionMapper } from "./codeartifact/get_package_version"; import { createQueueMapper, deleteQueueMapper, getQueueMapper, purgeQueueMapper, sendMessageMapper } from "./sqs"; +import { createBucketMapper, deleteBucketMapper, getBucketMapper } from "./s3"; import { createRepositoryMapper } from "./codeartifact/create_repository"; import { copyPackageVersionsMapper } from "./codeartifact/copy_package_versions"; import { deletePackageVersionsMapper } from "./codeartifact/delete_package_versions"; @@ -100,6 +101,9 @@ export const componentMappers: Record = { "sqs.sendMessage": sendMessageMapper, "sqs.deleteQueue": deleteQueueMapper, "sqs.purgeQueue": purgeQueueMapper, + "s3.createBucket": createBucketMapper, + "s3.getBucket": getBucketMapper, + "s3.deleteBucket": deleteBucketMapper, "codeArtifact.updatePackageVersionsStatus": updatePackageVersionsStatusMapper, "route53.createRecord": createRecordMapper, "route53.upsertRecord": upsertRecordMapper, @@ -174,6 +178,9 @@ export const eventStateRegistry: Record = { "sqs.sendMessage": buildActionStateRegistry("sent"), "sqs.deleteQueue": buildActionStateRegistry("deleted"), "sqs.purgeQueue": buildActionStateRegistry("purged"), + "s3.createBucket": buildActionStateRegistry("created"), + "s3.getBucket": buildActionStateRegistry("retrieved"), + "s3.deleteBucket": buildActionStateRegistry("deleted"), "codeArtifact.updatePackageVersionsStatus": buildActionStateRegistry("updated"), "route53.createRecord": buildActionStateRegistry("created"), "route53.upsertRecord": buildActionStateRegistry("upserted"), diff --git a/web_src/src/pages/app/mappers/aws/s3/create_bucket.spec.ts b/web_src/src/pages/app/mappers/aws/s3/create_bucket.spec.ts new file mode 100644 index 0000000000..5b67d3e58f --- /dev/null +++ b/web_src/src/pages/app/mappers/aws/s3/create_bucket.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import type { + ComponentBaseContext, + ComponentDefinition, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, +} from "../../types"; +import { createBucketMapper } from "./create_bucket"; +import { eventStateRegistry } from "../index"; + +function buildNode(overrides?: Partial): NodeInfo { + return { + id: "node-1", + name: "Create Bucket Node", + componentName: "aws.s3.createBucket", + isCollapsed: false, + configuration: {}, + metadata: {}, + ...overrides, + }; +} + +function buildOutput(data: unknown): OutputPayload { + return { type: "aws.s3.bucket", timestamp: new Date().toISOString(), data }; +} + +function buildExecution(overrides?: Partial): ExecutionInfo { + return { + id: "exec-1", + createdAt: "2026-06-01T10:00:00.000Z", + updatedAt: "2026-06-01T10:00:05.000Z", + state: "STATE_FINISHED", + result: "RESULT_PASSED", + resultReason: "RESULT_REASON_OK", + resultMessage: "", + metadata: {}, + configuration: {}, + rootEvent: undefined, + ...overrides, + }; +} + +function buildDetailsCtx(overrides?: { node?: Partial; execution?: Partial }): ExecutionDetailsContext { + const node = buildNode(overrides?.node); + return { nodes: [node], node, execution: buildExecution(overrides?.execution) }; +} + +const defaultDefinition: ComponentDefinition = { + name: "aws.s3.createBucket", + label: "S3 • Create Bucket", + description: "", + icon: "aws", + color: "gray", +}; + +function buildPropsContext(overrides?: Partial): ComponentBaseContext { + return { + nodes: [], + node: buildNode(), + componentDefinition: defaultDefinition, + lastExecutions: [], + currentUser: undefined, + actions: { invokeNodeExecutionHook: async () => {} }, + ...overrides, + }; +} + +const bucketOutput = { + bucketName: "my-example-bucket", + region: "us-east-1", + arn: "arn:aws:s3:::my-example-bucket", +}; + +describe("createBucketMapper.getExecutionDetails", () => { + it("does not throw when outputs is undefined", () => { + const ctx = buildDetailsCtx({ execution: { outputs: undefined } }); + expect(() => createBucketMapper.getExecutionDetails(ctx)).not.toThrow(); + }); + + it("extracts bucket fields from output", () => { + const ctx = buildDetailsCtx({ execution: { outputs: { default: [buildOutput(bucketOutput)] } } }); + const details = createBucketMapper.getExecutionDetails(ctx); + expect(details["Bucket"]).toBe("my-example-bucket"); + expect(details["Region"]).toBe("us-east-1"); + expect(details["ARN"]).toBe("arn:aws:s3:::my-example-bucket"); + }); +}); + +describe("createBucketMapper.props", () => { + it("uses node name as title", () => { + expect(createBucketMapper.props(buildPropsContext()).title).toBe("Create Bucket Node"); + }); + + it("includes bucket name from configuration in metadata", () => { + const props = createBucketMapper.props( + buildPropsContext({ node: buildNode({ configuration: { bucketName: "my-example-bucket", region: "us-east-1" } }) }), + ); + const labels = props.metadata?.map((m) => m.label) ?? []; + expect(labels).toContain("my-example-bucket"); + }); +}); + +describe("eventStateRegistry['s3.createBucket']", () => { + it("maps finished success to created", () => { + expect(eventStateRegistry["s3.createBucket"].getState(buildExecution())).toBe("created"); + }); +}); diff --git a/web_src/src/pages/app/mappers/aws/s3/create_bucket.ts b/web_src/src/pages/app/mappers/aws/s3/create_bucket.ts new file mode 100644 index 0000000000..5a8d9f317d --- /dev/null +++ b/web_src/src/pages/app/mappers/aws/s3/create_bucket.ts @@ -0,0 +1,101 @@ +import type { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../../types"; +import type { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import type React from "react"; +import { getBackgroundColorClass, getColorClass } from "@/lib/colors"; +import { getState, getStateMap, getTriggerRenderer } from "../.."; +import awsIcon from "@/assets/icons/integrations/aws.svg"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import type { MetadataItem } from "@/ui/metadataList"; +import { stringOrDash } from "../../utils"; + +interface CreateBucketConfiguration { + region?: string; + bucketName?: string; +} + +interface CreateBucketOutput { + bucketName?: string; + region?: string; + arn?: string; +} + +export const createBucketMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + title: + context.node.name || + context.componentDefinition.label || + context.componentDefinition.name || + "Unnamed component", + iconSrc: awsIcon, + iconColor: getColorClass(context.componentDefinition.color), + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + eventSections: lastExecution ? createBucketEventSections(context.nodes, lastExecution, componentName) : undefined, + includeEmptyState: !lastExecution, + metadata: createBucketMetadataList(context.node), + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const result = outputs?.default?.[0]?.data as CreateBucketOutput | undefined; + + if (!result) { + return {}; + } + + return { + Bucket: stringOrDash(result.bucketName), + Region: stringOrDash(result.region), + ARN: stringOrDash(result.arn), + }; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + if (!context.execution.createdAt) { + return ""; + } + return renderTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function createBucketMetadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as CreateBucketConfiguration | undefined; + + const bucketName = configuration?.bucketName?.trim(); + if (bucketName) { + metadata.push({ icon: "database", label: bucketName }); + } + + return metadata; +} + +function createBucketEventSections(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: renderTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} diff --git a/web_src/src/pages/app/mappers/aws/s3/delete_bucket.spec.ts b/web_src/src/pages/app/mappers/aws/s3/delete_bucket.spec.ts new file mode 100644 index 0000000000..d8b4ae3150 --- /dev/null +++ b/web_src/src/pages/app/mappers/aws/s3/delete_bucket.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import type { + ComponentBaseContext, + ComponentDefinition, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, +} from "../../types"; +import { deleteBucketMapper } from "./delete_bucket"; +import { eventStateRegistry } from "../index"; + +function buildNode(overrides?: Partial): NodeInfo { + return { + id: "node-1", + name: "Delete Bucket Node", + componentName: "aws.s3.deleteBucket", + isCollapsed: false, + configuration: {}, + metadata: {}, + ...overrides, + }; +} + +function buildOutput(data: unknown): OutputPayload { + return { type: "aws.s3.bucket.deleted", timestamp: new Date().toISOString(), data }; +} + +function buildExecution(overrides?: Partial): ExecutionInfo { + return { + id: "exec-1", + createdAt: "2026-06-01T10:00:00.000Z", + updatedAt: "2026-06-01T10:00:05.000Z", + state: "STATE_FINISHED", + result: "RESULT_PASSED", + resultReason: "RESULT_REASON_OK", + resultMessage: "", + metadata: {}, + configuration: {}, + rootEvent: undefined, + ...overrides, + }; +} + +function buildDetailsCtx(overrides?: { node?: Partial; execution?: Partial }): ExecutionDetailsContext { + const node = buildNode(overrides?.node); + return { nodes: [node], node, execution: buildExecution(overrides?.execution) }; +} + +const defaultDefinition: ComponentDefinition = { + name: "aws.s3.deleteBucket", + label: "S3 • Delete Bucket", + description: "", + icon: "aws", + color: "gray", +}; + +function buildPropsContext(overrides?: Partial): ComponentBaseContext { + return { + nodes: [], + node: buildNode(), + componentDefinition: defaultDefinition, + lastExecutions: [], + currentUser: undefined, + actions: { invokeNodeExecutionHook: async () => {} }, + ...overrides, + }; +} + +const deletedOutput = { bucketName: "my-example-bucket", deleted: true }; + +describe("deleteBucketMapper.getExecutionDetails", () => { + it("does not throw when outputs is undefined", () => { + const ctx = buildDetailsCtx({ execution: { outputs: undefined } }); + expect(() => deleteBucketMapper.getExecutionDetails(ctx)).not.toThrow(); + }); + + it("extracts deletion fields from output", () => { + const ctx = buildDetailsCtx({ execution: { outputs: { default: [buildOutput(deletedOutput)] } } }); + const details = deleteBucketMapper.getExecutionDetails(ctx); + expect(details["Bucket"]).toBe("my-example-bucket"); + expect(details["Deleted"]).toBe("Yes"); + }); +}); + +describe("deleteBucketMapper.props", () => { + it("uses node name as title", () => { + expect(deleteBucketMapper.props(buildPropsContext()).title).toBe("Delete Bucket Node"); + }); + + it("includes bucket from configuration in metadata", () => { + const props = deleteBucketMapper.props( + buildPropsContext({ node: buildNode({ configuration: { bucket: "my-example-bucket", region: "us-east-1" } }) }), + ); + const labels = props.metadata?.map((m) => m.label) ?? []; + expect(labels).toContain("my-example-bucket"); + }); +}); + +describe("eventStateRegistry['s3.deleteBucket']", () => { + it("maps finished success to deleted", () => { + expect(eventStateRegistry["s3.deleteBucket"].getState(buildExecution())).toBe("deleted"); + }); +}); diff --git a/web_src/src/pages/app/mappers/aws/s3/delete_bucket.ts b/web_src/src/pages/app/mappers/aws/s3/delete_bucket.ts new file mode 100644 index 0000000000..23ea9ef80d --- /dev/null +++ b/web_src/src/pages/app/mappers/aws/s3/delete_bucket.ts @@ -0,0 +1,99 @@ +import type { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../../types"; +import type { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import type React from "react"; +import { getBackgroundColorClass, getColorClass } from "@/lib/colors"; +import { getState, getStateMap, getTriggerRenderer } from "../.."; +import awsIcon from "@/assets/icons/integrations/aws.svg"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import type { MetadataItem } from "@/ui/metadataList"; +import { stringOrDash } from "../../utils"; + +interface DeleteBucketConfiguration { + region?: string; + bucket?: string; +} + +interface DeleteBucketOutput { + bucketName?: string; + deleted?: boolean; +} + +export const deleteBucketMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + title: + context.node.name || + context.componentDefinition.label || + context.componentDefinition.name || + "Unnamed component", + iconSrc: awsIcon, + iconColor: getColorClass(context.componentDefinition.color), + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + eventSections: lastExecution ? deleteBucketEventSections(context.nodes, lastExecution, componentName) : undefined, + includeEmptyState: !lastExecution, + metadata: deleteBucketMetadataList(context.node), + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const result = outputs?.default?.[0]?.data as DeleteBucketOutput | undefined; + + if (!result) { + return {}; + } + + return { + Bucket: stringOrDash(result.bucketName), + Deleted: result.deleted ? "Yes" : "No", + }; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + if (!context.execution.createdAt) { + return ""; + } + return renderTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function deleteBucketMetadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as DeleteBucketConfiguration | undefined; + + const bucket = configuration?.bucket?.trim(); + if (bucket) { + metadata.push({ icon: "database", label: bucket }); + } + + return metadata; +} + +function deleteBucketEventSections(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: renderTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} diff --git a/web_src/src/pages/app/mappers/aws/s3/get_bucket.spec.ts b/web_src/src/pages/app/mappers/aws/s3/get_bucket.spec.ts new file mode 100644 index 0000000000..5d30162890 --- /dev/null +++ b/web_src/src/pages/app/mappers/aws/s3/get_bucket.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import type { + ComponentBaseContext, + ComponentDefinition, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, +} from "../../types"; +import { getBucketMapper } from "./get_bucket"; +import { eventStateRegistry } from "../index"; + +function buildNode(overrides?: Partial): NodeInfo { + return { + id: "node-1", + name: "Get Bucket Node", + componentName: "aws.s3.getBucket", + isCollapsed: false, + configuration: {}, + metadata: {}, + ...overrides, + }; +} + +function buildOutput(data: unknown): OutputPayload { + return { type: "aws.s3.bucket", timestamp: new Date().toISOString(), data }; +} + +function buildExecution(overrides?: Partial): ExecutionInfo { + return { + id: "exec-1", + createdAt: "2026-06-01T10:00:00.000Z", + updatedAt: "2026-06-01T10:00:05.000Z", + state: "STATE_FINISHED", + result: "RESULT_PASSED", + resultReason: "RESULT_REASON_OK", + resultMessage: "", + metadata: {}, + configuration: {}, + rootEvent: undefined, + ...overrides, + }; +} + +function buildDetailsCtx(overrides?: { node?: Partial; execution?: Partial }): ExecutionDetailsContext { + const node = buildNode(overrides?.node); + return { nodes: [node], node, execution: buildExecution(overrides?.execution) }; +} + +const defaultDefinition: ComponentDefinition = { + name: "aws.s3.getBucket", + label: "S3 • Get Bucket", + description: "", + icon: "aws", + color: "gray", +}; + +function buildPropsContext(overrides?: Partial): ComponentBaseContext { + return { + nodes: [], + node: buildNode(), + componentDefinition: defaultDefinition, + lastExecutions: [], + currentUser: undefined, + actions: { invokeNodeExecutionHook: async () => {} }, + ...overrides, + }; +} + +const bucketOutput = { + bucketName: "my-example-bucket", + region: "eu-west-1", + arn: "arn:aws:s3:::my-example-bucket", +}; + +describe("getBucketMapper.getExecutionDetails", () => { + it("does not throw when outputs is undefined", () => { + const ctx = buildDetailsCtx({ execution: { outputs: undefined } }); + expect(() => getBucketMapper.getExecutionDetails(ctx)).not.toThrow(); + }); + + it("extracts bucket fields from output", () => { + const ctx = buildDetailsCtx({ execution: { outputs: { default: [buildOutput(bucketOutput)] } } }); + const details = getBucketMapper.getExecutionDetails(ctx); + expect(details["Bucket"]).toBe("my-example-bucket"); + expect(details["Region"]).toBe("eu-west-1"); + expect(details["ARN"]).toBe("arn:aws:s3:::my-example-bucket"); + }); +}); + +describe("getBucketMapper.props", () => { + it("uses node name as title", () => { + expect(getBucketMapper.props(buildPropsContext()).title).toBe("Get Bucket Node"); + }); + + it("includes bucket from configuration in metadata", () => { + const props = getBucketMapper.props( + buildPropsContext({ node: buildNode({ configuration: { bucket: "my-example-bucket", region: "eu-west-1" } }) }), + ); + const labels = props.metadata?.map((m) => m.label) ?? []; + expect(labels).toContain("my-example-bucket"); + }); +}); + +describe("eventStateRegistry['s3.getBucket']", () => { + it("maps finished success to retrieved", () => { + expect(eventStateRegistry["s3.getBucket"].getState(buildExecution())).toBe("retrieved"); + }); +}); diff --git a/web_src/src/pages/app/mappers/aws/s3/get_bucket.ts b/web_src/src/pages/app/mappers/aws/s3/get_bucket.ts new file mode 100644 index 0000000000..a17e2cb263 --- /dev/null +++ b/web_src/src/pages/app/mappers/aws/s3/get_bucket.ts @@ -0,0 +1,101 @@ +import type { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../../types"; +import type { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import type React from "react"; +import { getBackgroundColorClass, getColorClass } from "@/lib/colors"; +import { getState, getStateMap, getTriggerRenderer } from "../.."; +import awsIcon from "@/assets/icons/integrations/aws.svg"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import type { MetadataItem } from "@/ui/metadataList"; +import { stringOrDash } from "../../utils"; + +interface GetBucketConfiguration { + region?: string; + bucket?: string; +} + +interface GetBucketOutput { + bucketName?: string; + region?: string; + arn?: string; +} + +export const getBucketMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + title: + context.node.name || + context.componentDefinition.label || + context.componentDefinition.name || + "Unnamed component", + iconSrc: awsIcon, + iconColor: getColorClass(context.componentDefinition.color), + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + eventSections: lastExecution ? getBucketEventSections(context.nodes, lastExecution, componentName) : undefined, + includeEmptyState: !lastExecution, + metadata: getBucketMetadataList(context.node), + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const result = outputs?.default?.[0]?.data as GetBucketOutput | undefined; + + if (!result) { + return {}; + } + + return { + Bucket: stringOrDash(result.bucketName), + Region: stringOrDash(result.region), + ARN: stringOrDash(result.arn), + }; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + if (!context.execution.createdAt) { + return ""; + } + return renderTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function getBucketMetadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as GetBucketConfiguration | undefined; + + const bucket = configuration?.bucket?.trim(); + if (bucket) { + metadata.push({ icon: "database", label: bucket }); + } + + return metadata; +} + +function getBucketEventSections(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: renderTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} diff --git a/web_src/src/pages/app/mappers/aws/s3/index.ts b/web_src/src/pages/app/mappers/aws/s3/index.ts new file mode 100644 index 0000000000..88b65dfb1d --- /dev/null +++ b/web_src/src/pages/app/mappers/aws/s3/index.ts @@ -0,0 +1,3 @@ +export { createBucketMapper } from "./create_bucket"; +export { getBucketMapper } from "./get_bucket"; +export { deleteBucketMapper } from "./delete_bucket";