#!/usr/bin/env python3 """yakcloud ctl — 배포 환경(매니페스트 + 라이브 배포) 조회/수정 엔진. bin/yakcloud 가 위임 호출. 앱마다 바뀌는 부분을 CLI 로 수정한다. 매니페스트(yakcloud.yaml)가 선언적 소스이고, scale/set/env/domain 은 이미 배포돼 있으면 재빌드 없이 라이브 반영(PATCH)한다. env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER 서브커맨드: info · domain · scale · set · env · source(ls/add/rm) · bind · unbind """ from __future__ import annotations import argparse import json import os import sys import urllib.error import urllib.request import yaml URL = os.environ.get("YAKCLOUD_URL", "").rstrip("/") TOK = os.environ.get("YAKCLOUD_TOKEN", "") MANIFEST = "yakcloud.yaml" # ── 유틸 ──────────────────────────────────────────────────────────────── def need_api() -> None: if not URL or not TOK: sys.exit(" ✗ YAKCLOUD_URL / YAKCLOUD_TOKEN(배포 토큰) 환경변수가 필요합니다.") def api(method: str, path: str, body: dict | None = None) -> dict: data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(URL + "/api/v1" + path, data=data, method=method, headers={"Authorization": "Bearer " + TOK, "content-type": "application/json"}) try: with urllib.request.urlopen(req, timeout=30) as r: raw = r.read().decode() return json.loads(raw) if raw else {} except urllib.error.HTTPError as e: try: msg = json.loads(e.read().decode()).get("error", {}) except Exception: msg = {} sys.exit(" ✗ %s %s → %s: %s" % (method, path, e.code, msg.get("message") or msg or "요청 실패")) def unwrap(r): return r.get("data", r) if isinstance(r, dict) else r def load_manifest() -> dict: if not os.path.exists(MANIFEST): sys.exit(" ✗ %s 없음 — 'yakcloud init' 먼저" % MANIFEST) return yaml.safe_load(open(MANIFEST)) or {} def save_manifest(m: dict) -> None: yaml.safe_dump(m, open(MANIFEST, "w"), sort_keys=False, allow_unicode=True) def cluster_ref(m: dict) -> str: # 조회/설정은 기본 개발 환경 클러스터 기준. environments..cluster → 하위호환 cluster:. env = os.environ.get("YAKCLOUD_ENV", "dev") envs = m.get("environments") or {} # env 클러스터 최우선(저장/전역 YAKCLOUD_CLUSTER 는 하위호환 폴백). ref = ((envs.get(env) or {}).get("cluster") or os.environ.get("YAKCLOUD_CLUSTER") or m.get("cluster")) if not ref: sys.exit(" ✗ 대상 클러스터 미지정 — yakcloud.yaml 의 environments.%s.cluster (또는 YAKCLOUD_CLUSTER)" % env) return ref def resolve_cluster(ref: str) -> dict: for c in unwrap(api("GET", "/clusters")) or []: if ref in (c.get("id"), c.get("name")): return c sys.exit(" ✗ 클러스터 '%s' 없음" % ref) def find_workload(m: dict, name: str | None) -> dict: ws = m.get("workloads", []) or [] if name: w = next((w for w in ws if w.get("name") == name), None) if not w: sys.exit(" ✗ 워크로드 '%s' 없음 (%s)" % (name, ", ".join(w.get("name", "?") for w in ws))) return w if len(ws) == 1: return ws[0] sys.exit(" ✗ 워크로드를 지정하세요 (%s)" % ", ".join(w.get("name", "?") for w in ws)) def live_deps(cid: str) -> dict[str, dict]: return {d["name"]: d for d in (unwrap(api("GET", "/clusters/%s/deployments" % cid)) or [])} def live_services(cid: str) -> dict[str, dict]: return {s["name"]: s for s in (unwrap(api("GET", "/clusters/%s/services" % cid)) or [])} def patch_live(cid: str, name: str, patch: dict) -> bool: """워크로드가 이미 배포돼 있으면 PATCH(재빌드 없이 롤링 반영). 반환=적용여부.""" dep = live_deps(cid).get(name) if not dep: return False api("PATCH", "/deployments/%s" % dep["id"], patch) return True # ── 커맨드 ────────────────────────────────────────────────────────────── def cmd_info(_a) -> None: m = load_manifest() print("project : %s" % m.get("project", "-")) ref = m.get("cluster") or os.environ.get("YAKCLOUD_CLUSTER") or "-" deps: dict[str, dict] = {} svcs: dict[str, dict] = {} doms: list[dict] = [] cinfo = None if URL and TOK and (m.get("cluster") or os.environ.get("YAKCLOUD_CLUSTER")): cinfo = resolve_cluster(cluster_ref(m)) deps = live_deps(cinfo["id"]) svcs = live_services(cinfo["id"]) doms = unwrap(api("GET", "/clusters/%s/domains" % cinfo["id"])) or [] print("cluster : %s (id %s) 도메인 %s" % (cinfo.get("name"), cinfo["id"], cinfo.get("defaultHostname", "-"))) else: print("cluster : %s (라이브 상태는 YAKCLOUD_URL/TOKEN 설정 시 표시)" % ref) print("\nworkloads:") for w in m.get("workloads", []) or []: ex = w.get("expose", {}) or {} hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else []) line = " • %-14s image=%s port=%s replicas=%s health=%s" % ( w.get("name"), w.get("image"), w.get("port"), w.get("replicas", 1), w.get("health", "-")) d = deps.get(w.get("name")) if d: live_hosts = d.get("exposeHosts") or ([d["exposeHost"]] if d.get("exposeHost") else []) line += "\n live: %s %s/%s hosts=%s" % ( d.get("status"), d.get("replicasReady", 0), d.get("replicasDesired", 0), ", ".join(live_hosts) or "(내부)") elif hosts: line += "\n manifest hosts=%s" % ", ".join(hosts) binds = w.get("binds", []) or [] if binds: line += "\n binds: %s" % ", ".join("%s→%s" % (b["alias"], b["source"]) for b in binds) print(line) reqs = m.get("requires", []) or [] if reqs: print("\ndata sources (requires):") for r in reqs: s = svcs.get(r["name"]) st = (" [%s]" % s.get("status")) if s else "" print(" • %-14s %s/%s%s" % (r["name"], r.get("type"), r.get("plan", "small"), st)) if doms: print("\ndomains (cluster):") for d in doms: print(" • %-28s %s cert=%s" % (d["fqdn"], d["status"], d.get("certStatus"))) print("\n다음: 수정=yakcloud (scale|set|env|domain|source|bind) · 배포=yakcloud deploy vX.Y.Z") def _apply_workload_patch(m, cid_or_none, w, patch, label): """매니페스트 저장 후, 배포돼 있으면 라이브 PATCH.""" save_manifest(m) print(" ✓ 매니페스트: %s %s" % (w["name"], label)) if cid_or_none and patch and patch_live(cid_or_none, w["name"], patch): print(" ✓ 라이브 반영(재빌드 없음): %s" % w["name"]) elif cid_or_none: print(" · 아직 미배포 — 'yakcloud deploy' 시 반영") def _cid(m): if URL and TOK: return resolve_cluster(cluster_ref(m))["id"] return None def cmd_scale(a) -> None: m = load_manifest(); w = find_workload(m, a.workload) w["replicas"] = int(a.replicas) _apply_workload_patch(m, _cid(m), w, {"replicasDesired": int(a.replicas)}, "replicas=%s" % a.replicas) def cmd_set(a) -> None: m = load_manifest(); w = find_workload(m, a.workload) patch, changed = {}, [] if a.image is not None: w["image"] = a.image; patch["image"] = a.image; changed.append("image") if a.port is not None: w["port"] = int(a.port); patch["port"] = int(a.port); changed.append("port") if a.health is not None: w["health"] = a.health; patch["healthPath"] = a.health; changed.append("health") if a.cpu is not None or a.mem is not None: res = w.get("resources", {}) or {}; w["resources"] = res if a.cpu is not None: res["cpu"] = a.cpu; patch["cpuRequest"] = a.cpu; changed.append("cpu") if a.mem is not None: res["mem"] = a.mem; patch["memRequest"] = a.mem; changed.append("mem") if a.path is not None or a.rewrite is not None: ex = w.get("expose", {}) or {}; w["expose"] = ex if a.path is not None: ex["path"] = a.path; patch["path"] = a.path; changed.append("path") if a.rewrite is not None: rw = a.rewrite.lower() in ("1", "true", "yes", "on") ex["rewrite"] = rw; patch["rewritePrefix"] = rw; changed.append("rewrite") if not changed: sys.exit(" ✗ 변경할 항목 없음 — --image/--port/--health/--cpu/--mem/--path/--rewrite") _apply_workload_patch(m, _cid(m), w, patch, "set " + " ".join(changed)) def cmd_env(a) -> None: m = load_manifest(); w = find_workload(m, a.workload) env = w.get("env", []) or [] if isinstance(env, dict): env = [{"key": k, "value": v} for k, v in env.items()] idx = {e["key"]: e for e in env} for pair in a.pairs: if "=" not in pair: sys.exit(" ✗ KEY=VALUE 형식이어야 합니다: %s" % pair) k, v = pair.split("=", 1) idx[k] = {"key": k, "value": v, **({"secret": True} if k in (a.secret or []) else {})} for k in (a.unset or []): idx.pop(k, None) env = list(idx.values()) w["env"] = env patch = {"env": [{"key": e["key"], "value": str(e.get("value", "")), "secret": bool(e.get("secret", False))} for e in env]} _apply_workload_patch(m, _cid(m), w, patch, "env=[%s]" % ", ".join(e["key"] for e in env)) def cmd_source(a) -> None: m = load_manifest(); reqs = m.setdefault("requires", []) or [] m["requires"] = reqs if a.action == "add": if any(r.get("name") == a.name for r in reqs): sys.exit(" ✗ 이미 있음: %s" % a.name) if getattr(a, "shared", False): # 공유 외부 소스 — 배포 시 백엔드가 클러스터별 격리 DB/계정 민팅 reqs.append({"name": a.name, "type": a.type, "mode": "shared"}) save_manifest(m) print(" ✓ requires += {name=%s, type=%s, mode=shared}" % (a.name, a.type)) print(" · 공유 외부 소스 — 배포 시 백엔드가 클러스터별 격리 DB/계정 민팅(인클러스터 StatefulSet 없음).") print(" · 바인딩: 'yakcloud bind %s ' 후 'yakcloud deploy'" % a.name) else: reqs.append({"name": a.name, "type": a.type, "plan": a.plan}) save_manifest(m) print(" ✓ requires += {name=%s, type=%s, plan=%s}" % (a.name, a.type, a.plan)) print(" · 프로비저닝/바인딩은 'yakcloud bind %s ' 후 'yakcloud deploy'" % a.name) else: # rm m["requires"] = [r for r in reqs if r.get("name") != a.name] # 관련 바인딩도 정리 for w in m.get("workloads", []) or []: w["binds"] = [b for b in (w.get("binds", []) or []) if b.get("source") != a.name] save_manifest(m) print(" ✓ requires 에서 제거: %s (관련 binds 정리). 'yakcloud deploy' 로 반영" % a.name) def cmd_source_ls(a) -> None: """클러스터에서 이용 가능한 데이터 소스 목록 — 이름·타입·내부/외부·상태·플랜.""" need_api() m = (yaml.safe_load(open(MANIFEST)) if os.path.exists(MANIFEST) else {}) or {} c = resolve_cluster(cluster_ref(m)) svcs = unwrap(api("GET", "/clusters/%s/services" % c["id"])) or [] if not svcs: print("클러스터 '%s' — 데이터 소스 없음. 콘솔 '데이터 소스' 또는 'yakcloud source add' 로 추가." % (c.get("name") or c["id"])) return used = {r.get("name") for r in (m.get("requires", []) or [])} rows = [] for s in sorted(svcs, key=lambda x: (x.get("type", ""), x.get("name", ""))): mode = s.get("mode") loc = "내부" if mode == "LOCAL" else "외부" if mode == "REMOTE" else (mode or "?") mark = "✓" if s.get("name") in used else " " rows.append((mark, s.get("name", "?"), s.get("type", "?"), loc, s.get("status", "?"), s.get("size") or s.get("plan") or "-")) wn = max([len(r[1]) for r in rows] + [4]) wt = max([len(r[2]) for r in rows] + [4]) print("클러스터 '%s' 데이터 소스 (%d)" % (c.get("name") or c["id"], len(rows))) print(" %-*s %-*s %-4s %-8s 플랜" % (wn, "이름", wt, "타입", "위치", "상태")) for mark, name, typ, loc, st, plan in rows: print(" %s %-*s %-*s %-4s %-8s %s" % (mark, wn, name, wt, typ, loc, st, plan)) print("\n ✓=매니페스트 requires 에 이미 있음 · 바인딩: yakcloud bind <이름> ") def cmd_bind(a) -> None: m = load_manifest(); w = find_workload(m, a.workload) if not any(r.get("name") == a.source for r in (m.get("requires", []) or [])): sys.exit(" ✗ requires 에 소스 '%s' 없음 — 'yakcloud source add %s ' 먼저" % (a.source, a.source)) binds = w.get("binds", []) or []; w["binds"] = binds binds[:] = [b for b in binds if b.get("alias") != a.alias] binds.append({"alias": a.alias, "source": a.source}) save_manifest(m) print(" ✓ %s.binds += {alias=%s, source=%s}. 'yakcloud deploy' 로 반영(env 주입)" % (w["name"], a.alias, a.source)) def cmd_unbind(a) -> None: m = load_manifest(); w = find_workload(m, a.workload) binds = w.get("binds", []) or [] w["binds"] = [b for b in binds if b.get("alias") != a.alias] save_manifest(m) print(" ✓ %s.binds 에서 alias=%s 제거. 'yakcloud deploy' 로 반영" % (w["name"], a.alias)) def cmd_domain(a) -> None: need_api() m = load_manifest(); c = resolve_cluster(cluster_ref(m)); cid = c["id"] doms = unwrap(api("GET", "/clusters/%s/domains" % cid)) or [] d = next((x for x in doms if x["fqdn"] == a.fqdn), None) if d: print(" = 이미 등록됨: %s (status=%s)" % (a.fqdn, d["status"])) else: d = unwrap(api("POST", "/clusters/%s/domains" % cid, {"fqdn": a.fqdn})) print(" ✓ 등록: %s status=%s cert=%s" % (a.fqdn, d["status"], d["certStatus"])) v = d.get("verify") if v: print(' 외부 도메인 — DNS 에 TXT 추가 후 검증:\n %s TXT "%s"' % (v["host"], v["value"])) # 매니페스트 워크로드에 연결 ws = m.get("workloads", []) or [] w = next((x for x in ws if x.get("name") == a.workload), None) if a.workload else (ws[0] if len(ws) == 1 else None) if w is None and ws: print(" · 워크로드를 지정하세요: yakcloud domain %s (%s)" % (a.fqdn, ", ".join(x.get("name", "?") for x in ws))) return if w is None: return ex = w.get("expose", {}) or {}; w["expose"] = ex hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else []) if a.fqdn not in hosts: hosts.append(a.fqdn) ex.pop("host", None); ex["hosts"] = hosts save_manifest(m) print(" ✓ 매니페스트: %s.expose.hosts=%s" % (w["name"], hosts)) dep = live_deps(cid).get(w["name"]) if dep and d["status"] == "ACTIVE": cur = dep.get("exposeHosts") or ([dep["exposeHost"]] if dep.get("exposeHost") else []) newh = list(dict.fromkeys(cur + [a.fqdn])) api("PATCH", "/deployments/%s" % dep["id"], {"exposeHosts": newh}) print(" ✓ 라이브 반영(재빌드 없음): %s → %s" % (w["name"], ", ".join(newh))) elif dep: print(" · 도메인 활성 후 'yakcloud deploy' 로 반영") else: print(" · 미배포 — 'yakcloud deploy' 시 이 도메인으로 노출") def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="yakcloud", add_help=True) sub = p.add_subparsers(dest="cmd", required=True) sub.add_parser("info", help="프로젝트 개괄(매니페스트 + 라이브 상태)").set_defaults(fn=cmd_info) d = sub.add_parser("domain", help="도메인 등록 + 워크로드 할당") d.add_argument("fqdn"); d.add_argument("workload", nargs="?") d.set_defaults(fn=cmd_domain) s = sub.add_parser("scale", help="워크로드 replicas 변경") s.add_argument("workload"); s.add_argument("replicas", type=int) s.set_defaults(fn=cmd_scale) se = sub.add_parser("set", help="워크로드 필드 수정") se.add_argument("workload") se.add_argument("--image"); se.add_argument("--port"); se.add_argument("--health") se.add_argument("--cpu"); se.add_argument("--mem"); se.add_argument("--path"); se.add_argument("--rewrite") se.set_defaults(fn=cmd_set) e = sub.add_parser("env", help="워크로드 환경변수 KEY=VALUE 설정/해제") e.add_argument("workload"); e.add_argument("pairs", nargs="*") e.add_argument("--secret", action="append", help="이 KEY 를 secret 으로 표시") e.add_argument("--unset", action="append", help="이 KEY 제거") e.set_defaults(fn=cmd_env) so = sub.add_parser("source", help="데이터 소스: ls(클러스터 목록) · add · rm") sosub = so.add_subparsers(dest="action", required=True) sol = sosub.add_parser("ls", help="클러스터에서 이용 가능한 소스 목록"); sol.set_defaults(fn=cmd_source_ls) soa = sosub.add_parser("add"); soa.add_argument("name"); soa.add_argument("type") soa.add_argument("plan", nargs="?", default="small") soa.add_argument("--shared", action="store_true", help="공유 외부 소스(mode:shared) — 백엔드가 클러스터별 격리 DB/계정 민팅(소용량 클러스터용, 현재 mongodb)") soa.set_defaults(fn=cmd_source) sor = sosub.add_parser("rm"); sor.add_argument("name"); sor.set_defaults(fn=cmd_source) b = sub.add_parser("bind", help="워크로드에 소스 바인딩") b.add_argument("workload"); b.add_argument("source"); b.add_argument("alias") b.set_defaults(fn=cmd_bind) u = sub.add_parser("unbind", help="워크로드 바인딩 해제") u.add_argument("workload"); u.add_argument("alias") u.set_defaults(fn=cmd_unbind) return p if __name__ == "__main__": args = build_parser().parse_args() args.fn(args)