feat(cli): clusterName 환경별 소스명 매핑 + 운영 소스 3-모드 결정 조회(datasource options/ls) + 검증·문서 · v0.32.0

This commit is contained in:
2026-09-06 03:41:17 +09:00
parent 789aaee5b4
commit 075d7bc3d7
9 changed files with 356 additions and 62 deletions

View File

@ -78,6 +78,28 @@ def resolve_cluster(ref: str) -> dict:
sys.exit(" ✗ 클러스터 '%s' 없음" % ref)
def resolve_requires(m: dict, env: str | None = None) -> list:
"""base + environments.<env> 오버레이(이름 매칭) 병합된 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:
@ -111,19 +133,23 @@ def patch_live(cid: str, name: str, patch: dict) -> bool:
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 "-"
# 대상 클러스터 = environments.<env>.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 (m.get("cluster") or os.environ.get("YAKCLOUD_CLUSTER")):
cinfo = resolve_cluster(cluster_ref(m))
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)
print("cluster : %s (라이브 상태는 YAKCLOUD_URL/TOKEN + 대상 클러스터 설정 시 표시)" % (ref or "-"))
print("\nworkloads:")
for w in m.get("workloads", []) or []:
@ -144,13 +170,15 @@ def cmd_info(_a) -> None:
line += "\n binds: %s" % ", ".join("%s%s" % (b["alias"], b["source"]) for b in binds)
print(line)
reqs = m.get("requires", []) or []
reqs = resolve_requires(m) # 현재 환경(YAKCLOUD_ENV, 기본 dev) 오버레이 반영 — clusterName·mode 등
if reqs:
print("\ndata sources (requires):")
for r in reqs:
s = svcs.get(r["name"])
actual = actual_source(r)
s = svcs.get(actual) # 라이브 상태는 '실제 소스 이름'으로 조회
st = (" [%s]" % s.get("status")) if s else ""
print(" %-14s %s/%s%s" % (r["name"], r.get("type"), r.get("plan", "small"), st))
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:
@ -262,17 +290,42 @@ def cmd_source(a) -> None:
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.<env>.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 {}
c = resolve_cluster(cluster_ref(m))
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
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")
@ -282,16 +335,74 @@ def cmd_source_ls(a) -> None:
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)))
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 에 이미 있음 · 바인딩: yakcloud bind <workload> <이름> <alias>")
print("\n ✓=이 환경 requires 에 이미 있음(clusterName 우선) · 바인딩: yakcloud bind <workload> <이름> <alias>")
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.<env>.requires 에 { name: <논리>, clusterName: <이름> }")
else:
print(" 기존 소스 없음(같은 타입) — 위 모드 중 골라 requires 에 선언")
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 [])):
# 검증은 병합 뷰(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 <type>' 먼저" % (a.source, a.source))
binds = w.get("binds", []) or []; w["binds"] = binds
binds[:] = [b for b in binds if b.get("alias") != a.alias]
@ -377,11 +488,22 @@ def build_parser() -> argparse.ArgumentParser:
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)
sol = sosub.add_parser("ls", help="클러스터 소스 목록(운영 조회 --env prod, 에이전트 --json)")
sol.add_argument("--env", choices=["dev", "val", "prod"], help="대상 환경(environments.<env>.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/계정 민팅(소용량 클러스터용, 현재 mongodb)")
help="공유 외부 소스(mode:shared) — 백엔드가 격리 DB/계정 민팅. 지원=pg·mysql·mariadb·mongodb·rabbitmq·solr "
"(배포별 가용성은 'yakcloud datasource options <type>' 로 확인)")
soa.set_defaults(fn=cmd_source)
sor = sosub.add_parser("rm"); sor.add_argument("name"); sor.set_defaults(fn=cmd_source)