"""Real replica exit, unhealthy application, replacement and restart acceptance.""" import concurrent.futures import contextlib import json import subprocess import threading import time import uuid def run_recovery(api, wait_for, traffic, network, spec_a, port, evidence): services = [] secret = None held_ip = None stop = threading.Event() errors = [] metrics = {"requests": {}, "httpFailures": [], "stages": [], "requestIntervalSeconds": .1, "httpTimeoutSeconds": 5} evidence["replicaRecovery"] = metrics executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) loaders = [] started = time.monotonic() def path(service, suffix=""): return "/api/micro-services/" + service["id"] + suffix def current(service): return api.call("GET", path(service, "/deployment")) def ready(service): record = current(service) return record if record["phase"] == "running" and not (record.get("recovery") or {}).get("repair") else None gateway = api.call("GET", "/api/micro-networks/" + network)["subnet"]["gateway"] def create(replicas, private=False): name = "repair-" + uuid.uuid4().hex[:8] spec = {**spec_a, "name": name, "domain": None if private else name + ".example.test", "replicas": replicas, "healthPath": "/cgi-bin/recovery-health", "startupTimeout": 3, "drainTimeout": 30, "env": {**spec_a.get("env", {}), "SELF_INTERNAL": name + ".internal"}, "secrets": {"PASSWORD": secret}} service = {"id": api.call("POST", "/api/micro-services", spec)["id"], "spec": spec} services.append(service) api.call("POST", path(service, "/deployment"), {"microNetworkId": network}) service["initial"] = wait_for(lambda: ready(service), timeout=180) return service def load(service, internal=False): domain = service["spec"]["domain"] key = domain + ("/guest-internal" if internal else "") metrics["requests"][key] = 0 while not stop.is_set(): request_started = round(time.monotonic() - started, 2) try: response = traffic("127.0.0.1", port, domain, "/cgi-bin/internal-call" if internal else "/cgi-bin/rotation") if response != (200, "old"): raise AssertionError(repr(response)) except Exception as error: if len(metrics["httpFailures"]) < 10: metrics["httpFailures"].append({"domain": domain, "channel": key, "stage": metrics.get("currentStage"), "elapsedSeconds": round(time.monotonic() - started, 2), "startedSeconds": request_started, "error": str(error)}) if "ingressAtFailure" not in metrics: result = subprocess.run(["journalctl", "-u", "firecrab-ingress.service", "--no-pager", "-n", "40", "-o", "cat"], capture_output=True, text=True, timeout=3) metrics["ingressAtFailure"] = result.stdout metrics["requests"][key] += 1 stop.wait(metrics["requestIntervalSeconds"]) def ids(record): return {instance["vmId"] for instance in record["instances"]} def stop_service(service): wait_for(lambda: current(service)["phase"] in ["running", "degraded", "failed", "stopped"], timeout=180) api.call("POST", path(service, "/stop")) wait_for(lambda: current(service)["phase"] == "stopped", timeout=180) try: secret = api.call("POST", "/api/secrets", {"name": "recovery-" + uuid.uuid4().hex[:8], "value": "qa-secret-v0"})["id"] main, unrelated = create(2), create(1) for service in [main, unrelated]: for internal in [False, True]: thread = threading.Thread(target=load, args=(service, internal), daemon=True) loaders.append(thread) thread.start() original = main["initial"] unrelated_ids = ids(unrelated["initial"]) victim = original["instances"][0]["vmId"] metrics["currentStage"] = "restart after VM exit" metrics["exitInjectionSeconds"] = round(time.monotonic() - started, 2) api.call("POST", "/api/vms/" + victim + "/stop") metrics["exitAcknowledgedSeconds"] = round(time.monotonic() - started, 2) wait_for(lambda: (record if (record := ready(main)) and ids(record) == ids(original) and api.call("GET", "/api/vms/" + victim)["state"] == "running" else None), timeout=180) metrics["stages"].append("exited VM restarted; healthy replicas, release, image and captured Secret retained") metrics["currentStage"] = "replacement and durable drain" held = executor.submit(traffic, "127.0.0.1", port, main["spec"]["domain"], "/cgi-bin/hold") def holding(): for instance in current(main)["instances"]: if traffic(instance["ipv4"], 8080, "fixture", "/cgi-bin/holding") == (200, "holding"): return instance return None target = wait_for(holding, timeout=15) held_ip = target["ipv4"] traffic(held_ip, 8080, "fixture", "/cgi-bin/fail-health") def draining(): record = current(main) repair = (record.get("recovery") or {}).get("repair") return record if repair and repair.get("transition") and repair["transition"]["confirmed"] else None wait_for(draining, timeout=60) assert not held.done(), "failed replica was stopped before its held request drained" ingress_pid = api.call("GET", "/api/pipe")["units"][0]["mainPid"] subprocess.run(["systemctl", "restart", "firecrab-api.service"], check=True) api.ready() assert api.call("GET", "/api/pipe")["units"][0]["mainPid"] == ingress_pid assert not held.done() traffic(held_ip, 8080, "fixture", "/cgi-bin/release") assert held.result(timeout=15) == (200, "release-A") held_ip = None def replaced(): record = ready(main) return record if record and target["vmId"] not in ids(record) else None repaired = wait_for(replaced, timeout=240) assert len(ids(repaired)) == 2 and len(ids(repaired) & ids(original)) == 1 assert repaired["id"] == original["id"] and repaired["digest"] == original["digest"] assert repaired["spec"] == original["spec"] and repaired["request"] == original["request"] assert ids(current(unrelated)) == unrelated_ids routes = [route for route in api.call("GET", "/api/pipe")["config"]["routes"] if (route.get("owner") or {}).get("serviceId") == main["id"]] expected = sorted(instance["ipv4"] + ":8080" for instance in repaired["instances"]) assert len(routes) == 2 and all(sorted(route["upstreams"]) == expected for route in routes), routes assert target["vmId"] not in {vm["id"] for vm in api.call("GET", "/api/vms")} assert "qa-secret-v0" not in json.dumps(repaired) metrics["stages"].append("unhealthy VM replaced after restart failed; held request and repair journal survived API restart") metrics["currentStage"] = "all-unready internal-only service" single = create(1, private=True) only = single["initial"]["instances"][0] traffic(only["ipv4"], 8080, "fixture", "/cgi-bin/fail-health") wait_for(lambda: traffic(gateway, 80, single["spec"]["name"] + ".internal")[0] == 503, timeout=60) wait_for(lambda: (record if (record := ready(single)) and ids(record) != ids(single["initial"]) else None), timeout=180) metrics["stages"].append("all-unready service kept its hostname with HTTP 503 and recovered to a ready replacement") stop_service(single) metrics["currentStage"] = "Stop retention" time.sleep(16) assert current(single)["phase"] == "stopped" and not current(single)["instances"] assert ids(current(unrelated)) == unrelated_ids assert not metrics["httpFailures"], metrics["httpFailures"] assert min(metrics["requests"].values()) > 50 metrics["stages"].append("Stop was retained; unrelated service and healthy replica IDs were preserved") print("PASS replica restart, replacement, API recovery, bounded ownership and Nginx availability", flush=True) finally: stop.set() for thread in loaders: thread.join(timeout=10) if held_ip: with contextlib.suppress(Exception): traffic(held_ip, 8080, "fixture", "/cgi-bin/release") executor.shutdown(wait=True, cancel_futures=True) for service in reversed(services): try: stop_service(service) api.call("DELETE", path(service)) except Exception as error: errors.append(str(error)) if secret: try: api.call("DELETE", "/api/secrets/" + secret) except Exception as error: errors.append(str(error)) metrics["cleanupErrors"] = errors metrics["elapsedSeconds"] = round(time.monotonic() - started, 2) assert not errors, errors