cli v0.10.0: migrate oracle(Data Pump expdp/impdp + REMAP_SCHEMA + EXCLUDE) — 소스 컨테이너 exec+docker cp, chmod-as-root, exclude=user. 9종 전부 지원. docker E2E(캡처·제외·apply-skip·reset복원 CUSTOMERS/ORDERS) 검증

This commit is contained in:
2026-08-27 22:21:43 +09:00
parent d8fe2b0ea3
commit 9353cd7706
4 changed files with 76 additions and 8 deletions

View File

@ -32,7 +32,7 @@ YakCloud 콘솔 API로 **데이터소스 리컨실 + 워크로드 배포 + 바
- `yakcloud dev up``requires[]` 를 로컬 docker 로 기동 + `.env.dev`(프로덕션과 **바이트 동일**한 `<ALIAS>_*`) 생성
- `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/<source>/.migrateignore`) 스냅샷: 전체−제외 캡처(읽기전용), **빈 타깃만** 적용(부트스트랩). 지원 8종(SQL 3·mongo·minio·redis·solr·rabbitmq, oracle=다음). prod는 콘솔 소스관리자 컨펌+백업 후
- `yakcloud datasource migrate capture|apply|status` — 블랙리스트(`db/<source>/.migrateignore`) 스냅샷: 전체−제외 캡처(읽기전용), **빈 타깃만** 적용(부트스트랩). **9종 전부**(SQL 3·mongo·minio·redis·solr·rabbitmq·oracle). prod는 콘솔 소스관리자 컨펌+백업 후
**배포환경 설정(앱별) — 매니페스트 수정 + 배포중이면 재빌드 없이 라이브 반영**
- `yakcloud domain <fqdn> [wl]` 도메인 등록 + 워크로드 할당

View File

@ -57,7 +57,7 @@ yakcloud datasource migrate apply [source…] # 빈 타깃만 복
yakcloud datasource migrate status
```
- **블랙리스트**: `db/<source>/.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 직접 덮지 않음.

View File

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

View File

@ -24,11 +24,11 @@ import yakcloud_dev as dev # noqa: E402 (동일 scripts/ 의 dev 엔진 헬퍼
SNAP = "db" # 프로젝트 루트 db/<source>/ (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 "적용 완료(부트스트랩)"