cli v0.7.0: 'yakcloud dev' — 로컬 docker 데이터소스 + 프로덕션 _bind_env_for 바이트 동일 <ALIAS>_* env(.env.dev). up/run/down/status/logs/env/reset/doctor; 결정적 자격 파생; 9종 패리티+6종 docker E2E 검증. .gitignore 에 dev 산출물

This commit is contained in:
2026-08-27 19:27:09 +09:00
parent 31cf7fbb49
commit a82de865f7
6 changed files with 830 additions and 2 deletions

View File

@ -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 -- <명령>` 은 앱을 배포와 동일한 `<ALIAS>_*` 로 실행.
- **그 외 전부**(`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가 없으면 안 뜬다. 네가 대신한다:

View File

@ -28,6 +28,11 @@ YakCloud 콘솔 API로 **데이터소스 리컨실 + 워크로드 배포 + 바
- **"프로젝트 개괄"** → `yakcloud project info` (매니페스트 + 라이브 상태)
- 사전 점검 → `yakcloud project check` (dry-run)
**로컬 개발(docker 데이터소스 + 배포와 동일 env)**
- `yakcloud dev up``requires[]` 를 로컬 docker 로 기동 + `.env.dev`(프로덕션과 **바이트 동일**한 `<ALIAS>_*`) 생성
- `yakcloud dev run -- <명령>` — up 보장 후 `.env.dev` 주입해 앱 실행(배포된 파드와 동일 env → 무수정 이식)
- `yakcloud dev status|logs|down [--volumes]|reset|doctor` — 자격은 시드에서 결정적 파생(`.yakcloud/dev/`, gitignore)
**배포환경 설정(앱별) — 매니페스트 수정 + 배포중이면 재빌드 없이 라이브 반영**
- `yakcloud domain <fqdn> [wl]` 도메인 등록 + 워크로드 할당
- `yakcloud scale <wl> <n>` replicas

3
.gitignore vendored
View File

@ -3,3 +3,6 @@ __pycache__/
*.pyc
node_modules/
.env
.env.dev
.yakcloud/
docker-compose.yakcloud-dev.yml

View File

@ -36,6 +36,18 @@
> 비밀번호·키는 인클러스터 Secret으로 주입되고 콘솔/브라우저에 노출되지 않는다. URL의 user/pw는 URL-인코딩됨.
## 로컬 개발(`yakcloud dev`) — 배포와 동일한 env 계약
개발 중에는 `yakcloud dev up``requires[]`**로컬 docker 컨테이너**로 띄우고, 위 표와 **똑같은 `<ALIAS>_*`**
(같은 키·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)

View File

@ -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 <name> <type> [plan] | source rm <name>
bind <wl> <source> <alias> | unbind <wl> <alias>
── 로컬 개발(docker 데이터소스 + prod 동일 env) ──
dev up [source…] requires[] 를 로컬 docker 로 기동 + .env.dev 생성
dev run -- <명령> up 보장 후 .env.dev 로 앱 실행(배포와 동일 <ALIAS>_* 주입)
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 ;;

794
scripts/yakcloud_dev.py Normal file
View File

@ -0,0 +1,794 @@
#!/usr/bin/env python3
"""yakcloud dev — 로컬 개발용 데이터소스(docker) + 프로덕션 동일 <ALIAS>_* env 주입 엔진.
매니페스트(yakcloud.yaml)의 requires[] 를 로컬 docker 컨테이너로 띄우고,
프로덕션 백엔드(_bind_env_for)와 **동일한 env 계약**을 .env.dev 로 생성한다.
개발한 코드가 배포 후에도 무수정 동작(같은 <ALIAS>_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> 계약 → 대상 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 별 <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)}",
"# 프로덕션 배포 시 백엔드가 주입하는 <ALIAS>_* 와 동일한 계약(로컬 컨테이너를 가리킴).",
"",
]
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 <name> <type>' 로 소스를 추가하세요.")
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 <workload> <source> <alias>' 후 다시 up 하면 <ALIAS>_* 가 채워집니다.")
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·<src>-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="<ALIAS>_* 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()