// 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 }