Files
yakcloud-starter/scripts/yakcloud_migrate.py

305 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""yakcloud datasource migrate — 데이터소스 스냅샷(블랙리스트) 캡처 + 빈 타깃 적용(부트스트랩).
모델(확정):
capture = 소스 전체 native 덤프 블랙리스트(db/<source>/.migrateignore, 글롭) → db/<source>/snapshot/.
**읽기전용**(항상 안전). 커밋 전 '무엇이 잡히는지' 미리보기.
apply = 타깃이 **비어 있을 때만** 복원. 데이터가 있으면 **스킵**(절대 안 덮음, force 없음).
즉 새 환경을 dev 상태로 채우는 부트스트랩/시딩 도구. (prod 적용은 백엔드 경유 — 별도 승인)
dev 채널: 러너를 DB 클라이언트 이미지 one-shot 으로 compose 네트워크에 join → 소스 컨테이너 직접(자격 미노출).
자격은 yakcloud_dev 의 결정적 파생(creds_for)을 그대로 사용. 지원 타입: postgresql·mysql·mariadb·mongodb·minio.
"""
from __future__ import annotations
import argparse
import fnmatch
import json
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import yakcloud_dev as dev # noqa: E402 (동일 scripts/ 의 dev 엔진 헬퍼 재사용)
SNAP = "db" # 프로젝트 루트 db/<source>/ (git 추적 — .gitignore 아님)
IGNORE = ".migrateignore"
SUPPORTED = ("postgresql", "mysql", "mariadb", "mongodb", "minio")
IMG = {"postgresql": "postgres:16-alpine", "mysql": "mysql:8.4", "mariadb": "mariadb:11.4",
"mongodb": "mongo:7", "minio": "minio/mc:latest"}
def die(m: str) -> None:
sys.exit(f"{m}")
def _net(m: dict) -> str:
return f"{dev.compose_project(m)}_default"
def _running(m: dict, source: str) -> bool:
j = dev._ps_state(m).get(source) or {}
return (j.get("State") or "") == "running"
def _srcdir(source: str) -> str:
return os.path.join(SNAP, source)
def _blacklist(source: str) -> list[str]:
p = os.path.join(_srcdir(source), IGNORE)
if not os.path.exists(p):
return []
out = []
for ln in open(p):
ln = ln.split("#", 1)[0].strip()
if ln:
out.append(ln)
return out
def _excluded(name: str, patterns: list[str]) -> bool:
return any(fnmatch.fnmatch(name, pat) for pat in patterns)
def drun(net: str, image: str, shell: str, mounts=None, capture=True, check=True):
"""docker run one-shot(sh -c) — 모든 명령을 쉘 경유(리다이렉트/파이프 지원)."""
args = ["docker", "run", "--rm", "--network", net]
for host, cont in (mounts or []):
os.makedirs(host, exist_ok=True)
args += ["-v", f"{os.path.abspath(host)}:{cont}"]
args += ["--entrypoint", "sh", image, "-c", shell]
r = subprocess.run(args, text=True,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None)
if check and r.returncode != 0:
die(f"클라이언트 실행 실패({r.returncode}): {(r.stderr or r.stdout or '').strip()[:400]}")
return r
# ── 타입별: 대상 단위 나열(미리보기·글롭 확장용) ────────────────────────────
def list_units(m: dict, source: str, stype: str, cr: dict) -> list[str]:
net = _net(m)
if stype == "postgresql":
url = f"postgresql://{cr['user']}:{cr['password']}@{source}:5432/{cr['db']}"
q = "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
r = drun(net, IMG[stype], f"psql '{url}' -tAc \"{q}\"", check=False)
elif stype in ("mysql", "mariadb"):
q = f"SELECT table_name FROM information_schema.tables WHERE table_schema='{cr['db']}'"
r = drun(net, IMG[stype],
f"mysql -h {source} -u{cr['user']} -p{cr['password']} -N -B -e \"{q}\" 2>/dev/null", check=False)
elif stype == "mongodb":
js = "db.getCollectionNames().filter(c=>!c.startsWith('_yakcloud')).join('\\n')"
r = drun(net, IMG[stype],
f"mongosh --quiet --host {source} -u {cr['user']} -p {cr['password']} "
f"--authenticationDatabase {cr['db']} {cr['db']} --eval \"{js}\"", check=False)
elif stype == "minio":
# minio/mc 이미지엔 awk 가 없음 → --json 으로 받아 파이썬에서 key 파싱.
setup = f"mc alias set s http://{source}:9000 {cr['access']} {cr['secret']} >/dev/null 2>&1"
r = drun(net, IMG[stype],
f"{setup} && mc ls --recursive --json s/{cr['bucket']} 2>/dev/null", check=False)
keys = []
for ln in (r.stdout or "").splitlines():
ln = ln.strip()
if not ln:
continue
try:
j = json.loads(ln)
if j.get("key"):
keys.append(j["key"])
except Exception:
pass
return keys
else:
return []
return [x.strip() for x in (r.stdout or "").splitlines() if x.strip() and "Warning" not in x]
# ── 미리보기 ────────────────────────────────────────────────────────────
def preview(m: dict, targets: list[tuple[str, str]]) -> dict:
seed = dev.ensure_seed()
plan = {}
print("캡처 미리보기 (읽기전용) — ✓=포함, ✗=제외(블랙리스트)")
for source, stype in targets:
cr = dev.creds_for(seed, source, stype)
bl = _blacklist(source)
units = list_units(m, source, stype, cr)
inc = [u for u in units if not _excluded(u, bl)]
exc = [u for u in units if _excluded(u, bl)]
plan[source] = {"type": stype, "include": inc, "exclude": exc, "blacklist": bl}
unit_word = {"minio": "오브젝트"}.get(stype, "테이블/컬렉션")
print(f"\n{source} ({stype}) — {unit_word} {len(units)}개 · 포함 {len(inc)} / 제외 {len(exc)}")
for u in inc[:20]:
print(f"{u}")
if len(inc) > 20:
print(f" … 외 {len(inc)-20}")
for u in exc:
print(f"{u} (블랙리스트)")
if not bl:
print(f" · 블랙리스트 없음 → 전부 포함. 민감/개인정보·쓰레기는 "
f"{os.path.join(_srcdir(source), IGNORE)} 에 글롭으로 제외 권장")
return plan
# ── 캡처(전체 native 덤프 블랙리스트) ───────────────────────────────────
def capture_source(m: dict, source: str, stype: str, cr: dict) -> dict:
net, outdir = _net(m), os.path.join(_srcdir(source), "snapshot")
os.makedirs(outdir, exist_ok=True)
bl = _blacklist(source)
units = list_units(m, source, stype, cr)
excluded = [u for u in units if _excluded(u, bl)]
info = {"type": stype, "excluded": excluded, "blacklist": bl}
if stype == "postgresql":
url = f"postgresql://{cr['user']}:{cr['password']}@{source}:5432/{cr['db']}"
# pg_dump 는 패턴(*)을 지원 → 블랙리스트 글롭을 그대로 넘겨 소유 시퀀스(<tbl>_id_seq 등)까지 제외.
ex = " ".join(f"--exclude-table='{g}'" for g in bl)
drun(net, IMG[stype], f"pg_dump '{url}' {ex} -f /out/snapshot.sql", [(outdir, "/out")])
info["file"] = "snapshot.sql"
elif stype in ("mysql", "mariadb"):
ig = " ".join(f"--ignore-table={cr['db']}.{e}" for e in excluded)
drun(net, IMG[stype],
f"mysqldump -h {source} -u{cr['user']} -p{cr['password']} --skip-comments {ig} "
f"{cr['db']} > /out/snapshot.sql", [(outdir, "/out")])
info["file"] = "snapshot.sql"
elif stype == "mongodb":
ex = " ".join(f"--excludeCollection={e}" for e in excluded)
drun(net, IMG[stype],
f"mongodump --host {source} -u {cr['user']} -p {cr['password']} "
f"--authenticationDatabase {cr['db']} --db {cr['db']} {ex} --archive=/out/dump.archive",
[(outdir, "/out")])
info["file"] = "dump.archive"
elif stype == "minio":
setup = f"mc alias set s http://{source}:9000 {cr['access']} {cr['secret']} >/dev/null"
exflag = " ".join(f"--exclude '{e}'" for e in bl)
drun(net, IMG[stype],
f"{setup} && mc mirror --overwrite {exflag} s/{cr['bucket']} /out/bucket", [(outdir, "/out")])
info["file"], info["bucket"] = "bucket/", cr["bucket"]
else:
die(f"미지원 타입(캡처): {stype}")
json.dump(info, open(os.path.join(outdir, "manifest.json"), "w"), ensure_ascii=False, indent=2)
return info
# ── 타깃 비어있음 검사 + 적용(부트스트랩) ─────────────────────────────────
def is_empty(m: dict, source: str, stype: str, cr: dict) -> bool:
return len(list_units(m, source, stype, cr)) == 0
def apply_source(m: dict, source: str, stype: str, cr: dict) -> str:
net, outdir = _net(m), os.path.join(_srcdir(source), "snapshot")
if not os.path.isdir(outdir):
return "스냅샷 없음(먼저 capture)"
if not is_empty(m, source, stype, cr):
return "타깃에 데이터 있음 → 스킵(덮지 않음)"
if stype == "postgresql":
url = f"postgresql://{cr['user']}:{cr['password']}@{source}:5432/{cr['db']}"
drun(net, IMG[stype], f"psql '{url}' -v ON_ERROR_STOP=1 -f /out/snapshot.sql", [(outdir, "/out")])
elif stype in ("mysql", "mariadb"):
drun(net, IMG[stype],
f"mysql -h {source} -u{cr['user']} -p{cr['password']} {cr['db']} < /out/snapshot.sql",
[(outdir, "/out")])
elif stype == "mongodb":
drun(net, IMG[stype],
f"mongorestore --host {source} -u {cr['user']} -p {cr['password']} "
f"--authenticationDatabase {cr['db']} --archive=/out/dump.archive", [(outdir, "/out")])
elif stype == "minio":
setup = f"mc alias set s http://{source}:9000 {cr['access']} {cr['secret']} >/dev/null"
drun(net, IMG[stype],
f"{setup} && (mc mb -p s/{cr['bucket']} 2>/dev/null || true) && "
f"mc mirror --overwrite /out/bucket s/{cr['bucket']}", [(outdir, "/out")])
else:
die(f"미지원 타입(적용): {stype}")
return "적용 완료(부트스트랩)"
def select_targets(m: dict, names: list[str], require_running: bool) -> list[tuple[str, str]]:
reqs = {r["name"]: r["type"] for r in dev.requires(m)}
if names:
miss = [n for n in names if n not in reqs]
if miss:
die(f"requires 에 없음: {', '.join(miss)}")
chosen = [(n, reqs[n]) for n in names]
else:
chosen = list(reqs.items())
out = []
for s, t in chosen:
if t not in SUPPORTED:
print(f" · {s}({t}) — 마이그레이션 미지원(다음 단계: redis/solr/rabbitmq/oracle). 건너뜀")
continue
if require_running and not _running(m, s):
print(f" · {s} — dev 컨테이너 미기동('yakcloud dev up {s}' 먼저). 건너뜀")
continue
out.append((s, t))
if not out:
die("대상 소스가 없습니다.")
return out
# ── 명령 ────────────────────────────────────────────────────────────────
def cmd_capture(a) -> None:
m = dev.load_manifest()
targets = select_targets(m, a.source, require_running=True)
preview(m, targets)
if a.dry_run:
print("\n(미리보기 전용 — 캡처 안 함)")
return
if not a.yes and sys.stdin.isatty():
if input("\n위 대상을 db/<source>/snapshot/ 에 캡처할까요? [y/N] ").strip().lower() not in ("y", "yes"):
print("취소.")
return
seed = dev.ensure_seed()
for source, stype in targets:
cr = dev.creds_for(seed, source, stype)
info = capture_source(m, source, stype, cr)
print(f"{source} 캡처 → {os.path.join(_srcdir(source), 'snapshot', info['file'])}"
f" (제외 {len(info['excluded'])})")
print(" · db/ 를 커밋하세요(리뷰 대상). 적용: 'yakcloud datasource migrate apply'(빈 타깃만)")
def cmd_apply(a) -> None:
m = dev.load_manifest()
targets = select_targets(m, a.source, require_running=True)
seed = dev.ensure_seed()
for source, stype in targets:
cr = dev.creds_for(seed, source, stype)
print(f"{source} ({stype}): {apply_source(m, source, stype, cr)}")
print(" · prod 적용은 콘솔 소스관리자 컨펌 + 백업 후(롤백 보장) — 별도 경로")
def cmd_status(a) -> None:
m = dev.load_manifest()
reqs = {r["name"]: r["type"] for r in dev.requires(m)}
names = a.source or list(reqs)
print("migrate 스냅샷 상태")
for s in names:
t = reqs.get(s, "?")
snap = os.path.join(_srcdir(s), "snapshot", "manifest.json")
bl = _blacklist(s)
if os.path.exists(snap):
info = json.load(open(snap))
print(f" {s} ({t}): 스냅샷 있음(제외 {len(info.get('excluded', []))}) · 블랙리스트 {len(bl)}")
else:
print(f" {s} ({t}): 스냅샷 없음 · 블랙리스트 {len(bl)}")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="yakcloud datasource migrate")
sub = p.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("capture", help="소스 전체 상태 블랙리스트 를 db/<source>/snapshot/ 에 캡처(읽기전용)")
c.add_argument("source", nargs="*")
c.add_argument("--dry-run", action="store_true", help="미리보기만")
c.add_argument("-y", "--yes", action="store_true", help="확인 없이")
c.set_defaults(fn=cmd_capture)
ap = sub.add_parser("apply", help="스냅샷을 타깃에 적용(빈 타깃만, 데이터 있으면 스킵)")
ap.add_argument("source", nargs="*")
ap.set_defaults(fn=cmd_apply)
st = sub.add_parser("status", help="스냅샷·블랙리스트 상태")
st.add_argument("source", nargs="*")
st.set_defaults(fn=cmd_status)
return p
if __name__ == "__main__":
args = build_parser().parse_args()
args.fn(args)