Smoke Test Routing for Microservice DR Drills

This page implements one concrete task inside smoke test routing logic: a headless Python controller that provisions header-scoped ingress routing, steers cryptographically tagged synthetic requests to an isolated restore sandbox during a microservice DR drill, and exits with a strict POSIX status code the drill runner can branch on. Automated backup validation across a microservice topology needs deterministic traffic isolation — DNS cutover and static load-balancer reconfiguration introduce split-brain windows, propagation latency, and the risk that a synthetic payload lands on a live data plane. The controller here binds a per-drill identifier to a service-mesh routing rule so verification runs entirely inside the boundaries built by sandbox provisioning automation, maps every failed probe into a status tier consumed by downstream error categorization frameworks, and treats a “green” verdict as proof only that the restored service answered a smoke test inside the windows defined by your RTO and RPO mapping — never as a promotion signal on its own.

Architecture and Execution Model

Header-scoped smoke test routing sequence for a DR drill A sequence across four lifelines: Drill Controller, Istio Ingress, DR Sandbox, and Production Service. The controller applies a header-scoped VirtualService to the ingress, then sends a request carrying a signed X-Drill-Context header. The ingress matches the exact header and routes to the DR sandbox, which returns HTTP 200 for a passing smoke test. Any request without the header falls through to the production service untouched. After validation the controller tears the route down in a finally block. An unreachable sandbox returns HTTP 503 and never fails over to production. Drill Controller Istio Ingress DR Sandbox Production Service Apply header-scoped route Send signed X-Drill-Context Exact header match → sandbox HTTP 200 · smoke passes No header → production (untouched) Teardown route (finally) Unreachable sandbox → HTTP 503 never fails over to production

Figure. Sequence showing header-scoped steering of signed synthetic requests to the restore sandbox while production traffic and isolation guarantees stay intact.

The controller evaluates match conditions at the edge proxy before any downstream service processes a request. A drill request carries a signed X-Drill-Context header; the ingress proxy inspects it, and only an exact match is routed to the sandbox host. Every other request falls through to the default route and reaches production untouched. Isolation is a property of the mesh, not of DNS: the route lives for the validation window and is garbage-collected the instant the drill ends, so there is no propagation lag and no residual state to reconcile.

Prerequisites

  • Python 3.8+ (the IntEnum ordering and typing usage are stable from 3.8 onward).

  • The official Kubernetes client and requests, installed into the automation environment:

    bash
    pip install "kubernetes>=29.0" "requests>=2.31"
    
  • An Istio mesh (1.18+) fronting the target microservices, with the sandbox restore already staged into an isolated namespace by the provisioning stage.

  • In-cluster or kubeconfig access scoped to the drill. Run the controller from a Job inside the drill namespace (in-cluster) or from a bastion with a scoped kubeconfig — never against the production control plane.

  • A dedicated drill ServiceAccount with a Role/RoleBinding granting create, patch, and delete on networking.istio.io/virtualservices within the drill namespace:

    yaml
    rules:
      - apiGroups: ["networking.istio.io"]
        resources: ["virtualservices"]
        verbs: ["get", "list", "create", "patch", "delete"]
    
  • A shared drill-signing secret available to the controller as DRILL_SIGNING_KEY. The header value is an HMAC of the drill UUID so a leaked or replayed context token cannot be forged by an unprivileged client to reach the sandbox.

Production Implementation

The controller derives a signed drill context, provisions a VirtualService whose only match steers that context to the sandbox host, runs a smoke test through the mesh ingress, and tears the route down in a finally block so no route survives the process. It returns a deterministic status for CI/CD or DR automation hooks: a route that fails to apply, a sandbox that is unreachable, or a smoke test that returns a non-success code all fail the run.

python
#!/usr/bin/env python3
"""Header-scoped smoke test router for microservice DR drills.

Provisions an Istio VirtualService that steers a signed drill-context header to
an isolated restore sandbox, runs a smoke test through the mesh ingress, and
tears the route down regardless of outcome.

Exit codes (consumed by the DR drill runner):
    0  the route applied, the sandbox answered, and the smoke test passed
    1  the smoke test failed, or the sandbox was unreachable during the drill
    2  usage / configuration / cluster-access error -> abort the drill
"""
import hashlib
import hmac
import logging
import os
import sys
import uuid
from enum import IntEnum
from typing import Any, Dict

import requests
from kubernetes import client, config
from kubernetes.client.rest import ApiException

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("dr_smoke_router")

GROUP = "networking.istio.io"
VERSION = "v1beta1"
PLURAL = "virtualservices"


class SmokeStatus(IntEnum):
    PASS = 0
    FAIL = 1
    ABORT = 2


class DrillTrafficRouter:
    def __init__(self, namespace: str, service: str, sandbox_host: str, signing_key: bytes):
        try:
            config.load_incluster_config()
        except config.ConfigException:
            config.load_kube_config()
        self.api = client.CustomObjectsApi()
        self.namespace = namespace
        self.service = service
        self.sandbox_host = sandbox_host
        self.drill_id = str(uuid.uuid4())
        # HMAC the UUID so an unprivileged client cannot forge a routable header.
        self.drill_context = hmac.new(
            signing_key, self.drill_id.encode(), hashlib.sha256
        ).hexdigest()
        self.route_name = f"{service}-drill-route-{self.drill_id[:8]}"

    @property
    def prod_host(self) -> str:
        return f"{self.service}.{self.namespace}.svc.cluster.local"

    def _manifest(self) -> Dict[str, Any]:
        return {
            "apiVersion": f"{GROUP}/{VERSION}",
            "kind": "VirtualService",
            "metadata": {
                "name": self.route_name,
                "namespace": self.namespace,
                "labels": {
                    "app.kubernetes.io/managed-by": "drill-orchestrator",
                    "drill-id": self.drill_id,
                },
            },
            "spec": {
                "hosts": [self.prod_host],
                "http": [
                    {
                        "match": [
                            {"headers": {"x-drill-context": {"exact": self.drill_context}}}
                        ],
                        "route": [
                            {"destination": {"host": self.sandbox_host, "port": {"number": 8080}}}
                        ],
                        "timeout": "30s",
                        "retries": {"attempts": 2, "perTryTimeout": "10s", "retryOn": "5xx"},
                    },
                    {
                        "route": [
                            {"destination": {"host": self.prod_host, "port": {"number": 8080}}}
                        ]
                    },
                ],
            },
        }

    def apply_route(self) -> None:
        body = self._manifest()
        try:
            self.api.create_namespaced_custom_object(
                GROUP, VERSION, self.namespace, PLURAL, body
            )
            logger.info("Route applied: %s (drill %s)", self.route_name, self.drill_id)
        except ApiException as exc:
            if exc.status == 409:  # a retried drill left the route in place
                logger.warning("Route exists; patching to converge state")
                self.api.patch_namespaced_custom_object(
                    GROUP, VERSION, self.namespace, PLURAL, self.route_name, body
                )
            else:
                raise

    def teardown_route(self) -> None:
        try:
            self.api.delete_namespaced_custom_object(
                GROUP, VERSION, self.namespace, PLURAL, self.route_name
            )
            logger.info("Route removed: %s", self.route_name)
        except ApiException as exc:
            if exc.status == 404:
                logger.warning("Route already absent; nothing to tear down")
            else:
                raise

    def run_smoke_test(self, ingress_url: str) -> bool:
        headers = {
            "X-Drill-Context": self.drill_context,
            "Host": self.prod_host,
            "Content-Type": "application/json",
        }
        payload = {"test": "backup_validation", "drill_id": self.drill_id}
        try:
            resp = requests.post(
                f"{ingress_url}/health", json=payload, headers=headers, timeout=30
            )
        except requests.RequestException as exc:
            logger.error("Smoke test transport error: %s", exc)
            return False
        if resp.status_code == 503:
            # Isolation guarantee: an unreachable sandbox must NOT fail over to prod.
            logger.error("Sandbox unreachable (503); no failover to production")
            return False
        if resp.status_code != 200:
            logger.error("Smoke test returned HTTP %s", resp.status_code)
            return False
        logger.info("Smoke test passed: HTTP 200 from restore sandbox")
        return True


def main() -> int:
    if len(sys.argv) != 5:
        logger.error(
            "Usage: dr_smoke_router.py <namespace> <service> <sandbox_host> <ingress_url>"
        )
        return int(SmokeStatus.ABORT)

    signing_key = os.environ.get("DRILL_SIGNING_KEY")
    if not signing_key:
        logger.error("DRILL_SIGNING_KEY is not set; cannot sign the drill context")
        return int(SmokeStatus.ABORT)

    namespace, service, sandbox_host, ingress_url = sys.argv[1:5]
    try:
        router = DrillTrafficRouter(namespace, service, sandbox_host, signing_key.encode())
    except config.ConfigException as exc:
        logger.error("Cluster access error: %s", exc)
        return int(SmokeStatus.ABORT)

    try:
        router.apply_route()
    except ApiException as exc:
        logger.error("Failed to apply route: %s", exc)
        return int(SmokeStatus.ABORT)

    try:
        passed = router.run_smoke_test(ingress_url)
    finally:
        router.teardown_route()

    if not passed:
        logger.critical("Smoke test failed; drill red")
        return int(SmokeStatus.FAIL)

    logger.info("Route validated and torn down; drill green")
    return int(SmokeStatus.PASS)


if __name__ == "__main__":
    sys.exit(main())
Header-scoped routing decision at the Istio ingress proxy A top-down decision tree evaluated per request at the ingress proxy. First: does the X-Drill-Context header match the signed value exactly? If it is absent or does not match, the request takes the default route to the untouched production service and is never judged as a drill outcome. If it matches exactly, the request is routed to the DR sandbox host, and a second decision asks whether the sandbox is reachable. A reachable sandbox returns HTTP 200, the smoke test passes, and the controller exits 0 (drill green). An unreachable sandbox returns HTTP 503 with no failover to production, and the controller exits 1 (drill red). no match exact no reachable Ingress proxy inspects request X-Drill-Context exact match? Default route → Production (untouched) Route to DR sandbox host Sandbox reachable? HTTP 503 · no failover to production exit 1 — drill red escalate & quarantine HTTP 200 from sandbox exit 0 — drill green advance the drill

Figure. Per-request routing decision at the ingress proxy: an exact signed-header match reaches the sandbox and maps a 200 to exit 0, an unreachable sandbox returns 503 and maps to exit 1 with no failover, and an absent header falls through to untouched production.

Step-by-Step Execution Walkthrough

The controller assumes the isolated drill namespace exists and the restore has been staged into it behind the sandbox host. From there the drill proceeds through deterministic steps:

  1. Export the signing key. The same HMAC key must be shared with any client permitted to reach the sandbox, so a forged header cannot bypass isolation:

    bash
    export DRILL_SIGNING_KEY="$(kubectl get secret drill-signing -n dr-drill-sandbox -o jsonpath='{.data.key}' | base64 -d)"
    
  2. Confirm the sandbox host resolves inside the mesh. Verify the restore service is registered before routing traffic at it:

    bash
    istioctl proxy-config routes deploy/istio-ingressgateway -n istio-system \
      | grep dr-sandbox
    
  3. Run the controller against the drill namespace, service, sandbox host, and mesh ingress URL:

    bash
    python3 dr_smoke_router.py dr-drill-sandbox api-gateway \
      api-gateway.dr-sandbox.svc.cluster.local \
      http://istio-ingressgateway.istio-system.svc.cluster.local; echo "exit=$?"
    
  4. Branch on the exit code. 0 marks the restored topology reachable and lets the drill advance to deeper service checks, 1 fails the drill and triggers escalation, and 2 aborts on a malformed invocation, a missing signing key, or lost cluster access — mapped explicitly in the wrapper below. The route is always removed before the process exits, so a crash mid-drill cannot leak a routable path to the sandbox beyond the finally block.

Verification and Expected Output

A clean run applies the route, receives an HTTP 200 from the sandbox, tears the route down, and exits 0:

text
2026-07-05 05:14:02 | INFO | dr_smoke_router | Route applied: api-gateway-drill-route-3f9a1c04 (drill 3f9a1c04-...)
2026-07-05 05:14:03 | INFO | dr_smoke_router | Smoke test passed: HTTP 200 from restore sandbox
2026-07-05 05:14:03 | INFO | dr_smoke_router | Route removed: api-gateway-drill-route-3f9a1c04
2026-07-05 05:14:03 | INFO | dr_smoke_router | Route validated and torn down; drill green

A run against an unreachable sandbox records the 503, refuses to fail over, and exits 1:

text
2026-07-05 05:14:02 | INFO | dr_smoke_router | Route applied: api-gateway-drill-route-8b1d77e2 (drill 8b1d77e2-...)
2026-07-05 05:14:32 | ERROR | dr_smoke_router | Sandbox unreachable (503); no failover to production
2026-07-05 05:14:32 | INFO | dr_smoke_router | Route removed: api-gateway-drill-route-8b1d77e2
2026-07-05 05:14:32 | CRITICAL | dr_smoke_router | Smoke test failed; drill red

The exit code is the contract the drill runner reads:

  • 0 — the route applied, the sandbox answered, and the smoke test passed. Advance the drill.
  • 1 — the sandbox was unreachable or the smoke test failed. Escalate and quarantine.
  • 2 — missing arguments, missing signing key, or lost cluster access. Abort the drill.

Failure Modes and Troubleshooting

Symptom Cause Remediation
Smoke test reaches production instead of the sandbox Header name mismatch or the proxy lowercased x-drill-context past an exact match on a different case Confirm Istio header match keys are lowercase; verify the client sends the exact HMAC value the controller logged
ApiException: 403 Forbidden on VirtualService create Drill Role lacks create/patch on networking.istio.io/virtualservices Add the verbs to the Role; the controller must own its route within the drill namespace
Every drill request returns 503 Sandbox host not registered in the mesh, or the restore pod is not Ready Check istioctl proxy-config endpoints; confirm the provisioning stage finished before routing
Route survives a crashed drill Process killed before the finally teardown ran (SIGKILL, OOM) Run the background garbage collector that deletes VirtualServices whose drill-id label is older than the window
Smoke test transport error, no HTTP status Ingress URL wrong or the gateway is not listening on the given scheme/port Verify the ingress Service address and port; resolve it from inside the mesh, not the bastion
409 Conflict logged then run continues A prior drill left a route with the same name Expected on retries — the handler patches to converge; sweep stale routes in namespace teardown

Keep the mesh control-plane version aligned with the sidecar proxy version so header-match semantics do not drift between a drill client and the ingress, and scope the drill Role to the sandbox namespace so a misconfigured controller can never mutate production routing. Prefer a short timeout on the drill route so a hung sandbox fails fast rather than holding a connection open across the validation window.

Integration Notes

The controller is built for headless execution. A thin shell wrapper turns its exit code into an escalation decision and an alert:

bash
#!/usr/bin/env bash
set -euo pipefail

python3 dr_smoke_router.py dr-drill-sandbox api-gateway \
  api-gateway.dr-sandbox.svc.cluster.local \
  http://istio-ingressgateway.istio-system.svc.cluster.local
case $? in
  0) echo "[$(date -u)] smoke test green; advancing drill" ;;
  1) curl -s -X POST "$PAGERDUTY_WEBHOOK" -d '{"event":"dr_smoke_test_failed"}'
     exit 1 ;;
  *) echo "[$(date -u)] router misconfigured; aborting"; exit 2 ;;
esac

Wire that wrapper into whichever scheduler owns the drill:

  • Airflow — invoke it from a KubernetesPodOperator (or a BashOperator that inspects returncode); a non-zero exit fails the task and short-circuits the downstream service-check tasks, keeping the DAG run history as the audit trail.
  • Celery — wrap the call in a task that raises on non-zero so the broker records the failure, giving event-driven drills — triggered when a fresh restore lands in the sandbox — low-latency dispatch.
  • cron — schedule the wrapper directly on the bastion; because it returns strict POSIX codes, cron/systemd OnFailure handlers can route escalation alerts without extra glue.

Because the exit code is the same contract used by cluster-level validators such as the checksum validation pipeline, one orchestration wrapper can compose logical-integrity checks and this service-level smoke test into a single gated drill. For exact match-condition semantics consult the Istio HTTPMatchRequest reference; for the contingency-planning constraints that mandate synthetic traffic never intersect production state see NIST SP 800-34 Rev. 1.

Frequently Asked Questions

Why route on a header instead of DNS or a separate load balancer?

DNS cutover and static load-balancer reconfiguration both carry propagation delay and a window where synthetic traffic can land on the wrong data plane. A header match is evaluated at the edge proxy per request, so the routing decision is deterministic and instantaneous: a request either carries the exact drill context and reaches the sandbox, or it does not and reaches production. There is no TTL to wait out and no residual state to reconcile when the drill ends.

Why HMAC the drill context rather than use a raw UUID?

A raw UUID in an exact-match header is a bearer token: anyone who observes it — in a log, a trace, or a captured request — can replay it to reach the restore sandbox. Signing the UUID with a shared key means only clients that hold the signing key can compute a routable header value, so a leaked context token cannot be forged by an unprivileged client. The routing rule still matches on the exact string; the signature just makes that string unguessable and unforgeable.

Why must an unreachable sandbox return 503 instead of failing over to production?

Failover exists to preserve availability, but a DR drill is a validation exercise, not a production request. If the sandbox is down and the mesh silently fails a drill request over to the live service, the smoke test would pass against production data — defeating the isolation guarantee and potentially mutating live state. Returning 503 to the synthetic client is the correct outcome: it fails the drill honestly and keeps validation traffic and production state strictly disjoint.

What is the difference between exit code 2 and exit code 1?

Exit 2 is an abort condition — wrong argument count, a missing signing key, or lost cluster access — where the controller never judged the restore. Exit 1 is a verdict: the route applied and a smoke test ran, but the sandbox was unreachable or returned a non-success status. Runners should treat 2 as "fix the invocation" and 1 as "escalate and quarantine the drill namespace."

This script is one component of the broader Smoke Test Routing Logic workflow, itself part of Restore Drill Orchestration & Environment Isolation.