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