From a87c41358c7d668d71ad57197ded5c495421ceef Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Sun, 30 Aug 2026 17:28:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(dev):=20=EB=A1=9C=EC=BB=AC=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EC=86=8C=EC=8A=A4=209=EC=A2=85=20=ED=8C=8C?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0(minio=C2=B7rabbitmq=C2=B7solr=C2=B7oracle)?= =?UTF-8?q?=20=E2=80=94=200.21.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .yakcloud/dev.py | 139 +++++++++++++++++++++++++++++++++++++++++++---- bin/yakcloud | 2 +- 2 files changed, 128 insertions(+), 13 deletions(-) diff --git a/.yakcloud/dev.py b/.yakcloud/dev.py index 7fd7759..47fa210 100644 --- a/.yakcloud/dev.py +++ b/.yakcloud/dev.py @@ -48,13 +48,21 @@ TAG = os.environ.get("TAG", "dev") # 로컬 데이터 소스 = kind '밖'의 **공유** 도커/포드만 컨테이너(타입별 1개) + 프로젝트별 격리 DB. # 관리형 shared 모델과 동일 계약 — 이미 있는 공유 서버에 _ 격리 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}"}) diff --git a/bin/yakcloud b/bin/yakcloud index 7b0eace..cf23083 100755 --- a/bin/yakcloud +++ b/bin/yakcloud @@ -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}"