Files
yakcloud-starter/.yakcloud/ctl.py

523 lines
27 KiB
Python

#!/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.<env>.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.<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:
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.<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 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 <workload> %s <alias>''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 <workload> %s <alias>''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.<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 {}
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 <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)
# 검증은 병합 뷰(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]
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 <workload> (%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.<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/계정 민팅. 지원=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)
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)