| 1 | // Package dnsrec writes DURABLE DNS records — the DKIM CNAMEs, MAIL FROM MX |
| 2 | // and SPF/DMARC TXT records a sending identity needs (tenant-email spec §7). |
| 3 | // |
| 4 | // It is deliberately NOT internal/edge/dns.go. That package's dnsProvider |
| 5 | // publishes a transient ACME challenge TXT and hands back a cleanup closure; |
| 6 | // its whole contract is "present this, then remove it". These records outlive |
| 7 | // the process that wrote them, are upserted rather than presented, and are |
| 8 | // deleted only on teardown. Putting them behind that interface would mean a |
| 9 | // caller holding a cleanup closure for a record that must never be cleaned up. |
| 10 | package dnsrec |
| 11 | |
| 12 | import ( |
| 13 | "context" |
| 14 | "sync" |
| 15 | ) |
| 16 | |
| 17 | const ( |
| 18 | TypeCNAME = "CNAME" |
| 19 | TypeMX = "MX" |
| 20 | TypeTXT = "TXT" |
| 21 | ) |
| 22 | |
| 23 | // Record is one resource record set. Values is a set, not a single string, |
| 24 | // because a TXT or MX name can legitimately carry several. |
| 25 | type Record struct { |
| 26 | Name string |
| 27 | Type string |
| 28 | Values []string |
| 29 | TTL int64 |
| 30 | } |
| 31 | |
| 32 | // Key identifies the record SET — name and type, never the values. DNS |
| 33 | // replaces a whole set at a time, and identifying by value would make |
| 34 | // EnsureRecord a create (which collides with a stranded record from a |
| 35 | // crashed earlier attempt) instead of an upsert (which repairs it). |
| 36 | func (r Record) Key() string { return r.Name + "|" + r.Type } |
| 37 | |
| 38 | // Writer is the backend seam. Both methods are idempotent: an EnsureRecord |
| 39 | // over an existing set replaces it, and deleting an absent set succeeds. |
| 40 | // Every walk that drives one must be re-runnable after a half-failure. |
| 41 | type Writer interface { |
| 42 | EnsureRecord(ctx context.Context, r Record) error |
| 43 | DeleteRecord(ctx context.Context, r Record) error |
| 44 | } |
| 45 | |
| 46 | // MemWriter is the in-memory fake: no AWS, no network, and a one-shot |
| 47 | // failure injector so a test can drive a walk through a half-failure at an |
| 48 | // exact step. |
| 49 | type MemWriter struct { |
| 50 | mu sync.Mutex |
| 51 | |
| 52 | // Records maps Record.Key() -> the stored set. |
| 53 | Records map[string]Record |
| 54 | // FailNext, keyed by method name, is consumed one-shot: the next call |
| 55 | // to that method fails, then the entry is gone. Consumption happens |
| 56 | // BEFORE any state change, so a failed call leaves state untouched. |
| 57 | FailNext map[string]error |
| 58 | } |
| 59 | |
| 60 | func NewMemWriter() *MemWriter { |
| 61 | return &MemWriter{Records: map[string]Record{}, FailNext: map[string]error{}} |
| 62 | } |
| 63 | |
| 64 | func (w *MemWriter) takeFailure(method string) error { |
| 65 | if err, ok := w.FailNext[method]; ok { |
| 66 | delete(w.FailNext, method) |
| 67 | return err |
| 68 | } |
| 69 | return nil |
| 70 | } |
| 71 | |
| 72 | func (w *MemWriter) EnsureRecord(_ context.Context, r Record) error { |
| 73 | w.mu.Lock() |
| 74 | defer w.mu.Unlock() |
| 75 | if err := w.takeFailure("EnsureRecord"); err != nil { |
| 76 | return err |
| 77 | } |
| 78 | w.Records[r.Key()] = r |
| 79 | return nil |
| 80 | } |
| 81 | |
| 82 | func (w *MemWriter) DeleteRecord(_ context.Context, r Record) error { |
| 83 | w.mu.Lock() |
| 84 | defer w.mu.Unlock() |
| 85 | if err := w.takeFailure("DeleteRecord"); err != nil { |
| 86 | return err |
| 87 | } |
| 88 | delete(w.Records, r.Key()) |
| 89 | return nil |
| 90 | } |
| 91 | |