dnsrec: a seam for durable DNS records, with its fake
Separate from internal/edge/dns.go on purpose: that package presents a transient ACME challenge and hands back a cleanup closure, and these records must outlive the process that wrote them. Records are keyed by name and type so EnsureRecord is an upsert, which is what repairs a set stranded by a crashed earlier attempt.
2 files changed,
+165
−0
internal/dnsrec/dnsrec.go+90 −0internal/dnsrec/dnsrec_test.go+75 −0
diff --git a/internal/dnsrec/dnsrec.go b/internal/dnsrec/dnsrec.go| new file mode 100644 |
| index 000000000..ed43d7b8d |
| --- /dev/null |
| +++ b/internal/dnsrec/dnsrec.go |
| @@ -0,0 +1,90 @@ |
| +// Package dnsrec writes DURABLE DNS records — the DKIM CNAMEs, MAIL FROM MX |
| +// and SPF/DMARC TXT records a sending identity needs (tenant-email spec §7). |
| +// |
| +// It is deliberately NOT internal/edge/dns.go. That package's dnsProvider |
| +// publishes a transient ACME challenge TXT and hands back a cleanup closure; |
| +// its whole contract is "present this, then remove it". These records outlive |
| +// the process that wrote them, are upserted rather than presented, and are |
| +// deleted only on teardown. Putting them behind that interface would mean a |
| +// caller holding a cleanup closure for a record that must never be cleaned up. |
| +package dnsrec |
| + |
| +import ( |
| + "context" |
| + "sync" |
| +) |
| + |
| +const ( |
| + TypeCNAME = "CNAME" |
| + TypeMX = "MX" |
| + TypeTXT = "TXT" |
| +) |
| + |
| +// Record is one resource record set. Values is a set, not a single string, |
| +// because a TXT or MX name can legitimately carry several. |
| +type Record struct { |
| + Name string |
| + Type string |
| + Values []string |
| + TTL int64 |
| +} |
| + |
| +// Key identifies the record SET — name and type, never the values. DNS |
| +// replaces a whole set at a time, and identifying by value would make |
| +// EnsureRecord a create (which collides with a stranded record from a |
| +// crashed earlier attempt) instead of an upsert (which repairs it). |
| +func (r Record) Key() string { return r.Name + "|" + r.Type } |
| + |
| +// Writer is the backend seam. Both methods are idempotent: an EnsureRecord |
| +// over an existing set replaces it, and deleting an absent set succeeds. |
| +// Every walk that drives one must be re-runnable after a half-failure. |
| +type Writer interface { |
| + EnsureRecord(ctx context.Context, r Record) error |
| + DeleteRecord(ctx context.Context, r Record) error |
| +} |
| + |
| +// MemWriter is the in-memory fake: no AWS, no network, and a one-shot |
| +// failure injector so a test can drive a walk through a half-failure at an |
| +// exact step. |
| +type MemWriter struct { |
| + mu sync.Mutex |
| + |
| + // Records maps Record.Key() -> the stored set. |
| + Records map[string]Record |
| + // FailNext, keyed by method name, is consumed one-shot: the next call |
| + // to that method fails, then the entry is gone. Consumption happens |
| + // BEFORE any state change, so a failed call leaves state untouched. |
| + FailNext map[string]error |
| +} |
| + |
| +func NewMemWriter() *MemWriter { |
| + return &MemWriter{Records: map[string]Record{}, FailNext: map[string]error{}} |
| +} |
| + |
| +func (w *MemWriter) takeFailure(method string) error { |
| + if err, ok := w.FailNext[method]; ok { |
| + delete(w.FailNext, method) |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +func (w *MemWriter) EnsureRecord(_ context.Context, r Record) error { |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + if err := w.takeFailure("EnsureRecord"); err != nil { |
| + return err |
| + } |
| + w.Records[r.Key()] = r |
| + return nil |
| +} |
| + |
| +func (w *MemWriter) DeleteRecord(_ context.Context, r Record) error { |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + if err := w.takeFailure("DeleteRecord"); err != nil { |
| + return err |
| + } |
| + delete(w.Records, r.Key()) |
| + return nil |
| +} |
diff --git a/internal/dnsrec/dnsrec_test.go b/internal/dnsrec/dnsrec_test.go| new file mode 100644 |
| index 000000000..b1e107083 |
| --- /dev/null |
| +++ b/internal/dnsrec/dnsrec_test.go |
| @@ -0,0 +1,75 @@ |
| +package dnsrec |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| +) |
| + |
| +// A record is identified by (name, type), not by value. That is what makes |
| +// EnsureRecord an UPSERT rather than a create, and an upsert is what makes |
| +// every walk in internal/mail re-runnable after a half-failure. |
| +func TestKeyIsNameAndTypeOnly(t *testing.T) { |
| + a := Record{Name: "mail-eu-west-1.example.com", Type: TypeTXT, Values: []string{"v=spf1 -all"}} |
| + b := Record{Name: "mail-eu-west-1.example.com", Type: TypeTXT, Values: []string{"different"}} |
| + if a.Key() != b.Key() { |
| + t.Fatal("Key must not depend on Values — an upsert has to find the record it replaces") |
| + } |
| + c := Record{Name: "mail-eu-west-1.example.com", Type: TypeMX, Values: []string{"10 x"}} |
| + if a.Key() == c.Key() { |
| + t.Fatal("Key must depend on Type — MX and TXT coexist at one name") |
| + } |
| +} |
| + |
| +func TestMemWriterUpsertsAndDeletes(t *testing.T) { |
| + ctx, w := context.Background(), NewMemWriter() |
| + r := Record{Name: "x._domainkey.example.com", Type: TypeCNAME, |
| + Values: []string{"x.dkim.amazonses.com"}, TTL: 300} |
| + if err := w.EnsureRecord(ctx, r); err != nil { |
| + t.Fatalf("EnsureRecord: %v", err) |
| + } |
| + if got := w.Records[r.Key()]; got.Values[0] != "x.dkim.amazonses.com" { |
| + t.Fatalf("record not stored: %+v", got) |
| + } |
| + // Re-ensuring with a new value overwrites rather than duplicating. |
| + r.Values = []string{"y.dkim.amazonses.com"} |
| + if err := w.EnsureRecord(ctx, r); err != nil { |
| + t.Fatalf("re-EnsureRecord: %v", err) |
| + } |
| + if len(w.Records) != 1 || w.Records[r.Key()].Values[0] != "y.dkim.amazonses.com" { |
| + t.Fatalf("upsert did not replace: %+v", w.Records) |
| + } |
| + if err := w.DeleteRecord(ctx, r); err != nil { |
| + t.Fatalf("DeleteRecord: %v", err) |
| + } |
| + if len(w.Records) != 0 { |
| + t.Fatalf("delete left %d records", len(w.Records)) |
| + } |
| +} |
| + |
| +// "Already gone" is a success, because teardown must be re-runnable. |
| +func TestMemWriterDeleteAbsentIsSuccess(t *testing.T) { |
| + w := NewMemWriter() |
| + if err := w.DeleteRecord(context.Background(), |
| + Record{Name: "nope.example.com", Type: TypeTXT}); err != nil { |
| + t.Fatalf("deleting an absent record returned %v, want nil", err) |
| + } |
| +} |
| + |
| +// FailNext is one-shot, so a test can drive a walk through a half-failure |
| +// at an exact step and then let the retry succeed. |
| +func TestMemWriterFailNextIsOneShot(t *testing.T) { |
| + ctx, w := context.Background(), NewMemWriter() |
| + boom := errors.New("boom") |
| + w.FailNext["EnsureRecord"] = boom |
| + r := Record{Name: "a.example.com", Type: TypeTXT, Values: []string{"v"}} |
| + if err := w.EnsureRecord(ctx, r); !errors.Is(err, boom) { |
| + t.Fatalf("first EnsureRecord = %v, want boom", err) |
| + } |
| + if len(w.Records) != 0 { |
| + t.Fatal("a failed call must leave state untouched") |
| + } |
| + if err := w.EnsureRecord(ctx, r); err != nil { |
| + t.Fatalf("second EnsureRecord = %v, want nil", err) |
| + } |
| +} |