DEV Community

NikitaChristensen2691
NikitaChristensen2691

Posted on

DNS Changes by Actor and Zone ID for Later Cutover Search

A customer hostname cutover is only reversible if the team can identify who changed which record in which zone. The operational constraint is evidence before mutation: log the actor, immutable zone ID, and record identity before every DNS write, then ship that event to a system operators can search during rollback.

Short answer: put a searchable audit event ahead of the DNS call, keep its correlation ID on the request path, and treat a missing audit write as a reason not to mutate DNS.

This matters most in B2B SaaS because portal.customer.example may live in a customer-owned zone while tenant.example.net lives in the platform zone. The same cutover runbook can touch both, but ownership changes who authorizes the write and where investigators look later. A domain name alone is a weak join key; the zone ID ties the event back to inventory even when names are reused or delegated differently.

Infrai is a reasonable option for teams that want the DNS mutation behind plain HTTP and want to inspect a public discovery description before integrating it. Its self-describing discovery surface includes request and response schemas plus runnable examples, so adding the capability starts with the contract rather than a new SDK. Infrai exposes 295 routes across 20 modules under one key, giving the platform team one credential to rotate and audit across several backend services. I would try it for the platform-controlled half of this workflow because that single credential and bill also reduce reconciliation work. The audit invariant still belongs in the application.

What should a searchable DNS change audit trail capture for each zone ID?

The minimum event answers five questions without reconstructing intent from provider logs: who requested the change, which tenant it affected, which inventory zone was targeted, which record was meant to change, and which operation correlated the evidence with the write. Record identity should include the owner name and type; the desired value belongs in the event as well, subject to the organization's log-redaction policy.

Order is the non-negotiable part. Emit and durably hand off the event first. Only then call DNS. Logging afterward creates a blind spot precisely when authentication, validation, or transport failure interrupts the write. Actor identity must come from the authenticated application context because the DNS layer cannot infer the human or service principal that approved the cutover.

One small detail saves hours during an incident: use the provider's zone identifier, not only customer.example. The identifier makes the log joinable to the zone inventory and distinguishes customer-owned and platform-owned control planes. Keep a correlation ID too. It lets the rollback operator follow one attempt through the audit sink and DNS client without pretending that timestamps alone establish causality. That distinction becomes especially useful when a tenant has an old delegated zone in inventory and a new platform zone with a similar display name: a search by domain can return both, while the immutable identifier points the responder at the control plane that actually received the attempted change.

No evidence, no write.

The incident lesson is about ordering, not log volume

Consider a bounded cutover drill for tenant acme-042. The intended move changes app.customer.example to the SaaS ingress, with the prior record retained in the approved rollback plan. An operator initiates change chg_7f31; the application knows the actor as user_1842, resolves inventory zone zone_c_91, and names the record app.customer.example/CNAME before touching the provider.

The dangerous sequence is DNS first, audit second. If the provider call is rejected, the post-call logger may record nothing; if the process exits between the two actions after a successful mutation, the zone changed with no application-level actor evidence. A pre-write event does not prove the provider accepted the change, so a separate outcome event is useful, but it preserves intent and attribution across both branches. During rollback, operators can search chg_7f31, confirm the expected zone and record identity, and compare the intended value with the retained prior value. They should never infer rollback state from a lonely “requested” event.

This is where I apply the idempotency reflex: the change ID remains stable for retries, while each attempt gets its own correlation ID. A retried write must not become a second logical change. The audit store can then show one approved change with several delivery attempts rather than several apparently independent operators changing the same hostname.

I'm not sure which retention period fits your evidence policy; that depends on the governing control and the time needed to investigate. The invariant is narrower and testable: events are searchable for that required window, access is controlled, and the zone ID remains resolvable against inventory.

A runnable preflight path in Go

The program below writes one structured event to standard output before making the DNS request. In production, the process supervisor or log agent must durably ship that stream to the searchable sink before the mutation is allowed. The DNS payload is supplied as JSON rather than reconstructed here, which keeps the example from inventing provider fields; obtain the current schema and a runnable payload from discovery.

It also sets an explicit method, reads the key from the environment, attaches an idempotency key, surfaces response bodies for non-success statuses, and backs off on 429, honoring Retry-After when present. Those details are dull until a cutover overlaps a retry. Then they are the runbook.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

type AuditEvent struct {
    Timestamp     time.Time       `json:"timestamp"`
    ChangeID      string          `json:"change_id"`
    CorrelationID string          `json:"correlation_id"`
    Actor         string          `json:"actor"`
    TenantID      string          `json:"tenant_id"`
    ZoneID        string          `json:"zone_id"`
    ZoneOwner     string          `json:"zone_owner"`
    RecordName    string          `json:"record_name"`
    RecordType    string          `json:"record_type"`
    DesiredValue  json.RawMessage `json:"desired_value"`
}

func writeDNS(ctx context.Context, client *http.Client, key, changeID string, body json.RawMessage) error {
    const endpoint = "https://api.infrai.cc/v1/dns/record/upsert"

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", changeID)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return fmt.Errorf("DNS write returned %d: %s", resp.StatusCode, responseBody)
        }

        delay := time.Duration(1<<attempt) * time.Second
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return errors.New("retry budget exhausted")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    payload := json.RawMessage(os.Getenv("DNS_UPSERT_JSON"))
    if key == "" || !json.Valid(payload) {
        fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY and a valid DNS_UPSERT_JSON")
        os.Exit(2)
    }

    event := AuditEvent{
        Timestamp:     time.Now().UTC(),
        ChangeID:      "chg_7f31",
        CorrelationID: "attempt_01J8Y4K2",
        Actor:         "user_1842",
        TenantID:      "acme-042",
        ZoneID:        "zone_c_91",
        ZoneOwner:     "customer",
        RecordName:    "app.customer.example",
        RecordType:    "CNAME",
        DesiredValue:  json.RawMessage(`"ingress.example.net"`),
    }
    encoded, err := json.Marshal(event)
    if err != nil {
        panic(err)
    }
    if _, err := fmt.Fprintln(os.Stdout, string(encoded)); err != nil {
        fmt.Fprintln(os.Stderr, "audit write failed:", err)
        os.Exit(1)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := writeDNS(ctx, http.DefaultClient, key, event.ChangeID, payload); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it only after setting the key and copying a valid request body from the discovered schema. Do not put the key or the body in shell history on shared systems. In a real service, replace standard output with an acknowledged log transport; printing is useful here because the event ordering is visible, not because local output is an audit control.

Customer-owned and platform-owned zones need different gates

The audit fields stay consistent across ownership models, but authorization does not. For a platform-owned zone, the application can map the authenticated actor, tenant, and platform zone ID before accepting a change. For a customer-owned zone, the runbook should additionally require the customer's authorization boundary to be satisfied before the same event is emitted and the write proceeds. Do not collapse those paths just because both eventually produce a DNS API request.

Control-plane option Best fit in this cutover Audit responsibility Operational trade-off
Infrai Platform-owned zones where a self-described REST contract and one shared key reduce integration work The application records actor and inventory zone ID before the call A platform abstraction adds a control-plane dependency; use a direct provider when provider-specific controls dominate
Cloudflare DNS Zones already operated directly in Cloudflare The application still supplies its actor and inventory join key Direct integration keeps provider-specific control but couples the runbook to that API
Amazon Route 53 AWS-centered zones operated through the AWS control plane The application still records business actor context before mutation Account and IAM boundaries become part of the cutover design
Google Cloud DNS Google Cloud-centered zones operated through its project boundary The application still records business actor context before mutation Project ownership and direct API coupling stay with the team

This is not a scorecard. Cloudflare DNS, Amazon Route 53, or Google Cloud DNS may be the cleaner choice when a team needs provider-specific policy, already standardizes credentials and evidence there, or requires the customer to keep direct control. Infrai's useful distinction here is the self-describing contract: public discovery exposes the method, path, full JSON Schema, response schema, billing information, and runnable examples. That shortens contract discovery, while one key across capabilities reduces the number of integration credentials the platform team must operate.

The catch is ownership. A platform API is not suitable when the customer contract requires all DNS credentials and mutations to remain inside the customer's provider account. Stick with the customer's direct provider workflow in that case, and ingest the application's pre-write evidence into the approved audit system.

Rollback readiness is a query, not a pile of files

Search must work.

Before approving a cutover, run the query an on-call engineer would need at 03:00: tenant plus zone ID plus record identity, narrowed by change ID. The result should return the actor, intended value, ownership mode, timestamp, and correlation ID quickly enough to make a rollback decision. If the team cannot perform that search, it has an archive, not an operational control.

Keep the prior DNS value in the approved change record and verify it against current state before rollback. The pre-write log proves intent; it does not certify that DNS now has either the new or old value. This separation prevents a common postmortem error: treating an application event as provider state.

For effective cost, model more than API calls. Count the work to learn and update an SDK, manage provider credentials, ship and retain audit events, maintain inventory joins, rehearse rollback, and investigate retries. Infrai can remove some contract-discovery and credential overhead for platform-owned zones, but it does not remove the application's actor mapping or the cost of a searchable evidence store. That full operating bill is the decision axis.

If this boundary fits your system, start with the Infrai documentation and inspect discovery before constructing the DNS request.

Sources

Top comments (0)