import os
import glob
import re
import sys
import logging
import pandas as pd
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils.dataframe import dataframe_to_rows
from openpyxl.chart import BarChart, LineChart, Reference
from openpyxl.formatting.rule import CellIsRule
from openpyxl.drawing.image import Image as OpenpyxlImage
from minio import Minio
from minio.error import S3Error
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger(__name__)
DATA_DIR = os.getenv("DATA_DIR", "./data/merged")
CHARTS_DIR = os.getenv("CHARTS_DIR", "./data/charts")
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./data")
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio.ai-stor.local:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "admin")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "password")
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "devops-test")
USE_SSL = os.getenv("MINIO_USE_SSL", "False").lower() in ("true", "1", "yes")
def get_minio_client():
"""MinIO AIStor 클라이언트 초기화"""
try:
return Minio(
MINIO_ENDPOINT,
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=USE_SSL
)
except Exception as e:
logger.error(f"MinIO 클라이언트 초기화 실패: {e}")
return None
def upload_to_aistor(local_file_path, object_name):
"""생성된 엑셀 리포트를 MinIO AIStor devops-test 버킷에 업로드"""
client = get_minio_client()
if not client:
logger.warning("MinIO 클라이언트를 사용할 수 없어 업로드를 건너뜁니다.")
return False
try:
if not client.bucket_exists(MINIO_BUCKET):
logger.info(f"버킷이 존재하지 않아 생성합니다: {MINIO_BUCKET}")
client.make_bucket(MINIO_BUCKET)
logger.info(f"AIStor 업로드 시작: {local_file_path} -> bucket: {MINIO_BUCKET}/{object_name}")
client.fput_file(MINIO_BUCKET, object_name, local_file_path)
logger.info("AIStor 업로드 완료 성공.")
return True
except S3Error as e:
logger.error(f"AIStor 업로드 중 S3 에러 발생: {e}")
except Exception as e:
logger.error(f"AIStor 업로드 중 알 수 없는 오류 발생: {e}")
return False
def style_sheet(ws, title_text):
"""시트 기본 스타일링 (헤더, 그리드라인, 폰트 등)"""
ws.views.sheetView[0].showGridLines = True
font_family = "Arial"
header_fill = PatternFill(start_color="2F4F4F", end_color="2F4F4F", fill_type="solid")
header_font = Font(name=font_family, size=11, bold=True, color="FFFFFF")
data_font = Font(name=font_family, size=10, color="000000")
zebra_fill = PatternFill(start_color="F9FBFB", end_color="F9FBFB", fill_type="solid")
thin_border = Border(
left=Side(style='thin', color='E0E0E0'),
right=Side(style='thin', color='E0E0E0'),
top=Side(style='thin', color='E0E0E0'),
bottom=Side(style='thin', color='E0E0E0')
)
for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=ws.max_row, min_col=1, max_col=ws.max_column), start=1):
is_header = (row_idx == 1)
for cell in row:
if is_header:
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
else:
cell.font = data_font
cell.border = thin_border
if row_idx % 2 == 0:
cell.fill = zebra_fill
if isinstance(cell.value, (int, float)):
if cell.value == 0:
cell.number_format = '#,##0'
elif 'pct' in str(cell.column) or '비율' in str(ws.cell(1, cell.column).value or ''):
cell.number_format = '0.0%'
elif isinstance(cell.value, float):
cell.number_format = '#,##0.00'
else:
cell.number_format = '#,##0'
for col in ws.columns:
max_len = 0
col_letter = openpyxl.utils.get_column_letter(col[0].column)
for cell in col:
if cell.value:
max_len = max(max_len, len(str(cell.value)))
ws.column_dimensions[col_letter].width = max(max_len + 3, 12)
ws.freeze_panes = 'A2'
def add_pareto_chart(ws, data_len):
"""네임스페이스별 CPU 낭비 현황 오픈pyxl 자체 파레토 차트 삽입"""
if data_len <= 0:
logger.warning("차트를 생성할 데이터가 없어 차트 삽입을 건너뜁니다.")
return
try:
chart = BarChart()
chart.type = "col"
chart.style = 10
chart.title = "Namespace CPU Waste Pareto Analysis (Programmatic)"
chart.y_axis.title = "CPU Waste (Core-Hours)"
chart.x_axis.title = "Namespace"
data = Reference(ws, min_col=2, min_row=1, max_row=data_len + 1)
cats = Reference(ws, min_col=1, min_row=2, max_row=data_len + 1)
chart.add_data(data, titles_from_data=True)
chart.set_categories(cats)
line_chart = LineChart()
line_data = Reference(ws, min_col=3, min_row=1, max_row=data_len + 1)
line_chart.add_data(line_data, titles_from_data=True)
line_chart.y_axis.axId = 200
line_chart.y_axis.title = "Cumulative Percentage"
line_chart.y_axis.crosses = "max"
chart += line_chart
ws.add_chart(chart, f"E2")
logger.info("자체 스크립트 기반 파레토 차트가 시트(E2 위치)에 삽입되었습니다.")
except Exception as e:
logger.error(f"파레토 차트 생성 중 오류 발생: {e}")
def inject_step3_external_charts(wb, cluster, date):
"""
Step 3에서 생성된 19개 차트 중 미사용 중인 나머지 11개 차트를
별도의 'Charts' 전용 시트를 생성하여 바둑판 배열로 자동 삽입합니다.
- 기존 사용 8개: 1, 2, 3, 5, 6, 9, 10, 15
- 신규 추가 11개: 나머지 번호들
"""
used_charts = [1, 2, 3, 5, 6, 9, 10, 15]
all_charts = list(range(1, 20))
remaining_charts = [c for c in all_charts if c not in used_charts]
logger.info(f"[{cluster} | {date}] 미사용 차트({len(remaining_charts)}개) 일괄 배치를 위한 'Charts' 시트 생성 중...")
ws_charts = wb.create_sheet(title="Charts")
ws_charts.views.sheetView[0].showGridLines = True
title_font = Font(name="Arial", size=14, bold=True, color="2F4F4F")
ws_charts["A1"] = f"Step 3 Additional Dashboards & Charts ({cluster} - {date})"
ws_charts["A1"].font = title_font
col_positions = ['B', 'N']
current_row = 3
col_idx = 0
for chart_id in remaining_charts:
possible_filenames = [
f"chart_{chart_id}_{cluster}_{date}.png",
f"chart_{cluster}_{date}_{chart_id}.png",
f"chart_{chart_id}.png",
f"chart_fixed_{chart_id}.png"
]
chart_file_path = None
for fname in possible_filenames:
for d in [CHARTS_DIR, DATA_DIR, "./data", "."]:
p = os.path.join(d, fname)
if os.path.exists(p):
chart_file_path = p
break
if chart_file_path:
break
if chart_file_path:
try:
img = OpenpyxlImage(chart_file_path)
cell_address = f"{col_positions[col_idx]}{current_row}"
ws_charts.add_image(img, cell_address)
logger.info(f" -> 차트 {chart_id} 삽입 완료: {chart_file_path} -> cell: {cell_address}")
col_idx += 1
if col_idx >= len(col_positions):
col_idx = 0
current_row += 20
except Exception as e:
logger.error(f" -> 차트 {chart_id}({chart_file_path}) 삽입 중 openpyxl 예외 발생: {e}")
else:
logger.warning(f" -> 차트 {chart_id} 원부 이미지를 찾을 수 없어 건너뜁니다. (검색 패턴 샘플: chart_{chart_id}_*.png)")
def generate_report(cluster, date):
"""동적 매핑된 파일들을 결합하여 최종 엑셀 리포트 빌드"""
usage_file = os.path.join(DATA_DIR, f"daily_ns_usage_{cluster}_{date}.parquet")
pareto_file = os.path.join(DATA_DIR, f"pareto_ns_{cluster}_{date}.parquet")
enriched_file = os.path.join(DATA_DIR, f"daily_enriched_{cluster}_{date}.parquet")
missing_files = [f for f in [usage_file, pareto_file, enriched_file] if not os.path.exists(f)]
if missing_files:
logger.error(f"[{cluster} | {date}] 필수 가공원부가 유실되어 빌드를 건너뜁니다: {missing_files}")
return
logger.info(f"[{cluster} | {date}] 엑셀 리포트 가공 시작...")
df_usage = pd.read_parquet(usage_file)
df_pareto = pd.read_parquet(pareto_file)
df_enriched = pd.read_parquet(enriched_file)
wb = openpyxl.Workbook()
ws_summary = wb.active
ws_summary.title = "Summary Metrics"
ws_summary.append(["Metric Key", "Value"])
ws_summary.append(["Cluster Name", cluster])
ws_summary.append(["Analysis Date", date])
ws_summary.append(["Total Namespaces", len(df_usage['namespace'].unique()) if 'namespace' in df_usage.columns else 0])
if 'cpu_waste' in df_pareto.columns:
ws_summary.append(["Total CPU Waste (Core-Hours)", float(df_pareto['cpu_waste'].sum())])
style_sheet(ws_summary, "Summary Metrics")
ws_usage = wb.create_sheet(title="Daily Namespace Usage")
for r in dataframe_to_rows(df_usage, index=False, header=True):
ws_usage.append(r)
style_sheet(ws_usage, "Daily Namespace Usage")
ws_pareto = wb.create_sheet(title="Pareto Analysis")
for r in dataframe_to_rows(df_pareto, index=False, header=True):
ws_pareto.append(r)
style_sheet(ws_pareto, "Pareto Analysis")
add_pareto_chart(ws_pareto, len(df_pareto))
ws_enriched = wb.create_sheet(title="Enriched Metrics")
for r in dataframe_to_rows(df_enriched, index=False, header=True):
ws_enriched.append(r)
style_sheet(ws_enriched, "Enriched Metrics")
inject_step3_external_charts(wb, cluster, date)
if 'cpu_waste' in df_pareto.columns:
red_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
ws_pareto.conditional_formatting.add(
f"B2:B{len(df_pareto)+1}",
CellIsRule(operator='greaterThan', formula=['100'], stopIfTrue=True, fill=red_fill)
)
output_filename = f"res_usage_report_{cluster}_{date}.xlsx"
local_output_path = os.path.join(OUTPUT_DIR, output_filename)
os.makedirs(OUTPUT_DIR, exist_ok=True)
wb.save(local_output_path)
logger.info(f"로컬 엑셀 리포트 저장 성공: {local_output_path}")
upload_to_aistor(local_output_path, output_filename)
def main():
logger.info(f"가공원부 자동 검색 스캔 시작 (경로: {DATA_DIR})")
search_pattern = os.path.join(DATA_DIR, "pareto_ns_*.parquet")
pareto_files = glob.glob(search_pattern)
if not pareto_files:
logger.warning(f"탐색된 파레토 가공원부(*.parquet) 파일이 없습니다. 경로를 확인하세요: {search_pattern}")
return
processed_count = 0
for file_path in pareto_files:
filename = os.path.basename(file_path)
match = re.search(r"pareto_ns_(.*)_(.*)\.parquet", filename)
if match:
cluster = match.group(1)
date = match.group(2)
logger.info(f"파티션 감지 성공 -> Cluster: {cluster}, Date: {date}")
generate_report(cluster, date)
processed_count += 1
logger.info(f"전체 자동 매칭 빌드 프로세스 완료. (처리 완료 세트 수: {processed_count})")
if __name__ == "__main__":
main()