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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions internal/drivers/delegation.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package drivers
import (
"context"
"fmt"
"net"
"sort"
"strings"

Expand All @@ -28,12 +29,13 @@ type HoverDelegationClient interface {
// [ns1.hover.com, ns2.hover.com]; restore-from-stash is deferred to
// v0.3.0 because interfaces.ResourceRef has no state channel.
type DelegationDriver struct {
client HoverDelegationClient
client HoverDelegationClient
nsResolver func(context.Context, string) ([]string, error)
}

// NewDelegationDriver returns a DelegationDriver bound to a real *hover.Client.
func NewDelegationDriver(c *hover.Client) *DelegationDriver {
return &DelegationDriver{client: c}
return &DelegationDriver{client: c, nsResolver: lookupPublicNameservers}
}

// NewDelegationDriverWithClient returns a DelegationDriver bound to an
Expand All @@ -42,6 +44,12 @@ func NewDelegationDriverWithClient(c HoverDelegationClient) *DelegationDriver {
return &DelegationDriver{client: c}
}

// NewDelegationDriverWithClientAndResolver returns a DelegationDriver bound
// to an injected client and public-NS resolver; used by tests.
func NewDelegationDriverWithClientAndResolver(c HoverDelegationClient, resolver func(context.Context, string) ([]string, error)) *DelegationDriver {
return &DelegationDriver{client: c, nsResolver: resolver}
}

func (d *DelegationDriver) Type() string { return "infra.dns_delegation" }

func (d *DelegationDriver) SensitiveKeys() []string { return nil }
Expand Down Expand Up @@ -105,11 +113,15 @@ func parseDelegationSpec(spec interfaces.ResourceSpec) (dnsDelegationSpec, error
func nameserversToAny(ns []string) []any {
out := make([]any, len(ns))
for i, s := range ns {
out[i] = s
out[i] = normalizeNameserverHost(s)
}
return out
}

func normalizeNameserverHost(host string) string {
return strings.TrimSuffix(strings.TrimSpace(host), ".")
}

// delegationOutput builds the ResourceOutput for a Create/Update result.
// v0.2.0 ships without previous_nameservers (no state channel in
// interfaces.ResourceRef; v0.3.0 follow-up).
Expand Down Expand Up @@ -154,13 +166,34 @@ func (d *DelegationDriver) Read(ctx context.Context, ref interfaces.ResourceRef)
if domain == "" {
domain = ref.Name
}
if d.nsResolver != nil {
if ns, err := d.nsResolver(ctx, domain); err == nil && len(ns) > 0 {
return delegationOutput(ref.Name, domain, ns), nil
}
}
dom, err := d.client.GetDomainDelegation(ctx, domain)
if err != nil {
return nil, fmt.Errorf("dns_delegation read %q: %w", ref.Name, err)
}
return delegationOutput(ref.Name, domain, dom.Nameservers), nil
}

func lookupPublicNameservers(ctx context.Context, domain string) ([]string, error) {
resolver := net.DefaultResolver
records, err := resolver.LookupNS(ctx, domain)
if err != nil {
return nil, err
}
out := make([]string, 0, len(records))
for _, record := range records {
host := normalizeNameserverHost(record.Host)
if host != "" {
out = append(out, host)
}
}
return out, nil
}

// Update replaces the registrar nameservers. Rejects in-place domain
// renames (those must route through Diff → NeedsReplace → Delete-then-Create).
func (d *DelegationDriver) Update(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) {
Expand Down
24 changes: 24 additions & 0 deletions internal/drivers/delegation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import (
type fakeDelegationClient struct {
getResult *hover.DomainDelegation
getErr error
getCalls int
setErr error
lastSetNS []string
}

func (f *fakeDelegationClient) GetDomainDelegation(_ context.Context, _ string) (*hover.DomainDelegation, error) {
f.getCalls++
return f.getResult, f.getErr
}

Expand Down Expand Up @@ -140,6 +142,28 @@ func TestDelegationDriver_Read_HappyPath(t *testing.T) {
}
}

func TestDelegationDriver_Read_UsesPublicNSBeforeHoverLogin(t *testing.T) {
fc := &fakeDelegationClient{getErr: errors.New("hover login should not be needed")}
d := NewDelegationDriverWithClientAndResolver(fc, func(_ context.Context, domain string) ([]string, error) {
if domain != "example.com" {
t.Fatalf("resolver domain = %q, want example.com", domain)
}
return []string{"ns1.digitalocean.com.", "ns2.digitalocean.com.", "ns3.digitalocean.com."}, nil
})

out, err := d.Read(context.Background(), interfaces.ResourceRef{Name: "example.com", ProviderID: "example.com"})
if err != nil {
t.Fatalf("Read: %v", err)
}
if fc.getCalls != 0 {
t.Fatalf("GetDomainDelegation called %d times; public NS should avoid Hover login", fc.getCalls)
}
ns, _ := out.Outputs["nameservers"].([]any)
if len(ns) != 3 || ns[0] != "ns1.digitalocean.com" {
t.Fatalf("nameservers = %#v", ns)
}
}

func TestDelegationDriver_Read_PropagatesError(t *testing.T) {
fc := &fakeDelegationClient{getErr: errors.New("API down")}
d := NewDelegationDriverWithClient(fc)
Expand Down