웹 UI 추가 및 Dockerfile 추가

This commit is contained in:
2026-01-05 10:00:41 +09:00
parent a1ecf4c634
commit 40a75959db
8 changed files with 591 additions and 28 deletions

View File

@@ -1,36 +1,20 @@
# ===============================
# Stage 1: kubectl
# ===============================
FROM bitnami/kubectl AS kubectl
# ===============================
# Stage 2: runtime
# ===============================
FROM python:3.11-slim
# kubectl 복사
COPY --from=kubectl /opt/bitnami/kubectl/bin/kubectl /usr/local/bin/kubectl
RUN apt-get update && \
apt-get install -y curl && \
curl -LO https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl && \
install -m 0755 kubectl /usr/local/bin/kubectl && \
rm -rf /var/lib/apt/lists/*
# 필수 패키지
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# python 패키지
RUN pip install --no-cache-dir ruamel.yaml
# 작업 디렉토리
WORKDIR /app
# 스크립트 복사
COPY migrate.py /app/migrate.py
COPY migrator.py mapping.yaml app.py requirements.txt ./
COPY templates ./templates
# 기본 mapping.yaml
COPY mapping.yaml /app/mapping.yaml
RUN pip install --no-cache-dir -r requirements.txt gunicorn
# 실행 권한
RUN chmod +x /app/migrate.py
RUN mkdir /work
# 기본 엔트리포인트
ENTRYPOINT ["python", "/app/migrate.py"]
EXPOSE 8080
CMD ["gunicorn", "-b", "0.0.0.0:8080", "--timeout", "300", "app:app"]

117
app.py Normal file
View File

@@ -0,0 +1,117 @@
from flask import Flask, render_template, request, send_file, abort
import subprocess
import uuid
import shutil
from pathlib import Path
from datetime import datetime
import zipfile
#app = Flask(__name__)
app = Flask(__name__, static_folder="static")
BASE = Path("/work")
BASE.mkdir(exist_ok=True)
def ts():
return datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "POST":
job_id = str(uuid.uuid4())
job_dir = BASE / job_id
job_dir.mkdir(parents=True)
cmd = [
"python3",
"/app/migrator.py",
"--split",
"--mapping", "/app/mapping.yaml",
]
if request.form.get("from_class"):
cmd += ["--from-ingress-class", request.form["from_class"]]
if request.form.get("to_class"):
cmd += ["--ingress-class", request.form["to_class"]]
if request.form.get("namespace"):
cmd += ["--namespace", request.form["namespace"]]
subprocess.check_call(cmd, cwd=job_dir)
timestamp = ts()
# result.zip (yaml + report)
result_zip = BASE / f"{timestamp}-result.zip"
with zipfile.ZipFile(result_zip, "w", zipfile.ZIP_DEFLATED) as z:
for p in (job_dir / "yaml").rglob("*"):
z.write(p, p.relative_to(job_dir))
report = job_dir / "migration-report.md"
if report.exists():
z.write(report, report.relative_to(job_dir))
# backup.zip
backup_zip = BASE / f"{timestamp}-backup.zip"
with zipfile.ZipFile(backup_zip, "w", zipfile.ZIP_DEFLATED) as z:
for p in (job_dir / "backup").rglob("*"):
z.write(p, p.relative_to(job_dir))
return preview(job_id, result_zip.name, backup_zip.name)
return render_template("index.html")
@app.route("/preview/<job_id>/<result_zip>/<backup_zip>")
def preview(job_id, result_zip, backup_zip):
job_dir = BASE / job_id
def tree(root: Path):
t = {}
if not root.exists():
return t
for ns in sorted(root.iterdir()):
if ns.is_dir():
t[ns.name] = sorted(f.name for f in ns.iterdir() if f.is_file())
return t
return render_template(
"preview.html",
job_id=job_id,
yaml_tree=tree(job_dir / "yaml"),
backup_tree=tree(job_dir / "backup"),
result_zip=result_zip,
backup_zip=backup_zip,
)
@app.route("/view/<job_id>/<kind>/<path:path>")
def view_file(job_id, kind, path):
if kind not in ("yaml", "backup"):
abort(404)
f = BASE / job_id / kind / path
if not f.exists():
abort(404)
return f"<pre>{f.read_text()}</pre>"
@app.route("/report/<job_id>")
def view_report(job_id):
report = BASE / job_id / "migration-report.md"
if not report.exists():
abort(404)
return f"<pre>{report.read_text()}</pre>"
@app.route("/download/<name>")
def download(name):
path = BASE / name
if not path.exists():
abort(404)
return send_file(path, as_attachment=True)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, debug=False)

313
migrator.py Normal file
View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python3
import argparse
import subprocess
from copy import deepcopy
from pathlib import Path
from collections import defaultdict
from ruamel.yaml import YAML
# -----------------------------
# YAML 설정
# -----------------------------
yaml = YAML()
yaml.preserve_quotes = True
yaml.indent(mapping=2, sequence=4, offset=2)
yaml.width = 4096
# -----------------------------
# kubectl ingress 조회
# -----------------------------
def kubectl_ingress(namespace=None):
cmd = ["kubectl", "get", "ingress"]
if namespace:
cmd += ["-n", namespace]
else:
cmd.append("--all-namespaces")
cmd += ["-o", "yaml"]
out = subprocess.check_output(cmd)
return yaml.load(out)
# -----------------------------
# mapping.yaml 로드
# -----------------------------
def load_mapping(path):
with open(path) as f:
return yaml.load(f)
# -----------------------------
# annotation 렌더링
# -----------------------------
def render_annotations_block(unsupported, partial, converted):
lines = []
if unsupported:
note = unsupported[0]["note"]
lines.append(f" # {note}")
for u in unsupported:
lines.append(f" #{u['key']}: {u['value']}")
lines.append("")
if partial:
for p in partial:
lines.append(f" # PARTIAL SUPPORT: {p['note']}")
lines.append(f" {p['haproxy']}: \"{str(p['value'])}\"")
lines.append("")
for c in converted:
lines.append(f" {c['haproxy']}: \"{str(c['value'])}\"")
return "\n".join(lines)
# -----------------------------
# metadata 정리
# -----------------------------
def cleanup_metadata(meta):
for k in [
"creationTimestamp",
"generation",
"resourceVersion",
"uid",
"managedFields",
]:
meta.pop(k, None)
# -----------------------------
# ingressClassName 기준 필터링
# -----------------------------
def filter_by_ingress_class(data, from_ingress_class):
filtered = deepcopy(data)
filtered["items"] = []
for item in data.get("items", []):
spec = item.get("spec", {})
current_class = spec.get("ingressClassName", "nginx")
if current_class == from_ingress_class:
filtered["items"].append(item)
return filtered
# -----------------------------
# 변환 전 원본 백업 (backup/ns/ingress.yaml)
# -----------------------------
def backup_original_ingress(data, backup_dir="./backup"):
base = Path(backup_dir)
base.mkdir(parents=True, exist_ok=True)
for item in data.get("items", []):
meta = item.get("metadata", {})
name = meta.get("name")
namespace = meta.get("namespace", "default")
ns_dir = base / namespace
ns_dir.mkdir(parents=True, exist_ok=True)
with (ns_dir / f"{name}.yaml").open("w") as f:
yaml.dump(item, f)
# -----------------------------
# ingress 변환 로직
# -----------------------------
def migrate_ingress(data, mapping, to_ingress_class=None):
report = {
"converted": [],
"partial": [],
"unsupported": [],
"detail": defaultdict(lambda: {"partial": [], "unsupported": []}),
}
new = deepcopy(data)
for item in new.get("items", []):
meta = item.setdefault("metadata", {})
spec = item.setdefault("spec", {})
cleanup_metadata(meta)
name = meta.get("name")
namespace = meta.get("namespace", "default")
ingress_id = f"{namespace}/{name}"
if to_ingress_class:
spec["ingressClassName"] = to_ingress_class
anns = meta.get("annotations", {}) or {}
unsupported = []
partial = []
converted = []
for k, v in anns.items():
rule = mapping.get(k)
if not rule:
converted.append({"haproxy": k, "value": v})
continue
support = rule.get("support", "unsupported")
haproxy_key = rule.get("haproxy")
note = rule.get("note", "no HAProxy equivalent")
if support == "full" and haproxy_key:
converted.append({"haproxy": haproxy_key, "value": v})
report["converted"].append(k)
elif support == "partial" and haproxy_key:
partial.append({"haproxy": haproxy_key, "value": v, "note": note})
report["partial"].append(k)
report["detail"][ingress_id]["partial"].append(
{"nginx": k, "haproxy": haproxy_key, "note": note}
)
else:
unsupported.append({"key": k, "value": v, "note": note})
report["unsupported"].append(k)
report["detail"][ingress_id]["unsupported"].append(
{"nginx": k, "value": v, "note": note}
)
meta.pop("annotations", None)
item["_rendered_annotations"] = render_annotations_block(
unsupported, partial, converted
)
return new, report
# -----------------------------
# 리포트 생성
# -----------------------------
def write_report(report, path):
with open(path, "w") as f:
f.write("# Ingress Migration Report\n\n")
f.write("## Summary\n")
f.write(f"- Converted : {len(report['converted'])}\n")
f.write(f"- Partial : {len(report['partial'])}\n")
f.write(f"- Unsupported: {len(report['unsupported'])}\n\n")
f.write("---\n\n")
for ingress, detail in report["detail"].items():
if not detail["partial"] and not detail["unsupported"]:
continue
f.write(f"## {ingress}\n\n")
for p in detail["partial"]:
f.write(
f"- PARTIAL {p['nginx']}{p['haproxy']}\n"
f" - note: {p['note']}\n"
)
for u in detail["unsupported"]:
f.write(
f"- UNSUPPORTED {u['nginx']}\n"
f" - note: {u['note']}\n"
)
f.write("\n")
# -----------------------------
# 파일 저장 (yaml/ns/ingress.yaml)
# -----------------------------
def save_split(data, out_dir="./yaml"):
base = Path(out_dir)
base.mkdir(parents=True, exist_ok=True)
for item in data.get("items", []):
rendered = item.pop("_rendered_annotations", "")
meta = item["metadata"]
name = meta["name"]
namespace = meta.get("namespace", "default")
ns_dir = base / namespace
ns_dir.mkdir(parents=True, exist_ok=True)
path = ns_dir / f"{name}.yaml"
with path.open("w") as f:
yaml.dump(item, f)
if rendered:
content = path.read_text()
content = content.replace(
"metadata:\n",
"metadata:\n annotations:\n" + rendered + "\n",
1,
)
path.write_text(content)
# -----------------------------
# main
# -----------------------------
def main():
parser = argparse.ArgumentParser(
description="nginx ingress → HAProxy ingress migration utility"
)
parser.add_argument("--mapping", default="mapping.yaml")
parser.add_argument("--split", action="store_true")
parser.add_argument("--out", default="./yaml")
parser.add_argument("--ingress-class", help="target ingressClassName")
parser.add_argument(
"--from-ingress-class",
default="nginx",
help="source ingressClassName (default: nginx)",
)
parser.add_argument(
"--namespace",
help="target namespace (default: all namespaces)",
)
parser.add_argument("--report", default="migration-report.md")
args = parser.parse_args()
if not args.split:
parser.error("--split must be specified")
# 디렉토리 보장
Path("./backup").mkdir(parents=True, exist_ok=True)
Path(args.out).mkdir(parents=True, exist_ok=True)
ingress = kubectl_ingress(args.namespace)
ingress = filter_by_ingress_class(
ingress,
args.from_ingress_class,
)
backup_original_ingress(ingress)
mapping = load_mapping(args.mapping)
converted, report = migrate_ingress(
ingress,
mapping,
to_ingress_class=args.ingress_class,
)
save_split(converted, args.out)
write_report(report, args.report)
print("\n=== Migration Summary ===")
print(f"Converted : {len(report['converted'])}")
print(f"Partial : {len(report['partial'])}")
print(f"Unsupported: {len(report['unsupported'])}")
print(f"Backup : ./backup/<namespace>/<ingress>.yaml")
print(f"Output : {args.out}/<namespace>/<ingress>.yaml")
print(f"Report : {args.report}")
if __name__ == "__main__":
main()

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
flask
ruamel.yaml
gunicorn

73
static/style.css Normal file
View File

@@ -0,0 +1,73 @@
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
margin: 0;
background: #0f172a;
color: #e5e7eb;
}
.container {
max-width: 1100px;
margin: 40px auto;
padding: 24px;
}
.card {
background: #020617;
border: 1px solid #1e293b;
border-radius: 10px;
padding: 20px;
margin-bottom: 24px;
}
h1, h2, h3, h4 {
margin-top: 0;
}
label {
font-size: 14px;
color: #94a3b8;
}
input {
width: 100%;
padding: 10px;
margin: 6px 0 14px;
border-radius: 6px;
border: 1px solid #334155;
background: #020617;
color: #e5e7eb;
}
button {
background: #2563eb;
color: white;
border: none;
padding: 10px 18px;
border-radius: 6px;
cursor: pointer;
}
button:hover {
background: #1d4ed8;
}
a {
color: #60a5fa;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
ul {
padding-left: 18px;
}
pre {
background: #020617;
border: 1px solid #1e293b;
border-radius: 8px;
padding: 16px;
overflow-x: auto;
}

27
templates/index.html Normal file
View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<title>Ingress Migrator</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>Ingress Migrator</h1>
<div class="card">
<form method="post">
<label>From ingressClass</label>
<input name="from_class" value="nginx">
<label>To ingressClass</label>
<input name="to_class" value="haproxy">
<label>Namespace (optional)</label>
<input name="namespace">
<button type="submit">Run Migration</button>
</form>
</div>
</div>
</body>
</html>

44
templates/preview.html Normal file
View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<title>Result</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>Migration Result</h1>
<div class="card">
<h2>Converted YAML</h2>
{% for ns, files in yaml_tree.items() %}
<h4>{{ ns }}</h4>
<ul>
{% for f in files %}
<li><a href="/view/{{ job_id }}/yaml/{{ ns }}/{{ f }}">{{ f }}</a></li>
{% endfor %}
</ul>
{% endfor %}
</div>
<div class="card">
<h2>Original Backup</h2>
{% for ns, files in backup_tree.items() %}
<h4>{{ ns }}</h4>
<ul>
{% for f in files %}
<li><a href="/view/{{ job_id }}/backup/{{ ns }}/{{ f }}">{{ f }}</a></li>
{% endfor %}
</ul>
{% endfor %}
</div>
<div class="card">
<a href="/report/{{ job_id }}">📄 migration-report.md</a><br><br>
<a href="/download/{{ result_zip }}">⬇ result.zip</a><br>
<a href="/download/{{ backup_zip }}">⬇ backup.zip</a>
</div>
</div>
</body>
</html>

2
templates/report.html Normal file
View File

@@ -0,0 +1,2 @@
<h2>Migration Report</h2>
<pre>{{ content }}</pre>