#!/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 resolve_requires(m: dict, env: str | None = None) -> list: """base + environments. 오버레이(이름 매칭) 병합된 requires — deploy/dev 의 resolve_env 와 규약 일치. 실제 소스 이름(clusterName)·type/mode/plan 이 이 병합으로 환경별 결정된다(binds 는 논리 이름 고정).""" env = env or os.environ.get("YAKCLOUD_ENV", "dev") ec = (m.get("environments") or {}).get(env) or {} by: dict = {} order: list = [] for r in (m.get("requires") or []): by[r["name"]] = dict(r); order.append(r["name"]) for r in (ec.get("requires") or []): if r["name"] in by: by[r["name"]].update(r) else: by[r["name"]] = dict(r); order.append(r["name"]) return [by[n] for n in order] def actual_source(r: dict) -> str: """이 소스가 대상 클러스터에서 갖는 실제 이름(clusterName 우선, 없으면 논리 name).""" return r.get("clusterName") or r["name"] 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", "-")) # 대상 클러스터 = environments..cluster 우선(cluster_ref 규약과 동일) — 미지정이면 None 폴백. # 레거시 top-level cluster: 만 보던 게이트는 환경-온리 템플릿에서 라이브 상태가 항상 죽어 clusterName 진단이 안 뜸. env = os.environ.get("YAKCLOUD_ENV", "dev") ref = (((m.get("environments") or {}).get(env) or {}).get("cluster") or os.environ.get("YAKCLOUD_CLUSTER") or m.get("cluster")) deps: dict[str, dict] = {} svcs: dict[str, dict] = {} doms: list[dict] = [] cinfo = None if URL and TOK and ref: cinfo = resolve_cluster(ref) 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 or "-")) 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 = resolve_requires(m) # 현재 환경(YAKCLOUD_ENV, 기본 dev) 오버레이 반영 — clusterName·mode 등 if reqs: print("\ndata sources (requires):") for r in reqs: actual = actual_source(r) s = svcs.get(actual) # 라이브 상태는 '실제 소스 이름'으로 조회 st = (" [%s]" % s.get("status")) if s else "" mp = (" → %s" % actual) if actual != r["name"] else "" # 논리→실제 매핑 표시 print(" • %-14s%s %s/%s%s" % (r["name"], mp, 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 synced_health_path(path: str) -> str: """경로 동기화 기본: healthPath = 경로 + '/healthz' (콘솔 '경로 동기화'와 정합).""" base = (path or "/").strip().rstrip("/") return base + "/healthz" 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") # 헬스체크가 커스텀(매니페스트 health 명시)이 아니고 이번에 --health 도 안 주면 # 경로와 동기화 — healthPath 도 경로+/healthz 로 갱신. if a.health is None and not w.get("health"): patch["healthPath"] = synced_health_path(a.path); changed.append("health(sync)") 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 _ls_ref(m: dict, env_opt: str | None, cluster_opt: str | None) -> str: """소스 조회 대상 클러스터 ref — --cluster(명시) > --env(environments..cluster) > 현재 환경(cluster_ref).""" if cluster_opt: return cluster_opt if env_opt: ref = ((m.get("environments") or {}).get(env_opt) or {}).get("cluster") if not ref: sys.exit(" ✗ environments.%s.cluster 미지정 — --cluster <이름/id> 로 직접 지정하거나 매니페스트에 추가하세요." % env_opt) return ref return cluster_ref(m) def cmd_source_ls(a) -> None: """대상 클러스터에서 이용 가능한 데이터 소스 목록 — 이름·타입·내부/외부·상태·플랜. 운영 소스를 clusterName 으로 고를 때: 'yakcloud datasource ls --env prod' 로 실제 이름을 확인한다. (로컬 dev 는 도커로 논리 이름 그대로 뜨므로 조회 불필요 — 이 명령은 주로 운영/검증 소스 선택용.)""" need_api() m = (yaml.safe_load(open(MANIFEST)) if os.path.exists(MANIFEST) else {}) or {} env_opt = getattr(a, "env", None) c = resolve_cluster(_ls_ref(m, env_opt, getattr(a, "cluster", None))) svcs = unwrap(api("GET", "/clusters/%s/services" % c["id"])) or [] # ✓ 표시·매핑 = 그 환경의 병합 requires 기준(clusterName 우선) — 클러스터 서비스 이름과 일치. used = {actual_source(r) for r in resolve_requires(m, env_opt)} if getattr(a, "json", False): # 에이전트용 기계판독 출력 out = [{"name": s.get("name"), "type": s.get("type"), "mode": s.get("mode"), "status": s.get("status"), "plan": s.get("size") or s.get("plan"), "inRequires": s.get("name") in used} for s in svcs] print(json.dumps({"cluster": c.get("name") or c["id"], "clusterId": c["id"], "env": env_opt, "sources": out}, ensure_ascii=False, indent=2)) return if not svcs: print("클러스터 '%s' — 데이터 소스 없음. 콘솔 '데이터 소스' 또는 'yakcloud source add' 로 추가." % (c.get("name") or c["id"])) return 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]) tag = (" [env=%s]" % env_opt) if env_opt else "" print("클러스터 '%s'%s 데이터 소스 (%d)" % (c.get("name") or c["id"], tag, 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 에 이미 있음(clusterName 우선) · 바인딩: yakcloud bind <이름> ") print(" 이 소스를 논리 이름에 매핑: environments.%s.requires 에 { name: <논리>, clusterName: <위 이름> }" % (env_opt or "prod")) def cmd_source_options(a) -> None: """운영 소스 '모드 결정'용 정보 — 타입별 지원 모드(local/remote/shared) + 이 배포의 공유 가용성 + 기존 소스. 예) 'yakcloud datasource options postgresql --env prod' → shared 가능 여부·기존 소스로 모드를 고른다. (로컬 dev 는 도커로 뜨므로 이 결정은 주로 운영/검증용.)""" need_api() m = (yaml.safe_load(open(MANIFEST)) if os.path.exists(MANIFEST) else {}) or {} stype = a.type.lower() types = (unwrap(api("GET", "/source-options")) or {}).get("types") or {} info = types.get(stype) if not info: sys.exit(" ✗ 알 수 없는 타입 '%s' — %s 중 하나" % (stype, ", ".join(sorted(types)) or "?")) modes = info.get("modes") or [] shared_ok = bool(info.get("sharedAvailable")) # 대상 클러스터의 같은 타입 기존 소스(있으면 clusterName 으로 붙일 후보) env_opt = getattr(a, "env", None) existing = [] existing_error = None # 기존 소스 조회는 best-effort(모드 정보는 항상 출력). 단 '빈 목록'과 '조회 실패'를 반드시 구분해 노출한다 # — 삼키면 에이전트가 '소스 없음 → 신규 생성'으로 오판(있는데 중복 생성)할 수 있다. try: c = resolve_cluster(_ls_ref(m, env_opt, getattr(a, "cluster", None))) svcs = unwrap(api("GET", "/clusters/%s/services" % c["id"])) or [] existing = [{"name": s.get("name"), "mode": s.get("mode"), "status": s.get("status")} for s in svcs if str(s.get("type", "")).lower() == stype] except SystemExit as e: existing_error = (str(e).strip().lstrip("✗").strip() or "클러스터/소스 조회 실패") except Exception as e: # noqa: BLE001 — 네트워크 오류 등도 조회만 실패시키고 모드 정보는 출력 existing_error = "조회 오류: %s" % e if getattr(a, "json", False): # 에이전트용 out = {"type": stype, "modes": modes, "sharedAvailable": shared_ok, "existing": existing} if existing_error: out["existingError"] = existing_error # '빈 목록' vs '조회 실패' 구분 — 오판 방지 print(json.dumps(out, ensure_ascii=False, indent=2)) return print("데이터 소스 '%s' — 운영 소스 모드 선택지" % stype) print(" 지원 모드: %s" % ", ".join(modes)) if "shared" in modes: print(" · shared (공유) : %s" % ("가능 — 이 배포에 공유 서버 있음 → requires 에 { mode: shared } (격리 DB 민팅)" if shared_ok else "이 배포에 공유 서버 없음 → 사용 불가(local/remote 로)")) if "remote" in modes: print(" · remote (외부) : 콘솔/API로 외부 접속정보 등록(자격은 매니페스트/git 금지) → clusterName 으로 참조") if "local" in modes: print(" · local (인클러스터): 전용 인스턴스 신규 생성 → requires 에 { mode: local, plan: small }") if existing_error: print(" ⚠ 기존 소스 조회 실패(%s) — 대상 클러스터/토큰 확인. 위 모드 정보는 유효(기존 소스 유무는 미확인)." % existing_error) elif existing: print(" 기존 소스(이 클러스터·같은 타입):") for e in existing: print(" - %-20s [%s] %s" % (e["name"], e.get("mode") or "?", e.get("status") or "?")) print(" ↑ 기존 소스에 붙이려면 environments..requires 에 { name: <논리>, clusterName: <이름> }") else: print(" 기존 소스 없음(같은 타입) — 위 모드 중 골라 requires 에 선언") def cmd_bind(a) -> None: m = load_manifest(); w = find_workload(m, a.workload) # 검증은 병합 뷰(base + 현재 환경 오버레이) 기준 — info/datasource ls 와 일관. binds 는 논리 이름 참조. if not any(r.get("name") == a.source for r in resolve_requires(m)): 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="클러스터 소스 목록(운영 조회 --env prod, 에이전트 --json)") sol.add_argument("--env", choices=["dev", "val", "prod"], help="대상 환경(environments..cluster) — 기본=현재 환경") sol.add_argument("--cluster", help="대상 클러스터 이름/id 직접 지정(--env 무시)") sol.add_argument("--json", action="store_true", help="기계판독 JSON 출력(에이전트용)") sol.set_defaults(fn=cmd_source_ls) soo = sosub.add_parser("options", help="타입별 모드(local/remote/shared)+공유 가용성+기존 소스 — 운영 모드 결정용") soo.add_argument("type", help="데이터 소스 타입(postgresql·mysql·mongodb·redis·minio·rabbitmq·solr·oracle·mariadb)") soo.add_argument("--env", choices=["dev", "val", "prod"], help="대상 환경(기존 소스 조회용)") soo.add_argument("--cluster", help="대상 클러스터 이름/id 직접 지정") soo.add_argument("--json", action="store_true", help="기계판독 JSON 출력(에이전트용)") soo.set_defaults(fn=cmd_source_options) 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/계정 민팅. 지원=pg·mysql·mariadb·mongodb·rabbitmq·solr " "(배포별 가용성은 'yakcloud datasource options ' 로 확인)") 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)