From a82de865f7d95c176ba7ce9d79bf4fd07fcfcdb8 Mon Sep 17 00:00:00 2001 From: yakenator Date: Thu, 27 Aug 2026 19:27:09 +0900 Subject: [PATCH] =?UTF-8?q?cli=20v0.7.0:=20'yakcloud=20dev'=20=E2=80=94=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=20docker=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20+=20=ED=94=84=EB=A1=9C=EB=8D=95=EC=85=98?= =?UTF-8?q?=20=5Fbind=5Fenv=5Ffor=20=EB=B0=94=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=20=5F*=20env(.env.dev).=20up/run/down?= =?UTF-8?q?/status/logs/env/reset/doctor;=20=EA=B2=B0=EC=A0=95=EC=A0=81=20?= =?UTF-8?q?=EC=9E=90=EA=B2=A9=20=ED=8C=8C=EC=83=9D;=209=EC=A2=85=20?= =?UTF-8?q?=ED=8C=A8=EB=A6=AC=ED=8B=B0+6=EC=A2=85=20docker=20E2E=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D.=20.gitignore=20=EC=97=90=20dev=20=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=EB=AC=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/commands/yakcloud.md | 4 +- .claude/skills/yakcloud-deploy/SKILL.md | 5 + .gitignore | 3 + DATA-SOURCES.md | 12 + bin/yakcloud | 14 +- scripts/yakcloud_dev.py | 794 ++++++++++++++++++++++++ 6 files changed, 830 insertions(+), 2 deletions(-) create mode 100644 scripts/yakcloud_dev.py diff --git a/.claude/commands/yakcloud.md b/.claude/commands/yakcloud.md index 384c8c7..e1264cf 100644 --- a/.claude/commands/yakcloud.md +++ b/.claude/commands/yakcloud.md @@ -19,9 +19,11 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep, AskUserQuestion - **`project init [name]`** → §2 스캐폴딩(마법사 대체). - **`login`** → 비밀 입력이라 여기서 실행하지 말고, 터미널에서 `yakcloud login` 하라고 한 줄 안내. (사용자가 값을 직접 주면 `YAKCLOUD_URL=… YAKCLOUD_TOKEN=… GITEA_USER=… GITEA_TOKEN=… yakcloud config` 로 저장하되 **값은 화면에 출력 금지**.) +- **`dev …`**(`dev up|run|down|status|logs|env|reset|doctor`) → 로컬 docker 데이터소스 + `.env.dev`. + `docker` 데몬이 떠 있어야 함(없으면 `yakcloud dev doctor` 로 진단). `dev run -- <명령>` 은 앱을 배포와 동일한 `_*` 로 실행. - **그 외 전부**(`project deploy|info|check|update`, `datasource ls`, `domain`, `scale`, `set`, `env`, `source`, `bind`, `unbind`, `config`, `upgrade`) → `yakcloud $ARGUMENTS` 를 실행하고 **출력을 그대로** 보여준 뒤, - 필요할 때만 한 줄로 해석. 배포/삭제성(`deploy`·`source rm`·`unbind`·`domain`)은 실행 전 무엇을 하는지 한 줄 알리고 진행. + 필요할 때만 한 줄로 해석. 배포/삭제성(`deploy`·`source rm`·`unbind`·`domain`·`dev down --volumes`)은 실행 전 무엇을 하는지 한 줄 알리고 진행. ## 2) `project init` — 마법사 대체 (핵심) 터미널 마법사는 TTY가 없으면 안 뜬다. 네가 대신한다: diff --git a/.claude/skills/yakcloud-deploy/SKILL.md b/.claude/skills/yakcloud-deploy/SKILL.md index 8816bb3..b142d11 100644 --- a/.claude/skills/yakcloud-deploy/SKILL.md +++ b/.claude/skills/yakcloud-deploy/SKILL.md @@ -28,6 +28,11 @@ YakCloud 콘솔 API로 **데이터소스 리컨실 + 워크로드 배포 + 바 - **"프로젝트 개괄"** → `yakcloud project info` (매니페스트 + 라이브 상태) - 사전 점검 → `yakcloud project check` (dry-run) +**로컬 개발(docker 데이터소스 + 배포와 동일 env)** +- `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 domain [wl]` 도메인 등록 + 워크로드 할당 - `yakcloud scale ` replicas diff --git a/.gitignore b/.gitignore index 80cf3ae..3e1938c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ __pycache__/ *.pyc node_modules/ .env +.env.dev +.yakcloud/ +docker-compose.yakcloud-dev.yml diff --git a/DATA-SOURCES.md b/DATA-SOURCES.md index a0a773d..800160b 100644 --- a/DATA-SOURCES.md +++ b/DATA-SOURCES.md @@ -36,6 +36,18 @@ > 비밀번호·키는 인클러스터 Secret으로 주입되고 콘솔/브라우저에 노출되지 않는다. URL의 user/pw는 URL-인코딩됨. +## 로컬 개발(`yakcloud dev`) — 배포와 동일한 env 계약 +개발 중에는 `yakcloud dev up` 이 `requires[]` 를 **로컬 docker 컨테이너**로 띄우고, 위 표와 **똑같은 `_*`** +(같은 키·URL 포맷, 값만 로컬 컨테이너를 가리킴)를 `.env.dev` 로 생성한다. 즉 로컬에서 짠 코드가 배포 후에도 무수정 동작. +```sh +yakcloud dev up # requires[] 기동 + .env.dev 생성(무거운 oracle 은 명시 기동) +yakcloud dev run -- python app.py # .env.dev 주입해 앱 실행(= 배포된 파드와 동일한 env) +yakcloud dev status | logs | down [--volumes] | reset | doctor +``` +- 자격은 프로젝트 시드에서 **결정적 파생**(재현 가능), `.yakcloud/dev/` 에만 저장(gitignore). 브라우저/원격 미노출. +- `.env.dev`·`.yakcloud/`·`docker-compose.yakcloud-dev.yml` 은 커밋 금지(스캐폴드 `.gitignore` 에 포함). +- env 계약은 프로덕션 백엔드 `_bind_env_for` 와 **바이트 동일**하게 유지(단일 진실원천). + ## 앱 코드 예시 ```python # Python — alias=db (postgres) diff --git a/bin/yakcloud b/bin/yakcloud index 53b7baa..9b34fca 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.6.0" +VERSION="0.7.0" CONFIG_DIR="${YAKCLOUD_CONFIG_DIR:-$HOME/.config/yakcloud}" CONFIG_FILE="$CONFIG_DIR/config" @@ -39,6 +39,12 @@ ctl() { python3 scripts/yakcloud_ctl.py "$@" } +dev() { + [ -f scripts/yakcloud_dev.py ] || die "프로젝트 폴더가 아닙니다 — 'yakcloud project init' 로 초기화하세요." + ensure_pyyaml + python3 scripts/yakcloud_dev.py "$@" +} + # ── 자격 저장 ── 비밀(배포토큰·Gitea토큰)=OS 키체인(암호화), 그 외(URL·사용자)=config 파일(600) ── _KC="none" if command -v security >/dev/null 2>&1; then _KC="macos" @@ -320,6 +326,7 @@ cmd_project_update() { mkdir -p scripts .gitea/workflows .claude/skills cp "$tmp/s/scripts/yakcloud_deploy.py" scripts/ 2>/dev/null || true cp "$tmp/s/scripts/yakcloud_ctl.py" scripts/ 2>/dev/null || true + cp "$tmp/s/scripts/yakcloud_dev.py" scripts/ 2>/dev/null || true cp "$tmp/s/.gitea/workflows/deploy.yml" .gitea/workflows/ 2>/dev/null || true cp "$tmp/s/DATA-SOURCES.md" DATA-SOURCES.md 2>/dev/null || true rm -rf .claude/skills/yakcloud-deploy @@ -351,6 +358,10 @@ yakcloud $VERSION — YakCloud 프로젝트 CLI datasource ls 클러스터에서 이용 가능한 소스 목록(이름·타입·내부/외부·상태) source add [plan] | source rm bind | unbind + ── 로컬 개발(docker 데이터소스 + prod 동일 env) ── + dev up [source…] requires[] 를 로컬 docker 로 기동 + .env.dev 생성 + dev run -- <명령> up 보장 후 .env.dev 로 앱 실행(배포와 동일 _* 주입) + dev down [--volumes] | status | logs [src] | env | reset [src] | doctor env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER EOF } @@ -371,6 +382,7 @@ case "${1:-help}" in login) cmd_login ;; config) cmd_config ;; logout) cmd_logout ;; + dev) shift; dev "$@" ;; datasource|ds) shift; ctl source "$@" ;; domain|scale|set|env|source|bind|unbind) ctl "$@" ;; upgrade|self-update) cmd_upgrade ;; diff --git a/scripts/yakcloud_dev.py b/scripts/yakcloud_dev.py new file mode 100644 index 0000000..16d21e6 --- /dev/null +++ b/scripts/yakcloud_dev.py @@ -0,0 +1,794 @@ +#!/usr/bin/env python3 +"""yakcloud dev — 로컬 개발용 데이터소스(docker) + 프로덕션 동일 _* env 주입 엔진. + +매니페스트(yakcloud.yaml)의 requires[] 를 로컬 docker 컨테이너로 띄우고, +프로덕션 백엔드(_bind_env_for)와 **동일한 env 계약**을 .env.dev 로 생성한다. +개발한 코드가 배포 후에도 무수정 동작(같은 _URL 등)하도록 하는 게 목적. + +명령: up · down · status · logs · env · run · reset · doctor +로컬 전용(콘솔 API 미접속). 자격은 프로젝트 시드에서 결정적 파생 → .yakcloud/dev/ 에만 저장(gitignore). + +⚠ _bind_env_for 는 infra/api/yakcloud_api.py(2448~2542) 를 **바이트 동일 포팅**한 것(단일 진실원천). + 값 계약(키·URL 포맷)을 절대 바꾸지 말 것. 프로덕션 함수가 바뀌면 여기도 동일 반영(doctor 로 점검). +""" +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +import os +import re +import shutil +import socket +import subprocess +import sys +import time +from urllib.parse import quote + +try: + import yaml +except ModuleNotFoundError: + raise SystemExit("PyYAML 필요 — 'pip install pyyaml' 후 다시 실행하세요.") + +MANIFEST = "yakcloud.yaml" +DEV_DIR = ".yakcloud/dev" +SEED_FILE = f"{DEV_DIR}/.seed" +PORTS_FILE = f"{DEV_DIR}/ports.json" +COMPOSE_FILE = "docker-compose.yakcloud-dev.yml" +ENV_FILE = ".env.dev" +HOST = "127.0.0.1" +RABBIT_MGMT_PORT = 15672 # _bind_env_for 가 MGMT_URL 을 http://host:15672 로 하드코딩 → 고정 퍼블리시 +PORT_BASE_LOW, PORT_SPAN = 20000, 20000 + + +# ════════════════════ 프로덕션 env 계약(_bind_env_for) — 바이트 동일 포팅 ════════════════════ +# 원본: infra/api/yakcloud_api.py 2448~2542. HTTPException → RuntimeError 만 치환(문자열·로직 불변). +def _bind_env_for(inst: str, alias: str, stype: str, + data: dict, conn: dict) -> dict: + p = alias.upper().replace("-", "_") + + def need(key: str) -> str: + v = data.get(key) + if not v: + raise RuntimeError(f"service '{inst}' secret missing key '{key}'") + return v + + host, port = conn["host"], str(conn["port"]) + out: dict = {f"{p}_HOST": host, f"{p}_PORT": port} + if stype == "mongodb": + user, pw = need("MONGO_APP_USER"), need("MONGO_APP_PASSWORD") + db = conn.get("db") or need("MONGO_APP_DB") + out.update({ + f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db, + f"{p}_URL": (f"mongodb://{quote(user, safe='')}:{quote(pw, safe='')}" + f"@{host}:{port}/{db}?authSource={db}"), + }) + elif stype == "redis": + pw = need("REDIS_PASSWORD") + db = str(conn.get("db", 0)) + out.update({ + f"{p}_USERNAME": "", + f"{p}_PASSWORD": pw, f"{p}_DB": db, + f"{p}_URL": f"redis://:{quote(pw, safe='')}@{host}:{port}/{db}", + }) + elif stype == "minio": + ak, sk = need("MINIO_ROOT_USER"), need("MINIO_ROOT_PASSWORD") + bucket = conn.get("bucket") or need("MINIO_BUCKET") + ssl = str(data.get("_YC_SSL", "")).lower() == "true" or str(port) == "443" + scheme = "https" if ssl else "http" + netloc = host if ((ssl and str(port) == "443") or (not ssl and str(port) == "80")) else f"{host}:{port}" + endpoint = f"{scheme}://{netloc}" + out.update({ + f"{p}_USERNAME": ak, f"{p}_PASSWORD": sk, + f"{p}_URL": endpoint, f"{p}_ENDPOINT": endpoint, + f"{p}_ACCESS_KEY": ak, f"{p}_SECRET_KEY": sk, + f"{p}_BUCKET": bucket, f"{p}_REGION": "us-east-1", f"{p}_USE_SSL": "true" if ssl else "false", + }) + elif stype == "mysql": + user, pw = need("MYSQL_USER"), need("MYSQL_PASSWORD") + db = conn.get("db") or need("MYSQL_DATABASE") + out.update({ + f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db, + f"{p}_URL": f"mysql://{quote(user, safe='')}:{quote(pw, safe='')}@{host}:{port}/{db}", + }) + elif stype == "postgresql": + user, pw = need("POSTGRES_USER"), need("POSTGRES_PASSWORD") + db = conn.get("db") or need("POSTGRES_DB") + out.update({ + f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db, + f"{p}_URL": f"postgresql://{quote(user, safe='')}:{quote(pw, safe='')}@{host}:{port}/{db}", + }) + elif stype == "mariadb": + user, pw = need("MARIADB_USER"), need("MARIADB_PASSWORD") + db = conn.get("db") or need("MARIADB_DATABASE") + out.update({ + f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db, + f"{p}_URL": f"mysql://{quote(user, safe='')}:{quote(pw, safe='')}@{host}:{port}/{db}", + }) + elif stype == "rabbitmq": + user, pw = need("RABBITMQ_DEFAULT_USER"), need("RABBITMQ_DEFAULT_PASS") + vhost = conn.get("vhost") or need("RABBITMQ_DEFAULT_VHOST") + out.update({ + f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_VHOST": vhost, + f"{p}_URL": f"amqp://{quote(user, safe='')}:{quote(pw, safe='')}@{host}:{port}/{quote(vhost, safe='')}", + f"{p}_MGMT_URL": f"http://{host}:15672", + }) + elif stype == "solr": + core = conn.get("core") or need("SOLR_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 stype == "oracle": + user, pw = need("ORACLE_USER"), need("ORACLE_PASSWORD") + service = conn.get("service") or need("ORACLE_SERVICE") + out.update({ + f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_SERVICE": service, + f"{p}_URL": f"oracle://{quote(user, safe='')}:{quote(pw, safe='')}@{host}:{port}/{service}", + f"{p}_JDBC_URL": f"jdbc:oracle:thin:@//{host}:{port}/{service}", + f"{p}_DSN": f"{host}:{port}/{service}", + }) + else: + raise RuntimeError(f"unknown service type '{stype}'") + return out +# ════════════════════ (포팅 끝) ════════════════════ + + +TYPES = { # type → 내부포트(컨테이너), 무거움 여부 + "postgresql": {"port": 5432}, "mysql": {"port": 3306}, "mariadb": {"port": 3306}, + "mongodb": {"port": 27017}, "redis": {"port": 6379}, "minio": {"port": 9000}, + "rabbitmq": {"port": 5672}, "solr": {"port": 8983}, "oracle": {"port": 1521, "heavy": True}, +} + + +# ── 유틸 ──────────────────────────────────────────────────────────────── +def die(msg: str) -> None: + sys.exit(f" ✗ {msg}") + + +def sh(*args, check=True, capture=False, env=None): + r = subprocess.run(args, text=True, env=env, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None) + if check and r.returncode != 0: + detail = (r.stderr or r.stdout or "").strip() if capture else "" + die(f"명령 실패({r.returncode}): {' '.join(args[:3])}…\n {detail}") + return r + + +def load_manifest() -> dict: + if not os.path.exists(MANIFEST): + die(f"{MANIFEST} 없음 — 'yakcloud project init' 먼저") + return yaml.safe_load(open(MANIFEST)) or {} + + +def project_name(m: dict) -> str: + raw = (m.get("project") or os.path.basename(os.getcwd()) or "app").lower() + return re.sub(r"[^a-z0-9]+", "-", raw).strip("-") or "app" + + +def manifest_hash(m: dict) -> str: + return hashlib.sha256(json.dumps(m, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:12] + + +def compose_project(m: dict) -> str: + return f"yakdev-{project_name(m)}-{manifest_hash(m)[:8]}" + + +def requires(m: dict) -> list[dict]: + return [r for r in (m.get("requires") or []) if r.get("name") and r.get("type")] + + +def binds(m: dict) -> list[tuple[str, str, str]]: + """(workload, alias, source) 목록 — .env.dev 는 실제 바인딩된 alias 기준으로 생성.""" + out = [] + for w in (m.get("workloads") or []): + for b in (w.get("binds") or []): + if b.get("alias") and b.get("source"): + out.append((w.get("name", "?"), b["alias"], b["source"])) + return out + + +# ── 결정적 자격 파생(시드→HMAC) ────────────────────────────────────────── +_B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + +def ensure_seed() -> bytes: + os.makedirs(DEV_DIR, exist_ok=True) + if os.path.exists(SEED_FILE): + return bytes.fromhex(open(SEED_FILE).read().strip()) + seed = os.urandom(32) + with open(SEED_FILE, "w") as f: + f.write(seed.hex()) + os.chmod(SEED_FILE, 0o600) + return seed + + +def _hmac(seed: bytes, label: str) -> bytes: + return hmac.new(seed, label.encode(), hashlib.sha256).digest() + + +def _b62(b: bytes, n: int) -> str: + num = int.from_bytes(b, "big") + out = [] + while num and len(out) < n: + num, r = divmod(num, 62) + out.append(_B62[r]) + while len(out) < n: + out.append("0") + return "".join(out[:n]) + + +def _hex(b: bytes, n: int) -> str: + return b.hex()[:n] + + +def _norm(name: str) -> str: + s = re.sub(r"[^a-z0-9_]+", "_", (name or "app").lower()).strip("_") + if not s or not s[0].isalpha(): + s = "db_" + s + return s[:32] + + +def creds_for(seed: bytes, source: str, stype: str) -> dict: + """(seed, source, type) 로부터 결정적 자격. 특수문자 배제(base62)로 컨테이너 엔트리포인트 파싱 안전.""" + def d(field): + return _hmac(seed, f"{source}:{stype}:{field}") + pw = _b62(d("password"), 24) + if stype in ("postgresql", "mysql", "mariadb"): + return {"user": "app_" + _hex(d("user"), 8), "password": pw, + "db": _norm(source), "root": _b62(d("root"), 24)} + if stype == "mongodb": + return {"user": "app_" + _hex(d("user"), 8), "password": pw, "db": _norm(source), + "root_user": "root", "root_pw": _b62(d("root"), 24)} + if stype == "redis": + return {"password": pw, "db": "0"} + if stype == "minio": + return {"access": _b62(d("access"), 20), "secret": _b62(d("secret"), 40), "bucket": _norm(source)} + if stype == "rabbitmq": + return {"user": "app_" + _hex(d("user"), 8), "password": pw, "vhost": _norm(source)} + if stype == "solr": + return {"core": _norm(source)} + if stype == "oracle": + return {"user": "APP_" + _hex(d("user"), 8).upper(), "password": pw, + "service": "FREEPDB1", "sys_pw": _b62(d("sys"), 24)} + die(f"지원하지 않는 타입: {stype}") + + +def data_conn(source: str, stype: str, cr: dict, pub: int) -> tuple[dict, dict]: + """_bind_env_for 에 넘길 (data=인스턴스 Secret 상당, conn=접속정보). 키명은 need()/_DS_SQL_KEYS 와 정확히 일치.""" + conn = {"host": HOST, "port": pub} + if stype == "postgresql": + data = {"POSTGRES_USER": cr["user"], "POSTGRES_PASSWORD": cr["password"], "POSTGRES_DB": cr["db"]} + conn["db"] = cr["db"] + elif stype == "mysql": + data = {"MYSQL_USER": cr["user"], "MYSQL_PASSWORD": cr["password"], "MYSQL_DATABASE": cr["db"]} + conn["db"] = cr["db"] + elif stype == "mariadb": + data = {"MARIADB_USER": cr["user"], "MARIADB_PASSWORD": cr["password"], "MARIADB_DATABASE": cr["db"]} + conn["db"] = cr["db"] + elif stype == "mongodb": + data = {"MONGO_APP_USER": cr["user"], "MONGO_APP_PASSWORD": cr["password"], "MONGO_APP_DB": cr["db"]} + conn["db"] = cr["db"] + elif stype == "redis": + data = {"REDIS_PASSWORD": cr["password"]} + conn["db"] = 0 + elif stype == "minio": + data = {"MINIO_ROOT_USER": cr["access"], "MINIO_ROOT_PASSWORD": cr["secret"], "MINIO_BUCKET": cr["bucket"]} + conn["bucket"] = cr["bucket"] + elif stype == "rabbitmq": + data = {"RABBITMQ_DEFAULT_USER": cr["user"], "RABBITMQ_DEFAULT_PASS": cr["password"], + "RABBITMQ_DEFAULT_VHOST": cr["vhost"]} + conn["vhost"] = cr["vhost"] + elif stype == "solr": + data = {"SOLR_CORE": cr["core"]} + conn["core"] = cr["core"] + elif stype == "oracle": + data = {"ORACLE_USER": cr["user"], "ORACLE_PASSWORD": cr["password"], "ORACLE_SERVICE": cr["service"]} + conn["service"] = cr["service"] + else: + die(f"지원하지 않는 타입: {stype}") + return data, conn + + +# ── 포트 할당(결정적 후보 + 점유 검사, ports.json 고정) ────────────────── +def _free(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind((HOST, port)) + return True + except OSError: + return False + + +def alloc_ports(m: dict, reqs: list[dict]) -> dict: + os.makedirs(DEV_DIR, exist_ok=True) + saved = {} + if os.path.exists(PORTS_FILE): + saved = json.load(open(PORTS_FILE)) + base = PORT_BASE_LOW + (int(manifest_hash(m), 16) % PORT_SPAN) + used = set() + ports = {} + for i, r in enumerate(sorted(reqs, key=lambda x: x["name"])): + name, stype = r["name"], r["type"] + need_aux = stype in ("minio", "rabbitmq") + prev = saved.get(name) + # 기존 할당이 여전히 비어 있으면 재사용(재현성). 아니면 결정적 후보에서 재탐색. + cand = (prev.get("port") if isinstance(prev, dict) else None) or (base + i * 10) + while cand in used or (not _free(cand) and cand != (prev or {}).get("port")): + cand += 1 + used.add(cand) + entry = {"port": cand} + if need_aux: + acand = (prev.get("aux") if isinstance(prev, dict) else None) or (cand + 1) + while acand in used or not _free(acand): + acand += 1 + used.add(acand) + entry["aux"] = acand + ports[name] = entry + json.dump(ports, open(PORTS_FILE, "w"), indent=2) + return ports + + +# ── compose 서비스/초기화 자산 생성 ────────────────────────────────────── +def build_service(source: str, stype: str, cr: dict, pub: int, aux: int | None): + """(compose 서비스 dict, {초기화 사이드카 서비스}, {써야 할 자산파일: 내용}) 반환.""" + internal = TYPES[stype]["port"] + svc = {"container_name": f"yakdev-{source}", "restart": "unless-stopped", + "ports": [f"{HOST}:{pub}:{internal}"]} + side, assets = {}, {} + if stype == "postgresql": + svc.update(image="postgres:16-alpine", + environment={"POSTGRES_USER": cr["user"], "POSTGRES_PASSWORD": cr["password"], "POSTGRES_DB": cr["db"]}, + volumes=[f"yakdev-{source}-data:/var/lib/postgresql/data"], + healthcheck={"test": ["CMD-SHELL", f"pg_isready -U {cr['user']} -d {cr['db']}"], + "interval": "3s", "timeout": "3s", "retries": 20}) + elif stype in ("mysql", "mariadb"): + img = "mysql:8.4" if stype == "mysql" else "mariadb:11.4" + pre = "MYSQL" if stype == "mysql" else "MARIADB" + svc.update(image=img, + environment={f"{pre}_USER": cr["user"], f"{pre}_PASSWORD": cr["password"], + f"{pre}_DATABASE": cr["db"], f"{pre}_ROOT_PASSWORD": cr["root"]}, + volumes=[f"yakdev-{source}-data:/var/lib/mysql"]) + if stype == "mysql": + svc["healthcheck"] = {"test": ["CMD-SHELL", f"mysqladmin ping -h127.0.0.1 -u{cr['user']} -p{cr['password']}"], + "interval": "3s", "timeout": "3s", "retries": 30, "start_period": "20s"} + else: + svc["healthcheck"] = {"test": ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized"], + "interval": "3s", "timeout": "3s", "retries": 30, "start_period": "10s"} + elif stype == "mongodb": + # authSource= 계약 → 대상 db 에 앱유저 생성(initdb.d, 볼륨 최초부팅에만 실행). + js = (f"db.getSiblingDB({json.dumps(cr['db'])}).createUser({{" + f"user:{json.dumps(cr['user'])},pwd:{json.dumps(cr['password'])}," + f"roles:[{{role:'dbOwner',db:{json.dumps(cr['db'])}}}]}});\n") + assets[f"{source}-init/00-appuser.js"] = js + svc.update(image="mongo:7", + environment={"MONGO_INITDB_ROOT_USERNAME": cr["root_user"], "MONGO_INITDB_ROOT_PASSWORD": cr["root_pw"], + "MONGO_INITDB_DATABASE": cr["db"]}, + volumes=[f"yakdev-{source}-data:/data/db", + f"./{DEV_DIR}/{source}-init:/docker-entrypoint-initdb.d:ro"], + healthcheck={"test": ["CMD-SHELL", "mongosh --quiet --eval 'db.adminCommand(\"ping\").ok' | grep 1"], + "interval": "3s", "timeout": "3s", "retries": 20, "start_period": "5s"}) + elif stype == "redis": + svc.update(image="redis:7-alpine", + command=["redis-server", "--requirepass", cr["password"], "--save", ""], + healthcheck={"test": ["CMD-SHELL", f"redis-cli -a {cr['password']} ping | grep -q PONG"], + "interval": "3s", "timeout": "3s", "retries": 10}) + elif stype == "minio": + svc.update(image="minio/minio:latest", + command=["server", "/data", "--console-address", ":9001"], + environment={"MINIO_ROOT_USER": cr["access"], "MINIO_ROOT_PASSWORD": cr["secret"]}, + volumes=[f"yakdev-{source}-data:/data"], + healthcheck={"test": ["CMD-SHELL", "mc ready local 2>/dev/null || curl -fsS http://localhost:9000/minio/health/live"], + "interval": "3s", "timeout": "3s", "retries": 20, "start_period": "5s"}) + if aux: + svc["ports"].append(f"{HOST}:{aux}:9001") + # 버킷 생성 사이드카(멱등). minio healthy 후 실행, 성공 exit. + side[f"{source}-init"] = { + "image": "minio/mc:latest", "container_name": f"yakdev-{source}-init", "restart": "no", + "depends_on": {source: {"condition": "service_healthy"}}, + "entrypoint": ["sh", "-c", + f"mc alias set local http://{source}:9000 {cr['access']} {cr['secret']} && " + f"mc mb -p local/{cr['bucket']} && echo bucket-ready"]} + elif stype == "rabbitmq": + svc.update(image="rabbitmq:3.13-management", + environment={"RABBITMQ_DEFAULT_USER": cr["user"], "RABBITMQ_DEFAULT_PASS": cr["password"], + "RABBITMQ_DEFAULT_VHOST": cr["vhost"]}, + volumes=[f"yakdev-{source}-data:/var/lib/rabbitmq"], + healthcheck={"test": ["CMD-SHELL", "rabbitmq-diagnostics -q check_running && rabbitmq-diagnostics -q check_port_connectivity"], + "interval": "5s", "timeout": "5s", "retries": 20, "start_period": "20s"}) + # MGMT_URL 은 계약상 http://host:15672 고정 → mgmt 는 항상 15672 로 퍼블리시. + svc["ports"].append(f"{HOST}:{RABBIT_MGMT_PORT}:15672") + elif stype == "solr": + svc.update(image="solr:9", + command=["solr-precreate", cr["core"]], + volumes=[f"yakdev-{source}-data:/var/solr"], + healthcheck={"test": ["CMD-SHELL", f"curl -fsS http://localhost:8983/solr/{cr['core']}/admin/ping || exit 1"], + "interval": "3s", "timeout": "3s", "retries": 20, "start_period": "15s"}) + elif stype == "oracle": + svc.update(image="gvenzl/oracle-free:23-slim-faststart", + environment={"ORACLE_PASSWORD": cr["sys_pw"], "APP_USER": cr["user"], "APP_USER_PASSWORD": cr["password"]}, + volumes=[f"yakdev-{source}-data:/opt/oracle/oradata"], + healthcheck={"test": ["CMD-SHELL", "healthcheck.sh"], + "interval": "10s", "timeout": "10s", "retries": 40, "start_period": "90s"}) + else: + die(f"지원하지 않는 타입: {stype}") + return svc, side, assets + + +def gen_compose(m: dict, reqs: list[dict], ports: dict, seed: bytes) -> dict: + services, volumes = {}, {} + os.makedirs(DEV_DIR, exist_ok=True) + for r in reqs: + name, stype = r["name"], r["type"] + cr = creds_for(seed, name, stype) + pub = ports[name]["port"] + aux = ports[name].get("aux") + svc, side, assets = build_service(name, stype, cr, pub, aux) + services[name] = svc + services.update(side) + volumes[f"yakdev-{name}-data"] = None + for rel, content in assets.items(): + path = os.path.join(DEV_DIR, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + open(path, "w").write(content) + doc = {"name": compose_project(m), "services": services} + if volumes: + doc["volumes"] = {k: (v or {}) for k, v in volumes.items()} + yaml.safe_dump(doc, open(COMPOSE_FILE, "w"), sort_keys=False, allow_unicode=True, default_flow_style=False) + return doc + + +# ── .env.dev 생성(바인딩 alias 별 _* + 정적 env 병합) ────────────── +def compute_env(m: dict, reqs: list[dict], ports: dict, seed: bytes) -> tuple[dict, list[str]]: + by_name = {r["name"]: r["type"] for r in reqs} + env: dict = {} + lines: list[str] = [] + for wl, alias, source in binds(m): + stype = by_name.get(source) + if not stype or source not in ports: + continue + cr = creds_for(seed, source, stype) + data, conn = data_conn(source, stype, cr, ports[source]["port"]) + block = _bind_env_for(source, alias, stype, data, conn) + lines.append(f"# workload {wl} · alias {alias} → {source} ({stype})") + for k, v in block.items(): + env[k] = v + lines.append(f"{k}={v}") + lines.append("") + # 워크로드 정적 env(prod 배포 env 세트 재현) + static = [] + for w in (m.get("workloads") or []): + for e in (w.get("env") or []): + if isinstance(e, dict) and e.get("key"): + static.append((e["key"], str(e.get("value", "")))) + if isinstance(m.get("env"), dict): + static += [(k, str(v)) for k, v in m["env"].items()] + if static: + lines.append("# workloads[].env (정적)") + for k, v in static: + env[k] = v + lines.append(f"{k}={v}") + lines.append("") + return env, lines + + +def write_env_file(m: dict, lines: list[str]) -> None: + header = [ + "# ⚠ 자동 생성 — 'yakcloud dev up/run' 이 매번 덮어씀. 직접 수정 금지.", + f"# manifest-hash: {manifest_hash(m)}", + "# 프로덕션 배포 시 백엔드가 주입하는 _* 와 동일한 계약(로컬 컨테이너를 가리킴).", + "", + ] + open(ENV_FILE, "w").write("\n".join(header + lines) + "\n") + os.chmod(ENV_FILE, 0o600) + + +def parse_env_file() -> dict: + if not os.path.exists(ENV_FILE): + die(f"{ENV_FILE} 없음 — 'yakcloud dev up' 먼저") + out = {} + for ln in open(ENV_FILE): + ln = ln.rstrip("\n") + if not ln or ln.lstrip().startswith("#") or "=" not in ln: + continue + k, v = ln.split("=", 1) + out[k.strip()] = v + return out + + +# ── docker/compose 프리플라이트 ────────────────────────────────────────── +def preflight() -> None: + if subprocess.run(["docker", "version"], capture_output=True, text=True).returncode != 0: + die("docker 데몬에 접속 불가 — Docker Desktop/데몬을 켜세요.") + if subprocess.run(["docker", "compose", "version"], capture_output=True, text=True).returncode != 0: + die("'docker compose' 없음 — Docker Compose v2 필요.") + + +def compose(m: dict, *args, check=True, capture=False): + return sh("docker", "compose", "-p", compose_project(m), "-f", COMPOSE_FILE, *args, check=check, capture=capture) + + +def _ps_state(m: dict) -> dict: + r = compose(m, "ps", "-a", "--format", "json", check=False, capture=True) + st = {} + for ln in (r.stdout or "").splitlines(): + ln = ln.strip() + if not ln: + continue + try: + j = json.loads(ln) + st[j.get("Service")] = j + except Exception: + pass + return st + + +def wait_ready(m: dict, timeout: int) -> None: + """compose --wait 대체(일회성 init 컨테이너 exit0 을 실패로 오인하는 문제 회피). + 장기 서비스=healthy(또는 healthcheck 없으면 running), 일회성(*-init/restart:no)=exit 0 로 판정.""" + doc = yaml.safe_load(open(COMPOSE_FILE)) + long_svc, oneshot = [], [] + for name, sv in doc["services"].items(): + (oneshot if (name.endswith("-init") or str(sv.get("restart")) == "no") else long_svc).append(name) + deadline = time.time() + timeout + while True: + state = _ps_state(m) + pending, failed = [], [] + for n in long_svc: + j = state.get(n) or {} + h, s = (j.get("Health") or ""), (j.get("State") or "") + if h == "healthy" or (h == "" and s == "running"): + continue + (failed if h == "unhealthy" else pending).append(n) + for n in oneshot: + j = state.get(n) or {} + s, ec = (j.get("State") or ""), j.get("ExitCode") + if s == "exited": + if ec in (0, "0", None): + continue + failed.append(f"{n}(init exit={ec})") + else: + pending.append(n) + if failed: + die(f"기동 실패: {', '.join(map(str, failed))} — 'yakcloud dev logs <소스>' 로 확인") + if not pending: + return + if time.time() > deadline: + die(f"준비 대기 초과({timeout}s): {', '.join(pending)} — 'yakcloud dev logs' 확인 또는 --timeout 늘리기") + time.sleep(2) + + +# ── 명령 ──────────────────────────────────────────────────────────────── +def select_reqs(m: dict, names: list[str]) -> list[dict]: + reqs = requires(m) + if not reqs: + die("requires[] 가 비어 있음 — 'yakcloud source add ' 로 소스를 추가하세요.") + if names: + want = set(names) + reqs = [r for r in reqs if r["name"] in want] + missing = want - {r["name"] for r in reqs} + if missing: + die(f"매니페스트 requires 에 없음: {', '.join(sorted(missing))}") + return reqs + + +def cmd_up(a) -> None: + preflight() + m = load_manifest() + reqs = select_reqs(m, a.source) + heavy = [r["name"] for r in reqs if TYPES.get(r["type"], {}).get("heavy")] + if heavy and not a.source: + print(f" ⚠ 무거운 소스 제외(기본): {', '.join(heavy)} — 필요하면 'yakcloud dev up {heavy[0]}' 로 명시 기동") + reqs = [r for r in reqs if r["name"] not in heavy] + if not reqs: + die("기동할 소스가 없습니다.") + seed = ensure_seed() + if a.fresh: + compose(m, "down", "-v", check=False, capture=True) + ports = alloc_ports(m, reqs) + gen_compose(m, reqs, ports, seed) + names = [r["name"] for r in reqs] + print(f"▸ dev 소스 기동: {', '.join(names)} (project {compose_project(m)})") + # compose 파일은 선택된 소스(+init 사이드카)만 담으므로 서비스 명시 없이 전체 up. + up = ["up", "-d"] + if a.pull: + up.append("--pull=always") + compose(m, *up) + if not a.no_wait: + wait_ready(m, a.timeout) + env, lines = compute_env(m, reqs, ports, seed) + write_env_file(m, lines) + print(f"✓ 준비 완료 · {ENV_FILE} 생성({len(env)}개 env)") + for r in reqs: + p = ports[r["name"]]["port"] + print(f" {r['name']:<16} {r['type']:<11} 127.0.0.1:{p}") + if not binds(m): + print(" · 아직 바인딩 없음 — 'yakcloud bind ' 후 다시 up 하면 _* 가 채워집니다.") + print(f" 다음: 앱 실행 = yakcloud dev run -- <명령> (예: yakcloud dev run -- python app.py)") + + +def cmd_down(a) -> None: + m = load_manifest() + args = ["down"] + if a.volumes: + args.append("-v") + compose(m, *args, check=False) + if a.volumes: + shutil.rmtree(DEV_DIR, ignore_errors=True) # .seed·ports.json·-init 자산 일괄 삭제 + for f in (ENV_FILE, COMPOSE_FILE): + if os.path.exists(f): + os.remove(f) + print("✓ dev 중지 + 볼륨·자격·env·compose 삭제(완전 초기화)") + else: + print(f"✓ dev 중지(볼륨 보존 — 재 up 시 자격/데이터 유지). 완전 초기화=--volumes") + + +def cmd_status(a) -> None: + m = load_manifest() + if not os.path.exists(COMPOSE_FILE): + die("dev 미기동 — 'yakcloud dev up' 먼저") + r = compose(m, "ps", "--format", "json", check=False, capture=True) + rows = [] + for ln in (r.stdout or "").splitlines(): + ln = ln.strip() + if ln: + try: + rows.append(json.loads(ln)) + except Exception: + pass + ports = json.load(open(PORTS_FILE)) if os.path.exists(PORTS_FILE) else {} + bmap = {s: al for _, al, s in binds(m)} + print(f"dev 상태 · project {compose_project(m)}") + print(f" {'소스':<16} {'상태':<20} {'포트':<8} {'alias'}") + for row in rows: + name = row.get("Service", "?") + st = row.get("Health") or row.get("State", "?") + p = ports.get(name, {}).get("port", "-") + print(f" {name:<16} {str(st):<20} {str(p):<8} {bmap.get(name, '')}") + fresh = "최신" + if os.path.exists(ENV_FILE): + want = manifest_hash(m) + cur = next((l.split(":", 1)[1].strip() for l in open(ENV_FILE) if l.startswith("# manifest-hash:")), "") + fresh = "최신" if cur == want else "⚠ 오래됨(매니페스트 변경 — 'yakcloud dev up' 재실행)" + else: + fresh = "없음" + print(f" {ENV_FILE}: {fresh}") + + +def cmd_logs(a) -> None: + m = load_manifest() + args = ["logs"] + if a.follow: + args.append("-f") + if a.source: + args.append(a.source) + compose(m, *args, check=False) + + +def cmd_env(a) -> None: + m = load_manifest() + seed = ensure_seed() + reqs = requires(m) + ports = json.load(open(PORTS_FILE)) if os.path.exists(PORTS_FILE) else alloc_ports(m, reqs) + env, lines = compute_env(m, reqs, ports, seed) + if a.json: + print(json.dumps(env, ensure_ascii=False, indent=2)) + elif a.export: + for k, v in env.items(): + print(f"export {k}={json.dumps(v)}") + elif a.check: + ok = os.path.exists(ENV_FILE) and os.path.exists(COMPOSE_FILE) + print(f" {ENV_FILE}={'있음' if os.path.exists(ENV_FILE) else '없음'} · compose={'있음' if os.path.exists(COMPOSE_FILE) else '없음'} · env {len(env)}개") + if not ok: + die("dev 미기동 — 'yakcloud dev up' 먼저") + else: + print("\n".join(lines)) + + +def cmd_run(a) -> None: + if not a.cmd: + die("실행할 명령을 주세요 — 예: yakcloud dev run -- python app.py") + m = load_manifest() + if not a.no_up: + # up 을 선행(최신 .env.dev 보장). 인자 없이 = 모든(무거운 것 제외) 소스. + up_args = argparse.Namespace(source=[], fresh=False, no_wait=False, pull=False, timeout=a.timeout) + cmd_up(up_args) + env = dict(os.environ) + env.update(parse_env_file()) + print(f"▸ dev run: {' '.join(a.cmd)}") + os.execvpe(a.cmd[0], a.cmd, env) + + +def cmd_reset(a) -> None: + m = load_manifest() + if os.path.exists(COMPOSE_FILE): + if a.source: + compose(m, "rm", "-fsv", a.source, check=False) + else: + compose(m, "down", "-v", check=False) + print(f"✓ 초기화 완료{'('+a.source+')' if a.source else ''} — 'yakcloud dev up' 로 재기동(초기화 재실행)") + + +def cmd_doctor(a) -> None: + print("yakcloud dev doctor") + d = subprocess.run(["docker", "version"], capture_output=True, text=True) + print(f" docker : {'OK' if d.returncode == 0 else '✗ 데몬 미가동'}") + c = subprocess.run(["docker", "compose", "version"], capture_output=True, text=True) + print(f" docker compose : {'OK ' + (c.stdout or '').strip()[:40] if c.returncode == 0 else '✗ 없음'}") + print(f" arch : {os.uname().machine} (arm64=Apple Silicon: oracle 이미지 무거움/미지원 가능)") + m = load_manifest() if os.path.exists(MANIFEST) else {} + reqs = requires(m) + print(f" requires : {', '.join(r['name']+'('+r['type']+')' for r in reqs) or '(없음)'}") + # SSOT drift 점검: 프로덕션 원본이 있으면(_유지보수 환경_) 함수 본문 비교. + src = "infra/api/yakcloud_api.py" + if os.path.exists(src): + body = open(src).read() + ok = 'f"{p}_URL": (f"mongodb://{quote(user, safe=' in body and 'f"{p}_MGMT_URL": f"http://{host}:15672"' in body + print(f" _bind_env_for SSOT: {'참조 원본 발견 — 계약 마커 일치' if ok else '⚠ 원본과 계약 마커 불일치(포팅 재검토)'}") + else: + print(" _bind_env_for SSOT: (프로덕션 원본 없음 — 배포 시 백엔드가 동일 계약 주입)") + # 포트 충돌 + if reqs: + ports = alloc_ports(m, reqs) + conflict = [n for n, p in ports.items() if not _free(p["port"])] + print(f" 포트 : {'충돌 없음' if not conflict else '사용 중(재기동 시 자동 시프트): ' + ', '.join(conflict)}") + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="yakcloud dev", description="로컬 개발 데이터소스(docker) + prod 동일 env") + sub = p.add_subparsers(dest="cmd", required=True) + + up = sub.add_parser("up", help="소스 기동 + .env.dev 생성") + up.add_argument("source", nargs="*", help="일부만(기본=전체, 무거운 것 제외)") + up.add_argument("--fresh", action="store_true", help="볼륨·자격 초기화 후 기동") + up.add_argument("--no-wait", action="store_true", help="healthy 대기 생략") + up.add_argument("--pull", action="store_true", help="이미지 최신 pull") + up.add_argument("--timeout", type=int, default=180, help="healthy 대기 초(기본 180)") + up.set_defaults(fn=cmd_up) + + dn = sub.add_parser("down", help="중지(기본=볼륨 보존)") + dn.add_argument("--volumes", action="store_true", help="데이터 볼륨·자격·env 까지 삭제") + dn.set_defaults(fn=cmd_down) + + sub.add_parser("status", help="컨테이너 상태·포트·env 신선도").set_defaults(fn=cmd_status) + + lg = sub.add_parser("logs", help="컨테이너 로그") + lg.add_argument("source", nargs="?") + lg.add_argument("-f", "--follow", action="store_true") + lg.set_defaults(fn=cmd_logs) + + ev = sub.add_parser("env", help="_* env 출력") + ev.add_argument("--export", action="store_true", help="eval 용 export K=V") + ev.add_argument("--json", action="store_true") + ev.add_argument("--check", action="store_true", help="기동/신선도 진단") + ev.set_defaults(fn=cmd_env) + + rn = sub.add_parser("run", help="up 보장 후 .env.dev 로 앱 실행") + rn.add_argument("--no-up", action="store_true", help="기동 생략(이미 떠 있음)") + rn.add_argument("--timeout", type=int, default=180) + rn.add_argument("cmd", nargs=argparse.REMAINDER, help="-- 뒤에 실행할 명령") + rn.set_defaults(fn=cmd_run) + + rs = sub.add_parser("reset", help="볼륨 삭제 후 재초기화") + rs.add_argument("source", nargs="?") + rs.set_defaults(fn=cmd_reset) + + sub.add_parser("doctor", help="docker/compose/포트/계약 점검").set_defaults(fn=cmd_doctor) + return p + + +def main() -> None: + args = build_parser().parse_args() + # run 의 REMAINDER 는 앞의 '--' 를 포함할 수 있음 → 제거. + if getattr(args, "cmd", None) == "run" or getattr(args, "fn", None) is cmd_run: + if args.cmd and args.cmd[0] == "--": + args.cmd = args.cmd[1:] + args.fn(args) + + +if __name__ == "__main__": + main()