"""
MinIO Node Failure Impact Simulator
This script simulates node failures and visualizes the impact on MinIO erasure sets.
It shows which erasure sets are affected, their health status, and generates
an interactive HTML report.
"""
import argparse
import json
import re
import subprocess
import sys
from collections import defaultdict, Counter
from typing import Dict, List, Set, Tuple
def natural_sort_key(text):
"""Generate a key for natural sorting that handles numbers correctly."""
def atoi(text):
return int(text) if text.isdigit() else text
return [atoi(c) for c in re.split(r'(\d+)', text)]
def run_mc_command(alias: str, insecure: bool = False) -> Dict:
"""Run mc admin info command and return JSON output"""
cmd = f"mc admin info {alias} --json"
if insecure:
cmd += " --insecure"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode != 0:
print(f"Error running mc command: {result.stderr}", file=sys.stderr)
sys.exit(1)
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
print(f"Error parsing mc output as JSON: {e}", file=sys.stderr)
print(f"Output was: {result.stdout}", file=sys.stderr)
sys.exit(1)
def get_pod_node_mapping(namespace: str, objectstore_name: str = None) -> Dict[str, str]:
"""Get mapping of pod names to node names using kubectl"""
pod_to_node = {}
label_selectors = [
f'v1.min.io/tenant={objectstore_name}' if objectstore_name else None,
'app=minio',
'app.kubernetes.io/name=minio',
]
for selector in label_selectors:
if not selector:
continue
try:
result = subprocess.run(
['kubectl', '-n', namespace, 'get', 'pods', '-l', selector, '-o', 'json'],
capture_output=True,
text=True,
check=True
)
pods_info = json.loads(result.stdout)
items = pods_info.get('items', [])
if items:
for pod in items:
pod_name = pod['metadata']['name']
node_name = pod['spec'].get('nodeName', 'unknown')
if objectstore_name:
if objectstore_name in pod_name or 'minio' in pod_name.lower():
pod_to_node[pod_name] = node_name
else:
if 'minio' in pod_name.lower():
pod_to_node[pod_name] = node_name
if pod_to_node:
break
except (subprocess.CalledProcessError, json.JSONDecodeError):
continue
if not pod_to_node:
try:
result = subprocess.run(
['kubectl', '-n', namespace, 'get', 'pods', '-o', 'json'],
capture_output=True,
text=True,
check=True
)
pods_info = json.loads(result.stdout)
for pod in pods_info.get('items', []):
pod_name = pod['metadata']['name']
if 'minio' in pod_name.lower() or (objectstore_name and objectstore_name in pod_name):
node_name = pod['spec'].get('nodeName', 'unknown')
pod_to_node[pod_name] = node_name
except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
print(f"Warning: Could not get pod information: {e}", file=sys.stderr)
return pod_to_node
def analyze_erasure_sets(mc_info: Dict, pod_to_node: Dict[str, str] = None) -> Dict:
"""Analyze erasure set distribution from mc admin info output"""
servers = mc_info.get('info', {}).get('servers', [])
if not servers:
print("Error: No servers found in mc admin info output", file=sys.stderr)
return {}
erasure_sets = defaultdict(list)
for i, server in enumerate(servers):
endpoint = server.get('endpoint', '')
pod_or_node_name = endpoint.split(':')[0] if ':' in endpoint else endpoint
pod_or_node_name = pod_or_node_name.split('.')[0] if '.' in pod_or_node_name else pod_or_node_name
actual_node = 'unknown'
if pod_to_node and pod_or_node_name in pod_to_node:
actual_node = pod_to_node[pod_or_node_name]
else:
if pod_to_node:
for pod_name, node_name in pod_to_node.items():
if (pod_name == pod_or_node_name or
pod_or_node_name in pod_name or
pod_name in pod_or_node_name):
actual_node = node_name
break
if actual_node == 'unknown':
actual_node = pod_or_node_name
drives = server.get('drives', [])
for drive in drives:
erasure_set_id = drive.get('set_index')
if erasure_set_id is None:
erasure_set_id = drive.get('set')
if erasure_set_id is not None:
erasure_sets[erasure_set_id].append({
'node': actual_node,
'drive': drive.get('path', ''),
'state': drive.get('state', 'unknown'),
'endpoint': endpoint,
'pod_or_node': pod_or_node_name,
'used_bytes': drive.get('used_bytes', 0),
'total_bytes': drive.get('total_bytes', 0),
})
return erasure_sets
def simulate_node_failures(erasure_sets: Dict, failed_nodes: List[str]) -> Dict:
"""Simulate node failures and calculate impact on erasure sets"""
failed_nodes_set = set(failed_nodes)
impact_analysis = {}
for set_id, drives in erasure_sets.items():
total_drives = len(drives)
affected_drives = [d for d in drives if d['node'] in failed_nodes_set]
affected_count = len(affected_drives)
healthy_count = total_drives - affected_count
if total_drives >= 16:
parity = total_drives // 2
elif total_drives >= 8:
parity = total_drives // 2
else:
parity = total_drives // 2
data_drives = total_drives - parity
if affected_count == 0:
status = 'healthy'
status_level = 0
elif affected_count <= parity:
status = 'degraded'
status_level = 1
elif healthy_count >= data_drives:
status = 'critical'
status_level = 2
else:
status = 'failed'
status_level = 3
risk_percentage = (affected_count / total_drives) * 100
remaining_tolerance = max(0, parity - affected_count)
impact_analysis[set_id] = {
'total_drives': total_drives,
'affected_drives': affected_count,
'healthy_drives': healthy_count,
'affected_drive_list': affected_drives,
'parity_drives': parity,
'data_drives': data_drives,
'status': status,
'status_level': status_level,
'risk_percentage': risk_percentage,
'remaining_tolerance': remaining_tolerance,
'all_drives': drives
}
return impact_analysis
def generate_html_report(impact_analysis: Dict, failed_nodes: List[str],
all_nodes: Set[str], objectstore_name: str = "MinIO") -> str:
"""Generate interactive HTML report for node failure simulation"""
total_sets = len(impact_analysis)
status_counts = Counter(info['status'] for info in impact_analysis.values())
nodes_in_sets = set()
for info in impact_analysis.values():
for drive in info['all_drives']:
nodes_in_sets.add(drive['node'])
html = f"""<!DOCTYPE html>
<html>
<head>
<title>MinIO Node Failure Impact Analysis - {objectstore_name}</title>
<meta charset="UTF-8">
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg,
min-height: 100vh;
padding: 20px;
}}
.container {{
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}}
.header {{
background: linear-gradient(135deg,
color: white;
padding: 40px;
text-align: center;
}}
.header h1 {{
font-size: 2.5em;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
}}
.header .subtitle {{
font-size: 1.2em;
opacity: 0.9;
}}
.summary {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
padding: 30px;
background:
}}
.summary-card {{
background: white;
padding: 25px;
border-radius: 15px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}}
.summary-card:hover {{
transform: translateY(-5px);
box-shadow: 0 8px 12px rgba(0,0,0,0.15);
}}
.summary-card .label {{
font-size: 0.9em;
color:
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}}
.summary-card .value {{
font-size: 2.5em;
font-weight: bold;
color:
}}
.summary-card.failed-nodes {{
background: linear-gradient(135deg,
color: white;
}}
.summary-card.failed-nodes .label,
.summary-card.failed-nodes .value {{
color: white;
}}
.summary-card.healthy {{ border-left: 5px solid
.summary-card.degraded {{ border-left: 5px solid
.summary-card.critical {{ border-left: 5px solid
.summary-card.failed {{ border-left: 5px solid
.failed-nodes-list {{
background:
border-left: 4px solid
padding: 20px;
margin: 20px 30px;
border-radius: 10px;
}}
.failed-nodes-list h3 {{
color:
margin-bottom: 15px;
font-size: 1.3em;
}}
.failed-nodes-list ul {{
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}}
.failed-nodes-list li {{
background: white;
padding: 10px 15px;
border-radius: 8px;
border: 2px solid
font-weight: 500;
color:
}}
.failed-nodes-list li::before {{
content: '⚠️ ';
margin-right: 5px;
}}
.erasure-sets {{
padding: 30px;
}}
.erasure-sets h2 {{
font-size: 2em;
margin-bottom: 25px;
color:
border-bottom: 3px solid
padding-bottom: 10px;
}}
.filters {{
margin-bottom: 25px;
display: flex;
gap: 15px;
flex-wrap: wrap;
}}
.filter-btn {{
padding: 10px 20px;
border: 2px solid
background: white;
color:
border-radius: 25px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}}
.filter-btn:hover {{
background:
color: white;
transform: scale(1.05);
}}
.filter-btn.active {{
background:
color: white;
}}
.erasure-set {{
background: white;
border: 2px solid
border-radius: 15px;
margin-bottom: 20px;
overflow: hidden;
transition: all 0.3s ease;
}}
.erasure-set:hover {{
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
transform: translateY(-2px);
}}
.erasure-set.status-healthy {{
border-left: 6px solid
}}
.erasure-set.status-degraded {{
border-left: 6px solid
}}
.erasure-set.status-critical {{
border-left: 6px solid
}}
.erasure-set.status-failed {{
border-left: 6px solid
}}
.erasure-set-header {{
padding: 20px;
background:
border-bottom: 1px solid
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}}
.erasure-set-header:hover {{
background:
}}
.set-title {{
font-size: 1.3em;
font-weight: bold;
color:
}}
.status-badge {{
padding: 8px 16px;
border-radius: 20px;
font-weight: 600;
text-transform: uppercase;
font-size: 0.85em;
letter-spacing: 1px;
}}
.status-badge.healthy {{
background:
color:
}}
.status-badge.degraded {{
background:
color:
}}
.status-badge.critical {{
background:
color:
}}
.status-badge.failed {{
background:
color:
}}
.erasure-set-body {{
padding: 25px;
display: none;
}}
.erasure-set-body.expanded {{
display: block;
}}
.set-stats {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 25px;
}}
.stat-item {{
background:
padding: 15px;
border-radius: 10px;
border-left: 4px solid
}}
.stat-item .stat-label {{
font-size: 0.85em;
color:
margin-bottom: 5px;
}}
.stat-item .stat-value {{
font-size: 1.5em;
font-weight: bold;
color:
}}
.progress-bar {{
width: 100%;
height: 30px;
background:
border-radius: 15px;
overflow: hidden;
margin: 20px 0;
position: relative;
}}
.progress-fill {{
height: 100%;
transition: width 0.5s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 0.9em;
}}
.progress-fill.healthy {{ background: linear-gradient(90deg,
.progress-fill.degraded {{ background: linear-gradient(90deg,
.progress-fill.critical {{ background: linear-gradient(90deg,
.progress-fill.failed {{ background: linear-gradient(90deg,
.drives-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
margin-top: 20px;
}}
.drive-card {{
background:
padding: 15px;
border-radius: 10px;
border: 2px solid
transition: all 0.3s ease;
}}
.drive-card.affected {{
background:
border-color:
}}
.drive-card:hover {{
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
transform: translateY(-2px);
}}
.drive-node {{
font-weight: bold;
color:
margin-bottom: 8px;
font-size: 1.1em;
}}
.drive-card.affected .drive-node {{
color:
}}
.drive-card.affected .drive-node::before {{
content: '❌ ';
}}
.drive-card:not(.affected) .drive-node::before {{
content: '✅ ';
}}
.drive-path {{
font-size: 0.9em;
color:
font-family: 'Courier New', monospace;
margin-top: 5px;
}}
.drive-state {{
display: inline-block;
padding: 4px 8px;
border-radius: 5px;
font-size: 0.8em;
margin-top: 8px;
font-weight: 600;
}}
.drive-state.online {{
background:
color:
}}
.drive-state.offline {{
background:
color:
}}
.legend {{
background:
padding: 20px;
margin: 30px;
border-radius: 15px;
border: 2px solid
}}
.legend h3 {{
margin-bottom: 15px;
color:
}}
.legend-items {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
}}
.legend-item {{
display: flex;
align-items: center;
gap: 10px;
}}
.legend-color {{
width: 30px;
height: 30px;
border-radius: 8px;
flex-shrink: 0;
}}
.legend-text {{
font-size: 0.95em;
}}
.legend-text strong {{
display: block;
margin-bottom: 3px;
}}
.legend-text small {{
color:
}}
@media (max-width: 768px) {{
.summary {{
grid-template-columns: 1fr;
}}
.drives-grid {{
grid-template-columns: 1fr;
}}
.set-stats {{
grid-template-columns: 1fr;
}}
}}
.expand-icon {{
transition: transform 0.3s ease;
}}
.expanded .expand-icon {{
transform: rotate(180deg);
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔍 Node Failure Impact Analysis</h1>
<div class="subtitle">{objectstore_name} - Erasure Set Health Status</div>
</div>
<div class="summary">
<div class="summary-card failed-nodes">
<div class="label">Failed Nodes</div>
<div class="value">{len(failed_nodes)}</div>
</div>
<div class="summary-card">
<div class="label">Total Erasure Sets</div>
<div class="value">{total_sets}</div>
</div>
<div class="summary-card healthy">
<div class="label">Healthy Sets</div>
<div class="value">{status_counts.get('healthy', 0)}</div>
</div>
<div class="summary-card degraded">
<div class="label">Degraded Sets</div>
<div class="value">{status_counts.get('degraded', 0)}</div>
</div>
<div class="summary-card critical">
<div class="label">Critical Sets</div>
<div class="value">{status_counts.get('critical', 0)}</div>
</div>
<div class="summary-card failed">
<div class="label">Failed Sets</div>
<div class="value">{status_counts.get('failed', 0)}</div>
</div>
</div>
"""
if failed_nodes:
html += """
<div class="failed-nodes-list">
<h3>⚠️ Simulated Failed Nodes</h3>
<ul>
"""
for node in sorted(failed_nodes, key=natural_sort_key):
html += f" <li>{node}</li>\n"
html += """
</ul>
</div>
"""
html += """
<div class="erasure-sets">
<h2>Erasure Sets Analysis</h2>
<div class="filters">
<button class="filter-btn active">All Sets</button>
<button class="filter-btn">Healthy Only</button>
<button class="filter-btn">Degraded Only</button>
<button class="filter-btn">Critical Only</button>
<button class="filter-btn">Failed Only</button>
<button class="filter-btn">All Affected</button>
</div>
"""
for set_id in sorted(impact_analysis.keys()):
info = impact_analysis[set_id]
status = info['status']
health_percentage = (info['healthy_drives'] / info['total_drives']) * 100
html += f"""
<div class="erasure-set status-{status}" data-status="{status}">
<div class="erasure-set-header">
<div class="set-title">
Erasure Set {set_id}
<span class="expand-icon">▼</span>
</div>
<span class="status-badge {status}">{status.upper()}</span>
</div>
<div class="erasure-set-body">
<div class="progress-bar">
<div class="progress-fill {status}" style="width: {health_percentage}%">
{info['healthy_drives']}/{info['total_drives']} drives healthy
</div>
</div>
<div class="set-stats">
<div class="stat-item">
<div class="stat-label">Total Drives</div>
<div class="stat-value">{info['total_drives']}</div>
</div>
<div class="stat-item">
<div class="stat-label">Affected Drives</div>
<div class="stat-value" style="color:
</div>
<div class="stat-item">
<div class="stat-label">Data Drives</div>
<div class="stat-value">{info['data_drives']}</div>
</div>
<div class="stat-item">
<div class="stat-label">Parity Drives</div>
<div class="stat-value">{info['parity_drives']}</div>
</div>
<div class="stat-item">
<div class="stat-label">Remaining Tolerance</div>
<div class="stat-value" style="color: {'
{info['remaining_tolerance']}
</div>
</div>
<div class="stat-item">
<div class="stat-label">Risk Level</div>
<div class="stat-value">{info['risk_percentage']:.1f}%</div>
</div>
</div>
<h4 style="margin: 20px 0 10px 0; color:
<div class="drives-grid">
"""
for drive in sorted(info['all_drives'], key=lambda d: (d['node'], d['drive'])):
is_affected = drive['node'] in failed_nodes
affected_class = 'affected' if is_affected else ''
state_class = drive['state'].lower()
html += f"""
<div class="drive-card {affected_class}">
<div class="drive-node">{drive['node']}</div>
<div class="drive-path">{drive['drive']}</div>
<span class="drive-state {state_class}">{drive['state'].upper()}</span>
</div>
"""
html += """
</div>
</div>
</div>
"""
html += """
</div>
<div class="legend">
<h3>Status Legend</h3>
<div class="legend-items">
<div class="legend-item">
<div class="legend-color" style="background:
<div class="legend-text">
<strong>Healthy</strong>
<small>All drives operational</small>
</div>
</div>
<div class="legend-item">
<div class="legend-color" style="background:
<div class="legend-text">
<strong>Degraded</strong>
<small>Some drives down, within tolerance</small>
</div>
</div>
<div class="legend-item">
<div class="legend-color" style="background:
<div class="legend-text">
<strong>Critical</strong>
<small>Near failure threshold</small>
</div>
</div>
<div class="legend-item">
<div class="legend-color" style="background:
<div class="legend-text">
<strong>Failed</strong>
<small>Cannot maintain quorum</small>
</div>
</div>
</div>
</div>
</div>
<script>
function toggleSet(header) {
const body = header.nextElementSibling;
const icon = header.querySelector('.expand-icon');
body.classList.toggle('expanded');
icon.classList.toggle('expanded');
}
function filterSets(status) {
const sets = document.querySelectorAll('.erasure-set');
const buttons = document.querySelectorAll('.filter-btn');
// Update active button
buttons.forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
// Filter sets
sets.forEach(set => {
if (status === 'all') {
set.style.display = 'block';
} else if (status === 'affected') {
// Show degraded, critical, and failed
const setStatus = set.dataset.status;
if (setStatus !== 'healthy') {
set.style.display = 'block';
} else {
set.style.display = 'none';
}
} else {
if (set.dataset.status === status) {
set.style.display = 'block';
} else {
set.style.display = 'none';
}
}
});
}
// Expand first affected set by default
document.addEventListener('DOMContentLoaded', function() {
const affectedSets = document.querySelectorAll('.erasure-set:not(.status-healthy)');
if (affectedSets.length > 0) {
affectedSets[0].querySelector('.erasure-set-header').click();
}
});
</script>
</body>
</html>
"""
return html
def generate_text_report(impact_analysis: Dict, failed_nodes: List[str]) -> str:
"""Generate text-based report for console output"""
report = []
report.append("=" * 80)
report.append("MinIO Node Failure Impact Simulation")
report.append("=" * 80)
report.append("")
report.append(f"🔴 SIMULATED FAILED NODES ({len(failed_nodes)}):")
for node in sorted(failed_nodes, key=natural_sort_key):
report.append(f" • {node}")
report.append("")
total_sets = len(impact_analysis)
status_counts = Counter(info['status'] for info in impact_analysis.values())
report.append("📊 SUMMARY STATISTICS:")
report.append(f" Total Erasure Sets: {total_sets}")
report.append(f" ✅ Healthy: {status_counts.get('healthy', 0)}")
report.append(f" ⚠️ Degraded: {status_counts.get('degraded', 0)}")
report.append(f" 🔥 Critical: {status_counts.get('critical', 0)}")
report.append(f" ❌ Failed: {status_counts.get('failed', 0)}")
report.append("")
report.append("📋 DETAILED ERASURE SET ANALYSIS:")
report.append("-" * 80)
for set_id in sorted(impact_analysis.keys()):
info = impact_analysis[set_id]
status_icons = {
'healthy': '✅',
'degraded': '⚠️',
'critical': '🔥',
'failed': '❌'
}
icon = status_icons.get(info['status'], '❓')
report.append(f"\n{icon} Erasure Set {set_id} - {info['status'].upper()}")
report.append(f" Drives: {info['healthy_drives']}/{info['total_drives']} healthy")
report.append(f" Configuration: {info['data_drives']} data + {info['parity_drives']} parity")
report.append(f" Affected: {info['affected_drives']} drives")
report.append(f" Remaining Tolerance: {info['remaining_tolerance']} drive(s)")
report.append(f" Risk Level: {info['risk_percentage']:.1f}%")
if info['affected_drives'] > 0:
report.append(f" Affected Drives:")
for drive in sorted(info['affected_drive_list'], key=lambda d: (d['node'], d['drive'])):
report.append(f" ❌ {drive['node']}: {drive['drive']}")
report.append("")
report.append("=" * 80)
critical_or_failed = sum(1 for info in impact_analysis.values()
if info['status'] in ['critical', 'failed'])
if critical_or_failed > 0:
report.append("⚠️ RISK ASSESSMENT:")
report.append(f" {critical_or_failed} erasure set(s) are in CRITICAL or FAILED state!")
report.append(" Immediate action recommended to prevent data loss.")
else:
degraded = status_counts.get('degraded', 0)
if degraded > 0:
report.append("⚠️ RISK ASSESSMENT:")
report.append(f" {degraded} erasure set(s) are DEGRADED but operational.")
report.append(" Monitor closely and plan for node recovery.")
else:
report.append("✅ RISK ASSESSMENT:")
report.append(" All erasure sets are HEALTHY. No immediate risk.")
report.append("=" * 80)
return "\n".join(report)
def get_all_nodes_from_mc(mc_info: Dict) -> Set[str]:
"""Extract all unique node names from mc admin info"""
nodes = set()
servers = mc_info.get('info', {}).get('servers', [])
for server in servers:
endpoint = server.get('endpoint', '')
node_name = endpoint.split(':')[0] if ':' in endpoint else endpoint
node_name = node_name.split('.')[0] if '.' in node_name else node_name
if node_name and node_name != 'unknown':
nodes.add(node_name)
return nodes
def interactive_node_selection(all_nodes: List[str]) -> List[str]:
"""Interactive CLI for selecting nodes to fail"""
print("\n" + "=" * 80)
print("INTERACTIVE NODE SELECTION")
print("=" * 80)
print("\nAvailable nodes:")
for i, node in enumerate(all_nodes, 1):
print(f" {i}. {node}")
print("\nEnter node numbers to simulate failure (comma-separated, e.g., 1,3,5)")
print("Or enter node names (comma-separated)")
print("Press Enter without input to cancel.")
user_input = input("\n> ").strip()
if not user_input:
return []
failed_nodes = []
for item in user_input.split(','):
item = item.strip()
if item.isdigit():
idx = int(item) - 1
if 0 <= idx < len(all_nodes):
failed_nodes.append(all_nodes[idx])
else:
print(f"Warning: Invalid node number: {item}")
else:
if item in all_nodes:
failed_nodes.append(item)
else:
print(f"Warning: Node not found: {item}")
return failed_nodes
def main():
parser = argparse.ArgumentParser(
description='Simulate MinIO node failures and analyze erasure set impact',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --mc-alias myminio --failed-nodes node-1,node-2,node-3
%(prog)s --mc-alias myminio --interactive
%(prog)s --mc-alias myminio --failed-nodes node-1 --html-output report.html
%(prog)s --mc-alias myminio --namespace minio-tenant --name minio \\
--failed-nodes node-1,node-2
"""
)
parser.add_argument('--mc-alias', required=True,
help='MinIO mc alias for admin info command')
parser.add_argument('--failed-nodes',
help='Comma-separated list of node names to simulate failure')
parser.add_argument('--interactive', '-i', action='store_true',
help='Interactive mode to select nodes')
parser.add_argument('--namespace', '-n',
help='Kubernetes namespace (for better pod-to-node mapping)')
parser.add_argument('--name',
help='ObjectStore name (for better pod-to-node mapping)')
parser.add_argument('--html-output',
help='Generate HTML report to specified file')
parser.add_argument('--insecure', action='store_true',
help='Use --insecure flag with mc command')
parser.add_argument('--json-output',
help='Save analysis results as JSON')
args = parser.parse_args()
print("🔍 Fetching MinIO server information...")
mc_info = run_mc_command(args.mc_alias, args.insecure)
pod_to_node = None
if args.namespace:
print(f"📍 Fetching pod-to-node mapping from namespace '{args.namespace}'...")
pod_to_node = get_pod_node_mapping(args.namespace, args.name)
if pod_to_node:
print(f" Found {len(pod_to_node)} pods")
print("📊 Analyzing erasure set configuration...")
erasure_sets = analyze_erasure_sets(mc_info, pod_to_node)
if not erasure_sets:
print("Error: No erasure sets found", file=sys.stderr)
sys.exit(1)
print(f" Found {len(erasure_sets)} erasure sets")
all_nodes_in_sets = set()
for drives in erasure_sets.values():
for drive in drives:
all_nodes_in_sets.add(drive['node'])
all_nodes = sorted(all_nodes_in_sets, key=natural_sort_key)
print(f" Total nodes in cluster: {len(all_nodes)}")
failed_nodes = []
if args.interactive:
failed_nodes = interactive_node_selection(all_nodes)
if not failed_nodes:
print("No nodes selected. Exiting.")
return
elif args.failed_nodes:
failed_nodes = [n.strip() for n in args.failed_nodes.split(',')]
invalid_nodes = [n for n in failed_nodes if n not in all_nodes]
if invalid_nodes:
print(f"Error: Invalid node names: {', '.join(invalid_nodes)}", file=sys.stderr)
print(f"Available nodes: {', '.join(all_nodes)}", file=sys.stderr)
sys.exit(1)
else:
print("Error: Either --failed-nodes or --interactive must be specified", file=sys.stderr)
sys.exit(1)
print(f"\n⚡ Simulating failure of {len(failed_nodes)} node(s)...")
impact_analysis = simulate_node_failures(erasure_sets, failed_nodes)
text_report = generate_text_report(impact_analysis, failed_nodes)
print("\n" + text_report)
if args.html_output:
print(f"\n📄 Generating HTML report...")
objectstore_name = args.name or "MinIO Cluster"
html_report = generate_html_report(impact_analysis, failed_nodes,
all_nodes_in_sets, objectstore_name)
with open(args.html_output, 'w', encoding='utf-8') as f:
f.write(html_report)
print(f" ✅ HTML report saved to: {args.html_output}")
if args.json_output:
print(f"\n💾 Saving analysis as JSON...")
json_data = {
'failed_nodes': failed_nodes,
'total_erasure_sets': len(impact_analysis),
'summary': {
'healthy': sum(1 for i in impact_analysis.values() if i['status'] == 'healthy'),
'degraded': sum(1 for i in impact_analysis.values() if i['status'] == 'degraded'),
'critical': sum(1 for i in impact_analysis.values() if i['status'] == 'critical'),
'failed': sum(1 for i in impact_analysis.values() if i['status'] == 'failed'),
},
'erasure_sets': {
str(set_id): {
'status': info['status'],
'total_drives': info['total_drives'],
'affected_drives': info['affected_drives'],
'healthy_drives': info['healthy_drives'],
'parity_drives': info['parity_drives'],
'data_drives': info['data_drives'],
'remaining_tolerance': info['remaining_tolerance'],
'risk_percentage': info['risk_percentage'],
}
for set_id, info in impact_analysis.items()
}
}
with open(args.json_output, 'w') as f:
json.dump(json_data, f, indent=2)
print(f" ✅ JSON data saved to: {args.json_output}")
if __name__ == "__main__":
main()