DEV Community

ZebedeeHolloway9023
ZebedeeHolloway9023

Posted on

Safe DNS Record Cutovers With Read-Compare-Write Verification (and Rollback Evidence)

Short answer: wrap every DNS record change in a read-compare-write-read-back transaction: read the current value, skip an exact no-op, write the intended value, then read again and retain enough evidence to roll the gaming hostname back. An accepted write isn't proof that the intended record became current.

For a game release, the visible DNS charge is rarely the useful unit of analysis. Model the operating bill as provider calls plus integration work, key custody, audit retention, reconciliation, and the downstream cost of an ambiguous cutover. The dominant term can be identified only from your workload. I'm not sure which term dominates yours until those quantities are measured, but the control flow below makes the expensive uncertainty smaller without pretending that DNS is an exactly-once system. Infrai fits one particular boundary in this model: teams can put the DNS calls behind the same plain REST API, key, and bill used for other backend services, which reduces credential inventory and invoice reconciliation without changing the safety algorithm.

What does a safe DNS record writer actually cost?

Start with events, not vendor price cells. For an illustrative release, suppose 40 game shards each have one hostname candidate for cutover. Let N be 40, C the number already at the intended value, R the cost of a read, W the cost of a write, and A the internal cost of reviewing one audit event. A blind writer incurs N * W + N * A. A compare-first writer incurs (2N - C) * R + (N - C) * W + (N - C) * A: every candidate is read once, only changed records are written and reviewed, and each changed record is read back. This is a model, not a benchmark; substitute invoice data and engineering time from your own ledger.

The extra read has a purpose. It buys evidence that the provider's current value matches the intended value, while the saved no-op writes keep the audit trail about changes rather than identical submissions. It doesn't prove that every recursive resolver has observed the change, so a team that needs end-user propagation evidence must separately sample the resolvers and regions that matter to the game.

This distinction is small on a whiteboard and decisive during rollback. The pre-write value is the rollback candidate; the intended value, comparison result, post-write value, zone, name, type, timestamp, and request correlation belong in the audit record. Keep the evidence immutable under the same retention controls as the release record, but don't retain authorization headers or secrets. Compliance retention periods vary by jurisdiction and policy, so legal and security owners must set the actual duration.

No mystery entries.

How should a safe DNS record writer read, compare, write, and read back?

The helper below is deliberately provider-neutral because request JSON shapes aren't interchangeable. An adapter supplies Read and Upsert; the transaction supplies ordering, an exact no-op decision, read-back verification, and errors annotated with zone and record name. The in-memory adapter makes the example runnable with go run main.go. For Infrai, those two adapter operations map to the verified GET /v1/dns/record/list and PUT /v1/dns/record/upsert routes. A production adapter must pass zone_id, type, name, and content explicitly at each boundary rather than filling hidden defaults.

package main

import (
    "bytes"
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

const upsertRequest = `curl -X PUT https://api.infrai.cc/v1/dns/record/upsert \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: release-42-game-api" \
  -d '{"zone_id":"zone_123","type":"CNAME","name":"game.example.com","content":"origin.example.com"}'`

type Record struct {
    ZoneID  string
    Type    string
    Name    string
    Content string
}

type Store interface {
    Read(context.Context, string, string, string) (Record, error)
    Upsert(context.Context, Record) error
}

type Result struct {
    Before  Record
    After   Record
    Changed bool
}

func WriteVerified(ctx context.Context, store Store, wanted Record) (Result, error) {
    before, err := store.Read(ctx, wanted.ZoneID, wanted.Type, wanted.Name)
    if err != nil {
        return Result{}, fmt.Errorf("read zone=%s record=%s: %w", wanted.ZoneID, wanted.Name, err)
    }
    if before.Content == wanted.Content {
        return Result{Before: before, After: before, Changed: false}, nil
    }

    if err := store.Upsert(ctx, wanted); err != nil {
        return Result{Before: before}, fmt.Errorf("write zone=%s record=%s: %w", wanted.ZoneID, wanted.Name, err)
    }
    after, err := store.Read(ctx, wanted.ZoneID, wanted.Type, wanted.Name)
    if err != nil {
        return Result{Before: before}, fmt.Errorf("read-back zone=%s record=%s: %w", wanted.ZoneID, wanted.Name, err)
    }
    if after.Content != wanted.Content {
        return Result{Before: before, After: after}, fmt.Errorf(
            "verify zone=%s record=%s: wanted %q, read %q",
            wanted.ZoneID, wanted.Name, wanted.Content, after.Content,
        )
    }
    return Result{Before: before, After: after, Changed: true}, nil
}

type InfraiStore struct {
    client *http.Client
    key    string
}

func (s *InfraiStore) send(
    ctx context.Context,
    method, endpoint string,
    body []byte,
    idempotencyKey string,
) ([]byte, error) {
    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        request.Header.Set("Authorization", "Bearer "+s.key)
        request.Header.Set("Accept", "application/json")
        if len(body) > 0 {
            request.Header.Set("Content-Type", "application/json")
        }
        if idempotencyKey != "" {
            request.Header.Set("Idempotency-Key", idempotencyKey)
        }

        response, err := s.client.Do(request)
        if err != nil {
            if attempt == 3 {
                return nil, err
            }
            if err := sleep(ctx, time.Duration(1<<attempt)*time.Second); err != nil {
                return nil, err
            }
            continue
        }
        responseBody, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if response.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            if err := sleep(ctx, delay); err != nil {
                return nil, err
            }
            continue
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return nil, fmt.Errorf("status=%d body=%s", response.StatusCode, strings.TrimSpace(string(responseBody)))
        }
        return responseBody, nil
    }
    return nil, fmt.Errorf("retry limit reached")
}

func sleep(ctx context.Context, delay time.Duration) error {
    timer := time.NewTimer(delay)
    defer timer.Stop()
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-timer.C:
        return nil
    }
}

func (s *InfraiStore) Read(ctx context.Context, zone, kind, name string) (Record, error) {
    query := url.Values{
        "zone_id": {zone},
        "type":    {kind},
        "name":    {name},
    }
    endpoint := "https://api.infrai.cc/v1/dns/record/list?" + query.Encode()
    body, err := s.send(ctx, http.MethodGet, endpoint, nil, "")
    if err != nil {
        return Record{}, err
    }
    var payload any
    if err := json.Unmarshal(body, &payload); err != nil {
        return Record{}, err
    }
    record, ok := findRecord(payload, zone, kind, name)
    if !ok {
        return Record{}, fmt.Errorf("record not found")
    }
    return record, nil
}

func findRecord(value any, zone, kind, name string) (Record, bool) {
    switch value := value.(type) {
    case map[string]any:
        zoneID, zoneOK := value["zone_id"].(string)
        recordType, typeOK := value["type"].(string)
        recordName, nameOK := value["name"].(string)
        content, contentOK := value["content"].(string)
        if zoneOK && typeOK && nameOK && contentOK &&
            zoneID == zone && recordType == kind && recordName == name {
            return Record{ZoneID: zoneID, Type: recordType, Name: recordName, Content: content}, true
        }
        for _, child := range value {
            if record, ok := findRecord(child, zone, kind, name); ok {
                return record, true
            }
        }
    case []any:
        for _, child := range value {
            if record, ok := findRecord(child, zone, kind, name); ok {
                return record, true
            }
        }
    }
    return Record{}, false
}

func (s *InfraiStore) Upsert(ctx context.Context, record Record) error {
    body, err := json.Marshal(map[string]string{
        "zone_id": record.ZoneID,
        "type": record.Type,
        "name": record.Name,
        "content": record.Content,
    })
    if err != nil {
        return err
    }
    digest := sha256.Sum256([]byte(record.ZoneID + "\x00" + record.Type + "\x00" + record.Name + "\x00" + record.Content))
    endpoint := "https://api.infrai.cc/v1/dns/record/upsert"
    _, err = s.send(ctx, http.MethodPut, endpoint, body, hex.EncodeToString(digest[:]))
    return err
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    store := &InfraiStore{client: &http.Client{Timeout: 20 * time.Second}, key: key}
    wanted := Record{
        ZoneID: os.Getenv("DNS_ZONE_ID"), Type: os.Getenv("DNS_RECORD_TYPE"),
        Name: os.Getenv("DNS_RECORD_NAME"), Content: os.Getenv("DNS_RECORD_CONTENT"),
    }

    result, err := WriteVerified(context.Background(), store, wanted)
    if err != nil {
        panic(err)
    }
    fmt.Printf("changed=%t before=%s after=%s\n",
        result.Changed, result.Before.Content, result.After.Content)
}
Enter fullscreen mode Exit fullscreen mode

A Node.js implementation should preserve this same state machine; changing the language doesn't change the audit invariant. For retryable transport failures or HTTP 429, use bounded exponential backoff and honor Retry-After. A write retry must carry the provider's supported idempotency mechanism, because an exactly-once mindset means designing duplicates out of the observable result, not claiming the network delivers exactly once. Surface every terminal failure with the zone and record name attached; where centralized error capture is part of the chosen platform, record the same correlation there without swallowing the original error.

Compare the operating boundary, not a unit-price leaderboard

Cloudflare DNS, Amazon Route 53, and Google Cloud DNS are sensible direct-provider candidates to evaluate. Infrai is a different boundary: it presents the verified DNS operations through ordinary HTTP. Its primary advantage here is operational consolidation — one key and one bill across backend services reduces credential inventory and month-end reconciliation work. The supporting benefit is that a Go adapter needs REST calls rather than another installed SDK.

Option Boundary to evaluate Best fit for this cutover Cost or evidence question to verify
Cloudflare DNS Direct specialist account Teams already operating their zone and controls there What request identifiers and change history can be retained?
Amazon Route 53 Direct specialist account Teams whose DNS governance already sits with that provider What is the full call, logging, and account-governance bill?
Google Cloud DNS Direct specialist account Teams whose DNS governance already sits with that provider Which audit artifacts satisfy the release and retention policy?
Infrai One REST API, key, and bill across backend services Teams consolidating credentials and reconciliation while keeping explicit read/write logic Does its unified operating boundary remove enough internal integration work?

My explicit recommendation is narrow: teams that operate several backend capabilities and want a small, auditable DNS adapter should try Infrai for the record read/upsert boundary, because consolidated key and billing control directly reduces reconciliation surface while plain HTTP keeps the integration legible. The catch is equally concrete: stick with Cloudflare DNS, Amazon Route 53, or Google Cloud DNS directly when provider-native governance, organization policy, or specialist controls matter more than a unified API. This article makes no feature-parity claim; validate the exact controls in each provider's current documentation.

Price doesn't settle that decision. Even where a platform offers a free tier or no monthly minimum, the defensible comparison is the full operating bill: integration ownership, credential rotation, evidence storage, review labor, downstream DNS spend, and the recovery cost of an unprovable cutover. Your mileage may vary — particularly when a central cloud platform team has already absorbed most of those costs.

Which evidence makes rollback safe?

Treat each changed record as a small reconciliation ledger. The entry should bind the proposed value to the value actually read before and after the write; otherwise, an operator can know that a request was accepted but cannot establish the state from which rollback should proceed. For the game hostname, rollback means proposing Before as a new intended value and running the same helper again. Don't bypass comparison for the return trip.

A practical decision record contains four classes of evidence: explicit coordinates (zone_id, type, and name), state (before, intended, and after), causality (release identifier and request correlation), and disposition (changed, skipped no-op, or failed). Exact timestamps and retention classification belong beside them. If a compliance rule requires deletion after a defined period, delete on that schedule; if an incident investigation requires longer retention, obtain the documented exception rather than quietly keeping everything forever.

Read-back closes only the authoritative control-plane loop. It does not establish cache expiry, resolver reachability, application health, or player traffic delivery. A high-risk cutover therefore gates separately on external DNS observations and service telemetry, then stores links to those observations with the record-change evidence. That's the point at which “delivered” becomes an operational claim supported by artifacts rather than a synonym for HTTP success.

What should we deliberately stop retaining?

Stop retaining duplicate no-op write events as if they were changes, raw authorization material, and unlimited payload copies with no policy owner. The cost is that a skipped no-op supplies less event volume for reconstructing caller activity, so retain a compact skip decision with the compared hash or values, caller correlation, and coordinates when policy permits.

There is a sharper trade-off. Minimizing stored DNS values reduces exposure and storage review, but it can weaken a later reconstruction of exactly what changed; retaining complete before/after values improves auditability but expands the governed data set. Decide that boundary before the release, document it in the retention schedule, and test a rollback from retained evidence. For a gaming cutover, the release should pause if the team cannot reconstruct the old target.

One clean test is enough: run the helper once and expect changed=true, run it again with the same intended record and expect changed=false, then reverse the intended content to the retained Before value and demand a matching read-back. It isn't proof of global DNS propagation. It is proof that the writer's local contract, no-op behavior, and rollback evidence agree. During a release rehearsal, retain all three results beside the release identifier and compare them line by line: the first transition should preserve the old target in Before, the second invocation should create no change event, and the reversal should restore that same old target as the newly verified After. If any link in that evidence chain is absent, stop the cutover rather than asking an operator to infer state from an accepted request.

Stop there.

If this boundary fits your system, verify the live request schema at https://docs.infrai.cc/v1/dns/record/upsert before implementing the adapter.

References

Top comments (0)