cli v0.11.0: 로컬 dev/migrate 제거, 개발/운영 클러스터 승격 모델. environments.{dev,prod} 매니페스트; project deploy=개발 클러스터, project promote=재빌드 없이 운영 클러스터+운영 도메인. CI=dev 환경. dev/prod dry-run 검증
This commit is contained in:
@ -1,797 +0,0 @@
|
||||
#!/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", "-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="<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()
|
||||
Reference in New Issue
Block a user