carlos / platform Public

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.
Paul Campbell 6734ba8b2181af27700a654617635d67468b275f parent 31c5065
2 files changed, +165 −0
  • internal/dnsrec/dnsrec.go +90 −0
  • internal/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)
+ }
+}