380 lines
17 KiB
Python
380 lines
17 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 {}
|
|
ref = (os.environ.get("YAKCLOUD_CLUSTER")
|
|
or (envs.get(env) or {}).get("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 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", "-"))
|
|
ref = m.get("cluster") or os.environ.get("YAKCLOUD_CLUSTER") or "-"
|
|
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))
|
|
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("\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 = m.get("requires", []) or []
|
|
if reqs:
|
|
print("\ndata sources (requires):")
|
|
for r in reqs:
|
|
s = svcs.get(r["name"])
|
|
st = (" [%s]" % s.get("status")) if s else ""
|
|
print(" • %-14s %s/%s%s" % (r["name"], 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 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")
|
|
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)
|
|
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 cmd_source_ls(a) -> None:
|
|
"""클러스터에서 이용 가능한 데이터 소스 목록 — 이름·타입·내부/외부·상태·플랜."""
|
|
need_api()
|
|
m = (yaml.safe_load(open(MANIFEST)) if os.path.exists(MANIFEST) else {}) or {}
|
|
c = resolve_cluster(cluster_ref(m))
|
|
svcs = unwrap(api("GET", "/clusters/%s/services" % c["id"])) or []
|
|
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")
|
|
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])
|
|
print("클러스터 '%s' 데이터 소스 (%d)" % (c.get("name") or c["id"], 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>")
|
|
|
|
|
|
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 [])):
|
|
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="클러스터에서 이용 가능한 소스 목록"); sol.set_defaults(fn=cmd_source_ls)
|
|
soa = sosub.add_parser("add"); soa.add_argument("name"); soa.add_argument("type")
|
|
soa.add_argument("plan", nargs="?", default="small"); 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)
|