diff --git a/.claude/skills/yakcloud-deploy/SKILL.md b/.claude/skills/yakcloud-deploy/SKILL.md index f1b9e9a..a9fc897 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`) 스냅샷: 전체−제외 캡처(읽기전용), **빈 타깃만** 적용(부트스트랩). 지원 8종(SQL 3·mongo·minio·redis·solr·rabbitmq, oracle=다음). prod는 콘솔 소스관리자 컨펌+백업 후 +- `yakcloud datasource migrate capture|apply|status` — 블랙리스트(`db//.migrateignore`) 스냅샷: 전체−제외 캡처(읽기전용), **빈 타깃만** 적용(부트스트랩). **9종 전부**(SQL 3·mongo·minio·redis·solr·rabbitmq·oracle). prod는 콘솔 소스관리자 컨펌+백업 후 **배포환경 설정(앱별) — 매니페스트 수정 + 배포중이면 재빌드 없이 라이브 반영** - `yakcloud domain [wl]` 도메인 등록 + 워크로드 할당 diff --git a/DATA-SOURCES.md b/DATA-SOURCES.md index 6921785..ff8f9d6 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/redis DUMP/solr export/rabbitmq definitions). 지원: postgres·mysql·mariadb·mongodb·minio·redis·solr·rabbitmq(oracle=다음 단계). 블랙리스트 단위: SQL=테이블, mongo=컬렉션, minio=오브젝트, redis=키, solr=문서(쿼리절), rabbitmq=큐/익스체인지. rabbitmq는 정의만(메시지 제외). +- **capture=읽기전용**(안전), 전체 native 덤프. **9종 전부 지원**(pg_dump/mysqldump/mongodump/mc mirror/redis DUMP·RESTORE/solr export/rabbitmq definitions/oracle Data Pump). 블랙리스트 단위: SQL·oracle=테이블, mongo=컬렉션, minio=오브젝트, redis=키, solr=문서(쿼리절), rabbitmq=큐/익스체인지. rabbitmq는 정의만(메시지 제외), oracle은 REMAP_SCHEMA로 스키마 이식. - **apply=빈 타깃만**. 데이터가 있으면 **절대 안 덮음**(스킵). 라이브 prod 지속 동기화가 아님. - **db/** 는 커밋·리뷰 대상(`.env.dev`·`.yakcloud/` 와 반대로 gitignore 아님). - prod 적용은 콘솔 종류별 소스관리자 **컨펌 + 항상 백업 후(롤백 보장)** — CLI 로 prod 직접 덮지 않음. diff --git a/bin/yakcloud b/bin/yakcloud index 119a677..2acba63 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.9.0" +VERSION="0.10.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 931f8e1..1ff902f 100644 --- a/scripts/yakcloud_migrate.py +++ b/scripts/yakcloud_migrate.py @@ -24,11 +24,11 @@ import yakcloud_dev as dev # noqa: E402 (동일 scripts/ 의 dev 엔진 헬퍼 SNAP = "db" # 프로젝트 루트 db// (git 추적 — .gitignore 아님) IGNORE = ".migrateignore" -SUPPORTED = ("postgresql", "mysql", "mariadb", "mongodb", "minio", "redis", "solr", "rabbitmq") +SUPPORTED = ("postgresql", "mysql", "mariadb", "mongodb", "minio", "redis", "solr", "rabbitmq", "oracle") IMG = {"postgresql": "postgres:16-alpine", "mysql": "mysql:8.4", "mariadb": "mariadb:11.4", "mongodb": "mongo:7", "minio": "minio/mc:latest", "redis": "redis:7-alpine", "solr": "solr:9", "rabbitmq": "rabbitmq:3.13-management"} -UNSUPPORTED_NEXT = ("oracle",) # 다음 단계(무거움/설계 보완 필요) +UNSUPPORTED_NEXT = () # 9종 전부 지원(oracle=Data Pump) def _fill(tmpl: str, **kw) -> str: @@ -170,6 +170,9 @@ def list_units(m: dict, source: str, stype: str, cr: dict) -> list[str]: return [str(d.get("id", "?")) for d in json.loads(r.stdout or "{}").get("response", {}).get("docs", [])] except Exception: return [] + elif stype == "oracle": + return _ora_lines(_ora_sql(source, cr, + f"SELECT table_name FROM all_tables WHERE owner = UPPER('{cr['user']}');")) else: return [] return [x.strip() for x in (r.stdout or "").splitlines() if x.strip() and "Warning" not in x] @@ -237,6 +240,38 @@ def _solr_capture(net: str, source: str, cr: dict, bl: list[str], outdir: str) - return {"file": "docs.json", "captured": len(docs)} +# ── oracle: Data Pump(expdp/impdp) — 파일이 DB 서버측이라 소스 컨테이너 exec + docker cp + REMAP_SCHEMA ── +def _ora_sql(source: str, cr: dict, sql: str) -> str: + script = "SET HEADING OFF PAGESIZE 0 FEEDBACK OFF VERIFY OFF ECHO OFF TRIMSPOOL ON\n" + sql + "\nEXIT\n" + r = subprocess.run(["docker", "exec", "-i", f"yakdev-{source}", "bash", "-lc", + f"sqlplus -s system/{cr['sys_pw']}@//localhost:1521/FREEPDB1"], + input=script, text=True, capture_output=True) + return r.stdout or "" + + +def _ora_lines(out: str) -> list[str]: + bad = ("ORA-", "SP2-", "SQL>", "altered", "PL/SQL", "Connected") + return [ln.strip() for ln in out.splitlines() if ln.strip() and not any(b in ln for b in bad)] + + +def _ora_dppath(source: str, cr: dict) -> str: + ls = _ora_lines(_ora_sql(source, cr, + "SELECT directory_path FROM dba_directories WHERE directory_name='DATA_PUMP_DIR';")) + return ls[-1] if ls else "/opt/oracle/admin/FREE/dpdump" + + +def _ora_datapump(source: str, cr: dict, parfile: str, tool: str) -> None: + cont = f"yakdev-{source}" + subprocess.run(["docker", "exec", "-i", cont, "bash", "-lc", "cat > /tmp/yak.par"], + input=parfile, text=True, capture_output=True) + r = subprocess.run(["docker", "exec", cont, "bash", "-lc", + f"{tool} system/{cr['sys_pw']}@//localhost:1521/FREEPDB1 parfile=/tmp/yak.par"], + text=True, capture_output=True) + out = (r.stdout or "") + (r.stderr or "") # expdp/impdp 는 진행상황을 stderr 로 출력 + if "successfully completed" not in out: + die(f"{tool} 실패:\n{out[-600:]}") + + # ── 미리보기 ──────────────────────────────────────────────────────────── def preview(m: dict, targets: list[tuple[str, str]]) -> dict: seed = dev.ensure_seed() @@ -255,10 +290,12 @@ def preview(m: dict, targets: list[tuple[str, str]]) -> dict: 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)] + blm = [p.upper() for p in bl] if stype == "oracle" else bl # oracle 식별자=대문자 + inc = [u for u in units if not _excluded(u, blm)] + exc = [u for u in units if _excluded(u, blm)] plan[source] = {"type": stype, "include": inc, "exclude": exc, "blacklist": bl} - unit_word = {"minio": "오브젝트", "redis": "키", "rabbitmq": "큐/익스체인지"}.get(stype, "테이블/컬렉션") + unit_word = {"minio": "오브젝트", "redis": "키", "rabbitmq": "큐/익스체인지", + "oracle": "테이블"}.get(stype, "테이블/컬렉션") print(f"\n ● {source} ({stype}) — {unit_word} {len(units)}개 · 포함 {len(inc)} / 제외 {len(exc)}") for u in inc[:20]: print(f" ✓ {u}") @@ -279,6 +316,9 @@ def capture_source(m: dict, source: str, stype: str, cr: dict) -> dict: bl = _blacklist(source) if stype == "solr": # solr 는 fq(서버측) 제외라 fnmatch 단위 계산 안 함 units, excluded = [], [] + elif stype == "oracle": # oracle 식별자=대문자 → 블랙리스트 글롭도 대문자 정규화 매칭 + units = list_units(m, source, stype, cr) + excluded = [u for u in units if _excluded(u, [p.upper() for p in bl])] else: units = list_units(m, source, stype, cr) excluded = [u for u in units if _excluded(u, bl)] @@ -321,6 +361,19 @@ def capture_source(m: dict, source: str, stype: str, cr: dict) -> dict: info["note"] = "메시지 본문 제외(전이성). 정의(큐/익스체인지/바인딩/정책)만 이관." elif stype == "solr": info.update(_solr_capture(net, source, cr, bl, outdir)) + elif stype == "oracle": + cont, schema, dp = f"yakdev-{source}", cr["user"].upper(), _ora_dppath(source, cr) + subprocess.run(["docker", "exec", cont, "bash", "-lc", f"rm -f {dp}/yak.dmp"], capture_output=True) + par = f"schemas={schema}\ndirectory=DATA_PUMP_DIR\ndumpfile=yak.dmp\nreuse_dumpfiles=y\nnologfile=y\n" + if excluded: + lst = ", ".join(f"'{e.upper()}'" for e in excluded) + par += f'EXCLUDE=TABLE:"IN ({lst})"\n' + _ora_datapump(source, cr, par, "expdp") + cp = subprocess.run(["docker", "cp", f"{cont}:{dp}/yak.dmp", os.path.join(outdir, "data.dmp")], + capture_output=True, text=True) + if cp.returncode != 0: + die(f"덤프 추출 실패: {cp.stderr[-300:]}") + info["file"], info["schema"] = "data.dmp", schema else: die(f"미지원 타입(캡처): {stype}") json.dump(info, open(os.path.join(outdir, "manifest.json"), "w"), ensure_ascii=False, indent=2) @@ -366,6 +419,21 @@ def apply_source(m: dict, source: str, stype: str, cr: dict) -> str: 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")]) + elif stype == "oracle": + cont, dst, dp = f"yakdev-{source}", cr["user"].upper(), _ora_dppath(source, cr) + src = json.load(open(os.path.join(outdir, "manifest.json"))).get("schema", dst) + cp = subprocess.run(["docker", "cp", os.path.join(outdir, "data.dmp"), f"{cont}:{dp}/yak.dmp"], + capture_output=True, text=True) + if cp.returncode != 0: + die(f"덤프 주입 실패: {cp.stderr[-300:]}") + # docker cp 는 호스트 uid 로 파일 생성 → oracle 프로세스가 읽도록 root(-u 0)로 권한 부여. + subprocess.run(["docker", "exec", "-u", "0", cont, "bash", "-lc", f"chmod 644 {dp}/yak.dmp"], + capture_output=True) + _ora_sql(source, cr, f"ALTER USER {dst} QUOTA UNLIMITED ON USERS;") # 쿼터 보장(멱등) + # exclude=user: 대상 스키마가 이미 존재(gvenzl 생성) → CREATE USER 스킵(ORA-31684 benign 방지, 깨끗한 성공) + par = (f"directory=DATA_PUMP_DIR\ndumpfile=yak.dmp\nnologfile=y\nexclude=user\n" + f"remap_schema={src}:{dst}\ntable_exists_action=skip\n") + _ora_datapump(source, cr, par, "impdp") else: die(f"미지원 타입(적용): {stype}") return "적용 완료(부트스트랩)"