1240 lines
57 KiB
Python
1240 lines
57 KiB
Python
"""
|
||
Build the 16-slide deck for the Project-Infra / Project-Auth-Server presentation.
|
||
|
||
Usage:
|
||
/home/donghyeon/dev/Project-Infra/presentation/.venv/bin/python \
|
||
/home/donghyeon/dev/Project-Infra/presentation/scripts/build_pptx.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
from pptx import Presentation
|
||
from pptx.dml.color import RGBColor
|
||
from pptx.enum.dml import MSO_LINE_DASH_STYLE as MSO_LINE
|
||
from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
|
||
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||
from pptx.util import Cm, Emu, Pt
|
||
|
||
ROOT = Path("/home/donghyeon/dev/Project-Infra/presentation")
|
||
EXPORTS = ROOT / "exports"
|
||
OUT = ROOT / "build" / "deck.pptx"
|
||
|
||
FONT = "Pretendard"
|
||
FONT_FALLBACK = "Helvetica"
|
||
|
||
C_BG = RGBColor(0xFF, 0xFF, 0xFF)
|
||
C_TEXT = RGBColor(0x24, 0x29, 0x2F)
|
||
C_MUTED = RGBColor(0x57, 0x60, 0x6A)
|
||
C_BORDER = RGBColor(0xD0, 0xD7, 0xDE)
|
||
C_EDGE = RGBColor(0xC2, 0x41, 0x0C)
|
||
C_APP = RGBColor(0x1E, 0x40, 0xAF)
|
||
C_DATA = RGBColor(0x04, 0x78, 0x57)
|
||
C_SECRET = RGBColor(0x5B, 0x21, 0xB6)
|
||
C_DANGER = RGBColor(0xB9, 0x1C, 0x1C)
|
||
C_GOOD = RGBColor(0x15, 0x80, 0x3D)
|
||
C_PANEL = RGBColor(0xF6, 0xF8, 0xFA)
|
||
C_GOOD_FILL = RGBColor(0xEC, 0xFD, 0xF5)
|
||
C_DANGER_FILL = RGBColor(0xFE, 0xF2, 0xF2)
|
||
C_APP_FILL = RGBColor(0xF0, 0xF9, 0xFF)
|
||
C_WARN_FILL = RGBColor(0xFE, 0xF3, 0xC7)
|
||
C_WARN_LINE = RGBColor(0xCA, 0x8A, 0x04)
|
||
C_WARN_TEXT = RGBColor(0x92, 0x40, 0x0E)
|
||
|
||
|
||
# ─────────────── layout grid (master) ───────────────
|
||
MARGIN_X = Cm(1.2)
|
||
TITLE_TOP = Cm(0.8)
|
||
CONTENT_TOP = Cm(3.0)
|
||
CONTENT_TOP_WIDE = Cm(3.4)
|
||
CAPTION_BAND_H = Cm(1.1)
|
||
|
||
|
||
def content_w():
|
||
return SLIDE_W - MARGIN_X * 2
|
||
|
||
|
||
def emu(cm: float) -> int:
|
||
return Cm(cm)
|
||
|
||
|
||
SLIDE_W = Cm(33.867)
|
||
SLIDE_H = Cm(19.05)
|
||
|
||
|
||
def set_run(run, text: str, size: int, bold: bool = False, color=C_TEXT, mono: bool = False):
|
||
run.text = text
|
||
run.font.size = Pt(size)
|
||
run.font.bold = bold
|
||
run.font.name = "Consolas" if mono else FONT
|
||
run.font.color.rgb = color
|
||
|
||
|
||
def add_text_box(
|
||
slide,
|
||
left,
|
||
top,
|
||
width,
|
||
height,
|
||
text,
|
||
*,
|
||
size=18,
|
||
bold=False,
|
||
color=C_TEXT,
|
||
align=PP_ALIGN.LEFT,
|
||
anchor=MSO_ANCHOR.TOP,
|
||
mono=False,
|
||
):
|
||
box = slide.shapes.add_textbox(left, top, width, height)
|
||
tf = box.text_frame
|
||
tf.word_wrap = True
|
||
tf.margin_left = Cm(0.1)
|
||
tf.margin_right = Cm(0.1)
|
||
tf.margin_top = Cm(0.05)
|
||
tf.margin_bottom = Cm(0.05)
|
||
tf.vertical_anchor = anchor
|
||
p = tf.paragraphs[0]
|
||
p.alignment = align
|
||
set_run(p.add_run(), text, size, bold, color, mono)
|
||
return box
|
||
|
||
|
||
def add_panel(slide, left, top, width, height, fill=C_PANEL, line=C_BORDER, line_w=0.75):
|
||
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height)
|
||
shape.fill.solid()
|
||
shape.fill.fore_color.rgb = fill
|
||
shape.line.color.rgb = line
|
||
shape.line.width = Pt(line_w)
|
||
shape.shadow.inherit = False
|
||
shape.text_frame.text = ""
|
||
return shape
|
||
|
||
|
||
def add_slide_chrome(slide, page_no, total, domain_label=""):
|
||
"""Domain label (top-left) + page number (bottom-right)."""
|
||
if domain_label:
|
||
add_text_box(
|
||
slide,
|
||
Cm(0.6),
|
||
Cm(0.3),
|
||
Cm(15),
|
||
Cm(0.7),
|
||
domain_label,
|
||
size=12,
|
||
color=C_MUTED,
|
||
)
|
||
add_text_box(
|
||
slide,
|
||
SLIDE_W - Cm(2.4),
|
||
SLIDE_H - Cm(0.7),
|
||
Cm(2),
|
||
Cm(0.5),
|
||
f"{page_no} / {total}",
|
||
size=10,
|
||
color=C_MUTED,
|
||
align=PP_ALIGN.RIGHT,
|
||
)
|
||
|
||
|
||
def slide_diagram_only(
|
||
prs,
|
||
png_name,
|
||
page_no,
|
||
total,
|
||
domain_label,
|
||
*,
|
||
title=None,
|
||
subtitle=None,
|
||
caption=None,
|
||
):
|
||
"""다이어그램 슬라이드. drawio 는 순수 그림만 들어있고,
|
||
제목/부제/캡션은 PPT 단에서 일관된 chrome 으로 그린다.
|
||
|
||
title 은 한 줄 또는 두 줄로 wrap 될 수 있으므로 자체적으로 큰 박스 + 28pt 로 그림
|
||
(add_title 의 기본 layout 과 분리 — slide_diagram_only 외 함수에 영향 없음).
|
||
"""
|
||
s = blank_slide(prs)
|
||
if title:
|
||
# add_title 과 동일한 box (1.4cm × 32pt BOTTOM) — 모든 슬라이드 title baseline 통일
|
||
add_title(s, title, subtitle)
|
||
add_diagram_image(
|
||
s,
|
||
EXPORTS / png_name,
|
||
# title(0.8~2.2) + subtitle(2.3~3.2) + 0.3cm 여백
|
||
top_cm=3.5 if title else 1.0,
|
||
bottom_cm=2.4 if caption else 0.8,
|
||
)
|
||
if caption:
|
||
add_caption_band(s, caption)
|
||
add_slide_chrome(s, page_no, total, domain_label)
|
||
return s
|
||
|
||
|
||
def add_notes(slide, notes: str):
|
||
"""Attach speaker notes to a slide."""
|
||
tf = slide.notes_slide.notes_text_frame
|
||
tf.clear()
|
||
p = tf.paragraphs[0]
|
||
set_run(p.add_run(), notes, 11, False, C_TEXT)
|
||
|
||
|
||
def add_title(slide, title, subtitle=None, top=Cm(0.8)):
|
||
add_text_box(
|
||
slide, Cm(1.2), top, SLIDE_W - Cm(2.4), Cm(1.4), title, size=32, bold=True, color=C_TEXT,
|
||
anchor=MSO_ANCHOR.BOTTOM,
|
||
)
|
||
if subtitle:
|
||
add_text_box(
|
||
slide,
|
||
Cm(1.2),
|
||
top + Cm(1.5),
|
||
SLIDE_W - Cm(2.4),
|
||
Cm(0.9),
|
||
subtitle,
|
||
size=14,
|
||
color=C_MUTED,
|
||
)
|
||
|
||
|
||
def add_diagram_image(slide, png_path: Path, top_cm=3.0, bottom_cm=2.4):
|
||
"""Insert PNG centered, scaled to fit between top_cm and (slide_h - bottom_cm)."""
|
||
from PIL import Image
|
||
|
||
img = Image.open(png_path)
|
||
w_px, h_px = img.size
|
||
aspect = w_px / h_px
|
||
|
||
avail_w = SLIDE_W - Cm(1.2 * 2)
|
||
avail_h = SLIDE_H - Cm(top_cm + bottom_cm)
|
||
|
||
if avail_w / aspect <= avail_h:
|
||
target_w = avail_w
|
||
target_h = int(avail_w / aspect)
|
||
else:
|
||
target_h = avail_h
|
||
target_w = int(avail_h * aspect)
|
||
|
||
left = (SLIDE_W - target_w) // 2
|
||
top = Cm(top_cm) + (avail_h - target_h) // 2
|
||
slide.shapes.add_picture(str(png_path), left, top, width=target_w, height=target_h)
|
||
|
||
|
||
def add_caption_band(slide, text, *, color=C_TEXT, fill=C_PANEL):
|
||
band_top = SLIDE_H - Cm(2.0)
|
||
band = add_panel(slide, Cm(1.2), band_top, SLIDE_W - Cm(2.4), Cm(1.1), fill=fill)
|
||
tf = band.text_frame
|
||
tf.margin_left = Cm(0.4)
|
||
tf.margin_right = Cm(0.4)
|
||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||
p = tf.paragraphs[0]
|
||
p.alignment = PP_ALIGN.CENTER
|
||
set_run(p.add_run(), text, 14, True, color)
|
||
|
||
|
||
def add_diagram_placeholder(slide, left, top, width, height, *, label="다이어그램 자리", note=""):
|
||
"""발표 후 draw.io 로 채울 자리. 점선 테두리 + 라벨 + 짧은 안내."""
|
||
from pptx.dml.color import RGBColor as _RGB
|
||
PLACEHOLDER_FILL = _RGB(0xFA, 0xFA, 0xFA)
|
||
PLACEHOLDER_BORDER = _RGB(0xC9, 0xCD, 0xD3)
|
||
|
||
panel = slide.shapes.add_shape(
|
||
MSO_SHAPE.RECTANGLE, int(left), int(top), int(width), int(height)
|
||
)
|
||
panel.fill.solid()
|
||
panel.fill.fore_color.rgb = PLACEHOLDER_FILL
|
||
panel.line.color.rgb = PLACEHOLDER_BORDER
|
||
panel.line.width = Pt(1.25)
|
||
panel.line.dash_style = MSO_LINE.DASH
|
||
panel.shadow.inherit = False
|
||
|
||
add_text_box(
|
||
slide, left, top + (height - Cm(2.0)) // 2, width, Cm(1.0),
|
||
f"[ {label} ]",
|
||
size=20, bold=True, color=PLACEHOLDER_BORDER, align=PP_ALIGN.CENTER,
|
||
)
|
||
if note:
|
||
add_text_box(
|
||
slide, left, top + (height - Cm(2.0)) // 2 + Cm(1.1), width, Cm(0.8),
|
||
note,
|
||
size=12, color=C_MUTED, align=PP_ALIGN.CENTER,
|
||
)
|
||
|
||
|
||
def add_table(
|
||
slide,
|
||
left,
|
||
top,
|
||
headers,
|
||
rows,
|
||
*,
|
||
col_widths_cm=None,
|
||
header_fill=None,
|
||
row_h_cm=0.95,
|
||
body_size=12,
|
||
header_size=13,
|
||
highlight_rows=None,
|
||
):
|
||
cols = len(headers)
|
||
if col_widths_cm is None:
|
||
col_widths_cm = [(SLIDE_W.cm - 2.4) / cols] * cols
|
||
|
||
rows_n = len(rows) + 1
|
||
total_w = sum(Cm(w) for w in col_widths_cm)
|
||
row_h = Cm(row_h_cm)
|
||
table_shape = slide.shapes.add_table(rows_n, cols, left, top, total_w, row_h * rows_n)
|
||
table = table_shape.table
|
||
|
||
table.first_row = False
|
||
table.first_col = False
|
||
table.last_row = False
|
||
table.last_col = False
|
||
table.horz_banding = False
|
||
table.vert_banding = False
|
||
|
||
for i, w in enumerate(col_widths_cm):
|
||
table.columns[i].width = Cm(w)
|
||
for r in range(rows_n):
|
||
table.rows[r].height = row_h
|
||
|
||
for ci, h in enumerate(headers):
|
||
cell = table.cell(0, ci)
|
||
cell.fill.solid()
|
||
cell.fill.fore_color.rgb = header_fill or C_PANEL
|
||
tf = cell.text_frame
|
||
tf.margin_left = Cm(0.2)
|
||
tf.margin_right = Cm(0.2)
|
||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||
p = tf.paragraphs[0]
|
||
p.alignment = PP_ALIGN.CENTER
|
||
set_run(p.add_run(), h, header_size, True, C_TEXT)
|
||
|
||
HIGHLIGHT_FILL = RGBColor(0xFE, 0xF9, 0xC3)
|
||
HIGHLIGHT_BORDER = RGBColor(0xCA, 0x8A, 0x04)
|
||
for ri, row in enumerate(rows, start=1):
|
||
is_highlight = bool(highlight_rows and ri in highlight_rows)
|
||
for ci, text in enumerate(row):
|
||
cell = table.cell(ri, ci)
|
||
cell.fill.solid()
|
||
if is_highlight:
|
||
cell.fill.fore_color.rgb = HIGHLIGHT_FILL
|
||
else:
|
||
cell.fill.fore_color.rgb = C_BG if ri % 2 == 1 else C_PANEL
|
||
tf = cell.text_frame
|
||
tf.margin_left = Cm(0.2)
|
||
tf.margin_right = Cm(0.2)
|
||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||
p = tf.paragraphs[0]
|
||
p.alignment = PP_ALIGN.LEFT if ci == 0 else PP_ALIGN.CENTER
|
||
set_run(
|
||
p.add_run(),
|
||
text,
|
||
body_size,
|
||
bold=(ci == 0) or is_highlight,
|
||
color=HIGHLIGHT_BORDER if is_highlight else C_TEXT,
|
||
)
|
||
|
||
|
||
def blank_slide(prs):
|
||
return prs.slides.add_slide(prs.slide_layouts[6])
|
||
|
||
|
||
def add_arrow(slide, x1, y1, x2, y2, *, color=C_MUTED, width=1.5):
|
||
line = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, x1, y1, x2, y2)
|
||
line.line.color.rgb = color
|
||
line.line.width = Pt(width)
|
||
return line
|
||
|
||
|
||
# ─────────────────────── slides ───────────────────────
|
||
|
||
|
||
def slide_01_cover(prs, total):
|
||
s = blank_slide(prs)
|
||
add_notes(s, (
|
||
"발표 시작 — 인사 + 자기소개 짧게.\n\n"
|
||
"오늘 주제 한 줄 — Spring Boot auth-server 를 Kubernetes(K3s) 위에서 사이드카 없이, "
|
||
"운영자가 Vault 만 보고도 시크릿이 흐르게 어떻게 묶었는가.\n\n"
|
||
"발표 흐름 — Auth boundary → Application → Data/Migration → Secret(Vault·VSO) → "
|
||
"Runtime(K8s·Traefik) → 다음 검증 항목.\n\n"
|
||
"시간 안내 (약 30 분 + Q&A). 질문은 중간에 끊어도 OK."
|
||
))
|
||
# 한 줄 메인 카피 — 동사 + 결과
|
||
add_text_box(
|
||
s, Cm(2), Cm(5.6), SLIDE_W - Cm(4), Cm(2.2),
|
||
"Spring 인증 서버를 K3s 위에 올리기",
|
||
size=44, bold=True, color=C_TEXT,
|
||
)
|
||
add_text_box(
|
||
s, Cm(2), Cm(8.1), SLIDE_W - Cm(4), Cm(1.2),
|
||
"사이드카 없이, 운영자가 Vault 만 보고도 시크릿이 흐르게",
|
||
size=22, color=C_MUTED,
|
||
)
|
||
# accent bar
|
||
bar = s.shapes.add_shape(MSO_SHAPE.RECTANGLE, Cm(2), Cm(10.3), Cm(2.5), Cm(0.18))
|
||
bar.fill.solid()
|
||
bar.fill.fore_color.rgb = C_APP
|
||
bar.line.fill.background()
|
||
bar.shadow.inherit = False
|
||
|
||
# 발표 범위 키워드 — 4 개만, 동등 위계
|
||
keywords = ["Auth Boundary", "Clean Architecture", "Vault → VSO", "K3s Runtime"]
|
||
kw_top = Cm(11.3)
|
||
kw_gap = Cm(0.4)
|
||
kw_w = (SLIDE_W - Cm(4) - kw_gap * (len(keywords) - 1)) // len(keywords)
|
||
for i, kw in enumerate(keywords):
|
||
x = Cm(2) + (kw_w + kw_gap) * i
|
||
add_panel(s, x, kw_top, kw_w, Cm(0.95), fill=C_PANEL, line=C_BORDER, line_w=0.6)
|
||
add_text_box(
|
||
s, x, kw_top + Cm(0.22), kw_w, Cm(0.55), kw,
|
||
size=13, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER,
|
||
)
|
||
|
||
add_text_box(
|
||
s, Cm(2), Cm(14.4), SLIDE_W - Cm(4), Cm(0.8),
|
||
"발표자 · DongHyeonka · Backend / Platform",
|
||
size=16, bold=True, color=C_TEXT,
|
||
)
|
||
add_text_box(
|
||
s, Cm(2), Cm(15.3), SLIDE_W - Cm(4), Cm(0.7),
|
||
"2026-05-16 · 약 30분 + Q&A",
|
||
size=13, color=C_MUTED,
|
||
)
|
||
add_slide_chrome(s, 1, total)
|
||
|
||
|
||
def slide_02_goals(prs, total):
|
||
s = blank_slide(prs)
|
||
add_notes(s, (
|
||
"발표 범위 — 청중에게 '어떤 슬라이드들이 어떤 순서로 나오는지' 미리 보여주는 장.\n\n"
|
||
"5 개 축을 좌→우 흐름으로 보여줍니다. Auth → Application → Data → Secret → Runtime. "
|
||
"이 순서가 곧 슬라이드 순서이기도 합니다.\n\n"
|
||
"'다음 검증 항목'은 여기서 다루지 않고 마무리 슬라이드에서 통합해서 다룹니다 — "
|
||
"청중이 '현재 한 일' 과 '다음 할 일' 을 한 호흡으로 끊어서 받게 하기 위함."
|
||
))
|
||
add_title(s, "발표 범위 — 5 축", "Auth → App → Data → Secret → Runtime 순서로 진행")
|
||
|
||
axes = [
|
||
("Auth\nBoundary", "oauth2-proxy 가 Ingress 단 로그인,\nauth-server 는 JWT 재검증", C_APP),
|
||
("Application\nArchitecture", "Clean Architecture 5 모듈,\nArchUnit 로 의존 방향 자동 검증", C_APP),
|
||
("Data\n& Migration", "Postgres 도메인 저장소,\nFlyway = ConfigMap + Job", C_DATA),
|
||
("Secret\nManagement", "Vault → VSO → K8s Secret,\nPod 는 Vault 를 모름", C_SECRET),
|
||
("K8s\nRuntime", "Kustomize base/overlay,\nPSS + NetworkPolicy default-deny", C_APP),
|
||
]
|
||
n = len(axes)
|
||
gap = Cm(0.45)
|
||
card_w = (content_w() - gap * (n - 1)) // n
|
||
card_top = Cm(4.6)
|
||
card_h = Cm(6.6)
|
||
for i, (head, body, color) in enumerate(axes):
|
||
x = MARGIN_X + (card_w + gap) * i
|
||
# number badge
|
||
add_panel(s, x, card_top, card_w, card_h, fill=C_BG, line=color, line_w=1.6)
|
||
add_text_box(
|
||
s, x, card_top + Cm(0.35), card_w, Cm(0.7),
|
||
f"0{i + 1}", size=22, bold=True, color=color, align=PP_ALIGN.CENTER,
|
||
)
|
||
add_text_box(
|
||
s, x + Cm(0.2), card_top + Cm(1.4), card_w - Cm(0.4), Cm(1.9),
|
||
head, size=18, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER,
|
||
)
|
||
add_text_box(
|
||
s, x + Cm(0.3), card_top + Cm(3.6), card_w - Cm(0.6), Cm(2.7),
|
||
body, size=11, color=C_MUTED, align=PP_ALIGN.CENTER,
|
||
)
|
||
|
||
add_text_box(
|
||
s, MARGIN_X, Cm(12.0), content_w(), Cm(0.7),
|
||
"이번 발표는 '운영 완료' 가 아니라 '구성 정리' — 마지막에 다음 검증 항목으로 닫음",
|
||
size=14, color=C_MUTED, align=PP_ALIGN.CENTER,
|
||
)
|
||
add_caption_band(s, "5 축을 순서대로 — 같은 흐름이 슬라이드 순서이기도")
|
||
add_slide_chrome(s, 2, total, "들어가며")
|
||
|
||
|
||
def slide_03_overall(prs, total):
|
||
s = slide_diagram_only(
|
||
prs,
|
||
"01-overall.png",
|
||
3,
|
||
total,
|
||
"전체 아키텍처",
|
||
title="전체 아키텍처 — 요청 ⊥ 시크릿",
|
||
subtitle="요청은 좌→우 3 계층, 시크릿은 하단에서 위로 — Pod 는 Vault 를 모름",
|
||
caption="oauth2-proxy 가 Ingress 단 로그인, auth-server 가 JWT 재검증 — Edge / Resource Server 이중 경계",
|
||
)
|
||
add_notes(s, (
|
||
"전체 흐름은 두 축 + 시크릿 한 축으로 보면 됩니다.\n\n"
|
||
"■ 요청 흐름 (좌→우)\n"
|
||
" 사용자 요청은 Traefik Ingress 로 들어오고, oauth2-proxy 가 로그인 세션을 확인합니다. "
|
||
"로그인이 필요하면 Keycloak 으로 redirect 하고, 통과한 요청은 auth-server 로 전달됩니다.\n\n"
|
||
"■ 검증 경계\n"
|
||
" auth-server 는 oauth2-proxy 를 신뢰해서 끝내는 것이 아니라, Keycloak 이 발급한 JWT 를 "
|
||
"Spring Security Resource Server 로 다시 검증합니다. 즉 Ingress 게이트와 Resource Server "
|
||
"검증의 이중 경계.\n\n"
|
||
"■ 시크릿 흐름 (하단 → 위)\n"
|
||
" 운영자가 Vault 에 값을 넣고, VSO 가 Kubernetes Secret 으로 동기화합니다. "
|
||
"Pod 는 Vault 를 직접 알지 않고 평범한 Kubernetes Secret 을 envFrom 으로 받습니다.\n\n"
|
||
"①~⑤ 번호 따라 흐름 짚기:\n"
|
||
"① Traefik (kube-system) — Ingress 진입\n"
|
||
"② oauth2-proxy (mnt) — 로그인/세션 게이트\n"
|
||
"③ auth-server — Spring Boot · CA · MDC traceId · JWT 재검증\n"
|
||
"④ Postgres / MinIO — 데이터 + 객체 저장\n"
|
||
"⑤ Vault → VSO → K8s Secret — Pod 는 envFrom 으로 받음\n\n"
|
||
"강조 — Pod 는 Vault 를 모르고 평범한 K8s Secret 만 본다.\n"
|
||
"단, VSO 방식에서는 Secret 이 Kubernetes Secret 으로 존재하므로 etcd 보호가 필요합니다 "
|
||
"(다음 액션의 etcd encryption 항목으로 연결)."
|
||
))
|
||
|
||
|
||
def slide_04_auth(prs, total):
|
||
s = slide_diagram_only(
|
||
prs,
|
||
"02-auth-flow.png",
|
||
4,
|
||
total,
|
||
"세부 #1 · 인증 경계",
|
||
title="① 인증 경계 — 이중 검증",
|
||
subtitle="Edge(oauth2-proxy) 가 세션 게이트, App(Spring Resource Server) 이 JWT 재검증",
|
||
caption="검증 경계 2 곳 — Edge 에서 미인증 차단 + App 에서 issuer / signature / exp / role claim 재검증",
|
||
)
|
||
add_notes(s, (
|
||
"Key Message — oauth2-proxy 는 로그인/세션 게이트를 담당하고, auth-server 는 Keycloak "
|
||
"JWT 를 다시 검증한다.\n\n"
|
||
"여기서 중요한 건 인증을 한 번만 한다는 뜻이 아닙니다.\n"
|
||
"브라우저 로그인, redirect, callback, 세션 확인은 oauth2-proxy 가 담당합니다. "
|
||
"반면 auth-server 는 백엔드 API 서버이기 때문에 전달된 Keycloak JWT 의 issuer, signature, "
|
||
"expiration, role claim 을 다시 검증합니다.\n\n"
|
||
"그래서 이 구조는 'Ingress 에서 끝나는 인증' 이 아니라, "
|
||
"Ingress 게이트와 Resource Server 검증을 분리한 구조입니다.\n\n"
|
||
"Input: 브라우저의 GET /api/v1/auth/me (세션 쿠키 있을 수도, 없을 수도)\n"
|
||
"Output: 검증된 principal + 권한이 채워진 요청 → 컨트롤러 도달\n\n"
|
||
"흐름:\n"
|
||
"A. 미인증 → oauth2-proxy 가 302 redirect → Keycloak 로그인 → 세션 쿠키 발급\n"
|
||
"B. 인증된 요청 → oauth2-proxy 가 요청을 통과시키고 필요한 인증 정보를 전달\n"
|
||
"C. auth-server → Authorization Bearer JWT 를 Resource Server 로 검증 "
|
||
"(issuer-uri 환경변수, JWKS 자동 fetch + 캐시)\n\n"
|
||
"왜 분리하는가 — App 코드는 로그인 플로우를 직접 다루지 않고, 검증된 principal 과 "
|
||
"권한 조건을 전제로 HTTP 계약에 집중. callback / redirect / 쿠키 관리는 oauth2-proxy 가 담당.\n\n"
|
||
"JwtAuthenticationConverter 가 Keycloak realm role → Spring Authority 변환."
|
||
))
|
||
|
||
|
||
def slide_05_ca(prs, total):
|
||
s = blank_slide(prs)
|
||
add_notes(s, (
|
||
"Key Message — 레이어 의존 방향을 사람 리뷰가 아니라 ArchUnit 테스트로 검증한다.\n\n"
|
||
"Gradle multi-module 5 개. 의존은 한 방향만 — presentation/infrastructure → application → domain. "
|
||
"bootstrap 은 composition root — 모든 모듈 참조 OK.\n\n"
|
||
"ArchUnit 규칙은 슬라이드 표면에 명시 (외울 필요 X — 어떤 종류의 위반이 자동 차단되는지 보면 됨).\n\n"
|
||
"왜 테스트로? — 사람 리뷰는 빠질 수 있음. 테스트 실패는 PR 단계에서 차단. CI 연결 시 main 반영 전 자동."
|
||
))
|
||
add_title(s, "② 의존 방향을 테스트로 잠근다", "Clean Architecture 5 모듈 + ArchUnit 자동 검증")
|
||
|
||
# 좌측: 모듈 의존 다이어그램
|
||
diag_left = MARGIN_X
|
||
diag_top = CONTENT_TOP_WIDE
|
||
diag_w = Cm(15.0)
|
||
diag_h = Cm(11.0)
|
||
add_panel(s, diag_left, diag_top, diag_w, diag_h, fill=C_PANEL, line=C_BORDER, line_w=0.75)
|
||
|
||
# 4 boxes vertically: presentation, infrastructure / application / domain
|
||
box_w = Cm(6.0)
|
||
box_h = Cm(1.5)
|
||
center_x = diag_left + diag_w // 2
|
||
|
||
# row 1: presentation + infrastructure (top, 2 columns)
|
||
row1_y = diag_top + Cm(1.0)
|
||
pres_x = center_x - box_w - Cm(0.4)
|
||
infra_x = center_x + Cm(0.4)
|
||
add_panel(s, pres_x, row1_y, box_w, box_h, fill=C_BG, line=C_APP, line_w=1.2)
|
||
add_text_box(s, pres_x, row1_y + Cm(0.45), box_w, Cm(0.6), "presentation",
|
||
size=15, bold=True, mono=True, color=C_APP, align=PP_ALIGN.CENTER)
|
||
add_panel(s, infra_x, row1_y, box_w, box_h, fill=C_BG, line=C_APP, line_w=1.2)
|
||
add_text_box(s, infra_x, row1_y + Cm(0.45), box_w, Cm(0.6), "infrastructure",
|
||
size=15, bold=True, mono=True, color=C_APP, align=PP_ALIGN.CENTER)
|
||
|
||
# row 2: application (centered)
|
||
row2_y = row1_y + Cm(2.8)
|
||
app_x = center_x - box_w // 2
|
||
add_panel(s, app_x, row2_y, box_w, box_h, fill=C_BG, line=C_APP, line_w=1.2)
|
||
add_text_box(s, app_x, row2_y + Cm(0.45), box_w, Cm(0.6), "application",
|
||
size=15, bold=True, mono=True, color=C_APP, align=PP_ALIGN.CENTER)
|
||
|
||
# row 3: domain (centered, bold border)
|
||
row3_y = row2_y + Cm(2.8)
|
||
dom_x = center_x - box_w // 2
|
||
add_panel(s, dom_x, row3_y, box_w, box_h, fill=C_APP_FILL, line=C_APP, line_w=2.0)
|
||
add_text_box(s, dom_x, row3_y + Cm(0.45), box_w, Cm(0.6), "domain",
|
||
size=17, bold=True, mono=True, color=C_APP, align=PP_ALIGN.CENTER)
|
||
|
||
# arrows downward
|
||
add_arrow(s, pres_x + box_w // 2, row1_y + box_h, app_x + box_w // 2, row2_y, color=C_APP)
|
||
add_arrow(s, infra_x + box_w // 2, row1_y + box_h, app_x + box_w // 2, row2_y, color=C_APP)
|
||
add_arrow(s, app_x + box_w // 2, row2_y + box_h, dom_x + box_w // 2, row3_y, color=C_APP)
|
||
|
||
add_text_box(
|
||
s, diag_left, row3_y + box_h + Cm(0.5), diag_w, Cm(0.7),
|
||
"의존 방향은 한 방향만 — domain 은 그 누구도 모름",
|
||
size=13, bold=True, color=C_APP, align=PP_ALIGN.CENTER,
|
||
)
|
||
add_text_box(
|
||
s, diag_left, row3_y + box_h + Cm(1.2), diag_w, Cm(0.6),
|
||
"bootstrap (composition root) 만이 모든 모듈을 참조",
|
||
size=11, color=C_MUTED, align=PP_ALIGN.CENTER,
|
||
)
|
||
|
||
# 우측: ArchUnit 규칙
|
||
rules_left = diag_left + diag_w + Cm(0.5)
|
||
rules_w = SLIDE_W - rules_left - MARGIN_X
|
||
add_text_box(
|
||
s, rules_left, diag_top, rules_w, Cm(0.7),
|
||
"ArchUnit 이 차단하는 위반", size=15, bold=True, color=C_GOOD,
|
||
)
|
||
rules = [
|
||
("domain", "Spring / Hibernate / JPA / servlet 의존 0"),
|
||
("application", "presentation / infrastructure / Spring web 의존 0"),
|
||
("presentation", "domain / infrastructure 직접 의존 0"),
|
||
("presentation", "SecurityContext / Authentication 직접 접근 0"),
|
||
]
|
||
rule_top = diag_top + Cm(1.0)
|
||
for i, (layer, body) in enumerate(rules):
|
||
y = rule_top + Cm(i * 2.1)
|
||
add_panel(s, rules_left, y, rules_w, Cm(1.85), fill=C_BG, line=C_BORDER, line_w=0.75)
|
||
add_text_box(s, rules_left + Cm(0.3), y + Cm(0.2), rules_w - Cm(0.6), Cm(0.55),
|
||
layer, size=12, bold=True, mono=True, color=C_GOOD)
|
||
add_text_box(s, rules_left + Cm(0.3), y + Cm(0.8), rules_w - Cm(0.6), Cm(1.0),
|
||
body, size=12, color=C_TEXT)
|
||
|
||
add_caption_band(s, "사람 리뷰는 놓친다 — 테스트로 잠그면 PR 단계에서 자동 차단")
|
||
add_slide_chrome(s, 5, total, "세부 #2 · Clean Architecture")
|
||
|
||
|
||
def slide_06_exception_policy(prs, total):
|
||
"""6 — 내부 에러 코드가 클라이언트 응답으로 나가지 못하게 타입으로 막는다."""
|
||
s = blank_slide(prs)
|
||
add_notes(s, (
|
||
"Key Message — 내부 분류 코드와 외부 응답 코드를 sealed 타입으로 분리해서, "
|
||
"응답 경로에 내부 코드가 새려고 하면 빌드가 실패하게 만들었다.\n\n"
|
||
"런타임 if 검사가 아니라 컴파일 실패가 핵심.\n\n"
|
||
"[backup] 구체 이름 — ErrorCode (sealed) → ClientFacingErrorCode / ExternalErrorCode. "
|
||
"응답 매퍼는 ClientFacingErrorCode 만 인자로 받음."
|
||
))
|
||
add_title(s, "내부 코드를 응답에 새지 않게 — 타입으로 잠근다",
|
||
"런타임 검사 X · 컴파일 실패로 막는다")
|
||
|
||
# 좌측: 분리된 코드 카테고리 2 카드
|
||
card_top = CONTENT_TOP_WIDE
|
||
card_h = Cm(3.5)
|
||
left_card_w = Cm(15.0)
|
||
right_card_w = SLIDE_W - MARGIN_X * 2 - left_card_w - Cm(0.6)
|
||
right_card_x = MARGIN_X + left_card_w + Cm(0.6)
|
||
|
||
# 좌측 카드 1: Client-facing (응답에 나가는 코드)
|
||
add_panel(s, MARGIN_X, card_top, left_card_w, card_h, fill=C_GOOD_FILL, line=C_GOOD, line_w=1.6)
|
||
add_text_box(s, MARGIN_X + Cm(0.4), card_top + Cm(0.3), left_card_w - Cm(0.8), Cm(0.6),
|
||
"응답에 나가는 코드", size=13, bold=True, color=C_GOOD)
|
||
add_text_box(s, MARGIN_X + Cm(0.4), card_top + Cm(0.95), left_card_w - Cm(0.8), Cm(0.7),
|
||
"Client-facing", size=24, bold=True, color=C_TEXT)
|
||
add_text_box(s, MARGIN_X + Cm(0.4), card_top + Cm(2.0), left_card_w - Cm(0.8), Cm(1.3),
|
||
"Common · Auth · Presentation 도메인의 안정된 계약 코드만.\n"
|
||
"클라이언트가 보고 다음 액션을 결정할 수 있는 코드.",
|
||
size=12, color=C_TEXT)
|
||
|
||
# 좌측 카드 2 (아래): Internal-only
|
||
card2_top = card_top + card_h + Cm(0.4)
|
||
add_panel(s, MARGIN_X, card2_top, left_card_w, card_h, fill=C_DANGER_FILL, line=C_DANGER, line_w=1.6)
|
||
add_text_box(s, MARGIN_X + Cm(0.4), card2_top + Cm(0.3), left_card_w - Cm(0.8), Cm(0.6),
|
||
"내부 분류 전용", size=13, bold=True, color=C_DANGER)
|
||
add_text_box(s, MARGIN_X + Cm(0.4), card2_top + Cm(0.95), left_card_w - Cm(0.8), Cm(0.7),
|
||
"Internal-only", size=24, bold=True, color=C_TEXT)
|
||
add_text_box(s, MARGIN_X + Cm(0.4), card2_top + Cm(2.0), left_card_w - Cm(0.8), Cm(1.3),
|
||
"Infrastructure / 외부 어댑터 실패 원인 분류.\n"
|
||
"운영 트리아지에만 쓰이고, 응답 경로 통과 시 빌드 실패.",
|
||
size=12, color=C_TEXT)
|
||
|
||
# 우측: 응답 매퍼 시그니처 박스
|
||
mapper_h = card_h * 2 + Cm(0.4)
|
||
add_panel(s, right_card_x, card_top, right_card_w, mapper_h, fill=C_PANEL, line=C_TEXT, line_w=1.0)
|
||
add_text_box(s, right_card_x + Cm(0.4), card_top + Cm(0.4), right_card_w - Cm(0.8), Cm(0.6),
|
||
"응답 매퍼 시그니처", size=13, bold=True, color=C_MUTED)
|
||
add_text_box(s, right_card_x + Cm(0.4), card_top + Cm(1.4), right_card_w - Cm(0.8), Cm(1.4),
|
||
"map(\n ClientFacing\n) → HttpStatus",
|
||
size=18, bold=True, mono=True, color=C_GOOD)
|
||
add_text_box(s, right_card_x + Cm(0.4), card_top + Cm(4.6), right_card_w - Cm(0.8), Cm(2.5),
|
||
"Internal-only 코드를 인자로 넘기면\n→ 컴파일 실패\n"
|
||
"→ CI 가 PR 단계에서 차단",
|
||
size=12, color=C_TEXT)
|
||
|
||
add_text_box(
|
||
s, MARGIN_X, Cm(13.2), content_w(), Cm(0.8),
|
||
"내부 분류는 운영을 위해 남기되, 응답 계약에는 노출하지 않는다",
|
||
size=17, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER,
|
||
)
|
||
add_caption_band(s, "타입이 정책이다 — 컴파일러가 실수를 먼저 잡는다")
|
||
add_slide_chrome(s, 6, total, "세부 #2-A · Exception 정책")
|
||
|
||
|
||
def slide_06a_exception_layers(prs, total):
|
||
"""7 — 예외 발생 위치를 기준으로 핸들러 책임을 나눈다."""
|
||
s = blank_slide(prs)
|
||
add_notes(s, (
|
||
"Key Message — 예외가 '어디에서' 발생했는가에 따라 잡는 위치가 다르다. 운영 시 원인 분류 목적.\n\n"
|
||
"표는 클래스 이름이 아니라 '실패 지점' 과 '응답 의미' 만 노출. 청중이 클래스 5 개를 외울 이유는 없음.\n\n"
|
||
"[backup] 실제 핸들러 — InfrastructureExceptionHandler / SecurityResponseExceptionHandler / "
|
||
"ValidationExceptionHandler / RequestExceptionHandler / ApplicationExceptionHandler. "
|
||
"필터 체인은 SecurityExceptionHandler, advice 밖은 ApiErrorController.\n\n"
|
||
"advice 밖 = sendError / 이중 폴트 / 우회 경로. 이런 경로도 응답 envelope 통일."
|
||
))
|
||
add_title(s, "실패 지점별로 잡는 곳을 분리",
|
||
"어디서 실패했는지가 보이게 — 다음 조치가 빨라진다")
|
||
|
||
headers = ["실패 지점", "응답 / 운영 의미"]
|
||
rows = [
|
||
("DB / Vault / 외부 HTTP 어댑터", "내부 원인은 숨기고 COMMON-999 로 정규화"),
|
||
("Spring Security 필터", "인증 상태에 따라 401 / 403 분기 (다음 슬라이드)"),
|
||
("Bean Validation", "필드 단위 errors map 으로 입력 오류 반환"),
|
||
("Spring MVC 입력 파싱", "파라미터 / body / upload 등 요청 형식 오류"),
|
||
("Application use case", "BusinessException + 최후 안전망"),
|
||
]
|
||
add_table(
|
||
s, MARGIN_X, CONTENT_TOP_WIDE, headers, rows,
|
||
col_widths_cm=[13.0, 18.5],
|
||
row_h_cm=1.25,
|
||
body_size=13,
|
||
header_size=14,
|
||
)
|
||
|
||
# advice 밖 안전망 — 핵심 인사이트
|
||
panel_top = Cm(10.6)
|
||
add_panel(s, MARGIN_X, panel_top, content_w(), Cm(2.4),
|
||
fill=C_WARN_FILL, line=C_WARN_LINE, line_w=1.4)
|
||
add_text_box(s, MARGIN_X + Cm(0.5), panel_top + Cm(0.3), Cm(12), Cm(0.6),
|
||
"advice 가 못 보는 경로도 envelope 통일",
|
||
size=15, bold=True, color=C_WARN_TEXT)
|
||
add_text_box(s, MARGIN_X + Cm(0.5), panel_top + Cm(1.05), content_w() - Cm(1.0), Cm(1.2),
|
||
"필터 체인 인증/인가 예외 + sendError / 이중 폴트 / 우회 경로 — "
|
||
"전용 안전망 핸들러로 같은 응답 형식 보장",
|
||
size=12, color=C_TEXT)
|
||
|
||
add_caption_band(s, "클래스 이름이 아니라 실패 지점 — 운영 트리아지의 1차 분류")
|
||
add_slide_chrome(s, 7, total, "세부 #2-B · Exception 계층")
|
||
|
||
|
||
def slide_06b_envelope(prs, total):
|
||
"""8 — 실패 응답 계약: envelope, status 보존, 401/403 분기."""
|
||
s = blank_slide(prs)
|
||
add_notes(s, (
|
||
"Key Message — 응답 형식은 통일하되, HTTP 의미는 뭉개지 않는다.\n\n"
|
||
"3 카드:\n"
|
||
"① envelope — success/code/message/data/errors/traceId/timestamp. data 와 errors 분리, null 직렬화 제외.\n"
|
||
"② 401 vs 403 — 익명에게 403 을 주면 클라이언트가 재로그인 플로우를 시작 못 함. 그래서 익명=401, 인증된=403.\n"
|
||
"③ status 보존 — /error 에서 404→500 둔갑 방지. LB 헬스체크와 SDK 재시도 정책 보호.\n\n"
|
||
"[backup] 실제 envelope 클래스 = ApiResult<T>. 검증 근거 = JaCoCo Tier1 95%+, PIT app 94% / pres 77%, jqwik 속성 테스트."
|
||
))
|
||
add_title(s, "실패 응답 3 계약 — 클라이언트가 다음 행동을 결정 가능하게",
|
||
"형식은 통일, HTTP 의미는 보존")
|
||
|
||
n = 3
|
||
gap = Cm(0.5)
|
||
col_w = (content_w() - gap * (n - 1)) // n
|
||
card_top = CONTENT_TOP_WIDE
|
||
card_h = Cm(7.4)
|
||
cards = [
|
||
("01", "envelope 통일", "{ success, code, message,\n data, errors,\n traceId, timestamp }",
|
||
"data 와 errors 분리 — 성공/실패 응답이 섞이지 않음"),
|
||
("02", "401 vs 403", "익명 → 401\n인증된 사용자 → 403",
|
||
"익명에게 403 주면 클라이언트가 로그인 / 재발급 플로우 시작 불가"),
|
||
("03", "status 보존", "404 → 404\n500 → 500",
|
||
"/error 에서 컨테이너 결정 status 유지 — LB · SDK 정책 보호"),
|
||
]
|
||
for i, (num, head, mid, body) in enumerate(cards):
|
||
x = MARGIN_X + (col_w + gap) * i
|
||
add_panel(s, x, card_top, col_w, card_h, fill=C_BG, line=C_APP, line_w=1.4)
|
||
add_text_box(s, x, card_top + Cm(0.3), col_w, Cm(0.6),
|
||
num, size=14, bold=True, color=C_MUTED, align=PP_ALIGN.CENTER)
|
||
add_text_box(s, x + Cm(0.3), card_top + Cm(1.0), col_w - Cm(0.6), Cm(0.8),
|
||
head, size=18, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
|
||
add_panel(s, x + Cm(0.4), card_top + Cm(2.1), col_w - Cm(0.8), Cm(2.6),
|
||
fill=C_APP_FILL, line=C_APP, line_w=0.8)
|
||
add_text_box(s, x + Cm(0.5), card_top + Cm(2.3), col_w - Cm(1.0), Cm(2.3),
|
||
mid, size=13, bold=True, mono=True, color=C_APP, align=PP_ALIGN.CENTER)
|
||
add_text_box(s, x + Cm(0.5), card_top + Cm(5.0), col_w - Cm(1.0), Cm(2.2),
|
||
body, size=11, color=C_TEXT, align=PP_ALIGN.CENTER)
|
||
|
||
add_text_box(
|
||
s, MARGIN_X, Cm(13.0), content_w(), Cm(0.8),
|
||
"검증 근거 — JaCoCo Tier1 95%+ · PIT app 94% / pres 77% · jqwik 속성 테스트",
|
||
size=12, color=C_MUTED, align=PP_ALIGN.CENTER,
|
||
)
|
||
add_caption_band(s, "응답 형식은 통일, HTTP 의미는 뭉개지 않는다")
|
||
add_slide_chrome(s, 8, total, "세부 #2-C · Exception envelope")
|
||
|
||
|
||
def slide_07_logging_flow(prs, total):
|
||
"""9 — traceId 하나로 응답과 앱 로그를 묶는다."""
|
||
s = blank_slide(prs)
|
||
add_notes(s, (
|
||
"Key Message — 같은 traceId 값이 응답 헤더와 앱 로그에 동시에 박혀, 운영 트리아지 시 한 개 값으로 좁혀간다.\n\n"
|
||
"MDC 가 비면 응답에 sentinel '-' 를 노출 → '필터 누락' 자체를 숨기지 않음.\n\n"
|
||
"[backup] 실제 컴포넌트 — TraceIdFilter (servlet filter) → MDC.put(\"traceId\", ...). "
|
||
"logback 패턴은 %X{traceId}. RequestBoundApiResultFactory 가 응답 envelope 에 같은 값을 채움.\n"
|
||
"프로파일 appender 4 종 — local/dev CONSOLE, prod logstash JSON, audit-file RollingFileAppender."
|
||
))
|
||
add_title(s, "traceId 하나로 응답과 로그를 묶는다",
|
||
"MDC 가 비면 sentinel '-' 로 노출 — 필터 누락을 숨기지 않음")
|
||
|
||
# 가로 흐름: 요청 → 필터 → MDC → 출력 2 개
|
||
flow_top = Cm(5.5)
|
||
node_h = Cm(1.7)
|
||
|
||
# 좌측 3 박스 (요청 / 필터 / MDC)
|
||
upstream = [
|
||
("HTTP 요청", "", C_APP, False),
|
||
("traceId 발급", "(servlet filter)", C_APP, True),
|
||
("MDC", "(per-request context)", C_APP, True),
|
||
]
|
||
up_x = Cm(1.2)
|
||
up_w = Cm(6.0)
|
||
up_gap = Cm(0.6)
|
||
for i, (head, sub, color, mono) in enumerate(upstream):
|
||
x = up_x + (up_w + up_gap) * i
|
||
add_panel(s, x, flow_top, up_w, node_h, fill=C_BG, line=color, line_w=1.4)
|
||
add_text_box(s, x, flow_top + Cm(0.3), up_w, Cm(0.7),
|
||
head, size=17, bold=True, mono=mono, color=color, align=PP_ALIGN.CENTER)
|
||
if sub:
|
||
add_text_box(s, x, flow_top + Cm(1.0), up_w, Cm(0.55),
|
||
sub, size=10, color=C_MUTED, align=PP_ALIGN.CENTER)
|
||
if i > 0:
|
||
prev_x = up_x + (up_w + up_gap) * (i - 1)
|
||
add_arrow(s, prev_x + up_w, flow_top + node_h // 2, x, flow_top + node_h // 2, color=C_APP)
|
||
|
||
# 우측 2 출력 박스 (응답 헤더 / 앱 로그)
|
||
out_x = up_x + (up_w + up_gap) * 3 + Cm(0.6)
|
||
out_w = SLIDE_W - out_x - MARGIN_X
|
||
outputs = [
|
||
("응답 헤더", "X-Trace-Id", "클라이언트가 동일 ID 로 추적"),
|
||
("앱 로그", "%X{traceId}", "운영자가 같은 ID 로 요청 흐름 검색"),
|
||
]
|
||
out_h = Cm(1.7)
|
||
out_gap = Cm(0.4)
|
||
mdc_x = up_x + (up_w + up_gap) * 2
|
||
for i, (label, code, sub) in enumerate(outputs):
|
||
y = flow_top - Cm(0.5) + (out_h + out_gap) * i + (Cm(0.5) if i == 0 else Cm(0.5))
|
||
# adjust: spread vertically around flow_top
|
||
y = flow_top - Cm(0.7) + (out_h + out_gap) * i + (out_h + out_gap) * 0
|
||
add_panel(s, out_x, y, out_w, out_h, fill=C_APP_FILL, line=C_APP, line_w=1.2)
|
||
add_text_box(s, out_x + Cm(0.3), y + Cm(0.18), out_w - Cm(0.6), Cm(0.45),
|
||
label, size=11, bold=True, color=C_MUTED)
|
||
add_text_box(s, out_x + Cm(0.3), y + Cm(0.65), out_w - Cm(0.6), Cm(0.55),
|
||
code, size=15, bold=True, mono=True, color=C_APP)
|
||
add_text_box(s, out_x + Cm(0.3), y + Cm(1.18), out_w - Cm(0.6), Cm(0.5),
|
||
sub, size=10, color=C_MUTED)
|
||
add_arrow(s, mdc_x + up_w, flow_top + node_h // 2, out_x, y + out_h // 2, color=C_APP)
|
||
|
||
# 강조 패널 — MDC empty → '-'
|
||
sentinel_top = Cm(10.0)
|
||
add_panel(s, MARGIN_X, sentinel_top, content_w(), Cm(2.5),
|
||
fill=C_WARN_FILL, line=C_WARN_LINE, line_w=1.4)
|
||
add_text_box(s, MARGIN_X + Cm(0.5), sentinel_top + Cm(0.3), Cm(20), Cm(0.6),
|
||
"필터 누락도 숨기지 않는다", size=15, bold=True, color=C_WARN_TEXT)
|
||
add_text_box(s, MARGIN_X + Cm(0.5), sentinel_top + Cm(1.05), content_w() - Cm(1.0), Cm(1.3),
|
||
"MDC 가 비어 있으면 응답 traceId 필드에 sentinel \"-\" 노출 — "
|
||
"JSON null 로 가리지 않아 'TraceIdFilter 가 빠졌다' 가 즉시 보임",
|
||
size=12, color=C_TEXT)
|
||
|
||
add_caption_band(s, "운영 트리아지는 traceId 한 개로 요청 단위 흐름을 좁혀간다")
|
||
add_slide_chrome(s, 9, total, "세부 #2-D · Logging traceId")
|
||
|
||
|
||
def slide_08_data(prs, total):
|
||
s = slide_diagram_only(
|
||
prs,
|
||
"04-data.png",
|
||
10,
|
||
total,
|
||
"세부 #3 · 데이터 레이어",
|
||
title="③ 객체 저장소를 1 개로 통일 — MinIO",
|
||
subtitle="docker-registry 는 자기 PV 0 — REGISTRY_STORAGE=s3 로 MinIO 에 위임",
|
||
caption="앱 데이터 = Postgres · 객체 = MinIO 한 곳. 백업 / 용량 / 모니터링이 객체 저장소 1 군데로 모임",
|
||
)
|
||
add_notes(s, (
|
||
"Key Message — PostgreSQL 은 auth-server 데이터, MinIO 는 object storage 와 registry "
|
||
"backend 를 담당한다.\n\n"
|
||
"발표 멘트 — auth-server 관점에서 핵심 데이터 저장소는 PostgreSQL 입니다. "
|
||
"사용자 식별 정보와 Flyway migration 대상 schema 가 여기에 있습니다.\n\n"
|
||
"반면 MinIO 는 auth-server 의 도메인 저장소라기보다는 infra 쪽 object storage 입니다. "
|
||
"Docker Registry 가 자체 PV 를 갖지 않고 MinIO S3 backend 를 사용하도록 구성했습니다.\n\n"
|
||
"즉 애플리케이션 데이터와 플랫폼 object storage 책임을 분리한 구조입니다.\n\n"
|
||
"Postgres — schema: auth, 5 마이그레이션 (V1~V5). Flyway Job 이 별도 적용 (다음 슬라이드).\n"
|
||
"MinIO Tenant — MinIO Operator 로 띄움. buckets: auth · docker-registry.\n\n"
|
||
"registry 는 자체 PV 0 — REGISTRY_STORAGE=s3 + REGISTRY_STORAGE_S3_REGIONENDPOINT. "
|
||
"객체 저장소 인프라 1 개로 통일해 백업 / 용량 / 모니터링을 한 군데에서.\n\n"
|
||
"이미지 push / containerd image pull 은 별도 슬라이드 (S08-B, 발표 시간 따라)."
|
||
))
|
||
|
||
|
||
def slide_09_flyway(prs, total):
|
||
s = slide_diagram_only(
|
||
prs,
|
||
"05-flyway-evolution.png",
|
||
11,
|
||
total,
|
||
"세부 #4 · Flyway 진화",
|
||
title="④ Flyway = ConfigMap + Job (이미지 빌드 사이클 제거)",
|
||
subtitle="현재 ~2 KB · 회귀 기준 = 1 MiB · 도달 시 이미지 내장 또는 별도 artifact 로 전환",
|
||
caption="SQL 한 줄 변경 = ConfigMap 갱신 + 표준 Flyway Job 재실행 (registry 빌드/push 없음)",
|
||
)
|
||
add_notes(s, (
|
||
"Key Message — 단순함이 우선인 단계엔 ConfigMap. 단, 1 MiB 한계 (회귀 기준) 명시.\n\n"
|
||
"Before — SQL → migration-jar → 이미지 빌드 + push → Job pull. SQL 1 줄 변경마다 빌드.\n"
|
||
"After — Git SQL → ConfigMap mount → 공식 flyway:10.20.1 이미지 + Job. SQL 변경 = ConfigMap 갱신.\n\n"
|
||
"지금 ~2 KB 라 여유 충분. 회귀 조건이 명확해야 결정이 가벼움."
|
||
))
|
||
|
||
|
||
def slide_10_vault_compare(prs, total):
|
||
"""12 — Vault Injector vs VSO: 3 단점을 한 화면에 통합"""
|
||
s = blank_slide(prs)
|
||
add_notes(s, (
|
||
"Key Message — Vault Injector 를 안 고른 3 가지 이유. 다음 슬라이드(VSO 진화) 의 전제.\n\n"
|
||
"이건 Injector 적대가 아니라, '우리 환경(단일 노드 K3s dev) 기준' 의 선택. "
|
||
"대규모 운영팀 / 풍부한 메모리 / 사이드카 운영 경험 충분한 환경에서는 Injector 가 정답일 수도.\n\n"
|
||
"3 단점:\n"
|
||
"① 의존 사슬 — auto-unseal(KMS) → Vault → K8s Secret → Pod 모두 직렬. KMS 한 점이 클러스터 전체 영향.\n"
|
||
" 더 깊게 — auto-unseal token 자체가 K8s Secret 으로 저장되어 etcd 에 남음. Vault 도입 동기와 충돌.\n"
|
||
"② 사이드카 비용 — 로컬 측정 156 Mi/Pod (vault-agent 1.17.2, idle, kubectl top 3 회 동일). "
|
||
" *주의* 표본 1, idle baseline. 정상 운영 시 +20~50 Mi 가능. 정확한 절대값보다 'Pod N = 사이드카 N' 의 선형성 메시지.\n"
|
||
"③ K8s 네이티브 X — Pod 어노테이션 기반. GitOps(ArgoCD/Flux)가 sync 됐는지 자체를 검증 불가.\n"
|
||
" VSO 는 CRD 기반이라 status 필드로 reconcile 결과를 외부에 노출.\n\n"
|
||
"결론 — 우리 환경에서는 VSO. (다음 슬라이드)"
|
||
))
|
||
add_title(s, "⑤ Vault Injector 를 안 고른 3 가지 이유",
|
||
"우리 환경(단일 노드 K3s dev) 기준 — 대규모 운영에서는 Injector 도 정답일 수 있음")
|
||
|
||
# 3 카드 가로 배치
|
||
n = 3
|
||
gap = Cm(0.5)
|
||
col_w = (content_w() - gap * (n - 1)) // n
|
||
card_top = CONTENT_TOP_WIDE
|
||
card_h = Cm(8.4)
|
||
|
||
cards = [
|
||
{
|
||
"num": "01",
|
||
"head": "의존 사슬",
|
||
"viz": "KMS → Vault → K8s Secret\n→ Pod → 모든 워크로드",
|
||
"body": "auto-unseal 채택 시 KMS 한 점이 전체 직렬화.\nunseal token 도 K8s Secret 으로 etcd 에 남음 — 도입 동기 자기충돌",
|
||
},
|
||
{
|
||
"num": "02",
|
||
"head": "사이드카 비용 (선형)",
|
||
"viz": "≈ 156 Mi / Pod",
|
||
"body": "vault-agent 1.17.2 · idle · 로컬 단일 측정.\n표본 1 · 정상 운영 +20~50 Mi 가능 — 절대값보다 'Pod N = 사이드카 N' 메시지가 핵심",
|
||
},
|
||
{
|
||
"num": "03",
|
||
"head": "K8s 네이티브 X",
|
||
"viz": "Pod annotation\n(K8s 1급 객체 X)",
|
||
"body": "Injector 는 어노테이션 기반 동적 주입.\nGitOps(ArgoCD/Flux)가 sync 결과를 검증 못 함 — VSO 는 CRD status 로 공개",
|
||
},
|
||
]
|
||
for i, c in enumerate(cards):
|
||
x = MARGIN_X + (col_w + gap) * i
|
||
add_panel(s, x, card_top, col_w, card_h, fill=C_BG, line=C_DANGER, line_w=1.4)
|
||
add_text_box(s, x, card_top + Cm(0.3), col_w, Cm(0.6),
|
||
c["num"], size=14, bold=True, color=C_DANGER, align=PP_ALIGN.CENTER)
|
||
add_text_box(s, x + Cm(0.3), card_top + Cm(1.0), col_w - Cm(0.6), Cm(0.8),
|
||
c["head"], size=18, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
|
||
# viz block
|
||
add_panel(s, x + Cm(0.4), card_top + Cm(2.2), col_w - Cm(0.8), Cm(2.8),
|
||
fill=C_DANGER_FILL, line=C_DANGER, line_w=0.8)
|
||
add_text_box(s, x + Cm(0.5), card_top + Cm(2.5), col_w - Cm(1.0), Cm(2.3),
|
||
c["viz"], size=13, bold=True, mono=True, color=C_DANGER, align=PP_ALIGN.CENTER)
|
||
# body
|
||
add_text_box(s, x + Cm(0.5), card_top + Cm(5.3), col_w - Cm(1.0), Cm(2.9),
|
||
c["body"], size=11, color=C_TEXT, align=PP_ALIGN.LEFT)
|
||
|
||
# 다음 슬라이드 연결 한 줄
|
||
add_text_box(
|
||
s, MARGIN_X, Cm(13.0), content_w(), Cm(0.8),
|
||
"→ 다음 슬라이드: 같은 Vault 를 유지하면서 Pod-Vault 접점만 바꾼 해법 (VSO)",
|
||
size=14, bold=True, color=C_GOOD, align=PP_ALIGN.CENTER,
|
||
)
|
||
add_caption_band(s, "이건 Injector 적대가 아니라 우리 환경 기준의 선택")
|
||
add_slide_chrome(s, 12, total, "세부 #5 · Vault 운영 비교")
|
||
|
||
|
||
def slide_11_vault_vso(prs, total):
|
||
s = slide_diagram_only(
|
||
prs,
|
||
"06-vault-evolution.png",
|
||
13,
|
||
total,
|
||
"세부 #5 · VSO 진화",
|
||
title="⑤ VSO — Pod 와 Vault 사이 접점만 바꾼다",
|
||
subtitle="사이드카 0 · Pod 는 평범한 K8s Secret 만 envFrom · 다음 경계 = etcd",
|
||
caption="운영자는 Vault 를 source of truth 로 · 애플리케이션은 K8s Secret 만 소비",
|
||
)
|
||
add_notes(s, (
|
||
"Key Message — Vault 를 빼는 게 아니라 'Pod ↔ Vault' 접점만 바꾼다. Pod 는 Vault 를 모름.\n\n"
|
||
"CRD 3 종 — VaultConnection (연결), VaultAuth (K8s auth), VaultStaticSecret (KV → K8s Secret 매핑, 현재 7 개+).\n\n"
|
||
"트레이드오프 — VSO 가 만드는 K8s Secret 은 etcd 에 base64 로 저장. 다음 슬라이드(etcd 결정 트리)에서 닫음.\n\n"
|
||
"이 슬라이드 단일 메시지 = '접점만 바꿨다'. 사이드카 / 어노테이션 / 비용 비교는 이전 슬라이드(12)에서 마쳤음 — 여기선 반복 X."
|
||
))
|
||
|
||
|
||
def slide_12_etcd_encryption(prs, total):
|
||
"""14 — etcd encryption-at-rest: 환경별 결정 트리 + 닭달걀 경고"""
|
||
s = blank_slide(prs)
|
||
add_notes(s, (
|
||
"Key Message — VSO 채택의 부수 책임 = etcd 보호. 환경별 권장 옵션이 다르고, "
|
||
"Vault transit 으로 etcd 를 암호화하면 닭달걀 (Vault Pod 가 K8s Secret 을 etcd 에서 읽어야 시작).\n\n"
|
||
"현재 상태 — dev 환경에서 K3s --secrets-encryption 은 *다음 액션*. 아직 미적용. "
|
||
"마무리 슬라이드의 다음 검증 항목으로 연결.\n\n"
|
||
"[backup] 옵션별 키 관리:\n"
|
||
"• K3s --secrets-encryption — AES-CBC 자동, 키는 노드 디스크\n"
|
||
"• kubeadm EncryptionConfiguration — KMS provider 와 결합\n"
|
||
"• 클라우드 KMS — AWS KMS / GCP KMS provider"
|
||
))
|
||
add_title(s, "⑤-C etcd 보호 — 환경별 결정",
|
||
"Vault transit 으로 etcd 암호화 시 닭달걀 — 단일 Vault 에선 별도 provider 필수")
|
||
|
||
# 결정 카드 3 개
|
||
n = 3
|
||
gap = Cm(0.5)
|
||
col_w = (content_w() - gap * (n - 1)) // n
|
||
card_top = CONTENT_TOP_WIDE
|
||
card_h = Cm(6.0)
|
||
|
||
cards = [
|
||
("dev / 단일 노드", "K3s\n--secrets-encryption", "한 줄 옵션 · AES-CBC 자동 · 추가 인프라 0",
|
||
"현재 다음 액션 후보", C_GOOD),
|
||
("표준 kubeadm", "EncryptionConfiguration\n+ KMS provider", "외부 KMS 와 결합 · 키 회전 정책 관리 필요",
|
||
"운영 환경 표준", C_APP),
|
||
("클라우드", "AWS KMS / GCP KMS\nprovider", "클라우드 관리형 키 · IAM 위임 · 가장 손쉬움",
|
||
"managed 환경 정답", C_APP),
|
||
]
|
||
for i, (env, opt, body, hint, color) in enumerate(cards):
|
||
x = MARGIN_X + (col_w + gap) * i
|
||
add_panel(s, x, card_top, col_w, card_h, fill=C_BG, line=color, line_w=1.4)
|
||
add_text_box(s, x, card_top + Cm(0.25), col_w, Cm(0.6),
|
||
env, size=12, bold=True, color=C_MUTED, align=PP_ALIGN.CENTER)
|
||
add_text_box(s, x + Cm(0.3), card_top + Cm(0.9), col_w - Cm(0.6), Cm(1.6),
|
||
opt, size=15, bold=True, mono=True, color=color, align=PP_ALIGN.CENTER)
|
||
add_text_box(s, x + Cm(0.4), card_top + Cm(2.8), col_w - Cm(0.8), Cm(2.0),
|
||
body, size=11, color=C_TEXT, align=PP_ALIGN.CENTER)
|
||
add_text_box(s, x + Cm(0.4), card_top + Cm(4.9), col_w - Cm(0.8), Cm(0.7),
|
||
hint, size=11, bold=True, color=color, align=PP_ALIGN.CENTER)
|
||
|
||
# 닭달걀 경고 패널 — 핵심 인사이트
|
||
chick_top = Cm(11.0)
|
||
add_panel(s, MARGIN_X, chick_top, content_w(), Cm(2.4),
|
||
fill=C_WARN_FILL, line=C_WARN_LINE, line_w=1.5)
|
||
add_text_box(s, MARGIN_X + Cm(0.5), chick_top + Cm(0.25), Cm(20), Cm(0.6),
|
||
"닭과 달걀 — Vault 로 etcd 를 직접 풀 수 없다",
|
||
size=15, bold=True, color=C_WARN_TEXT)
|
||
add_text_box(s, MARGIN_X + Cm(0.5), chick_top + Cm(0.95), content_w() - Cm(1.0), Cm(1.4),
|
||
"Vault Pod 도 K8s Secret(설정 · TLS · token) 을 etcd 에서 읽어야 시작 → "
|
||
"etcd 복호화 = Vault 호출 = Vault 가 다시 etcd 필요. "
|
||
"단일 Vault 환경에선 별도 provider Vault 또는 다른 KMS 필수.",
|
||
size=12, color=C_TEXT)
|
||
|
||
add_caption_band(s, "VSO 가 K8s Secret 을 만들면 etcd 보호가 다음 경계 — 닭달걀 함정 인지")
|
||
add_slide_chrome(s, 14, total, "세부 #5-C · etcd 보호")
|
||
|
||
|
||
def slide_13_k8s_decisions(prs, total):
|
||
s = blank_slide(prs)
|
||
add_notes(s, (
|
||
"Key Message — '기본은 막고 필요한 것만 연다'. 4 개 결정은 모두 '변경 영향 면 축소' 목적.\n\n"
|
||
"시간 짧으면 #1, #3 만. base/overlay = 환경 차이 격리, NetworkPolicy default-deny = 보안 표면 코드화.\n\n"
|
||
"[backup] YAML 예시 — podSelector: {} + Ingress/Egress 양쪽 빈 정책 = 완전 차단. "
|
||
"그 위에 컴포넌트별 allow 만 추가. 파일 위치 = "
|
||
"gitops/policies/baseline/overlays/lab/networkpolicy.yaml."
|
||
))
|
||
add_title(s, "⑥ K8s 설정 4 결정 — 기본 deny, 명시적 allow",
|
||
"환경 차이는 overlay 로, 보안 표면은 코드로 정의")
|
||
|
||
headers = ["#", "결정", "줄어드는 것"]
|
||
rows = [
|
||
("1", "base / overlay 분리",
|
||
"환경 차이를 한 곳에만 — base 수정 1 번 = 모든 환경 자동 반영"),
|
||
("2", "PSS Restricted enforce",
|
||
"runAsNonRoot · readOnlyRootFilesystem · capabilities=DROP ALL — 위반 시 admission 차단"),
|
||
("3", "NetworkPolicy default-deny",
|
||
"기본 거부 + 명시 허용 — 신규 Pod 의 의도하지 않은 통신 가능성"),
|
||
("4", "ingressClassName 명시",
|
||
"annotation 의존 X — 다중 ingress 환경에서 라우팅 모호성"),
|
||
]
|
||
add_table(
|
||
s, MARGIN_X, CONTENT_TOP_WIDE, headers, rows,
|
||
col_widths_cm=[1.5, 8.5, 21.5],
|
||
header_fill=C_PANEL,
|
||
row_h_cm=1.4,
|
||
body_size=13,
|
||
header_size=14,
|
||
)
|
||
|
||
# 핵심 결정 #3 강조 — 라이트 코드 패널
|
||
code_top = Cm(10.0)
|
||
code_w = content_w()
|
||
add_text_box(
|
||
s, MARGIN_X, code_top, code_w, Cm(0.6),
|
||
"#3 default-deny — 한 매니페스트가 전체 통신을 막고, allow 만 추가하게 한다",
|
||
size=12, bold=True, color=C_APP,
|
||
)
|
||
add_panel(s, MARGIN_X, code_top + Cm(0.7), code_w, Cm(3.4),
|
||
fill=C_PANEL, line=C_APP, line_w=1.0)
|
||
add_text_box(
|
||
s, MARGIN_X + Cm(0.4), code_top + Cm(0.85), code_w - Cm(0.8), Cm(3.2),
|
||
"kind: NetworkPolicy\n"
|
||
"spec:\n"
|
||
" podSelector: {} # 모든 Pod\n"
|
||
" policyTypes: [Ingress, Egress] # 양쪽 모두 명시\n"
|
||
" # ingress / egress 둘 다 비어있음 → 완전 차단",
|
||
size=12, mono=True, color=C_TEXT,
|
||
)
|
||
|
||
add_caption_band(s, "보안 표면을 코드로 정의 — drift 도, 실수도 동시에 줄어든다")
|
||
add_slide_chrome(s, 15, total, "세부 #6 · K8s 결정")
|
||
|
||
|
||
def slide_14_traefik(prs, total):
|
||
s = slide_diagram_only(
|
||
prs,
|
||
"07-traefik-pipeline.png",
|
||
16,
|
||
total,
|
||
"세부 #7 · Traefik",
|
||
title="⑦ Traefik — 운영 정책을 Middleware 한 줄 토글",
|
||
subtitle="K3s packaged Traefik 유지 · 코어 manifest 0 수정 · HelmChartConfig overlay 로 GitOps",
|
||
caption="dev 는 셀프사인 인증서 발급 이슈로 HTTP 운영 중 — cert-manager + 외부 DNS 가 다음 액션",
|
||
)
|
||
add_notes(s, (
|
||
"Key Message — Traefik 자체를 새로 설치하지 않는다. K3s 기본 Traefik 을 유지하면서 "
|
||
"운영 정책만 HelmChartConfig + Middleware 로 GitOps 화.\n\n"
|
||
"dev TLS 비활성 컨텍스트 — 셀프사인 인증서로 oauth2-proxy ForwardAuth 셋업 중 신뢰 체인 이슈가 있어, "
|
||
"dev 단계에선 HTTP 로 운영. 운영 환경 표준은 cert-manager + 외부 DNS + Let's Encrypt (마무리 슬라이드 다음 액션).\n\n"
|
||
"[backup] Middleware 체인 — https-redirect / security-headers / (registry 만) BasicAuth. "
|
||
"TLSOption modern-tls = TLS 1.2+ strict SNI. "
|
||
"라우터 annotation = traefik.ingress.kubernetes.io/router.middlewares 에 체인 명시. "
|
||
"namespace prefix 주의 — kube-system-* (Traefik 위치) / mnt-* (워크로드 위치)."
|
||
))
|
||
|
||
|
||
def slide_16_next(prs, total):
|
||
s = blank_slide(prs)
|
||
add_notes(s, (
|
||
"Closing — 한 줄 메시지: 백엔드는 애플리케이션 내부만으로 완성되지 않는다. "
|
||
"인증 → 시크릿 → 런타임 순서로 경계를 나누고 닫는 작업이 같이 가야 운영 가능한 구조가 된다.\n\n"
|
||
"다음 액션은 우선순위 3 개로 추렸음:\n"
|
||
"• cert-manager + 외부 DNS — dev TLS 셀프사인 이슈 해소, ssl_insecure_skip_verify 제거\n"
|
||
"• ForwardAuth E2E 검증 — 실제 사용자 세션 / 로그인 / 로그아웃 / 차단 흐름 통과\n"
|
||
"• etcd encryption-at-rest — K3s --secrets-encryption 부터\n\n"
|
||
"(시간 있으면) backup 3 — registries.yaml 자동화 · CORS 책임 위치 · presentation-http/grpc 분리.\n\n"
|
||
"Q&A 안내 — 질문 받음. 예상 질문:\n"
|
||
"• '왜 K3s 인가' → dev 학습 환경, 단일 노드 적합, 같은 K8s API.\n"
|
||
"• 'ArchUnit 빌드 시간' → 테스트 단계만, +수 초.\n"
|
||
"• 'Vault 단일 노드 prod 가능?' → file backend 라 HA 불가. raft 전환 필요."
|
||
))
|
||
add_title(s, "한 줄 정리 + 다음 액션")
|
||
|
||
# 단일 closing 메시지 — 중앙 큰 박스
|
||
msg_top = Cm(3.6)
|
||
msg_h = Cm(3.2)
|
||
add_panel(s, MARGIN_X, msg_top, content_w(), msg_h,
|
||
fill=C_APP_FILL, line=C_APP, line_w=1.6)
|
||
add_text_box(
|
||
s, MARGIN_X + Cm(0.5), msg_top + Cm(0.4), content_w() - Cm(1.0), Cm(1.0),
|
||
"백엔드는 애플리케이션 내부만으로 완성되지 않는다",
|
||
size=22, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER,
|
||
)
|
||
add_text_box(
|
||
s, MARGIN_X + Cm(0.5), msg_top + Cm(1.5), content_w() - Cm(1.0), Cm(1.5),
|
||
"인증 → 시크릿 → 런타임 순서로 경계를 나누고 닫아야 운영 가능한 구조가 된다",
|
||
size=15, color=C_MUTED, align=PP_ALIGN.CENTER,
|
||
)
|
||
|
||
# 다음 액션 — top 3 만
|
||
act_top = Cm(7.8)
|
||
add_text_box(
|
||
s, MARGIN_X, act_top, content_w(), Cm(0.7),
|
||
"다음 액션 — 우선순위 3", size=14, bold=True, color=C_DANGER,
|
||
)
|
||
actions = [
|
||
("01", "cert-manager + 외부 DNS",
|
||
"dev TLS 셀프사인 이슈 해소 · ssl_insecure_skip_verify 제거"),
|
||
("02", "ForwardAuth E2E 검증",
|
||
"실제 사용자 로그인 / 로그아웃 / 차단 흐름 통과 확인"),
|
||
("03", "etcd encryption-at-rest",
|
||
"K3s --secrets-encryption 부터 (VSO 채택의 다음 경계)"),
|
||
]
|
||
n = 3
|
||
gap = Cm(0.4)
|
||
a_w = (content_w() - gap * (n - 1)) // n
|
||
a_top = act_top + Cm(0.85)
|
||
a_h = Cm(3.6)
|
||
for i, (num, head, body) in enumerate(actions):
|
||
x = MARGIN_X + (a_w + gap) * i
|
||
add_panel(s, x, a_top, a_w, a_h, fill=C_BG, line=C_DANGER, line_w=1.2)
|
||
add_text_box(s, x + Cm(0.3), a_top + Cm(0.2), a_w - Cm(0.6), Cm(0.55),
|
||
num, size=11, bold=True, color=C_DANGER)
|
||
add_text_box(s, x + Cm(0.3), a_top + Cm(0.8), a_w - Cm(0.6), Cm(0.7),
|
||
head, size=15, bold=True, color=C_TEXT)
|
||
add_text_box(s, x + Cm(0.3), a_top + Cm(1.7), a_w - Cm(0.6), Cm(1.8),
|
||
body, size=11, color=C_MUTED)
|
||
|
||
# backup 3 — 한 줄 작게
|
||
add_text_box(
|
||
s, MARGIN_X, Cm(13.0), content_w(), Cm(0.6),
|
||
"backup — registries.yaml 자동화 · CORS 책임 위치 · presentation-http/grpc 분리",
|
||
size=11, color=C_MUTED, align=PP_ALIGN.CENTER,
|
||
)
|
||
|
||
add_caption_band(s, "Q & A — 질문 받겠습니다")
|
||
add_slide_chrome(s, 17, total, "마무리")
|
||
|
||
|
||
# ─────────────────────── build ───────────────────────
|
||
|
||
|
||
def build():
|
||
prs = Presentation()
|
||
prs.slide_width = SLIDE_W
|
||
prs.slide_height = SLIDE_H
|
||
|
||
total = 17
|
||
# 들어가며
|
||
slide_01_cover(prs, total)
|
||
slide_02_goals(prs, total)
|
||
# ① Auth boundary
|
||
slide_03_overall(prs, total)
|
||
slide_04_auth(prs, total)
|
||
# ② Application
|
||
slide_05_ca(prs, total)
|
||
slide_06_exception_policy(prs, total)
|
||
slide_06a_exception_layers(prs, total)
|
||
slide_06b_envelope(prs, total)
|
||
slide_07_logging_flow(prs, total)
|
||
# ③ Data
|
||
slide_08_data(prs, total)
|
||
slide_09_flyway(prs, total)
|
||
# ④ Secret (Vault Injector 단점 → VSO → etcd)
|
||
slide_10_vault_compare(prs, total)
|
||
slide_11_vault_vso(prs, total)
|
||
slide_12_etcd_encryption(prs, total)
|
||
# ⑤ Runtime
|
||
slide_13_k8s_decisions(prs, total)
|
||
slide_14_traefik(prs, total)
|
||
# 마무리
|
||
slide_16_next(prs, total)
|
||
|
||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||
prs.save(OUT)
|
||
print(f"saved → {OUT}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
build()
|