diff --git a/internal/drivers/delegation.go b/internal/drivers/delegation.go index 9a0de46..abde479 100644 --- a/internal/drivers/delegation.go +++ b/internal/drivers/delegation.go @@ -5,6 +5,7 @@ package drivers import ( "context" "fmt" + "net" "sort" "strings" @@ -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 @@ -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 } @@ -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). @@ -154,6 +166,11 @@ 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) @@ -161,6 +178,22 @@ func (d *DelegationDriver) Read(ctx context.Context, ref interfaces.ResourceRef) 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) { diff --git a/internal/drivers/delegation_test.go b/internal/drivers/delegation_test.go index 3ad4fec..1866516 100644 --- a/internal/drivers/delegation_test.go +++ b/internal/drivers/delegation_test.go @@ -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 } @@ -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)