From d5082ab2dff624c8a72f31a261a8dda6fc8870fe Mon Sep 17 00:00:00 2001 From: Nathan Huh Date: Thu, 26 Mar 2026 11:17:54 +0900 Subject: [PATCH] feat: add Route53 hosted zones and DNS records browser - Add Route53 service and Route53 Browser feature to domain catalog - Implement ListHostedZones and ListResourceRecordSets with pagination - Add Route53ClientAPI interface and client initialization in repository - Add 3 TUI screens: zone list, record list, record detail - Support filtering on both zone list and record list views - Add HostedZone and DNSRecord models with DisplayTitle/FilterText - Add comprehensive tests for API methods and model functions --- go.mod | 1 + go.sum | 2 + internal/app/app.go | 367 +++++++++++++++++++++++++ internal/domain/catalog.go | 9 + internal/domain/model.go | 14 +- internal/services/aws/repository.go | 32 ++- internal/services/aws/route53.go | 91 ++++++ internal/services/aws/route53_model.go | 58 ++++ internal/services/aws/route53_test.go | 348 +++++++++++++++++++++++ 9 files changed, 906 insertions(+), 16 deletions(-) create mode 100644 internal/services/aws/route53.go create mode 100644 internal/services/aws/route53_model.go create mode 100644 internal/services/aws/route53_test.go diff --git a/go.mod b/go.mod index 556ea31..4e6d670 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 github.com/aws/aws-sdk-go-v2/service/rds v1.116.3 + github.com/aws/aws-sdk-go-v2/service/route53 v1.62.4 github.com/aws/aws-sdk-go-v2/service/ssm v1.68.3 github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 diff --git a/go.sum b/go.sum index 09139d3..d80d0df 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= github.com/aws/aws-sdk-go-v2/service/rds v1.116.3 h1:H/ZYZ6QR4EXJAYElI5xkIM/yCz+A4uHIvWpzl+IfJks= github.com/aws/aws-sdk-go-v2/service/rds v1.116.3/go.mod h1:QbXW4coAMakHQhf1qhE0eVVCen9gwB/Kvn+HHHKhpGY= +github.com/aws/aws-sdk-go-v2/service/route53 v1.62.4 h1:64aYPyHg3RjLvnMMSYQSg7aP+r1WRCPIS9SP9KfHjWg= +github.com/aws/aws-sdk-go-v2/service/route53 v1.62.4/go.mod h1:bPSPzWTn9LSX6e0KPp4LlPoaspouZdKAlIdSMdhBBrs= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= github.com/aws/aws-sdk-go-v2/service/ssm v1.68.3 h1:bBoWhx8lsFLTXintRX64ZBXcmFZbGqUmaPUrjXECqIc= diff --git a/internal/app/app.go b/internal/app/app.go index 74ea9d3..d69012a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -28,6 +28,9 @@ const ( screenRDSList screenRDSDetail screenRDSConfirm + screenRoute53ZoneList + screenRoute53RecordList + screenRoute53RecordDetail screenContextPicker screenContextAdd screenLoading @@ -96,6 +99,14 @@ type rdsTickMsg struct { instanceID string } +type route53ZonesLoadedMsg struct { + zones []awsservice.HostedZone +} + +type route53RecordsLoadedMsg struct { + records []awsservice.DNSRecord +} + // Model is the root Bubbletea model. type Model struct { cfg *config.Config @@ -146,6 +157,20 @@ type Model struct { rdsConfirmInput string // typed input for destructive action confirmation rdsPolling bool + // Route53 browser state + route53Zones []awsservice.HostedZone + filteredRoute53Zones []awsservice.HostedZone + route53ZoneIdx int + route53ZoneFilter string + route53ZoneFilterActive bool + selectedRoute53Zone *awsservice.HostedZone + route53Records []awsservice.DNSRecord + filteredRoute53Records []awsservice.DNSRecord + route53RecordIdx int + route53RecordFilter string + route53RecordFilterActive bool + selectedRoute53Record *awsservice.DNSRecord + // Context picker configPath string ctxList []config.ContextInfo @@ -244,6 +269,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.screen = screenSubnetDetail return m, nil + case route53ZonesLoadedMsg: + m.route53Zones = msg.zones + m.filteredRoute53Zones = msg.zones + m.route53ZoneIdx = 0 + m.screen = screenRoute53ZoneList + return m, nil + + case route53RecordsLoadedMsg: + m.route53Records = msg.records + m.filteredRoute53Records = msg.records + m.route53RecordIdx = 0 + m.screen = screenRoute53RecordList + return m, nil + case rdsInstancesLoadedMsg: m.rdsInstances = msg.instances m.filteredRDS = msg.instances @@ -366,6 +405,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateRDSDetail(msg) case screenRDSConfirm: return m.updateRDSConfirm(msg) + case screenRoute53ZoneList: + return m.updateRoute53ZoneList(msg) + case screenRoute53RecordList: + return m.updateRoute53RecordList(msg) + case screenRoute53RecordDetail: + return m.updateRoute53RecordDetail(msg) case screenContextPicker: return m.updateContextPicker(msg) case screenContextAdd: @@ -429,6 +474,9 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case domain.FeatureRDSBrowser: m.screen = screenLoading return m, m.loadRDSInstances() + case domain.FeatureRoute53Browser: + m.screen = screenLoading + return m, m.loadRoute53Zones() } } } @@ -737,6 +785,145 @@ func (m *Model) applyRDSFilter() { m.rdsIdx = 0 } +func (m Model) updateRoute53ZoneList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if m.route53ZoneFilterActive { + switch key { + case "esc": + m.route53ZoneFilterActive = false + case "enter": + m.route53ZoneFilterActive = false + case "backspace": + if len(m.route53ZoneFilter) > 0 { + m.route53ZoneFilter = m.route53ZoneFilter[:len(m.route53ZoneFilter)-1] + m.applyRoute53ZoneFilter() + } + default: + if len(key) == 1 { + m.route53ZoneFilter += key + m.applyRoute53ZoneFilter() + } + } + return m, nil + } + + switch key { + case "q", "esc": + m.screen = screenFeatureList + m.route53ZoneFilter = "" + m.filteredRoute53Zones = m.route53Zones + m.route53ZoneIdx = 0 + case "up", "k": + if m.route53ZoneIdx > 0 { + m.route53ZoneIdx-- + } + case "down", "j": + if m.route53ZoneIdx < len(m.filteredRoute53Zones)-1 { + m.route53ZoneIdx++ + } + case "/": + m.route53ZoneFilterActive = true + case "enter": + if len(m.filteredRoute53Zones) > 0 && m.route53ZoneIdx < len(m.filteredRoute53Zones) { + selected := m.filteredRoute53Zones[m.route53ZoneIdx] + m.selectedRoute53Zone = &selected + m.screen = screenLoading + return m, m.loadRoute53Records(selected.ID) + } + } + return m, nil +} + +func (m Model) updateRoute53RecordList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if m.route53RecordFilterActive { + switch key { + case "esc": + m.route53RecordFilterActive = false + case "enter": + m.route53RecordFilterActive = false + case "backspace": + if len(m.route53RecordFilter) > 0 { + m.route53RecordFilter = m.route53RecordFilter[:len(m.route53RecordFilter)-1] + m.applyRoute53RecordFilter() + } + default: + if len(key) == 1 { + m.route53RecordFilter += key + m.applyRoute53RecordFilter() + } + } + return m, nil + } + + switch key { + case "q", "esc": + m.screen = screenRoute53ZoneList + m.route53RecordFilter = "" + m.filteredRoute53Records = m.route53Records + m.route53RecordIdx = 0 + case "up", "k": + if m.route53RecordIdx > 0 { + m.route53RecordIdx-- + } + case "down", "j": + if m.route53RecordIdx < len(m.filteredRoute53Records)-1 { + m.route53RecordIdx++ + } + case "/": + m.route53RecordFilterActive = true + case "enter": + if len(m.filteredRoute53Records) > 0 && m.route53RecordIdx < len(m.filteredRoute53Records) { + selected := m.filteredRoute53Records[m.route53RecordIdx] + m.selectedRoute53Record = &selected + m.screen = screenRoute53RecordDetail + } + } + return m, nil +} + +func (m Model) updateRoute53RecordDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + m.screen = screenRoute53RecordList + } + return m, nil +} + +func (m *Model) applyRoute53ZoneFilter() { + if m.route53ZoneFilter == "" { + m.filteredRoute53Zones = m.route53Zones + } else { + query := strings.ToLower(m.route53ZoneFilter) + var result []awsservice.HostedZone + for _, zone := range m.route53Zones { + if strings.Contains(zone.FilterText(), query) { + result = append(result, zone) + } + } + m.filteredRoute53Zones = result + } + m.route53ZoneIdx = 0 +} + +func (m *Model) applyRoute53RecordFilter() { + if m.route53RecordFilter == "" { + m.filteredRoute53Records = m.route53Records + } else { + query := strings.ToLower(m.route53RecordFilter) + var result []awsservice.DNSRecord + for _, rec := range m.route53Records { + if strings.Contains(rec.FilterText(), query) { + result = append(result, rec) + } + } + m.filteredRoute53Records = result + } + m.route53RecordIdx = 0 +} + func (m *Model) applyFilter() { if m.filterInput == "" { m.filtered = m.instances @@ -861,6 +1048,49 @@ func (m Model) loadRDSInstances() tea.Cmd { } } +func (m Model) loadRoute53Zones() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + m.awsRepo = repo + + zones, err := repo.ListHostedZones(ctx) + if err != nil { + return errMsg{err: err} + } + if len(zones) == 0 { + return errMsg{err: fmt.Errorf("no hosted zones found")} + } + return route53ZonesLoadedMsg{zones: zones} + } +} + +func (m Model) loadRoute53Records(zoneID string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + } + + records, err := repo.ListResourceRecordSets(ctx, zoneID) + if err != nil { + return errMsg{err: err} + } + if len(records) == 0 { + return errMsg{err: fmt.Errorf("no records found in zone %s", zoneID)} + } + return route53RecordsLoadedMsg{records: records} + } +} + func (m Model) executeRDSAction(action, dbInstanceID string) tea.Cmd { clusterID := "" if m.selectedRDS != nil { @@ -1021,6 +1251,12 @@ func (m Model) View() string { v = m.viewRDSDetail() case screenRDSConfirm: v = m.viewRDSConfirm() + case screenRoute53ZoneList: + v = m.viewRoute53ZoneList() + case screenRoute53RecordList: + v = m.viewRoute53RecordList() + case screenRoute53RecordDetail: + v = m.viewRoute53RecordDetail() case screenContextPicker: v = m.viewContextPicker() case screenContextAdd: @@ -1660,6 +1896,137 @@ func (m Model) viewRDSDetail() string { return b.String() } +func (m Model) viewRoute53ZoneList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("Route53 Hosted Zones")) + b.WriteString("\n") + + if m.route53ZoneFilterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.route53ZoneFilter))) + } else if m.route53ZoneFilter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.route53ZoneFilter))) + } + b.WriteString("\n\n") + + if len(m.filteredRoute53Zones) == 0 { + b.WriteString(dimStyle.Render(" No matching hosted zones")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-8, 5) + start := 0 + if m.route53ZoneIdx >= visibleLines { + start = m.route53ZoneIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredRoute53Zones)) + + for i := start; i < end; i++ { + zone := m.filteredRoute53Zones[i] + cursor := " " + style := normalStyle + if i == m.route53ZoneIdx { + cursor = "> " + style = selectedStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, zone.DisplayTitle()))) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d zones", len(m.filteredRoute53Zones), len(m.route53Zones)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: records • esc: back • H: home")) + return b.String() +} + +func (m Model) viewRoute53RecordList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + zoneName := "" + if m.selectedRoute53Zone != nil { + zoneName = m.selectedRoute53Zone.Name + } + b.WriteString(titleStyle.Render(fmt.Sprintf("DNS Records — %s", zoneName))) + b.WriteString("\n") + + if m.route53RecordFilterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.route53RecordFilter))) + } else if m.route53RecordFilter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.route53RecordFilter))) + } + b.WriteString("\n\n") + + if len(m.filteredRoute53Records) == 0 { + b.WriteString(dimStyle.Render(" No matching records")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-8, 5) + start := 0 + if m.route53RecordIdx >= visibleLines { + start = m.route53RecordIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredRoute53Records)) + + for i := start; i < end; i++ { + rec := m.filteredRoute53Records[i] + cursor := " " + style := normalStyle + if i == m.route53RecordIdx { + cursor = "> " + style = selectedStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, rec.DisplayTitle()))) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d records", len(m.filteredRoute53Records), len(m.route53Records)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: detail • esc: back • H: home")) + return b.String() +} + +func (m Model) viewRoute53RecordDetail() string { + if m.selectedRoute53Record == nil { + return "" + } + r := m.selectedRoute53Record + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("DNS Record Detail")) + b.WriteString("\n\n") + + b.WriteString(normalStyle.Render(fmt.Sprintf(" Name : %s", r.Name))) + b.WriteString("\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" Type : %s", r.Type))) + b.WriteString("\n") + + if r.AliasTarget != "" { + b.WriteString(normalStyle.Render(fmt.Sprintf(" Alias : %s", r.AliasTarget))) + b.WriteString("\n") + } else { + b.WriteString(normalStyle.Render(fmt.Sprintf(" TTL : %d", r.TTL))) + b.WriteString("\n") + } + + if len(r.Values) > 0 { + b.WriteString(normalStyle.Render(" Values :")) + b.WriteString("\n") + for _, v := range r.Values { + b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", v))) + b.WriteString("\n") + } + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("esc: back • H: home")) + return b.String() +} + func (m Model) viewRDSConfirm() string { if m.selectedRDS == nil { return "" diff --git a/internal/domain/catalog.go b/internal/domain/catalog.go index 76c7b41..eb80794 100644 --- a/internal/domain/catalog.go +++ b/internal/domain/catalog.go @@ -30,5 +30,14 @@ func Catalog() []Service { }, }, }, + { + Name: ServiceRoute53, + Features: []Feature{ + { + Kind: FeatureRoute53Browser, + Description: "Browse hosted zones and DNS records", + }, + }, + }, } } diff --git a/internal/domain/model.go b/internal/domain/model.go index 931d166..74fd08d 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -4,18 +4,20 @@ package domain type AwsService string const ( - ServiceEC2 AwsService = "EC2" - ServiceVPC AwsService = "VPC" - ServiceRDS AwsService = "RDS" + ServiceEC2 AwsService = "EC2" + ServiceVPC AwsService = "VPC" + ServiceRDS AwsService = "RDS" + ServiceRoute53 AwsService = "Route53" ) // FeatureKind represents a specific feature within a service. type FeatureKind string const ( - FeatureSSMSession FeatureKind = "SSM Sessions Manager" - FeatureVPCBrowser FeatureKind = "VPC Browser" - FeatureRDSBrowser FeatureKind = "RDS Browser" + FeatureSSMSession FeatureKind = "SSM Sessions Manager" + FeatureVPCBrowser FeatureKind = "VPC Browser" + FeatureRDSBrowser FeatureKind = "RDS Browser" + FeatureRoute53Browser FeatureKind = "Route53 Browser" ) // Feature describes a selectable feature under an AWS service. diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index 5774969..9669777 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -10,6 +10,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/rds" + "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/aws-sdk-go-v2/service/sts" @@ -25,6 +26,9 @@ var _ EC2ClientAPI = (*ec2.Client)(nil) // Verify *rds.Client satisfies RDSClientAPI at compile time. var _ RDSClientAPI = (*rds.Client)(nil) +// Verify *route53.Client satisfies Route53ClientAPI at compile time. +var _ Route53ClientAPI = (*route53.Client)(nil) + // SSMClientAPI is the interface for SSM operations used by AwsRepository. type SSMClientAPI interface { ssm.DescribeInstanceInformationAPIClient @@ -43,6 +47,12 @@ type RDSClientAPI interface { FailoverDBCluster(ctx context.Context, params *rds.FailoverDBClusterInput, optFns ...func(*rds.Options)) (*rds.FailoverDBClusterOutput, error) } +// Route53ClientAPI is the interface for Route53 operations used by AwsRepository. +type Route53ClientAPI interface { + ListHostedZones(ctx context.Context, params *route53.ListHostedZonesInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) + ListResourceRecordSets(ctx context.Context, params *route53.ListResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) +} + // EC2ClientAPI is the interface for EC2 operations used by AwsRepository. type EC2ClientAPI interface { ec2.DescribeInstancesAPIClient @@ -60,10 +70,11 @@ type CallerIdentity struct { // AwsRepository holds AWS SDK clients for EC2, SSM, RDS, and STS. type AwsRepository struct { - EC2Client EC2ClientAPI - SSMClient SSMClientAPI - RDSClient RDSClientAPI - STSClient *sts.Client + EC2Client EC2ClientAPI + SSMClient SSMClientAPI + RDSClient RDSClientAPI + Route53Client Route53ClientAPI + STSClient *sts.Client Region string Profile string } @@ -123,12 +134,13 @@ func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, } return &AwsRepository{ - EC2Client: ec2.NewFromConfig(awsCfg), - SSMClient: ssm.NewFromConfig(awsCfg), - RDSClient: rds.NewFromConfig(awsCfg), - STSClient: sts.NewFromConfig(awsCfg), - Region: cfg.Region, - Profile: cfg.Profile, + EC2Client: ec2.NewFromConfig(awsCfg), + SSMClient: ssm.NewFromConfig(awsCfg), + RDSClient: rds.NewFromConfig(awsCfg), + Route53Client: route53.NewFromConfig(awsCfg), + STSClient: sts.NewFromConfig(awsCfg), + Region: cfg.Region, + Profile: cfg.Profile, }, nil } diff --git a/internal/services/aws/route53.go b/internal/services/aws/route53.go new file mode 100644 index 0000000..c26e025 --- /dev/null +++ b/internal/services/aws/route53.go @@ -0,0 +1,91 @@ +package aws + +import ( + "context" + "fmt" + "strings" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/route53" +) + +// ListHostedZones returns all Route53 hosted zones in the current account. +func (r *AwsRepository) ListHostedZones(ctx context.Context) ([]HostedZone, error) { + var zones []HostedZone + var marker *string + + for { + output, err := r.Route53Client.ListHostedZones(ctx, &route53.ListHostedZonesInput{ + Marker: marker, + }) + if err != nil { + return nil, fmt.Errorf("failed to list hosted zones: %w", err) + } + + for _, hz := range output.HostedZones { + zone := HostedZone{ + ID: cleanZoneID(awssdk.ToString(hz.Id)), + Name: awssdk.ToString(hz.Name), + ResourceRecordCount: awssdk.ToInt64(hz.ResourceRecordSetCount), + IsPrivate: hz.Config != nil && hz.Config.PrivateZone, + } + if hz.Config != nil { + zone.Comment = awssdk.ToString(hz.Config.Comment) + } + zones = append(zones, zone) + } + + if !output.IsTruncated { + break + } + marker = output.NextMarker + } + + return zones, nil +} + +// ListResourceRecordSets returns all DNS records for a given hosted zone. +func (r *AwsRepository) ListResourceRecordSets(ctx context.Context, zoneID string) ([]DNSRecord, error) { + var records []DNSRecord + input := &route53.ListResourceRecordSetsInput{ + HostedZoneId: awssdk.String(zoneID), + } + + for { + output, err := r.Route53Client.ListResourceRecordSets(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to list resource record sets for zone %s: %w", zoneID, err) + } + + for _, rrs := range output.ResourceRecordSets { + rec := DNSRecord{ + Name: awssdk.ToString(rrs.Name), + Type: string(rrs.Type), + } + if rrs.TTL != nil { + rec.TTL = *rrs.TTL + } + if rrs.AliasTarget != nil { + rec.AliasTarget = awssdk.ToString(rrs.AliasTarget.DNSName) + } + for _, v := range rrs.ResourceRecords { + rec.Values = append(rec.Values, awssdk.ToString(v.Value)) + } + records = append(records, rec) + } + + if !output.IsTruncated { + break + } + input.StartRecordName = output.NextRecordName + input.StartRecordType = output.NextRecordType + input.StartRecordIdentifier = output.NextRecordIdentifier + } + + return records, nil +} + +// cleanZoneID strips the "/hostedzone/" prefix that AWS sometimes includes. +func cleanZoneID(id string) string { + return strings.TrimPrefix(id, "/hostedzone/") +} diff --git a/internal/services/aws/route53_model.go b/internal/services/aws/route53_model.go new file mode 100644 index 0000000..2b6a2ae --- /dev/null +++ b/internal/services/aws/route53_model.go @@ -0,0 +1,58 @@ +package aws + +import ( + "fmt" + "strings" +) + +// HostedZone holds essential information about a Route53 hosted zone. +type HostedZone struct { + ID string + Name string + ResourceRecordCount int64 + IsPrivate bool + Comment string +} + +// DisplayTitle returns a formatted string for list display. +func (hz HostedZone) DisplayTitle() string { + zoneType := "Public" + if hz.IsPrivate { + zoneType = "Private" + } + return fmt.Sprintf("%s %s %d records [%s]", + hz.Name, hz.ID, hz.ResourceRecordCount, zoneType) +} + +// FilterText returns a lowercase string for keyword matching. +func (hz HostedZone) FilterText() string { + return strings.ToLower(fmt.Sprintf("%s %s %s", + hz.Name, hz.ID, hz.Comment)) +} + +// DNSRecord holds essential information about a Route53 resource record set. +type DNSRecord struct { + Name string + Type string + TTL int64 + Values []string + AliasTarget string +} + +// DisplayTitle returns a formatted string for list display. +func (r DNSRecord) DisplayTitle() string { + if r.AliasTarget != "" { + return fmt.Sprintf("%s %s ALIAS → %s", r.Name, r.Type, r.AliasTarget) + } + valStr := strings.Join(r.Values, ", ") + if len(valStr) > 60 { + valStr = valStr[:57] + "..." + } + return fmt.Sprintf("%s %s TTL:%d %s", r.Name, r.Type, r.TTL, valStr) +} + +// FilterText returns a lowercase string for keyword matching. +func (r DNSRecord) FilterText() string { + return strings.ToLower(fmt.Sprintf("%s %s %s %s", + r.Name, r.Type, strings.Join(r.Values, " "), r.AliasTarget)) +} diff --git a/internal/services/aws/route53_test.go b/internal/services/aws/route53_test.go new file mode 100644 index 0000000..2cb4db9 --- /dev/null +++ b/internal/services/aws/route53_test.go @@ -0,0 +1,348 @@ +package aws + +import ( + "context" + "fmt" + "strings" + "testing" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/route53" + r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" +) + +// mockRoute53Client implements Route53ClientAPI for testing. +type mockRoute53Client struct { + listHostedZonesFunc func(ctx context.Context, params *route53.ListHostedZonesInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) + listResourceRecordSetsFunc func(ctx context.Context, params *route53.ListResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) +} + +func (m *mockRoute53Client) ListHostedZones(ctx context.Context, params *route53.ListHostedZonesInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { + return m.listHostedZonesFunc(ctx, params, optFns...) +} + +func (m *mockRoute53Client) ListResourceRecordSets(ctx context.Context, params *route53.ListResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { + return m.listResourceRecordSetsFunc(ctx, params, optFns...) +} + +// --- ListHostedZones tests --- + +func TestListHostedZones_Success(t *testing.T) { + mock := &mockRoute53Client{ + listHostedZonesFunc: func(_ context.Context, _ *route53.ListHostedZonesInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { + return &route53.ListHostedZonesOutput{ + HostedZones: []r53types.HostedZone{ + { + Id: awssdk.String("/hostedzone/Z1234567890"), + Name: awssdk.String("example.com."), + ResourceRecordSetCount: awssdk.Int64(10), + Config: &r53types.HostedZoneConfig{ + PrivateZone: false, + Comment: awssdk.String("Production zone"), + }, + }, + { + Id: awssdk.String("/hostedzone/Z0987654321"), + Name: awssdk.String("internal.example.com."), + ResourceRecordSetCount: awssdk.Int64(5), + Config: &r53types.HostedZoneConfig{ + PrivateZone: true, + Comment: awssdk.String("Internal VPC zone"), + }, + }, + }, + IsTruncated: false, + }, nil + }, + } + + repo := &AwsRepository{Route53Client: mock} + zones, err := repo.ListHostedZones(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(zones) != 2 { + t.Fatalf("expected 2 zones, got %d", len(zones)) + } + + z := zones[0] + if z.ID != "Z1234567890" { + t.Errorf("expected ID 'Z1234567890', got %q", z.ID) + } + if z.Name != "example.com." { + t.Errorf("expected Name 'example.com.', got %q", z.Name) + } + if z.ResourceRecordCount != 10 { + t.Errorf("expected ResourceRecordCount 10, got %d", z.ResourceRecordCount) + } + if z.IsPrivate { + t.Error("expected IsPrivate to be false") + } + if z.Comment != "Production zone" { + t.Errorf("expected Comment 'Production zone', got %q", z.Comment) + } + + z2 := zones[1] + if !z2.IsPrivate { + t.Error("expected second zone to be private") + } +} + +func TestListHostedZones_Empty(t *testing.T) { + mock := &mockRoute53Client{ + listHostedZonesFunc: func(_ context.Context, _ *route53.ListHostedZonesInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { + return &route53.ListHostedZonesOutput{ + HostedZones: []r53types.HostedZone{}, + IsTruncated: false, + }, nil + }, + } + + repo := &AwsRepository{Route53Client: mock} + zones, err := repo.ListHostedZones(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(zones) != 0 { + t.Errorf("expected empty slice, got %d", len(zones)) + } +} + +func TestListHostedZones_Error(t *testing.T) { + mock := &mockRoute53Client{ + listHostedZonesFunc: func(_ context.Context, _ *route53.ListHostedZonesInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { + return nil, fmt.Errorf("access denied") + }, + } + + repo := &AwsRepository{Route53Client: mock} + _, err := repo.ListHostedZones(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestListHostedZones_NilConfig(t *testing.T) { + mock := &mockRoute53Client{ + listHostedZonesFunc: func(_ context.Context, _ *route53.ListHostedZonesInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) { + return &route53.ListHostedZonesOutput{ + HostedZones: []r53types.HostedZone{ + { + Id: awssdk.String("/hostedzone/Z111"), + Name: awssdk.String("noconfig.com."), + ResourceRecordSetCount: awssdk.Int64(1), + Config: nil, + }, + }, + IsTruncated: false, + }, nil + }, + } + + repo := &AwsRepository{Route53Client: mock} + zones, err := repo.ListHostedZones(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(zones) != 1 { + t.Fatalf("expected 1 zone, got %d", len(zones)) + } + if zones[0].IsPrivate { + t.Error("expected IsPrivate to be false when Config is nil") + } + if zones[0].Comment != "" { + t.Errorf("expected empty Comment when Config is nil, got %q", zones[0].Comment) + } +} + +// --- ListResourceRecordSets tests --- + +func TestListResourceRecordSets_Success(t *testing.T) { + mock := &mockRoute53Client{ + listResourceRecordSetsFunc: func(_ context.Context, params *route53.ListResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { + if awssdk.ToString(params.HostedZoneId) != "Z123" { + t.Errorf("expected zone ID 'Z123', got %q", awssdk.ToString(params.HostedZoneId)) + } + return &route53.ListResourceRecordSetsOutput{ + ResourceRecordSets: []r53types.ResourceRecordSet{ + { + Name: awssdk.String("example.com."), + Type: r53types.RRTypeA, + TTL: awssdk.Int64(300), + ResourceRecords: []r53types.ResourceRecord{ + {Value: awssdk.String("1.2.3.4")}, + {Value: awssdk.String("5.6.7.8")}, + }, + }, + { + Name: awssdk.String("www.example.com."), + Type: r53types.RRTypeA, + AliasTarget: &r53types.AliasTarget{ + DNSName: awssdk.String("d111111abcdef8.cloudfront.net."), + }, + }, + }, + IsTruncated: false, + }, nil + }, + } + + repo := &AwsRepository{Route53Client: mock} + records, err := repo.ListResourceRecordSets(context.Background(), "Z123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(records) != 2 { + t.Fatalf("expected 2 records, got %d", len(records)) + } + + r := records[0] + if r.Name != "example.com." { + t.Errorf("expected Name 'example.com.', got %q", r.Name) + } + if r.Type != "A" { + t.Errorf("expected Type 'A', got %q", r.Type) + } + if r.TTL != 300 { + t.Errorf("expected TTL 300, got %d", r.TTL) + } + if len(r.Values) != 2 { + t.Fatalf("expected 2 values, got %d", len(r.Values)) + } + if r.Values[0] != "1.2.3.4" { + t.Errorf("expected value '1.2.3.4', got %q", r.Values[0]) + } + + alias := records[1] + if alias.AliasTarget != "d111111abcdef8.cloudfront.net." { + t.Errorf("expected AliasTarget 'd111111abcdef8.cloudfront.net.', got %q", alias.AliasTarget) + } + if len(alias.Values) != 0 { + t.Errorf("expected no values for alias record, got %d", len(alias.Values)) + } +} + +func TestListResourceRecordSets_Empty(t *testing.T) { + mock := &mockRoute53Client{ + listResourceRecordSetsFunc: func(_ context.Context, _ *route53.ListResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { + return &route53.ListResourceRecordSetsOutput{ + ResourceRecordSets: []r53types.ResourceRecordSet{}, + IsTruncated: false, + }, nil + }, + } + + repo := &AwsRepository{Route53Client: mock} + records, err := repo.ListResourceRecordSets(context.Background(), "Z123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(records) != 0 { + t.Errorf("expected empty slice, got %d", len(records)) + } +} + +func TestListResourceRecordSets_Error(t *testing.T) { + mock := &mockRoute53Client{ + listResourceRecordSetsFunc: func(_ context.Context, _ *route53.ListResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { + return nil, fmt.Errorf("zone not found") + }, + } + + repo := &AwsRepository{Route53Client: mock} + _, err := repo.ListResourceRecordSets(context.Background(), "Z999") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +// --- Model tests --- + +func TestHostedZoneDisplayTitle(t *testing.T) { + hz := HostedZone{ + Name: "example.com.", ID: "Z123", + ResourceRecordCount: 10, IsPrivate: false, + } + title := hz.DisplayTitle() + if !strings.Contains(title, "example.com.") { + t.Errorf("DisplayTitle should contain zone name, got %q", title) + } + if !strings.Contains(title, "Public") { + t.Errorf("DisplayTitle should contain 'Public', got %q", title) + } + + hz.IsPrivate = true + title = hz.DisplayTitle() + if !strings.Contains(title, "Private") { + t.Errorf("DisplayTitle should contain 'Private', got %q", title) + } +} + +func TestHostedZoneFilterText(t *testing.T) { + hz := HostedZone{ + Name: "Example.COM.", ID: "Z123ABC", Comment: "Prod Zone", + } + ft := hz.FilterText() + for _, kw := range []string{"example.com.", "z123abc", "prod zone"} { + if !strings.Contains(ft, kw) { + t.Errorf("FilterText %q should contain %q", ft, kw) + } + } +} + +func TestDNSRecordDisplayTitle_Normal(t *testing.T) { + r := DNSRecord{ + Name: "example.com.", Type: "A", TTL: 300, + Values: []string{"1.2.3.4"}, + } + title := r.DisplayTitle() + if !strings.Contains(title, "example.com.") { + t.Errorf("DisplayTitle should contain name, got %q", title) + } + if !strings.Contains(title, "TTL:300") { + t.Errorf("DisplayTitle should contain TTL, got %q", title) + } +} + +func TestDNSRecordDisplayTitle_Alias(t *testing.T) { + r := DNSRecord{ + Name: "www.example.com.", Type: "A", + AliasTarget: "d111.cloudfront.net.", + } + title := r.DisplayTitle() + if !strings.Contains(title, "ALIAS") { + t.Errorf("DisplayTitle should contain 'ALIAS', got %q", title) + } + if !strings.Contains(title, "d111.cloudfront.net.") { + t.Errorf("DisplayTitle should contain alias target, got %q", title) + } +} + +func TestDNSRecordFilterText(t *testing.T) { + r := DNSRecord{ + Name: "API.Example.Com.", Type: "CNAME", + Values: []string{"lb.example.com."}, AliasTarget: "", + } + ft := r.FilterText() + for _, kw := range []string{"api.example.com.", "cname", "lb.example.com."} { + if !strings.Contains(ft, kw) { + t.Errorf("FilterText %q should contain %q", ft, kw) + } + } +} + +func TestCleanZoneID(t *testing.T) { + tests := []struct { + input, expected string + }{ + {"/hostedzone/Z123", "Z123"}, + {"Z123", "Z123"}, + {"/hostedzone/", ""}, + } + for _, tt := range tests { + got := cleanZoneID(tt.input) + if got != tt.expected { + t.Errorf("cleanZoneID(%q) = %q, want %q", tt.input, got, tt.expected) + } + } +}