"""
Copyright MinIO 2025
PVC Allocator for MinIO ObjectStore
Generates PVCs with balanced placement across nodes for MinIO erasure sets
"""
import argparse
import json
import re
import subprocess
import sys
import yaml
from collections import defaultdict, Counter
from typing import Dict, List, Tuple, Any, Optional
def natural_sort_key(text):
"""
Generate a key for natural sorting that handles numbers correctly.
E.g., node-2 comes before node-10
"""
def atoi(text):
return int(text) if text.isdigit() else text
return [atoi(c) for c in re.split(r'(\d+)', text)]
def run_kubectl(cmd: str) -> str:
"""Execute kubectl command and return output"""
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode != 0:
print(f"Error running kubectl: {result.stderr}", file=sys.stderr)
sys.exit(1)
return result.stdout
def get_objectstore_cr(name: str, namespace: str = None, file: str = None) -> Dict:
"""Get ObjectStore CR from file or kubectl"""
if file:
with open(file, 'r') as f:
return yaml.safe_load(f)
elif name and namespace:
cmd = f"kubectl -n {namespace} get objectstore {name} -o yaml"
output = run_kubectl(cmd)
return yaml.safe_load(output)
else:
print("Error: Either provide --file or both --name and --namespace", file=sys.stderr)
sys.exit(1)
def get_nodes_by_label(label_key: str, audit_mode: bool = False) -> Dict[str, List[str]]:
"""Get nodes grouped by label value"""
cmd = "kubectl get nodes -o json"
output = run_kubectl(cmd)
nodes_data = json.loads(output)
nodes_by_label = defaultdict(list)
all_nodes = []
nodes_without_label = []
for node in nodes_data.get('items', []):
node_name = node['metadata']['name']
labels = node['metadata'].get('labels', {})
all_nodes.append(node_name)
if label_key in labels:
label_value = labels[label_key]
nodes_by_label[label_value].append(node_name)
else:
nodes_without_label.append(node_name)
if not nodes_by_label:
if audit_mode and all_nodes:
print(f"Warning: No nodes have label '{label_key}'", file=sys.stderr)
print(f"Treating each node as its own rack for audit purposes", file=sys.stderr)
for i, node in enumerate(sorted(all_nodes, key=natural_sort_key)):
nodes_by_label[str(i + 1)] = [node]
print(f"Created {len(nodes_by_label)} synthetic racks for {len(all_nodes)} nodes", file=sys.stderr)
elif all_nodes:
print(f"Warning: No nodes found with label '{label_key}'", file=sys.stderr)
print(f"Found {len(all_nodes)} nodes total: {', '.join(sorted(all_nodes, key=natural_sort_key))}", file=sys.stderr)
if nodes_data.get('items'):
first_node = nodes_data['items'][0]
node_labels = first_node['metadata'].get('labels', {})
relevant_labels = [k for k in node_labels.keys()
if 'min.io' in k or 'rack' in k.lower() or 'zone' in k.lower() or 'region' in k.lower()]
if relevant_labels:
print(f"\nPotentially relevant labels on node '{first_node['metadata']['name']}':", file=sys.stderr)
for key in sorted(relevant_labels):
print(f" {key}: {node_labels[key]}", file=sys.stderr)
elif nodes_without_label and audit_mode:
print(f"Warning: {len(nodes_without_label)} nodes don't have label '{label_key}': {', '.join(sorted(nodes_without_label, key=natural_sort_key))}", file=sys.stderr)
for label_value in nodes_by_label:
nodes_by_label[label_value].sort(key=natural_sort_key)
return dict(nodes_by_label)
def calculate_pool_pvcs(pool: Dict) -> Tuple[int, int, int]:
"""Calculate total PVCs needed for a pool"""
servers = pool.get('servers', 1)
volumes_per_server = pool.get('volumesPerServer', 1)
total_pvcs = servers * volumes_per_server
return servers, volumes_per_server, total_pvcs
def plan_pvc_placement(pools: List[Dict], nodes_by_label: Dict[str, List[str]],
objectstore_name: str, namespace: str) -> List[Dict]:
"""Plan PVC placement across nodes - distribute servers round-robin across racks first"""
placements = []
rack_names = sorted(nodes_by_label.keys(), key=natural_sort_key)
if not rack_names:
print("Error: No nodes found with the specified label", file=sys.stderr)
sys.exit(1)
racks = []
for rack_name in rack_names:
nodes = sorted(nodes_by_label[rack_name], key=natural_sort_key)
racks.append({
'name': rack_name,
'nodes': nodes,
'next_node_index': 0
})
server_count = 0
for pool_idx, pool in enumerate(pools):
pool_name = pool.get('name', f'pool-{pool_idx}')
servers, volumes_per_server, total_pvcs = calculate_pool_pvcs(pool)
pvc_template = pool.get('volumeClaimTemplate', {})
for server_idx in range(servers):
rack_idx = server_count % len(racks)
rack = racks[rack_idx]
node_idx = rack['next_node_index'] % len(rack['nodes'])
selected_node = rack['nodes'][node_idx]
rack['next_node_index'] += 1
for volume_idx in range(volumes_per_server):
pvc_name = f"data{volume_idx}-{objectstore_name}-{pool_name}-{server_idx}"
placement = {
'pvc_name': pvc_name,
'pool_name': pool_name,
'server_index': server_idx,
'volume_index': volume_idx,
'node': selected_node,
'bucket': rack['name'],
'pvc_template': pvc_template,
'namespace': namespace
}
placements.append(placement)
server_count += 1
return placements
def generate_storage_class(name: str, base_storage_class: str, node_label_key: str,
node_label_value: str, node_names: List[str],
directpv_drives_label: str = None) -> Dict:
"""Generate DirectPV-compatible StorageClass with node affinity
Args:
node_names: List of node names for this storage class (single node for per-node,
multiple for per-rack)
"""
parameters = {
'csi.storage.k8s.io/fstype': 'xfs',
}
if directpv_drives_label:
if '=' in directpv_drives_label:
key, value = directpv_drives_label.split('=', 1)
parameters[key] = value
else:
parameters[directpv_drives_label] = ''
return {
'apiVersion': 'storage.k8s.io/v1',
'kind': 'StorageClass',
'metadata': {
'name': name,
'labels': {
'application-name': 'directpv.min.io',
'application-type': 'CSIDriver',
'directpv.min.io/created-by': 'pvc-allocator',
'directpv.min.io/version': 'v1beta1'
},
'finalizers': ['foregroundDeletion']
},
'provisioner': 'directpv-min-io',
'reclaimPolicy': 'Delete',
'volumeBindingMode': 'WaitForFirstConsumer',
'allowVolumeExpansion': True,
'allowedTopologies': [{
'matchLabelExpressions': [
{
'key': 'directpv.min.io/identity',
'values': ['directpv-min-io']
},
{
'key': 'directpv.min.io/node',
'values': node_names
}
]
}],
'parameters': parameters
}
def generate_pvc(placement: Dict, storage_class_name: str) -> Dict:
"""Generate PVC from placement info"""
pvc_template = placement['pvc_template']
pvc = {
'apiVersion': pvc_template.get('apiVersion', 'v1'),
'kind': 'PersistentVolumeClaim',
'metadata': {
'name': placement['pvc_name'],
'namespace': placement['namespace']
}
}
template_metadata = pvc_template.get('metadata', {}).copy()
template_labels = template_metadata.get('labels', {})
pvc['metadata']['labels'] = {
**template_labels,
'app': 'minio',
'pool': placement['pool_name'],
'server': str(placement['server_index']),
'volume': str(placement['volume_index'])
}
if 'annotations' in template_metadata:
pvc['metadata']['annotations'] = template_metadata['annotations']
pvc_spec = pvc_template.get('spec', {}).copy()
pvc_spec['storageClassName'] = storage_class_name
pvc['spec'] = pvc_spec
return pvc
def generate_yaml_output(placements: List[Dict], node_label_key: str,
nodes_by_label: Dict[str, List[str]],
base_storage_class: str = None,
directpv_drives_label: str = None,
storage_class_per_node: bool = False) -> str:
"""Generate final YAML output with StorageClasses and PVCs"""
documents = []
storage_classes_created = {}
bucket_names = sorted(nodes_by_label.keys(), key=natural_sort_key)
if storage_class_per_node:
placements_by_node = defaultdict(list)
for placement in placements:
node_key = (placement['node'], placement['bucket'])
placements_by_node[node_key].append(placement)
for (node, bucket), node_placements in placements_by_node.items():
bucket_index = bucket_names.index(bucket)
sc_name = f"minio-{bucket_index}-{node}"
if sc_name not in storage_classes_created:
sc = generate_storage_class(
name=sc_name,
base_storage_class=base_storage_class or 'standard',
node_label_key=node_label_key,
node_label_value=bucket,
node_names=[node],
directpv_drives_label=directpv_drives_label
)
documents.append(sc)
storage_classes_created[sc_name] = sc
else:
for bucket in bucket_names:
bucket_index = bucket_names.index(bucket)
sc_name = f"minio-rack-{bucket_index}"
nodes_in_rack = nodes_by_label[bucket]
sc = generate_storage_class(
name=sc_name,
base_storage_class=base_storage_class or 'standard',
node_label_key=node_label_key,
node_label_value=bucket,
node_names=nodes_in_rack,
directpv_drives_label=directpv_drives_label
)
documents.append(sc)
storage_classes_created[sc_name] = sc
for placement in placements:
bucket_index = bucket_names.index(placement['bucket'])
if storage_class_per_node:
sc_name = f"minio-{bucket_index}-{placement['node']}"
else:
sc_name = f"minio-rack-{bucket_index}"
pvc = generate_pvc(placement, sc_name)
documents.append(pvc)
yaml_output = ""
for doc in documents:
yaml_output += "---\n"
yaml_output += yaml.dump(doc, default_flow_style=False, sort_keys=False)
return yaml_output
def generate_html_visualization(placements: List[Dict], nodes_by_label: Dict[str, List[str]],
objectstore_name: str) -> str:
"""Generate HTML table visualization of pod placement across racks and nodes"""
total_pvcs = len(placements)
num_racks = len(nodes_by_label)
total_nodes = sum(len(nodes) for nodes in nodes_by_label.values())
html = f"""<!DOCTYPE html>
<html>
<head>
<title>MinIO PVC Placement Visualization</title>
<style>
body {{
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}}
h1 {{
color: #333;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
}}
.info {{
background-color: #e7f3ff;
border-left: 4px solid #007bff;
padding: 10px;
margin-bottom: 20px;
}}
table {{
border-collapse: collapse;
width: 100%;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
th {{
background-color: #007bff;
color: white;
padding: 12px;
text-align: left;
font-weight: 600;
position: sticky;
top: 0;
z-index: 10;
}}
.rack-header {{
background-color: #0056b3;
text-align: center;
}}
td {{
padding: 10px;
border: 1px solid #ddd;
vertical-align: top;
}}
.node-cell {{
background-color: #f8f9fa;
font-weight: 500;
border-right: 2px solid #007bff;
}}
.pod-cell {{
background-color: white;
}}
.pod-item {{
background-color: #e8f5e9;
border: 1px solid #4caf50;
border-radius: 4px;
padding: 5px;
margin: 3px 0;
font-size: 0.9em;
}}
.pool-0 {{ background-color: #e3f2fd; border-color: #2196f3; }}
.pool-1 {{ background-color: #f3e5f5; border-color: #9c27b0; }}
.pool-2 {{ background-color: #fff3e0; border-color: #ff9800; }}
.pool-3 {{ background-color: #e8f5e9; border-color: #4caf50; }}
.empty-cell {{
color: #999;
font-style: italic;
text-align: center;
}}
.stats {{
margin-top: 20px;
padding: 15px;
background-color: white;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
.stat-item {{
display: inline-block;
margin-right: 30px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
}}
.stat-label {{
font-weight: 600;
color: #666;
}}
.stat-value {{
font-size: 1.2em;
color: #007bff;
font-weight: bold;
}}
</style>
</head>
<body>
<h1>MinIO PVC Placement Visualization - {objectstore_name}</h1>
<div class="info">
<strong>ObjectStore:</strong> {objectstore_name}<br>
<strong>Total PVCs:</strong> {total_pvcs}<br>
<strong>Racks:</strong> {num_racks}<br>
<strong>Total Nodes:</strong> {total_nodes}
</div>
<table>
<thead>
<tr>
<th>Node</th>
"""
for rack in sorted(nodes_by_label.keys()):
html += f' <th class="rack-header">Rack {rack}</th>\n'
html += ' </tr>\n </thead>\n <tbody>\n'
placement_lookup = defaultdict(list)
for placement in placements:
key = (placement['node'], placement['bucket'])
pod_name = f"{objectstore_name}-{placement['pool_name']}-{placement['server_index']}"
placement_lookup[key].append({
'pod': pod_name,
'pvc': placement['pvc_name'],
'pool': placement['pool_name'],
'server_index': placement['server_index'],
'volume_index': placement['volume_index'],
'pool_idx': list(set(p['pool_name'] for p in placements)).index(placement['pool_name'])
})
max_nodes = max(len(nodes) for nodes in nodes_by_label.values())
for node_idx in range(max_nodes):
html += ' <tr>\n'
html += f' <td class="node-cell">Node {node_idx + 1}</td>\n'
for rack in sorted(nodes_by_label.keys()):
nodes = nodes_by_label[rack]
if node_idx < len(nodes):
node = nodes[node_idx]
key = (node, rack)
pods = placement_lookup.get(key, [])
html += ' <td class="pod-cell">\n'
html += f' <strong>{node}</strong><br>\n'
if pods:
pods_by_name = defaultdict(list)
for pod_info in pods:
pods_by_name[pod_info['pod']].append(pod_info)
for pod_name, pod_pvcs in pods_by_name.items():
pool_class = f"pool-{pod_pvcs[0]['pool_idx'] % 4}"
volume_info = f"Volumes: {', '.join(str(p['volume_index']) for p in pod_pvcs)}"
pvc_names = '\\n'.join(p['pvc'] for p in pod_pvcs)
html += f' <div class="pod-item {pool_class}" title="PVCs:\\n{pvc_names}">\n'
html += f' <strong>{pod_name}</strong><br>\n'
html += f' <small>{volume_info}</small>\n'
html += ' </div>\n'
else:
html += ' <div class="empty-cell">No pods</div>\n'
html += ' </td>\n'
else:
html += ' <td class="empty-cell">-</td>\n'
html += ' </tr>\n'
html += ' </tbody>\n </table>\n'
html += ' <div class="stats">\n'
html += ' <h2>Placement Statistics</h2>\n'
for rack in sorted(nodes_by_label.keys()):
nodes = nodes_by_label[rack]
rack_pvcs = sum(len(placement_lookup.get((node, rack), [])) for node in nodes)
html += f' <div class="stat-item">\n'
html += f' <div class="stat-label">Rack {rack}</div>\n'
html += f' <div class="stat-value">{rack_pvcs} PVCs</div>\n'
html += f' </div>\n'
html += ' </div>\n'
html += '</body>\n</html>'
return html
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 with label {args.node_label}...")
nodes_by_label = get_nodes_by_label(args.node_label, audit_mode=True)
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,
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', required=True,
help='Node label for placement buckets (e.g., v1.min.io/rack)')
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
print(f"Fetching ObjectStore CR...")
objectstore = get_objectstore_cr(args.name, args.namespace, args.file)
objectstore_name = objectstore['metadata']['name']
namespace = objectstore['metadata']['namespace']
pools = objectstore['spec'].get('pools', [])
if not pools:
print("Error: No pools found in ObjectStore CR", file=sys.stderr)
sys.exit(1)
print(f"Fetching nodes with label {args.node_label}...")
nodes_by_label = get_nodes_by_label(args.node_label)
if not nodes_by_label:
print(f"Error: No nodes found with label {args.node_label}", file=sys.stderr)
sys.exit(1)
print(f"Found {len(nodes_by_label)} buckets with total {sum(len(nodes) for nodes in nodes_by_label.values())} nodes")
print("Planning PVC placement across nodes...")
placements = plan_pvc_placement(pools, nodes_by_label, objectstore_name, namespace)
print(f"Planned {len(placements)} PVCs across nodes")
print("Generating YAML output...")
yaml_output = generate_yaml_output(placements, args.node_label, nodes_by_label,
args.base_storage_class, args.directpv_drives_label,
args.storage_class_per_node)
with open(args.output, 'w') as f:
f.write(yaml_output)
print(f"Successfully generated {args.output} with {len(placements)} PVCs")
if args.html_output:
print("Generating HTML visualization...")
html_output = generate_html_visualization(placements, nodes_by_label, objectstore_name)
with open(args.html_output, 'w') as f:
f.write(html_output)
print(f"Successfully generated HTML visualization: {args.html_output}")
print("\nPlacement Summary:")
placement_count = defaultdict(int)
for p in placements:
placement_count[f"{p['bucket']}/{p['node']}"] += 1
for location, count in sorted(placement_count.items()):
print(f" {location}: {count} PVCs")
if __name__ == "__main__":
main()