#!/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", "-fs", a.source, check=False) # named 볼륨은 compose rm -v 로 안 지워짐 → 명시 삭제(진짜 초기화) sh("docker", "volume", "rm", "-f", f"{compose_project(m)}_yakdev-{a.source}-data", check=False, capture=True) 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()