diff --git a/.claude/skills/yakcloud-deploy/SKILL.md b/.claude/skills/yakcloud-deploy/SKILL.md index 32bdc59..f1b9e9a 100644 --- a/.claude/skills/yakcloud-deploy/SKILL.md +++ b/.claude/skills/yakcloud-deploy/SKILL.md @@ -32,7 +32,7 @@ YakCloud 콘솔 API로 **데이터소스 리컨실 + 워크로드 배포 + 바 - `yakcloud dev up` — `requires[]` 를 로컬 docker 로 기동 + `.env.dev`(프로덕션과 **바이트 동일**한 `_*`) 생성 - `yakcloud dev run -- <명령>` — up 보장 후 `.env.dev` 주입해 앱 실행(배포된 파드와 동일 env → 무수정 이식) - `yakcloud dev status|logs|down [--volumes]|reset|doctor` — 자격은 시드에서 결정적 파생(`.yakcloud/dev/`, gitignore) -- `yakcloud datasource migrate capture|apply|status` — 블랙리스트(`db//.migrateignore`) 스냅샷: 전체−제외 캡처(읽기전용), **빈 타깃만** 적용(부트스트랩). prod는 콘솔 소스관리자 컨펌+백업 후 +- `yakcloud datasource migrate capture|apply|status` — 블랙리스트(`db//.migrateignore`) 스냅샷: 전체−제외 캡처(읽기전용), **빈 타깃만** 적용(부트스트랩). 지원 8종(SQL 3·mongo·minio·redis·solr·rabbitmq, oracle=다음). prod는 콘솔 소스관리자 컨펌+백업 후 **배포환경 설정(앱별) — 매니페스트 수정 + 배포중이면 재빌드 없이 라이브 반영** - `yakcloud domain [wl]` 도메인 등록 + 워크로드 할당 diff --git a/DATA-SOURCES.md b/DATA-SOURCES.md index 0bb5402..6921785 100644 --- a/DATA-SOURCES.md +++ b/DATA-SOURCES.md @@ -57,7 +57,7 @@ yakcloud datasource migrate apply [source…] # 빈 타깃만 복 yakcloud datasource migrate status ``` - **블랙리스트**: `db//.migrateignore` (글롭, git 추적). 예: `audit_log_*`, `tmp_*`, `cache/*`. 민감/개인정보·쓰레기는 반드시 여기에. -- **capture=읽기전용**(안전), 전체 native 덤프(pg_dump/mysqldump/mongodump/mc mirror). 지원: postgres·mysql·mariadb·mongodb·minio(redis/solr/rabbitmq/oracle=다음 단계). +- **capture=읽기전용**(안전), 전체 native 덤프(pg_dump/mysqldump/mongodump/mc mirror/redis DUMP/solr export/rabbitmq definitions). 지원: postgres·mysql·mariadb·mongodb·minio·redis·solr·rabbitmq(oracle=다음 단계). 블랙리스트 단위: SQL=테이블, mongo=컬렉션, minio=오브젝트, redis=키, solr=문서(쿼리절), rabbitmq=큐/익스체인지. rabbitmq는 정의만(메시지 제외). - **apply=빈 타깃만**. 데이터가 있으면 **절대 안 덮음**(스킵). 라이브 prod 지속 동기화가 아님. - **db/** 는 커밋·리뷰 대상(`.env.dev`·`.yakcloud/` 와 반대로 gitignore 아님). - prod 적용은 콘솔 종류별 소스관리자 **컨펌 + 항상 백업 후(롤백 보장)** — CLI 로 prod 직접 덮지 않음. diff --git a/bin/yakcloud b/bin/yakcloud index 135a49c..119a677 100755 --- a/bin/yakcloud +++ b/bin/yakcloud @@ -18,7 +18,7 @@ set -uo pipefail # -e 미사용: 'test && action' 관용구가 값 없을 때 REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}" BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}" -VERSION="0.8.0" +VERSION="0.9.0" CONFIG_DIR="${YAKCLOUD_CONFIG_DIR:-$HOME/.config/yakcloud}" CONFIG_FILE="$CONFIG_DIR/config" diff --git a/scripts/yakcloud_migrate.py b/scripts/yakcloud_migrate.py index 612fdf0..931f8e1 100644 --- a/scripts/yakcloud_migrate.py +++ b/scripts/yakcloud_migrate.py @@ -24,9 +24,48 @@ import yakcloud_dev as dev # noqa: E402 (동일 scripts/ 의 dev 엔진 헬퍼 SNAP = "db" # 프로젝트 루트 db// (git 추적 — .gitignore 아님) IGNORE = ".migrateignore" -SUPPORTED = ("postgresql", "mysql", "mariadb", "mongodb", "minio") +SUPPORTED = ("postgresql", "mysql", "mariadb", "mongodb", "minio", "redis", "solr", "rabbitmq") IMG = {"postgresql": "postgres:16-alpine", "mysql": "mysql:8.4", "mariadb": "mariadb:11.4", - "mongodb": "mongo:7", "minio": "minio/mc:latest"} + "mongodb": "mongo:7", "minio": "minio/mc:latest", + "redis": "redis:7-alpine", "solr": "solr:9", "rabbitmq": "rabbitmq:3.13-management"} +UNSUPPORTED_NEXT = ("oracle",) # 다음 단계(무거움/설계 보완 필요) + + +def _fill(tmpl: str, **kw) -> str: + """플레이스홀더 치환 — f-string/.format 대신(셸의 \\r\\n·$7·${..} 이스케이프를 보존).""" + for k, v in kw.items(): + tmpl = tmpl.replace("{" + k + "}", str(v)) + return tmpl + + +# redis 캡처: SCAN → 블랙리스트(키 글롭) 클라이언트 제외 → 키별 PTTL+DUMP(base64) → keys.b64. 읽기전용. +_REDIS_CAPTURE = r''' +R="redis-cli -h {host} -a {pw} --no-auth-warning -n {db}"; EX="{bl}"; : > /out/keys.b64 +$R --scan | while IFS= read -r k; do + [ -z "$k" ] && continue + skip=0; for pat in $EX; do case "$k" in $pat) skip=1;; esac; done; [ "$skip" = 1 ] && continue + pttl=$($R PTTL "$k"); case "$pttl" in -*) pttl=0;; esac + kb=$(printf "%s" "$k" | base64 | tr -d "\n") + vb=$($R DUMP "$k" | head -c -1 | base64 | tr -d "\n") + printf "%s\t%s\t%s\n" "$pttl" "$kb" "$vb" >> /out/keys.b64 +done +''' + +# redis 복원: keys.b64 → RESP RESTORE(REPLACE) 스트림 → redis-cli --pipe(바이너리 안전). 빈 타깃 전제. +_REDIS_RESTORE = r''' +R="redis-cli -h {host} -a {pw} --no-auth-warning -n {db}"; TAB=$(printf '\t') +{ while IFS="$TAB" read -r pttl kb vb; do + [ -z "$kb" ] && continue + k=$(printf "%s" "$kb" | base64 -d) + vlen=$(printf "%s" "$vb" | base64 -d | wc -c | tr -d ' ') + klen=$(printf "%s" "$k" | wc -c | tr -d ' '); tlen=$(printf "%s" "$pttl" | wc -c | tr -d ' ') + printf '*5\r\n$7\r\nRESTORE\r\n' + printf '$%s\r\n%s\r\n' "$klen" "$k" + printf '$%s\r\n%s\r\n' "$tlen" "$pttl" + printf '$%s\r\n' "$vlen"; printf "%s" "$vb" | base64 -d; printf '\r\n' + printf '$7\r\nREPLACE\r\n' + done < /out/keys.b64; } | $R --pipe +''' def die(m: str) -> None: @@ -110,11 +149,94 @@ def list_units(m: dict, source: str, stype: str, cr: dict) -> list[str]: except Exception: pass return keys + elif stype == "redis": + r = drun(net, IMG[stype], + f"redis-cli -h {source} -a {cr['password']} --no-auth-warning -n {cr['db']} --scan", check=False) + return [x for x in (r.stdout or "").splitlines() if x.strip()] + elif stype == "rabbitmq": + ra = (f"rabbitmqadmin -H {source} -P 15672 -u {cr['user']} -p {cr['password']} " + f"-V {cr['vhost']} -f tsv -q") + q = drun(net, IMG[stype], f"{ra} list queues name", check=False) + e = drun(net, IMG[stype], f"{ra} list exchanges name", check=False) + qn = [x.strip() for x in (q.stdout or "").splitlines() if x.strip() and x.strip() != "name"] + en = [x.strip() for x in (e.stdout or "").splitlines() + if x.strip() and x.strip() != "name" and not x.strip().startswith("amq.")] + return qn + en + elif stype == "solr": + # source=core 1:1 → 단위='문서'. is_empty/미리보기용 표본 id(최대 10). 전체 수는 _solr_numfound. + r = drun(net, IMG[stype], + f'curl -sS "http://{source}:8983/solr/{cr["core"]}/select?q=*:*&rows=10&fl=id&wt=json"', check=False) + try: + return [str(d.get("id", "?")) for d in json.loads(r.stdout or "{}").get("response", {}).get("docs", [])] + except Exception: + return [] else: return [] return [x.strip() for x in (r.stdout or "").splitlines() if x.strip() and "Warning" not in x] +def _solr_numfound(m: dict, source: str, cr: dict) -> int: + r = drun(_net(m), IMG["solr"], + f'curl -sS "http://{source}:8983/solr/{cr["core"]}/select?q=*:*&rows=0&wt=json"', check=False) + try: + return int(json.loads(r.stdout or "{}").get("response", {}).get("numFound", 0)) + except Exception: + return 0 + + +def _rabbit_filter(path: str, bl: list[str], info: dict) -> None: + """rabbitmq defs.json 에서 블랙리스트(큐/익스체인지 글롭) 항목 + 참조 bindings 를 제거.""" + d = json.load(open(path)) + rmq = {q["name"] for q in d.get("queues", []) if _excluded(q.get("name", ""), bl)} + rmx = {x["name"] for x in d.get("exchanges", []) if _excluded(x.get("name", ""), bl)} + d["queues"] = [q for q in d.get("queues", []) if q.get("name", "") not in rmq] + d["exchanges"] = [x for x in d.get("exchanges", []) if x.get("name", "") not in rmx] + keep = [] + for b in d.get("bindings", []): + if b.get("source", "") in rmx: + continue + dt, dn = b.get("destination_type", ""), b.get("destination", "") + if dt == "queue" and dn in rmq: + continue + if dt == "exchange" and dn in rmx: + continue + keep.append(b) + d["bindings"] = keep + json.dump(d, open(path, "w"), ensure_ascii=False, indent=2) + info["excluded"] = sorted(rmq | rmx) + + +def _solr_capture(net: str, source: str, cr: dict, bl: list[str], outdir: str) -> dict: + """cursorMark 딥페이징으로 전체 문서 export(블랙리스트=서버측 fq 제외), 내부필드 제거 → docs.json.""" + core = cr["core"] + blfq = "" + if bl: + expr = " OR ".join(f"( {ln} )" for ln in bl) + blfq = f"--data-urlencode 'fq=-({expr})'" + internal = {"_version_", "_root_", "_nest_path_", "_nest_parent_"} + docs, mark, n = [], "*", 0 + while True: + cmd = (f'curl -sS "http://{source}:8983/solr/{core}/select" -G --data-urlencode "q=*:*" {blfq} ' + f'--data-urlencode "fl=*" --data-urlencode "sort=id asc" --data-urlencode "rows=1000" ' + f'--data-urlencode "cursorMark={mark}" --data-urlencode "wt=json" -o /out/_page.json') + drun(net, IMG["solr"], cmd, [(outdir, "/out")]) + j = json.load(open(os.path.join(outdir, "_page.json"))) + for doc in j.get("response", {}).get("docs", []): + docs.append({k: v for k, v in doc.items() if k not in internal}) + nxt = j.get("nextCursorMark", mark) + if nxt == mark: + break + mark = nxt + n += 1 + p = os.path.join(outdir, "_page.json") + if os.path.exists(p): + os.remove(p) + json.dump(docs, open(os.path.join(outdir, "docs.json"), "w"), ensure_ascii=False) + drun(net, IMG["solr"], f'curl -sS "http://{source}:8983/solr/{core}/schema?wt=json" -o /out/schema.json', + [(outdir, "/out")]) + return {"file": "docs.json", "captured": len(docs)} + + # ── 미리보기 ──────────────────────────────────────────────────────────── def preview(m: dict, targets: list[tuple[str, str]]) -> dict: seed = dev.ensure_seed() @@ -123,11 +245,20 @@ def preview(m: dict, targets: list[tuple[str, str]]) -> dict: for source, stype in targets: cr = dev.creds_for(seed, source, stype) bl = _blacklist(source) + if stype == "solr": # solr 블랙리스트=서버측 fq(문서 필터), fnmatch 미적용 → 문서 수만 표시 + nf = _solr_numfound(m, source, cr) + plan[source] = {"type": stype, "numFound": nf, "blacklist": bl} + print(f"\n ● {source} (solr) — 문서 {nf}개" + + (f" · 블랙리스트 {len(bl)}줄(서버측 fq 로 제외)" if bl else "")) + if not bl: + print(f" · 블랙리스트 없음 → 전 문서 포함. 제외는 " + f"{os.path.join(_srcdir(source), IGNORE)} 에 Solr 쿼리절(한 줄에 하나)로") + continue 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, "테이블/컬렉션") + unit_word = {"minio": "오브젝트", "redis": "키", "rabbitmq": "큐/익스체인지"}.get(stype, "테이블/컬렉션") print(f"\n ● {source} ({stype}) — {unit_word} {len(units)}개 · 포함 {len(inc)} / 제외 {len(exc)}") for u in inc[:20]: print(f" ✓ {u}") @@ -146,8 +277,11 @@ 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)] + if stype == "solr": # solr 는 fq(서버측) 제외라 fnmatch 단위 계산 안 함 + units, excluded = [], [] + else: + 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']}" @@ -174,6 +308,19 @@ def capture_source(m: dict, source: str, stype: str, cr: dict) -> dict: drun(net, IMG[stype], f"{setup} && mc mirror --overwrite {exflag} s/{cr['bucket']} /out/bucket", [(outdir, "/out")]) info["file"], info["bucket"] = "bucket/", cr["bucket"] + elif stype == "redis": + drun(net, IMG[stype], _fill(_REDIS_CAPTURE, host=source, pw=cr["password"], db=cr["db"], bl=" ".join(bl)), + [(outdir, "/out")]) + info["file"] = "keys.b64" + elif stype == "rabbitmq": + drun(net, IMG[stype], + f"rabbitmqadmin -H {source} -P 15672 -u {cr['user']} -p {cr['password']} -V {cr['vhost']} " + f"export /out/defs.json", [(outdir, "/out")]) + _rabbit_filter(os.path.join(outdir, "defs.json"), bl, info) # 블랙리스트 큐/익스체인지 + bindings 제거 + info["file"] = "defs.json" + info["note"] = "메시지 본문 제외(전이성). 정의(큐/익스체인지/바인딩/정책)만 이관." + elif stype == "solr": + info.update(_solr_capture(net, source, cr, bl, outdir)) else: die(f"미지원 타입(캡처): {stype}") json.dump(info, open(os.path.join(outdir, "manifest.json"), "w"), ensure_ascii=False, indent=2) @@ -182,6 +329,8 @@ def capture_source(m: dict, source: str, stype: str, cr: dict) -> dict: # ── 타깃 비어있음 검사 + 적용(부트스트랩) ───────────────────────────────── def is_empty(m: dict, source: str, stype: str, cr: dict) -> bool: + if stype == "solr": + return _solr_numfound(m, source, cr) == 0 return len(list_units(m, source, stype, cr)) == 0 @@ -207,6 +356,16 @@ def apply_source(m: dict, source: str, stype: str, cr: dict) -> str: 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")]) + elif stype == "redis": + drun(net, IMG[stype], _fill(_REDIS_RESTORE, host=source, pw=cr["password"], db=cr["db"]), [(outdir, "/out")]) + elif stype == "rabbitmq": + drun(net, IMG[stype], + f"rabbitmqadmin -H {source} -P 15672 -u {cr['user']} -p {cr['password']} -V {cr['vhost']} " + f"import /out/defs.json", [(outdir, "/out")]) + elif stype == "solr": + drun(net, IMG[stype], + f'curl -sS "http://{source}:8983/solr/{cr["core"]}/update?commit=true" ' + f'-H "Content-Type: application/json" --data-binary @/out/docs.json', [(outdir, "/out")]) else: die(f"미지원 타입(적용): {stype}") return "적용 완료(부트스트랩)" @@ -224,7 +383,7 @@ def select_targets(m: dict, names: list[str], require_running: bool) -> list[tup out = [] for s, t in chosen: if t not in SUPPORTED: - print(f" · {s}({t}) — 마이그레이션 미지원(다음 단계: redis/solr/rabbitmq/oracle). 건너뜀") + print(f" · {s}({t}) — 마이그레이션 미지원(다음 단계: {', '.join(UNSUPPORTED_NEXT)}). 건너뜀") continue if require_running and not _running(m, s): print(f" · {s} — dev 컨테이너 미기동('yakcloud dev up {s}' 먼저). 건너뜀")