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
10 changes: 9 additions & 1 deletion cmd/wfctl/infra_import_all.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,16 @@ func runInfraImportAllWithDeps(ctx context.Context, provider interfaces.IaCProvi
// for ProviderID resolution + AppliedConfig hashing + timestamp normalization
// so the synthesized state matches the single-resource import path exactly.
func buildResourceStateFromImport(zoneName, cloudID, resourceType, providerType string, imported *interfaces.ResourceState) (interfaces.ResourceState, error) {
// Prefix the sanitized zone name with the resource type so that importing
// two different types (e.g. infra.dns and infra.dns_delegation) for the
// same domain produces DISTINCT IDs and therefore distinct on-disk
// filenames. sanitizeStateID maps "/" → "_", so
// "infra.dns/example-com" → infra.dns_example-com.json
// "infra.dns_delegation/example-com" → infra.dns_delegation_example-com.json
// ProviderID stays as the bare cloudID (domain) so record.FromResourceStates
// can group both types into a single portfolio snapshot by (Provider, Domain).
spec := interfaces.ResourceSpec{
Name: sanitizeImportedZoneName(zoneName),
Name: resourceType + "/" + sanitizeImportedZoneName(zoneName),
Type: resourceType,
}
return resourceStateFromImportedState(spec, providerType, imported, cloudID)
Expand Down
124 changes: 124 additions & 0 deletions cmd/wfctl/infra_import_all_format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,127 @@ func TestImportAllSanitizeFlagRequiresPortfolioFormat(t *testing.T) {
t.Errorf("error should mention 'sanitize'; got: %v", err)
}
}

// TestBuildResourceStateFromImport_TypeNamespacedID pins the CRITICAL-1 fix:
// importing infra.dns and infra.dns_delegation for the SAME domain must
// produce DISTINCT state IDs (and therefore distinct on-disk filenames) so
// that a second import does not overwrite the first. ProviderID must stay the
// bare domain in both cases — record.FromResourceStates keys the snapshot
// domain on ProviderID, not on ID.
func TestBuildResourceStateFromImport_TypeNamespacedID(t *testing.T) {
imported := &interfaces.ResourceState{
ProviderID: "example.com",
Type: "infra.dns",
}

dnsState, err := buildResourceStateFromImport("example.com", "example.com", "infra.dns", "hover", imported)
if err != nil {
t.Fatalf("buildResourceStateFromImport infra.dns: %v", err)
}

delegImported := &interfaces.ResourceState{
ProviderID: "example.com",
Type: "infra.dns_delegation",
}
delegState, err := buildResourceStateFromImport("example.com", "example.com", "infra.dns_delegation", "hover", delegImported)
if err != nil {
t.Fatalf("buildResourceStateFromImport infra.dns_delegation: %v", err)
}

// IDs must be distinct so SaveResource writes to different filenames.
if dnsState.ID == delegState.ID {
t.Errorf("infra.dns and infra.dns_delegation produced the same ID %q; want distinct IDs", dnsState.ID)
}

// ProviderID must remain the bare domain so FromResourceStates can group
// both states into a single snapshot.
if dnsState.ProviderID != "example.com" {
t.Errorf("infra.dns ProviderID = %q; want %q", dnsState.ProviderID, "example.com")
}
if delegState.ProviderID != "example.com" {
t.Errorf("infra.dns_delegation ProviderID = %q; want %q", delegState.ProviderID, "example.com")
}

// Verify the on-disk filenames are also distinct via sanitizeStateID.
dnsFname := sanitizeStateID(dnsState.ID) + ".json"
delegFname := sanitizeStateID(delegState.ID) + ".json"
if dnsFname == delegFname {
t.Errorf("sanitized filenames are the same %q; want distinct files", dnsFname)
}
}

// TestDumpPortfolio_MergesDnsAndDelegationForSameDomain pins the end-to-end
// portfolio merge contract (CRITICAL-1 class): when BOTH an infra.dns state
// and an infra.dns_delegation state for the same domain are present in the
// store (using type-namespaced IDs so they coexist), dumpPortfolioToFile must
// produce exactly ONE snapshot for that domain carrying both records and
// authority.registrar_nameservers.
func TestDumpPortfolio_MergesDnsAndDelegationForSameDomain(t *testing.T) {
store := &fakeStateStore{}

// infra.dns state — type-namespaced ID, bare domain as ProviderID.
_ = store.SaveResource(context.Background(), interfaces.ResourceState{
ID: "infra.dns/example-com",
Name: "infra.dns/example-com",
Type: "infra.dns",
Provider: "hover",
ProviderID: "example.com",
Outputs: map[string]any{
"records": []any{
map[string]any{"type": "A", "name": "@", "data": "192.0.2.1", "ttl": 300},
},
},
})

// infra.dns_delegation state — distinct ID, same bare domain as ProviderID.
_ = store.SaveResource(context.Background(), interfaces.ResourceState{
ID: "infra.dns_delegation/example-com",
Name: "infra.dns_delegation/example-com",
Type: "infra.dns_delegation",
Provider: "hover",
ProviderID: "example.com",
Outputs: map[string]any{
"registrar_nameservers": []any{"ns1.example.net", "ns2.example.net"},
},
})

dir := t.TempDir()
out := filepath.Join(dir, "portfolio.json")
if err := dumpPortfolioToFile(context.Background(), store, out, false); err != nil {
t.Fatalf("dumpPortfolioToFile: %v", err)
}

data, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read: %v", err)
}
var p record.Portfolio
if err := json.Unmarshal(data, &p); err != nil {
t.Fatalf("unmarshal portfolio: %v", err)
}

// Exactly one snapshot for example.com.
if len(p.Snapshots) != 1 {
t.Fatalf("want 1 snapshot for example.com; got %d: %+v", len(p.Snapshots), p.Snapshots)
}
snap := p.Snapshots[0]

// Snapshot must carry at least one record from the infra.dns state.
if len(snap.Records) == 0 {
t.Error("snapshot has no records; want at least one (from infra.dns state)")
}

// Snapshot must carry authority.registrar_nameservers from the delegation state.
if snap.Authority == nil {
t.Fatal("snapshot.authority is nil; want registrar_nameservers from infra.dns_delegation state")
}
ns, ok := snap.Authority["registrar_nameservers"]
if !ok {
t.Errorf("snapshot.authority missing registrar_nameservers; got %+v", snap.Authority)
} else {
nsSlice, ok := ns.([]any)
if !ok || len(nsSlice) == 0 {
t.Errorf("registrar_nameservers = %v; want non-empty slice", ns)
}
}
}
33 changes: 33 additions & 0 deletions decisions/0047-dns-delegation-portfolio-authority.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 0047. Represent DNS delegation in the portfolio via Snapshot.Authority

**Status:** Accepted
**Date:** 2026-06-02
**Decision-makers:** codingsloth@pm.me (directed), Claude (Opus 4.8)
**Related:** docs/plans/2026-06-02-dns-delegation-portfolio-design.md

## Context

The DNS catalog (gocodealone-dns import-dns.yml) imports `--type infra.dns` only — hosted records. For domains whose NS delegate elsewhere, those are parking/placeholder records, and the registrar-level NS delegation (which provider actually serves each domain) is never captured. The canonical portfolio schema `workflow.dns-portfolio.export.v1` already has an unused `Snapshot.Authority map[string]any` field. User requires BOTH layers in the catalog — delegation AND hosted records — so that records staged at a provider ahead of an NS cutover stay visible (live-only would hide them).

## Decision

Populate `Snapshot.Authority` with delegation NS, merged by `(provider, domain)` with hosted records, so one snapshot carries both layers. `record.FromResourceStates` groups states by `(provider, domain)`: `infra.dns` → `Records`; `infra.dns_delegation` → `Authority`. `workflow-plugin-hover` `EnumerateAll` gains an `infra.dns_delegation` case.

**Capture BOTH registrar and live NS** (`authority.registrar_nameservers` from `GetDomainDelegation` = registrar intent/authoritative; `authority.live_nameservers` from public DNS = propagation). The registrar-vs-live gap IS the NS-switch-staging signal the user requires. Critically: the catalog must source `registrar_nameservers` from `GetDomainDelegation` explicitly — NOT from `DelegationDriver.Read`, which returns the live public lookup first (so a naive import would capture stale live NS during a cutover). `Read`/drift behavior is left unchanged (no DNS-provider drift blast radius); the delegation `EnumerateAll`/`Import` path sources registrar+live directly.

**Consumer read model:** `authority` attaches to the registrar's snapshot (provider=hover). To find where a domain is live, match `registrar_nameservers` to a provider; a Hover snapshot whose `registrar_nameservers` point elsewhere carries staging/placeholder records.

Alternatives rejected:
- **Side-file (`--format state`)** — splits the catalog into two inconsistent formats.
- **NS-as-records** — conflates registrar delegation with in-zone NS records.
- **Live-NS-only** — captures the wrong NS during a cutover (defeats the staging-visibility requirement).
- **Change `DelegationDriver.Read` to registrar-primary** — would fix the source but changes drift semantics across the DNS-provider ecosystem; isolated to the catalog path instead.
- **Single EnumerateAll pass emitting both types** — overloads the `--type` filter contract; shared browser profile mitigates the double-login cost instead.

## Consequences

- 3-repo cascade (workflow engine + hover plugin + gocodealone-dns) + 2 releases (wfctl + hover v0.5.1). Engine change is ~15 lines + tests.
- Additive schema use — `Authority` was always present (`omitempty`); no schema break, existing portfolio consumers unaffected.
- A snapshot now distinguishes live (NS point here) from staging/placeholder (NS elsewhere) records.
- Revertible per-repo by version pins; reverting canonicalize returns portfolios to records-only.
- DO delegation not captured (DO NS are self-referential; the registrar Hover holds the real delegation) — future-optional, not built.
132 changes: 111 additions & 21 deletions dns/record/canonicalize.go
Original file line number Diff line number Diff line change
@@ -1,49 +1,139 @@
package record

import "github.com/GoCodeAlone/workflow/interfaces"
import (
"sort"
"strings"
"unicode"

"github.com/GoCodeAlone/workflow/interfaces"
)

// FromResourceStates converts imported IaC state into a canonical Portfolio.
// Reads each infra.dns ResourceState's records (Outputs preferred, else
// AppliedConfig), renaming provider-specific value keys to the canonical "value".
// Each infra.dns_delegation state populates Snapshot.Authority with
// registrar_nameservers and live_nameservers for the matching domain.
// States of other types are silently skipped.
//
// Snapshots are grouped by (Provider, Domain) so that infra.dns and
// infra.dns_delegation states for the same domain are merged into one Snapshot.
// Output is sorted by (Provider, Domain) for deterministic order.
//
// Provider value-key divergence (verified against provider drivers):
// - DigitalOcean + Cloudflare emit "data"
// - Hover emits "content" (workflow-plugin-hover/internal/drivers/dns.go:538)
// - Namecheap emits "address"
//
// The valueAlias helper resolves the first non-empty of: data → content → address → value.
// Non-infra.dns states are silently skipped.
func FromResourceStates(states []interfaces.ResourceState) Portfolio {
p := Portfolio{Schema: SchemaV1}
for i := range states {
st := &states[i]
if st.Type != "infra.dns" {
continue

type key struct{ provider, domain string }
order := []key{}
snapByKey := map[key]*Snapshot{}

getOrCreate := func(provider, domain string) *Snapshot {
k := key{provider, domain}
if s, ok := snapByKey[k]; ok {
return s
}
Comment on lines +35 to 39
recs := pickRecords(st.Outputs, st.AppliedConfig)
snap := Snapshot{
ID: st.ID,
Provider: st.Provider,
Domain: st.ProviderID,
s := &Snapshot{
ID: provider + "-" + sanitizeDomainForID(domain),
Provider: provider,
Domain: domain,
Records: []Record{},
}
// Fall back to AppliedConfig["domain"] if ProviderID is empty.
if snap.Domain == "" {
if d, ok := st.AppliedConfig["domain"].(string); ok {
snap.Domain = d
snapByKey[k] = s
order = append(order, k)
return s
}

for i := range states {
st := &states[i]

switch st.Type {
case "infra.dns":
domain := st.ProviderID
if domain == "" {
if d, ok := st.AppliedConfig["domain"].(string); ok {
domain = d
}
}
}
for _, raw := range recs {
m, ok := raw.(map[string]any)
if !ok {
if domain == "" {
continue
}
snap.Records = append(snap.Records, recordFromMap(m))
snap := getOrCreate(st.Provider, domain)
recs := pickRecords(st.Outputs, st.AppliedConfig)
for _, raw := range recs {
m, ok := raw.(map[string]any)
if !ok {
continue
}
snap.Records = append(snap.Records, recordFromMap(m))
}

case "infra.dns_delegation":
domain := st.ProviderID
if domain == "" {
if d, ok := st.AppliedConfig["domain"].(string); ok {
domain = d
}
}
if domain == "" {
continue
}
snap := getOrCreate(st.Provider, domain)
for _, nsKey := range []string{"registrar_nameservers", "live_nameservers"} {
if v, ok := st.Outputs[nsKey]; ok {
if slice, ok := v.([]any); ok {
cp := make([]any, len(slice))
copy(cp, slice)
if snap.Authority == nil {
snap.Authority = map[string]any{}
}
snap.Authority[nsKey] = cp
}
}
}

default:
continue
}
}

// Sort by (provider, domain) for deterministic output.
sort.Slice(order, func(i, j int) bool {
if order[i].provider != order[j].provider {
return order[i].provider < order[j].provider
}
p.Snapshots = append(p.Snapshots, snap)
return order[i].domain < order[j].domain
})

for _, k := range order {
p.Snapshots = append(p.Snapshots, *snapByKey[k])
}
return p
}

// sanitizeDomainForID converts a domain string into an ID-safe slug:
// lowercase, runs of non-alphanumeric runes (incl. '.' and '/') replaced
// with a single '-', leading/trailing '-' trimmed.
func sanitizeDomainForID(s string) string {
s = strings.ToLower(s)
var b strings.Builder
inRun := false
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
inRun = false
} else if !inRun {
b.WriteByte('-')
inRun = true
}
}
return strings.Trim(b.String(), "-")
}

// pickRecords returns the records slice from Outputs if non-empty,
// otherwise falls back to AppliedConfig.
func pickRecords(outputs, appliedConfig map[string]any) []any {
Expand Down
Loading
Loading