import base64 import datetime import json import os import re import subprocess import sys import time def required(name): value = os.environ.get(name, "").strip() if not value: raise RuntimeError(f"missing required environment variable {name}") return value def run(command, timeout_seconds): return subprocess.run( command, check=False, capture_output=True, text=True, timeout=timeout_seconds, ) def stderr_tail(result, limit=1200): return (result.stderr or "")[-limit:] def list_group_states(kafka_command, timeout_seconds): result = run(kafka_command + ["--list", "--state"], timeout_seconds) if result.returncode != 0: raise RuntimeError(f"Kafka group state listing failed: {stderr_tail(result)}") states = {} header_seen = False for raw_line in result.stdout.splitlines(): fields = raw_line.split() if len(fields) >= 2 and fields[0] == "GROUP" and fields[1] == "STATE": header_seen = True continue if header_seen and len(fields) >= 2: states[fields[0]] = fields[1] if not header_seen: raise RuntimeError("Kafka group state listing did not include the GROUP STATE header") return states, stderr_tail(result) def iso_time(timestamp_ms): return datetime.datetime.fromtimestamp(timestamp_ms / 1000, datetime.timezone.utc).isoformat().replace("+00:00", "Z") def classify(states, policy, now_ms): pattern = re.compile(policy["timestamp"]["pattern"]) capture_group = int(policy["timestamp"]["captureGroup"]) minimum_age_ms = int(policy["minimumGroupAgeHours"]) * 60 * 60 * 1000 inactive_states = set(policy["inactiveStates"]) prefix_matched = [] pattern_matched = [] inactive_matched = [] age_eligible = [] candidates = [] invalid_timestamp_count = 0 for name, state in states.items(): if not name.startswith(policy["prefix"]): continue prefix_matched.append(name) match = pattern.fullmatch(name) if match is None: continue pattern_matched.append(name) try: created_ms = int(match.group(capture_group)) except (IndexError, TypeError, ValueError): invalid_timestamp_count += 1 continue age_ms = now_ms - created_ms if age_ms < 0: invalid_timestamp_count += 1 continue if state in inactive_states: inactive_matched.append(name) if age_ms >= minimum_age_ms: age_eligible.append(name) if state in inactive_states and age_ms >= minimum_age_ms: candidates.append({ "name": name, "state": state, "createdAt": iso_time(created_ms), "ageHours": round(age_ms / 3600000, 3), }) candidates.sort(key=lambda item: (-item["ageHours"], item["name"])) return { "prefixMatchedCount": len(prefix_matched), "patternMatchedCount": len(pattern_matched), "inactiveMatchedCount": len(inactive_matched), "ageEligibleCount": len(age_eligible), "invalidTimestampCount": invalid_timestamp_count, "candidates": candidates, } def delete_candidates(kafka_command, candidates, batch_size, timeout_seconds): batch_evidence = [] for start in range(0, len(candidates), batch_size): batch = candidates[start:start + batch_size] command = kafka_command + ["--delete"] for candidate in batch: command.extend(["--group", candidate["name"]]) result = run(command, timeout_seconds) batch_evidence.append({ "size": len(batch), "exitCode": result.returncode, "stderrTail": stderr_tail(result), }) return batch_evidence def main(): target = required("KAFKA_TARGET") namespace = required("KAFKA_NAMESPACE") cluster = required("KAFKA_CLUSTER") bootstrap = required("KAFKA_BOOTSTRAP") confirm = required("KAFKA_GROUP_CONFIRM") == "true" full = required("KAFKA_FULL") == "true" candidate_limit = int(required("KAFKA_CANDIDATE_LIMIT")) timeout_seconds = int(required("KAFKA_COMMAND_TIMEOUT_SECONDS")) batch_size = int(required("KAFKA_DELETE_BATCH_SIZE")) policy = json.loads(base64.b64decode(required("KAFKA_POLICY_B64")).decode("utf-8")) pod_result = run([ "kubectl", "-n", namespace, "get", "pod", "-l", f"strimzi.io/cluster={cluster},strimzi.io/kind=Kafka", "-o", "jsonpath={.items[0].metadata.name}", ], timeout_seconds) pod = pod_result.stdout.strip() if pod_result.returncode != 0 or not pod: raise RuntimeError(f"Kafka broker pod not found: {stderr_tail(pod_result)}") kafka_command = [ "kubectl", "-n", namespace, "exec", pod, "--", "bin/kafka-consumer-groups.sh", "--bootstrap-server", bootstrap, ] observed_at_ms = int(time.time() * 1000) before_states, list_stderr = list_group_states(kafka_command, timeout_seconds) selection = classify(before_states, policy, observed_at_ms) eligible_candidates = selection.pop("candidates") selected_candidates = eligible_candidates[:candidate_limit] batch_evidence = [] deletions = [] after_states = before_states if confirm and selected_candidates: batch_evidence = delete_candidates(kafka_command, selected_candidates, batch_size, timeout_seconds) after_states, _ = list_group_states(kafka_command, timeout_seconds) inactive_states = set(policy["inactiveStates"]) for candidate in selected_candidates: name = candidate["name"] state_after = after_states.get(name) if state_after is None: status = "deleted" elif state_after not in inactive_states: status = "state-changed" else: status = "failed" deletions.append({"name": name, "status": status, "stateAfter": state_after}) display_deletions = deletions if full else deletions[:candidate_limit] deleted_count = sum(1 for item in deletions if item["status"] == "deleted") state_changed_count = sum(1 for item in deletions if item["status"] == "state-changed") failed_count = sum(1 for item in deletions if item["status"] == "failed") payload = { "ok": failed_count == 0, "target": target, "namespace": namespace, "cluster": cluster, "pod": pod, "mode": "confirmed" if confirm else "plan", "mutation": confirm, "observedAt": iso_time(observed_at_ms), "policy": policy, "summary": { "totalGroupCount": len(before_states), **selection, "eligibleCount": len(eligible_candidates), "selectedCount": len(selected_candidates), "omittedCount": max(0, len(eligible_candidates) - len(selected_candidates)), "deleteAttemptedCount": len(deletions), "deletedCount": deleted_count, "stateChangedCount": state_changed_count, "failedCount": failed_count, }, "candidates": selected_candidates, "eligibleCandidates": eligible_candidates if full else [], "deletions": display_deletions, "batchEvidence": batch_evidence if full else [], "stderrTail": list_stderr, "valuesPrinted": False, } print(json.dumps(payload, ensure_ascii=False, indent=2)) return 0 if payload["ok"] else 1 if __name__ == "__main__": try: sys.exit(main()) except Exception as error: confirmed = os.environ.get("KAFKA_GROUP_CONFIRM", "").strip() == "true" print(json.dumps({ "ok": False, "error": str(error), "mutation": confirmed, "mutationOutcome": "unknown" if confirmed else "not-attempted", }, ensure_ascii=False)) sys.exit(1)