cli v0.11.0: 로컬 dev/migrate 제거, 개발/운영 클러스터 승격 모델. environments.{dev,prod} 매니페스트; project deploy=개발 클러스터, project promote=재빌드 없이 운영 클러스터+운영 도메인. CI=dev 환경. dev/prod dry-run 검증

This commit is contained in:
2026-08-28 05:07:14 +09:00
parent 9353cd7706
commit dd6cf8a880
11 changed files with 98 additions and 1441 deletions

View File

@ -60,9 +60,13 @@ def save_manifest(m: dict) -> None:
def cluster_ref(m: dict) -> str:
ref = os.environ.get("YAKCLOUD_CLUSTER") or m.get("cluster")
# 조회/설정은 기본 개발 환경 클러스터 기준. environments.<env>.cluster → 하위호환 cluster:.
env = os.environ.get("YAKCLOUD_ENV", "dev")
envs = m.get("environments") or {}
ref = (os.environ.get("YAKCLOUD_CLUSTER")
or (envs.get(env) or {}).get("cluster") or m.get("cluster"))
if not ref:
sys.exit(" ✗ 대상 클러스터 미지정 — YAKCLOUD_CLUSTER 또는 yakcloud.yaml 의 cluster:")
sys.exit(" ✗ 대상 클러스터 미지정 — yakcloud.yaml 의 environments.%s.cluster (또는 YAKCLOUD_CLUSTER)" % env)
return ref

View File

@ -31,7 +31,9 @@ URL = os.environ["YAKCLOUD_URL"].rstrip("/")
TOKEN = os.environ["YAKCLOUD_TOKEN"]
CLUSTER_REF = os.environ.get("YAKCLOUD_CLUSTER")
CLUSTER = "" # main 에서 이름→id 로 해석해 채운다
CLUSTER_HOST = "" # 클러스터 기본 도메인 — expose.host(s) 미지정 시 여기로 노출
CLUSTER_HOST = "" # 클러스터 기본 도메인 — 항상 노출(운영은 여기 + 운영 도메인)
ENV = os.environ.get("YAKCLOUD_ENV", "dev") # 대상 환경 environments.<env> (기본 dev). promote=prod
ENV_DOMAINS: list = [] # main 에서 environments.<env>.domains 로 채움(운영 도메인)
TAG = os.environ.get("TAG", "latest")
DRY = "--dry-run" in sys.argv[1:]
@ -163,24 +165,25 @@ def deploy_workload(w: dict) -> tuple[str | None, bool]:
"healthPath": w.get("health"),
"path": ex.get("path", "/"), "pathType": "Prefix", "rewritePrefix": bool(ex.get("rewrite", False)),
}
# 노출 도메인: 여러 개(hosts) > 단일(host) > 클러스터 기본 도메인.
hosts = ex.get("hosts")
if hosts:
body["exposeHosts"] = hosts
# 노출 도메인 = 워크로드 지정 host(s) + 환경 도메인(운영). 커스텀이 있으면 기본 도메인도 함께 노출.
wl_hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])
custom = list(dict.fromkeys(wl_hosts + ENV_DOMAINS))
if custom:
body["exposeHosts"] = list(dict.fromkeys(custom + ([CLUSTER_HOST] if CLUSTER_HOST else [])))
else:
body["exposeHost"] = ex.get("host") or (CLUSTER_HOST or None)
body["exposeHost"] = CLUSTER_HOST or None
# 환경변수(선언적): 리스트[{key,value,secret?}] 또는 맵{KEY: VALUE}.
env = w.get("env") or []
if isinstance(env, dict):
env = [{"key": k, "value": v} for k, v in env.items()]
body["env"] = [{"key": e["key"], "value": str(e.get("value", "")),
"secret": bool(e.get("secret", False))} for e in env]
# 도메인 리컨실 — expose 에 명시한 host(들)를 레지스트리에 등록(없으면). 배포 하나로 '도달 가능한 앱'이 되게.
for h in (ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])):
# 도메인 리컨실 — 워크로드 host(들) + 환경(운영) 도메인을 레지스트리에 등록(없으면).
for h in custom:
reconcile_domain(h)
if DRY:
log(f"deploy '{w['name']}' 예정: image={image} port={body['port']} path={body['path']} "
f"replicas={body['replicasDesired']} hosts={hosts or body.get('exposeHost')}")
f"replicas={body['replicasDesired']} hosts={body.get('exposeHosts') or body.get('exposeHost')}")
return None, False
# 멱등: 기존 배포면 PATCH(이미지 갱신 → kubectl apply = 무중단 롤링). 없으면 신규 생성.
existing = next((d for d in cluster_deployments() if d.get("name") == w["name"]), None)
@ -202,15 +205,22 @@ def bind(dep_id: str | None, alias: str, service_id: str | None, source: str) ->
def main() -> None:
global CLUSTER, CLUSTER_HOST
global CLUSTER, CLUSTER_HOST, ENV_DOMAINS
path = next((a for a in sys.argv[1:] if not a.startswith("--")), "yakcloud.yaml")
m = yaml.safe_load(open(path))
ref = CLUSTER_REF or m.get("cluster")
# 환경 해석: environments.<ENV>.{cluster,domains}. 하위호환: 없으면 top-level cluster:.
envs = m.get("environments") or {}
env_cfg = envs.get(ENV) or {}
ref = CLUSTER_REF or env_cfg.get("cluster") or m.get("cluster")
if not ref:
raise SystemExit("대상 클러스터 미지정 — YAKCLOUD_CLUSTER 환경변수 또는 매니페스트 cluster: 필드")
raise SystemExit(
f"환경 '{ENV}' 대상 클러스터 미지정 — 매니페스트 environments.{ENV}.cluster (또는 YAKCLOUD_CLUSTER). "
f"등록된 환경: {', '.join(envs) or '(없음)'}")
ENV_DOMAINS = env_cfg.get("domains") or []
CLUSTER, cname = resolve_cluster(ref)
CLUSTER_HOST = (unwrap(api("GET", f"/clusters/{CLUSTER}")) or {}).get("defaultHostname") or ""
log(f"project '{m.get('project')}' → cluster '{cname}' ({CLUSTER}) (tag {TAG}){' [DRY-RUN]' if DRY else ''}")
dom = f" +도메인 {ENV_DOMAINS}" if ENV_DOMAINS else ""
log(f"[{ENV}] project '{m.get('project')}' → cluster '{cname}' ({CLUSTER}){dom} (tag {TAG}){' [DRY-RUN]' if DRY else ''}")
src_ids: dict[str, str | None] = {}
for req in m.get("requires", []):
src_ids[req["name"]] = reconcile_source(req)

View File

@ -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()

View File

@ -1,531 +0,0 @@
#!/usr/bin/env python3
"""yakcloud datasource migrate — 데이터소스 스냅샷(블랙리스트) 캡처 + 빈 타깃 적용(부트스트랩).
모델(확정):
capture = 소스 전체 native 덤프 블랙리스트(db/<source>/.migrateignore, 글롭) → db/<source>/snapshot/.
**읽기전용**(항상 안전). 커밋 전 '무엇이 잡히는지' 미리보기.
apply = 타깃이 **비어 있을 때만** 복원. 데이터가 있으면 **스킵**(절대 안 덮음, force 없음).
즉 새 환경을 dev 상태로 채우는 부트스트랩/시딩 도구. (prod 적용은 백엔드 경유 — 별도 승인)
dev 채널: 러너를 DB 클라이언트 이미지 one-shot 으로 compose 네트워크에 join → 소스 컨테이너 직접(자격 미노출).
자격은 yakcloud_dev 의 결정적 파생(creds_for)을 그대로 사용. 지원 타입: postgresql·mysql·mariadb·mongodb·minio.
"""
from __future__ import annotations
import argparse
import fnmatch
import json
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import yakcloud_dev as dev # noqa: E402 (동일 scripts/ 의 dev 엔진 헬퍼 재사용)
SNAP = "db" # 프로젝트 루트 db/<source>/ (git 추적 — .gitignore 아님)
IGNORE = ".migrateignore"
SUPPORTED = ("postgresql", "mysql", "mariadb", "mongodb", "minio", "redis", "solr", "rabbitmq", "oracle")
IMG = {"postgresql": "postgres:16-alpine", "mysql": "mysql:8.4", "mariadb": "mariadb:11.4",
"mongodb": "mongo:7", "minio": "minio/mc:latest",
"redis": "redis:7-alpine", "solr": "solr:9", "rabbitmq": "rabbitmq:3.13-management"}
UNSUPPORTED_NEXT = () # 9종 전부 지원(oracle=Data Pump)
def _fill(tmpl: str, **kw) -> str:
"""플레이스홀더 치환 — f-string/.format 대신(셸의 \\r\\n·$7·${..} 이스케이프를 보존)."""
for k, v in kw.items():
tmpl = tmpl.replace("{" + k + "}", str(v))
return tmpl
# redis 캡처: SCAN → 블랙리스트(키 글롭) 클라이언트 제외 → 키별 PTTL+DUMP(base64) → keys.b64. 읽기전용.
_REDIS_CAPTURE = r'''
R="redis-cli -h {host} -a {pw} --no-auth-warning -n {db}"; EX="{bl}"; : > /out/keys.b64
$R --scan | while IFS= read -r k; do
[ -z "$k" ] && continue
skip=0; for pat in $EX; do case "$k" in $pat) skip=1;; esac; done; [ "$skip" = 1 ] && continue
pttl=$($R PTTL "$k"); case "$pttl" in -*) pttl=0;; esac
kb=$(printf "%s" "$k" | base64 | tr -d "\n")
vb=$($R DUMP "$k" | head -c -1 | base64 | tr -d "\n")
printf "%s\t%s\t%s\n" "$pttl" "$kb" "$vb" >> /out/keys.b64
done
'''
# redis 복원: keys.b64 → RESP RESTORE(REPLACE) 스트림 → redis-cli --pipe(바이너리 안전). 빈 타깃 전제.
_REDIS_RESTORE = r'''
R="redis-cli -h {host} -a {pw} --no-auth-warning -n {db}"; TAB=$(printf '\t')
{ while IFS="$TAB" read -r pttl kb vb; do
[ -z "$kb" ] && continue
k=$(printf "%s" "$kb" | base64 -d)
vlen=$(printf "%s" "$vb" | base64 -d | wc -c | tr -d ' ')
klen=$(printf "%s" "$k" | wc -c | tr -d ' '); tlen=$(printf "%s" "$pttl" | wc -c | tr -d ' ')
printf '*5\r\n$7\r\nRESTORE\r\n'
printf '$%s\r\n%s\r\n' "$klen" "$k"
printf '$%s\r\n%s\r\n' "$tlen" "$pttl"
printf '$%s\r\n' "$vlen"; printf "%s" "$vb" | base64 -d; printf '\r\n'
printf '$7\r\nREPLACE\r\n'
done < /out/keys.b64; } | $R --pipe
'''
def die(m: str) -> None:
sys.exit(f"{m}")
def _net(m: dict) -> str:
return f"{dev.compose_project(m)}_default"
def _running(m: dict, source: str) -> bool:
j = dev._ps_state(m).get(source) or {}
return (j.get("State") or "") == "running"
def _srcdir(source: str) -> str:
return os.path.join(SNAP, source)
def _blacklist(source: str) -> list[str]:
p = os.path.join(_srcdir(source), IGNORE)
if not os.path.exists(p):
return []
out = []
for ln in open(p):
ln = ln.split("#", 1)[0].strip()
if ln:
out.append(ln)
return out
def _excluded(name: str, patterns: list[str]) -> bool:
return any(fnmatch.fnmatch(name, pat) for pat in patterns)
def drun(net: str, image: str, shell: str, mounts=None, capture=True, check=True):
"""docker run one-shot(sh -c) — 모든 명령을 쉘 경유(리다이렉트/파이프 지원)."""
args = ["docker", "run", "--rm", "--network", net]
for host, cont in (mounts or []):
os.makedirs(host, exist_ok=True)
args += ["-v", f"{os.path.abspath(host)}:{cont}"]
args += ["--entrypoint", "sh", image, "-c", shell]
r = subprocess.run(args, text=True,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None)
if check and r.returncode != 0:
die(f"클라이언트 실행 실패({r.returncode}): {(r.stderr or r.stdout or '').strip()[:400]}")
return r
# ── 타입별: 대상 단위 나열(미리보기·글롭 확장용) ────────────────────────────
def list_units(m: dict, source: str, stype: str, cr: dict) -> list[str]:
net = _net(m)
if stype == "postgresql":
url = f"postgresql://{cr['user']}:{cr['password']}@{source}:5432/{cr['db']}"
q = "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
r = drun(net, IMG[stype], f"psql '{url}' -tAc \"{q}\"", check=False)
elif stype in ("mysql", "mariadb"):
q = f"SELECT table_name FROM information_schema.tables WHERE table_schema='{cr['db']}'"
r = drun(net, IMG[stype],
f"mysql -h {source} -u{cr['user']} -p{cr['password']} -N -B -e \"{q}\" 2>/dev/null", check=False)
elif stype == "mongodb":
js = "db.getCollectionNames().filter(c=>!c.startsWith('_yakcloud')).join('\\n')"
r = drun(net, IMG[stype],
f"mongosh --quiet --host {source} -u {cr['user']} -p {cr['password']} "
f"--authenticationDatabase {cr['db']} {cr['db']} --eval \"{js}\"", check=False)
elif stype == "minio":
# minio/mc 이미지엔 awk 가 없음 → --json 으로 받아 파이썬에서 key 파싱.
setup = f"mc alias set s http://{source}:9000 {cr['access']} {cr['secret']} >/dev/null 2>&1"
r = drun(net, IMG[stype],
f"{setup} && mc ls --recursive --json s/{cr['bucket']} 2>/dev/null", check=False)
keys = []
for ln in (r.stdout or "").splitlines():
ln = ln.strip()
if not ln:
continue
try:
j = json.loads(ln)
if j.get("key"):
keys.append(j["key"])
except Exception:
pass
return keys
elif stype == "redis":
r = drun(net, IMG[stype],
f"redis-cli -h {source} -a {cr['password']} --no-auth-warning -n {cr['db']} --scan", check=False)
return [x for x in (r.stdout or "").splitlines() if x.strip()]
elif stype == "rabbitmq":
ra = (f"rabbitmqadmin -H {source} -P 15672 -u {cr['user']} -p {cr['password']} "
f"-V {cr['vhost']} -f tsv -q")
q = drun(net, IMG[stype], f"{ra} list queues name", check=False)
e = drun(net, IMG[stype], f"{ra} list exchanges name", check=False)
qn = [x.strip() for x in (q.stdout or "").splitlines() if x.strip() and x.strip() != "name"]
en = [x.strip() for x in (e.stdout or "").splitlines()
if x.strip() and x.strip() != "name" and not x.strip().startswith("amq.")]
return qn + en
elif stype == "solr":
# source=core 1:1 → 단위='문서'. is_empty/미리보기용 표본 id(최대 10). 전체 수는 _solr_numfound.
r = drun(net, IMG[stype],
f'curl -sS "http://{source}:8983/solr/{cr["core"]}/select?q=*:*&rows=10&fl=id&wt=json"', check=False)
try:
return [str(d.get("id", "?")) for d in json.loads(r.stdout or "{}").get("response", {}).get("docs", [])]
except Exception:
return []
elif stype == "oracle":
return _ora_lines(_ora_sql(source, cr,
f"SELECT table_name FROM all_tables WHERE owner = UPPER('{cr['user']}');"))
else:
return []
return [x.strip() for x in (r.stdout or "").splitlines() if x.strip() and "Warning" not in x]
def _solr_numfound(m: dict, source: str, cr: dict) -> int:
r = drun(_net(m), IMG["solr"],
f'curl -sS "http://{source}:8983/solr/{cr["core"]}/select?q=*:*&rows=0&wt=json"', check=False)
try:
return int(json.loads(r.stdout or "{}").get("response", {}).get("numFound", 0))
except Exception:
return 0
def _rabbit_filter(path: str, bl: list[str], info: dict) -> None:
"""rabbitmq defs.json 에서 블랙리스트(큐/익스체인지 글롭) 항목 + 참조 bindings 를 제거."""
d = json.load(open(path))
rmq = {q["name"] for q in d.get("queues", []) if _excluded(q.get("name", ""), bl)}
rmx = {x["name"] for x in d.get("exchanges", []) if _excluded(x.get("name", ""), bl)}
d["queues"] = [q for q in d.get("queues", []) if q.get("name", "") not in rmq]
d["exchanges"] = [x for x in d.get("exchanges", []) if x.get("name", "") not in rmx]
keep = []
for b in d.get("bindings", []):
if b.get("source", "") in rmx:
continue
dt, dn = b.get("destination_type", ""), b.get("destination", "")
if dt == "queue" and dn in rmq:
continue
if dt == "exchange" and dn in rmx:
continue
keep.append(b)
d["bindings"] = keep
json.dump(d, open(path, "w"), ensure_ascii=False, indent=2)
info["excluded"] = sorted(rmq | rmx)
def _solr_capture(net: str, source: str, cr: dict, bl: list[str], outdir: str) -> dict:
"""cursorMark 딥페이징으로 전체 문서 export(블랙리스트=서버측 fq 제외), 내부필드 제거 → docs.json."""
core = cr["core"]
blfq = ""
if bl:
expr = " OR ".join(f"( {ln} )" for ln in bl)
blfq = f"--data-urlencode 'fq=-({expr})'"
internal = {"_version_", "_root_", "_nest_path_", "_nest_parent_"}
docs, mark, n = [], "*", 0
while True:
cmd = (f'curl -sS "http://{source}:8983/solr/{core}/select" -G --data-urlencode "q=*:*" {blfq} '
f'--data-urlencode "fl=*" --data-urlencode "sort=id asc" --data-urlencode "rows=1000" '
f'--data-urlencode "cursorMark={mark}" --data-urlencode "wt=json" -o /out/_page.json')
drun(net, IMG["solr"], cmd, [(outdir, "/out")])
j = json.load(open(os.path.join(outdir, "_page.json")))
for doc in j.get("response", {}).get("docs", []):
docs.append({k: v for k, v in doc.items() if k not in internal})
nxt = j.get("nextCursorMark", mark)
if nxt == mark:
break
mark = nxt
n += 1
p = os.path.join(outdir, "_page.json")
if os.path.exists(p):
os.remove(p)
json.dump(docs, open(os.path.join(outdir, "docs.json"), "w"), ensure_ascii=False)
drun(net, IMG["solr"], f'curl -sS "http://{source}:8983/solr/{core}/schema?wt=json" -o /out/schema.json',
[(outdir, "/out")])
return {"file": "docs.json", "captured": len(docs)}
# ── oracle: Data Pump(expdp/impdp) — 파일이 DB 서버측이라 소스 컨테이너 exec + docker cp + REMAP_SCHEMA ──
def _ora_sql(source: str, cr: dict, sql: str) -> str:
script = "SET HEADING OFF PAGESIZE 0 FEEDBACK OFF VERIFY OFF ECHO OFF TRIMSPOOL ON\n" + sql + "\nEXIT\n"
r = subprocess.run(["docker", "exec", "-i", f"yakdev-{source}", "bash", "-lc",
f"sqlplus -s system/{cr['sys_pw']}@//localhost:1521/FREEPDB1"],
input=script, text=True, capture_output=True)
return r.stdout or ""
def _ora_lines(out: str) -> list[str]:
bad = ("ORA-", "SP2-", "SQL>", "altered", "PL/SQL", "Connected")
return [ln.strip() for ln in out.splitlines() if ln.strip() and not any(b in ln for b in bad)]
def _ora_dppath(source: str, cr: dict) -> str:
ls = _ora_lines(_ora_sql(source, cr,
"SELECT directory_path FROM dba_directories WHERE directory_name='DATA_PUMP_DIR';"))
return ls[-1] if ls else "/opt/oracle/admin/FREE/dpdump"
def _ora_datapump(source: str, cr: dict, parfile: str, tool: str) -> None:
cont = f"yakdev-{source}"
subprocess.run(["docker", "exec", "-i", cont, "bash", "-lc", "cat > /tmp/yak.par"],
input=parfile, text=True, capture_output=True)
r = subprocess.run(["docker", "exec", cont, "bash", "-lc",
f"{tool} system/{cr['sys_pw']}@//localhost:1521/FREEPDB1 parfile=/tmp/yak.par"],
text=True, capture_output=True)
out = (r.stdout or "") + (r.stderr or "") # expdp/impdp 는 진행상황을 stderr 로 출력
if "successfully completed" not in out:
die(f"{tool} 실패:\n{out[-600:]}")
# ── 미리보기 ────────────────────────────────────────────────────────────
def preview(m: dict, targets: list[tuple[str, str]]) -> dict:
seed = dev.ensure_seed()
plan = {}
print("캡처 미리보기 (읽기전용) — ✓=포함, ✗=제외(블랙리스트)")
for source, stype in targets:
cr = dev.creds_for(seed, source, stype)
bl = _blacklist(source)
if stype == "solr": # solr 블랙리스트=서버측 fq(문서 필터), fnmatch 미적용 → 문서 수만 표시
nf = _solr_numfound(m, source, cr)
plan[source] = {"type": stype, "numFound": nf, "blacklist": bl}
print(f"\n{source} (solr) — 문서 {nf}"
+ (f" · 블랙리스트 {len(bl)}줄(서버측 fq 로 제외)" if bl else ""))
if not bl:
print(f" · 블랙리스트 없음 → 전 문서 포함. 제외는 "
f"{os.path.join(_srcdir(source), IGNORE)} 에 Solr 쿼리절(한 줄에 하나)로")
continue
units = list_units(m, source, stype, cr)
blm = [p.upper() for p in bl] if stype == "oracle" else bl # oracle 식별자=대문자
inc = [u for u in units if not _excluded(u, blm)]
exc = [u for u in units if _excluded(u, blm)]
plan[source] = {"type": stype, "include": inc, "exclude": exc, "blacklist": bl}
unit_word = {"minio": "오브젝트", "redis": "", "rabbitmq": "큐/익스체인지",
"oracle": "테이블"}.get(stype, "테이블/컬렉션")
print(f"\n{source} ({stype}) — {unit_word} {len(units)}개 · 포함 {len(inc)} / 제외 {len(exc)}")
for u in inc[:20]:
print(f"{u}")
if len(inc) > 20:
print(f" … 외 {len(inc)-20}")
for u in exc:
print(f"{u} (블랙리스트)")
if not bl:
print(f" · 블랙리스트 없음 → 전부 포함. 민감/개인정보·쓰레기는 "
f"{os.path.join(_srcdir(source), IGNORE)} 에 글롭으로 제외 권장")
return plan
# ── 캡처(전체 native 덤프 블랙리스트) ───────────────────────────────────
def capture_source(m: dict, source: str, stype: str, cr: dict) -> dict:
net, outdir = _net(m), os.path.join(_srcdir(source), "snapshot")
os.makedirs(outdir, exist_ok=True)
bl = _blacklist(source)
if stype == "solr": # solr 는 fq(서버측) 제외라 fnmatch 단위 계산 안 함
units, excluded = [], []
elif stype == "oracle": # oracle 식별자=대문자 → 블랙리스트 글롭도 대문자 정규화 매칭
units = list_units(m, source, stype, cr)
excluded = [u for u in units if _excluded(u, [p.upper() for p in bl])]
else:
units = list_units(m, source, stype, cr)
excluded = [u for u in units if _excluded(u, bl)]
info = {"type": stype, "excluded": excluded, "blacklist": bl}
if stype == "postgresql":
url = f"postgresql://{cr['user']}:{cr['password']}@{source}:5432/{cr['db']}"
# pg_dump 는 패턴(*)을 지원 → 블랙리스트 글롭을 그대로 넘겨 소유 시퀀스(<tbl>_id_seq 등)까지 제외.
ex = " ".join(f"--exclude-table='{g}'" for g in bl)
drun(net, IMG[stype], f"pg_dump '{url}' {ex} -f /out/snapshot.sql", [(outdir, "/out")])
info["file"] = "snapshot.sql"
elif stype in ("mysql", "mariadb"):
ig = " ".join(f"--ignore-table={cr['db']}.{e}" for e in excluded)
drun(net, IMG[stype],
f"mysqldump -h {source} -u{cr['user']} -p{cr['password']} --skip-comments {ig} "
f"{cr['db']} > /out/snapshot.sql", [(outdir, "/out")])
info["file"] = "snapshot.sql"
elif stype == "mongodb":
ex = " ".join(f"--excludeCollection={e}" for e in excluded)
drun(net, IMG[stype],
f"mongodump --host {source} -u {cr['user']} -p {cr['password']} "
f"--authenticationDatabase {cr['db']} --db {cr['db']} {ex} --archive=/out/dump.archive",
[(outdir, "/out")])
info["file"] = "dump.archive"
elif stype == "minio":
setup = f"mc alias set s http://{source}:9000 {cr['access']} {cr['secret']} >/dev/null"
exflag = " ".join(f"--exclude '{e}'" for e in bl)
drun(net, IMG[stype],
f"{setup} && mc mirror --overwrite {exflag} s/{cr['bucket']} /out/bucket", [(outdir, "/out")])
info["file"], info["bucket"] = "bucket/", cr["bucket"]
elif stype == "redis":
drun(net, IMG[stype], _fill(_REDIS_CAPTURE, host=source, pw=cr["password"], db=cr["db"], bl=" ".join(bl)),
[(outdir, "/out")])
info["file"] = "keys.b64"
elif stype == "rabbitmq":
drun(net, IMG[stype],
f"rabbitmqadmin -H {source} -P 15672 -u {cr['user']} -p {cr['password']} -V {cr['vhost']} "
f"export /out/defs.json", [(outdir, "/out")])
_rabbit_filter(os.path.join(outdir, "defs.json"), bl, info) # 블랙리스트 큐/익스체인지 + bindings 제거
info["file"] = "defs.json"
info["note"] = "메시지 본문 제외(전이성). 정의(큐/익스체인지/바인딩/정책)만 이관."
elif stype == "solr":
info.update(_solr_capture(net, source, cr, bl, outdir))
elif stype == "oracle":
cont, schema, dp = f"yakdev-{source}", cr["user"].upper(), _ora_dppath(source, cr)
subprocess.run(["docker", "exec", cont, "bash", "-lc", f"rm -f {dp}/yak.dmp"], capture_output=True)
par = f"schemas={schema}\ndirectory=DATA_PUMP_DIR\ndumpfile=yak.dmp\nreuse_dumpfiles=y\nnologfile=y\n"
if excluded:
lst = ", ".join(f"'{e.upper()}'" for e in excluded)
par += f'EXCLUDE=TABLE:"IN ({lst})"\n'
_ora_datapump(source, cr, par, "expdp")
cp = subprocess.run(["docker", "cp", f"{cont}:{dp}/yak.dmp", os.path.join(outdir, "data.dmp")],
capture_output=True, text=True)
if cp.returncode != 0:
die(f"덤프 추출 실패: {cp.stderr[-300:]}")
info["file"], info["schema"] = "data.dmp", schema
else:
die(f"미지원 타입(캡처): {stype}")
json.dump(info, open(os.path.join(outdir, "manifest.json"), "w"), ensure_ascii=False, indent=2)
return info
# ── 타깃 비어있음 검사 + 적용(부트스트랩) ─────────────────────────────────
def is_empty(m: dict, source: str, stype: str, cr: dict) -> bool:
if stype == "solr":
return _solr_numfound(m, source, cr) == 0
return len(list_units(m, source, stype, cr)) == 0
def apply_source(m: dict, source: str, stype: str, cr: dict) -> str:
net, outdir = _net(m), os.path.join(_srcdir(source), "snapshot")
if not os.path.isdir(outdir):
return "스냅샷 없음(먼저 capture)"
if not is_empty(m, source, stype, cr):
return "타깃에 데이터 있음 → 스킵(덮지 않음)"
if stype == "postgresql":
url = f"postgresql://{cr['user']}:{cr['password']}@{source}:5432/{cr['db']}"
drun(net, IMG[stype], f"psql '{url}' -v ON_ERROR_STOP=1 -f /out/snapshot.sql", [(outdir, "/out")])
elif stype in ("mysql", "mariadb"):
drun(net, IMG[stype],
f"mysql -h {source} -u{cr['user']} -p{cr['password']} {cr['db']} < /out/snapshot.sql",
[(outdir, "/out")])
elif stype == "mongodb":
drun(net, IMG[stype],
f"mongorestore --host {source} -u {cr['user']} -p {cr['password']} "
f"--authenticationDatabase {cr['db']} --archive=/out/dump.archive", [(outdir, "/out")])
elif stype == "minio":
setup = f"mc alias set s http://{source}:9000 {cr['access']} {cr['secret']} >/dev/null"
drun(net, IMG[stype],
f"{setup} && (mc mb -p s/{cr['bucket']} 2>/dev/null || true) && "
f"mc mirror --overwrite /out/bucket s/{cr['bucket']}", [(outdir, "/out")])
elif stype == "redis":
drun(net, IMG[stype], _fill(_REDIS_RESTORE, host=source, pw=cr["password"], db=cr["db"]), [(outdir, "/out")])
elif stype == "rabbitmq":
drun(net, IMG[stype],
f"rabbitmqadmin -H {source} -P 15672 -u {cr['user']} -p {cr['password']} -V {cr['vhost']} "
f"import /out/defs.json", [(outdir, "/out")])
elif stype == "solr":
drun(net, IMG[stype],
f'curl -sS "http://{source}:8983/solr/{cr["core"]}/update?commit=true" '
f'-H "Content-Type: application/json" --data-binary @/out/docs.json', [(outdir, "/out")])
elif stype == "oracle":
cont, dst, dp = f"yakdev-{source}", cr["user"].upper(), _ora_dppath(source, cr)
src = json.load(open(os.path.join(outdir, "manifest.json"))).get("schema", dst)
cp = subprocess.run(["docker", "cp", os.path.join(outdir, "data.dmp"), f"{cont}:{dp}/yak.dmp"],
capture_output=True, text=True)
if cp.returncode != 0:
die(f"덤프 주입 실패: {cp.stderr[-300:]}")
# docker cp 는 호스트 uid 로 파일 생성 → oracle 프로세스가 읽도록 root(-u 0)로 권한 부여.
subprocess.run(["docker", "exec", "-u", "0", cont, "bash", "-lc", f"chmod 644 {dp}/yak.dmp"],
capture_output=True)
_ora_sql(source, cr, f"ALTER USER {dst} QUOTA UNLIMITED ON USERS;") # 쿼터 보장(멱등)
# exclude=user: 대상 스키마가 이미 존재(gvenzl 생성) → CREATE USER 스킵(ORA-31684 benign 방지, 깨끗한 성공)
par = (f"directory=DATA_PUMP_DIR\ndumpfile=yak.dmp\nnologfile=y\nexclude=user\n"
f"remap_schema={src}:{dst}\ntable_exists_action=skip\n")
_ora_datapump(source, cr, par, "impdp")
else:
die(f"미지원 타입(적용): {stype}")
return "적용 완료(부트스트랩)"
def select_targets(m: dict, names: list[str], require_running: bool) -> list[tuple[str, str]]:
reqs = {r["name"]: r["type"] for r in dev.requires(m)}
if names:
miss = [n for n in names if n not in reqs]
if miss:
die(f"requires 에 없음: {', '.join(miss)}")
chosen = [(n, reqs[n]) for n in names]
else:
chosen = list(reqs.items())
out = []
for s, t in chosen:
if t not in SUPPORTED:
print(f" · {s}({t}) — 마이그레이션 미지원(다음 단계: {', '.join(UNSUPPORTED_NEXT)}). 건너뜀")
continue
if require_running and not _running(m, s):
print(f" · {s} — dev 컨테이너 미기동('yakcloud dev up {s}' 먼저). 건너뜀")
continue
out.append((s, t))
if not out:
die("대상 소스가 없습니다.")
return out
# ── 명령 ────────────────────────────────────────────────────────────────
def cmd_capture(a) -> None:
m = dev.load_manifest()
targets = select_targets(m, a.source, require_running=True)
preview(m, targets)
if a.dry_run:
print("\n(미리보기 전용 — 캡처 안 함)")
return
if not a.yes and sys.stdin.isatty():
if input("\n위 대상을 db/<source>/snapshot/ 에 캡처할까요? [y/N] ").strip().lower() not in ("y", "yes"):
print("취소.")
return
seed = dev.ensure_seed()
for source, stype in targets:
cr = dev.creds_for(seed, source, stype)
info = capture_source(m, source, stype, cr)
print(f"{source} 캡처 → {os.path.join(_srcdir(source), 'snapshot', info['file'])}"
f" (제외 {len(info['excluded'])})")
print(" · db/ 를 커밋하세요(리뷰 대상). 적용: 'yakcloud datasource migrate apply'(빈 타깃만)")
def cmd_apply(a) -> None:
m = dev.load_manifest()
targets = select_targets(m, a.source, require_running=True)
seed = dev.ensure_seed()
for source, stype in targets:
cr = dev.creds_for(seed, source, stype)
print(f"{source} ({stype}): {apply_source(m, source, stype, cr)}")
print(" · prod 적용은 콘솔 소스관리자 컨펌 + 백업 후(롤백 보장) — 별도 경로")
def cmd_status(a) -> None:
m = dev.load_manifest()
reqs = {r["name"]: r["type"] for r in dev.requires(m)}
names = a.source or list(reqs)
print("migrate 스냅샷 상태")
for s in names:
t = reqs.get(s, "?")
snap = os.path.join(_srcdir(s), "snapshot", "manifest.json")
bl = _blacklist(s)
if os.path.exists(snap):
info = json.load(open(snap))
print(f" {s} ({t}): 스냅샷 있음(제외 {len(info.get('excluded', []))}) · 블랙리스트 {len(bl)}")
else:
print(f" {s} ({t}): 스냅샷 없음 · 블랙리스트 {len(bl)}")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="yakcloud datasource migrate")
sub = p.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("capture", help="소스 전체 상태 블랙리스트 를 db/<source>/snapshot/ 에 캡처(읽기전용)")
c.add_argument("source", nargs="*")
c.add_argument("--dry-run", action="store_true", help="미리보기만")
c.add_argument("-y", "--yes", action="store_true", help="확인 없이")
c.set_defaults(fn=cmd_capture)
ap = sub.add_parser("apply", help="스냅샷을 타깃에 적용(빈 타깃만, 데이터 있으면 스킵)")
ap.add_argument("source", nargs="*")
ap.set_defaults(fn=cmd_apply)
st = sub.add_parser("status", help="스냅샷·블랙리스트 상태")
st.add_argument("source", nargs="*")
st.set_defaults(fn=cmd_status)
return p
if __name__ == "__main__":
args = build_parser().parse_args()
args.fn(args)