Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions docs/components/AWS.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";
<LinkCard title="Route 53 • Create DNS Record" href="#route-53-•-create-dns-record" description="Create a DNS record in an AWS Route 53 hosted zone" />
<LinkCard title="Route 53 • Delete DNS Record" href="#route-53-•-delete-dns-record" description="Delete a DNS record from an AWS Route 53 hosted zone" />
<LinkCard title="Route 53 • Upsert DNS Record" href="#route-53-•-upsert-dns-record" description="Create or update a DNS record in an AWS Route 53 hosted zone" />
<LinkCard title="S3 • Create Bucket" href="#s3-•-create-bucket" description="Create a new S3 bucket" />
<LinkCard title="S3 • Delete Bucket" href="#s3-•-delete-bucket" description="Delete an S3 bucket" />
<LinkCard title="S3 • Get Bucket" href="#s3-•-get-bucket" description="Get metadata for an S3 bucket" />
<LinkCard title="SNS • Create Topic" href="#sns-•-create-topic" description="Create an AWS SNS topic" />
<LinkCard title="SNS • Delete Topic" href="#sns-•-delete-topic" description="Delete an AWS SNS topic" />
<LinkCard title="SNS • Get Subscription" href="#sns-•-get-subscription" description="Get an AWS SNS subscription by ARN" />
Expand Down Expand Up @@ -3021,6 +3024,98 @@ The Upsert DNS Record component creates or updates a DNS record in an AWS Route
}
```

<a id="s3-•-create-bucket"></a>

## 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"
}
```

<a id="s3-•-delete-bucket"></a>

## 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"
}
```

<a id="s3-•-get-bucket"></a>

## 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"
}
```

<a id="sns-•-create-topic"></a>

## SNS • Create Topic
Expand Down
4 changes: 4 additions & 0 deletions pkg/integrations/aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -198,6 +199,9 @@ func (a *AWS) Actions() []core.Action {
&route53.CreateRecord{},
&route53.UpsertRecord{},
&route53.DeleteRecord{},
&s3.CreateBucket{},
&s3.GetBucket{},
&s3.DeleteBucket{},
}
}

Expand Down
4 changes: 4 additions & 0 deletions pkg/integrations/aws/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)

Expand Down
185 changes: 185 additions & 0 deletions pkg/integrations/aws/s3/client.go
Original file line number Diff line number Diff line change
@@ -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(
`<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><LocationConstraint>%s</LocationConstraint></CreateBucketConfiguration>`,
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())
}
Loading