feat(cli): clusterName 환경별 소스명 매핑 + 운영 소스 3-모드 결정 조회(datasource options/ls) + 검증·문서 · v0.32.0
This commit is contained in:
152
.yakcloud/ctl.py
152
.yakcloud/ctl.py
@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user