Skip to content

Commit 95896c8

Browse files
authored
Merge pull request #588 from okurz/feature/poo201144_zombie_reaper
feat: add zombie-reaper.py to handle zombie qemu left-overs
2 parents d497ce2 + 9b4ae46 commit 95896c8

1 file changed

Lines changed: 148 additions & 0 deletions

File tree

s390x-qemu-zombie-reaper.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env python3
2+
# Copyright SUSE LLC
3+
# ruff: noqa: T201, S404
4+
5+
"""Automated openQA Zombie Reaper for s390x.
6+
7+
Checks s390x hypervisors for persistent zombie qemu processes and reboots the host
8+
if any are found, while also retriggering the affected openQA jobs.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import shlex
15+
import subprocess
16+
import time
17+
from typing import TYPE_CHECKING, Annotated
18+
19+
import typer
20+
21+
if TYPE_CHECKING:
22+
from typing import Any
23+
24+
app = typer.Typer(help="Automated openQA Zombie Reaper for s390x")
25+
26+
# Mapping of hypervisors to the workers that use them
27+
HYPERVISORS = {
28+
"s390zl12.oqa.prg2.suse.org": ["worker31", "worker32"],
29+
"s390zl13.oqa.prg2.suse.org": ["worker32", "worker33"],
30+
}
31+
32+
33+
def run_cmd(cmd: str, *, check: bool = True, verbose: bool = False) -> str:
34+
"""Run a shell command and return its output."""
35+
if verbose:
36+
print(f"Executing: {cmd}")
37+
try:
38+
res = subprocess.run(shlex.split(cmd), capture_output=True, text=True, check=check) # noqa: S603
39+
return res.stdout.strip()
40+
except subprocess.CalledProcessError as e:
41+
print(f"Error executing command: {cmd}\n{e.stderr}")
42+
return ""
43+
44+
45+
def get_running_jobs(hypervisor_host: str, *, verbose: bool = False) -> list[int]:
46+
"""Fetch job IDs currently assigned to workers using this hypervisor."""
47+
try:
48+
# Extract the short name (e.g., s390zl12) from the FQDN
49+
short_name = hypervisor_host.split(".", maxsplit=1)[0]
50+
51+
output = run_cmd("openqa-cli api --osd -X GET workers", verbose=verbose)
52+
if not output:
53+
return []
54+
55+
data: dict[str, Any] = json.loads(output)
56+
workers_data = data.get("workers", [])
57+
jobs_to_restart = []
58+
59+
for worker in workers_data:
60+
properties = worker.get("properties", {})
61+
worker_class = properties.get("WORKER_CLASS", "")
62+
63+
# Match if the hypervisor short name is in the WORKER_CLASS
64+
if short_name in worker_class:
65+
job_id = worker.get("jobid")
66+
if job_id:
67+
jobs_to_restart.append(job_id)
68+
69+
return list(set(jobs_to_restart))
70+
except (json.JSONDecodeError, KeyError, TypeError) as e:
71+
print(f"Failed to fetch running jobs for {hypervisor_host}: {e}")
72+
return []
73+
74+
75+
def trigger_actions(host: str, jobs: list[int], *, dry_run: bool, verbose: bool) -> None:
76+
"""Trigger kdump (which reboots the machine) and job re-triggering."""
77+
# Echoing 'c' to sysrq-trigger panics the kernel, dumping core and rebooting
78+
reboot_cmd = f'ssh {host} sudo bash -c "echo c > /proc/sysrq-trigger"'
79+
if dry_run:
80+
print(f"[DRY-RUN] Would execute: {reboot_cmd}")
81+
for job_id in jobs:
82+
retrigger_cmd = f"openqa-cli api --osd -X POST jobs/{job_id}/restart"
83+
print(f"[DRY-RUN] Would execute: {retrigger_cmd}")
84+
else:
85+
print(f"Triggering kernel crash dump (kdump) on {host}...")
86+
run_cmd(reboot_cmd, verbose=verbose)
87+
88+
for job_id in jobs:
89+
retrigger_cmd = f"openqa-cli api --osd -X POST jobs/{job_id}/restart"
90+
print(f"Retriggering job {job_id}...")
91+
run_cmd(retrigger_cmd, verbose=verbose)
92+
93+
94+
def handle_host(host: str, *, dry_run: bool, verbose: bool) -> None:
95+
"""Check a single host for zombies and take action if found."""
96+
if verbose:
97+
print(f"Checking {host} for zombies...")
98+
99+
# Discover zombie qemu processes
100+
zombie_pids_str = run_cmd(f"ssh {host} pgrep -r Z qemu-system-s39", check=False, verbose=verbose)
101+
if not zombie_pids_str:
102+
if verbose:
103+
print(f"Host {host} is clean.")
104+
return
105+
106+
# Snapshot process identities (PID + start time + state) to detect PID reuse
107+
pids = ",".join(zombie_pids_str.split())
108+
id_cmd = f"ssh {host} ps -o pid,lstart,state -p {pids} --no-headers"
109+
initial_identities = run_cmd(id_cmd, check=False, verbose=verbose)
110+
111+
print(f"Detected potential zombies on {host}, waiting 10s to verify persistence...")
112+
time.sleep(10)
113+
114+
# Re-verify identities. Only processes that match exactly are confirmed as persistent zombies.
115+
verified_identities = run_cmd(id_cmd, check=False, verbose=verbose)
116+
stuck_identities = set(initial_identities.splitlines()).intersection(verified_identities.splitlines())
117+
118+
if not stuck_identities:
119+
if verbose:
120+
print(f"Zombies on {host} were transient or PIDs were reused.")
121+
return
122+
123+
# Extract PIDs for logging
124+
stuck_pids = [line.split()[0] for line in stuck_identities]
125+
print(f"!!! CRITICAL: Found persistent zombie processes on {host}: {', '.join(stuck_pids)}")
126+
127+
print(f"Identifying jobs using {host}...")
128+
jobs = get_running_jobs(host, verbose=verbose)
129+
if jobs:
130+
print(f"Affected jobs to be retriggered: {', '.join(map(str, jobs))}")
131+
else:
132+
print(f"No active jobs found using {host}.")
133+
134+
trigger_actions(host, jobs, dry_run=dry_run, verbose=verbose)
135+
136+
137+
@app.command()
138+
def reap(
139+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would be done without executing")] = False, # noqa: FBT002
140+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, # noqa: FBT002
141+
) -> None:
142+
"""Check hypervisors for zombies and reboot/retrigger jobs if found."""
143+
for host in HYPERVISORS:
144+
handle_host(host, dry_run=dry_run, verbose=verbose)
145+
146+
147+
if __name__ == "__main__":
148+
app()

0 commit comments

Comments
 (0)