기능 추가

This commit is contained in:
2026-01-05 13:15:49 +09:00
parent 9c7ba65c32
commit dcdcfecc40
12 changed files with 525 additions and 81 deletions

166
app.py
View File

@@ -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 subprocess
import uuid import uuid
import shutil
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
import zipfile import zipfile
import yaml # ⭐ 추가
#app = Flask(__name__)
app = Flask(__name__, static_folder="static") app = Flask(__name__, static_folder="static")
BASE = Path("/work") BASE = Path("/work")
@@ -17,12 +19,61 @@ def ts():
return datetime.now().strftime("%Y-%m-%d-%H-%M-%S") 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"]) @app.route("/", methods=["GET", "POST"])
def index(): def index():
if request.method == "POST": if request.method == "POST":
job_id = str(uuid.uuid4()) job_id = str(uuid.uuid4())
job_dir = BASE / job_id job_dir = BASE / job_id
job_dir.mkdir(parents=True) job_dir.mkdir(parents=True, exist_ok=True)
cmd = [ cmd = [
"python3", "python3",
@@ -33,33 +84,41 @@ def index():
if request.form.get("from_class"): if request.form.get("from_class"):
cmd += ["--from-ingress-class", request.form["from_class"]] cmd += ["--from-ingress-class", request.form["from_class"]]
if request.form.get("to_class"): if request.form.get("to_class"):
cmd += ["--ingress-class", request.form["to_class"]] cmd += ["--ingress-class", request.form["to_class"]]
if request.form.get("namespace"): if request.form.get("namespace"):
cmd += ["--namespace", request.form["namespace"]] cmd += ["--namespace", request.form["namespace"]]
subprocess.check_call(cmd, cwd=job_dir) 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" 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: with zipfile.ZipFile(result_zip, "w", zipfile.ZIP_DEFLATED) as z:
for p in (job_dir / "yaml").rglob("*"): for p in (job_dir / "yaml").rglob("*"):
if p.is_file():
z.write(p, p.relative_to(job_dir)) z.write(p, p.relative_to(job_dir))
report = job_dir / "migration-report.md" report = job_dir / "migration-report.md"
if report.exists(): if report.exists():
z.write(report, report.relative_to(job_dir)) z.write(report, report.relative_to(job_dir))
# backup.zip # backup zip
backup_zip = BASE / f"{timestamp}-backup.zip"
with zipfile.ZipFile(backup_zip, "w", zipfile.ZIP_DEFLATED) as z: with zipfile.ZipFile(backup_zip, "w", zipfile.ZIP_DEFLATED) as z:
for p in (job_dir / "backup").rglob("*"): for p in (job_dir / "backup").rglob("*"):
if p.is_file():
z.write(p, p.relative_to(job_dir)) 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") return render_template("index.html")
@@ -68,13 +127,14 @@ def index():
def preview(job_id, result_zip, backup_zip): def preview(job_id, result_zip, backup_zip):
job_dir = BASE / job_id job_dir = BASE / job_id
def tree(root: Path): def tree(root):
t = {} t = {}
if not root.exists(): if root.exists():
return t for ns in root.iterdir():
for ns in sorted(root.iterdir()):
if ns.is_dir(): if ns.is_dir():
t[ns.name] = sorted(f.name for f in ns.iterdir() if f.is_file()) t[ns.name] = sorted(
f.name for f in ns.iterdir() if f.is_file()
)
return t return t
return render_template( return render_template(
@@ -87,14 +147,35 @@ def preview(job_id, result_zip, backup_zip):
) )
@app.route("/view/<job_id>/<kind>/<path:path>") @app.route("/view/<job_id>/<kind>/<path:path>", methods=["GET", "POST"])
def view_file(job_id, kind, path): def view_file(job_id, kind, path):
if kind not in ("yaml", "backup"): if kind not in ("yaml", "backup"):
abort(404) abort(404)
f = BASE / job_id / kind / path f = BASE / job_id / kind / path
if not f.exists(): if not f.exists() or not f.is_file():
abort(404) abort(404)
return f"<pre>{f.read_text()}</pre>"
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/<job_id>") @app.route("/report/<job_id>")
@@ -102,7 +183,52 @@ def view_report(job_id):
report = BASE / job_id / "migration-report.md" report = BASE / job_id / "migration-report.md"
if not report.exists(): if not report.exists():
abort(404) abort(404)
return f"<pre>{report.read_text()}</pre>"
return render_template(
"view.html",
title="Migration Report",
content=report.read_text()
)
@app.route("/apply/<job_id>/<kind>", 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/<name>") @app.route("/download/<name>")

View File

@@ -1,3 +1,4 @@
flask flask
ruamel.yaml ruamel.yaml
gunicorn gunicorn
PyYAML

View File

@@ -1,12 +1,24 @@
* {
box-sizing: border-box;
}
body { body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
margin: 0; margin: 0;
background: #0f172a; font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: #020617;
color: #e5e7eb; color: #e5e7eb;
} }
body::before {
content: "";
position: fixed;
inset: 0;
background: linear-gradient(180deg, #0b1220, #020617);
z-index: -1;
}
.container { .container {
max-width: 1100px; max-width: 1400px;
margin: 40px auto; margin: 40px auto;
padding: 24px; padding: 24px;
} }
@@ -14,43 +26,85 @@ body {
.card { .card {
background: #020617; background: #020617;
border: 1px solid #1e293b; border: 1px solid #1e293b;
border-radius: 10px; border-radius: 14px;
padding: 20px; padding: 24px;
margin-bottom: 24px; margin-bottom: 24px;
} }
h1, h2, h3, h4 { h1 {
margin-top: 0; 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 { label {
font-size: 14px; font-size: 14px;
color: #94a3b8; color: #94a3b8;
margin-bottom: 6px;
} }
input { input {
width: 100%; height: 44px;
padding: 10px; padding: 10px 12px;
margin: 6px 0 14px; font-size: 15px;
border-radius: 6px; border-radius: 8px;
border: 1px solid #334155; border: 1px solid #334155;
background: #020617; background: #020617;
color: #e5e7eb; color: #e5e7eb;
} }
input:focus {
outline: none;
border-color: #3b82f6;
}
button { button {
background: #2563eb; height: 44px;
color: white; padding: 0 26px;
border-radius: 10px;
border: none; border: none;
padding: 10px 18px; font-size: 15px;
border-radius: 6px; font-weight: 600;
cursor: pointer; cursor: pointer;
background: #2563eb;
color: #fff;
white-space: nowrap;
} }
button:hover { button:hover {
background: #1d4ed8; 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 { a {
color: #60a5fa; color: #60a5fa;
text-decoration: none; text-decoration: none;
@@ -60,14 +114,49 @@ a:hover {
text-decoration: underline; text-decoration: underline;
} }
ul { .actions-row {
padding-left: 18px; display: flex;
gap: 14px;
flex-wrap: wrap;
} }
pre { .btn {
background: #020617; display: inline-flex;
border: 1px solid #1e293b; align-items: center;
border-radius: 8px; justify-content: center;
padding: 16px; height: 44px;
overflow-x: auto; 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;
} }

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>{{ title }}</h1>
<div class="card">
<p><strong>Status:</strong> {{ status }}</p>
<p><strong>Command:</strong></p>
<pre>{{ command }}</pre>
</div>
<div class="card">
<h3>kubectl output</h3>
<pre>{{ output }}</pre>
</div>
<div class="card actions-row">
<button onclick="history.back()">Back</button>
<a class="btn" href="/">Home</a>
</div>
</div>
</body>
</html>

View File

@@ -9,15 +9,21 @@
<h1>Ingress Migrator</h1> <h1>Ingress Migrator</h1>
<div class="card"> <div class="card">
<form method="post"> <form method="post" class="form-grid">
<div class="field">
<label>From ingressClass</label> <label>From ingressClass</label>
<input name="from_class" value="nginx"> <input name="from_class" value="nginx">
</div>
<div class="field">
<label>To ingressClass</label> <label>To ingressClass</label>
<input name="to_class" value="haproxy"> <input name="to_class" value="haproxy">
</div>
<div class="field">
<label>Namespace (optional)</label> <label>Namespace (optional)</label>
<input name="namespace"> <input name="namespace">
</div>
<button type="submit">Run Migration</button> <button type="submit">Run Migration</button>
</form> </form>

View File

@@ -1,44 +1,61 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>Result</title> <title>Ingress Migrator - Preview</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>Migration Preview</h1>
<h1>Migration Result</h1>
<div class="card"> <div class="card">
<h2>Converted YAML</h2> <h3>Converted YAML</h3>
<ul>
{% for ns, files in yaml_tree.items() %} {% for ns, files in yaml_tree.items() %}
<h4>{{ ns }}</h4> <li><strong>{{ ns }}</strong>
<ul> <ul>
{% for f in files %} {% for f in files %}
<li><a href="/view/{{ job_id }}/yaml/{{ ns }}/{{ f }}">{{ f }}</a></li> <li>
<a href="/view/{{ job_id }}/yaml/{{ ns }}/{{ f }}">{{ f }}</a>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</li>
{% endfor %} {% endfor %}
</ul>
</div> </div>
<div class="card"> <div class="card">
<h2>Original Backup</h2> <h3>Original Backup</h3>
<ul>
{% for ns, files in backup_tree.items() %} {% for ns, files in backup_tree.items() %}
<h4>{{ ns }}</h4> <li><strong>{{ ns }}</strong>
<ul> <ul>
{% for f in files %} {% for f in files %}
<li><a href="/view/{{ job_id }}/backup/{{ ns }}/{{ f }}">{{ f }}</a></li> <li>
<a href="/view/{{ job_id }}/backup/{{ ns }}/{{ f }}">{{ f }}</a>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</li>
{% endfor %} {% endfor %}
</ul>
</div> </div>
<div class="card"> <div class="card actions-row">
<a href="/report/{{ job_id }}">📄 migration-report.md</a><br><br> <a class="btn" href="/report/{{ job_id }}">View Migration Report</a>
<a href="/download/{{ result_zip }}">⬇ result.zip</a><br> <a class="btn" href="/download/{{ result_zip }}">Download Result ZIP</a>
<a href="/download/{{ backup_zip }}">⬇ backup.zip</a> <a class="btn" href="/download/{{ backup_zip }}">Download Backup ZIP</a>
</div>
<div class="card actions-row">
<form method="post" action="/apply/{{ job_id }}/yaml">
<button class="btn btn-danger" type="submit">Replace Converted YAML</button>
</form>
<form method="post" action="/apply/{{ job_id }}/backup">
<button class="btn" type="submit">Rollback from Backup</button>
</form>
<button onclick="history.back()">Back</button>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,2 +1,20 @@
<h2>Migration Report</h2> <!DOCTYPE html>
<html>
<head>
<title>Migration Report</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>Migration Report</h1>
<div class="card">
<pre>{{ content }}</pre> <pre>{{ content }}</pre>
</div>
<div class="card actions-row">
<button class="btn" onclick="history.back()">Back</button>
</div>
</div>
</body>
</html>

33
templates/view.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h2>{{ title }}</h2>
<div class="card">
{% if editable %}
<form method="post">
<textarea name="content" class="editor">{{ content }}</textarea>
<div class="actions-row" style="margin-top:16px;">
<button type="submit">Save</button>
<button type="button" onclick="history.back()">Cancel</button>
</div>
</form>
{% else %}
<pre>{{ content }}</pre>
{% endif %}
</div>
{% if not editable %}
<div class="card actions-row">
<button onclick="history.back()">Back</button>
</div>
{% endif %}
</div>
</body>
</html>

26
yaml/account.yaml Normal file
View File

@@ -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

57
yaml/configmap.yaml Normal file
View File

@@ -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"

32
yaml/deploy.yaml Normal file
View File

@@ -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

11
yaml/svc.yaml Normal file
View File

@@ -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