From 6be48e11acc934ac0f3612b0b45d30a2507b10cb Mon Sep 17 00:00:00 2001 From: yinebebt Date: Fri, 13 Feb 2026 11:53:12 +0300 Subject: [PATCH 1/5] Add AWS Route53 DNS record management components Adds Create, Upsert, and Delete DNS record components for the AWS Route53 integration, enabling users to manage DNS records within existing hosted zones as part of SuperPlane workflows. Signed-off-by: yinebebt --- docs/components/AWS.mdx | 94 +++++++ pkg/integrations/aws/aws.go | 4 + pkg/integrations/aws/resources.go | 4 + pkg/integrations/aws/route53/client.go | 264 ++++++++++++++++++ pkg/integrations/aws/route53/common.go | 149 ++++++++++ pkg/integrations/aws/route53/create_record.go | 160 +++++++++++ .../aws/route53/create_record_test.go | 239 ++++++++++++++++ pkg/integrations/aws/route53/delete_record.go | 161 +++++++++++ .../aws/route53/delete_record_test.go | 205 ++++++++++++++ pkg/integrations/aws/route53/example.go | 50 ++++ .../route53/example_output_create_record.json | 7 + .../route53/example_output_delete_record.json | 7 + .../route53/example_output_upsert_record.json | 7 + pkg/integrations/aws/route53/resources.go | 32 +++ pkg/integrations/aws/route53/upsert_record.go | 160 +++++++++++ .../aws/route53/upsert_record_test.go | 164 +++++++++++ .../assets/icons/integrations/aws.route53.svg | 10 + .../src/pages/workflowv2/mappers/aws/index.ts | 9 + .../mappers/aws/route53/create_record.ts | 108 +++++++ .../mappers/aws/route53/delete_record.ts | 108 +++++++ .../mappers/aws/route53/upsert_record.ts | 108 +++++++ .../ui/componentSidebar/integrationIcons.tsx | 2 + 22 files changed, 2052 insertions(+) create mode 100644 pkg/integrations/aws/route53/client.go create mode 100644 pkg/integrations/aws/route53/common.go create mode 100644 pkg/integrations/aws/route53/create_record.go create mode 100644 pkg/integrations/aws/route53/create_record_test.go create mode 100644 pkg/integrations/aws/route53/delete_record.go create mode 100644 pkg/integrations/aws/route53/delete_record_test.go create mode 100644 pkg/integrations/aws/route53/example.go create mode 100644 pkg/integrations/aws/route53/example_output_create_record.json create mode 100644 pkg/integrations/aws/route53/example_output_delete_record.json create mode 100644 pkg/integrations/aws/route53/example_output_upsert_record.json create mode 100644 pkg/integrations/aws/route53/resources.go create mode 100644 pkg/integrations/aws/route53/upsert_record.go create mode 100644 pkg/integrations/aws/route53/upsert_record_test.go create mode 100644 web_src/src/assets/icons/integrations/aws.route53.svg create mode 100644 web_src/src/pages/workflowv2/mappers/aws/route53/create_record.ts create mode 100644 web_src/src/pages/workflowv2/mappers/aws/route53/delete_record.ts create mode 100644 web_src/src/pages/workflowv2/mappers/aws/route53/upsert_record.ts diff --git a/docs/components/AWS.mdx b/docs/components/AWS.mdx index 5f821fe4cd..ab4029159e 100644 --- a/docs/components/AWS.mdx +++ b/docs/components/AWS.mdx @@ -30,6 +30,9 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + + + @@ -755,6 +758,97 @@ 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 +{ + "changeId": "/change/C1234567890ABC", + "recordName": "api.example.com", + "recordType": "A", + "status": "PENDING", + "submittedAt": "2026-01-28T10:30:00.000Z" +} +``` + + + +## 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 +{ + "changeId": "/change/C5555555555GHI", + "recordName": "old.example.com", + "recordType": "A", + "status": "PENDING", + "submittedAt": "2026-01-28T10:30:00.000Z" +} +``` + + + +## 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 +{ + "changeId": "/change/C9876543210DEF", + "recordName": "app.example.com", + "recordType": "CNAME", + "status": "PENDING", + "submittedAt": "2026-01-28T10:30:00.000Z" +} +``` + ## SNS • Create Topic diff --git a/pkg/integrations/aws/aws.go b/pkg/integrations/aws/aws.go index cec64c6c56..8354b65486 100644 --- a/pkg/integrations/aws/aws.go +++ b/pkg/integrations/aws/aws.go @@ -23,6 +23,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" ) @@ -148,6 +149,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 2b29f97732..290b678d2f 100644 --- a/pkg/integrations/aws/resources.go +++ b/pkg/integrations/aws/resources.go @@ -5,6 +5,7 @@ import ( "github.com/superplanehq/superplane/pkg/integrations/aws/codeartifact" "github.com/superplanehq/superplane/pkg/integrations/aws/ecr" "github.com/superplanehq/superplane/pkg/integrations/aws/lambda" + "github.com/superplanehq/superplane/pkg/integrations/aws/route53" "github.com/superplanehq/superplane/pkg/integrations/aws/sns" ) @@ -22,6 +23,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 0000000000..b95f9ff1fd --- /dev/null +++ b/pkg/integrations/aws/route53/client.go @@ -0,0 +1,264 @@ +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 +} + +// 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()) +} + +func parseError(body []byte) *common.Error { + var payload struct { + Error struct { + Code string `xml:"Code"` + Message string `xml:"Message"` + } `xml:"Error"` + } + + if err := xml.Unmarshal(body, &payload); err != nil { + return nil + } + + if payload.Error.Code == "" && payload.Error.Message == "" { + return nil + } + + return &common.Error{ + Code: strings.TrimSpace(payload.Error.Code), + Message: strings.TrimSpace(payload.Error.Message), + } +} + +/* + * 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"` +} diff --git a/pkg/integrations/aws/route53/common.go b/pkg/integrations/aws/route53/common.go new file mode 100644 index 0000000000..6728d4e5ec --- /dev/null +++ b/pkg/integrations/aws/route53/common.go @@ -0,0 +1,149 @@ +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"` +} + +// 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 0000000000..efd54119fd --- /dev/null +++ b/pkg/integrations/aws/route53/create_record.go @@ -0,0 +1,160 @@ +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"` +} + +type CreateRecordMetadata struct { + HostedZoneID string `json:"hostedZoneId" mapstructure:"hostedZoneId"` + RecordName string `json:"recordName" mapstructure:"recordName"` +} + +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) + if err := validateRecordConfiguration(config.HostedZoneID, config.RecordName, config.RecordType, config.Values); err != nil { + return err + } + + return ctx.Metadata.Set(CreateRecordMetadata{ + HostedZoneID: config.HostedZoneID, + RecordName: config.RecordName, + }) +} + +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) + } + + output := map[string]any{ + "changeId": result.ID, + "status": result.Status, + "submittedAt": result.SubmittedAt, + "recordName": config.RecordName, + "recordType": config.RecordType, + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "aws.route53.record", + []any{output}, + ) +} + +func (c *CreateRecord) Actions() []core.Action { + return []core.Action{} +} + +func (c *CreateRecord) HandleAction(ctx core.ActionContext) error { + return nil +} + +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 0000000000..fc44e9ac48 --- /dev/null +++ b/pkg/integrations/aws/route53/create_record_test.go @@ -0,0 +1,239 @@ +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 -> stores metadata", func(t *testing.T) { + metadata := &contexts.MetadataContext{} + err := component.Setup(core.SetupContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: metadata, + Configuration: map[string]any{ + "hostedZoneId": " Z123 ", + "recordName": " example.com ", + "recordType": "A", + "ttl": 300, + "values": []string{"1.2.3.4"}, + }, + }) + + require.NoError(t, err) + stored, ok := metadata.Metadata.(CreateRecordMetadata) + require.True(t, ok) + assert.Equal(t, "Z123", stored.HostedZoneID) + assert.Equal(t, "example.com", stored.RecordName) + }) +} + +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("success -> emits record change", 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)), + }, + }, + } + + 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.record", execState.Type) + + payload := execState.Payloads[0].(map[string]any) + data, ok := payload["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "/change/C1234567890", data["changeId"]) + assert.Equal(t, "PENDING", data["status"]) + assert.Equal(t, "example.com", data["recordName"]) + assert.Equal(t, "A", data["recordType"]) + + require.Len(t, httpContext.Requests, 1) + assert.Contains(t, httpContext.Requests[0].URL.String(), "route53.amazonaws.com") + assert.Contains(t, httpContext.Requests[0].URL.String(), "hostedzone/Z123/rrset") + }) + + t.Run("API error -> returns error", func(t *testing.T) { + xmlError := ` + + + InvalidChangeBatch + Tried to create resource record set but it already exists + +` + + 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": "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/delete_record.go b/pkg/integrations/aws/route53/delete_record.go new file mode 100644 index 0000000000..77c0d81fe9 --- /dev/null +++ b/pkg/integrations/aws/route53/delete_record.go @@ -0,0 +1,161 @@ +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"` +} + +type DeleteRecordMetadata struct { + HostedZoneID string `json:"hostedZoneId" mapstructure:"hostedZoneId"` + RecordName string `json:"recordName" mapstructure:"recordName"` +} + +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) + if err := validateRecordConfiguration(config.HostedZoneID, config.RecordName, config.RecordType, config.Values); err != nil { + return err + } + + return ctx.Metadata.Set(DeleteRecordMetadata{ + HostedZoneID: config.HostedZoneID, + RecordName: config.RecordName, + }) +} + +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) + } + + output := map[string]any{ + "changeId": result.ID, + "status": result.Status, + "submittedAt": result.SubmittedAt, + "recordName": config.RecordName, + "recordType": config.RecordType, + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "aws.route53.record", + []any{output}, + ) +} + +func (c *DeleteRecord) Actions() []core.Action { + return []core.Action{} +} + +func (c *DeleteRecord) HandleAction(ctx core.ActionContext) error { + return nil +} + +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 0000000000..a24135e91f --- /dev/null +++ b/pkg/integrations/aws/route53/delete_record_test.go @@ -0,0 +1,205 @@ +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 -> stores metadata", func(t *testing.T) { + metadata := &contexts.MetadataContext{} + err := component.Setup(core.SetupContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: metadata, + Configuration: map[string]any{ + "hostedZoneId": "Z123", + "recordName": "old.example.com", + "recordType": "A", + "ttl": 300, + "values": []string{"1.2.3.4"}, + }, + }) + + require.NoError(t, err) + stored, ok := metadata.Metadata.(DeleteRecordMetadata) + require.True(t, ok) + assert.Equal(t, "Z123", stored.HostedZoneID) + assert.Equal(t, "old.example.com", stored.RecordName) + }) +} + +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("success -> emits record change", 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)), + }, + }, + } + + 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.record", execState.Type) + + payload := execState.Payloads[0].(map[string]any) + data, ok := payload["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "/change/C5555555555", data["changeId"]) + assert.Equal(t, "PENDING", data["status"]) + assert.Equal(t, "old.example.com", data["recordName"]) + assert.Equal(t, "A", data["recordType"]) + }) + + 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 0000000000..68b1409d91 --- /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 0000000000..fd67bee261 --- /dev/null +++ b/pkg/integrations/aws/route53/example_output_create_record.json @@ -0,0 +1,7 @@ +{ + "changeId": "/change/C1234567890ABC", + "status": "PENDING", + "submittedAt": "2026-01-28T10:30:00.000Z", + "recordName": "api.example.com", + "recordType": "A" +} 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 0000000000..75537836f3 --- /dev/null +++ b/pkg/integrations/aws/route53/example_output_delete_record.json @@ -0,0 +1,7 @@ +{ + "changeId": "/change/C5555555555GHI", + "status": "PENDING", + "submittedAt": "2026-01-28T10:30:00.000Z", + "recordName": "old.example.com", + "recordType": "A" +} 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 0000000000..4f844a977c --- /dev/null +++ b/pkg/integrations/aws/route53/example_output_upsert_record.json @@ -0,0 +1,7 @@ +{ + "changeId": "/change/C9876543210DEF", + "status": "PENDING", + "submittedAt": "2026-01-28T10:30:00.000Z", + "recordName": "app.example.com", + "recordType": "CNAME" +} diff --git a/pkg/integrations/aws/route53/resources.go b/pkg/integrations/aws/route53/resources.go new file mode 100644 index 0000000000..501074652b --- /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 0000000000..bf6a8f0af3 --- /dev/null +++ b/pkg/integrations/aws/route53/upsert_record.go @@ -0,0 +1,160 @@ +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"` +} + +type UpsertRecordMetadata struct { + HostedZoneID string `json:"hostedZoneId" mapstructure:"hostedZoneId"` + RecordName string `json:"recordName" mapstructure:"recordName"` +} + +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) + if err := validateRecordConfiguration(config.HostedZoneID, config.RecordName, config.RecordType, config.Values); err != nil { + return err + } + + return ctx.Metadata.Set(UpsertRecordMetadata{ + HostedZoneID: config.HostedZoneID, + RecordName: config.RecordName, + }) +} + +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) + } + + output := map[string]any{ + "changeId": result.ID, + "status": result.Status, + "submittedAt": result.SubmittedAt, + "recordName": config.RecordName, + "recordType": config.RecordType, + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "aws.route53.record", + []any{output}, + ) +} + +func (c *UpsertRecord) Actions() []core.Action { + return []core.Action{} +} + +func (c *UpsertRecord) HandleAction(ctx core.ActionContext) error { + return nil +} + +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 0000000000..fe79886505 --- /dev/null +++ b/pkg/integrations/aws/route53/upsert_record_test.go @@ -0,0 +1,164 @@ +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 -> stores metadata", func(t *testing.T) { + metadata := &contexts.MetadataContext{} + err := component.Setup(core.SetupContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: metadata, + Configuration: map[string]any{ + "hostedZoneId": "Z123", + "recordName": "api.example.com", + "recordType": "CNAME", + "ttl": 60, + "values": []string{"lb.example.com"}, + }, + }) + + require.NoError(t, err) + stored, ok := metadata.Metadata.(UpsertRecordMetadata) + require.True(t, ok) + assert.Equal(t, "Z123", stored.HostedZoneID) + assert.Equal(t, "api.example.com", stored.RecordName) + }) +} + +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("success -> emits record change", 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)), + }, + }, + } + + 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.record", execState.Type) + + payload := execState.Payloads[0].(map[string]any) + data, ok := payload["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "/change/C9876543210", data["changeId"]) + assert.Equal(t, "PENDING", data["status"]) + assert.Equal(t, "api.example.com", data["recordName"]) + assert.Equal(t, "CNAME", data["recordType"]) + }) +} 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 0000000000..07ea536b5d --- /dev/null +++ b/web_src/src/assets/icons/integrations/aws.route53.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/web_src/src/pages/workflowv2/mappers/aws/index.ts b/web_src/src/pages/workflowv2/mappers/aws/index.ts index 215540d6e8..756f33e781 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 { onTopicMessageTriggerRenderer } from "./sns/on_topic_message"; import { createTopicMapper } from "./sns/create_topic"; import { deleteTopicMapper } from "./sns/delete_topic"; @@ -34,6 +37,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, @@ -60,6 +66,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 0000000000..5cb6dda82a --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/aws/route53/create_record.ts @@ -0,0 +1,108 @@ +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"; + +interface RecordConfiguration { + hostedZoneId?: string; + recordName?: string; + recordType?: string; + ttl?: number; +} + +interface RecordChangePayload { + changeId?: string; + status?: string; + submittedAt?: string; + recordName?: string; + recordType?: string; +} + +export const createRecordMapper: 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.recordName), + "Record Type": stringOrDash(data.recordType), + "Change ID": stringOrDash(data.changeId), + Status: stringOrDash(data.status), + "Submitted At": stringOrDash(data.submittedAt), + }; + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) { + return ""; + } + return formatTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +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 ?? "", + }, + ]; +} 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 0000000000..54e4dfd2de --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/aws/route53/delete_record.ts @@ -0,0 +1,108 @@ +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"; + +interface RecordConfiguration { + hostedZoneId?: string; + recordName?: string; + recordType?: string; + ttl?: number; +} + +interface RecordChangePayload { + changeId?: string; + status?: string; + submittedAt?: string; + recordName?: string; + recordType?: string; +} + +export const deleteRecordMapper: 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.recordName), + "Record Type": stringOrDash(data.recordType), + "Change ID": stringOrDash(data.changeId), + Status: stringOrDash(data.status), + "Submitted At": stringOrDash(data.submittedAt), + }; + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) { + return ""; + } + return formatTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +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 ?? "", + }, + ]; +} 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 0000000000..bf131341f6 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/aws/route53/upsert_record.ts @@ -0,0 +1,108 @@ +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"; + +interface RecordConfiguration { + hostedZoneId?: string; + recordName?: string; + recordType?: string; + ttl?: number; +} + +interface RecordChangePayload { + changeId?: string; + status?: string; + submittedAt?: string; + recordName?: string; + recordType?: string; +} + +export const upsertRecordMapper: 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.recordName), + "Record Type": stringOrDash(data.recordType), + "Change ID": stringOrDash(data.changeId), + Status: stringOrDash(data.status), + "Submitted At": stringOrDash(data.submittedAt), + }; + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) { + return ""; + } + return formatTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +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 ?? "", + }, + ]; +} diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx index 23f8305436..27187441f1 100644 --- a/web_src/src/ui/componentSidebar/integrationIcons.tsx +++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx @@ -4,6 +4,7 @@ import awsIcon from "@/assets/icons/integrations/aws.svg"; import awsLambdaIcon from "@/assets/icons/integrations/aws.lambda.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"; @@ -82,6 +83,7 @@ export const APP_LOGO_MAP: Record> = { aws: { cloudwatch: awsCloudwatchIcon, lambda: awsLambdaIcon, + route53: awsRoute53Icon, sns: awsSnsIcon, }, }; From e9651e7290bd799459b88ab4d67884ad9fef81f9 Mon Sep 17 00:00:00 2001 From: yinebebt Date: Fri, 13 Feb 2026 20:45:26 +0300 Subject: [PATCH 2/5] fix: poll until INSYNC, parse InvalidChangeBatch and consolidate mappers - Poll until change status is INSYNC - Parse InvalidChangeBatch XML error format in parseError - Consolidate create/upsert/delete mappers to one shared record.ts Signed-off-by: yinebebt --- pkg/integrations/aws/route53/client.go | 88 ++++++- pkg/integrations/aws/route53/common.go | 8 + pkg/integrations/aws/route53/create_record.go | 49 +++- .../aws/route53/create_record_test.go | 224 +++++++++++++++++- pkg/integrations/aws/route53/delete_record.go | 49 +++- .../aws/route53/delete_record_test.go | 59 ++++- pkg/integrations/aws/route53/poll.go | 58 +++++ pkg/integrations/aws/route53/upsert_record.go | 49 +++- .../aws/route53/upsert_record_test.go | 59 ++++- .../mappers/aws/route53/create_record.ts | 109 +-------- .../mappers/aws/route53/delete_record.ts | 109 +-------- .../workflowv2/mappers/aws/route53/record.ts | 108 +++++++++ .../mappers/aws/route53/upsert_record.ts | 109 +-------- 13 files changed, 694 insertions(+), 384 deletions(-) create mode 100644 pkg/integrations/aws/route53/poll.go create mode 100644 web_src/src/pages/workflowv2/mappers/aws/route53/record.ts diff --git a/pkg/integrations/aws/route53/client.go b/pkg/integrations/aws/route53/client.go index b95f9ff1fd..b83c339b27 100644 --- a/pkg/integrations/aws/route53/client.go +++ b/pkg/integrations/aws/route53/client.go @@ -112,6 +112,57 @@ func (c *Client) ChangeResourceRecordSets(hostedZoneID, action string, recordSet }, 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 @@ -177,26 +228,37 @@ func (c *Client) signRequest(req *http.Request, payload []byte) error { 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 { - var payload struct { + // 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, &payload); err != nil { - return nil + 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), + } } - if payload.Error.Code == "" && payload.Error.Message == "" { - return nil + // InvalidChangeBatch format (e.g. "RRSet with DNS name X is not permitted in zone Y"). + var invalidBatch struct { + Messages []string `xml:"Messages>Message"` } - - return &common.Error{ - Code: strings.TrimSpace(payload.Error.Code), - Message: strings.TrimSpace(payload.Error.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 } /* @@ -262,3 +324,9 @@ 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 index 6728d4e5ec..c1ff499f8a 100644 --- a/pkg/integrations/aws/route53/common.go +++ b/pkg/integrations/aws/route53/common.go @@ -14,6 +14,14 @@ type ChangeInfo struct { 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"` diff --git a/pkg/integrations/aws/route53/create_record.go b/pkg/integrations/aws/route53/create_record.go index efd54119fd..a1bed4992a 100644 --- a/pkg/integrations/aws/route53/create_record.go +++ b/pkg/integrations/aws/route53/create_record.go @@ -116,27 +116,52 @@ func (c *CreateRecord) Execute(ctx core.ExecutionContext) error { return fmt.Errorf("failed to create DNS record: %w", err) } - output := map[string]any{ - "changeId": result.ID, - "status": result.Status, - "submittedAt": result.SubmittedAt, - "recordName": config.RecordName, - "recordType": config.RecordType, + if result.Status == "INSYNC" { + output := map[string]any{ + "changeId": result.ID, + "status": result.Status, + "submittedAt": result.SubmittedAt, + "recordName": config.RecordName, + "recordType": config.RecordType, + } + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "aws.route53.record", + []any{output}, + ) } - return ctx.ExecutionState.Emit( - core.DefaultOutputChannel.Name, - "aws.route53.record", - []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{} + return []core.Action{ + { + Name: pollChangeActionName, + Description: "Poll for change status", + }, + } } func (c *CreateRecord) HandleAction(ctx core.ActionContext) error { - return nil + 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) { diff --git a/pkg/integrations/aws/route53/create_record_test.go b/pkg/integrations/aws/route53/create_record_test.go index fc44e9ac48..d08756bb4c 100644 --- a/pkg/integrations/aws/route53/create_record_test.go +++ b/pkg/integrations/aws/route53/create_record_test.go @@ -139,7 +139,7 @@ func TestCreateRecord_Execute(t *testing.T) { require.Contains(t, err.Error(), "credentials") }) - t.Run("success -> emits record change", func(t *testing.T) { + t.Run("status PENDING -> schedules poll and does not emit", func(t *testing.T) { xmlResponse := ` @@ -158,6 +158,63 @@ func TestCreateRecord_Execute(t *testing.T) { }, } + 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{ @@ -182,33 +239,28 @@ func TestCreateRecord_Execute(t *testing.T) { require.Len(t, execState.Payloads, 1) require.True(t, execState.Passed) require.Equal(t, "aws.route53.record", execState.Type) - payload := execState.Payloads[0].(map[string]any) data, ok := payload["data"].(map[string]any) require.True(t, ok) assert.Equal(t, "/change/C1234567890", data["changeId"]) - assert.Equal(t, "PENDING", data["status"]) + assert.Equal(t, "INSYNC", data["status"]) assert.Equal(t, "example.com", data["recordName"]) assert.Equal(t, "A", data["recordType"]) - - require.Len(t, httpContext.Requests, 1) - assert.Contains(t, httpContext.Requests[0].URL.String(), "route53.amazonaws.com") - assert.Contains(t, httpContext.Requests[0].URL.String(), "hostedzone/Z123/rrset") }) - t.Run("API error -> returns error", func(t *testing.T) { + t.Run("API error ErrorResponse format -> returns error", func(t *testing.T) { xmlError := ` - InvalidChangeBatch - Tried to create resource record set but it already exists + AccessDenied + User is not authorized ` httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ { - StatusCode: http.StatusBadRequest, + StatusCode: http.StatusForbidden, Body: io.NopCloser(strings.NewReader(xmlError)), }, }, @@ -233,7 +285,157 @@ func TestCreateRecord_Execute(t *testing.T) { }, }) + 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.record", execState.Type) + payload := execState.Payloads[0].(map[string]any) + data, ok := payload["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "/change/C1234567890", data["changeId"]) + assert.Equal(t, "INSYNC", data["status"]) + assert.Equal(t, "example.com", data["recordName"]) + assert.Equal(t, "A", data["recordType"]) }) } diff --git a/pkg/integrations/aws/route53/delete_record.go b/pkg/integrations/aws/route53/delete_record.go index 77c0d81fe9..208665ee83 100644 --- a/pkg/integrations/aws/route53/delete_record.go +++ b/pkg/integrations/aws/route53/delete_record.go @@ -117,27 +117,52 @@ func (c *DeleteRecord) Execute(ctx core.ExecutionContext) error { return fmt.Errorf("failed to delete DNS record: %w", err) } - output := map[string]any{ - "changeId": result.ID, - "status": result.Status, - "submittedAt": result.SubmittedAt, - "recordName": config.RecordName, - "recordType": config.RecordType, + if result.Status == "INSYNC" { + output := map[string]any{ + "changeId": result.ID, + "status": result.Status, + "submittedAt": result.SubmittedAt, + "recordName": config.RecordName, + "recordType": config.RecordType, + } + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "aws.route53.record", + []any{output}, + ) } - return ctx.ExecutionState.Emit( - core.DefaultOutputChannel.Name, - "aws.route53.record", - []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{} + return []core.Action{ + { + Name: pollChangeActionName, + Description: "Poll for change status", + }, + } } func (c *DeleteRecord) HandleAction(ctx core.ActionContext) error { - return nil + 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) { diff --git a/pkg/integrations/aws/route53/delete_record_test.go b/pkg/integrations/aws/route53/delete_record_test.go index a24135e91f..1d0ad2815b 100644 --- a/pkg/integrations/aws/route53/delete_record_test.go +++ b/pkg/integrations/aws/route53/delete_record_test.go @@ -109,7 +109,7 @@ func TestDeleteRecord_Execute(t *testing.T) { require.Contains(t, err.Error(), "credentials") }) - t.Run("success -> emits record change", func(t *testing.T) { + t.Run("status PENDING -> schedules poll and does not emit", func(t *testing.T) { xmlResponse := ` @@ -128,6 +128,60 @@ func TestDeleteRecord_Execute(t *testing.T) { }, } + 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{ @@ -152,12 +206,11 @@ func TestDeleteRecord_Execute(t *testing.T) { require.Len(t, execState.Payloads, 1) require.True(t, execState.Passed) require.Equal(t, "aws.route53.record", execState.Type) - payload := execState.Payloads[0].(map[string]any) data, ok := payload["data"].(map[string]any) require.True(t, ok) assert.Equal(t, "/change/C5555555555", data["changeId"]) - assert.Equal(t, "PENDING", data["status"]) + assert.Equal(t, "INSYNC", data["status"]) assert.Equal(t, "old.example.com", data["recordName"]) assert.Equal(t, "A", data["recordType"]) }) diff --git a/pkg/integrations/aws/route53/poll.go b/pkg/integrations/aws/route53/poll.go new file mode 100644 index 0000000000..7a72aa1c2a --- /dev/null +++ b/pkg/integrations/aws/route53/poll.go @@ -0,0 +1,58 @@ +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) + } + + output := map[string]any{ + "changeId": change.ID, + "status": change.Status, + "submittedAt": meta.SubmittedAt, + "recordName": meta.RecordName, + "recordType": meta.RecordType, + } + + if change.Status == "INSYNC" { + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "aws.route53.record", + []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/upsert_record.go b/pkg/integrations/aws/route53/upsert_record.go index bf6a8f0af3..a23e98c7ee 100644 --- a/pkg/integrations/aws/route53/upsert_record.go +++ b/pkg/integrations/aws/route53/upsert_record.go @@ -116,27 +116,52 @@ func (c *UpsertRecord) Execute(ctx core.ExecutionContext) error { return fmt.Errorf("failed to upsert DNS record: %w", err) } - output := map[string]any{ - "changeId": result.ID, - "status": result.Status, - "submittedAt": result.SubmittedAt, - "recordName": config.RecordName, - "recordType": config.RecordType, + if result.Status == "INSYNC" { + output := map[string]any{ + "changeId": result.ID, + "status": result.Status, + "submittedAt": result.SubmittedAt, + "recordName": config.RecordName, + "recordType": config.RecordType, + } + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "aws.route53.record", + []any{output}, + ) } - return ctx.ExecutionState.Emit( - core.DefaultOutputChannel.Name, - "aws.route53.record", - []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{} + return []core.Action{ + { + Name: pollChangeActionName, + Description: "Poll for change status", + }, + } } func (c *UpsertRecord) HandleAction(ctx core.ActionContext) error { - return nil + 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) { diff --git a/pkg/integrations/aws/route53/upsert_record_test.go b/pkg/integrations/aws/route53/upsert_record_test.go index fe79886505..2c4e5eca89 100644 --- a/pkg/integrations/aws/route53/upsert_record_test.go +++ b/pkg/integrations/aws/route53/upsert_record_test.go @@ -109,7 +109,7 @@ func TestUpsertRecord_Execute(t *testing.T) { require.Contains(t, err.Error(), "credentials") }) - t.Run("success -> emits record change", func(t *testing.T) { + t.Run("status PENDING -> schedules poll and does not emit", func(t *testing.T) { xmlResponse := ` @@ -128,6 +128,60 @@ func TestUpsertRecord_Execute(t *testing.T) { }, } + 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{ @@ -152,12 +206,11 @@ func TestUpsertRecord_Execute(t *testing.T) { require.Len(t, execState.Payloads, 1) require.True(t, execState.Passed) require.Equal(t, "aws.route53.record", execState.Type) - payload := execState.Payloads[0].(map[string]any) data, ok := payload["data"].(map[string]any) require.True(t, ok) assert.Equal(t, "/change/C9876543210", data["changeId"]) - assert.Equal(t, "PENDING", data["status"]) + assert.Equal(t, "INSYNC", data["status"]) assert.Equal(t, "api.example.com", data["recordName"]) assert.Equal(t, "CNAME", data["recordType"]) }) 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 index 5cb6dda82a..9864888042 100644 --- a/web_src/src/pages/workflowv2/mappers/aws/route53/create_record.ts +++ b/web_src/src/pages/workflowv2/mappers/aws/route53/create_record.ts @@ -1,108 +1,3 @@ -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"; +import { recordMapper } from "./record"; -interface RecordConfiguration { - hostedZoneId?: string; - recordName?: string; - recordType?: string; - ttl?: number; -} - -interface RecordChangePayload { - changeId?: string; - status?: string; - submittedAt?: string; - recordName?: string; - recordType?: string; -} - -export const createRecordMapper: 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.recordName), - "Record Type": stringOrDash(data.recordType), - "Change ID": stringOrDash(data.changeId), - Status: stringOrDash(data.status), - "Submitted At": stringOrDash(data.submittedAt), - }; - }, - - subtitle(context: SubtitleContext): string { - if (!context.execution.createdAt) { - return ""; - } - return formatTimeAgo(new Date(context.execution.createdAt)); - }, -}; - -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 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 index 54e4dfd2de..35824fea12 100644 --- a/web_src/src/pages/workflowv2/mappers/aws/route53/delete_record.ts +++ b/web_src/src/pages/workflowv2/mappers/aws/route53/delete_record.ts @@ -1,108 +1,3 @@ -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"; +import { recordMapper } from "./record"; -interface RecordConfiguration { - hostedZoneId?: string; - recordName?: string; - recordType?: string; - ttl?: number; -} - -interface RecordChangePayload { - changeId?: string; - status?: string; - submittedAt?: string; - recordName?: string; - recordType?: string; -} - -export const deleteRecordMapper: 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.recordName), - "Record Type": stringOrDash(data.recordType), - "Change ID": stringOrDash(data.changeId), - Status: stringOrDash(data.status), - "Submitted At": stringOrDash(data.submittedAt), - }; - }, - - subtitle(context: SubtitleContext): string { - if (!context.execution.createdAt) { - return ""; - } - return formatTimeAgo(new Date(context.execution.createdAt)); - }, -}; - -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 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 0000000000..af57318b1f --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/aws/route53/record.ts @@ -0,0 +1,108 @@ +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 { + changeId?: string; + status?: string; + submittedAt?: string; + recordName?: string; + recordType?: 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.recordName), + "Record Type": stringOrDash(data.recordType), + "Change ID": stringOrDash(data.changeId), + Status: stringOrDash(data.status), + "Submitted At": stringOrDash(data.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 index bf131341f6..9c26bcd288 100644 --- a/web_src/src/pages/workflowv2/mappers/aws/route53/upsert_record.ts +++ b/web_src/src/pages/workflowv2/mappers/aws/route53/upsert_record.ts @@ -1,108 +1,3 @@ -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"; +import { recordMapper } from "./record"; -interface RecordConfiguration { - hostedZoneId?: string; - recordName?: string; - recordType?: string; - ttl?: number; -} - -interface RecordChangePayload { - changeId?: string; - status?: string; - submittedAt?: string; - recordName?: string; - recordType?: string; -} - -export const upsertRecordMapper: 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.recordName), - "Record Type": stringOrDash(data.recordType), - "Change ID": stringOrDash(data.changeId), - Status: stringOrDash(data.status), - "Submitted At": stringOrDash(data.submittedAt), - }; - }, - - subtitle(context: SubtitleContext): string { - if (!context.execution.createdAt) { - return ""; - } - return formatTimeAgo(new Date(context.execution.createdAt)); - }, -}; - -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 upsertRecordMapper = recordMapper; From c4b135f2fb743c7fa19d2fc86005569eb3fc5c8a Mon Sep 17 00:00:00 2001 From: Lucas Pinheiro Date: Mon, 16 Feb 2026 18:29:01 -0300 Subject: [PATCH 3/5] small polish Signed-off-by: Lucas Pinheiro --- pkg/integrations/aws/route53/create_record.go | 30 +++++++------------ .../aws/route53/create_record_test.go | 29 ++++++++---------- pkg/integrations/aws/route53/delete_record.go | 30 +++++++------------ .../aws/route53/delete_record_test.go | 20 +++++-------- .../route53/example_output_create_record.json | 18 +++++++---- .../route53/example_output_delete_record.json | 18 +++++++---- .../route53/example_output_upsert_record.json | 18 +++++++---- pkg/integrations/aws/route53/poll.go | 21 +++++++++---- pkg/integrations/aws/route53/upsert_record.go | 30 +++++++------------ .../aws/route53/upsert_record_test.go | 20 +++++-------- .../assets/icons/integrations/aws.route53.svg | 20 ++++++------- .../workflowv2/mappers/aws/route53/record.ts | 24 ++++++++++----- .../src/ui/BuildingBlocksSidebar/index.tsx | 3 ++ 13 files changed, 145 insertions(+), 136 deletions(-) diff --git a/pkg/integrations/aws/route53/create_record.go b/pkg/integrations/aws/route53/create_record.go index a1bed4992a..8402ec3ca7 100644 --- a/pkg/integrations/aws/route53/create_record.go +++ b/pkg/integrations/aws/route53/create_record.go @@ -22,11 +22,6 @@ type CreateRecordConfiguration struct { Values []string `json:"values" mapstructure:"values"` } -type CreateRecordMetadata struct { - HostedZoneID string `json:"hostedZoneId" mapstructure:"hostedZoneId"` - RecordName string `json:"recordName" mapstructure:"recordName"` -} - func (c *CreateRecord) Name() string { return "aws.route53.createRecord" } @@ -79,14 +74,7 @@ func (c *CreateRecord) Setup(ctx core.SetupContext) error { } config = c.normalizeConfig(config) - if err := validateRecordConfiguration(config.HostedZoneID, config.RecordName, config.RecordType, config.Values); err != nil { - return err - } - - return ctx.Metadata.Set(CreateRecordMetadata{ - HostedZoneID: config.HostedZoneID, - RecordName: config.RecordName, - }) + return validateRecordConfiguration(config.HostedZoneID, config.RecordName, config.RecordType, config.Values) } func (c *CreateRecord) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { @@ -118,15 +106,19 @@ func (c *CreateRecord) Execute(ctx core.ExecutionContext) error { if result.Status == "INSYNC" { output := map[string]any{ - "changeId": result.ID, - "status": result.Status, - "submittedAt": result.SubmittedAt, - "recordName": config.RecordName, - "recordType": config.RecordType, + "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.record", + "aws.route53.change", []any{output}, ) } diff --git a/pkg/integrations/aws/route53/create_record_test.go b/pkg/integrations/aws/route53/create_record_test.go index d08756bb4c..a65e0968c1 100644 --- a/pkg/integrations/aws/route53/create_record_test.go +++ b/pkg/integrations/aws/route53/create_record_test.go @@ -85,11 +85,10 @@ func TestCreateRecord_Setup(t *testing.T) { require.ErrorContains(t, err, "at least one record value is required") }) - t.Run("valid configuration -> stores metadata", func(t *testing.T) { - metadata := &contexts.MetadataContext{} + t.Run("valid configuration -> no error", func(t *testing.T) { err := component.Setup(core.SetupContext{ Integration: &contexts.IntegrationContext{}, - Metadata: metadata, + Metadata: &contexts.MetadataContext{}, Configuration: map[string]any{ "hostedZoneId": " Z123 ", "recordName": " example.com ", @@ -100,10 +99,6 @@ func TestCreateRecord_Setup(t *testing.T) { }) require.NoError(t, err) - stored, ok := metadata.Metadata.(CreateRecordMetadata) - require.True(t, ok) - assert.Equal(t, "Z123", stored.HostedZoneID) - assert.Equal(t, "example.com", stored.RecordName) }) } @@ -238,14 +233,15 @@ func TestCreateRecord_Execute(t *testing.T) { require.NoError(t, err) require.Len(t, execState.Payloads, 1) require.True(t, execState.Passed) - require.Equal(t, "aws.route53.record", execState.Type) + 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) - assert.Equal(t, "/change/C1234567890", data["changeId"]) - assert.Equal(t, "INSYNC", data["status"]) - assert.Equal(t, "example.com", data["recordName"]) - assert.Equal(t, "A", data["recordType"]) + 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) { @@ -433,9 +429,10 @@ func TestCreateRecord_HandleAction(t *testing.T) { payload := execState.Payloads[0].(map[string]any) data, ok := payload["data"].(map[string]any) require.True(t, ok) - assert.Equal(t, "/change/C1234567890", data["changeId"]) - assert.Equal(t, "INSYNC", data["status"]) - assert.Equal(t, "example.com", data["recordName"]) - assert.Equal(t, "A", data["recordType"]) + 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 index 208665ee83..2e8eaafdaf 100644 --- a/pkg/integrations/aws/route53/delete_record.go +++ b/pkg/integrations/aws/route53/delete_record.go @@ -22,11 +22,6 @@ type DeleteRecordConfiguration struct { Values []string `json:"values" mapstructure:"values"` } -type DeleteRecordMetadata struct { - HostedZoneID string `json:"hostedZoneId" mapstructure:"hostedZoneId"` - RecordName string `json:"recordName" mapstructure:"recordName"` -} - func (c *DeleteRecord) Name() string { return "aws.route53.deleteRecord" } @@ -80,14 +75,7 @@ func (c *DeleteRecord) Setup(ctx core.SetupContext) error { } config = c.normalizeConfig(config) - if err := validateRecordConfiguration(config.HostedZoneID, config.RecordName, config.RecordType, config.Values); err != nil { - return err - } - - return ctx.Metadata.Set(DeleteRecordMetadata{ - HostedZoneID: config.HostedZoneID, - RecordName: config.RecordName, - }) + return validateRecordConfiguration(config.HostedZoneID, config.RecordName, config.RecordType, config.Values) } func (c *DeleteRecord) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { @@ -119,15 +107,19 @@ func (c *DeleteRecord) Execute(ctx core.ExecutionContext) error { if result.Status == "INSYNC" { output := map[string]any{ - "changeId": result.ID, - "status": result.Status, - "submittedAt": result.SubmittedAt, - "recordName": config.RecordName, - "recordType": config.RecordType, + "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.record", + "aws.route53.change", []any{output}, ) } diff --git a/pkg/integrations/aws/route53/delete_record_test.go b/pkg/integrations/aws/route53/delete_record_test.go index 1d0ad2815b..44f06253cf 100644 --- a/pkg/integrations/aws/route53/delete_record_test.go +++ b/pkg/integrations/aws/route53/delete_record_test.go @@ -55,11 +55,10 @@ func TestDeleteRecord_Setup(t *testing.T) { require.ErrorContains(t, err, "at least one record value is required") }) - t.Run("valid configuration -> stores metadata", func(t *testing.T) { - metadata := &contexts.MetadataContext{} + t.Run("valid configuration -> no error", func(t *testing.T) { err := component.Setup(core.SetupContext{ Integration: &contexts.IntegrationContext{}, - Metadata: metadata, + Metadata: &contexts.MetadataContext{}, Configuration: map[string]any{ "hostedZoneId": "Z123", "recordName": "old.example.com", @@ -70,10 +69,6 @@ func TestDeleteRecord_Setup(t *testing.T) { }) require.NoError(t, err) - stored, ok := metadata.Metadata.(DeleteRecordMetadata) - require.True(t, ok) - assert.Equal(t, "Z123", stored.HostedZoneID) - assert.Equal(t, "old.example.com", stored.RecordName) }) } @@ -205,14 +200,15 @@ func TestDeleteRecord_Execute(t *testing.T) { require.NoError(t, err) require.Len(t, execState.Payloads, 1) require.True(t, execState.Passed) - require.Equal(t, "aws.route53.record", execState.Type) + 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) - assert.Equal(t, "/change/C5555555555", data["changeId"]) - assert.Equal(t, "INSYNC", data["status"]) - assert.Equal(t, "old.example.com", data["recordName"]) - assert.Equal(t, "A", data["recordType"]) + 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) { diff --git a/pkg/integrations/aws/route53/example_output_create_record.json b/pkg/integrations/aws/route53/example_output_create_record.json index fd67bee261..de555115ff 100644 --- a/pkg/integrations/aws/route53/example_output_create_record.json +++ b/pkg/integrations/aws/route53/example_output_create_record.json @@ -1,7 +1,15 @@ { - "changeId": "/change/C1234567890ABC", - "status": "PENDING", - "submittedAt": "2026-01-28T10:30:00.000Z", - "recordName": "api.example.com", - "recordType": "A" + "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 index 75537836f3..9424b496c1 100644 --- a/pkg/integrations/aws/route53/example_output_delete_record.json +++ b/pkg/integrations/aws/route53/example_output_delete_record.json @@ -1,7 +1,15 @@ { - "changeId": "/change/C5555555555GHI", - "status": "PENDING", - "submittedAt": "2026-01-28T10:30:00.000Z", - "recordName": "old.example.com", - "recordType": "A" + "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 index 4f844a977c..f9084df083 100644 --- a/pkg/integrations/aws/route53/example_output_upsert_record.json +++ b/pkg/integrations/aws/route53/example_output_upsert_record.json @@ -1,7 +1,15 @@ { - "changeId": "/change/C9876543210DEF", - "status": "PENDING", - "submittedAt": "2026-01-28T10:30:00.000Z", - "recordName": "app.example.com", - "recordType": "CNAME" + "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 index 7a72aa1c2a..b373372546 100644 --- a/pkg/integrations/aws/route53/poll.go +++ b/pkg/integrations/aws/route53/poll.go @@ -33,18 +33,27 @@ func pollChangeUntilSynced(ctx core.ActionContext) error { return fmt.Errorf("failed to get change status: %w", err) } + submittedAt := change.SubmittedAt + if submittedAt == "" { + submittedAt = meta.SubmittedAt + } + output := map[string]any{ - "changeId": change.ID, - "status": change.Status, - "submittedAt": meta.SubmittedAt, - "recordName": meta.RecordName, - "recordType": meta.RecordType, + "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.record", + "aws.route53.change", []any{output}, ) } diff --git a/pkg/integrations/aws/route53/upsert_record.go b/pkg/integrations/aws/route53/upsert_record.go index a23e98c7ee..6880d491cf 100644 --- a/pkg/integrations/aws/route53/upsert_record.go +++ b/pkg/integrations/aws/route53/upsert_record.go @@ -22,11 +22,6 @@ type UpsertRecordConfiguration struct { Values []string `json:"values" mapstructure:"values"` } -type UpsertRecordMetadata struct { - HostedZoneID string `json:"hostedZoneId" mapstructure:"hostedZoneId"` - RecordName string `json:"recordName" mapstructure:"recordName"` -} - func (c *UpsertRecord) Name() string { return "aws.route53.upsertRecord" } @@ -79,14 +74,7 @@ func (c *UpsertRecord) Setup(ctx core.SetupContext) error { } config = c.normalizeConfig(config) - if err := validateRecordConfiguration(config.HostedZoneID, config.RecordName, config.RecordType, config.Values); err != nil { - return err - } - - return ctx.Metadata.Set(UpsertRecordMetadata{ - HostedZoneID: config.HostedZoneID, - RecordName: config.RecordName, - }) + return validateRecordConfiguration(config.HostedZoneID, config.RecordName, config.RecordType, config.Values) } func (c *UpsertRecord) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { @@ -118,15 +106,19 @@ func (c *UpsertRecord) Execute(ctx core.ExecutionContext) error { if result.Status == "INSYNC" { output := map[string]any{ - "changeId": result.ID, - "status": result.Status, - "submittedAt": result.SubmittedAt, - "recordName": config.RecordName, - "recordType": config.RecordType, + "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.record", + "aws.route53.change", []any{output}, ) } diff --git a/pkg/integrations/aws/route53/upsert_record_test.go b/pkg/integrations/aws/route53/upsert_record_test.go index 2c4e5eca89..a3a7c5b2f8 100644 --- a/pkg/integrations/aws/route53/upsert_record_test.go +++ b/pkg/integrations/aws/route53/upsert_record_test.go @@ -55,11 +55,10 @@ func TestUpsertRecord_Setup(t *testing.T) { require.ErrorContains(t, err, "record name is required") }) - t.Run("valid configuration -> stores metadata", func(t *testing.T) { - metadata := &contexts.MetadataContext{} + t.Run("valid configuration -> no error", func(t *testing.T) { err := component.Setup(core.SetupContext{ Integration: &contexts.IntegrationContext{}, - Metadata: metadata, + Metadata: &contexts.MetadataContext{}, Configuration: map[string]any{ "hostedZoneId": "Z123", "recordName": "api.example.com", @@ -70,10 +69,6 @@ func TestUpsertRecord_Setup(t *testing.T) { }) require.NoError(t, err) - stored, ok := metadata.Metadata.(UpsertRecordMetadata) - require.True(t, ok) - assert.Equal(t, "Z123", stored.HostedZoneID) - assert.Equal(t, "api.example.com", stored.RecordName) }) } @@ -205,13 +200,14 @@ func TestUpsertRecord_Execute(t *testing.T) { require.NoError(t, err) require.Len(t, execState.Payloads, 1) require.True(t, execState.Passed) - require.Equal(t, "aws.route53.record", execState.Type) + 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) - assert.Equal(t, "/change/C9876543210", data["changeId"]) - assert.Equal(t, "INSYNC", data["status"]) - assert.Equal(t, "api.example.com", data["recordName"]) - assert.Equal(t, "CNAME", data["recordType"]) + 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 index 07ea536b5d..6150693e38 100644 --- a/web_src/src/assets/icons/integrations/aws.route53.svg +++ b/web_src/src/assets/icons/integrations/aws.route53.svg @@ -1,10 +1,10 @@ - - - - - - - - - - + + + Icon-Architecture/64/Arch_Amazon-Route-53_64 + + + + + + + \ No newline at end of file diff --git a/web_src/src/pages/workflowv2/mappers/aws/route53/record.ts b/web_src/src/pages/workflowv2/mappers/aws/route53/record.ts index af57318b1f..651ad09149 100644 --- a/web_src/src/pages/workflowv2/mappers/aws/route53/record.ts +++ b/web_src/src/pages/workflowv2/mappers/aws/route53/record.ts @@ -23,11 +23,19 @@ export interface RecordConfiguration { } export interface RecordChangePayload { - changeId?: string; + change?: Change; + record?: ResourceRecord; +} + +export interface Change { + id?: string; status?: string; submittedAt?: string; - recordName?: string; - recordType?: string; +} + +export interface ResourceRecord { + name?: string; + type?: string; } function recordMetadataList(node: NodeInfo): MetadataItem[] { @@ -91,11 +99,11 @@ export const recordMapper: ComponentBaseMapper = { } return { - "Record Name": stringOrDash(data.recordName), - "Record Type": stringOrDash(data.recordType), - "Change ID": stringOrDash(data.changeId), - Status: stringOrDash(data.status), - "Submitted At": stringOrDash(data.submittedAt), + "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), }; }, diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx index f7254cd022..b6d0996083 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 awsCodeArtifactIcon from "@/assets/icons/integrations/aws.codeartifact.svg"; import awsCloudwatchIcon from "@/assets/icons/integrations/aws.cloudwatch.svg"; @@ -426,6 +427,7 @@ function CategorySection({ cloudwatch: awsCloudwatchIcon, lambda: awsLambdaIcon, ecr: awsEcrIcon, + route53: awsRoute53Icon, sns: awsSnsIcon, }, }; @@ -505,6 +507,7 @@ function CategorySection({ cloudwatch: awsCloudwatchIcon, ecr: awsEcrIcon, lambda: awsLambdaIcon, + route53: awsRoute53Icon, sns: awsSnsIcon, }, }; From 1963c2f90b5032fb8b499f76e452176168b63980 Mon Sep 17 00:00:00 2001 From: Lucas Pinheiro Date: Mon, 16 Feb 2026 18:36:03 -0300 Subject: [PATCH 4/5] update docs Signed-off-by: Lucas Pinheiro --- docs/components/AWS.mdx | 54 +++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/docs/components/AWS.mdx b/docs/components/AWS.mdx index 4bb62fdb15..471d5e0d7a 100644 --- a/docs/components/AWS.mdx +++ b/docs/components/AWS.mdx @@ -956,11 +956,19 @@ The Create DNS Record component creates a new DNS record in an AWS Route 53 host ```json { - "changeId": "/change/C1234567890ABC", - "recordName": "api.example.com", - "recordType": "A", - "status": "PENDING", - "submittedAt": "2026-01-28T10:30:00.000Z" + "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" } ``` @@ -987,11 +995,19 @@ The Delete DNS Record component deletes a DNS record from an AWS Route 53 hosted ```json { - "changeId": "/change/C5555555555GHI", - "recordName": "old.example.com", - "recordType": "A", - "status": "PENDING", - "submittedAt": "2026-01-28T10:30:00.000Z" + "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" } ``` @@ -1017,11 +1033,19 @@ The Upsert DNS Record component creates or updates a DNS record in an AWS Route ```json { - "changeId": "/change/C9876543210DEF", - "recordName": "app.example.com", - "recordType": "CNAME", - "status": "PENDING", - "submittedAt": "2026-01-28T10:30:00.000Z" + "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" } ``` From fbba348ac4639d29aca1e8492bb57df82f98a64e Mon Sep 17 00:00:00 2001 From: Lucas Pinheiro Date: Mon, 16 Feb 2026 18:43:38 -0300 Subject: [PATCH 5/5] fix test Signed-off-by: Lucas Pinheiro --- pkg/integrations/aws/route53/create_record_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/integrations/aws/route53/create_record_test.go b/pkg/integrations/aws/route53/create_record_test.go index a65e0968c1..ca05d33e96 100644 --- a/pkg/integrations/aws/route53/create_record_test.go +++ b/pkg/integrations/aws/route53/create_record_test.go @@ -425,7 +425,7 @@ func TestCreateRecord_HandleAction(t *testing.T) { require.NoError(t, err) require.Len(t, execState.Payloads, 1) require.True(t, execState.Passed) - require.Equal(t, "aws.route53.record", execState.Type) + 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)