feat: drive isolated remote synthetic replay with namespaced pod restart
Adds runtime: remote to the synthetic pilot driver (external base URL and disposable database, restart via the driver's namespaced ServiceAccount, UTC phase windows for external cgroup sampling) and the target replay profile pinned to the deployed release digest and 3 Gunicorn workers. Five focused tests added; 22 tests pass, ruff clean. For railiance-apps RAPPS-WP-0014-T04. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 8340@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c
This commit is contained in:
parent
44e5458b17
commit
dca6683a8a
4 changed files with 286 additions and 22 deletions
53
config/synthetic-pilot-target-remote.json
Normal file
53
config/synthetic-pilot-target-remote.json
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"users": 2,
|
||||
"workers": 3,
|
||||
"iterations": 100000,
|
||||
"tenders": 100,
|
||||
"document_bytes": 1048576,
|
||||
"think_seconds": 0.2,
|
||||
"timeout_seconds": 30,
|
||||
"ordinary_p95_seconds": 2.0,
|
||||
"duration_seconds": 180,
|
||||
"idle_seconds": 30,
|
||||
"runtime": "remote",
|
||||
"app_image": "forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91",
|
||||
"app_cpu_limit": 1.0,
|
||||
"app_memory_limit": "1Gi",
|
||||
"operations": [
|
||||
"list",
|
||||
"search",
|
||||
"tender-create",
|
||||
"detail",
|
||||
"tender-edit",
|
||||
"lot-create",
|
||||
"lot-edit",
|
||||
"task-create",
|
||||
"task-edit",
|
||||
"document-upload",
|
||||
"document-download"
|
||||
],
|
||||
"phases": [
|
||||
{
|
||||
"name": "ordinary",
|
||||
"kind": "load",
|
||||
"duration_seconds": 90,
|
||||
"think_seconds": 0.2
|
||||
},
|
||||
{
|
||||
"name": "burst",
|
||||
"kind": "load",
|
||||
"duration_seconds": 30,
|
||||
"think_seconds": 0
|
||||
},
|
||||
{
|
||||
"name": "restart",
|
||||
"kind": "restart"
|
||||
},
|
||||
{
|
||||
"name": "recovery",
|
||||
"kind": "load",
|
||||
"duration_seconds": 60,
|
||||
"think_seconds": 0.2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -157,3 +157,17 @@ hardware and shared-contention evidence belongs to receiving task
|
|||
`railiance-apps/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md` T04, linked to
|
||||
CUST-WP-0071-T02 and RESOURCE-WP-0008-T02. Its routine read-only capture/preparation
|
||||
is authorized; customer datasets must not be used for benchmark writes.
|
||||
|
||||
## Remote runtime for an isolated target replay
|
||||
|
||||
`runtime: remote` drives an already running isolated synthetic deployment instead
|
||||
of starting Docker. `SYNTHETIC_BASE_URL` and `SYNTHETIC_DATABASE_URL` are required
|
||||
and must point at a disposable app/database, never a customer dataset. A restart
|
||||
phase deletes the single ready pod matched by `SYNTHETIC_RESTART_SELECTOR` in the
|
||||
driver's own namespace (namespaced ServiceAccount) and waits for a different ready
|
||||
replacement. Resource sampling is external in this mode; every phase and the idle
|
||||
window retain UTC start/finish so external cgroup samples can be aligned.
|
||||
`config/synthetic-pilot-target-remote.json` is the phased profile with the deployed
|
||||
release's image digest and Gunicorn worker count (image CMD: 3). The 2026-10-21
|
||||
replay on railiance01 and its manifests/evidence are retained in
|
||||
`railiance-apps/docs/evidence/2026-10-21-vergabe-target-replay/`.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Provisional loopback HTTP workload using Gunicorn and disposable PostgreSQL."""
|
||||
"""Provisional HTTP workload against Gunicorn and a disposable PostgreSQL.
|
||||
|
||||
Runtimes: ``host`` and ``container`` start everything locally. ``remote`` drives
|
||||
an already running isolated synthetic deployment (for example a throwaway
|
||||
Kubernetes namespace): the base URL, database URL and optional restart pod
|
||||
selector come from ``SYNTHETIC_BASE_URL``, ``SYNTHETIC_DATABASE_URL`` and
|
||||
``SYNTHETIC_RESTART_SELECTOR``. Remote resource sampling is external; phase UTC
|
||||
windows are retained so samples can be aligned afterwards.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
|
|
@ -97,8 +105,8 @@ def phase_plan(config):
|
|||
raise ValueError("phase names must be unique and not idle/startup")
|
||||
names.add(name)
|
||||
if item.get("kind") == "restart":
|
||||
if config.get("runtime") != "container":
|
||||
raise ValueError("restart phase requires container runtime")
|
||||
if config.get("runtime") not in ("container", "remote"):
|
||||
raise ValueError("restart phase requires container or remote runtime")
|
||||
elif item.get("kind") == "load":
|
||||
for key in ("duration_seconds", "think_seconds"):
|
||||
value = item.get(key)
|
||||
|
|
@ -144,6 +152,70 @@ def container_url(name):
|
|||
return f"http://127.0.0.1:{port}"
|
||||
|
||||
|
||||
KUBE_SA = Path("/var/run/secrets/kubernetes.io/serviceaccount")
|
||||
|
||||
|
||||
def kube_request(method, path, sa_dir=KUBE_SA):
|
||||
"""Minimal in-cluster API call using the pod's own namespaced ServiceAccount."""
|
||||
host = os.environ["KUBERNETES_SERVICE_HOST"]
|
||||
port = os.environ.get("KUBERNETES_SERVICE_PORT", "443")
|
||||
import ssl
|
||||
|
||||
context = ssl.create_default_context(cafile=str(sa_dir / "ca.crt"))
|
||||
req = urllib.request.Request(
|
||||
f"https://{host}:{port}{path}",
|
||||
method=method,
|
||||
headers={"Authorization": "Bearer " + (sa_dir / "token").read_text().strip()},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10, context=context) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
|
||||
def ready_pods(listing):
|
||||
return [
|
||||
item["metadata"]["name"]
|
||||
for item in listing.get("items", [])
|
||||
if not item["metadata"].get("deletionTimestamp")
|
||||
and any(
|
||||
c["type"] == "Ready" and c["status"] == "True"
|
||||
for c in item.get("status", {}).get("conditions", [])
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def restart_kubernetes_pod(base_url, selector, timeout=180, sa_dir=KUBE_SA, request=None):
|
||||
"""Delete the single selected pod in this namespace and wait for a new ready one."""
|
||||
request = request or (lambda method, path: kube_request(method, path, sa_dir))
|
||||
namespace = (sa_dir / "namespace").read_text().strip()
|
||||
query = urllib.parse.urlencode({"labelSelector": selector})
|
||||
pods = f"/api/v1/namespaces/{namespace}/pods"
|
||||
before = ready_pods(request("GET", f"{pods}?{query}"))
|
||||
if len(before) != 1:
|
||||
raise RuntimeError("restart requires exactly one ready selected pod")
|
||||
request("DELETE", f"{pods}/{before[0]}")
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
listing = request("GET", f"{pods}?{query}")
|
||||
names = [i["metadata"]["name"] for i in listing.get("items", [])]
|
||||
if before[0] not in names and ready_pods(listing):
|
||||
wait_ready(base_url, timeout=max(1, deadline - time.monotonic()))
|
||||
return base_url
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("replacement pod readiness timeout")
|
||||
|
||||
|
||||
def settings_text(media, issue_db, allowed_hosts=("127.0.0.1",)):
|
||||
return (
|
||||
"from vergabe_teilnahme.settings.base import *\n"
|
||||
f"DEBUG = False\nALLOWED_HOSTS = {list(allowed_hosts)!r}\nNETKINGDOM_ENABLED = False\n"
|
||||
f"MEDIA_ROOT = {media!r}\n"
|
||||
f"ISSUE_FACADE_LOCAL_DB = {issue_db!r}\n"
|
||||
'ISSUE_FACADE_GITEA = None\nFORCE_SCRIPT_NAME = None\nMEDIA_URL = "/media/"\n'
|
||||
'EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"\n'
|
||||
"SESSION_COOKIE_SECURE = False\nCSRF_COOKIE_SECURE = False\n"
|
||||
)
|
||||
|
||||
|
||||
def restart_container(name):
|
||||
subprocess.run(
|
||||
["docker", "restart", "--time", "5", name],
|
||||
|
|
@ -182,7 +254,7 @@ def validate(config):
|
|||
raise ValueError("duration must be positive and idle nonnegative")
|
||||
if config["document_bytes"] < 8:
|
||||
raise ValueError("document_bytes must be at least 8")
|
||||
if config.get("runtime", "host") not in ("host", "container"):
|
||||
if config.get("runtime", "host") not in ("host", "container", "remote"):
|
||||
raise ValueError("unsupported runtime")
|
||||
if config.get("runtime") == "container":
|
||||
if (
|
||||
|
|
@ -221,8 +293,10 @@ def guarded_sampler(function, errors):
|
|||
errors.append({"sampler": function.__name__, "error_type": type(exc).__name__})
|
||||
|
||||
|
||||
def sampling_gaps(samples, db_samples, errors):
|
||||
def sampling_gaps(samples, db_samples, errors, remote=False):
|
||||
gaps = list(errors)
|
||||
if remote:
|
||||
return gaps
|
||||
if any(s.get("sample_error") for s in samples):
|
||||
gaps.append({"sampler": "application", "reason": "sample failed"})
|
||||
good = [s for s in samples if "monotonic_seconds" in s]
|
||||
|
|
@ -243,17 +317,10 @@ def run(config, root, failure_output=None):
|
|||
os.environ["SECRET_KEY"] = "synthetic-disposable-not-a-deployment-key"
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "synthetic_settings"
|
||||
container_mode = config.get("runtime", "host") == "container"
|
||||
remote_mode = config.get("runtime") == "remote"
|
||||
media = "/tmp/synthetic-media" if container_mode else str(root / "media")
|
||||
issue_db = "/tmp/synthetic-issues.db" if container_mode else str(root / "issues.db")
|
||||
(root / "synthetic_settings.py").write_text(
|
||||
"from vergabe_teilnahme.settings.base import *\n"
|
||||
'DEBUG = False\nALLOWED_HOSTS = ["127.0.0.1"]\nNETKINGDOM_ENABLED = False\n'
|
||||
f"MEDIA_ROOT = {media!r}\n"
|
||||
f"ISSUE_FACADE_LOCAL_DB = {issue_db!r}\n"
|
||||
'ISSUE_FACADE_GITEA = None\nFORCE_SCRIPT_NAME = None\nMEDIA_URL = "/media/"\n'
|
||||
'EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"\n'
|
||||
"SESSION_COOKIE_SECURE = False\nCSRF_COOKIE_SECURE = False\n"
|
||||
)
|
||||
(root / "synthetic_settings.py").write_text(settings_text(media, issue_db))
|
||||
sys.path.insert(0, str(root))
|
||||
import django
|
||||
|
||||
|
|
@ -289,7 +356,9 @@ def run(config, root, failure_output=None):
|
|||
sampling_lock = threading.Lock()
|
||||
try:
|
||||
startup_started = time.monotonic()
|
||||
if container_mode:
|
||||
if remote_mode:
|
||||
base_url = os.environ["SYNTHETIC_BASE_URL"].rstrip("/")
|
||||
elif container_mode:
|
||||
app_container = os.environ["SYNTHETIC_CONTAINER"] + "-app"
|
||||
root.chmod(0o755)
|
||||
subprocess.run(
|
||||
|
|
@ -466,13 +535,16 @@ def run(config, root, failure_output=None):
|
|||
db_sampler = threading.Thread(
|
||||
target=guarded_sampler, args=(sample_db, sampler_errors), daemon=True
|
||||
)
|
||||
sampler_threads.extend([sampler, db_sampler])
|
||||
sampler.start()
|
||||
db_sampler.start()
|
||||
if not remote_mode:
|
||||
sampler_threads.extend([sampler, db_sampler])
|
||||
sampler.start()
|
||||
db_sampler.start()
|
||||
wait_ready(base_url)
|
||||
startup_seconds = time.monotonic() - startup_started
|
||||
phase.update(name="idle", kind="idle")
|
||||
idle_window = {"name": "idle", "kind": "idle", "started_at": datetime.now(UTC).isoformat()}
|
||||
time.sleep(config["idle_seconds"])
|
||||
idle_window["finished_at"] = datetime.now(UTC).isoformat()
|
||||
plan = phase_plan(config)
|
||||
active_plan = []
|
||||
phase_results = []
|
||||
|
|
@ -678,10 +750,16 @@ def run(config, root, failure_output=None):
|
|||
for active_phase in plan:
|
||||
phase.update(name=active_phase["name"], kind=active_phase["kind"])
|
||||
phase_started = time.monotonic()
|
||||
phase_started_at = datetime.now(UTC).isoformat()
|
||||
if active_phase["kind"] == "restart":
|
||||
old_url = base_url
|
||||
with sampling_lock:
|
||||
base_url = restart_container(app_container)
|
||||
if remote_mode:
|
||||
base_url = restart_kubernetes_pod(
|
||||
base_url, os.environ["SYNTHETIC_RESTART_SELECTOR"]
|
||||
)
|
||||
else:
|
||||
base_url = restart_container(app_container)
|
||||
transport_rebinds.append(
|
||||
{"phase": active_phase["name"], "before": old_url, "after": base_url}
|
||||
)
|
||||
|
|
@ -690,6 +768,8 @@ def run(config, root, failure_output=None):
|
|||
"name": active_phase["name"],
|
||||
"kind": "restart",
|
||||
"seconds": time.monotonic() - phase_started,
|
||||
"started_at": phase_started_at,
|
||||
"finished_at": datetime.now(UTC).isoformat(),
|
||||
"ready": True,
|
||||
}
|
||||
)
|
||||
|
|
@ -711,6 +791,8 @@ def run(config, root, failure_output=None):
|
|||
"name": active_phase["name"],
|
||||
"kind": "load",
|
||||
"seconds": time.monotonic() - phase_started,
|
||||
"started_at": phase_started_at,
|
||||
"finished_at": datetime.now(UTC).isoformat(),
|
||||
"operations": active_plan,
|
||||
"expected_requests": expected,
|
||||
"actual_requests": len(phase_rows),
|
||||
|
|
@ -726,6 +808,7 @@ def run(config, root, failure_output=None):
|
|||
result = {
|
||||
"schema_version": 2,
|
||||
"startup_seconds": startup_seconds,
|
||||
"idle_window": idle_window,
|
||||
"transport_rebinds": transport_rebinds,
|
||||
"phase_results": phase_results,
|
||||
"started_at": started,
|
||||
|
|
@ -736,8 +819,12 @@ def run(config, root, failure_output=None):
|
|||
"python": platform.python_version(),
|
||||
"django": django.get_version(),
|
||||
"platform": platform.platform(),
|
||||
"database": "disposable PostgreSQL 16 container",
|
||||
"transport": "loopback HTTP Gunicorn",
|
||||
"database": os.environ.get(
|
||||
"SYNTHETIC_DATABASE_DESCRIPTION", "disposable PostgreSQL 16 container"
|
||||
),
|
||||
"transport": os.environ.get(
|
||||
"SYNTHETIC_TRANSPORT_DESCRIPTION", "loopback HTTP Gunicorn"
|
||||
),
|
||||
"workers": config["workers"],
|
||||
},
|
||||
"ordinary": ordinary,
|
||||
|
|
@ -764,7 +851,9 @@ def run(config, root, failure_output=None):
|
|||
sampling_done.set()
|
||||
for thread in sampler_threads:
|
||||
thread.join(timeout=7)
|
||||
gaps = sampling_gaps(samples, db_samples, sampler_errors)
|
||||
gaps = sampling_gaps(samples, db_samples, sampler_errors, remote=remote_mode)
|
||||
if remote_mode:
|
||||
result["resource_sampling"] = "external; align with phase UTC windows"
|
||||
if any(thread.is_alive() for thread in sampler_threads):
|
||||
gaps.append({"sampler": "cleanup", "reason": "thread did not stop"})
|
||||
result["measurement_gaps"] = gaps
|
||||
|
|
@ -825,6 +914,45 @@ def run(config, root, failure_output=None):
|
|||
connections.close_all()
|
||||
|
||||
|
||||
def host_facts(config):
|
||||
return {
|
||||
"logical_cpus": os.cpu_count(),
|
||||
"affinity_cpus": len(os.sched_getaffinity(0)),
|
||||
"cpu_model": next(
|
||||
(
|
||||
x.split(":", 1)[1].strip()
|
||||
for x in Path("/proc/cpuinfo").read_text().splitlines()
|
||||
if x.startswith("model name")
|
||||
),
|
||||
"unknown",
|
||||
),
|
||||
"memory_total": next(
|
||||
x for x in Path("/proc/meminfo").read_text().splitlines() if x.startswith("MemTotal:")
|
||||
),
|
||||
"cgroup_cpu_max": Path("/sys/fs/cgroup/cpu.max").read_text().strip()
|
||||
if Path("/sys/fs/cgroup/cpu.max").exists()
|
||||
else "unavailable",
|
||||
"app_cpu_quota": config.get("app_cpu_limit", "not imposed by harness"),
|
||||
}
|
||||
|
||||
|
||||
def main_remote(config, args):
|
||||
"""Drive an existing isolated synthetic deployment; never a shared database."""
|
||||
for name in ("SYNTHETIC_BASE_URL", "SYNTHETIC_DATABASE_URL"):
|
||||
if not os.environ.get(name):
|
||||
raise SystemExit(f"{name} is required for remote runtime")
|
||||
with tempfile.TemporaryDirectory(prefix="vergabe-synthetic-") as directory:
|
||||
result = run(config, Path(directory), args.output)
|
||||
result["source_revision"] = os.environ.get("SYNTHETIC_SOURCE_REVISION", "unrecorded")
|
||||
result["driver_sha256"] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
||||
result["host"] = host_facts(config)
|
||||
result["config_sha256"] = hashlib.sha256(args.config.read_bytes()).hexdigest()
|
||||
args.output.write_text(json.dumps(result, indent=2) + "\n")
|
||||
keys = ("ordinary", "document_transfer", "complete", "provisional_pass")
|
||||
print(json.dumps({k: result[k] for k in keys}))
|
||||
return 0 if result["provisional_pass"] and result["measurement_complete"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
|
|
@ -832,6 +960,8 @@ if __name__ == "__main__":
|
|||
args = parser.parse_args()
|
||||
config = validate(json.loads(args.config.read_text()))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
if config.get("runtime") == "remote":
|
||||
sys.exit(main_remote(config, args))
|
||||
name = "vergabe-synthetic-" + uuid.uuid4().hex[:12]
|
||||
network = name + "-network"
|
||||
network_args = []
|
||||
|
|
|
|||
|
|
@ -239,5 +239,72 @@ class SizingTests(unittest.TestCase):
|
|||
)
|
||||
|
||||
|
||||
class RemoteRuntimeTests(unittest.TestCase):
|
||||
def config(self):
|
||||
return SyntheticContractTests.config(None) | {
|
||||
"runtime": "remote",
|
||||
"phases": [
|
||||
{"name": "ordinary", "kind": "load", "duration_seconds": 1, "think_seconds": 0},
|
||||
{"name": "restart", "kind": "restart"},
|
||||
],
|
||||
}
|
||||
|
||||
def test_remote_runtime_accepts_restart_and_host_rejects_it(self):
|
||||
self.assertEqual(pilot.validate(self.config())["runtime"], "remote")
|
||||
with self.assertRaises(ValueError):
|
||||
pilot.validate(self.config() | {"runtime": "host"})
|
||||
|
||||
def test_remote_mode_skips_local_sampler_coverage(self):
|
||||
self.assertEqual(pilot.sampling_gaps([], [], [], remote=True), [])
|
||||
self.assertTrue(pilot.sampling_gaps([], [], []))
|
||||
|
||||
def test_settings_text_sets_allowed_hosts_and_disables_sso(self):
|
||||
text = pilot.settings_text("/m", "/i.db", ["vergabe-app"])
|
||||
self.assertIn("ALLOWED_HOSTS = ['vergabe-app']", text)
|
||||
self.assertIn("NETKINGDOM_ENABLED = False", text)
|
||||
|
||||
def test_restart_deletes_single_ready_pod_and_waits_for_replacement(self):
|
||||
ready = {"type": "Ready", "status": "True"}
|
||||
|
||||
def pod(name, conditions=(ready,), deleting=False):
|
||||
meta = {"name": name} | ({"deletionTimestamp": "x"} if deleting else {})
|
||||
return {"metadata": meta, "status": {"conditions": list(conditions)}}
|
||||
|
||||
listings = [
|
||||
{"items": [pod("old")]},
|
||||
{"items": [pod("old", deleting=True), pod("new", conditions=())]},
|
||||
{"items": [pod("new")]},
|
||||
]
|
||||
calls = []
|
||||
|
||||
def request(method, path):
|
||||
calls.append((method, path))
|
||||
return listings.pop(0) if method == "GET" else {}
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
Path(directory, "namespace").write_text("bench\n")
|
||||
with (
|
||||
mock.patch.object(pilot, "wait_ready") as waited,
|
||||
mock.patch.object(pilot.time, "sleep"),
|
||||
):
|
||||
url = pilot.restart_kubernetes_pod(
|
||||
"http://app", "app=x", sa_dir=Path(directory), request=request
|
||||
)
|
||||
self.assertEqual(url, "http://app")
|
||||
self.assertIn(("DELETE", "/api/v1/namespaces/bench/pods/old"), calls)
|
||||
waited.assert_called_once()
|
||||
|
||||
def test_restart_refuses_ambiguous_selection(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
Path(directory, "namespace").write_text("bench")
|
||||
with self.assertRaises(RuntimeError):
|
||||
pilot.restart_kubernetes_pod(
|
||||
"http://app",
|
||||
"app=x",
|
||||
sa_dir=Path(directory),
|
||||
request=lambda method, path: {"items": []},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue