523 lines
27 KiB
Python
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)
|