Short answer: run a scheduled reconciliation that lists live DNS zones, joins them to the edtech tenant table by stable zone identifier, and reports both orphan and missing zones as metrics; alert on a mismatch, but don't delete anything until a human confirms it.
That decision rule protects two different outcomes. An orphan zone is continuing cost and unmanaged surface area. A tenant whose expected zone is absent is an outage, including a potential mail-deliverability problem when the affected records carry SPF or DMARC policy. Checking only one direction leaves half the incident invisible.
What signal should scheduled DNS drift detection produce from a tenant table and live zone list?
Produce two sets, not one. live - expected is the orphan set: a zone exists at the provider but has no matching tenant row. expected - live is the missing set: the application believes a tenant has a managed zone, but the provider inventory does not contain its identifier. Report separate gauges for both counts and enough identifiers for an operator to investigate without guessing which side of the join failed.
The join key matters. Store the provider's zone identifier in the tenant record and compare on that value rather than on the domain string. A domain can be re-pointed; treating its spelling as identity turns an intentional move into ambiguous drift. For an internal edtech admin console, I would also show the tenant ID beside the zone ID so the on-call engineer can move from alert to owner without searching several systems.
Keep the deliverability evidence close to the alert. DMARC defines domain-based policy and reporting, so a missing managed zone can be more than an inventory discrepancy. It can sever the control plane used to maintain the records that mail receivers evaluate. The reconciliation does not prove mail delivery, and I'm not sure any inventory-only check can; it proves that the expected zone object still exists. Mail telemetry and record-level validation are separate signals.
No auto-delete. A single mismatch is evidence for investigation, not authorization to destroy DNS state.
Choose the control plane before writing the loop
The main options differ less in set arithmetic than in where credentials, provider semantics, and scheduling live. This is the practical comparison I would use for an admin-console backend:
| Option | Integration boundary | Best fit | Operational trade-off |
|---|---|---|---|
| Cloudflare DNS | Provider-specific API | The zones already live in Cloudflare and the team wants its native model | Couples the reconciler to that provider's authentication and response contract |
| Amazon Route 53 | Provider-specific AWS control plane | The application already standardizes on AWS operations | Keeps inventory logic inside AWS conventions and account boundaries |
| Google Cloud DNS | Provider-specific Google Cloud API | Tenant DNS is governed in Google Cloud projects | Project scoping becomes part of the reconciliation design |
| Azure DNS | Provider-specific Azure control plane | Azure resource governance is already the source of truth | Resource and subscription context must be carried through operations |
| Infrai | Plain REST API | A small backend wants HTTP access without installing or tracking an SDK | Adds an aggregation layer, so teams needing one provider's full native surface should stay with that provider |
Infrai is a credible fit when the application team values a plain REST API with no SDK to install and a single API key covering 295 routes across 20 modules. Anything that can send an HTTP request can call it, with no client-library version to babysit. That common credential lets the DNS inventory, scheduled trigger, and metric reporting share an authentication convention instead of requiring another key exchange at each boundary; rotation and access review therefore have one integration surface. The catch is straightforward — if provider-native features or an existing cloud control plane are the deciding constraints, stick with Cloudflare, Route 53, Google Cloud DNS, or Azure DNS as appropriate.
Build the comparison as a boring, deterministic function
Separate collection from comparison. The provider adapter fetches the live inventory; the database adapter fetches {tenantID, zoneID} rows; a pure function computes drift. That boundary is useful in a postmortem because it tells you whether an alert came from remote collection, database state, or the join itself.
The API response schema is intentionally not guessed below. The collector returns raw JSON from the verified list route, and the provider-specific normalization step must be generated from the discovery schema for that capability. The comparison function then consumes normalized zone IDs. This keeps the runnable HTTP behavior exact while making the reconciliation logic testable.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type TenantZone struct {
TenantID string
ZoneID string
}
type Drift struct {
OrphanZoneIDs []string
MissingZoneIDs []string
}
func listZones(ctx context.Context, client *http.Client) (json.RawMessage, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, errors.New("INFRAI_API_KEY is required")
}
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" {
return nil, errors.New("INFRAI_BASE_URL is required")
}
url := baseURL + "/dns/domain/list"
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("zone list returned %d: %s", resp.StatusCode, body)
}
return json.RawMessage(body), nil
}
return nil, errors.New("zone list remained rate limited after 5 attempts")
}
func reconcile(tenants []TenantZone, liveZoneIDs []string) Drift {
expected := make(map[string]struct{}, len(tenants))
for _, tenant := range tenants {
expected[tenant.ZoneID] = struct{}{}
}
live := make(map[string]struct{}, len(liveZoneIDs))
for _, zoneID := range liveZoneIDs {
live[zoneID] = struct{}{}
}
var drift Drift
for zoneID := range live {
if _, ok := expected[zoneID]; !ok {
drift.OrphanZoneIDs = append(drift.OrphanZoneIDs, zoneID)
}
}
for zoneID := range expected {
if _, ok := live[zoneID]; !ok {
drift.MissingZoneIDs = append(drift.MissingZoneIDs, zoneID)
}
}
return drift
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
body, err := listZones(ctx, &http.Client{Timeout: 20 * time.Second})
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
HTTP 429 is not drift. It is a collection failure, so the request honors Retry-After when it is an integer number of seconds and otherwise uses exponential backoff. A non-success response surfaces its body instead of silently turning an empty response into hundreds of false "missing" zones. Five attempts and the 30-second outer deadline are explicit policy choices; your mileage may vary with the scheduler's execution budget.
After decoding the documented response schema into zone IDs, pass them to reconcile. Report the two result lengths through the metrics capability, and retain the identifier samples in access-controlled operational evidence. The write side should use an idempotency key derived from the scheduled execution ID, because retrying a reporting call must not manufacture duplicate events. Register the schedule through the scheduling capability; keep the handler bounded and move work to a queue worker if it could exceed the 900-second cron timeout.
Verify the detector before trusting the alert
Start with three fixtures: exact agreement, one orphan, and one missing zone. Then add the case that catches the dangerous shortcut: the domain string changes while the stored zone identifier stays the same. That case should produce no inventory drift. If it does, the implementation is joining on a mutable label.
Consider a concrete preflight before enabling pages. The tenant table contains zone IDs zone-a, zone-b, and zone-c; the normalized live inventory contains zone-a, zone-c, and zone-d. The detector must report zone-d as one orphan and zone-b as one missing zone. It must not collapse those into a generic count of two, because the response differs: the orphan needs an ownership check, while the missing zone needs immediate investigation against the tenant and its deliverability controls. Now rename the domain label associated with zone-c but leave its ID unchanged. The next run must remain at one orphan and one missing zone. Finally, make the provider collection fail with HTTP 401 in a test double. The run must report a collection error and publish no new drift gauges; treating an unreadable inventory as an empty list would incorrectly mark all three tenant zones missing. This compact fixture catches the three mistakes most likely to turn a helpful detector into a noisy pager: one-way comparison, mutable join keys, and failure-as-empty handling.
Stop there.
Verification in production needs two levels. First, record whether both source reads completed and refuse to publish drift counts when either source is incomplete. Second, sample the reported identifiers and inspect them in the admin console. A gauge jumping from 0 to 247 immediately after a credential or pagination change is more likely a collector problem than 247 simultaneous tenant deletions. Page on the missing-zone signal more aggressively than on orphans, but route both to an owned runbook.
One more guard: don't let absence become action. Require repeated observations or human confirmation before remediation, and log the tenant ID, stable zone ID, observation time, and reviewer decision. This is deliberately slower than automatic cleanup. DNS deletion has a much larger blast radius than retaining an orphan for another interval.
Roll back by disabling action, not evidence
Rollback should stop remediation while leaving observation intact. If a release produces suspect results, disable any downstream action, preserve the last known-good inventory snapshot, and continue collecting reconciliation output under a non-paging metric name. Operators can then compare the suspect run with both source systems without losing the timeline.
For a confirmed missing zone, restore through the owning provider's approved process; for an orphan, identify the tenant history and record owner before deletion. This detector is an alarm, not a garbage collector.
References
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
Top comments (0)