"""
MinIO Maintenance Group Planner
This script analyzes MinIO erasure set distribution and generates optimal
node groupings for safe maintenance operations. It determines which nodes
can be taken down simultaneously without risking data availability.
"""
import argparse
import json
import re
import subprocess
import sys
from collections import defaultdict, Counter
from typing import Dict, List, Set, Tuple
import itertools
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)
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):
pass
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 server in 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'),
})
return erasure_sets
def calculate_erasure_set_tolerance(total_drives: int) -> int:
"""Calculate how many drives can be lost in an erasure set"""
if total_drives >= 16:
parity = total_drives // 2
elif total_drives >= 8:
parity = total_drives // 2
else:
parity = total_drives // 2
return parity
def check_group_safety(node_group: Set[str], erasure_sets: Dict,
max_risk_level: str = 'degraded') -> Tuple[bool, Dict]:
"""Check if taking down a group of nodes is safe"""
impact = {}
is_safe = True
for set_id, drives in erasure_sets.items():
total_drives = len(drives)
affected_drives = sum(1 for d in drives if d['node'] in node_group)
parity = calculate_erasure_set_tolerance(total_drives)
data_drives = total_drives - parity
healthy_drives = total_drives - affected_drives
if affected_drives == 0:
status = 'healthy'
elif affected_drives <= parity:
status = 'degraded'
elif healthy_drives >= data_drives:
status = 'critical'
else:
status = 'failed'
impact[set_id] = {
'status': status,
'affected': affected_drives,
'total': total_drives,
'parity': parity
}
risk_levels = {'healthy': 0, 'degraded': 1, 'critical': 2, 'failed': 3}
if risk_levels[status] > risk_levels[max_risk_level]:
is_safe = False
return is_safe, impact
def find_conflicting_nodes(node: str, erasure_sets: Dict, max_tolerance: int) -> Set[str]:
"""Find nodes that conflict with the given node (share too many erasure sets)"""
node_sets = set()
for set_id, drives in erasure_sets.items():
if any(d['node'] == node for d in drives):
node_sets.add(set_id)
conflicting_nodes = set()
for other_node in get_all_nodes(erasure_sets):
if other_node == node:
continue
shared_sets = []
for set_id in node_sets:
drives = erasure_sets[set_id]
if any(d['node'] == other_node for d in drives):
shared_sets.append(set_id)
if shared_sets:
test_group = {node, other_node}
is_safe, _ = check_group_safety(test_group, erasure_sets, 'degraded')
if not is_safe:
conflicting_nodes.add(other_node)
return conflicting_nodes
def get_all_nodes(erasure_sets: Dict) -> List[str]:
"""Get all unique nodes from erasure sets"""
nodes = set()
for drives in erasure_sets.values():
for drive in drives:
nodes.add(drive['node'])
return sorted(nodes, key=natural_sort_key)
def greedy_group_assignment(all_nodes: List[str], erasure_sets: Dict,
max_risk_level: str = 'degraded') -> List[Set[str]]:
"""Greedy algorithm to assign nodes to groups"""
groups = []
remaining_nodes = set(all_nodes)
while remaining_nodes:
current_group = set()
nodes_to_try = sorted(remaining_nodes, key=natural_sort_key)
for node in nodes_to_try:
test_group = current_group | {node}
is_safe, _ = check_group_safety(test_group, erasure_sets, max_risk_level)
if is_safe:
current_group.add(node)
if current_group:
groups.append(current_group)
remaining_nodes -= current_group
else:
node = nodes_to_try[0]
groups.append({node})
remaining_nodes.remove(node)
return groups
def optimize_groups(groups: List[Set[str]], erasure_sets: Dict,
max_risk_level: str = 'degraded') -> List[Set[str]]:
"""Try to merge groups to minimize the total number of groups"""
improved = True
while improved:
improved = False
for i in range(len(groups)):
for j in range(i + 1, len(groups)):
merged = groups[i] | groups[j]
is_safe, _ = check_group_safety(merged, erasure_sets, max_risk_level)
if is_safe:
new_groups = [groups[k] for k in range(len(groups)) if k != i and k != j]
new_groups.append(merged)
groups = new_groups
improved = True
break
if improved:
break
return groups
def generate_html_report(groups: List[Set[str]], erasure_sets: Dict,
all_nodes: List[str], max_risk_level: str,
objectstore_name: str = "MinIO") -> str:
"""Generate HTML report for maintenance groups"""
group_impacts = []
for i, group in enumerate(groups):
is_safe, impact = check_group_safety(group, erasure_sets, max_risk_level)
group_impacts.append({
'group_id': i,
'nodes': sorted(group, key=natural_sort_key),
'is_safe': is_safe,
'impact': impact
})
html = f"""<!DOCTYPE html>
<html>
<head>
<title>MinIO Maintenance Plan - {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, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}}
.container {{
max-width: 1600px;
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, #667eea 0%, #764ba2 100%);
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: #f8f9fa;
}}
.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;
}}
.summary-card:hover {{
transform: translateY(-5px);
}}
.summary-card .label {{
font-size: 0.9em;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}}
.summary-card .value {{
font-size: 2.5em;
font-weight: bold;
color: #667eea;
}}
.info-box {{
background: #e7f3ff;
border-left: 4px solid #2196f3;
padding: 20px;
margin: 20px 30px;
border-radius: 10px;
}}
.info-box h3 {{
color: #1976d2;
margin-bottom: 10px;
}}
.maintenance-groups {{
padding: 30px;
}}
.maintenance-groups h2 {{
font-size: 2em;
margin-bottom: 25px;
color: #333;
border-bottom: 3px solid #667eea;
padding-bottom: 10px;
}}
.timeline {{
position: relative;
padding: 20px 0;
}}
.timeline::before {{
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 4px;
background: #e0e0e0;
transform: translateX(-50%);
}}
.group-card {{
position: relative;
margin: 40px 0;
display: grid;
grid-template-columns: 1fr 80px 1fr;
gap: 20px;
align-items: center;
}}
.group-card:nth-child(even) .group-content {{
grid-column: 3;
}}
.group-card:nth-child(even) .timeline-marker {{
grid-column: 2;
}}
.group-card:nth-child(even) .group-info {{
grid-column: 1;
}}
.group-card:nth-child(odd) .group-content {{
grid-column: 1;
}}
.group-card:nth-child(odd) .timeline-marker {{
grid-column: 2;
}}
.group-card:nth-child(odd) .group-info {{
grid-column: 3;
}}
.timeline-marker {{
width: 80px;
height: 80px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5em;
font-weight: bold;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
z-index: 1;
position: relative;
}}
.group-content {{
background: white;
padding: 25px;
border-radius: 15px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border: 2px solid #e0e0e0;
transition: all 0.3s ease;
}}
.group-content:hover {{
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
border-color: #667eea;
}}
.group-header {{
margin-bottom: 20px;
}}
.group-title {{
font-size: 1.5em;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}}
.group-subtitle {{
color: #666;
font-size: 0.95em;
}}
.nodes-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
margin-top: 15px;
}}
.node-badge {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 10px 15px;
border-radius: 8px;
text-align: center;
font-weight: 500;
transition: transform 0.2s ease;
}}
.node-badge:hover {{
transform: scale(1.05);
}}
.group-info {{
background: #f8f9fa;
padding: 20px;
border-radius: 15px;
border: 2px solid #e0e0e0;
}}
.info-item {{
margin-bottom: 15px;
}}
.info-label {{
font-size: 0.85em;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 5px;
}}
.info-value {{
font-size: 1.2em;
font-weight: bold;
color: #333;
}}
.status-indicator {{
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 600;
margin-top: 5px;
}}
.status-safe {{
background: #d4edda;
color: #155724;
}}
.status-warning {{
background: #fff3cd;
color: #856404;
}}
.erasure-summary {{
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}}
.erasure-stats {{
display: flex;
gap: 15px;
flex-wrap: wrap;
}}
.erasure-stat {{
background: white;
padding: 8px 15px;
border-radius: 8px;
border: 2px solid #e0e0e0;
font-size: 0.9em;
}}
.erasure-stat.healthy {{
border-color: #28a745;
background: #d4edda;
}}
.erasure-stat.degraded {{
border-color: #ffc107;
background: #fff3cd;
}}
.instructions {{
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 30px;
border-radius: 10px;
}}
.instructions h3 {{
color: #856404;
margin-bottom: 15px;
}}
.instructions ol {{
margin-left: 20px;
}}
.instructions li {{
margin: 10px 0;
color: #856404;
}}
@media (max-width: 1200px) {{
.timeline::before {{
display: none;
}}
.group-card {{
grid-template-columns: 1fr !important;
}}
.group-card > * {{
grid-column: 1 !important;
}}
.timeline-marker {{
margin: 0 auto;
}}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔧 Maintenance Group Plan</h1>
<div class="subtitle">{objectstore_name} - Safe Node Grouping Strategy</div>
</div>
<div class="summary">
<div class="summary-card">
<div class="label">Total Nodes</div>
<div class="value">{len(all_nodes)}</div>
</div>
<div class="summary-card">
<div class="label">Maintenance Groups</div>
<div class="value">{len(groups)}</div>
</div>
<div class="summary-card">
<div class="label">Erasure Sets</div>
<div class="value">{len(erasure_sets)}</div>
</div>
<div class="summary-card">
<div class="label">Max Risk Level</div>
<div class="value" style="font-size: 1.5em; text-transform: uppercase;">{max_risk_level}</div>
</div>
</div>
<div class="info-box">
<h3>📋 Maintenance Strategy</h3>
<p>The cluster has been divided into <strong>{len(groups)} groups</strong> that can be safely taken offline for maintenance sequentially.
Each group is guaranteed not to exceed the <strong>{max_risk_level.upper()}</strong> risk level when taken offline simultaneously.</p>
</div>
<div class="maintenance-groups">
<h2>Maintenance Groups Timeline</h2>
<div class="timeline">
"""
for group_info in group_impacts:
group_id = group_info['group_id']
nodes = group_info['nodes']
impact = group_info['impact']
is_safe = group_info['is_safe']
status_counts = Counter(info['status'] for info in impact.values())
html += f"""
<div class="group-card">
<div class="timeline-marker">{group_id + 1}</div>
<div class="group-content">
<div class="group-header">
<div class="group-title">Group {group_id + 1}</div>
<div class="group-subtitle">{len(nodes)} nodes can be maintained together</div>
</div>
<div class="nodes-grid">
"""
for node in nodes:
html += f' <div class="node-badge">{node}</div>\n'
html += """
</div>
<div class="erasure-summary">
<div class="erasure-stats">
"""
if status_counts.get('healthy', 0) > 0:
html += f' <div class="erasure-stat healthy">✅ {status_counts["healthy"]} Healthy</div>\n'
if status_counts.get('degraded', 0) > 0:
html += f' <div class="erasure-stat degraded">⚠️ {status_counts["degraded"]} Degraded</div>\n'
html += """
</div>
</div>
</div>
<div class="group-info">
<div class="info-item">
<div class="info-label">Nodes in Group</div>
<div class="info-value">{}</div>
</div>
<div class="info-item">
<div class="info-label">Safety Status</div>
<div class="status-indicator status-safe">✅ SAFE</div>
</div>
<div class="info-item">
<div class="info-label">Affected Sets</div>
<div class="info-value">{}</div>
</div>
</div>
</div>
""".format(len(nodes), sum(1 for info in impact.values() if info['status'] != 'healthy'))
html += """
</div>
</div>
<div class="instructions">
<h3>📖 Maintenance Execution Plan</h3>
<ol>
<li><strong>Verify cluster health</strong> before starting maintenance</li>
<li><strong>Take down Group 1 nodes</strong> for maintenance</li>
<li><strong>Perform maintenance operations</strong> (updates, hardware replacement, etc.)</li>
<li><strong>Bring Group 1 nodes back online</strong> and verify health</li>
<li><strong>Wait for data rebalancing</strong> if necessary</li>
<li><strong>Repeat for subsequent groups</strong> (Group 2, 3, ...)</li>
<li><strong>Final verification</strong> after all groups are complete</li>
</ol>
<p style="margin-top: 15px;"><strong>⚠️ Important:</strong> Always complete maintenance for one group and verify cluster health before proceeding to the next group.</p>
</div>
</div>
</body>
</html>
"""
return html
def generate_text_report(groups: List[Set[str]], erasure_sets: Dict,
max_risk_level: str) -> str:
"""Generate text-based maintenance plan report"""
report = []
report.append("=" * 80)
report.append("MinIO Maintenance Group Plan")
report.append("=" * 80)
report.append("")
report.append(f"📊 SUMMARY:")
report.append(f" Total Nodes: {sum(len(g) for g in groups)}")
report.append(f" Maintenance Groups: {len(groups)}")
report.append(f" Erasure Sets: {len(erasure_sets)}")
report.append(f" Max Risk Level: {max_risk_level.upper()}")
report.append("")
report.append("🔧 MAINTENANCE GROUPS:")
report.append("-" * 80)
for i, group in enumerate(groups, 1):
report.append(f"\n📦 Group {i} ({len(group)} nodes):")
for node in sorted(group, key=natural_sort_key):
report.append(f" • {node}")
is_safe, impact = check_group_safety(group, erasure_sets, max_risk_level)
status_counts = Counter(info['status'] for info in impact.values())
report.append(f"\n Impact Analysis:")
report.append(f" Safety: {'✅ SAFE' if is_safe else '❌ UNSAFE'}")
report.append(f" Healthy Sets: {status_counts.get('healthy', 0)}")
report.append(f" Degraded Sets: {status_counts.get('degraded', 0)}")
report.append(f" Critical Sets: {status_counts.get('critical', 0)}")
report.append(f" Failed Sets: {status_counts.get('failed', 0)}")
report.append("")
report.append("=" * 80)
report.append("📖 EXECUTION PLAN:")
report.append("-" * 80)
for i in range(len(groups)):
report.append(f"\nStep {i + 1}: Maintain Group {i + 1}")
report.append(f" 1. Verify cluster health")
report.append(f" 2. Take down Group {i + 1} nodes")
report.append(f" 3. Perform maintenance")
report.append(f" 4. Bring nodes back online")
report.append(f" 5. Verify cluster health")
if i < len(groups) - 1:
report.append(f" 6. Proceed to Group {i + 2}")
report.append("")
report.append("=" * 80)
report.append("⚠️ IMPORTANT NOTES:")
report.append(" • Always complete one group before starting the next")
report.append(" • Verify cluster health between groups")
report.append(" • Monitor erasure set status during maintenance")
report.append(" • Have a rollback plan ready")
report.append("=" * 80)
return "\n".join(report)
def main():
parser = argparse.ArgumentParser(
description='Generate optimal maintenance groups for MinIO cluster',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Generate maintenance groups allowing degraded state
%(prog)s --mc-alias myminio --max-risk degraded
# Generate conservative groups (healthy only)
%(prog)s --mc-alias myminio --max-risk healthy
# With HTML output
%(prog)s --mc-alias myminio --max-risk degraded --html-output plan.html
# With namespace for better mapping
%(prog)s --mc-alias myminio --namespace minio-tenant --name minio \\
--max-risk degraded --html-output plan.html --json-output plan.json
"""
)
parser.add_argument('--mc-alias', required=True,
help='MinIO mc alias for admin info command')
parser.add_argument('--max-risk', choices=['healthy', 'degraded', 'critical'],
default='degraded',
help='Maximum acceptable risk level when nodes are down (default: degraded)')
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 maintenance plan to specified file')
parser.add_argument('--json-output',
help='Save maintenance plan as JSON')
parser.add_argument('--insecure', action='store_true',
help='Use --insecure flag with mc command')
parser.add_argument('--algorithm', choices=['greedy', 'optimal'],
default='greedy',
help='Grouping algorithm: greedy (fast) or optimal (slower, better) (default: greedy)')
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 = get_all_nodes(erasure_sets)
print(f" Total nodes in cluster: {len(all_nodes)}")
print(f"\n📋 Erasure Set Configuration:")
drive_counts = [len(drives) for drives in erasure_sets.values()]
if drive_counts:
min_drives = min(drive_counts)
max_drives = max(drive_counts)
avg_drives = sum(drive_counts) / len(drive_counts)
print(f" Drives per set: min={min_drives}, max={max_drives}, avg={avg_drives:.1f}")
typical_tolerance = calculate_erasure_set_tolerance(int(avg_drives))
print(f" Typical fault tolerance: {typical_tolerance} drives per set")
print(f"\n🔧 Generating maintenance groups (max risk: {args.max_risk.upper()})...")
groups = greedy_group_assignment(all_nodes, erasure_sets, args.max_risk)
if args.algorithm == 'optimal':
print(" Optimizing group assignments...")
groups = optimize_groups(groups, erasure_sets, args.max_risk)
print(f" Generated {len(groups)} maintenance groups")
all_safe = True
for i, group in enumerate(groups):
is_safe, _ = check_group_safety(group, erasure_sets, args.max_risk)
if not is_safe:
print(f" ⚠️ Warning: Group {i+1} exceeds max risk level!")
all_safe = False
if all_safe:
print(" ✅ All groups verified safe")
text_report = generate_text_report(groups, erasure_sets, args.max_risk)
print("\n" + text_report)
if args.html_output:
print(f"\n📄 Generating HTML maintenance plan...")
objectstore_name = args.name or "MinIO Cluster"
html_report = generate_html_report(groups, erasure_sets, all_nodes,
args.max_risk, objectstore_name)
with open(args.html_output, 'w', encoding='utf-8') as f:
f.write(html_report)
print(f" ✅ HTML plan saved to: {args.html_output}")
if args.json_output:
print(f"\n💾 Saving maintenance plan as JSON...")
groups_data = []
for i, group in enumerate(groups):
is_safe, impact = check_group_safety(group, erasure_sets, args.max_risk)
status_counts = Counter(info['status'] for info in impact.values())
groups_data.append({
'group_id': i + 1,
'nodes': sorted(group, key=natural_sort_key),
'node_count': len(group),
'is_safe': is_safe,
'impact_summary': {
'healthy_sets': status_counts.get('healthy', 0),
'degraded_sets': status_counts.get('degraded', 0),
'critical_sets': status_counts.get('critical', 0),
'failed_sets': status_counts.get('failed', 0),
}
})
json_data = {
'max_risk_level': args.max_risk,
'total_nodes': len(all_nodes),
'total_groups': len(groups),
'total_erasure_sets': len(erasure_sets),
'groups': groups_data,
'execution_order': [i + 1 for i in range(len(groups))]
}
with open(args.json_output, 'w') as f:
json.dump(json_data, f, indent=2)
print(f" ✅ JSON data saved to: {args.json_output}")
print(f"\n✨ SUMMARY:")
print(f" • Divide cluster into {len(groups)} groups")
print(f" • Maintain groups sequentially: Group 1 → Group 2 → ... → Group {len(groups)}")
print(f" • Each group can be safely taken offline simultaneously")
print(f" • Maximum risk level per group: {args.max_risk.upper()}")
group_sizes = [len(g) for g in groups]
print(f"\n📦 Group Sizes:")
for i, size in enumerate(group_sizes, 1):
nodes_in_group = sorted(groups[i-1], key=natural_sort_key)
print(f" Group {i}: {size} nodes ({', '.join(nodes_in_group[:3])}{'...' if size > 3 else ''})")
if __name__ == "__main__":
main()