From dcdcfecc407ddfa9e753e810cdb3492d036cd5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B3=80=EC=A0=95=ED=9B=88?= Date: Mon, 5 Jan 2026 13:15:49 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 172 +++++++++++++++++++++++++++++++----- requirements.txt | 1 + static/style.css | 135 +++++++++++++++++++++++----- templates/apply_result.html | 28 ++++++ templates/index.html | 20 +++-- templates/preview.html | 69 +++++++++------ templates/report.html | 22 ++++- templates/view.html | 33 +++++++ yaml/account.yaml | 26 ++++++ yaml/configmap.yaml | 57 ++++++++++++ yaml/deploy.yaml | 32 +++++++ yaml/svc.yaml | 11 +++ 12 files changed, 525 insertions(+), 81 deletions(-) create mode 100644 templates/apply_result.html create mode 100644 templates/view.html create mode 100644 yaml/account.yaml create mode 100644 yaml/configmap.yaml create mode 100644 yaml/deploy.yaml create mode 100644 yaml/svc.yaml diff --git a/app.py b/app.py index e018a56..767b9f7 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,14 @@ -from flask import Flask, render_template, request, send_file, abort +from flask import ( + Flask, render_template, request, + send_file, abort, redirect, url_for +) import subprocess import uuid -import shutil from pathlib import Path from datetime import datetime import zipfile +import yaml # ⭐ 추가 -#app = Flask(__name__) app = Flask(__name__, static_folder="static") BASE = Path("/work") @@ -17,12 +19,61 @@ def ts(): return datetime.now().strftime("%Y-%m-%d-%H-%M-%S") +# ===== backup YAML sanitize ===== + +REMOVE_METADATA_FIELDS = { + "uid", + "resourceVersion", + "generation", + "creationTimestamp", + "managedFields", +} + +def sanitize_backup_yaml(path: Path): + docs = list(yaml.safe_load_all(path.read_text())) + cleaned = [] + + for doc in docs: + if not isinstance(doc, dict): + cleaned.append(doc) + continue + + # metadata 정리 + meta = doc.get("metadata") + if isinstance(meta, dict): + for k in list(meta.keys()): + if k in REMOVE_METADATA_FIELDS: + meta.pop(k, None) + + # status 제거 (rollback 충돌 방지) + doc.pop("status", None) + + cleaned.append(doc) + + path.write_text( + yaml.safe_dump_all( + cleaned, + sort_keys=False + ) + ) + + +def sanitize_backup_tree(backup_dir: Path): + if not backup_dir.exists(): + return + + for p in backup_dir.rglob("*.yaml"): + sanitize_backup_yaml(p) + + +# ===== routes ===== + @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) + job_dir.mkdir(parents=True, exist_ok=True) cmd = [ "python3", @@ -33,33 +84,41 @@ def index(): 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() + # 🔥 backup YAML 정화 + sanitize_backup_tree(job_dir / "backup") - # result.zip (yaml + report) + timestamp = ts() result_zip = BASE / f"{timestamp}-result.zip" + backup_zip = BASE / f"{timestamp}-backup.zip" + + # 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)) + if p.is_file(): + 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" + # 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)) + if p.is_file(): + z.write(p, p.relative_to(job_dir)) - return preview(job_id, result_zip.name, backup_zip.name) + return redirect(url_for( + "preview", + job_id=job_id, + result_zip=result_zip.name, + backup_zip=backup_zip.name + )) return render_template("index.html") @@ -68,13 +127,14 @@ def index(): def preview(job_id, result_zip, backup_zip): job_dir = BASE / job_id - def tree(root: Path): + def tree(root): 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()) + if root.exists(): + for ns in 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( @@ -87,14 +147,35 @@ def preview(job_id, result_zip, backup_zip): ) -@app.route("/view///") +@app.route("/view///", methods=["GET", "POST"]) 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(): + if not f.exists() or not f.is_file(): abort(404) - return f"
{f.read_text()}
" + + editable = (kind == "yaml") + + if request.method == "POST": + if not editable: + abort(403) + + f.write_text(request.form.get("content", "")) + return redirect(url_for( + "view_file", + job_id=job_id, + kind=kind, + path=path + )) + + return render_template( + "view.html", + title=f"{kind.upper()} / {path}", + content=f.read_text(), + editable=editable + ) @app.route("/report/") @@ -102,7 +183,52 @@ def view_report(job_id): report = BASE / job_id / "migration-report.md" if not report.exists(): abort(404) - return f"
{report.read_text()}
" + + return render_template( + "view.html", + title="Migration Report", + content=report.read_text() + ) + + +@app.route("/apply//", methods=["POST"]) +def apply_yaml(job_id, kind): + if kind not in ("yaml", "backup"): + abort(400) + + target_dir = BASE / job_id / kind + if not target_dir.exists(): + abort(404) + + if kind == "yaml": + cmd = ["kubectl", "replace", "-f", str(target_dir), "--recursive"] + title = "Apply Converted YAML" + else: + cmd = ["kubectl", "replace", "-f", str(target_dir), "--recursive"] + title = "Rollback from Backup" + + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + output = "" + if proc.stdout: + output += proc.stdout + if proc.stderr: + output += "\n" + proc.stderr + + status = "SUCCESS" if proc.returncode == 0 else "FAILED" + + return render_template( + "apply_result.html", + title=title, + status=status, + command=" ".join(cmd), + output=output + ) @app.route("/download/") diff --git a/requirements.txt b/requirements.txt index 433345b..5fdd9e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ flask ruamel.yaml gunicorn +PyYAML diff --git a/static/style.css b/static/style.css index 2c9e445..ecd7555 100644 --- a/static/style.css +++ b/static/style.css @@ -1,12 +1,24 @@ +* { + box-sizing: border-box; +} + body { - font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; - background: #0f172a; + font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background: #020617; color: #e5e7eb; } +body::before { + content: ""; + position: fixed; + inset: 0; + background: linear-gradient(180deg, #0b1220, #020617); + z-index: -1; +} + .container { - max-width: 1100px; + max-width: 1400px; margin: 40px auto; padding: 24px; } @@ -14,43 +26,85 @@ body { .card { background: #020617; border: 1px solid #1e293b; - border-radius: 10px; - padding: 20px; + border-radius: 14px; + padding: 24px; margin-bottom: 24px; } -h1, h2, h3, h4 { - margin-top: 0; +h1 { + margin: 0 0 24px; + font-size: 36px; +} + +/* ======= FORM LAYOUT (GRID / NO OVERLAP) ======= */ + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr auto; + gap: 20px; + align-items: end; +} + +.field { + display: flex; + flex-direction: column; } label { font-size: 14px; color: #94a3b8; + margin-bottom: 6px; } input { - width: 100%; - padding: 10px; - margin: 6px 0 14px; - border-radius: 6px; + height: 44px; + padding: 10px 12px; + font-size: 15px; + border-radius: 8px; border: 1px solid #334155; background: #020617; color: #e5e7eb; } +input:focus { + outline: none; + border-color: #3b82f6; +} + button { - background: #2563eb; - color: white; + height: 44px; + padding: 0 26px; + border-radius: 10px; border: none; - padding: 10px 18px; - border-radius: 6px; + font-size: 15px; + font-weight: 600; cursor: pointer; + background: #2563eb; + color: #fff; + white-space: nowrap; } button:hover { background: #1d4ed8; } +/* ======= VIEW / PREVIEW ======= */ + +pre { + background: transparent; /* ❌ 배경 제거 */ + border: none; /* ❌ 테두리 제거 */ + padding: 0; /* ❌ 중복 패딩 제거 */ + font-size: 13px; + line-height: 1.6; + overflow-x: auto; + color: #e5e7eb; + margin: 0; +} + +ul { + padding-left: 20px; +} + a { color: #60a5fa; text-decoration: none; @@ -60,14 +114,49 @@ a:hover { text-decoration: underline; } -ul { - padding-left: 18px; +.actions-row { + display: flex; + gap: 14px; + flex-wrap: wrap; } -pre { - background: #020617; - border: 1px solid #1e293b; - border-radius: 8px; - padding: 16px; - overflow-x: auto; +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 44px; + padding: 0 26px; + border-radius: 10px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + background: #2563eb; + color: #fff; + white-space: nowrap; + text-decoration: none; +} + +.btn:hover { + background: #1d4ed8; + text-decoration: none; +} + +.btn-danger { + background: #dc2626; +} +.btn-danger:hover { + background: #b91c1c; +} + +.editor { + width: 100%; + min-height: 600px; + background: transparent; + color: #e5e7eb; + border: none; + outline: none; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + line-height: 1.6; } diff --git a/templates/apply_result.html b/templates/apply_result.html new file mode 100644 index 0000000..7a6780f --- /dev/null +++ b/templates/apply_result.html @@ -0,0 +1,28 @@ + + + + {{ title }} + + + +
+

{{ title }}

+ +
+

Status: {{ status }}

+

Command:

+
{{ command }}
+
+ +
+

kubectl output

+
{{ output }}
+
+ +
+ + Home +
+
+ + diff --git a/templates/index.html b/templates/index.html index d62ad0d..2b5e1fc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -9,15 +9,21 @@

Ingress Migrator

-
- - + +
+ + +
- - +
+ + +
- - +
+ + +
diff --git a/templates/preview.html b/templates/preview.html index 93b07cb..0ee2aeb 100644 --- a/templates/preview.html +++ b/templates/preview.html @@ -1,44 +1,61 @@ - Result + Ingress Migrator - Preview
- -

Migration Result

+

Migration Preview

-

Converted YAML

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

{{ ns }}

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

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 %} +

Original Backup

+
    + {% for ns, files in backup_tree.items() %} +
  • {{ ns }} +
      + {% for f in files %} +
    • + {{ f }} +
    • + {% endfor %} +
    +
  • + {% endfor %} +
-
- 📄 migration-report.md

- ⬇ result.zip
- ⬇ backup.zip + +
+
+ +
+
+ +
+
-
diff --git a/templates/report.html b/templates/report.html index de66ff5..57e5504 100644 --- a/templates/report.html +++ b/templates/report.html @@ -1,2 +1,20 @@ -

Migration Report

-
{{ content }}
+ + + + Migration Report + + + +
+

Migration Report

+ +
+
{{ content }}
+
+ +
+ +
+
+ + diff --git a/templates/view.html b/templates/view.html new file mode 100644 index 0000000..79f7c24 --- /dev/null +++ b/templates/view.html @@ -0,0 +1,33 @@ + + + + {{ title }} + + + +
+

{{ title }}

+ +
+ {% if editable %} +
+ + +
+ + +
+
+ {% else %} +
{{ content }}
+ {% endif %} +
+ + {% if not editable %} +
+ +
+ {% endif %} +
+ + diff --git a/yaml/account.yaml b/yaml/account.yaml new file mode 100644 index 0000000..4fdcac7 --- /dev/null +++ b/yaml/account.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: ingress-migrator +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: ingress-migrator +rules: + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: ingress-migrator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: ingress-migrator +subjects: + - kind: ServiceAccount + name: ingress-migrator + namespace: testapp diff --git a/yaml/configmap.yaml b/yaml/configmap.yaml new file mode 100644 index 0000000..3e67ab9 --- /dev/null +++ b/yaml/configmap.yaml @@ -0,0 +1,57 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ingress-migrator-mapping +data: + mapping.yaml: | + nginx.ingress.kubernetes.io/affinity: + haproxy: haproxy.org/cookie-persistence + support: partial + note: "value 수정 필요 (ex. cookie -> route)" + + nginx.ingress.kubernetes.io/backend-protocol: + haproxy: haproxy.org/server-ssl + support: partial + note: "value 수정 필요 (ex. HTTPS -> true)" + + nginx.ingress.kubernetes.io/force-ssl-redirect: + haproxy: haproxy.org/ssl-redirect + support: full + + nginx.ingress.kubernetes.io/session-cookie-hash: + haproxy: null + support: unsupported + note: "HAProxy는 해시 방식 지정 불필요 (자동 제공)" + + nginx.ingress.kubernetes.io/session-cookie-name: + haproxy: haproxy.org/cookie-persistence + support: full + + nginx.ingress.kubernetes.io/ssl-redirect: + haproxy: haproxy.org/ssl-redirect + support: full + + nginx.ingress.kubernetes.io/server-snippet: + haproxy: haproxy.org/backend-config-snippet + support: partial + note: "value 수정 필요" + + nginx.ingress.kubernetes.io/app-root: + haproxy: haproxy.org/path-rewrite + support: partial + note: "Change Value" + + nginx.ingress.kubernetes.io/configuration-snippet: + haproxy: null + support: unsupported + note: "HAProxy does not support arbitrary NGINX snippets" + + proxy-buffering: + haproxy: null + support: unsupported + note: "HAProxy does not support arbitrary NGINX snippets" + + gzip-level: + haproxy: null + support: unsupported + note: "HAProxy does not support arbitrary NGINX snippets" diff --git a/yaml/deploy.yaml b/yaml/deploy.yaml new file mode 100644 index 0000000..89d8712 --- /dev/null +++ b/yaml/deploy.yaml @@ -0,0 +1,32 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ingress-migrator-web +spec: + replicas: 1 + selector: + matchLabels: + app: ingress-migrator-web + template: + metadata: + labels: + app: ingress-migrator-web + spec: + serviceAccountName: ingress-migrator + containers: + - name: web + image: sa8001/ngtoha:v3 + ports: + - containerPort: 8080 + volumeMounts: + - name: work + mountPath: /work + - name: mapping + mountPath: /app/mapping.yaml + subPath: mapping.yaml + volumes: + - name: work + emptyDir: {} + - name: mapping + configMap: + name: ingress-migrator-mapping diff --git a/yaml/svc.yaml b/yaml/svc.yaml new file mode 100644 index 0000000..9dfa6f5 --- /dev/null +++ b/yaml/svc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: ingress-migrator-web +spec: + selector: + app: ingress-migrator-web + ports: + - port: 80 + targetPort: 8080 + type: NodePort