diff --git a/docs/components/AWS.mdx b/docs/components/AWS.mdx
index 60cd4e0a1..471d5e0d7 100644
--- a/docs/components/AWS.mdx
+++ b/docs/components/AWS.mdx
@@ -33,6 +33,9 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";
+
+
+
@@ -931,6 +934,121 @@ The Run Lambda component invokes a Lambda function.
}
```
+
+
+## Route 53 • Create DNS Record
+
+The Create DNS Record component creates a new DNS record in an AWS Route 53 hosted zone.
+
+### Use Cases
+
+- **Domain management**: Create DNS records for new services or endpoints
+- **Automated provisioning**: Set up DNS entries as part of infrastructure workflows
+- **Multi-environment setup**: Create environment-specific DNS records automatically
+
+### How It Works
+
+1. Connects to AWS Route 53 using the integration credentials
+2. Creates a new DNS record in the specified hosted zone
+3. Returns the change status and submission timestamp
+
+### Example Output
+
+```json
+{
+ "data": {
+ "change": {
+ "id": "/change/C1234567890ABC",
+ "status": "INSYNC",
+ "submittedAt": "2026-01-28T10:30:00.000Z"
+ },
+ "record": {
+ "name": "api.example.com",
+ "type": "A"
+ }
+ },
+ "timestamp": "2026-01-28T10:30:00.000Z",
+ "type": "aws.route53.change"
+}
+```
+
+
+
+## Route 53 • Delete DNS Record
+
+The Delete DNS Record component deletes a DNS record from an AWS Route 53 hosted zone.
+
+### Use Cases
+
+- **Cleanup**: Remove DNS records when decommissioning services
+- **Environment teardown**: Delete DNS entries for temporary environments
+- **Migration**: Remove old DNS records after migrating to new endpoints
+
+### How It Works
+
+1. Connects to AWS Route 53 using the integration credentials
+2. Deletes the specified DNS record from the hosted zone
+3. The record name, type, TTL, and values must match the existing record exactly
+4. Returns the change status and submission timestamp
+
+### Example Output
+
+```json
+{
+ "data": {
+ "change": {
+ "id": "/change/C5555555555GHI",
+ "status": "INSYNC",
+ "submittedAt": "2026-01-28T10:30:00.000Z"
+ },
+ "record": {
+ "name": "api.example.com",
+ "type": "A"
+ }
+ },
+ "timestamp": "2026-01-28T10:30:00.000Z",
+ "type": "aws.route53.change"
+}
+```
+
+
+
+## Route 53 • Upsert DNS Record
+
+The Upsert DNS Record component creates or updates a DNS record in an AWS Route 53 hosted zone.
+
+### Use Cases
+
+- **Idempotent updates**: Safely create or update DNS records without checking existence first
+- **Rolling deployments**: Update DNS records to point to new infrastructure
+- **Failover management**: Switch DNS records between primary and secondary endpoints
+
+### How It Works
+
+1. Connects to AWS Route 53 using the integration credentials
+2. Creates the DNS record if it doesn't exist, or updates it if it does
+3. Returns the change status and submission timestamp
+
+### Example Output
+
+```json
+{
+ "data": {
+ "change": {
+ "id": "/change/C9876543210DEF",
+ "status": "INSYNC",
+ "submittedAt": "2026-01-28T10:30:00.000Z"
+ },
+ "record": {
+ "name": "api.example.com",
+ "type": "A"
+ }
+ },
+ "timestamp": "2026-01-28T10:30:00.000Z",
+ "type": "aws.route53.change"
+}
+```
+
## SNS • Create Topic
diff --git a/pkg/integrations/aws/aws.go b/pkg/integrations/aws/aws.go
index 29a6e34cb..7572f6ae5 100644
--- a/pkg/integrations/aws/aws.go
+++ b/pkg/integrations/aws/aws.go
@@ -24,6 +24,7 @@ import (
"github.com/superplanehq/superplane/pkg/integrations/aws/eventbridge"
"github.com/superplanehq/superplane/pkg/integrations/aws/iam"
"github.com/superplanehq/superplane/pkg/integrations/aws/lambda"
+ "github.com/superplanehq/superplane/pkg/integrations/aws/route53"
"github.com/superplanehq/superplane/pkg/integrations/aws/sns"
"github.com/superplanehq/superplane/pkg/registry"
)
@@ -152,6 +153,9 @@ func (a *AWS) Components() []core.Component {
&ecr.GetImageScanFindings{},
&ecr.ScanImage{},
&lambda.RunFunction{},
+ &route53.CreateRecord{},
+ &route53.UpsertRecord{},
+ &route53.DeleteRecord{},
}
}
diff --git a/pkg/integrations/aws/resources.go b/pkg/integrations/aws/resources.go
index 52ba4134c..911faa09e 100644
--- a/pkg/integrations/aws/resources.go
+++ b/pkg/integrations/aws/resources.go
@@ -6,6 +6,7 @@ import (
"github.com/superplanehq/superplane/pkg/integrations/aws/ecr"
"github.com/superplanehq/superplane/pkg/integrations/aws/ecs"
"github.com/superplanehq/superplane/pkg/integrations/aws/lambda"
+ "github.com/superplanehq/superplane/pkg/integrations/aws/route53"
"github.com/superplanehq/superplane/pkg/integrations/aws/sns"
)
@@ -35,6 +36,9 @@ func (a *AWS) ListResources(resourceType string, ctx core.ListResourcesContext)
case "codeartifact.domain":
return codeartifact.ListDomains(ctx, resourceType)
+ case "route53.hostedZone":
+ return route53.ListHostedZones(ctx, resourceType)
+
case "sns.topic":
return sns.ListTopics(ctx, resourceType)
diff --git a/pkg/integrations/aws/route53/client.go b/pkg/integrations/aws/route53/client.go
new file mode 100644
index 000000000..b83c339b2
--- /dev/null
+++ b/pkg/integrations/aws/route53/client.go
@@ -0,0 +1,332 @@
+package route53
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "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"
+ "github.com/superplanehq/superplane/pkg/integrations/aws/common"
+)
+
+const (
+ serviceName = "route53"
+ region = "us-east-1"
+ endpoint = "https://route53.amazonaws.com/2013-04-01"
+ xmlNS = "https://route53.amazonaws.com/doc/2013-04-01/"
+)
+
+type Client struct {
+ http core.HTTPContext
+ credentials *aws.Credentials
+ signer *v4.Signer
+}
+
+func NewClient(httpCtx core.HTTPContext, credentials *aws.Credentials) *Client {
+ return &Client{
+ http: httpCtx,
+ credentials: credentials,
+ signer: v4.NewSigner(),
+ }
+}
+
+// ChangeResourceRecordSets creates, updates, or deletes DNS records in a hosted zone.
+func (c *Client) ChangeResourceRecordSets(hostedZoneID, action string, recordSet ResourceRecordSet) (*ChangeInfo, error) {
+ records := make([]ResourceRecord, len(recordSet.Values))
+ for i, v := range recordSet.Values {
+ records[i] = ResourceRecord{Value: v}
+ }
+
+ request := changeResourceRecordSetsRequest{
+ XMLNS: xmlNS,
+ ChangeBatch: changeBatch{
+ Changes: []change{
+ {
+ Action: action,
+ ResourceRecordSet: xmlResourceRecordSet{
+ Name: recordSet.Name,
+ Type: recordSet.Type,
+ TTL: recordSet.TTL,
+ ResourceRecords: xmlResourceRecords{Records: records},
+ },
+ },
+ },
+ },
+ }
+
+ body, err := xml.Marshal(request)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ body = append([]byte(xml.Header), body...)
+ url := fmt.Sprintf("%s/hostedzone/%s/rrset", endpoint, normalizeHostedZoneID(hostedZoneID))
+
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("failed to build request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "text/xml")
+
+ if err := c.signRequest(req, body); err != nil {
+ return nil, err
+ }
+
+ res, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+ defer res.Body.Close()
+
+ responseBody, err := io.ReadAll(res.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if res.StatusCode < 200 || res.StatusCode >= 300 {
+ if awsErr := parseError(responseBody); awsErr != nil {
+ return nil, awsErr
+ }
+ return nil, fmt.Errorf("Route 53 API request failed with %d: %s", res.StatusCode, string(responseBody))
+ }
+
+ var response changeResourceRecordSetsResponse
+ if err := xml.Unmarshal(responseBody, &response); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &ChangeInfo{
+ ID: response.ChangeInfo.ID,
+ Status: response.ChangeInfo.Status,
+ SubmittedAt: response.ChangeInfo.SubmittedAt,
+ }, nil
+}
+
+// GetChange returns the current status of a change request.
+// changeID is the ID returned by ChangeResourceRecordSets (e.g. /change/C0123456789ABCDEF).
+func (c *Client) GetChange(changeID string) (*ChangeInfo, error) {
+ changeID = strings.TrimSpace(changeID)
+ if changeID == "" {
+ return nil, fmt.Errorf("change ID is required")
+ }
+ if !strings.HasPrefix(changeID, "/") {
+ changeID = "/" + changeID
+ }
+ url := endpoint + changeID
+
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to build get change request: %w", err)
+ }
+
+ if err := c.signRequest(req, []byte{}); err != nil {
+ return nil, err
+ }
+
+ res, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("get change request failed: %w", err)
+ }
+
+ body, err := io.ReadAll(res.Body)
+ res.Body.Close()
+ if err != nil {
+ return nil, fmt.Errorf("failed to read get change response: %w", err)
+ }
+
+ if res.StatusCode < 200 || res.StatusCode >= 300 {
+ if awsErr := parseError(body); awsErr != nil {
+ return nil, awsErr
+ }
+ return nil, fmt.Errorf("get change failed with %d: %s", res.StatusCode, string(body))
+ }
+
+ var response getChangeResponse
+ if err := xml.Unmarshal(body, &response); err != nil {
+ return nil, fmt.Errorf("failed to decode get change response: %w", err)
+ }
+
+ return &ChangeInfo{
+ ID: response.ChangeInfo.ID,
+ Status: response.ChangeInfo.Status,
+ SubmittedAt: response.ChangeInfo.SubmittedAt,
+ }, nil
+}
+
+// ListHostedZones returns all hosted zones in the account.
+func (c *Client) ListHostedZones() ([]HostedZoneSummary, error) {
+ var zones []HostedZoneSummary
+ marker := ""
+
+ for {
+ url := fmt.Sprintf("%s/hostedzone?maxitems=100", endpoint)
+ if strings.TrimSpace(marker) != "" {
+ url += "&marker=" + marker
+ }
+
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to build list hosted zones request: %w", err)
+ }
+
+ if err := c.signRequest(req, []byte{}); err != nil {
+ return nil, err
+ }
+
+ res, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("list hosted zones request failed: %w", err)
+ }
+
+ body, err := io.ReadAll(res.Body)
+ res.Body.Close()
+ if err != nil {
+ return nil, fmt.Errorf("failed to read list hosted zones response: %w", err)
+ }
+
+ if res.StatusCode < 200 || res.StatusCode >= 300 {
+ if awsErr := parseError(body); awsErr != nil {
+ return nil, awsErr
+ }
+ return nil, fmt.Errorf("list hosted zones failed with %d: %s", res.StatusCode, string(body))
+ }
+
+ var response listHostedZonesResponse
+ if err := xml.Unmarshal(body, &response); err != nil {
+ return nil, fmt.Errorf("failed to decode list hosted zones response: %w", err)
+ }
+
+ for _, hz := range response.HostedZones {
+ zones = append(zones, HostedZoneSummary{
+ ID: hz.ID,
+ Name: hz.Name,
+ })
+ }
+
+ if !response.IsTruncated {
+ break
+ }
+ marker = response.NextMarker
+ }
+
+ return zones, nil
+}
+
+func (c *Client) signRequest(req *http.Request, payload []byte) error {
+ hash := sha256.Sum256(payload)
+ payloadHash := hex.EncodeToString(hash[:])
+ return c.signer.SignHTTP(context.Background(), *c.credentials, req, payloadHash, serviceName, region, time.Now())
+}
+
+// parseError extracts a user-facing error from Route53 API error responses.
+// It handles both the standard ErrorResponse > Error > Code/Message format and
+// the InvalidChangeBatch format (root with ).
+func parseError(body []byte) *common.Error {
+ // Standard ErrorResponse format (e.g. AccessDenied, InvalidInput).
+ var errResp struct {
+ Error struct {
+ Code string `xml:"Code"`
+ Message string `xml:"Message"`
+ } `xml:"Error"`
+ }
+ if err := xml.Unmarshal(body, &errResp); err == nil && (errResp.Error.Code != "" || errResp.Error.Message != "") {
+ return &common.Error{
+ Code: strings.TrimSpace(errResp.Error.Code),
+ Message: strings.TrimSpace(errResp.Error.Message),
+ }
+ }
+
+ // InvalidChangeBatch format (e.g. "RRSet with DNS name X is not permitted in zone Y").
+ var invalidBatch struct {
+ Messages []string `xml:"Messages>Message"`
+ }
+ if err := xml.Unmarshal(body, &invalidBatch); err == nil && len(invalidBatch.Messages) > 0 {
+ msg := strings.TrimSpace(strings.Join(invalidBatch.Messages, "; "))
+ return &common.Error{
+ Code: "InvalidChangeBatch",
+ Message: msg,
+ }
+ }
+
+ return nil
+}
+
+/*
+ * normalizeHostedZoneID strips the /hostedzone/ prefix if present.
+ */
+func normalizeHostedZoneID(id string) string {
+ return strings.TrimPrefix(strings.TrimSpace(id), "/hostedzone/")
+}
+
+// XML request types for ChangeResourceRecordSets.
+type changeResourceRecordSetsRequest struct {
+ XMLName xml.Name `xml:"ChangeResourceRecordSetsRequest"`
+ XMLNS string `xml:"xmlns,attr"`
+ ChangeBatch changeBatch `xml:"ChangeBatch"`
+}
+
+type changeBatch struct {
+ Changes []change `xml:"Changes>Change"`
+}
+
+type change struct {
+ Action string `xml:"Action"`
+ ResourceRecordSet xmlResourceRecordSet `xml:"ResourceRecordSet"`
+}
+
+type xmlResourceRecordSet struct {
+ Name string `xml:"Name"`
+ Type string `xml:"Type"`
+ TTL int `xml:"TTL"`
+ ResourceRecords xmlResourceRecords `xml:"ResourceRecords"`
+}
+
+type xmlResourceRecords struct {
+ Records []ResourceRecord `xml:"ResourceRecord"`
+}
+
+type ResourceRecord struct {
+ Value string `xml:"Value"`
+}
+
+// XML response types for ChangeResourceRecordSets.
+type changeResourceRecordSetsResponse struct {
+ XMLName xml.Name `xml:"ChangeResourceRecordSetsResponse"`
+ ChangeInfo xmlChangeInfo `xml:"ChangeInfo"`
+}
+
+type xmlChangeInfo struct {
+ ID string `xml:"Id"`
+ Status string `xml:"Status"`
+ SubmittedAt string `xml:"SubmittedAt"`
+}
+
+// XML response types for ListHostedZones.
+type listHostedZonesResponse struct {
+ XMLName xml.Name `xml:"ListHostedZonesResponse"`
+ HostedZones []xmlHostedZone `xml:"HostedZones>HostedZone"`
+ IsTruncated bool `xml:"IsTruncated"`
+ MaxItems int `xml:"MaxItems"`
+ NextMarker string `xml:"NextMarker"`
+}
+
+type xmlHostedZone struct {
+ ID string `xml:"Id"`
+ Name string `xml:"Name"`
+}
+
+// XML response type for GetChange.
+type getChangeResponse struct {
+ XMLName xml.Name `xml:"GetChangeResponse"`
+ ChangeInfo xmlChangeInfo `xml:"ChangeInfo"`
+}
diff --git a/pkg/integrations/aws/route53/common.go b/pkg/integrations/aws/route53/common.go
new file mode 100644
index 000000000..c1ff499f8
--- /dev/null
+++ b/pkg/integrations/aws/route53/common.go
@@ -0,0 +1,157 @@
+package route53
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/superplanehq/superplane/pkg/configuration"
+)
+
+// ChangeInfo contains information about a Route 53 change request.
+type ChangeInfo struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ SubmittedAt string `json:"submittedAt"`
+}
+
+// RecordChangePollMetadata is stored when a change is PENDING and we schedule a poll.
+type RecordChangePollMetadata struct {
+ ChangeID string `json:"changeId" mapstructure:"changeId"`
+ RecordName string `json:"recordName" mapstructure:"recordName"`
+ RecordType string `json:"recordType" mapstructure:"recordType"`
+ SubmittedAt string `json:"submittedAt" mapstructure:"submittedAt"`
+}
+
+// HostedZoneSummary contains basic information about a hosted zone.
+type HostedZoneSummary struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// ResourceRecordSet represents a DNS record set to be created, updated, or deleted.
+type ResourceRecordSet struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ TTL int `json:"ttl"`
+ Values []string `json:"values"`
+}
+
+/*
+ * DNS record types supported by AWS Route 53.
+ * See: https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/ResourceRecordTypes.html
+ */
+var RecordTypeOptions = []configuration.FieldOption{
+ {Label: "A", Value: "A"},
+ {Label: "AAAA", Value: "AAAA"},
+ {Label: "CAA", Value: "CAA"},
+ {Label: "CNAME", Value: "CNAME"},
+ {Label: "DS", Value: "DS"},
+ {Label: "MX", Value: "MX"},
+ {Label: "NAPTR", Value: "NAPTR"},
+ {Label: "NS", Value: "NS"},
+ {Label: "PTR", Value: "PTR"},
+ {Label: "SOA", Value: "SOA"},
+ {Label: "SPF", Value: "SPF"},
+ {Label: "SRV", Value: "SRV"},
+ {Label: "TXT", Value: "TXT"},
+}
+
+/*
+ * recordConfigurationFields returns the shared configuration fields
+ * for all Route 53 DNS record components.
+ */
+func recordConfigurationFields() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "hostedZoneId",
+ Label: "Hosted Zone",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The hosted zone to manage DNS records in",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "route53.hostedZone",
+ },
+ },
+ },
+ {
+ Name: "recordName",
+ Label: "Record Name",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Description: "The DNS record name (e.g. example.com or sub.example.com)",
+ },
+ {
+ Name: "recordType",
+ Label: "Record Type",
+ Type: configuration.FieldTypeSelect,
+ Required: true,
+ Default: "A",
+ TypeOptions: &configuration.TypeOptions{
+ Select: &configuration.SelectTypeOptions{
+ Options: RecordTypeOptions,
+ },
+ },
+ },
+ {
+ Name: "ttl",
+ Label: "TTL (seconds)",
+ Type: configuration.FieldTypeNumber,
+ Required: true,
+ Default: "300",
+ Description: "Time to live for the DNS record in seconds",
+ TypeOptions: &configuration.TypeOptions{
+ Number: &configuration.NumberTypeOptions{
+ Min: func() *int { min := 0; return &min }(),
+ Max: func() *int { max := 2147483647; return &max }(),
+ },
+ },
+ },
+ {
+ Name: "values",
+ Label: "Record Values",
+ Type: configuration.FieldTypeList,
+ Required: true,
+ Description: "The values for the DNS record (e.g. IP addresses, domain names)",
+ TypeOptions: &configuration.TypeOptions{
+ List: &configuration.ListTypeOptions{
+ ItemLabel: "Value",
+ ItemDefinition: &configuration.ListItemDefinition{
+ Type: configuration.FieldTypeString,
+ },
+ },
+ },
+ },
+ }
+}
+
+func validateRecordConfiguration(hostedZoneID, recordName, recordType string, values []string) error {
+ if hostedZoneID == "" {
+ return fmt.Errorf("hosted zone is required")
+ }
+
+ if recordName == "" {
+ return fmt.Errorf("record name is required")
+ }
+
+ if recordType == "" {
+ return fmt.Errorf("record type is required")
+ }
+
+ if len(values) == 0 {
+ return fmt.Errorf("at least one record value is required")
+ }
+
+ return nil
+}
+
+func normalizeValues(values []string) []string {
+ normalized := make([]string, 0, len(values))
+ for _, v := range values {
+ v = strings.TrimSpace(v)
+ if v != "" {
+ normalized = append(normalized, v)
+ }
+ }
+ return normalized
+}
diff --git a/pkg/integrations/aws/route53/create_record.go b/pkg/integrations/aws/route53/create_record.go
new file mode 100644
index 000000000..8402ec3ca
--- /dev/null
+++ b/pkg/integrations/aws/route53/create_record.go
@@ -0,0 +1,177 @@
+package route53
+
+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 CreateRecord struct{}
+
+type CreateRecordConfiguration struct {
+ HostedZoneID string `json:"hostedZoneId" mapstructure:"hostedZoneId"`
+ RecordName string `json:"recordName" mapstructure:"recordName"`
+ RecordType string `json:"recordType" mapstructure:"recordType"`
+ TTL int `json:"ttl" mapstructure:"ttl"`
+ Values []string `json:"values" mapstructure:"values"`
+}
+
+func (c *CreateRecord) Name() string {
+ return "aws.route53.createRecord"
+}
+
+func (c *CreateRecord) Label() string {
+ return "Route 53 • Create DNS Record"
+}
+
+func (c *CreateRecord) Description() string {
+ return "Create a DNS record in an AWS Route 53 hosted zone"
+}
+
+func (c *CreateRecord) Documentation() string {
+ return `The Create DNS Record component creates a new DNS record in an AWS Route 53 hosted zone.
+
+## Use Cases
+
+- **Domain management**: Create DNS records for new services or endpoints
+- **Automated provisioning**: Set up DNS entries as part of infrastructure workflows
+- **Multi-environment setup**: Create environment-specific DNS records automatically
+
+## How It Works
+
+1. Connects to AWS Route 53 using the integration credentials
+2. Creates a new DNS record in the specified hosted zone
+3. Returns the change status and submission timestamp
+`
+}
+
+func (c *CreateRecord) Icon() string {
+ return "aws"
+}
+
+func (c *CreateRecord) Color() string {
+ return "gray"
+}
+
+func (c *CreateRecord) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (c *CreateRecord) Configuration() []configuration.Field {
+ return recordConfigurationFields()
+}
+
+func (c *CreateRecord) Setup(ctx core.SetupContext) error {
+ var config CreateRecordConfiguration
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ return fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ config = c.normalizeConfig(config)
+ return validateRecordConfiguration(config.HostedZoneID, config.RecordName, config.RecordType, config.Values)
+}
+
+func (c *CreateRecord) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (c *CreateRecord) Execute(ctx core.ExecutionContext) error {
+ var config CreateRecordConfiguration
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ return fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ config = c.normalizeConfig(config)
+ creds, err := common.CredentialsFromInstallation(ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("failed to get AWS credentials: %w", err)
+ }
+
+ client := NewClient(ctx.HTTP, creds)
+ result, err := client.ChangeResourceRecordSets(config.HostedZoneID, "CREATE", ResourceRecordSet{
+ Name: config.RecordName,
+ Type: config.RecordType,
+ TTL: config.TTL,
+ Values: config.Values,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to create DNS record: %w", err)
+ }
+
+ if result.Status == "INSYNC" {
+ output := map[string]any{
+ "change": map[string]any{
+ "id": result.ID,
+ "status": result.Status,
+ "submittedAt": result.SubmittedAt,
+ },
+ "record": map[string]any{
+ "name": config.RecordName,
+ "type": config.RecordType,
+ },
+ }
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ "aws.route53.change",
+ []any{output},
+ )
+ }
+
+ if err := ctx.Metadata.Set(RecordChangePollMetadata{
+ ChangeID: result.ID,
+ RecordName: config.RecordName,
+ RecordType: config.RecordType,
+ SubmittedAt: result.SubmittedAt,
+ }); err != nil {
+ return fmt.Errorf("failed to set poll metadata: %w", err)
+ }
+ return ctx.Requests.ScheduleActionCall(
+ pollChangeActionName,
+ map[string]any{},
+ pollInterval,
+ )
+}
+
+func (c *CreateRecord) Actions() []core.Action {
+ return []core.Action{
+ {
+ Name: pollChangeActionName,
+ Description: "Poll for change status",
+ },
+ }
+}
+
+func (c *CreateRecord) HandleAction(ctx core.ActionContext) error {
+ switch ctx.Name {
+ case pollChangeActionName:
+ return pollChangeUntilSynced(ctx)
+ default:
+ return fmt.Errorf("unknown action: %s", ctx.Name)
+ }
+}
+
+func (c *CreateRecord) HandleWebhook(ctx core.WebhookRequestContext) (int, error) {
+ return http.StatusOK, nil
+}
+
+func (c *CreateRecord) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (c *CreateRecord) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (c *CreateRecord) normalizeConfig(config CreateRecordConfiguration) CreateRecordConfiguration {
+ config.HostedZoneID = strings.TrimSpace(config.HostedZoneID)
+ config.RecordName = strings.TrimSpace(config.RecordName)
+ config.RecordType = strings.TrimSpace(config.RecordType)
+ config.Values = normalizeValues(config.Values)
+ return config
+}
diff --git a/pkg/integrations/aws/route53/create_record_test.go b/pkg/integrations/aws/route53/create_record_test.go
new file mode 100644
index 000000000..ca05d33e9
--- /dev/null
+++ b/pkg/integrations/aws/route53/create_record_test.go
@@ -0,0 +1,438 @@
+package route53
+
+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 TestCreateRecord_Setup(t *testing.T) {
+ component := &CreateRecord{}
+
+ t.Run("invalid configuration -> error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: "invalid",
+ })
+
+ require.ErrorContains(t, err, "failed to decode configuration")
+ })
+
+ t.Run("missing hosted zone -> error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: map[string]any{
+ "recordName": "example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ })
+
+ require.ErrorContains(t, err, "hosted zone is required")
+ })
+
+ t.Run("missing record name -> error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ })
+
+ require.ErrorContains(t, err, "record name is required")
+ })
+
+ t.Run("missing record type -> error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "example.com",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ })
+
+ require.ErrorContains(t, err, "record type is required")
+ })
+
+ t.Run("missing values -> error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "example.com",
+ "recordType": "A",
+ "ttl": 300,
+ },
+ })
+
+ require.ErrorContains(t, err, "at least one record value is required")
+ })
+
+ t.Run("valid configuration -> no error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: map[string]any{
+ "hostedZoneId": " Z123 ",
+ "recordName": " example.com ",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ })
+
+ require.NoError(t, err)
+ })
+}
+
+func TestCreateRecord_Execute(t *testing.T) {
+ component := &CreateRecord{}
+
+ t.Run("invalid configuration -> error", func(t *testing.T) {
+ err := component.Execute(core.ExecutionContext{
+ Configuration: "invalid",
+ ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}},
+ Integration: &contexts.IntegrationContext{},
+ })
+
+ 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{
+ "hostedZoneId": "Z123",
+ "recordName": "example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}},
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{},
+ },
+ })
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "credentials")
+ })
+
+ t.Run("status PENDING -> schedules poll and does not emit", func(t *testing.T) {
+ xmlResponse := `
+
+
+ /change/C1234567890
+ PENDING
+ 2026-01-28T10:30:00.000Z
+
+`
+
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(xmlResponse)),
+ },
+ },
+ }
+
+ metadata := &contexts.MetadataContext{}
+ requests := &contexts.RequestContext{}
+ execState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ ExecutionState: execState,
+ HTTP: httpContext,
+ Metadata: metadata,
+ Requests: requests,
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{
+ "accessKeyId": {Name: "accessKeyId", Value: []byte("key")},
+ "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")},
+ "sessionToken": {Name: "sessionToken", Value: []byte("token")},
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ require.Len(t, execState.Payloads, 0, "should not emit until INSYNC")
+ assert.Equal(t, pollChangeActionName, requests.Action)
+ assert.Equal(t, pollInterval, requests.Duration)
+ stored, ok := metadata.Metadata.(RecordChangePollMetadata)
+ require.True(t, ok)
+ assert.Equal(t, "/change/C1234567890", stored.ChangeID)
+ assert.Equal(t, "example.com", stored.RecordName)
+ assert.Equal(t, "A", stored.RecordType)
+ assert.Equal(t, "2026-01-28T10:30:00.000Z", stored.SubmittedAt)
+ require.Len(t, httpContext.Requests, 1)
+ assert.Contains(t, httpContext.Requests[0].URL.String(), "hostedzone/Z123/rrset")
+ })
+
+ t.Run("status INSYNC -> emits record change immediately", func(t *testing.T) {
+ xmlResponse := `
+
+
+ /change/C1234567890
+ INSYNC
+ 2026-01-28T10:30:00.000Z
+
+`
+
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(xmlResponse)),
+ },
+ },
+ }
+
+ execState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ ExecutionState: execState,
+ HTTP: httpContext,
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{
+ "accessKeyId": {Name: "accessKeyId", Value: []byte("key")},
+ "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")},
+ "sessionToken": {Name: "sessionToken", Value: []byte("token")},
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ require.Len(t, execState.Payloads, 1)
+ require.True(t, execState.Passed)
+ require.Equal(t, "aws.route53.change", execState.Type)
+ payload := execState.Payloads[0].(map[string]any)
+ data, ok := payload["data"].(map[string]any)
+ require.True(t, ok)
+ change, ok := data["change"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "/change/C1234567890", change["id"])
+ assert.Equal(t, "INSYNC", change["status"])
+ assert.Equal(t, "2026-01-28T10:30:00.000Z", change["submittedAt"])
+ })
+
+ t.Run("API error ErrorResponse format -> returns error", func(t *testing.T) {
+ xmlError := `
+
+
+ AccessDenied
+ User is not authorized
+
+`
+
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusForbidden,
+ Body: io.NopCloser(strings.NewReader(xmlError)),
+ },
+ },
+ }
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}},
+ HTTP: httpContext,
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{
+ "accessKeyId": {Name: "accessKeyId", Value: []byte("key")},
+ "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")},
+ "sessionToken": {Name: "sessionToken", Value: []byte("token")},
+ },
+ },
+ })
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "AccessDenied")
+ require.Contains(t, err.Error(), "User is not authorized")
+ })
+
+ t.Run("API error InvalidChangeBatch format -> returns error", func(t *testing.T) {
+ // Route53 returns this format (not ErrorResponse) for InvalidChangeBatch.
+ xmlError := `
+
+
+ RRSet with DNS name dev.example.com. is not permitted in zone example.com.
+
+`
+
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusBadRequest,
+ Body: io.NopCloser(strings.NewReader(xmlError)),
+ },
+ },
+ }
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "dev.example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}},
+ HTTP: httpContext,
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{
+ "accessKeyId": {Name: "accessKeyId", Value: []byte("key")},
+ "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")},
+ "sessionToken": {Name: "sessionToken", Value: []byte("token")},
+ },
+ },
+ })
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "InvalidChangeBatch")
+ require.Contains(t, err.Error(), "RRSet with DNS name dev.example.com")
+ require.Contains(t, err.Error(), "not permitted in zone example.com")
+ })
+}
+
+func TestCreateRecord_HandleAction(t *testing.T) {
+ component := &CreateRecord{}
+
+ t.Run("unknown action -> error", func(t *testing.T) {
+ err := component.HandleAction(core.ActionContext{
+ Name: "unknown",
+ })
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "unknown action")
+ })
+
+ t.Run("pollChange status still PENDING -> schedules poll again", func(t *testing.T) {
+ getChangeXML := `
+
+
+ /change/C1234567890
+ PENDING
+ 2026-01-28T10:30:00.000Z
+
+`
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(getChangeXML)),
+ },
+ },
+ }
+ requests := &contexts.RequestContext{}
+ err := component.HandleAction(core.ActionContext{
+ Name: pollChangeActionName,
+ HTTP: httpContext,
+ Requests: requests,
+ Metadata: &contexts.MetadataContext{
+ Metadata: RecordChangePollMetadata{
+ ChangeID: "/change/C1234567890",
+ RecordName: "example.com",
+ RecordType: "A",
+ SubmittedAt: "2026-01-28T10:30:00.000Z",
+ },
+ },
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{
+ "accessKeyId": {Name: "accessKeyId", Value: []byte("key")},
+ "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")},
+ "sessionToken": {Name: "sessionToken", Value: []byte("token")},
+ },
+ },
+ })
+ require.NoError(t, err)
+ assert.Equal(t, pollChangeActionName, requests.Action)
+ assert.Equal(t, pollInterval, requests.Duration)
+ })
+
+ t.Run("pollChange status INSYNC -> emits record change", func(t *testing.T) {
+ getChangeXML := `
+
+
+ /change/C1234567890
+ INSYNC
+ 2026-01-28T10:30:00.000Z
+
+`
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(getChangeXML)),
+ },
+ },
+ }
+ execState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.HandleAction(core.ActionContext{
+ Name: pollChangeActionName,
+ HTTP: httpContext,
+ ExecutionState: execState,
+ Metadata: &contexts.MetadataContext{
+ Metadata: RecordChangePollMetadata{
+ ChangeID: "/change/C1234567890",
+ RecordName: "example.com",
+ RecordType: "A",
+ SubmittedAt: "2026-01-28T10:30:00.000Z",
+ },
+ },
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{
+ "accessKeyId": {Name: "accessKeyId", Value: []byte("key")},
+ "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")},
+ "sessionToken": {Name: "sessionToken", Value: []byte("token")},
+ },
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, execState.Payloads, 1)
+ require.True(t, execState.Passed)
+ require.Equal(t, "aws.route53.change", execState.Type)
+ payload := execState.Payloads[0].(map[string]any)
+ data, ok := payload["data"].(map[string]any)
+ require.True(t, ok)
+ change, ok := data["change"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "/change/C1234567890", change["id"])
+ assert.Equal(t, "INSYNC", change["status"])
+ assert.Equal(t, "2026-01-28T10:30:00.000Z", change["submittedAt"])
+ })
+}
diff --git a/pkg/integrations/aws/route53/delete_record.go b/pkg/integrations/aws/route53/delete_record.go
new file mode 100644
index 000000000..2e8eaafda
--- /dev/null
+++ b/pkg/integrations/aws/route53/delete_record.go
@@ -0,0 +1,178 @@
+package route53
+
+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 DeleteRecord struct{}
+
+type DeleteRecordConfiguration struct {
+ HostedZoneID string `json:"hostedZoneId" mapstructure:"hostedZoneId"`
+ RecordName string `json:"recordName" mapstructure:"recordName"`
+ RecordType string `json:"recordType" mapstructure:"recordType"`
+ TTL int `json:"ttl" mapstructure:"ttl"`
+ Values []string `json:"values" mapstructure:"values"`
+}
+
+func (c *DeleteRecord) Name() string {
+ return "aws.route53.deleteRecord"
+}
+
+func (c *DeleteRecord) Label() string {
+ return "Route 53 • Delete DNS Record"
+}
+
+func (c *DeleteRecord) Description() string {
+ return "Delete a DNS record from an AWS Route 53 hosted zone"
+}
+
+func (c *DeleteRecord) Documentation() string {
+ return `The Delete DNS Record component deletes a DNS record from an AWS Route 53 hosted zone.
+
+## Use Cases
+
+- **Cleanup**: Remove DNS records when decommissioning services
+- **Environment teardown**: Delete DNS entries for temporary environments
+- **Migration**: Remove old DNS records after migrating to new endpoints
+
+## How It Works
+
+1. Connects to AWS Route 53 using the integration credentials
+2. Deletes the specified DNS record from the hosted zone
+3. The record name, type, TTL, and values must match the existing record exactly
+4. Returns the change status and submission timestamp
+`
+}
+
+func (c *DeleteRecord) Icon() string {
+ return "aws"
+}
+
+func (c *DeleteRecord) Color() string {
+ return "gray"
+}
+
+func (c *DeleteRecord) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (c *DeleteRecord) Configuration() []configuration.Field {
+ return recordConfigurationFields()
+}
+
+func (c *DeleteRecord) Setup(ctx core.SetupContext) error {
+ var config DeleteRecordConfiguration
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ return fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ config = c.normalizeConfig(config)
+ return validateRecordConfiguration(config.HostedZoneID, config.RecordName, config.RecordType, config.Values)
+}
+
+func (c *DeleteRecord) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (c *DeleteRecord) Execute(ctx core.ExecutionContext) error {
+ var config DeleteRecordConfiguration
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ return fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ config = c.normalizeConfig(config)
+ creds, err := common.CredentialsFromInstallation(ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("failed to get AWS credentials: %w", err)
+ }
+
+ client := NewClient(ctx.HTTP, creds)
+ result, err := client.ChangeResourceRecordSets(config.HostedZoneID, "DELETE", ResourceRecordSet{
+ Name: config.RecordName,
+ Type: config.RecordType,
+ TTL: config.TTL,
+ Values: config.Values,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to delete DNS record: %w", err)
+ }
+
+ if result.Status == "INSYNC" {
+ output := map[string]any{
+ "change": map[string]any{
+ "id": result.ID,
+ "status": result.Status,
+ "submittedAt": result.SubmittedAt,
+ },
+ "record": map[string]any{
+ "name": config.RecordName,
+ "type": config.RecordType,
+ },
+ }
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ "aws.route53.change",
+ []any{output},
+ )
+ }
+
+ if err := ctx.Metadata.Set(RecordChangePollMetadata{
+ ChangeID: result.ID,
+ RecordName: config.RecordName,
+ RecordType: config.RecordType,
+ SubmittedAt: result.SubmittedAt,
+ }); err != nil {
+ return fmt.Errorf("failed to set poll metadata: %w", err)
+ }
+ return ctx.Requests.ScheduleActionCall(
+ pollChangeActionName,
+ map[string]any{},
+ pollInterval,
+ )
+}
+
+func (c *DeleteRecord) Actions() []core.Action {
+ return []core.Action{
+ {
+ Name: pollChangeActionName,
+ Description: "Poll for change status",
+ },
+ }
+}
+
+func (c *DeleteRecord) HandleAction(ctx core.ActionContext) error {
+ switch ctx.Name {
+ case pollChangeActionName:
+ return pollChangeUntilSynced(ctx)
+ default:
+ return fmt.Errorf("unknown action: %s", ctx.Name)
+ }
+}
+
+func (c *DeleteRecord) HandleWebhook(ctx core.WebhookRequestContext) (int, error) {
+ return http.StatusOK, nil
+}
+
+func (c *DeleteRecord) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (c *DeleteRecord) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (c *DeleteRecord) normalizeConfig(config DeleteRecordConfiguration) DeleteRecordConfiguration {
+ config.HostedZoneID = strings.TrimSpace(config.HostedZoneID)
+ config.RecordName = strings.TrimSpace(config.RecordName)
+ config.RecordType = strings.TrimSpace(config.RecordType)
+ config.Values = normalizeValues(config.Values)
+ return config
+}
diff --git a/pkg/integrations/aws/route53/delete_record_test.go b/pkg/integrations/aws/route53/delete_record_test.go
new file mode 100644
index 000000000..44f06253c
--- /dev/null
+++ b/pkg/integrations/aws/route53/delete_record_test.go
@@ -0,0 +1,254 @@
+package route53
+
+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 TestDeleteRecord_Setup(t *testing.T) {
+ component := &DeleteRecord{}
+
+ t.Run("invalid configuration -> error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: "invalid",
+ })
+
+ require.ErrorContains(t, err, "failed to decode configuration")
+ })
+
+ t.Run("missing hosted zone -> error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: map[string]any{
+ "recordName": "example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ })
+
+ require.ErrorContains(t, err, "hosted zone is required")
+ })
+
+ t.Run("missing values -> error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "example.com",
+ "recordType": "A",
+ "ttl": 300,
+ },
+ })
+
+ require.ErrorContains(t, err, "at least one record value is required")
+ })
+
+ t.Run("valid configuration -> no error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "old.example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ })
+
+ require.NoError(t, err)
+ })
+}
+
+func TestDeleteRecord_Execute(t *testing.T) {
+ component := &DeleteRecord{}
+
+ t.Run("invalid configuration -> error", func(t *testing.T) {
+ err := component.Execute(core.ExecutionContext{
+ Configuration: "invalid",
+ ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}},
+ Integration: &contexts.IntegrationContext{},
+ })
+
+ 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{
+ "hostedZoneId": "Z123",
+ "recordName": "example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}},
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{},
+ },
+ })
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "credentials")
+ })
+
+ t.Run("status PENDING -> schedules poll and does not emit", func(t *testing.T) {
+ xmlResponse := `
+
+
+ /change/C5555555555
+ PENDING
+ 2026-02-13T15:00:00.000Z
+
+`
+
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(xmlResponse)),
+ },
+ },
+ }
+
+ metadata := &contexts.MetadataContext{}
+ requests := &contexts.RequestContext{}
+ execState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "old.example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ ExecutionState: execState,
+ HTTP: httpContext,
+ Metadata: metadata,
+ Requests: requests,
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{
+ "accessKeyId": {Name: "accessKeyId", Value: []byte("key")},
+ "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")},
+ "sessionToken": {Name: "sessionToken", Value: []byte("token")},
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ require.Len(t, execState.Payloads, 0, "should not emit until INSYNC")
+ assert.Equal(t, pollChangeActionName, requests.Action)
+ assert.Equal(t, pollInterval, requests.Duration)
+ stored, ok := metadata.Metadata.(RecordChangePollMetadata)
+ require.True(t, ok)
+ assert.Equal(t, "/change/C5555555555", stored.ChangeID)
+ assert.Equal(t, "old.example.com", stored.RecordName)
+ assert.Equal(t, "A", stored.RecordType)
+ })
+
+ t.Run("status INSYNC -> emits record change immediately", func(t *testing.T) {
+ xmlResponse := `
+
+
+ /change/C5555555555
+ INSYNC
+ 2026-02-13T15:00:00.000Z
+
+`
+
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(xmlResponse)),
+ },
+ },
+ }
+
+ execState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "old.example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ ExecutionState: execState,
+ HTTP: httpContext,
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{
+ "accessKeyId": {Name: "accessKeyId", Value: []byte("key")},
+ "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")},
+ "sessionToken": {Name: "sessionToken", Value: []byte("token")},
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ require.Len(t, execState.Payloads, 1)
+ require.True(t, execState.Passed)
+ require.Equal(t, "aws.route53.change", execState.Type)
+ payload := execState.Payloads[0].(map[string]any)
+ data, ok := payload["data"].(map[string]any)
+ require.True(t, ok)
+ change, ok := data["change"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "/change/C5555555555", change["id"])
+ assert.Equal(t, "INSYNC", change["status"])
+ assert.Equal(t, "2026-02-13T15:00:00.000Z", change["submittedAt"])
+ })
+
+ t.Run("API error -> returns error", func(t *testing.T) {
+ xmlError := `
+
+
+ InvalidChangeBatch
+ The resource record set does not exist
+
+`
+
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusBadRequest,
+ Body: io.NopCloser(strings.NewReader(xmlError)),
+ },
+ },
+ }
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "nonexistent.example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}},
+ HTTP: httpContext,
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{
+ "accessKeyId": {Name: "accessKeyId", Value: []byte("key")},
+ "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")},
+ "sessionToken": {Name: "sessionToken", Value: []byte("token")},
+ },
+ },
+ })
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "InvalidChangeBatch")
+ })
+}
diff --git a/pkg/integrations/aws/route53/example.go b/pkg/integrations/aws/route53/example.go
new file mode 100644
index 000000000..68b1409d9
--- /dev/null
+++ b/pkg/integrations/aws/route53/example.go
@@ -0,0 +1,50 @@
+package route53
+
+import (
+ _ "embed"
+ "sync"
+
+ "github.com/superplanehq/superplane/pkg/utils"
+)
+
+//go:embed example_output_create_record.json
+var exampleOutputCreateRecordBytes []byte
+
+//go:embed example_output_upsert_record.json
+var exampleOutputUpsertRecordBytes []byte
+
+//go:embed example_output_delete_record.json
+var exampleOutputDeleteRecordBytes []byte
+
+var exampleOutputCreateRecordOnce sync.Once
+var exampleOutputCreateRecord map[string]any
+
+var exampleOutputUpsertRecordOnce sync.Once
+var exampleOutputUpsertRecord map[string]any
+
+var exampleOutputDeleteRecordOnce sync.Once
+var exampleOutputDeleteRecord map[string]any
+
+func (c *CreateRecord) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(
+ &exampleOutputCreateRecordOnce,
+ exampleOutputCreateRecordBytes,
+ &exampleOutputCreateRecord,
+ )
+}
+
+func (c *UpsertRecord) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(
+ &exampleOutputUpsertRecordOnce,
+ exampleOutputUpsertRecordBytes,
+ &exampleOutputUpsertRecord,
+ )
+}
+
+func (c *DeleteRecord) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(
+ &exampleOutputDeleteRecordOnce,
+ exampleOutputDeleteRecordBytes,
+ &exampleOutputDeleteRecord,
+ )
+}
diff --git a/pkg/integrations/aws/route53/example_output_create_record.json b/pkg/integrations/aws/route53/example_output_create_record.json
new file mode 100644
index 000000000..de555115f
--- /dev/null
+++ b/pkg/integrations/aws/route53/example_output_create_record.json
@@ -0,0 +1,15 @@
+{
+ "data": {
+ "change": {
+ "id": "/change/C1234567890ABC",
+ "status": "INSYNC",
+ "submittedAt": "2026-01-28T10:30:00.000Z"
+ },
+ "record": {
+ "name": "api.example.com",
+ "type": "A"
+ }
+ },
+ "timestamp": "2026-01-28T10:30:00.000Z",
+ "type": "aws.route53.change"
+}
diff --git a/pkg/integrations/aws/route53/example_output_delete_record.json b/pkg/integrations/aws/route53/example_output_delete_record.json
new file mode 100644
index 000000000..9424b496c
--- /dev/null
+++ b/pkg/integrations/aws/route53/example_output_delete_record.json
@@ -0,0 +1,15 @@
+{
+ "data": {
+ "change": {
+ "id": "/change/C5555555555GHI",
+ "status": "INSYNC",
+ "submittedAt": "2026-01-28T10:30:00.000Z"
+ },
+ "record": {
+ "name": "api.example.com",
+ "type": "A"
+ }
+ },
+ "timestamp": "2026-01-28T10:30:00.000Z",
+ "type": "aws.route53.change"
+}
diff --git a/pkg/integrations/aws/route53/example_output_upsert_record.json b/pkg/integrations/aws/route53/example_output_upsert_record.json
new file mode 100644
index 000000000..f9084df08
--- /dev/null
+++ b/pkg/integrations/aws/route53/example_output_upsert_record.json
@@ -0,0 +1,15 @@
+{
+ "data": {
+ "change": {
+ "id": "/change/C9876543210DEF",
+ "status": "INSYNC",
+ "submittedAt": "2026-01-28T10:30:00.000Z"
+ },
+ "record": {
+ "name": "api.example.com",
+ "type": "A"
+ }
+ },
+ "timestamp": "2026-01-28T10:30:00.000Z",
+ "type": "aws.route53.change"
+}
diff --git a/pkg/integrations/aws/route53/poll.go b/pkg/integrations/aws/route53/poll.go
new file mode 100644
index 000000000..b37337254
--- /dev/null
+++ b/pkg/integrations/aws/route53/poll.go
@@ -0,0 +1,67 @@
+package route53
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/pkg/integrations/aws/common"
+)
+
+const (
+ pollChangeActionName = "pollChange"
+ pollInterval = 5 * time.Second
+)
+
+// pollChangeUntilSynced runs when the pollChange action is invoked. It fetches the
+// change status; if INSYNC it emits the result and finishes, otherwise schedules another poll.
+func pollChangeUntilSynced(ctx core.ActionContext) error {
+ var meta RecordChangePollMetadata
+ if err := mapstructure.Decode(ctx.Metadata.Get(), &meta); err != nil {
+ return fmt.Errorf("failed to decode poll metadata: %w", err)
+ }
+
+ creds, err := common.CredentialsFromInstallation(ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("failed to get AWS credentials: %w", err)
+ }
+
+ client := NewClient(ctx.HTTP, creds)
+ change, err := client.GetChange(meta.ChangeID)
+ if err != nil {
+ return fmt.Errorf("failed to get change status: %w", err)
+ }
+
+ submittedAt := change.SubmittedAt
+ if submittedAt == "" {
+ submittedAt = meta.SubmittedAt
+ }
+
+ output := map[string]any{
+ "change": map[string]any{
+ "id": change.ID,
+ "status": change.Status,
+ "submittedAt": submittedAt,
+ },
+ "record": map[string]any{
+ "name": meta.RecordName,
+ "type": meta.RecordType,
+ },
+ }
+
+ if change.Status == "INSYNC" {
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ "aws.route53.change",
+ []any{output},
+ )
+ }
+
+ // Still PENDING; keep polling with same metadata
+ return ctx.Requests.ScheduleActionCall(
+ pollChangeActionName,
+ map[string]any{},
+ pollInterval,
+ )
+}
diff --git a/pkg/integrations/aws/route53/resources.go b/pkg/integrations/aws/route53/resources.go
new file mode 100644
index 000000000..501074652
--- /dev/null
+++ b/pkg/integrations/aws/route53/resources.go
@@ -0,0 +1,32 @@
+package route53
+
+import (
+ "fmt"
+
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/pkg/integrations/aws/common"
+)
+
+func ListHostedZones(ctx core.ListResourcesContext, resourceType string) ([]core.IntegrationResource, error) {
+ credentials, err := common.CredentialsFromInstallation(ctx.Integration)
+ if err != nil {
+ return nil, err
+ }
+
+ client := NewClient(ctx.HTTP, credentials)
+ zones, err := client.ListHostedZones()
+ if err != nil {
+ return nil, fmt.Errorf("failed to list hosted zones: %w", err)
+ }
+
+ resources := make([]core.IntegrationResource, 0, len(zones))
+ for _, zone := range zones {
+ resources = append(resources, core.IntegrationResource{
+ Type: resourceType,
+ Name: zone.Name,
+ ID: zone.ID,
+ })
+ }
+
+ return resources, nil
+}
diff --git a/pkg/integrations/aws/route53/upsert_record.go b/pkg/integrations/aws/route53/upsert_record.go
new file mode 100644
index 000000000..6880d491c
--- /dev/null
+++ b/pkg/integrations/aws/route53/upsert_record.go
@@ -0,0 +1,177 @@
+package route53
+
+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 UpsertRecord struct{}
+
+type UpsertRecordConfiguration struct {
+ HostedZoneID string `json:"hostedZoneId" mapstructure:"hostedZoneId"`
+ RecordName string `json:"recordName" mapstructure:"recordName"`
+ RecordType string `json:"recordType" mapstructure:"recordType"`
+ TTL int `json:"ttl" mapstructure:"ttl"`
+ Values []string `json:"values" mapstructure:"values"`
+}
+
+func (c *UpsertRecord) Name() string {
+ return "aws.route53.upsertRecord"
+}
+
+func (c *UpsertRecord) Label() string {
+ return "Route 53 • Upsert DNS Record"
+}
+
+func (c *UpsertRecord) Description() string {
+ return "Create or update a DNS record in an AWS Route 53 hosted zone"
+}
+
+func (c *UpsertRecord) Documentation() string {
+ return `The Upsert DNS Record component creates or updates a DNS record in an AWS Route 53 hosted zone.
+
+## Use Cases
+
+- **Idempotent updates**: Safely create or update DNS records without checking existence first
+- **Rolling deployments**: Update DNS records to point to new infrastructure
+- **Failover management**: Switch DNS records between primary and secondary endpoints
+
+## How It Works
+
+1. Connects to AWS Route 53 using the integration credentials
+2. Creates the DNS record if it doesn't exist, or updates it if it does
+3. Returns the change status and submission timestamp
+`
+}
+
+func (c *UpsertRecord) Icon() string {
+ return "aws"
+}
+
+func (c *UpsertRecord) Color() string {
+ return "gray"
+}
+
+func (c *UpsertRecord) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (c *UpsertRecord) Configuration() []configuration.Field {
+ return recordConfigurationFields()
+}
+
+func (c *UpsertRecord) Setup(ctx core.SetupContext) error {
+ var config UpsertRecordConfiguration
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ return fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ config = c.normalizeConfig(config)
+ return validateRecordConfiguration(config.HostedZoneID, config.RecordName, config.RecordType, config.Values)
+}
+
+func (c *UpsertRecord) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (c *UpsertRecord) Execute(ctx core.ExecutionContext) error {
+ var config UpsertRecordConfiguration
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ return fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ config = c.normalizeConfig(config)
+ creds, err := common.CredentialsFromInstallation(ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("failed to get AWS credentials: %w", err)
+ }
+
+ client := NewClient(ctx.HTTP, creds)
+ result, err := client.ChangeResourceRecordSets(config.HostedZoneID, "UPSERT", ResourceRecordSet{
+ Name: config.RecordName,
+ Type: config.RecordType,
+ TTL: config.TTL,
+ Values: config.Values,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to upsert DNS record: %w", err)
+ }
+
+ if result.Status == "INSYNC" {
+ output := map[string]any{
+ "change": map[string]any{
+ "id": result.ID,
+ "status": result.Status,
+ "submittedAt": result.SubmittedAt,
+ },
+ "record": map[string]any{
+ "name": config.RecordName,
+ "type": config.RecordType,
+ },
+ }
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ "aws.route53.change",
+ []any{output},
+ )
+ }
+
+ if err := ctx.Metadata.Set(RecordChangePollMetadata{
+ ChangeID: result.ID,
+ RecordName: config.RecordName,
+ RecordType: config.RecordType,
+ SubmittedAt: result.SubmittedAt,
+ }); err != nil {
+ return fmt.Errorf("failed to set poll metadata: %w", err)
+ }
+ return ctx.Requests.ScheduleActionCall(
+ pollChangeActionName,
+ map[string]any{},
+ pollInterval,
+ )
+}
+
+func (c *UpsertRecord) Actions() []core.Action {
+ return []core.Action{
+ {
+ Name: pollChangeActionName,
+ Description: "Poll for change status",
+ },
+ }
+}
+
+func (c *UpsertRecord) HandleAction(ctx core.ActionContext) error {
+ switch ctx.Name {
+ case pollChangeActionName:
+ return pollChangeUntilSynced(ctx)
+ default:
+ return fmt.Errorf("unknown action: %s", ctx.Name)
+ }
+}
+
+func (c *UpsertRecord) HandleWebhook(ctx core.WebhookRequestContext) (int, error) {
+ return http.StatusOK, nil
+}
+
+func (c *UpsertRecord) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (c *UpsertRecord) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (c *UpsertRecord) normalizeConfig(config UpsertRecordConfiguration) UpsertRecordConfiguration {
+ config.HostedZoneID = strings.TrimSpace(config.HostedZoneID)
+ config.RecordName = strings.TrimSpace(config.RecordName)
+ config.RecordType = strings.TrimSpace(config.RecordType)
+ config.Values = normalizeValues(config.Values)
+ return config
+}
diff --git a/pkg/integrations/aws/route53/upsert_record_test.go b/pkg/integrations/aws/route53/upsert_record_test.go
new file mode 100644
index 000000000..a3a7c5b2f
--- /dev/null
+++ b/pkg/integrations/aws/route53/upsert_record_test.go
@@ -0,0 +1,213 @@
+package route53
+
+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 TestUpsertRecord_Setup(t *testing.T) {
+ component := &UpsertRecord{}
+
+ t.Run("invalid configuration -> error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: "invalid",
+ })
+
+ require.ErrorContains(t, err, "failed to decode configuration")
+ })
+
+ t.Run("missing hosted zone -> error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: map[string]any{
+ "recordName": "example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ })
+
+ require.ErrorContains(t, err, "hosted zone is required")
+ })
+
+ t.Run("missing record name -> error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ })
+
+ require.ErrorContains(t, err, "record name is required")
+ })
+
+ t.Run("valid configuration -> no error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "api.example.com",
+ "recordType": "CNAME",
+ "ttl": 60,
+ "values": []string{"lb.example.com"},
+ },
+ })
+
+ require.NoError(t, err)
+ })
+}
+
+func TestUpsertRecord_Execute(t *testing.T) {
+ component := &UpsertRecord{}
+
+ t.Run("invalid configuration -> error", func(t *testing.T) {
+ err := component.Execute(core.ExecutionContext{
+ Configuration: "invalid",
+ ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}},
+ Integration: &contexts.IntegrationContext{},
+ })
+
+ 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{
+ "hostedZoneId": "Z123",
+ "recordName": "example.com",
+ "recordType": "A",
+ "ttl": 300,
+ "values": []string{"1.2.3.4"},
+ },
+ ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}},
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{},
+ },
+ })
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "credentials")
+ })
+
+ t.Run("status PENDING -> schedules poll and does not emit", func(t *testing.T) {
+ xmlResponse := `
+
+
+ /change/C9876543210
+ PENDING
+ 2026-02-13T14:00:00.000Z
+
+`
+
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(xmlResponse)),
+ },
+ },
+ }
+
+ metadata := &contexts.MetadataContext{}
+ requests := &contexts.RequestContext{}
+ execState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "api.example.com",
+ "recordType": "CNAME",
+ "ttl": 60,
+ "values": []string{"lb.example.com"},
+ },
+ ExecutionState: execState,
+ HTTP: httpContext,
+ Metadata: metadata,
+ Requests: requests,
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{
+ "accessKeyId": {Name: "accessKeyId", Value: []byte("key")},
+ "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")},
+ "sessionToken": {Name: "sessionToken", Value: []byte("token")},
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ require.Len(t, execState.Payloads, 0, "should not emit until INSYNC")
+ assert.Equal(t, pollChangeActionName, requests.Action)
+ assert.Equal(t, pollInterval, requests.Duration)
+ stored, ok := metadata.Metadata.(RecordChangePollMetadata)
+ require.True(t, ok)
+ assert.Equal(t, "/change/C9876543210", stored.ChangeID)
+ assert.Equal(t, "api.example.com", stored.RecordName)
+ assert.Equal(t, "CNAME", stored.RecordType)
+ })
+
+ t.Run("status INSYNC -> emits record change immediately", func(t *testing.T) {
+ xmlResponse := `
+
+
+ /change/C9876543210
+ INSYNC
+ 2026-02-13T14:00:00.000Z
+
+`
+
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(xmlResponse)),
+ },
+ },
+ }
+
+ execState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "hostedZoneId": "Z123",
+ "recordName": "api.example.com",
+ "recordType": "CNAME",
+ "ttl": 60,
+ "values": []string{"lb.example.com"},
+ },
+ ExecutionState: execState,
+ HTTP: httpContext,
+ Integration: &contexts.IntegrationContext{
+ Secrets: map[string]core.IntegrationSecret{
+ "accessKeyId": {Name: "accessKeyId", Value: []byte("key")},
+ "secretAccessKey": {Name: "secretAccessKey", Value: []byte("secret")},
+ "sessionToken": {Name: "sessionToken", Value: []byte("token")},
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ require.Len(t, execState.Payloads, 1)
+ require.True(t, execState.Passed)
+ require.Equal(t, "aws.route53.change", execState.Type)
+ payload := execState.Payloads[0].(map[string]any)
+ data, ok := payload["data"].(map[string]any)
+ require.True(t, ok)
+ change, ok := data["change"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "/change/C9876543210", change["id"])
+ assert.Equal(t, "INSYNC", change["status"])
+ assert.Equal(t, "2026-02-13T14:00:00.000Z", change["submittedAt"])
+ })
+}
diff --git a/web_src/src/assets/icons/integrations/aws.route53.svg b/web_src/src/assets/icons/integrations/aws.route53.svg
new file mode 100644
index 000000000..6150693e3
--- /dev/null
+++ b/web_src/src/assets/icons/integrations/aws.route53.svg
@@ -0,0 +1,10 @@
+
+
\ No newline at end of file
diff --git a/web_src/src/pages/workflowv2/mappers/aws/index.ts b/web_src/src/pages/workflowv2/mappers/aws/index.ts
index f7bbdefd8..9503487e0 100644
--- a/web_src/src/pages/workflowv2/mappers/aws/index.ts
+++ b/web_src/src/pages/workflowv2/mappers/aws/index.ts
@@ -15,6 +15,9 @@ import { deleteRepositoryMapper } from "./codeartifact/delete_repository";
import { disposePackageVersionsMapper } from "./codeartifact/dispose_package_versions";
import { updatePackageVersionsStatusMapper } from "./codeartifact/update_package_versions_status";
import { onAlarmTriggerRenderer } from "./cloudwatch/on_alarm";
+import { createRecordMapper } from "./route53/create_record";
+import { upsertRecordMapper } from "./route53/upsert_record";
+import { deleteRecordMapper } from "./route53/delete_record";
import { describeServiceMapper } from "./ecs/describe_service";
import { runTaskMapper } from "./ecs/run_task";
import { stopTaskMapper } from "./ecs/stop_task";
@@ -40,6 +43,9 @@ export const componentMappers: Record = {
"codeArtifact.disposePackageVersions": disposePackageVersionsMapper,
"codeArtifact.getPackageVersion": getPackageVersionMapper,
"codeArtifact.updatePackageVersionsStatus": updatePackageVersionsStatusMapper,
+ "route53.createRecord": createRecordMapper,
+ "route53.upsertRecord": upsertRecordMapper,
+ "route53.deleteRecord": deleteRecordMapper,
"sns.getTopic": getTopicMapper,
"sns.getSubscription": getSubscriptionMapper,
"sns.createTopic": createTopicMapper,
@@ -69,6 +75,9 @@ export const eventStateRegistry: Record = {
"codeArtifact.disposePackageVersions": buildActionStateRegistry("disposed"),
"codeArtifact.getPackageVersion": buildActionStateRegistry("retrieved"),
"codeArtifact.updatePackageVersionsStatus": buildActionStateRegistry("updated"),
+ "route53.createRecord": buildActionStateRegistry("created"),
+ "route53.upsertRecord": buildActionStateRegistry("upserted"),
+ "route53.deleteRecord": buildActionStateRegistry("deleted"),
"sns.getTopic": buildActionStateRegistry("retrieved"),
"sns.getSubscription": buildActionStateRegistry("retrieved"),
"sns.createTopic": buildActionStateRegistry("created"),
diff --git a/web_src/src/pages/workflowv2/mappers/aws/route53/create_record.ts b/web_src/src/pages/workflowv2/mappers/aws/route53/create_record.ts
new file mode 100644
index 000000000..986488804
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/aws/route53/create_record.ts
@@ -0,0 +1,3 @@
+import { recordMapper } from "./record";
+
+export const createRecordMapper = recordMapper;
diff --git a/web_src/src/pages/workflowv2/mappers/aws/route53/delete_record.ts b/web_src/src/pages/workflowv2/mappers/aws/route53/delete_record.ts
new file mode 100644
index 000000000..35824fea1
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/aws/route53/delete_record.ts
@@ -0,0 +1,3 @@
+import { recordMapper } from "./record";
+
+export const deleteRecordMapper = recordMapper;
diff --git a/web_src/src/pages/workflowv2/mappers/aws/route53/record.ts b/web_src/src/pages/workflowv2/mappers/aws/route53/record.ts
new file mode 100644
index 000000000..651ad0914
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/aws/route53/record.ts
@@ -0,0 +1,116 @@
+import {
+ ComponentBaseContext,
+ ComponentBaseMapper,
+ ExecutionDetailsContext,
+ ExecutionInfo,
+ NodeInfo,
+ OutputPayload,
+ SubtitleContext,
+} from "../../types";
+import { ComponentBaseProps, EventSection } from "@/ui/componentBase";
+import { getBackgroundColorClass, getColorClass } from "@/utils/colors";
+import { getState, getStateMap, getTriggerRenderer } from "../..";
+import awsRoute53Icon from "@/assets/icons/integrations/aws.route53.svg";
+import { formatTimeAgo } from "@/utils/date";
+import { MetadataItem } from "@/ui/metadataList";
+import { stringOrDash } from "../../utils";
+
+export interface RecordConfiguration {
+ hostedZoneId?: string;
+ recordName?: string;
+ recordType?: string;
+ ttl?: number;
+}
+
+export interface RecordChangePayload {
+ change?: Change;
+ record?: ResourceRecord;
+}
+
+export interface Change {
+ id?: string;
+ status?: string;
+ submittedAt?: string;
+}
+
+export interface ResourceRecord {
+ name?: string;
+ type?: string;
+}
+
+function recordMetadataList(node: NodeInfo): MetadataItem[] {
+ const config = node.configuration as RecordConfiguration | undefined;
+ const items: MetadataItem[] = [];
+
+ if (config?.recordName) {
+ items.push({ icon: "globe", label: config.recordName });
+ }
+ if (config?.recordType) {
+ items.push({ icon: "tag", label: config.recordType });
+ }
+
+ return items;
+}
+
+function recordEventSections(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 ?? 0),
+ eventTitle: title,
+ eventSubtitle: formatTimeAgo(new Date(execution.createdAt ?? 0)),
+ eventState: getState(componentName)(execution),
+ eventId: execution.rootEvent?.id ?? "",
+ },
+ ];
+}
+
+export const recordMapper: 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: awsRoute53Icon,
+ iconColor: getColorClass(context.componentDefinition.color),
+ collapsedBackground: getBackgroundColorClass(context.componentDefinition.color),
+ collapsed: context.node.isCollapsed,
+ eventSections: lastExecution ? recordEventSections(context.nodes, lastExecution, componentName) : undefined,
+ includeEmptyState: !lastExecution,
+ metadata: recordMetadataList(context.node),
+ eventStateMap: getStateMap(componentName),
+ };
+ },
+
+ getExecutionDetails(context: ExecutionDetailsContext): Record {
+ const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined;
+ const data = outputs?.default?.[0]?.data as RecordChangePayload | undefined;
+
+ if (!data) {
+ return {};
+ }
+
+ return {
+ "Record Name": stringOrDash(data.record?.name),
+ "Record Type": stringOrDash(data.record?.type),
+ "Change ID": stringOrDash(data.change?.id),
+ Status: stringOrDash(data.change?.status),
+ "Submitted At": stringOrDash(data.change?.submittedAt),
+ };
+ },
+
+ subtitle(context: SubtitleContext): string {
+ if (!context.execution.createdAt) {
+ return "";
+ }
+ return formatTimeAgo(new Date(context.execution.createdAt));
+ },
+};
diff --git a/web_src/src/pages/workflowv2/mappers/aws/route53/upsert_record.ts b/web_src/src/pages/workflowv2/mappers/aws/route53/upsert_record.ts
new file mode 100644
index 000000000..9c26bcd28
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/aws/route53/upsert_record.ts
@@ -0,0 +1,3 @@
+import { recordMapper } from "./record";
+
+export const upsertRecordMapper = recordMapper;
diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx
index c202c5610..100134e8a 100644
--- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx
+++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx
@@ -29,6 +29,7 @@ import pagerDutyIcon from "@/assets/icons/integrations/pagerduty.svg";
import slackIcon from "@/assets/icons/integrations/slack.svg";
import awsIcon from "@/assets/icons/integrations/aws.svg";
import awsLambdaIcon from "@/assets/icons/integrations/aws.lambda.svg";
+import awsRoute53Icon from "@/assets/icons/integrations/aws.route53.svg";
import awsEcrIcon from "@/assets/icons/integrations/aws.ecr.svg";
import awsEcsIcon from "@/assets/icons/integrations/aws.ecs.svg";
import awsCodeArtifactIcon from "@/assets/icons/integrations/aws.codeartifact.svg";
@@ -427,6 +428,7 @@ function CategorySection({
cloudwatch: awsCloudwatchIcon,
lambda: awsLambdaIcon,
ecr: awsEcrIcon,
+ route53: awsRoute53Icon,
ecs: awsEcsIcon,
sns: awsSnsIcon,
},
@@ -507,6 +509,7 @@ function CategorySection({
cloudwatch: awsCloudwatchIcon,
ecr: awsEcrIcon,
lambda: awsLambdaIcon,
+ route53: awsRoute53Icon,
ecs: awsEcsIcon,
sns: awsSnsIcon,
},
diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx
index 43ccb0f9f..aa6f4bbd1 100644
--- a/web_src/src/ui/componentSidebar/integrationIcons.tsx
+++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx
@@ -5,6 +5,7 @@ import awsLambdaIcon from "@/assets/icons/integrations/aws.lambda.svg";
import awsEcsIcon from "@/assets/icons/integrations/aws.ecs.svg";
import circleciIcon from "@/assets/icons/integrations/circleci.svg";
import awsCloudwatchIcon from "@/assets/icons/integrations/aws.cloudwatch.svg";
+import awsRoute53Icon from "@/assets/icons/integrations/aws.route53.svg";
import awsSnsIcon from "@/assets/icons/integrations/aws.sns.svg";
import cloudflareIcon from "@/assets/icons/integrations/cloudflare.svg";
import dash0Icon from "@/assets/icons/integrations/dash0.svg";
@@ -83,6 +84,7 @@ export const APP_LOGO_MAP: Record> = {
aws: {
cloudwatch: awsCloudwatchIcon,
lambda: awsLambdaIcon,
+ route53: awsRoute53Icon,
ecs: awsEcsIcon,
sns: awsSnsIcon,
},