diff --git a/.yakcloud/dev.py b/.yakcloud/dev.py index b3d2ccf..cb25da6 100644 --- a/.yakcloud/dev.py +++ b/.yakcloud/dev.py @@ -24,8 +24,10 @@ import base64 import json import os import secrets +import hashlib import subprocess import sys +import time import urllib.request from urllib.parse import quote @@ -40,14 +42,16 @@ RUNTIME = os.environ.get("YAK_DEV_RUNTIME", "docker") HOST_SUFFIX = os.environ.get("YAK_DEV_HOST_SUFFIX", "dev.localhost") TAG = os.environ.get("TAG", "dev") -# 소스 타입별 로컬 컨테이너 스펙 — 관리형과 동일한 인클러스터 시크릿 키(_bind_env_for 입력)를 세팅. -# port = 백엔드 _SVC_PORT 와 동일. img = 공개 오피셜 이미지(로컬 dev 는 파리티보다 기동성 우선). -# 유저명은 백엔드와 동일하게 고정 'appuser'(소스명 파생 금지 — 'root' 등 예약어 충돌·환경 간 값 발산 방지). -APP_USER = "appuser" -_SVC_PORT = {"POSTGRESQL": 5432, "MYSQL": 3306, "MARIADB": 3306, - "MONGODB": 27017, "REDIS": 6379} +# 로컬 데이터 소스 = kind '밖'의 **공유** 도커/포드만 컨테이너(타입별 1개) + 프로젝트별 격리 DB. +# 관리형 shared 모델과 동일 계약 — 이미 있는 공유 서버에 _ 격리 DB/유저만 민팅(소스 생성비 최소). +# 클러스터(kind)는 앱(비즈니스)만. 앱은 no-selector Service+Endpoints(host=소스명)로 공유 컨테이너의 격리 DB 접속. +_SVC_PORT = {"POSTGRESQL": 5432, "MYSQL": 3306, "MARIADB": 3306, "MONGODB": 27017, "REDIS": 6379} _IMG = {"POSTGRESQL": "postgres:16-alpine", "MYSQL": "mysql:8", "MARIADB": "mariadb:11", "MONGODB": "mongo:7", "REDIS": "redis:7-alpine"} +_SHARED_CT = {"POSTGRESQL": "yak-dev-postgres", "MYSQL": "yak-dev-mysql", "MARIADB": "yak-dev-mariadb", + "MONGODB": "yak-dev-mongo", "REDIS": "yak-dev-redis"} +SHARED_NET = os.environ.get("YAK_DEV_NET", "kind") # kind 가 쓰는 도커 네트워크 +SHARED_ADMIN = "yakdevadmin" # dev 전용 공유 컨테이너 관리자 비밀번호(로컬 한정) SUPPORTED = set(_SVC_PORT) MANAGED = "app.kubernetes.io/managed-by=yakcloud-dev" @@ -109,128 +113,129 @@ def ensure_ns(ns: str) -> None: # ── 소스 자격: 최초 생성 시 랜덤(유저=고정 appuser, db=소스별), 이후 재사용(idempotent) ────── def get_or_make_creds(ns: str, name: str) -> dict: + """프로젝트-소스별 비밀번호 — 기존 Secret 이 있으면 재사용(멱등), 없으면 랜덤 생성.""" sec = f"yak-dev-src-{sanitize(name)}" r = kubectl(["get", "secret", sec, "-n", ns, "-o", "json"], check=False) if r.returncode == 0: - data = json.loads(r.stdout).get("data", {}) - c = {k: base64.b64decode(v).decode() for k, v in data.items()} - c["secret"] = sec # 재사용 경로도 secret 이름을 포함(멱등 재배포 시 KeyError 방지) - return c - return {"user": APP_USER, "password": secrets.token_hex(16), - "db": sanitize(name)[:24] or "app", "secret": sec} + pw = (json.loads(r.stdout).get("data", {}) or {}).get("password") + if pw: + return {"password": base64.b64decode(pw).decode(), "secret": sec} + return {"password": secrets.token_hex(16), "secret": sec} -def _source_container(stype: str, c: dict) -> dict: - """소스 타입별 컨테이너 정의 + 인클러스터 시크릿 키(관리형과 동일) + '앱-유저 인증' readiness. +def _rt(args: list[str], **kw) -> subprocess.CompletedProcess: + return sh([RUNTIME, *args], **kw) - readiness 를 tcp 가 아니라 **앱 유저 로그인 exec** 으로 게이트한다 — 초기화(유저/DB 생성) 중 - 포트만 열린 상태를 'Ready' 로 오판해 workload 가 먼저 붙어 인증 실패로 크래시하는 것을 막는다.""" - port = _SVC_PORT[stype] - rootpw = c["password"] + "root" - env: list[dict] = [] - args: list[str] | None = None - init_cm: dict | None = None - vol_mounts: list[dict] = [] + +def _dexec(cname: str, shell_cmd: str, check: bool = True) -> subprocess.CompletedProcess: + return _rt(["exec", cname, "sh", "-c", shell_cmd], check=check) + + +def _dbname(project: str, source: str) -> str: + """프로젝트별 격리 단위(DB·유저)명 — DB 안전(소문자·영숫자·언더스코어).""" + raw = f"{project}_{source}".lower() + out = "".join(c if (c.isalnum() or c == "_") else "_" for c in raw).strip("_") + return (out or "app")[:40] + + +def _container_ip(cname: str) -> str: + r = _rt(["inspect", "-f", + '{{(index .NetworkSettings.Networks "' + SHARED_NET + '").IPAddress}}', cname], check=False) + return (r.stdout or "").strip() + + +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) # 죽은 동명 컨테이너 정리 + img = _IMG[stype] + run = ["run", "-d", "--name", cname, "--network", SHARED_NET, + "--label", "yakcloud.dev/shared=1", "--label", f"yakcloud.dev/type={stype}"] + if stype == "POSTGRESQL": + run += ["-e", f"POSTGRES_PASSWORD={SHARED_ADMIN}", img] + ready = "pg_isready -U postgres >/dev/null 2>&1" + elif stype == "MYSQL": + run += ["-e", f"MYSQL_ROOT_PASSWORD={SHARED_ADMIN}", img] + ready = f'mysql -uroot -p{SHARED_ADMIN} -e "SELECT 1" >/dev/null 2>&1' + elif stype == "MARIADB": + run += ["-e", f"MARIADB_ROOT_PASSWORD={SHARED_ADMIN}", img] + ready = f'mariadb -uroot -p{SHARED_ADMIN} -e "SELECT 1" >/dev/null 2>&1' + elif stype == "MONGODB": + 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 + run += [img, "redis-server", "--requirepass", SHARED_ADMIN] + ready = f'redis-cli -a {SHARED_ADMIN} ping 2>/dev/null | grep -q PONG' + 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}' 확인") + ip = _container_ip(cname) + if not ip: + die(f"공유 컨테이너 '{cname}' IP 조회 실패 — 도커 네트워크 '{SHARED_NET}' 확인") + return cname, ip + + +def _mint(stype: str, cname: str, unit: str, pw: str) -> None: + """공유 컨테이너에 프로젝트별 격리 DB/유저 생성(idempotent). unit=DB·유저명(영숫자·언더스코어).""" if stype == "POSTGRESQL": - env = [{"name": "POSTGRES_USER", "value": c["user"]}, - {"name": "POSTGRES_PASSWORD", "value": c["password"]}, - {"name": "POSTGRES_DB", "value": c["db"]}, - {"name": "PGDATA", "value": "/var/lib/postgresql/data/pgdata"}] - probe = ["sh", "-c", 'PGPASSWORD="$POSTGRES_PASSWORD" psql -h 127.0.0.1 -U "$POSTGRES_USER" ' - '-d "$POSTGRES_DB" -c "SELECT 1" >/dev/null 2>&1'] - elif stype == "MYSQL": - env = [{"name": "MYSQL_USER", "value": c["user"]}, - {"name": "MYSQL_PASSWORD", "value": c["password"]}, - {"name": "MYSQL_DATABASE", "value": c["db"]}, - {"name": "MYSQL_ROOT_PASSWORD", "value": rootpw}] - probe = ["sh", "-c", 'mysql -h127.0.0.1 -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" ' - '"$MYSQL_DATABASE" -e "SELECT 1" >/dev/null 2>&1'] - elif stype == "MARIADB": - env = [{"name": "MARIADB_USER", "value": c["user"]}, - {"name": "MARIADB_PASSWORD", "value": c["password"]}, - {"name": "MARIADB_DATABASE", "value": c["db"]}, - {"name": "MARIADB_ROOT_PASSWORD", "value": rootpw}] - probe = ["sh", "-c", 'mariadb -h127.0.0.1 -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" ' - '"$MARIADB_DATABASE" -e "SELECT 1" >/dev/null 2>&1'] - elif stype == "REDIS": - args = ["redis-server", "--requirepass", c["password"]] - env = [{"name": "REDISCLI_AUTH", "value": c["password"]}] - probe = ["sh", "-c", 'redis-cli ping | grep -q PONG'] + _dexec(cname, f'psql -U postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname=\'{unit}\'" | grep -q 1 ' + f'|| psql -U postgres -c "CREATE ROLE {unit} LOGIN PASSWORD \'{pw}\'"') + _dexec(cname, f'psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname=\'{unit}\'" | grep -q 1 ' + f'|| psql -U postgres -c "CREATE DATABASE {unit} OWNER {unit}"') + elif stype in ("MYSQL", "MARIADB"): + client = "mysql" if stype == "MYSQL" else "mariadb" + sql = (f"CREATE DATABASE IF NOT EXISTS {unit}; " + f"CREATE USER IF NOT EXISTS '{unit}'@'%' IDENTIFIED BY '{pw}'; " + f"GRANT ALL ON {unit}.* TO '{unit}'@'%'; FLUSH PRIVILEGES;") + _dexec(cname, f'{client} -uroot -p{SHARED_ADMIN} -e "{sql}"') elif stype == "MONGODB": - # 루트 유저(root) + 앱 db 에 앱 유저(appuser) 생성(authSource=db 파리티) via init.js - env = [{"name": "MONGO_INITDB_ROOT_USERNAME", "value": "root"}, - {"name": "MONGO_INITDB_ROOT_PASSWORD", "value": rootpw}, - {"name": "MONGO_INITDB_DATABASE", "value": c["db"]}, - {"name": "MONGO_APP_USER", "value": c["user"]}, - {"name": "MONGO_APP_PASSWORD", "value": c["password"]}, - {"name": "MONGO_APP_DB", "value": c["db"]}] - js = (f'db.getSiblingDB("{c["db"]}").createUser({{user:"{c["user"]}",' - f'pwd:"{c["password"]}",roles:[{{role:"readWrite",db:"{c["db"]}"}}]}});') - init_cm = {"js": js} - vol_mounts = [{"name": "initdb", "mountPath": "/docker-entrypoint-initdb.d"}] - probe = ["sh", "-c", 'mongosh "mongodb://$MONGO_APP_USER:$MONGO_APP_PASSWORD@127.0.0.1:' - f'{port}/$MONGO_APP_DB?authSource=$MONGO_APP_DB" --quiet ' - '--eval "db.runCommand({ping:1}).ok" | grep -q 1'] - else: # pragma: no cover - die(f"내부오류: 미지원 타입 {stype}") - cont = {"name": "db", "image": _IMG[stype], "imagePullPolicy": "IfNotPresent", - "ports": [{"containerPort": port}], "env": env, - "readinessProbe": {"exec": {"command": probe}, "initialDelaySeconds": 5, - "periodSeconds": 5, "failureThreshold": 40, "timeoutSeconds": 5}, - "volumeMounts": vol_mounts} - if args: - cont["args"] = args - return {"container": cont, "port": port, "init_cm": init_cm, "vol_mounts": vol_mounts} + js = (f'try{{db.getSiblingDB("{unit}").createUser({{user:"{unit}",pwd:"{pw}",' + f'roles:[{{role:"readWrite",db:"{unit}"}}]}})}}catch(e){{if(!/already exists/i.test(e.message))throw e}}') + _dexec(cname, f'mongosh "mongodb://root:{SHARED_ADMIN}@127.0.0.1:27017/admin" --quiet --eval \'{js}\'') -def provision_source(ns: str, req: dict) -> dict | None: - """requires 소스를 로컬 컨테이너로 프로비저닝(idempotent) → 접속정보 반환.""" +def provision_source(ns: str, project: str, req: dict) -> dict | None: + """requires 소스 → 공유 도커 컨테이너의 프로젝트별 격리 DB + no-selector Service/Endpoints. 접속정보 반환.""" name = req["name"] stype = req["type"].upper() - if req.get("mode") == "shared": - warn(f"source '{name}': mode=shared 는 로컬 dev 에서 일반 로컬 컨테이너로 대체(격리 DB 민팅 없음).") + if req.get("mode") == "local": + warn(f"source '{name}': mode=local(인클러스터)는 로컬 dev 에서 공유 컨테이너로 대체됩니다.") if stype not in SUPPORTED: - die(f"source '{name}' 타입 {stype} — 로컬 dev 미지원(지원: {', '.join(sorted(SUPPORTED))}). " - f"관리형(deploy/promote)에서는 지원됩니다.") - dep = sanitize(f"src-{name}") + die(f"source '{name}' 타입 {stype} — 로컬 dev 미지원(지원: {', '.join(sorted(SUPPORTED))}).") svc = sanitize(name) - creds = get_or_make_creds(ns, name) - spec = _source_container(stype, creds) - labels = {"app": dep, "app.kubernetes.io/managed-by": "yakcloud-dev", - "yakcloud.dev/role": "source", "yakcloud.dev/source": svc} - objs: list[dict] = [{ - "apiVersion": "v1", "kind": "Secret", - "metadata": {"name": creds["secret"], "namespace": ns, "labels": labels}, - "stringData": {"user": creds["user"], "password": creds["password"], "db": creds["db"]}, - }] - volumes: list[dict] = [] - if spec["init_cm"]: - cm = f"{dep}-init" - objs.append({"apiVersion": "v1", "kind": "ConfigMap", - "metadata": {"name": cm, "namespace": ns, "labels": labels}, - "data": {"init.js": spec["init_cm"]["js"]}}) - volumes.append({"name": "initdb", "configMap": {"name": cm}}) - objs.append({ - "apiVersion": "apps/v1", "kind": "Deployment", - "metadata": {"name": dep, "namespace": ns, "labels": labels}, - "spec": {"replicas": 1, "selector": {"matchLabels": {"app": dep}}, - "strategy": {"type": "Recreate"}, - "template": {"metadata": {"labels": labels}, - "spec": {"containers": [spec["container"]], "volumes": volumes}}}, - }) - objs.append({ - "apiVersion": "v1", "kind": "Service", - "metadata": {"name": svc, "namespace": ns, "labels": labels}, - "spec": {"selector": {"app": dep}, - "ports": [{"port": spec["port"], "targetPort": spec["port"]}]}, - }) - log(f"source '{name}' ({stype}) 프로비저닝 → 서비스 {svc}:{spec['port']}") - apply(objs) - r = kubectl(["rollout", "status", f"deployment/{dep}", "-n", ns, "--timeout=300s"], check=False) - if r.returncode != 0: - die(f"source '{name}' 기동/인증 준비 대기 실패 — 'kubectl --context {CTX} -n {ns} get pods' 확인") - return {"stype": stype, "host": svc, "port": spec["port"], - "user": creds["user"], "password": creds["password"], "db": creds["db"]} + unit = _dbname(project, name) + port = _SVC_PORT[stype] + pw = get_or_make_creds(ns, name)["password"] # 프로젝트-소스별 비밀번호(Secret 재사용=멱등) + cname, ip = _ensure_shared(stype) + if stype == "REDIS": + 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}") + else: + _mint(stype, cname, unit, pw) + conn = {"stype": stype, "host": svc, "port": port, "user": unit, "password": pw, "db": unit} + log(f"source '{name}' ({stype}) → 공유 '{cname}' · 격리 DB '{unit}' · 서비스 {svc}:{port}") + labels = {"app.kubernetes.io/managed-by": "yakcloud-dev", "yakcloud.dev/role": "source", "yakcloud.dev/source": svc} + # no-selector Service + 수동 Endpoints → kind 밖 공유 컨테이너 IP 로 라우팅. + apply([ + {"apiVersion": "v1", "kind": "Secret", + "metadata": {"name": f"yak-dev-src-{svc}", "namespace": ns, "labels": labels}, + "stringData": {"password": pw, "unit": unit}}, + {"apiVersion": "v1", "kind": "Service", + "metadata": {"name": svc, "namespace": ns, "labels": labels}, + "spec": {"ports": [{"port": port, "targetPort": port}]}}, + {"apiVersion": "v1", "kind": "Endpoints", + "metadata": {"name": svc, "namespace": ns, "labels": labels}, + "subsets": [{"addresses": [{"ip": ip}], "ports": [{"port": port}]}]}, + ]) + return conn # ── 바인딩 env: 백엔드 _bind_env_for 와 동일(하이픈 alias → 언더스코어 프리픽스 포함) ──────── @@ -245,8 +250,8 @@ def bind_env(alias: str, conn: dict) -> dict: out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db, f"{p}_URL": f"mongodb://{qu}:{qp}@{host}:{port}/{db}?authSource={db}"}) elif st == "REDIS": - out.update({f"{p}_USERNAME": "", f"{p}_PASSWORD": pw, f"{p}_DB": "0", - f"{p}_URL": f"redis://:{qp}@{host}:{port}/0"}) + out.update({f"{p}_USERNAME": "", f"{p}_PASSWORD": pw, f"{p}_DB": db, + f"{p}_URL": f"redis://:{qp}@{host}:{port}/{db}"}) elif st == "MYSQL": out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db, f"{p}_URL": f"mysql://{qu}:{qp}@{host}:{port}/{db}"}) @@ -338,7 +343,7 @@ def deploy_workload(project: str, ns: str, w: dict, src_conns: dict) -> tuple[st # ── 고아 정리: 매니페스트에 없는(라벨된) 소스/워크로드 리소스 삭제(직선 리컨실) ────────────── def _prune(ns: str, role: str, label_key: str, desired: set) -> None: - r = kubectl(["get", "deployment,service,secret,configmap,ingress", "-n", ns, + r = kubectl(["get", "deployment,service,endpoints,secret,configmap,ingress", "-n", ns, "-l", f"{MANAGED},yakcloud.dev/role={role}", "-o", "json"], check=False) if r.returncode != 0: return @@ -405,7 +410,7 @@ def cmd_deploy(m: dict) -> None: ensure_ns(ns) src_conns: dict[str, dict] = {} for req in reqs: - conn = provision_source(ns, req) + conn = provision_source(ns, project, req) if conn: src_conns[req["name"]] = conn results = [deploy_workload(project, ns, w, src_conns) for w in wls] diff --git a/bin/yakcloud b/bin/yakcloud index 310f470..75f34f7 100755 --- a/bin/yakcloud +++ b/bin/yakcloud @@ -492,6 +492,9 @@ cmd_dev() { local rt kenv=""; rt="$(_dev_runtime)"; [ "$rt" = podman ] && kenv="KIND_EXPERIMENTAL_PROVIDER=podman" info "kind 클러스터 '$DEV_CLUSTER' 삭제…" env $kenv kind delete cluster --name "$DEV_CLUSTER" && echo "✓ 삭제 완료" + # 공유 데이터 소스 컨테이너(yak-dev-*)도 정리(머신 레벨 전체 teardown). + local scs; scs="$("${rt:-docker}" ps -aq -f label=yakcloud.dev/shared=1 2>/dev/null)" + [ -n "$scs" ] && { info "공유 소스 컨테이너 정리…"; echo "$scs" | xargs "${rt:-docker}" rm -f >/dev/null 2>&1; } _dev_deregister ;; *) echo "yakcloud dev "; exit 1 ;;