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
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
IntEnumordering andtypingusage 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/RoleBindinggranting create, patch, and delete onnetworking.istio.io/virtualserviceswithin 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.
#!/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())
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:
-
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)" -
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 -
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=$?" -
Branch on the exit code.
0marks the restored topology reachable and lets the drill advance to deeper service checks,1fails the drill and triggers escalation, and2aborts 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 thefinallyblock.
Verification and Expected Output
A clean run applies the route, receives an HTTP 200 from the sandbox, tears the route down, and exits 0:
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:
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:
#!/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 aBashOperatorthat inspectsreturncode); 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/systemdOnFailurehandlers 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."
Related
- Smoke Test Routing Logic — the parent workflow this controller plugs into as its header-scoped traffic-steering stage.
- Sandbox Provisioning Automation — how the isolated restore namespace and sandbox host are instantiated before routing runs.
- Fallback Chain Design for Kubernetes Clusters — where a green fallback verdict hands off to the service-level smoke tests on this page.
- Point-in-Time Targeting for MongoDB Backups — selecting the recovery point the restored services are validated against.
- Error Categorization Frameworks — mapping each failed probe into an orchestrator action.
This script is one component of the broader Smoke Test Routing Logic workflow, itself part of Restore Drill Orchestration & Environment Isolation.