diff --git a/Dockerfile b/Dockerfile index befa27f..2c09edd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..e018a56 --- /dev/null +++ b/app.py @@ -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///") +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///") +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"
{f.read_text()}
" + + +@app.route("/report/") +def view_report(job_id): + report = BASE / job_id / "migration-report.md" + if not report.exists(): + abort(404) + return f"
{report.read_text()}
" + + +@app.route("/download/") +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) diff --git a/migrator.py b/migrator.py new file mode 100644 index 0000000..c569702 --- /dev/null +++ b/migrator.py @@ -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//.yaml") + print(f"Output : {args.out}//.yaml") + print(f"Report : {args.report}") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..433345b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask +ruamel.yaml +gunicorn diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..2c9e445 --- /dev/null +++ b/static/style.css @@ -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; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..d62ad0d --- /dev/null +++ b/templates/index.html @@ -0,0 +1,27 @@ + + + + Ingress Migrator + + + +
+

Ingress Migrator

+ +
+
+ + + + + + + + + + +
+
+
+ + diff --git a/templates/preview.html b/templates/preview.html new file mode 100644 index 0000000..93b07cb --- /dev/null +++ b/templates/preview.html @@ -0,0 +1,44 @@ + + + + Result + + + +
+ +

Migration Result

+ +
+

Converted YAML

+ {% for ns, files in yaml_tree.items() %} +

{{ ns }}

+
    + {% for f in files %} +
  • {{ f }}
  • + {% endfor %} +
+ {% endfor %} +
+ +
+

Original Backup

+ {% for ns, files in backup_tree.items() %} +

{{ ns }}

+
    + {% for f in files %} +
  • {{ f }}
  • + {% endfor %} +
+ {% endfor %} +
+ + + +
+ + diff --git a/templates/report.html b/templates/report.html new file mode 100644 index 0000000..de66ff5 --- /dev/null +++ b/templates/report.html @@ -0,0 +1,2 @@ +

Migration Report

+
{{ content }}