|
| 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