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) -> Dict[str, str]:
"""
Get mapping of pod names to node names using kubectl
Returns dict mapping pod name to node name
"""
pod_to_node = {}
label_selectors = [
f'v1.min.io/tenant={objectstore_name}',
f'app=minio',
f'app.kubernetes.io/name=minio',
f'app.kubernetes.io/instance={objectstore_name}',
]
for selector in label_selectors:
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:
print(f" Found {len(items)} pods with selector '{selector}'")
for pod in items:
pod_name = pod['metadata']['name']
node_name = pod['spec'].get('nodeName', 'unknown')
if objectstore_name in pod_name or 'minio' in pod_name.lower():
pod_to_node[pod_name] = node_name
if pod_to_node:
break
except subprocess.CalledProcessError:
continue
except json.JSONDecodeError:
continue
if not pod_to_node:
try:
print(f" No pods found with standard labels, trying name-based search...")
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 (objectstore_name in pod_name or
'minio' in pod_name.lower() or
pod_name.startswith(f'{objectstore_name}-pool')):
node_name = pod['spec'].get('nodeName', 'unknown')
pod_to_node[pod_name] = node_name
if pod_to_node:
print(f" Found {len(pod_to_node)} MinIO pods by name pattern")
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, nodes_by_label: Dict[str, List[str]],
node_label_key: str, namespace: str = None,
objectstore_name: 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)
node_to_rack = {}
for rack, nodes in nodes_by_label.items():
for node in nodes:
node_to_rack[node] = rack
pod_to_node = {}
if namespace and objectstore_name:
pod_to_node = get_pod_node_mapping(namespace, objectstore_name)
print(f"Found {len(pod_to_node)} pods in namespace {namespace}")
for pod, node in pod_to_node.items():
print(f" Pod {pod} is running on node {node}")
print(f"\nProcessing {len(servers)} servers from mc admin info...")
for i, server in enumerate(servers):
endpoint = server.get('endpoint', '')
if i < 3 or 'unknown' in endpoint:
print(f" Server {i}: endpoint = '{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_or_node_name in pod_to_node:
actual_node = pod_to_node[pod_or_node_name]
if i < 3:
print(f" -> Matched pod '{pod_or_node_name}' -> Node {actual_node}")
elif pod_or_node_name in node_to_rack:
actual_node = pod_or_node_name
if i < 3:
print(f" -> Direct node {actual_node}")
else:
matched = False
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 or
pod_name.split('-pool-')[-1] == pod_or_node_name.split('-pool-')[-1] if '-pool-' in pod_name and '-pool-' in pod_or_node_name else False):
actual_node = node_name
if i < 3:
print(f" -> Fuzzy matched to pod '{pod_name}' -> Node {actual_node}")
matched = True
break
if not matched:
print(f" WARNING: Could not determine node for endpoint '{endpoint}'")
print(f" Extracted name: '{pod_or_node_name}'")
if pod_to_node:
print(f" Available pods: {list(pod_to_node.keys())[:3]}...")
if node_to_rack:
print(f" Available nodes: {list(node_to_rack.keys())[:3]}...")
rack = node_to_rack.get(actual_node, 'unknown')
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,
'rack': rack,
'drive': drive.get('path', ''),
'state': drive.get('state', 'unknown'),
'endpoint': endpoint,
'pod_or_node': pod_or_node_name
})
print(f"\nFound {len(erasure_sets)} erasure sets with {sum(len(drives) for drives in erasure_sets.values())} total drives")
return erasure_sets
def generate_audit_report(erasure_sets: Dict, nodes_by_label: Dict[str, List[str]],
objectstore_name: str) -> str:
"""Generate a text-based audit report of erasure set placement"""
report = []
report.append("=" * 80)
report.append(f"MinIO Erasure Set Placement Audit Report")
report.append(f"ObjectStore: {objectstore_name}")
report.append("=" * 80)
report.append("")
total_sets = len(erasure_sets)
total_drives = sum(len(drives) for drives in erasure_sets.values())
total_racks = len(nodes_by_label)
total_nodes = sum(len(nodes) for nodes in nodes_by_label.values())
report.append("📊 Summary Statistics:")
report.append(f" • Total Erasure Sets: {total_sets}")
report.append(f" • Total Drives: {total_drives}")
report.append(f" • Total Racks: {total_racks}")
report.append(f" • Total Nodes: {total_nodes}")
report.append("")
report.append("🔍 Erasure Set Analysis:")
report.append("-" * 40)
issues_found = []
for set_id in sorted(erasure_sets.keys()):
drives = erasure_sets[set_id]
report.append(f"\nErasure Set {set_id}:")
report.append(f" Drives: {len(drives)}")
rack_counts = Counter(drive['rack'] for drive in drives)
report.append(f" Distribution across racks:")
for rack in sorted(rack_counts.keys(), key=natural_sort_key):
count = rack_counts[rack]
report.append(f" Rack {rack}: {count} drives")
if count > 1:
issue = f"⚠️ WARNING: Erasure set {set_id} has {count} drives on rack {rack}"
issues_found.append(issue)
report.append(f" {issue}")
report.append(" Drive details:")
for drive in sorted(drives, key=lambda x: (x['rack'], x['node'], x['drive'])):
state_icon = "✅" if drive['state'] == 'online' else "❌"
report.append(f" {state_icon} Rack {drive['rack']}, Node {drive['node']}: {drive['drive']}")
report.append("")
report.append("=" * 80)
if issues_found:
report.append("⚠️ ISSUES FOUND:")
report.append("-" * 40)
for issue in issues_found:
report.append(issue)
report.append("")
report.append("RECOMMENDATION: Multiple drives from the same erasure set on the same rack")
report.append("reduces fault tolerance. Consider redistributing pods across racks.")
else:
report.append("✅ No placement issues found!")
report.append("All erasure sets are properly distributed across racks.")
report.append("=" * 80)
report.append("")
report.append("📈 Rack Utilization:")
report.append("-" * 40)
rack_drive_counts = defaultdict(int)
for drives in erasure_sets.values():
for drive in drives:
rack_drive_counts[drive['rack']] += 1
for rack in sorted(rack_drive_counts.keys(), key=natural_sort_key):
count = rack_drive_counts[rack]
bar = "█" * (count // 2) + ("▌" if count % 2 else "")
report.append(f" Rack {rack}: {bar} ({count} drives)")
return "\n".join(report)
def audit_mode(args):
"""Run in audit mode to analyze existing MinIO deployment"""
print("🔍 Running MinIO Placement Audit...")
print(f"Fetching MinIO server info from '{args.mc_alias}'...")
mc_info = run_mc_command(args.mc_alias, args.insecure)
print(f"Fetching ObjectStore CR...")
objectstore = get_objectstore_cr(args.name, args.namespace, args.file)
objectstore_name = objectstore['metadata']['name']
namespace = objectstore['metadata']['namespace']
print(f"Fetching nodes...")
nodes_by_label = get_nodes_by_label(args.node_label, audit_mode=True, json_file=args.nodes_json)
if not nodes_by_label:
print(f"Error: No nodes found at all", file=sys.stderr)
sys.exit(1)
print("Analyzing erasure set distribution...")
erasure_sets = analyze_erasure_sets(mc_info, nodes_by_label, args.node_label or "rack",
namespace, objectstore_name)
report = generate_audit_report(erasure_sets, nodes_by_label, objectstore_name)
print("\n" + report)
if args.audit_output:
with open(args.audit_output, 'w') as f:
f.write(report)
print(f"\n📄 Report saved to: {args.audit_output}")
def main():
parser = argparse.ArgumentParser(
description='Generate PVCs for MinIO ObjectStore with balanced placement'
)
parser.add_argument('--name', help='Name of the ObjectStore CR')
parser.add_argument('--namespace', '-n', help='Namespace of the ObjectStore CR')
parser.add_argument('--file', '-f', help='Path to ObjectStore CR YAML file')
parser.add_argument('--node-label',
help='Node label for placement buckets (e.g., v1.min.io/rack)')
parser.add_argument('--nodes-json',
help='JSON file containing node configuration (rack -> [nodes])')
parser.add_argument('--output', '-o', default='pvcs.yaml',
help='Output YAML file path (default: pvcs.yaml)')
parser.add_argument('--html-output',
help='Generate HTML visualization of placement')
parser.add_argument('--base-storage-class',
help='Base storage class to use as template')
parser.add_argument('--directpv-drives-label',
help='DirectPV drive label to filter drives (e.g., directpv.min.io/tier=fast)')
parser.add_argument('--storage-class-per-node', action='store_true',
help='Create one storage class per node (default: one per rack)')
parser.add_argument('--audit', action='store_true',
help='Run in audit mode to analyze existing MinIO deployment')
parser.add_argument('--mc-alias',
help='MinIO mc alias for admin info command (required for audit mode)')
parser.add_argument('--insecure', action='store_true',
help='Use --insecure flag with mc command')
parser.add_argument('--audit-output',
help='Save audit report to file')
args = parser.parse_args()
if args.audit:
if not args.mc_alias:
print("Error: --mc-alias is required for audit mode", file=sys.stderr)
sys.exit(1)
audit_mode(args)
return
if not args.node_label and not args.nodes_json:
print("Error: Either --node-label or --nodes-json is required", file=sys.stderr)
sys.exit(1)
if args.node_label and args.nodes_json:
print("Error: Cannot specify both --node-label and --nodes-json", file=sys.stderr)
sys.exit(1)