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
44 changes: 44 additions & 0 deletions internal/drivers/delegation.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,50 @@ func lookupPublicNameservers(ctx context.Context, domain string) ([]string, erro
return out, nil
}

// ReadForImport fetches both the registrar (authoritative) and live public
// nameservers, returning them in a single ResourceOutput. Unlike Read, this
// method always calls GetDomainDelegation first (registrar = authoritative
// intent); the live resolver is called best-effort and its result is omitted
// if unavailable. The primary "nameservers" key equals the registrar NS so
// existing Diff / nameserversFromOutputs semantics remain consistent.
//
// This is the import-path equivalent of Read; it MUST NOT be used for drift
// detection or apply (those continue to use Read).
func (d *DelegationDriver) ReadForImport(ctx context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("dns_delegation read-for-import %q: %w", ref.Name, err)
}
domain := ref.ProviderID
if domain == "" {
domain = ref.Name
}

// Registrar is authoritative — any error is a hard failure.
dom, err := d.client.GetDomainDelegation(ctx, domain)
if err != nil {
return nil, fmt.Errorf("dns_delegation read-for-import %q: %w", ref.Name, err)
}

outputs := map[string]any{
"nameservers": nameserversToAny(dom.Nameservers),
"registrar_nameservers": nameserversToAny(dom.Nameservers),
}
Comment on lines +236 to +239

// Live resolver is best-effort; omit live_nameservers on any failure.
if d.nsResolver != nil {
if liveNS, liveErr := d.nsResolver(ctx, domain); liveErr == nil && len(liveNS) > 0 {
outputs["live_nameservers"] = nameserversToAny(liveNS)
}
}

return &interfaces.ResourceOutput{
Name: ref.Name,
Type: "infra.dns_delegation",
ProviderID: domain,
Outputs: outputs,
}, 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
98 changes: 98 additions & 0 deletions internal/drivers/delegation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -534,3 +534,101 @@ func TestDelegationDriver_Diff_DomainChange_SetsBothNeedsUpdateAndReplace(t *tes
t.Error("NeedsUpdate=false; should be true alongside NeedsReplace")
}
}

func TestDelegationReadForImport_DualNS(t *testing.T) {
// Registrar (authoritative intent) returns dnsimple NS.
// Live public DNS returns digitalocean NS (simulates in-flight NS switch).
fc := &fakeDelegationClient{
getResult: &hoverclient.DomainDelegation{
ID: "domain-x.com",
Name: "x.com",
Nameservers: []string{"ns1.dnsimple.com"},
},
}
liveResolver := func(_ context.Context, domain string) ([]string, error) {
if domain != "x.com" {
t.Fatalf("resolver domain = %q, want x.com", domain)
}
return []string{"ns1.digitalocean.com"}, nil
}
d := NewDelegationDriverWithClientAndResolver(fc, liveResolver)

ref := interfaces.ResourceRef{Name: "x.com", Type: "infra.dns_delegation", ProviderID: "x.com"}
out, err := d.ReadForImport(context.Background(), ref)
if err != nil {
t.Fatalf("ReadForImport: %v", err)
}
if out == nil {
t.Fatal("ReadForImport returned nil output")
}

// Primary "nameservers" key MUST equal the registrar NS (authoritative intent).
ns, ok := out.Outputs["nameservers"].([]any)
if !ok || len(ns) != 1 || ns[0] != "ns1.dnsimple.com" {
t.Errorf("nameservers = %v, want [ns1.dnsimple.com]", out.Outputs["nameservers"])
}

// registrar_nameservers MUST equal the registrar NS.
regNS, ok := out.Outputs["registrar_nameservers"].([]any)
if !ok || len(regNS) != 1 || regNS[0] != "ns1.dnsimple.com" {
t.Errorf("registrar_nameservers = %v, want [ns1.dnsimple.com]", out.Outputs["registrar_nameservers"])
}

// live_nameservers MUST equal the live DNS NS.
liveNS, ok := out.Outputs["live_nameservers"].([]any)
if !ok || len(liveNS) != 1 || liveNS[0] != "ns1.digitalocean.com" {
t.Errorf("live_nameservers = %v, want [ns1.digitalocean.com]", out.Outputs["live_nameservers"])
}

// Structural invariants.
if out.Name != "x.com" {
t.Errorf("Name = %q, want x.com", out.Name)
}
if out.Type != "infra.dns_delegation" {
t.Errorf("Type = %q, want infra.dns_delegation", out.Type)
}
if out.ProviderID != "x.com" {
t.Errorf("ProviderID = %q, want x.com", out.ProviderID)
}
}

func TestDelegationReadForImport_LiveLookupFailsGracefully(t *testing.T) {
// Registrar returns NS; live resolver errors → live_nameservers key omitted.
fc := &fakeDelegationClient{
getResult: &hoverclient.DomainDelegation{
ID: "domain-y.com",
Name: "y.com",
Nameservers: []string{"ns1.dnsimple.com"},
},
}
failingResolver := func(_ context.Context, _ string) ([]string, error) {
return nil, errors.New("DNS lookup failed")
}
d := NewDelegationDriverWithClientAndResolver(fc, failingResolver)

ref := interfaces.ResourceRef{Name: "y.com", Type: "infra.dns_delegation", ProviderID: "y.com"}
out, err := d.ReadForImport(context.Background(), ref)
if err != nil {
t.Fatalf("ReadForImport: %v", err)
}
if out == nil {
t.Fatal("ReadForImport returned nil output")
}

// Primary "nameservers" key MUST be present from registrar.
ns, ok := out.Outputs["nameservers"].([]any)
if !ok || len(ns) != 1 || ns[0] != "ns1.dnsimple.com" {
t.Errorf("nameservers = %v, want [ns1.dnsimple.com]", out.Outputs["nameservers"])
}

// registrar_nameservers MUST be present.
regNS, ok := out.Outputs["registrar_nameservers"].([]any)
if !ok || len(regNS) != 1 || regNS[0] != "ns1.dnsimple.com" {
t.Errorf("registrar_nameservers = %v, want [ns1.dnsimple.com]", out.Outputs["registrar_nameservers"])
}

// live_nameservers MUST be absent when live lookup fails.
if _, present := out.Outputs["live_nameservers"]; present {
t.Errorf("live_nameservers should be absent when live lookup fails; got %v", out.Outputs["live_nameservers"])
}
}
56 changes: 47 additions & 9 deletions internal/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,24 +257,59 @@ func (p *HoverProvider) DetectDrift(ctx context.Context, resources []interfaces.

// Import reads an existing Hover-managed resource and returns IaC adoption
// state. cloudID is the domain name for both infra.dns and infra.dns_delegation.
//
// For infra.dns_delegation, Import uses DelegationDriver.ReadForImport so that
// the registrar NS (authoritative intent) is captured as the primary value, and
// the live public NS is recorded as a propagation annotation. This avoids the
// live-first semantics of DelegationDriver.Read, which would capture stale TTL-
// cached NS during an in-flight NS switch.
func (p *HoverProvider) Import(ctx context.Context, cloudID string, resourceType string) (*interfaces.ResourceState, error) {
if cloudID == "" {
return nil, fmt.Errorf("hover import: provider_id is required")
}
if resourceType == "" {
resourceType = "infra.dns"
}

ref := interfaces.ResourceRef{Name: cloudID, Type: resourceType, ProviderID: cloudID}

// Delegation import: use the dual-fetch ReadForImport path so the
// registrar NS (not the live-first Read result) is the authoritative value.
if resourceType == "infra.dns_delegation" {
d, err := p.ResourceDriver(resourceType)
if err != nil {
return nil, err
}
if dd, ok := d.(*drivers.DelegationDriver); ok {
out, err := dd.ReadForImport(ctx, ref)
if err != nil {
return nil, fmt.Errorf("hover import %q: %w", cloudID, err)
}
if out == nil {
return nil, fmt.Errorf("hover import %q: driver returned nil output", cloudID)
}
return buildResourceState(cloudID, out), nil
}
Comment on lines +283 to +292
}

d, err := p.ResourceDriver(resourceType)
if err != nil {
return nil, err
}
out, err := d.Read(ctx, interfaces.ResourceRef{Name: cloudID, Type: resourceType, ProviderID: cloudID})
out, err := d.Read(ctx, ref)
if err != nil {
return nil, fmt.Errorf("hover import %q: %w", cloudID, err)
}
if out == nil {
return nil, fmt.Errorf("hover import %q: driver returned nil output", cloudID)
}
return buildResourceState(cloudID, out), nil
}

// buildResourceState constructs a ResourceState from a ResourceOutput.
// Used by Import to ensure the delegation and generic Read paths produce an
// identical ResourceState shape.
func buildResourceState(cloudID string, out *interfaces.ResourceOutput) *interfaces.ResourceState {
now := time.Now()
id := out.ProviderID
if id == "" {
Expand All @@ -290,7 +325,7 @@ func (p *HoverProvider) Import(ctx context.Context, cloudID string, resourceType
Outputs: out.Outputs,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
}

// ResolveSizing is a stub: Hover has no compute sizing.
Expand Down Expand Up @@ -326,26 +361,29 @@ func (p *HoverProvider) EnumerateAll(ctx context.Context, resourceType string) (
if p.domains == nil {
return nil, fmt.Errorf("hover: EnumerateAll called on provider that is not initialized — call Initialize first")
}
if resourceType != "infra.dns" {
if resourceType != "infra.dns" && resourceType != "infra.dns_delegation" {
return nil, fmt.Errorf("hover: EnumerateAll: resource type %q not supported", resourceType)
}
domains, err := p.domains.ListDomains(ctx)
if err != nil {
return nil, fmt.Errorf("hover: EnumerateAll infra.dns: %w", err)
return nil, fmt.Errorf("hover: EnumerateAll %s: %w", resourceType, err)
}
out := make([]*interfaces.ResourceOutput, 0, len(domains))
for _, d := range domains {
if d.Name == "" {
continue
}
out = append(out, &interfaces.ResourceOutput{
o := &interfaces.ResourceOutput{
ProviderID: d.Name,
Type: "infra.dns",
Outputs: map[string]any{
Type: resourceType,
}
if resourceType == "infra.dns" {
o.Outputs = map[string]any{
"zone": d.Name,
"domain_id": d.ID,
},
})
}
}
out = append(out, o)
}
return out, nil
}
Expand Down
Loading