refactor(cli): 엔진 전역화(.yakcloud 다운로드 소스) + 스캐폴드 최소화 + shared 기본 — 0.18.0
엔진(deploy/ctl/dev.py)은 install.sh 가 ~/.config/yakcloud/lib 로 설치, CLI 가 전역 실행. project init 스캐폴드 = 앱+매니페스트+얇은 .gitea CI 만. 데이터소스 기본 shared. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
390
.yakcloud/ctl.py
Normal file
390
.yakcloud/ctl.py
Normal file
@ -0,0 +1,390 @@
|
||||
#!/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 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)
|
||||
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 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.add_argument("--shared", action="store_true",
|
||||
help="공유 외부 소스(mode:shared) — 백엔드가 클러스터별 격리 DB/계정 민팅(소용량 클러스터용, 현재 mongodb)")
|
||||
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)
|
||||
253
.yakcloud/deploy.py
Normal file
253
.yakcloud/deploy.py
Normal file
@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""yakcloud deploy — 매니페스트(yakcloud.yaml) 기반 리컨실 배포 CLI (콘솔 API).
|
||||
|
||||
흐름(선언적 idempotent):
|
||||
1) requires 리컨실 — 논리 이름의 소스가 READY 면 스킵, 없으면 type/plan 으로 프로비저닝 → READY 대기.
|
||||
2) workloads 배포 — 각 워크로드 배포(POST .../deployments). 기존이면 PATCH(무중단 롤링).
|
||||
3) 바인딩 — 각 bind(alias→source) 를 POST /services/{serviceId}/bindings {deploymentId, alias}.
|
||||
|
||||
인증: 콘솔 API 에 개인 배포 토큰(PAT) Bearer. (환경변수)
|
||||
YAKCLOUD_URL 예) https://console.yakenator.io
|
||||
YAKCLOUD_TOKEN 배포 토큰(PAT) — 콘솔 설정에서 발급
|
||||
YAKCLOUD_CLUSTER 대상 클러스터 이름 또는 콘솔 id (없으면 매니페스트 cluster:)
|
||||
TAG 이미지 태그 치환용(${TAG}); 없으면 latest
|
||||
사용: yakcloud_deploy.py [manifest.yaml] [--dry-run]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ModuleNotFoundError:
|
||||
raise SystemExit("PyYAML 필요 — 'pip install pyyaml'(또는 'pip3 install --break-system-packages pyyaml') 후 다시 실행하세요.")
|
||||
|
||||
URL = os.environ["YAKCLOUD_URL"].rstrip("/")
|
||||
TOKEN = os.environ["YAKCLOUD_TOKEN"]
|
||||
CLUSTER_REF = os.environ.get("YAKCLOUD_CLUSTER")
|
||||
CLUSTER = "" # main 에서 이름→id 로 해석해 채운다
|
||||
CLUSTER_HOST = "" # 클러스터 기본 도메인 — 항상 노출(운영은 여기 + 운영 도메인)
|
||||
ENV = os.environ.get("YAKCLOUD_ENV", "dev") # 대상 환경 environments.<env> (기본 dev). promote=prod
|
||||
ENV_DOMAINS: list = [] # main 에서 environments.<env>.domains 로 채움(운영 도메인)
|
||||
TAG = os.environ.get("TAG", "latest")
|
||||
DRY = "--dry-run" in sys.argv[1:]
|
||||
|
||||
|
||||
def api(method: str, path: str, body: dict | None = None, _retry: int = 5) -> dict:
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(f"{URL}/api/v1{path}", data=data, method=method,
|
||||
headers={"Authorization": f"Bearer {TOKEN}", "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:
|
||||
payload = e.read().decode()
|
||||
if e.code == 429 and _retry > 0: # 쓰기 레이트리밋 — retryAfterSec 백오프 후 재시도
|
||||
try:
|
||||
wait = json.loads(payload).get("error", {}).get("detail", {}).get("retryAfterSec", 2)
|
||||
except Exception: # noqa: BLE001
|
||||
wait = 2
|
||||
time.sleep(max(1, int(wait)) + 1)
|
||||
return api(method, path, body, _retry - 1)
|
||||
hint = {
|
||||
401: " — 배포 토큰(YAKCLOUD_TOKEN)이 없거나 만료/오류",
|
||||
403: " — 이 토큰 권한으로는 불가(엔드포인트 권한 확인)",
|
||||
404: " — 경로의 id/이름 확인",
|
||||
405: " — 이 배포 토큰/엔드포인트로 허용되지 않는 작업(콘솔에서 처리 필요)",
|
||||
}.get(e.code, "")
|
||||
raise SystemExit(f"[api] {method} {path} -> {e.code}{hint}: {payload[:300]}")
|
||||
|
||||
|
||||
def unwrap(r):
|
||||
return r.get("data", r) if isinstance(r, dict) else r
|
||||
|
||||
|
||||
def log(m: str) -> None:
|
||||
print(("\033[33m[dry]\033[0m " if DRY else "\033[36m▸\033[0m ") + m, flush=True)
|
||||
|
||||
|
||||
def resolve_cluster(ref: str) -> tuple[str, str]:
|
||||
clusters = unwrap(api("GET", "/clusters")) or []
|
||||
for c in clusters:
|
||||
if ref in (c.get("id"), c.get("name")):
|
||||
return c["id"], c.get("name") or c["id"]
|
||||
names = ", ".join(c.get("name", "?") for c in clusters) or "(없음)"
|
||||
raise SystemExit(f"클러스터 '{ref}' 를 찾을 수 없습니다. 계정 클러스터: {names}")
|
||||
|
||||
|
||||
def cluster_services() -> list[dict]:
|
||||
return unwrap(api("GET", f"/clusters/{CLUSTER}/services")) or []
|
||||
|
||||
|
||||
def cluster_deployments() -> list[dict]:
|
||||
return unwrap(api("GET", f"/clusters/{CLUSTER}/deployments")) or []
|
||||
|
||||
|
||||
def _ready_sources() -> str:
|
||||
return ", ".join(s.get("name", "?") for s in cluster_services() if s.get("status") == "READY") or "(없음)"
|
||||
|
||||
|
||||
def reconcile_source(req: dict) -> str | None:
|
||||
name, stype, plan = req["name"], req["type"].upper(), req.get("plan", "small")
|
||||
match = next((s for s in cluster_services() if s.get("name") == name), None)
|
||||
if match and match.get("status") == "READY":
|
||||
log(f"source '{name}' ({stype}) 이미 READY → 스킵 (id={match['id']})")
|
||||
return match["id"]
|
||||
mode = req.get("mode", "shared") # shared(기본)=공유 외부 서버에 격리 DB 민팅 · local=인클러스터 프로비저닝(옵션, size 사용)
|
||||
if DRY:
|
||||
if match:
|
||||
log(f"source '{name}' 상태={match.get('status')} — READY 대기 필요")
|
||||
else:
|
||||
plan_or_mode = "shared" if mode == "shared" else plan
|
||||
log(f"source '{name}' ({stype}, {plan_or_mode}) 없음 → 프로비저닝 예정(POST /services, mode={mode}). 현재 READY: {_ready_sources()}")
|
||||
return None
|
||||
if not match:
|
||||
log(f"source '{name}' ({stype}, {'shared' if mode=='shared' else plan}) 프로비저닝 시도… (mode={mode})")
|
||||
try:
|
||||
# 소스 생성 = POST /services (clusterId 는 body). /clusters/{id}/services 는 GET 전용.
|
||||
# local=인클러스터 프로비저닝(size 사용) · shared=공유 외부(size 무관, 백엔드가 격리 DB/계정 민팅).
|
||||
body = {"clusterId": CLUSTER, "type": stype, "name": name, "mode": mode}
|
||||
if mode == "local":
|
||||
body["size"] = plan
|
||||
api("POST", "/services", body)
|
||||
except SystemExit as e:
|
||||
raise SystemExit(
|
||||
f"소스 '{name}' 생성 실패 — 콘솔에서 소스를 만든 뒤 requires[].name·binds[].source 를 그 소스명으로 "
|
||||
f"지정하거나 다시 시도하세요.\n 현재 READY 소스: {_ready_sources()}\n (원인: {e})")
|
||||
for _ in range(120): # ~10분
|
||||
s = next((s for s in cluster_services() if s.get("name") == name), None)
|
||||
if s and s.get("status") == "READY":
|
||||
log(f"source '{name}' READY (id={s['id']})")
|
||||
return s["id"]
|
||||
if s and s.get("status") == "ERROR":
|
||||
raise SystemExit(f"source '{name}' 프로비저닝 ERROR")
|
||||
time.sleep(5)
|
||||
raise SystemExit(f"source '{name}' READY 대기 초과")
|
||||
|
||||
|
||||
def reconcile_domain(fqdn: str) -> None:
|
||||
"""워크로드 expose host 를 도메인 레지스트리에 리컨실 — 없으면 등록.
|
||||
관리형 도메인(yakenator.io/openrepublic.club/sapiens.inc 등)은 즉시 ACTIVE, 외부는 TXT 검증 안내."""
|
||||
if not fqdn or fqdn == CLUSTER_HOST:
|
||||
return # 클러스터 기본 도메인은 이미 등록·라우팅됨
|
||||
doms = unwrap(api("GET", f"/clusters/{CLUSTER}/domains")) or []
|
||||
if any(d.get("fqdn") == fqdn for d in doms):
|
||||
log(f"domain '{fqdn}' 이미 등록됨")
|
||||
return
|
||||
if DRY:
|
||||
log(f"domain '{fqdn}' 없음 → 등록 예정(관리형=즉시 ACTIVE, 외부=TXT 검증)")
|
||||
return
|
||||
try:
|
||||
d = unwrap(api("POST", f"/clusters/{CLUSTER}/domains", {"fqdn": fqdn}))
|
||||
except SystemExit as e:
|
||||
log(f"⚠ domain '{fqdn}' 등록 실패 — {e}")
|
||||
return
|
||||
log(f"domain '{fqdn}' 등록: status={d.get('status')} cert={d.get('certStatus')}")
|
||||
v = d.get("verify")
|
||||
if v:
|
||||
log(f' 외부 도메인 — DNS 에 TXT 추가 후 검증: {v["host"]} TXT "{v["value"]}"')
|
||||
|
||||
|
||||
def _repo(img: str) -> str:
|
||||
"""이미지에서 :tag 제거한 repo(레지스트리 포트 host:5000/… 의 콜론은 보존)."""
|
||||
seg = img.rsplit("/", 1)[-1]
|
||||
return img.rsplit(":", 1)[0] if ":" in seg else img
|
||||
|
||||
|
||||
def deploy_workload(w: dict) -> tuple[str | None, bool]:
|
||||
image = w["image"].replace("${TAG}", TAG)
|
||||
ex = w.get("expose", {}) or {}
|
||||
res = w.get("resources", {}) or {}
|
||||
body = {
|
||||
"name": w["name"], "image": image,
|
||||
"port": w.get("port"),
|
||||
"replicasDesired": w.get("replicas", 1),
|
||||
"cpuRequest": res.get("cpu", "25m"), "memRequest": res.get("mem", "96Mi"),
|
||||
"healthPath": w.get("health"),
|
||||
"path": ex.get("path", "/"), "pathType": "Prefix", "rewritePrefix": bool(ex.get("rewrite", False)),
|
||||
}
|
||||
# 노출 도메인 = 워크로드 지정 host(s) + 환경 도메인(운영). 커스텀이 있으면 기본 도메인도 함께 노출.
|
||||
wl_hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])
|
||||
custom = list(dict.fromkeys(wl_hosts + ENV_DOMAINS))
|
||||
if custom:
|
||||
body["exposeHosts"] = list(dict.fromkeys(custom + ([CLUSTER_HOST] if CLUSTER_HOST else [])))
|
||||
else:
|
||||
body["exposeHost"] = CLUSTER_HOST or None
|
||||
# 환경변수(선언적): 리스트[{key,value,secret?}] 또는 맵{KEY: VALUE}.
|
||||
env = w.get("env") or []
|
||||
if isinstance(env, dict):
|
||||
env = [{"key": k, "value": v} for k, v in env.items()]
|
||||
body["env"] = [{"key": e["key"], "value": str(e.get("value", "")),
|
||||
"secret": bool(e.get("secret", False))} for e in env]
|
||||
# 충돌 가드 — 같은 이름의 기존 배포가 '다른 앱'(다른 이미지 repo)이면 덮어쓰기 거부(무조건 배포 금지).
|
||||
# 같은 repo(태그만 다름)=정상 재배포는 PATCH 로 진행. (백엔드 create_deployment 도 동일하게 409 로 강제.)
|
||||
existing = next((d for d in cluster_deployments() if d.get("name") == w["name"]), None)
|
||||
if existing:
|
||||
ex_repo = _repo(existing.get("image") or "")
|
||||
if ex_repo and ex_repo != _repo(image):
|
||||
raise SystemExit(
|
||||
f"배포명 '{w['name']}' 충돌 — 이 클러스터에 이미 다른 앱이 그 이름을 쓰고 있습니다.\n"
|
||||
f" 기존 image: {existing.get('image')}\n 내 image: {image}\n"
|
||||
f" → 워크로드 이름을 바꾸거나(yakcloud set 로 name 변경) 기존 배포를 먼저 정리하세요.")
|
||||
# 도메인 리컨실 — 워크로드 host(들) + 환경(운영) 도메인 등록(없으면). 충돌 통과 뒤에만.
|
||||
for h in custom:
|
||||
reconcile_domain(h)
|
||||
if DRY:
|
||||
note = "기존 동일 앱 → PATCH 롤링" if existing else "신규 생성"
|
||||
log(f"deploy '{w['name']}' 예정({note}): image={image} port={body['port']} path={body['path']} "
|
||||
f"replicas={body['replicasDesired']} hosts={body.get('exposeHosts') or body.get('exposeHost')}")
|
||||
return None, False
|
||||
if existing:
|
||||
log(f"deploy '{w['name']}' 기존(동일 앱) → PATCH 롤링 업데이트 image={image}")
|
||||
api("PATCH", f"/deployments/{existing['id']}", body)
|
||||
return existing["id"], False
|
||||
log(f"deploy '{w['name']}' 신규 생성 image={image}")
|
||||
dep = unwrap(api("POST", f"/clusters/{CLUSTER}/deployments", body))
|
||||
return dep.get("id"), True
|
||||
|
||||
|
||||
def bind(dep_id: str | None, alias: str, service_id: str | None, source: str) -> None:
|
||||
if DRY or not dep_id or not service_id:
|
||||
log(f"bind '{alias}' → source '{source}' (serviceId={service_id}) 예정")
|
||||
return
|
||||
api("POST", f"/services/{service_id}/bindings", {"deploymentId": dep_id, "alias": alias})
|
||||
log(f"bind '{alias}' → '{source}' 완료")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global CLUSTER, CLUSTER_HOST, ENV_DOMAINS
|
||||
path = next((a for a in sys.argv[1:] if not a.startswith("--")), "yakcloud.yaml")
|
||||
m = yaml.safe_load(open(path))
|
||||
# 환경 해석: environments.<ENV>.{cluster,domains}. 하위호환: 없으면 top-level cluster:.
|
||||
envs = m.get("environments") or {}
|
||||
env_cfg = envs.get(ENV) or {}
|
||||
# environments 의 env 클러스터가 최우선(저장/전역 YAKCLOUD_CLUSTER 는 하위호환 폴백만).
|
||||
ref = env_cfg.get("cluster") or CLUSTER_REF or m.get("cluster")
|
||||
if not ref:
|
||||
raise SystemExit(
|
||||
f"환경 '{ENV}' 대상 클러스터 미지정 — 매니페스트 environments.{ENV}.cluster (또는 YAKCLOUD_CLUSTER). "
|
||||
f"등록된 환경: {', '.join(envs) or '(없음)'}")
|
||||
ENV_DOMAINS = env_cfg.get("domains") or []
|
||||
CLUSTER, cname = resolve_cluster(ref)
|
||||
CLUSTER_HOST = (unwrap(api("GET", f"/clusters/{CLUSTER}")) or {}).get("defaultHostname") or ""
|
||||
dom = f" +도메인 {ENV_DOMAINS}" if ENV_DOMAINS else ""
|
||||
log(f"[{ENV}] project '{m.get('project')}' → cluster '{cname}' ({CLUSTER}){dom} (tag {TAG}){' [DRY-RUN]' if DRY else ''}")
|
||||
src_ids: dict[str, str | None] = {}
|
||||
for req in m.get("requires", []):
|
||||
src_ids[req["name"]] = reconcile_source(req)
|
||||
for w in m.get("workloads", []):
|
||||
dep_id, created = deploy_workload(w)
|
||||
if DRY or created: # 기존 배포는 PATCH(롤링)로 바인딩 유지 → 신규일 때만 바인딩
|
||||
for b in w.get("binds", []):
|
||||
bind(dep_id, b["alias"], src_ids.get(b["source"]), b["source"])
|
||||
log("완료 — 콘솔 앱 탭에서 배포/바인딩 확인" if not DRY else "완료(dry-run) — 실제 변경 없음")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
463
.yakcloud/dev.py
Normal file
463
.yakcloud/dev.py
Normal file
@ -0,0 +1,463 @@
|
||||
#!/usr/bin/env python3
|
||||
"""yakcloud dev — 로컬 kind 클러스터 리컨실 엔진(개발 환경, 콘솔 API 불필요).
|
||||
|
||||
관리형 리컨실(yakcloud_deploy.py = 콘솔 API)과 **동일한 계약**을 로컬 kind 에서 재현한다.
|
||||
그래야 dev(로컬 kind) → val/prod(관리형 RKE2) 가 '직선'이 된다.
|
||||
1) requires 소스 → 로컬 컨테이너(Deployment+Service+Secret)로 프로비저닝
|
||||
2) workloads → 이미지 빌드 + kind load + Deployment+Service+Ingress
|
||||
3) 바인딩 → 백엔드 _bind_env_for 와 **동일한** <ALIAS>_URL/_HOST/_PORT/… env 주입
|
||||
|
||||
dev 는 CLI 로컬 완결 — 콘솔 API·인터넷 불필요(최초 이미지 pull 제외), 오프라인 반복.
|
||||
kind 생성/삭제(up/down)는 bin/yakcloud 가 담당. 이 엔진은 deploy | status | down(네임스페이스 정리)만.
|
||||
|
||||
환경변수:
|
||||
YAK_DEV_CONTEXT kubectl 컨텍스트(기본 kind-yak-dev)
|
||||
YAK_DEV_CLUSTER kind 클러스터 이름(kind load 용, 기본 yak-dev)
|
||||
YAK_DEV_RUNTIME docker|podman (이미지 빌드/로드)
|
||||
YAK_DEV_HOST_SUFFIX 인그레스 호스트 접미사(기본 dev.localhost) → <project>.dev.localhost
|
||||
TAG 이미지 태그 치환(${TAG}); 없으면 dev
|
||||
사용: yakcloud_dev.py <deploy|status|down> [manifest.yaml]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from urllib.parse import quote
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ModuleNotFoundError:
|
||||
raise SystemExit("PyYAML 필요 — 'pip install pyyaml' 후 다시 실행하세요.")
|
||||
|
||||
CTX = os.environ.get("YAK_DEV_CONTEXT", "kind-yak-dev")
|
||||
KIND_CLUSTER = os.environ.get("YAK_DEV_CLUSTER", "yak-dev")
|
||||
RUNTIME = os.environ.get("YAK_DEV_RUNTIME", "docker")
|
||||
HOST_SUFFIX = os.environ.get("YAK_DEV_HOST_SUFFIX", "dev.localhost")
|
||||
TAG = os.environ.get("TAG", "dev")
|
||||
|
||||
# 소스 타입별 로컬 컨테이너 스펙 — 관리형과 동일한 인클러스터 시크릿 키(_bind_env_for 입력)를 세팅.
|
||||
# port = 백엔드 _SVC_PORT 와 동일. img = 공개 오피셜 이미지(로컬 dev 는 파리티보다 기동성 우선).
|
||||
# 유저명은 백엔드와 동일하게 고정 'appuser'(소스명 파생 금지 — 'root' 등 예약어 충돌·환경 간 값 발산 방지).
|
||||
APP_USER = "appuser"
|
||||
_SVC_PORT = {"POSTGRESQL": 5432, "MYSQL": 3306, "MARIADB": 3306,
|
||||
"MONGODB": 27017, "REDIS": 6379}
|
||||
_IMG = {"POSTGRESQL": "postgres:16-alpine", "MYSQL": "mysql:8", "MARIADB": "mariadb:11",
|
||||
"MONGODB": "mongo:7", "REDIS": "redis:7-alpine"}
|
||||
SUPPORTED = set(_SVC_PORT)
|
||||
MANAGED = "app.kubernetes.io/managed-by=yakcloud-dev"
|
||||
|
||||
|
||||
def log(m: str) -> None:
|
||||
print("\033[36m▸\033[0m " + m, flush=True)
|
||||
|
||||
|
||||
def warn(m: str) -> None:
|
||||
print("\033[33m⚠\033[0m " + m, flush=True)
|
||||
|
||||
|
||||
def die(m: str) -> None:
|
||||
raise SystemExit("\033[31m✗\033[0m " + m)
|
||||
|
||||
|
||||
def sanitize(s: str) -> str:
|
||||
"""K8s 이름 규칙(소문자·숫자·하이픈, 시작/끝은 영숫자)."""
|
||||
out = "".join(c if (c.isalnum() or c == "-") else "-" for c in (s or "").lower())
|
||||
out = out.strip("-") or "app"
|
||||
return out[:53]
|
||||
|
||||
|
||||
def sh(cmd: list[str], *, input: str | None = None, check: bool = True,
|
||||
capture: bool = True, env: dict | None = None) -> subprocess.CompletedProcess:
|
||||
e = dict(os.environ)
|
||||
if env:
|
||||
e.update(env)
|
||||
r = subprocess.run(cmd, input=input, text=True,
|
||||
stdout=subprocess.PIPE if capture else None,
|
||||
stderr=subprocess.PIPE if capture else None, env=e)
|
||||
if check and r.returncode != 0:
|
||||
msg = (r.stderr or r.stdout or "").strip()
|
||||
die(f"명령 실패: {' '.join(cmd)}\n {msg[:500]}")
|
||||
return r
|
||||
|
||||
|
||||
def kubectl(args: list[str], *, input: str | None = None, check: bool = True,
|
||||
capture: bool = True) -> subprocess.CompletedProcess:
|
||||
return sh(["kubectl", "--context", CTX, *args], input=input, check=check, capture=capture)
|
||||
|
||||
|
||||
def apply(objs: list[dict]) -> None:
|
||||
"""K8s 오브젝트(dict)들을 kubectl apply -f - 로 반영."""
|
||||
doc = "\n---\n".join(json.dumps(o) for o in objs)
|
||||
kubectl(["apply", "-f", "-"], input=doc)
|
||||
|
||||
|
||||
def context_ok() -> None:
|
||||
r = sh(["kubectl", "config", "get-contexts", "-o", "name"], check=False)
|
||||
if CTX not in (r.stdout or "").split():
|
||||
die(f"kubectl 컨텍스트 '{CTX}' 없음 — 먼저 'yakcloud dev up' 으로 로컬 클러스터를 만드세요.")
|
||||
|
||||
|
||||
def ensure_ns(ns: str) -> None:
|
||||
apply([{"apiVersion": "v1", "kind": "Namespace",
|
||||
"metadata": {"name": ns, "labels": {"app.kubernetes.io/managed-by": "yakcloud-dev"}}}])
|
||||
|
||||
|
||||
# ── 소스 자격: 최초 생성 시 랜덤(유저=고정 appuser, db=소스별), 이후 재사용(idempotent) ──────
|
||||
def get_or_make_creds(ns: str, name: str) -> dict:
|
||||
sec = f"yak-dev-src-{sanitize(name)}"
|
||||
r = kubectl(["get", "secret", sec, "-n", ns, "-o", "json"], check=False)
|
||||
if r.returncode == 0:
|
||||
data = json.loads(r.stdout).get("data", {})
|
||||
c = {k: base64.b64decode(v).decode() for k, v in data.items()}
|
||||
c["secret"] = sec # 재사용 경로도 secret 이름을 포함(멱등 재배포 시 KeyError 방지)
|
||||
return c
|
||||
return {"user": APP_USER, "password": secrets.token_hex(16),
|
||||
"db": sanitize(name)[:24] or "app", "secret": sec}
|
||||
|
||||
|
||||
def _source_container(stype: str, c: dict) -> dict:
|
||||
"""소스 타입별 컨테이너 정의 + 인클러스터 시크릿 키(관리형과 동일) + '앱-유저 인증' readiness.
|
||||
|
||||
readiness 를 tcp 가 아니라 **앱 유저 로그인 exec** 으로 게이트한다 — 초기화(유저/DB 생성) 중
|
||||
포트만 열린 상태를 'Ready' 로 오판해 workload 가 먼저 붙어 인증 실패로 크래시하는 것을 막는다."""
|
||||
port = _SVC_PORT[stype]
|
||||
rootpw = c["password"] + "root"
|
||||
env: list[dict] = []
|
||||
args: list[str] | None = None
|
||||
init_cm: dict | None = None
|
||||
vol_mounts: list[dict] = []
|
||||
if stype == "POSTGRESQL":
|
||||
env = [{"name": "POSTGRES_USER", "value": c["user"]},
|
||||
{"name": "POSTGRES_PASSWORD", "value": c["password"]},
|
||||
{"name": "POSTGRES_DB", "value": c["db"]},
|
||||
{"name": "PGDATA", "value": "/var/lib/postgresql/data/pgdata"}]
|
||||
probe = ["sh", "-c", 'PGPASSWORD="$POSTGRES_PASSWORD" psql -h 127.0.0.1 -U "$POSTGRES_USER" '
|
||||
'-d "$POSTGRES_DB" -c "SELECT 1" >/dev/null 2>&1']
|
||||
elif stype == "MYSQL":
|
||||
env = [{"name": "MYSQL_USER", "value": c["user"]},
|
||||
{"name": "MYSQL_PASSWORD", "value": c["password"]},
|
||||
{"name": "MYSQL_DATABASE", "value": c["db"]},
|
||||
{"name": "MYSQL_ROOT_PASSWORD", "value": rootpw}]
|
||||
probe = ["sh", "-c", 'mysql -h127.0.0.1 -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" '
|
||||
'"$MYSQL_DATABASE" -e "SELECT 1" >/dev/null 2>&1']
|
||||
elif stype == "MARIADB":
|
||||
env = [{"name": "MARIADB_USER", "value": c["user"]},
|
||||
{"name": "MARIADB_PASSWORD", "value": c["password"]},
|
||||
{"name": "MARIADB_DATABASE", "value": c["db"]},
|
||||
{"name": "MARIADB_ROOT_PASSWORD", "value": rootpw}]
|
||||
probe = ["sh", "-c", 'mariadb -h127.0.0.1 -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" '
|
||||
'"$MARIADB_DATABASE" -e "SELECT 1" >/dev/null 2>&1']
|
||||
elif stype == "REDIS":
|
||||
args = ["redis-server", "--requirepass", c["password"]]
|
||||
env = [{"name": "REDISCLI_AUTH", "value": c["password"]}]
|
||||
probe = ["sh", "-c", 'redis-cli ping | grep -q PONG']
|
||||
elif stype == "MONGODB":
|
||||
# 루트 유저(root) + 앱 db 에 앱 유저(appuser) 생성(authSource=db 파리티) via init.js
|
||||
env = [{"name": "MONGO_INITDB_ROOT_USERNAME", "value": "root"},
|
||||
{"name": "MONGO_INITDB_ROOT_PASSWORD", "value": rootpw},
|
||||
{"name": "MONGO_INITDB_DATABASE", "value": c["db"]},
|
||||
{"name": "MONGO_APP_USER", "value": c["user"]},
|
||||
{"name": "MONGO_APP_PASSWORD", "value": c["password"]},
|
||||
{"name": "MONGO_APP_DB", "value": c["db"]}]
|
||||
js = (f'db.getSiblingDB("{c["db"]}").createUser({{user:"{c["user"]}",'
|
||||
f'pwd:"{c["password"]}",roles:[{{role:"readWrite",db:"{c["db"]}"}}]}});')
|
||||
init_cm = {"js": js}
|
||||
vol_mounts = [{"name": "initdb", "mountPath": "/docker-entrypoint-initdb.d"}]
|
||||
probe = ["sh", "-c", 'mongosh "mongodb://$MONGO_APP_USER:$MONGO_APP_PASSWORD@127.0.0.1:'
|
||||
f'{port}/$MONGO_APP_DB?authSource=$MONGO_APP_DB" --quiet '
|
||||
'--eval "db.runCommand({ping:1}).ok" | grep -q 1']
|
||||
else: # pragma: no cover
|
||||
die(f"내부오류: 미지원 타입 {stype}")
|
||||
cont = {"name": "db", "image": _IMG[stype], "imagePullPolicy": "IfNotPresent",
|
||||
"ports": [{"containerPort": port}], "env": env,
|
||||
"readinessProbe": {"exec": {"command": probe}, "initialDelaySeconds": 5,
|
||||
"periodSeconds": 5, "failureThreshold": 40, "timeoutSeconds": 5},
|
||||
"volumeMounts": vol_mounts}
|
||||
if args:
|
||||
cont["args"] = args
|
||||
return {"container": cont, "port": port, "init_cm": init_cm, "vol_mounts": vol_mounts}
|
||||
|
||||
|
||||
def provision_source(ns: str, req: dict) -> dict | None:
|
||||
"""requires 소스를 로컬 컨테이너로 프로비저닝(idempotent) → 접속정보 반환."""
|
||||
name = req["name"]
|
||||
stype = req["type"].upper()
|
||||
if req.get("mode") == "shared":
|
||||
warn(f"source '{name}': mode=shared 는 로컬 dev 에서 일반 로컬 컨테이너로 대체(격리 DB 민팅 없음).")
|
||||
if stype not in SUPPORTED:
|
||||
die(f"source '{name}' 타입 {stype} — 로컬 dev 미지원(지원: {', '.join(sorted(SUPPORTED))}). "
|
||||
f"관리형(deploy/promote)에서는 지원됩니다.")
|
||||
dep = sanitize(f"src-{name}")
|
||||
svc = sanitize(name)
|
||||
creds = get_or_make_creds(ns, name)
|
||||
spec = _source_container(stype, creds)
|
||||
labels = {"app": dep, "app.kubernetes.io/managed-by": "yakcloud-dev",
|
||||
"yakcloud.dev/role": "source", "yakcloud.dev/source": svc}
|
||||
objs: list[dict] = [{
|
||||
"apiVersion": "v1", "kind": "Secret",
|
||||
"metadata": {"name": creds["secret"], "namespace": ns, "labels": labels},
|
||||
"stringData": {"user": creds["user"], "password": creds["password"], "db": creds["db"]},
|
||||
}]
|
||||
volumes: list[dict] = []
|
||||
if spec["init_cm"]:
|
||||
cm = f"{dep}-init"
|
||||
objs.append({"apiVersion": "v1", "kind": "ConfigMap",
|
||||
"metadata": {"name": cm, "namespace": ns, "labels": labels},
|
||||
"data": {"init.js": spec["init_cm"]["js"]}})
|
||||
volumes.append({"name": "initdb", "configMap": {"name": cm}})
|
||||
objs.append({
|
||||
"apiVersion": "apps/v1", "kind": "Deployment",
|
||||
"metadata": {"name": dep, "namespace": ns, "labels": labels},
|
||||
"spec": {"replicas": 1, "selector": {"matchLabels": {"app": dep}},
|
||||
"strategy": {"type": "Recreate"},
|
||||
"template": {"metadata": {"labels": labels},
|
||||
"spec": {"containers": [spec["container"]], "volumes": volumes}}},
|
||||
})
|
||||
objs.append({
|
||||
"apiVersion": "v1", "kind": "Service",
|
||||
"metadata": {"name": svc, "namespace": ns, "labels": labels},
|
||||
"spec": {"selector": {"app": dep},
|
||||
"ports": [{"port": spec["port"], "targetPort": spec["port"]}]},
|
||||
})
|
||||
log(f"source '{name}' ({stype}) 프로비저닝 → 서비스 {svc}:{spec['port']}")
|
||||
apply(objs)
|
||||
r = kubectl(["rollout", "status", f"deployment/{dep}", "-n", ns, "--timeout=300s"], check=False)
|
||||
if r.returncode != 0:
|
||||
die(f"source '{name}' 기동/인증 준비 대기 실패 — 'kubectl --context {CTX} -n {ns} get pods' 확인")
|
||||
return {"stype": stype, "host": svc, "port": spec["port"],
|
||||
"user": creds["user"], "password": creds["password"], "db": creds["db"]}
|
||||
|
||||
|
||||
# ── 바인딩 env: 백엔드 _bind_env_for 와 동일(하이픈 alias → 언더스코어 프리픽스 포함) ────────
|
||||
def bind_env(alias: str, conn: dict) -> dict:
|
||||
p = alias.upper().replace("-", "_") # 백엔드와 동일 — 하이픈 alias 에서도 env 키 일치
|
||||
host, port = conn["host"], str(conn["port"])
|
||||
user, pw, db = conn["user"], conn["password"], conn["db"]
|
||||
qu, qp = quote(user, safe=""), quote(pw, safe="")
|
||||
out = {f"{p}_HOST": host, f"{p}_PORT": port}
|
||||
st = conn["stype"]
|
||||
if st == "MONGODB":
|
||||
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db,
|
||||
f"{p}_URL": f"mongodb://{qu}:{qp}@{host}:{port}/{db}?authSource={db}"})
|
||||
elif st == "REDIS":
|
||||
out.update({f"{p}_USERNAME": "", f"{p}_PASSWORD": pw, f"{p}_DB": "0",
|
||||
f"{p}_URL": f"redis://:{qp}@{host}:{port}/0"})
|
||||
elif st == "MYSQL":
|
||||
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db,
|
||||
f"{p}_URL": f"mysql://{qu}:{qp}@{host}:{port}/{db}"})
|
||||
elif st == "MARIADB":
|
||||
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db,
|
||||
f"{p}_URL": f"mysql://{qu}:{qp}@{host}:{port}/{db}"})
|
||||
else: # POSTGRESQL
|
||||
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db,
|
||||
f"{p}_URL": f"postgresql://{qu}:{qp}@{host}:{port}/{db}"})
|
||||
return out
|
||||
|
||||
|
||||
# ── 워크로드: 빌드 + kind load + Deployment/Service/Ingress ─────────────────────
|
||||
def _image_id(image: str) -> str:
|
||||
r = sh([RUNTIME, "image", "inspect", "--format", "{{.Id}}", image], check=False)
|
||||
return (r.stdout or "").strip() if r.returncode == 0 else ""
|
||||
|
||||
|
||||
def build_and_load(image: str, ctx_dir: str) -> str:
|
||||
log(f"이미지 빌드({RUNTIME}) {image} ← {ctx_dir}")
|
||||
sh([RUNTIME, "build", "-t", image, ctx_dir], capture=False)
|
||||
log(f"kind load → 클러스터 {KIND_CLUSTER}")
|
||||
env = {"KIND_EXPERIMENTAL_PROVIDER": "podman"} if RUNTIME == "podman" else {}
|
||||
sh(["kind", "load", "docker-image", image, "--name", KIND_CLUSTER], capture=False, env=env)
|
||||
return _image_id(image)
|
||||
|
||||
|
||||
def deploy_workload(project: str, ns: str, w: dict, src_conns: dict) -> tuple[str, bool]:
|
||||
image = w["image"].replace("${TAG}", TAG)
|
||||
port = int(w.get("port", 8080))
|
||||
ex = w.get("expose", {}) or {}
|
||||
res = w.get("resources", {}) or {}
|
||||
name = sanitize(w["name"])
|
||||
img_id = build_and_load(image, w["build"]) if w.get("build") else ""
|
||||
|
||||
# env = 컨테이너 기본 + 워크로드 선언 env + 바인딩 소스 env(직선 파리티)
|
||||
env = [{"name": "PORT", "value": str(port)}, {"name": "APP_NAME", "value": project}]
|
||||
wenv = w.get("env") or []
|
||||
if isinstance(wenv, dict):
|
||||
wenv = [{"key": k, "value": v} for k, v in wenv.items()]
|
||||
for e in wenv:
|
||||
env.append({"name": e["key"], "value": str(e.get("value", ""))})
|
||||
for b in w.get("binds", []) or []:
|
||||
conn = src_conns.get(b["source"])
|
||||
if not conn:
|
||||
die(f"bind '{b['alias']}' → source '{b['source']}' 미해결(requires 에 없음).")
|
||||
for k, v in bind_env(b["alias"], conn).items():
|
||||
env.append({"name": k, "value": v})
|
||||
|
||||
labels = {"app": name, "app.kubernetes.io/managed-by": "yakcloud-dev", "yakcloud.dev/role": "workload"}
|
||||
# 고정 태그(dev) 재빌드 시에도 새 이미지가 반영되도록 image-id 를 파드 템플릿 어노테이션에(spec diff 유발).
|
||||
pod_meta = {"labels": labels}
|
||||
if img_id:
|
||||
pod_meta["annotations"] = {"yakcloud.dev/image-id": img_id}
|
||||
container = {"name": name, "image": image, "imagePullPolicy": "IfNotPresent",
|
||||
"ports": [{"containerPort": port}], "env": env,
|
||||
"resources": {"requests": {"cpu": res.get("cpu", "25m"), "memory": res.get("mem", "96Mi")}}}
|
||||
if w.get("health"):
|
||||
container["readinessProbe"] = {"httpGet": {"path": w["health"], "port": port},
|
||||
"initialDelaySeconds": 3, "periodSeconds": 5, "failureThreshold": 30}
|
||||
dep = {"apiVersion": "apps/v1", "kind": "Deployment",
|
||||
"metadata": {"name": name, "namespace": ns, "labels": labels},
|
||||
"spec": {"replicas": int(w.get("replicas", 1)),
|
||||
"selector": {"matchLabels": {"app": name}},
|
||||
"template": {"metadata": pod_meta, "spec": {"containers": [container]}}}}
|
||||
svc = {"apiVersion": "v1", "kind": "Service",
|
||||
"metadata": {"name": name, "namespace": ns, "labels": labels},
|
||||
"spec": {"selector": {"app": name}, "ports": [{"port": port, "targetPort": port}]}}
|
||||
|
||||
hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])
|
||||
if not hosts:
|
||||
hosts = [f"{project}.{HOST_SUFFIX}"]
|
||||
path = ex.get("path", "/") or "/"
|
||||
rules = [{"host": h, "http": {"paths": [{"path": path, "pathType": "Prefix",
|
||||
"backend": {"service": {"name": name, "port": {"number": port}}}}]}} for h in hosts]
|
||||
ing = {"apiVersion": "networking.k8s.io/v1", "kind": "Ingress",
|
||||
"metadata": {"name": name, "namespace": ns, "labels": labels,
|
||||
"annotations": {"nginx.ingress.kubernetes.io/rewrite-target": "/"} if ex.get("rewrite") else {}},
|
||||
"spec": {"ingressClassName": "nginx", "rules": rules}}
|
||||
|
||||
log(f"deploy '{name}' image={image} port={port} replicas={dep['spec']['replicas']} hosts={hosts}")
|
||||
apply([dep, svc, ing])
|
||||
r = kubectl(["rollout", "status", f"deployment/{name}", "-n", ns, "--timeout=180s"], check=False)
|
||||
ok = r.returncode == 0
|
||||
if not ok:
|
||||
warn(f"'{name}' 롤아웃 실패/지연 — 'kubectl --context {CTX} -n {ns} get pods' 로 확인")
|
||||
return hosts[0], ok
|
||||
|
||||
|
||||
# ── 고아 정리: 매니페스트에 없는(라벨된) 소스/워크로드 리소스 삭제(직선 리컨실) ──────────────
|
||||
def _prune(ns: str, role: str, label_key: str, desired: set) -> None:
|
||||
r = kubectl(["get", "deployment,service,secret,configmap,ingress", "-n", ns,
|
||||
"-l", f"{MANAGED},yakcloud.dev/role={role}", "-o", "json"], check=False)
|
||||
if r.returncode != 0:
|
||||
return
|
||||
for it in json.loads(r.stdout or "{}").get("items", []):
|
||||
val = (it.get("metadata", {}).get("labels", {}) or {}).get(label_key)
|
||||
if val is None or val in desired:
|
||||
continue
|
||||
kind = it["kind"].lower()
|
||||
nm = it["metadata"]["name"]
|
||||
log(f"고아 정리({role}): {kind}/{nm}")
|
||||
kubectl(["delete", kind, nm, "-n", ns, "--ignore-not-found"], check=False)
|
||||
|
||||
|
||||
def _wl_hosts(project: str, w: dict) -> list:
|
||||
ex = w.get("expose", {}) or {}
|
||||
hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])
|
||||
return hosts or [f"{project}.{HOST_SUFFIX}"]
|
||||
|
||||
|
||||
def _report_to_console(project: str, reqs: list, wls: list) -> None:
|
||||
"""dev deploy 결과(소스·워크로드 요약)를 콘솔에 best-effort 리포트 — LOCAL 클러스터 데이터소스·앱 탭 렌더용.
|
||||
로그인(URL+TOKEN)+등록명(YAK_REG_NAME) 없으면 조용히 스킵(오프라인 dev 불변식)."""
|
||||
url = os.environ.get("YAKCLOUD_URL")
|
||||
tok = os.environ.get("YAKCLOUD_TOKEN")
|
||||
name = os.environ.get("YAK_REG_NAME")
|
||||
if not (url and tok and name):
|
||||
return
|
||||
report = {
|
||||
"project": project,
|
||||
"sources": [{"name": r["name"], "type": str(r.get("type", "")).upper()} for r in reqs],
|
||||
"workloads": [{"name": sanitize(w["name"]), "image": w["image"].replace("${TAG}", TAG),
|
||||
"hosts": _wl_hosts(project, w), "port": int(w.get("port", 8080))} for w in wls],
|
||||
}
|
||||
body = json.dumps({"name": name, "displayName": "로컬 개발 (kind)", "report": report}).encode()
|
||||
req = urllib.request.Request(url.rstrip("/") + "/api/v1/clusters/local", data=body, method="POST",
|
||||
headers={"Authorization": "Bearer " + tok, "content-type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=6):
|
||||
log("콘솔 로컬 클러스터 목록에 데이터소스·앱 리포트 반영")
|
||||
except Exception: # noqa: BLE001 — best-effort(오프라인/미로그인/네트워크)
|
||||
pass
|
||||
|
||||
|
||||
def cmd_deploy(m: dict) -> None:
|
||||
context_ok()
|
||||
project = sanitize(m.get("project", "app"))
|
||||
ns = project
|
||||
reqs = m.get("requires", []) or []
|
||||
wls = m.get("workloads", []) or []
|
||||
|
||||
# 충돌 가드 — 서로 다른 소스명/워크로드명이 같은 서비스명으로 뭉개지면 자격 오배선 → 거부.
|
||||
svc_owner: dict[str, str] = {}
|
||||
for r in reqs:
|
||||
s = sanitize(r["name"])
|
||||
if s in svc_owner and svc_owner[s] != r["name"]:
|
||||
die(f"소스명 충돌 — '{svc_owner[s]}' 와 '{r['name']}' 가 같은 서비스명 '{s}' 로 뭉개집니다. 이름을 구분하세요.")
|
||||
svc_owner[s] = r["name"]
|
||||
for w in wls:
|
||||
s = sanitize(w["name"])
|
||||
if s in svc_owner:
|
||||
die(f"워크로드 '{w['name']}' 가 소스 '{svc_owner[s]}' 와 같은 서비스명 '{s}' 로 충돌합니다.")
|
||||
|
||||
log(f"[dev/local] project '{project}' → kind '{KIND_CLUSTER}' (ns {ns}, tag {TAG})")
|
||||
ensure_ns(ns)
|
||||
src_conns: dict[str, dict] = {}
|
||||
for req in reqs:
|
||||
conn = provision_source(ns, req)
|
||||
if conn:
|
||||
src_conns[req["name"]] = conn
|
||||
results = [deploy_workload(project, ns, w, src_conns) for w in wls]
|
||||
|
||||
# 매니페스트에서 빠진 소스/워크로드 정리(고아 방지 — add-only 가 아니라 리컨실).
|
||||
_prune(ns, "source", "yakcloud.dev/source", {sanitize(r["name"]) for r in reqs})
|
||||
_prune(ns, "workload", "app", {sanitize(w["name"]) for w in wls})
|
||||
|
||||
_report_to_console(project, reqs, wls) # 콘솔 LOCAL 클러스터 탭(데이터소스·앱) 리포트
|
||||
|
||||
failed = [h for h, ok in results if not ok]
|
||||
print()
|
||||
if failed:
|
||||
warn(f"일부 워크로드 롤아웃 실패({len(failed)}/{len(results)}) — 아래 상태 확인 후 재배포.")
|
||||
else:
|
||||
log("✓ 로컬 배포 완료(직선 dev). 접속:")
|
||||
first_host = results[0][0] if results else None
|
||||
if first_host:
|
||||
print(f" http://{first_host}/ (kind 인그레스 80 포트)")
|
||||
print(f" curl -s http://{first_host}/ | python3 -m json.tool")
|
||||
print(f" ↑ 응답의 bound_sources 에 바인딩 소스 <ALIAS>_URL 키가 보이면 직선 파리티 성립")
|
||||
print(f" kubectl --context {CTX} -n {ns} get deploy,svc,ing,pods")
|
||||
print(f" 승격: yakcloud project deploy --local <tag> (레지스트리 push) → yakcloud project promote --to val")
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_status(m: dict) -> None:
|
||||
context_ok()
|
||||
ns = sanitize(m.get("project", "app"))
|
||||
print(f"[dev/local] ns={ns} context={CTX}")
|
||||
sh(["kubectl", "--context", CTX, "-n", ns, "get", "deploy,svc,ing,pods"], capture=False, check=False)
|
||||
|
||||
|
||||
def cmd_down(m: dict) -> None:
|
||||
"""네임스페이스(이 프로젝트의 dev 리소스)만 정리 — kind 클러스터 자체는 'yakcloud dev down' 이 지운다."""
|
||||
context_ok()
|
||||
ns = sanitize(m.get("project", "app"))
|
||||
log(f"네임스페이스 '{ns}' 삭제(로컬 dev 리소스 정리)")
|
||||
sh(["kubectl", "--context", CTX, "delete", "namespace", ns, "--ignore-not-found"], capture=False, check=False)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in ("deploy", "status", "down"):
|
||||
die("사용: yakcloud_dev.py <deploy|status|down> [manifest.yaml]")
|
||||
cmd = sys.argv[1]
|
||||
path = next((a for a in sys.argv[2:] if not a.startswith("--")), "yakcloud.yaml")
|
||||
if not os.path.exists(path):
|
||||
die(f"매니페스트 없음: {path} — 'yakcloud project init' 먼저")
|
||||
m = yaml.safe_load(open(path)) or {}
|
||||
{"deploy": cmd_deploy, "status": cmd_status, "down": cmd_down}[cmd](m)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user