기능 추가

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

172
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 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/<job_id>/<kind>/<path:path>")
@app.route("/view/<job_id>/<kind>/<path:path>", 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"<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>")
@@ -102,7 +183,52 @@ 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>"
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>")

View File

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

View File

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

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>
<div class="card">
<form method="post">
<label>From ingressClass</label>
<input name="from_class" value="nginx">
<form method="post" class="form-grid">
<div class="field">
<label>From ingressClass</label>
<input name="from_class" value="nginx">
</div>
<label>To ingressClass</label>
<input name="to_class" value="haproxy">
<div class="field">
<label>To ingressClass</label>
<input name="to_class" value="haproxy">
</div>
<label>Namespace (optional)</label>
<input name="namespace">
<div class="field">
<label>Namespace (optional)</label>
<input name="namespace">
</div>
<button type="submit">Run Migration</button>
</form>

View File

@@ -1,44 +1,61 @@
<!DOCTYPE html>
<html>
<head>
<title>Result</title>
<title>Ingress Migrator - Preview</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>Migration Result</h1>
<h1>Migration Preview</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 %}
<h3>Converted YAML</h3>
<ul>
{% for ns, files in yaml_tree.items() %}
<li><strong>{{ ns }}</strong>
<ul>
{% for f in files %}
<li>
<a href="/view/{{ job_id }}/yaml/{{ ns }}/{{ f }}">{{ f }}</a>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
</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 %}
<h3>Original Backup</h3>
<ul>
{% for ns, files in backup_tree.items() %}
<li><strong>{{ ns }}</strong>
<ul>
{% for f in files %}
<li>
<a href="/view/{{ job_id }}/backup/{{ ns }}/{{ f }}">{{ f }}</a>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
</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 class="card actions-row">
<a class="btn" href="/report/{{ job_id }}">View Migration Report</a>
<a class="btn" href="/download/{{ result_zip }}">Download Result 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>
</body>
</html>

View File

@@ -1,2 +1,20 @@
<h2>Migration Report</h2>
<pre>{{ content }}</pre>
<!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>
</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