From da1911a0361d16b81ae0d7a61ab0a7180998785e Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Sat, 29 Aug 2026 19:33:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(dev):=20=EB=8B=A4=EC=A4=91=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20dev=20=ED=81=B4=EB=9F=AC=EC=8A=A4=ED=84=B0(--name/-?= =?UTF-8?q?-port)=20+=20=EB=85=B8=EB=93=9C=20=EB=A6=AC=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=ED=98=84=ED=99=A9=20=EB=A6=B4=EB=A0=88?= =?UTF-8?q?=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `yakcloud dev up --name dev2 --port 8081` 로 머신에 여러 kind dev 클러스터(공유 소스 컨테이너 재사용). agent 가 노드 CPU/MEM/DISK(예약·실측)를 릴레이 → 콘솔이 원격과 동일한 노드 리소스 카드로 표시. dev up 이 metrics-server 자동 설치(실측용). 리뷰 반영(pidfile 정리·포트 범위·phase·디스크). Co-Authored-By: Claude Opus 4.8 --- .yakcloud/agent.py | 104 +++++++++++++++++++++++++++++++++++++++++++++ .yakcloud/dev.py | 20 ++++++--- bin/yakcloud | 88 ++++++++++++++++++++++++++++++-------- 3 files changed, 190 insertions(+), 22 deletions(-) diff --git a/.yakcloud/agent.py b/.yakcloud/agent.py index 63abc36..a6e19ce 100644 --- a/.yakcloud/agent.py +++ b/.yakcloud/agent.py @@ -272,7 +272,111 @@ def _kubectl(argv: list[str]) -> str: return r.stdout +def _cpu_m(v) -> int: + """CPU 수량 → millicores. '25m'→25, '1'→1000, '1.5'→1500.""" + if not v: + return 0 + v = str(v) + if v.endswith("m"): + try: + return int(float(v[:-1])) + except ValueError: + return 0 + if v.endswith("n"): # nanocores(top 일부) + return int(float(v[:-1]) / 1_000_000) + try: + return int(float(v) * 1000) + except ValueError: + return 0 + + +def _mem_b(v) -> int: + """메모리 수량 → bytes. Ki/Mi/Gi/Ti + K/M/G 접미사.""" + if not v: + return 0 + v = str(v).strip() + units = {"Ki": 1024, "Mi": 1024**2, "Gi": 1024**3, "Ti": 1024**4, + "K": 1000, "M": 1000**2, "G": 1000**3, "T": 1000**4} + for u, mult in units.items(): + if v.endswith(u): + try: + return int(float(v[:-len(u)]) * mult) + except ValueError: + return 0 + try: + return int(float(v)) + except ValueError: + return 0 + + def _h_kube(action: str, params: dict) -> dict: + if action == "usage": + nodes_j = json.loads(_kubectl(["get", "nodes", "-o", "json"]) or "{}").get("items", []) + pods_j = json.loads(_kubectl(["get", "pods", "-A", "-o", "json"]) or "{}").get("items", []) + # 노드별 예약(스케줄된 비종료 파드 컨테이너 requests 합) + req = {} + for p in pods_j: + nn = (p.get("spec", {}) or {}).get("nodeName") + if not nn or (p.get("status", {}) or {}).get("phase") in ("Succeeded", "Failed"): + continue + acc = req.setdefault(nn, [0, 0]) + for c in (p.get("spec", {}) or {}).get("containers", []) or []: + rq = ((c.get("resources", {}) or {}).get("requests", {}) or {}) + acc[0] += _cpu_m(rq.get("cpu")) + acc[1] += _mem_b(rq.get("memory")) + # 실측(metrics-server 있으면): kubectl top nodes + top = {} + r = subprocess.run(["kubectl", "--context", CTX, "top", "nodes", "--no-headers"], + capture_output=True, text=True) + if r.returncode == 0: + for ln in r.stdout.splitlines(): + pt = ln.split() + if len(pt) >= 5: # NAME CPU(cores) CPU% MEM MEM% + top[pt[0]] = {"cpu": pt[1], "cpu_pct": pt[2].rstrip("%"), "mem": pt[3], "mem_pct": pt[4].rstrip("%")} + out = [] + for n in nodes_j: + meta, st = n.get("metadata", {}) or {}, n.get("status", {}) or {} + name = meta.get("name") + labels = meta.get("labels", {}) or {} + roles = [k.split("/", 1)[1] for k in labels if k.startswith("node-role.kubernetes.io/")] + cap, alloc = st.get("capacity", {}) or {}, st.get("allocatable", {}) or {} + cpu_alloc = round(_cpu_m(alloc.get("cpu")) / 1000.0, 2) + mem_alloc = round(_mem_b(alloc.get("memory")) / (1024**3), 2) + rq = req.get(name, [0, 0]) + cpu_req = round(rq[0] / 1000.0, 2) + mem_req = round(rq[1] / (1024**3), 2) + ready = any(c.get("type") == "Ready" and c.get("status") == "True" + for c in (st.get("conditions", []) or [])) + t = top.get(name, {}) + # 디스크 실측 — kubelet Summary API(metrics-server 불필요, kind 지원). 원격(백엔드)과 동일 필드. + disk = disk_pct = disk_cap = None + ds = subprocess.run(["kubectl", "--context", CTX, "get", "--raw", + f"/api/v1/nodes/{name}/proxy/stats/summary"], capture_output=True, text=True) + if ds.returncode == 0: + try: + fs = ((json.loads(ds.stdout).get("node", {}) or {}).get("fs", {})) or {} + used, capb = fs.get("usedBytes"), fs.get("capacityBytes") + if used is not None and capb: + disk = f"{round(used / 1024**3, 1)}Gi" + disk_cap = round(capb / 1024**3, 1) + disk_pct = str(round(used / capb * 100)) + except (ValueError, ZeroDivisionError, TypeError): + pass + out.append({ + "name": name, "roles": roles or ["control-plane"], "joined": ready, "ready": ready, + "pending": False, "maas_status": None, # kind 노드 — MAAS 프로비저닝 개념 없음 + "phase": "Ready" if ready else "NotReady", # NodeMetricDTO.phase(필수) — 상태 배지 + "cpu_cap": round(_cpu_m(cap.get("cpu")) / 1000.0, 2), "cpu_alloc": cpu_alloc, + "cpu_req": cpu_req, "cpu_req_pct": round(cpu_req / cpu_alloc * 100) if cpu_alloc else None, + "cpu": t.get("cpu"), "cpu_pct": t.get("cpu_pct"), + "mem_cap": round(_mem_b(cap.get("memory")) / (1024**3), 2), "mem_alloc": mem_alloc, + "mem_req": mem_req, "mem_req_pct": round(mem_req / mem_alloc * 100) if mem_alloc else None, + "mem": t.get("mem"), "mem_pct": t.get("mem_pct"), + "disk": disk, "disk_pct": disk_pct, "disk_cap": disk_cap, + }) + return {"metrics_available": bool(top), "nodes": out, + "autoscale": {"enabled": False, "min": None, "max": None}, + "provisioning": False, "history": []} if action == "pods": app = params.get("app") or "" sel = ["-l", f"app={app}"] if app else [] diff --git a/.yakcloud/dev.py b/.yakcloud/dev.py index cee76bf..0d01341 100644 --- a/.yakcloud/dev.py +++ b/.yakcloud/dev.py @@ -41,6 +41,8 @@ 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") +DEV_NAME = os.environ.get("YAK_DEV_NAME", "dev") # 여러 dev 클러스터 구분(에이전트 pidfile 키) +HTTP_PORT = os.environ.get("YAK_DEV_HTTP_PORT", "80") # 인그레스 host 포트(이름 붙은 클러스터는 비80) TAG = os.environ.get("TAG", "dev") # 로컬 데이터 소스 = kind '밖'의 **공유** 도커/포드만 컨테이너(타입별 1개) + 프로젝트별 격리 DB. @@ -364,6 +366,12 @@ def _wl_hosts(project: str, w: dict) -> list: return hosts or [f"{project}.{HOST_SUFFIX}"] +def _disp_hosts(project: str, w: dict) -> list: + """콘솔 표시용 호스트 — 비80 인그레스 포트면 host:port(인그레스 규칙 자체엔 포트 안 붙임).""" + sfx = "" if HTTP_PORT == "80" else f":{HTTP_PORT}" + return [h + sfx for h in _wl_hosts(project, w)] + + def _report_to_console(project: str, reqs: list, wls: list) -> None: """dev deploy 결과(소스·워크로드 요약)를 콘솔에 best-effort 리포트 — LOCAL 클러스터 데이터소스·앱 탭 렌더용. 로그인(URL+TOKEN)+등록명(YAK_REG_NAME) 없으면 조용히 스킵(오프라인 dev 불변식).""" @@ -378,9 +386,9 @@ def _report_to_console(project: str, reqs: list, wls: list) -> None: "sources": [{"name": r["name"], "type": str(r.get("type", "")).upper(), "secret": f"yak-dev-src-{sanitize(r['name'])}"} 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], + "hosts": _disp_hosts(project, w), "port": int(w.get("port", 8080))} for w in wls], } - body = json.dumps({"name": name, "displayName": "로컬 개발 (kind)", "report": report}).encode() + body = json.dumps({"name": name, "displayName": name, "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: @@ -398,7 +406,8 @@ def _agent_dir() -> str: def _agent_pidfile(ns: str) -> str: - return os.path.join(_agent_dir(), f"{ns}.pid") + # 클러스터별 구분 — 서로 다른 dev 클러스터가 같은 ns(프로젝트)를 가져도 pidfile 이 안 겹치게. + return os.path.join(_agent_dir(), f"{DEV_NAME}-{ns}.pid") def _stop_agent(ns: str) -> None: @@ -493,8 +502,9 @@ def cmd_deploy(m: dict) -> None: 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") + psfx = "" if HTTP_PORT == "80" else f":{HTTP_PORT}" + print(f" http://{first_host}{psfx}/ (kind 인그레스 포트 {HTTP_PORT})") + print(f" curl -s http://{first_host}{psfx}/ | python3 -m json.tool") print(f" ↑ 응답의 bound_sources 에 바인딩 소스 _URL 키가 보이면 직선 파리티 성립") print(f" kubectl --context {CTX} -n {ns} get deploy,svc,ing,pods") print(f" 승격: yakcloud project deploy --local (레지스트리 push) → yakcloud project promote --to val") diff --git a/bin/yakcloud b/bin/yakcloud index d597380..5a9be7a 100755 --- a/bin/yakcloud +++ b/bin/yakcloud @@ -411,6 +411,28 @@ _dev_runtime() { echo "" } +# 여러 로컬 dev 클러스터 지원 — --name/--port 를 파싱해 클러스터명·컨텍스트·등록명·인그레스 포트를 파생. +# 기본(--name 없음) = dev → kind 'yak-dev', ctx 'kind-yak-dev', 등록명 , 인그레스 80/443 +# --name dev2 --port N → kind 'yak-dev2', ctx 'kind-yak-dev2', 등록명 -dev2, 인그레스 N (80은 기본 클러스터 몫) +# 나머지 인자(태그 등)는 DEV_ARGS 배열에 남긴다(--name 값이 태그로 오인되지 않게 여기서 소비). +_dev_resolve() { + DEV_NAME="dev"; DEV_HTTP_PORT="80"; DEV_PORT_EXPLICIT=0; DEV_ARGS=() + while [ "$#" -gt 0 ]; do + case "$1" in + --name|--cluster) DEV_NAME="${2:-}"; shift 2 || shift ;; + --name=*) DEV_NAME="${1#--name=}"; shift ;; + --cluster=*) DEV_NAME="${1#--cluster=}"; shift ;; + --port) DEV_HTTP_PORT="${2:-}"; DEV_PORT_EXPLICIT=1; shift 2 || shift ;; + --port=*) DEV_HTTP_PORT="${1#--port=}"; DEV_PORT_EXPLICIT=1; shift ;; + *) DEV_ARGS+=("$1"); shift ;; + esac + done + DEV_NAME="$(printf '%s' "$DEV_NAME" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9-' '-' | sed 's/^-*//; s/-*$//')" + [ -z "$DEV_NAME" ] && DEV_NAME="dev" + DEV_CLUSTER="${YAK_DEV_CLUSTER:-yak-$DEV_NAME}" + DEV_CTX="kind-$DEV_CLUSTER" # kind 는 항상 컨텍스트를 'kind-<클러스터명>' 으로 만든다 +} + cmd_dev_up() { command -v kind >/dev/null 2>&1 || die "kind 필요 — 'brew install kind' (https://kind.sigs.k8s.io)" command -v kubectl >/dev/null 2>&1 || die "kubectl 필요" @@ -419,8 +441,19 @@ cmd_dev_up() { if env $kenv kind get clusters 2>/dev/null | grep -qx "$DEV_CLUSTER"; then info "kind 클러스터 '$DEV_CLUSTER' 이미 존재 → 재사용 ($rt)" else - info "kind 클러스터 '$DEV_CLUSTER' 생성 ($rt) — 인그레스 80/443 host 매핑…" - env $kenv kind create cluster --name "$DEV_CLUSTER" --config - <<'EOF' || die "kind 생성 실패" + # 이름 붙은(비기본) 클러스터는 80 이 기본 클러스터 몫 → --port 필수. 기본(dev)은 80/443. + if [ "$DEV_NAME" != "dev" ] && [ "${DEV_PORT_EXPLICIT:-0}" = 0 ]; then + die "이름 붙은 dev 클러스터('$DEV_NAME')는 인그레스 포트가 필요합니다 — 예: yakcloud dev up --name $DEV_NAME --port 8081" + fi + case "$DEV_HTTP_PORT" in ''|*[!0-9]*) die "포트는 숫자여야 합니다: '$DEV_HTTP_PORT'";; esac + if [ "$DEV_HTTP_PORT" -lt 1 ] || [ "$DEV_HTTP_PORT" -gt 65535 ]; then + die "포트는 1-65535 범위여야 합니다: '$DEV_HTTP_PORT'" + fi + info "kind 클러스터 '$DEV_CLUSTER' 생성 ($rt) — 인그레스 http 포트 ${DEV_HTTP_PORT}…" + local ports=" - { containerPort: 80, hostPort: ${DEV_HTTP_PORT}, protocol: TCP }" + [ "$DEV_HTTP_PORT" = "80" ] && ports="$ports + - { containerPort: 443, hostPort: 443, protocol: TCP }" + env $kenv kind create cluster --name "$DEV_CLUSTER" --config - </dev/null 2>&1 \ || warn_line "ingress 컨트롤러 준비 지연 — 잠시 후 'yakcloud dev deploy'." + # metrics-server(실측 노드/파드 CPU·메모리) — 콘솔 '노드 리소스 사용현황'의 실측 바용. kind 는 kubelet TLS 자체서명 → --kubelet-insecure-tls. + info "metrics-server 설치/확인(노드 리소스 실측)…" + kubectl --context "$DEV_CTX" apply -f "${YAK_DEV_METRICS:-https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml}" >/dev/null 2>&1 \ + && kubectl --context "$DEV_CTX" -n kube-system patch deployment metrics-server --type=json \ + -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' >/dev/null 2>&1 \ + || warn_line "metrics-server 설치 건너뜀(무시 가능 — 실측 대신 예약값만 표시)." echo "✓ 로컬 dev 클러스터 준비 ($DEV_CTX). 다음: yakcloud dev deploy" _dev_register } @@ -451,7 +489,10 @@ warn_line() { echo " ⚠ $*" >&2; } # 콘솔 클러스터 목록에 이 로컬 클러스터를 메타 등록 — 머신 호스트명 기준(머신당 1개, 프로젝트는 네임스페이스). _local_reg_name() { local h; h="$(hostname -s 2>/dev/null | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9-' '-' | sed 's/^-*//; s/-*$//')" - echo "${h:-local-dev}" + h="${h:-local-dev}" + # 여러 dev 클러스터 구분 — 기본(dev)은 , 이름 붙은 클러스터는 -(콘솔에 구분 표시). + [ -n "${DEV_NAME:-}" ] && [ "$DEV_NAME" != "dev" ] && h="$h-$DEV_NAME" + echo "$h" } # best-effort 등록/heartbeat — 로그인(URL+토큰) 없으면 조용히 스킵(오프라인 dev 불변식 유지). _dev_register() { @@ -459,7 +500,7 @@ _dev_register() { local name; name="$(_local_reg_name)" curl -fsS -m 6 -X POST "${YAKCLOUD_URL%/}/api/v1/clusters/local" \ -H "Authorization: Bearer $YAKCLOUD_TOKEN" -H 'content-type: application/json' \ - -d "{\"name\":\"$name\",\"displayName\":\"로컬 개발 (kind)\"}" >/dev/null 2>&1 \ + -d "{\"name\":\"$name\",\"displayName\":\"$name\"}" >/dev/null 2>&1 \ && info "콘솔 클러스터 목록에 로컬 '$name' 등록/갱신" || true return 0 } @@ -474,16 +515,17 @@ _dev_deregister() { cmd_dev() { local sub="${1:-}"; [ "$#" -gt 0 ] && shift + _dev_resolve "$@" # --name/--port 파싱 → DEV_NAME/DEV_CLUSTER/DEV_CTX/DEV_HTTP_PORT/DEV_ARGS case "$sub" in - up) cmd_dev_up "$@" ;; + up) cmd_dev_up ;; deploy|status|clean) [ -f yakcloud.yaml ] || die "프로젝트 폴더가 아닙니다 — 'yakcloud project init' 먼저" - [ -f yakcloud.yaml ] || die "yakcloud.yaml 없음" ensure_pyyaml local rt tag=""; rt="$(_dev_runtime)" - for a in "$@"; do case "$a" in -*) : ;; *) [ -z "$tag" ] && tag="$a" ;; esac; done + for a in "${DEV_ARGS[@]+"${DEV_ARGS[@]}"}"; do case "$a" in -*) : ;; *) [ -z "$tag" ] && tag="$a" ;; esac; done local pysub="$sub"; [ "$sub" = clean ] && pysub="down" - YAK_DEV_CONTEXT="$DEV_CTX" YAK_DEV_CLUSTER="$DEV_CLUSTER" YAK_DEV_RUNTIME="${rt:-docker}" \ + YAK_DEV_CONTEXT="$DEV_CTX" YAK_DEV_CLUSTER="$DEV_CLUSTER" YAK_DEV_NAME="$DEV_NAME" \ + YAK_DEV_RUNTIME="${rt:-docker}" YAK_DEV_HTTP_PORT="$DEV_HTTP_PORT" \ YAK_REG_NAME="$(_local_reg_name)" TAG="${tag:-${TAG:-dev}}" python3 "$(_engine dev)" "$pysub" yakcloud.yaml [ "$sub" = deploy ] && _dev_register || true ;; @@ -492,23 +534,35 @@ cmd_dev() { local rt kenv=""; rt="$(_dev_runtime)"; [ "$rt" = podman ] && kenv="KIND_EXPERIMENTAL_PROVIDER=podman" info "kind 클러스터 '$DEV_CLUSTER' 삭제…" env $kenv kind delete cluster --name "$DEV_CLUSTER" && echo "✓ 삭제 완료" - # 공유 데이터 소스 컨테이너(yak-dev-*)도 정리(머신 레벨 전체 teardown). - local scs; scs="$("${rt:-docker}" ps -aq -f label=yakcloud.dev/shared=1 2>/dev/null)" - [ -n "$scs" ] && { info "공유 소스 컨테이너 정리…"; echo "$scs" | xargs "${rt:-docker}" rm -f >/dev/null 2>&1; } - # 릴레이 에이전트 데몬(모든 프로젝트) 정리 — kind 삭제되면 폴링해도 무의미. + # 이 클러스터의 릴레이 에이전트만 정리(pidfile -.pid). 다른 dev 클러스터 것은 건드리지 않음. local agd="${YAK_CONFIG_DIR:-$HOME/.config/yakcloud}/agent" if [ -d "$agd" ]; then - for pf in "$agd"/*.pid; do + for pf in "$agd"/"$DEV_NAME"-*.pid; do [ -f "$pf" ] || continue local apid; apid="$(cat "$pf" 2>/dev/null)" [ -n "$apid" ] && kill "$apid" 2>/dev/null rm -f "$pf" done - info "릴레이 에이전트 정리" + info "릴레이 에이전트 정리($DEV_NAME)" + fi + # 공유 소스 컨테이너(yak-dev-*)는 여러 클러스터가 공유 → '마지막' dev 클러스터 삭제 시에만 정리. + if env $kenv kind get clusters 2>/dev/null | grep -q '^yak-'; then + info "다른 로컬 dev 클러스터가 남아 공유 소스 컨테이너는 유지" + else + local scs; scs="$("${rt:-docker}" ps -aq -f label=yakcloud.dev/shared=1 2>/dev/null)" + [ -n "$scs" ] && { info "공유 소스 컨테이너 정리(마지막 dev 클러스터)…"; echo "$scs" | xargs "${rt:-docker}" rm -f >/dev/null 2>&1; } + # 마지막 클러스터 → 남은 모든 에이전트 pidfile 정리(구 .pid 명명 포함 — 전부 고아). + if [ -d "$agd" ]; then + for pf in "$agd"/*.pid; do + [ -f "$pf" ] || continue + local lp; lp="$(cat "$pf" 2>/dev/null)"; [ -n "$lp" ] && kill "$lp" 2>/dev/null + rm -f "$pf" + done + fi fi _dev_deregister ;; - *) echo "yakcloud dev "; exit 1 ;; + *) echo "yakcloud dev "; exit 1 ;; esac }