feat(dev): 로컬 데이터소스 9종 파리티(minio·rabbitmq·solr·oracle) — 0.21.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 17:28:25 +09:00
parent 0e83adc82a
commit a87c41358c
2 changed files with 128 additions and 13 deletions

View File

@ -48,13 +48,21 @@ TAG = os.environ.get("TAG", "dev")
# 로컬 데이터 소스 = kind '밖'의 **공유** 도커/포드만 컨테이너(타입별 1개) + 프로젝트별 격리 DB.
# 관리형 shared 모델과 동일 계약 — 이미 있는 공유 서버에 <project>_<src> 격리 DB/유저만 민팅(소스 생성비 최소).
# 클러스터(kind)는 앱(비즈니스)만. 앱은 no-selector Service+Endpoints(host=소스명)로 공유 컨테이너의 격리 DB 접속.
_SVC_PORT = {"POSTGRESQL": 5432, "MYSQL": 3306, "MARIADB": 3306, "MONGODB": 27017, "REDIS": 6379}
_SVC_PORT = {"POSTGRESQL": 5432, "MYSQL": 3306, "MARIADB": 3306, "MONGODB": 27017, "REDIS": 6379,
"MINIO": 9000, "RABBITMQ": 5672, "SOLR": 8983, "ORACLE": 1521}
_IMG = {"POSTGRESQL": "postgres:16-alpine", "MYSQL": "mysql:8", "MARIADB": "mariadb:11",
"MONGODB": "mongo:7", "REDIS": "redis:7-alpine"}
"MONGODB": "mongo:7", "REDIS": "redis:7-alpine",
"MINIO": "minio/minio", "RABBITMQ": "rabbitmq:3-management", "SOLR": "solr:9",
"ORACLE": os.environ.get("YAK_DEV_ORACLE_IMG", "gvenzl/oracle-free:slim")}
_SHARED_CT = {"POSTGRESQL": "yak-dev-postgres", "MYSQL": "yak-dev-mysql", "MARIADB": "yak-dev-mariadb",
"MONGODB": "yak-dev-mongo", "REDIS": "yak-dev-redis"}
"MONGODB": "yak-dev-mongo", "REDIS": "yak-dev-redis",
"MINIO": "yak-dev-minio", "RABBITMQ": "yak-dev-rabbitmq", "SOLR": "yak-dev-solr",
"ORACLE": "yak-dev-oracle"}
SHARED_NET = os.environ.get("YAK_DEV_NET", "kind") # kind 가 쓰는 도커 네트워크
SHARED_ADMIN = "yakdevadmin" # dev 전용 공유 컨테이너 관리자 비밀번호(로컬 한정)
MINIO_ROOT_USER = "yakdevadmin" # MinIO 루트 유저(로컬 dev, ≥3자)
ORACLE_PDB = os.environ.get("YAK_DEV_ORACLE_PDB", "FREEPDB1") # gvenzl/oracle-free 기본 PDB
MC_IMG = os.environ.get("YAK_DEV_MC_IMG", "minio/mc") # 버킷 민팅용 MinIO 클라이언트
SUPPORTED = set(_SVC_PORT)
MANAGED = "app.kubernetes.io/managed-by=yakcloud-dev"
@ -141,6 +149,14 @@ def _dbname(project: str, source: str) -> str:
return (out or "app")[:40]
def _minio_bucket(unit: str) -> str:
"""S3/MinIO 버킷명 규칙 — 소문자·숫자·하이픈·점만(언더스코어 불가), 3~63자, 시작/끝은 영숫자."""
b = "".join(c if (c.isalnum() or c in "-.") else "-" for c in unit.replace("_", "-").lower()).strip("-.")
if len(b) < 3:
b = (b + "-bkt")
return b[:63].strip("-.")
def _container_ip(cname: str) -> str:
r = _rt(["inspect", "-f",
'{{(index .NetworkSettings.Networks "' + SHARED_NET + '").IPAddress}}', cname], check=False)
@ -151,10 +167,13 @@ def _ensure_shared(stype: str) -> tuple[str, str]:
"""타입별 '공유' 컨테이너를 kind 네트워크에 보장(idempotent, 관리자 자격) → (이름, IP)."""
cname = _SHARED_CT[stype]
if not _rt(["ps", "-q", "-f", f"name=^{cname}$"], check=False).stdout.strip():
_rt(["rm", "-f", cname], check=False) # 죽은 동명 컨테이너 정리
# 죽은 동명 컨테이너 + 익명 볼륨(-v)까지 정리 — 재생성 시 항상 깨끗한 볼륨으로 시작(고아 볼륨 누적·재사용 방지).
# (특히 rabbitmq/erlang 은 옛 uid 소유 볼륨을 재사용하면 .erlang.cookie eacces 로 재크래시)
_rt(["rm", "-f", "-v", cname], check=False)
img = _IMG[stype]
run = ["run", "-d", "--name", cname, "--network", SHARED_NET,
"--label", "yakcloud.dev/shared=1", "--label", f"yakcloud.dev/type={stype}"]
tries = 90
if stype == "POSTGRESQL":
run += ["-e", f"POSTGRES_PASSWORD={SHARED_ADMIN}", img]
ready = "pg_isready -U postgres >/dev/null 2>&1"
@ -168,17 +187,35 @@ def _ensure_shared(stype: str) -> tuple[str, str]:
run += ["-e", "MONGO_INITDB_ROOT_USERNAME=root", "-e", f"MONGO_INITDB_ROOT_PASSWORD={SHARED_ADMIN}", img]
ready = (f'mongosh "mongodb://root:{SHARED_ADMIN}@127.0.0.1:27017/admin" --quiet '
'--eval "db.runCommand({ping:1}).ok" | grep -q 1')
else: # REDIS
elif stype == "REDIS":
run += [img, "redis-server", "--requirepass", SHARED_ADMIN]
ready = f'redis-cli -a {SHARED_ADMIN} ping 2>/dev/null | grep -q PONG'
elif stype == "MINIO":
run += ["-e", f"MINIO_ROOT_USER={MINIO_ROOT_USER}", "-e", f"MINIO_ROOT_PASSWORD={SHARED_ADMIN}",
img, "server", "/data", "--console-address", ":9001"]
ready = None # 준비 대기·버킷 생성은 민팅(mc ready)에서 — minio 서버 이미지엔 헬스툴 없음
elif stype == "RABBITMQ":
# --user 0(root) 로 실행하면 엔트리포인트가 gosu 로 rabbitmq(999) 유저로 내려가 부팅한다.
# ⚠ 준비체크/민팅을 root 로 exec 하면 erlang 이 root 소유 .erlang.cookie 를 만들어 메인(999)과 충돌 → eacces 크래시.
# 반드시 'gosu rabbitmq' 로 diagnostics/ctl 을 999 로 실행해야 함(쿠키 소유자 일치). check_running=앱 기동 확인.
run += ["--user", "0", "-e", "RABBITMQ_DEFAULT_USER=root", "-e", f"RABBITMQ_DEFAULT_PASS={SHARED_ADMIN}", img]
ready = "gosu rabbitmq rabbitmq-diagnostics -q check_running >/dev/null 2>&1"
elif stype == "SOLR":
run += [img]
ready = "wait-for-solr.sh --max-attempts 2 --wait-seconds 1 >/dev/null 2>&1"
else: # ORACLE
run += ["-e", f"ORACLE_PASSWORD={SHARED_ADMIN}", img]
ready = "healthcheck.sh >/dev/null 2>&1"
tries = 240 # 첫 DB 생성이 느림(최대 ~8분)
log(f"공유 소스 컨테이너 '{cname}' ({stype}) 기동 ({RUNTIME}, net={SHARED_NET})")
_rt(run, capture=False)
for _ in range(90):
if _dexec(cname, ready, check=False).returncode == 0:
break
time.sleep(2)
else:
die(f"공유 컨테이너 '{cname}' 준비 대기 초과 — '{RUNTIME} logs {cname}' 확인")
if ready:
for _ in range(tries):
if _dexec(cname, ready, check=False).returncode == 0:
break
time.sleep(2)
else:
die(f"공유 컨테이너 '{cname}' 준비 대기 초과 — '{RUNTIME} logs {cname}' 확인")
ip = _container_ip(cname)
if not ip:
die(f"공유 컨테이너 '{cname}' IP 조회 실패 — 도커 네트워크 '{SHARED_NET}' 확인")
@ -204,6 +241,44 @@ def _mint(stype: str, cname: str, unit: str, pw: str) -> None:
_dexec(cname, f'mongosh "mongodb://root:{SHARED_ADMIN}@127.0.0.1:27017/admin" --quiet --eval \'{js}\'')
def _mint_minio(cname: str, ip: str, bucket: str) -> None:
"""별도 mc 컨테이너로 준비 대기(mc ready) + 프로젝트별 버킷 생성(idempotent). minio 서버 이미지엔 mc 없음.
별칭은 MC_HOST env 로 주입(막 뜬 서버에 'mc alias set' 검증 핑이 너무 일찍 실패하는 경쟁 회피) → mc ready 가 대기."""
host_env = f"MC_HOST_h=http://{MINIO_ROOT_USER}:{SHARED_ADMIN}@{ip}:9000"
cmd = f"mc ready h && mc mb --ignore-existing h/{bucket}"
_rt(["run", "--rm", "--network", SHARED_NET, "-e", host_env, "--entrypoint", "sh", MC_IMG, "-c", cmd], capture=False)
def _mint_rabbitmq(cname: str, vhost: str, user: str, pw: str) -> None:
"""프로젝트별 vhost + 유저 + 권한(idempotent). rabbitmqctl 은 'gosu rabbitmq'(999)로 —
root 로 실행하면 erlang 쿠키 소유자 불일치로 노드 접속 실패/충돌(부팅 크래시와 동일 원인)."""
g = "gosu rabbitmq rabbitmqctl -q"
cmd = (f"{g} list_vhosts | grep -qx {vhost} || {g} add_vhost {vhost}; "
f"{g} list_users | awk '{{print $1}}' | grep -qx {user} "
f"&& {g} change_password {user} {pw} || {g} add_user {user} {pw}; "
f'{g} set_permissions -p {vhost} {user} ".*" ".*" ".*"')
_dexec(cname, cmd)
def _mint_solr(cname: str, core: str) -> None:
"""프로젝트별 core 생성(이미 있으면 무시)."""
_dexec(cname, f"solr create -c {core} 2>/dev/null || true", check=False)
def _mint_oracle(cname: str, user: str, pw: str) -> None:
"""프로젝트별 스키마/유저 생성(PDB, idempotent — ORA-01920=이미 존재 무시)."""
plsql = (
"SET FEEDBACK OFF VERIFY OFF;\n"
f"BEGIN EXECUTE IMMEDIATE 'CREATE USER {user} IDENTIFIED BY \"{pw}\"'; "
"EXCEPTION WHEN OTHERS THEN IF SQLCODE!=-1920 THEN RAISE; END IF; END;\n/\n"
f"GRANT CONNECT, RESOURCE TO {user};\n"
f"ALTER USER {user} QUOTA UNLIMITED ON USERS;\n"
"EXIT;\n"
)
_rt(["exec", "-i", cname, "bash", "-c",
f"sqlplus -S -L system/{SHARED_ADMIN}@localhost:1521/{ORACLE_PDB}"], input=plsql)
def provision_source(ns: str, project: str, req: dict) -> dict | None:
"""requires 소스 → 공유 도커 컨테이너의 프로젝트별 격리 DB + no-selector Service/Endpoints. 접속정보 반환."""
name = req["name"]
@ -221,6 +296,23 @@ def provision_source(ns: str, project: str, req: dict) -> dict | None:
dbnum = str(int(hashlib.sha1(unit.encode()).hexdigest(), 16) % 16) # DB 번호 격리(0~15)
conn = {"stype": stype, "host": svc, "port": port, "user": "", "password": SHARED_ADMIN, "db": dbnum}
log(f"source '{name}' (REDIS) → 공유 '{cname}' · DB#{dbnum} · 서비스 {svc}:{port}")
elif stype == "MINIO":
bucket = _minio_bucket(unit) # S3 버킷명(언더스코어→하이픈)
_mint_minio(cname, ip, bucket) # 프로젝트별 버킷
conn = {"stype": stype, "host": svc, "port": port, "user": MINIO_ROOT_USER, "password": SHARED_ADMIN, "bucket": bucket}
log(f"source '{name}' (MINIO) → 공유 '{cname}' · 버킷 '{bucket}' · 서비스 {svc}:{port}")
elif stype == "RABBITMQ":
_mint_rabbitmq(cname, unit, unit, pw) # vhost=유저=unit
conn = {"stype": stype, "host": svc, "port": port, "user": unit, "password": pw, "vhost": unit}
log(f"source '{name}' (RABBITMQ) → 공유 '{cname}' · vhost/유저 '{unit}' · 서비스 {svc}:{port}")
elif stype == "SOLR":
_mint_solr(cname, unit) # 프로젝트별 core
conn = {"stype": stype, "host": svc, "port": port, "user": "", "password": "", "core": unit}
log(f"source '{name}' (SOLR) → 공유 '{cname}' · core '{unit}' · 서비스 {svc}:{port}")
elif stype == "ORACLE":
_mint_oracle(cname, unit, pw) # 프로젝트별 스키마/유저
conn = {"stype": stype, "host": svc, "port": port, "user": unit, "password": pw, "service": ORACLE_PDB}
log(f"source '{name}' (ORACLE) → 공유 '{cname}' · 스키마 '{unit}' · {svc}:{port}/{ORACLE_PDB}")
else:
_mint(stype, cname, unit, pw)
conn = {"stype": stype, "host": svc, "port": port, "user": unit, "password": pw, "db": unit}
@ -245,7 +337,7 @@ def provision_source(ns: str, project: str, req: dict) -> dict | None:
def bind_env(alias: str, conn: dict) -> dict:
p = alias.upper().replace("-", "_") # 백엔드와 동일 — 하이픈 alias 에서도 env 키 일치
host, port = conn["host"], str(conn["port"])
user, pw, db = conn["user"], conn["password"], conn["db"]
user, pw, db = conn.get("user", ""), conn.get("password", ""), conn.get("db", "")
qu, qp = quote(user, safe=""), quote(pw, safe="")
out = {f"{p}_HOST": host, f"{p}_PORT": port}
st = conn["stype"]
@ -261,6 +353,29 @@ def bind_env(alias: str, conn: dict) -> dict:
elif st == "MARIADB":
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db,
f"{p}_URL": f"mysql://{qu}:{qp}@{host}:{port}/{db}"})
elif st == "MINIO":
endpoint = f"http://{host}:{port}" # 로컬 kind = http(비 TLS)
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw,
f"{p}_URL": endpoint, f"{p}_ENDPOINT": endpoint,
f"{p}_ACCESS_KEY": user, f"{p}_SECRET_KEY": pw,
f"{p}_BUCKET": conn.get("bucket", ""), f"{p}_REGION": "us-east-1", f"{p}_USE_SSL": "false"})
elif st == "RABBITMQ":
vhost = conn.get("vhost", "")
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_VHOST": vhost,
f"{p}_URL": f"amqp://{qu}:{qp}@{host}:{port}/{quote(vhost, safe='')}",
f"{p}_MGMT_URL": f"http://{host}:15672"})
elif st == "SOLR":
core = conn.get("core", "")
endpoint = f"http://{host}:{port}"
out.update({f"{p}_USERNAME": "", f"{p}_PASSWORD": "",
f"{p}_CORE": core, f"{p}_ENDPOINT": endpoint,
f"{p}_URL": f"{endpoint}/solr/{core}"})
elif st == "ORACLE":
service = conn.get("service", "")
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_SERVICE": service,
f"{p}_URL": f"oracle://{qu}:{qp}@{host}:{port}/{service}",
f"{p}_JDBC_URL": f"jdbc:oracle:thin:@//{host}:{port}/{service}",
f"{p}_DSN": f"{host}:{port}/{service}"})
else: # POSTGRESQL
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db,
f"{p}_URL": f"postgresql://{qu}:{qp}@{host}:{port}/{db}"})

View File

@ -16,7 +16,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.20.0"
VERSION="0.21.0"
# 로컬 개발 클러스터(kind) — 개발 후 운영까지 '직선 배포'의 dev 구간.
DEV_CTX="${YAK_DEV_CONTEXT:-kind-yak-dev}"
DEV_CLUSTER="${YAK_DEV_CLUSTER:-yak-dev}"