feat(dev): 릴레이 에이전트를 클러스터 단위로 — dev up 에서 기동(배포 전 노드 모니터링·연결)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -59,11 +59,16 @@ def log(m: str) -> None:
|
|||||||
|
|
||||||
# ── 로컬 자격 해석: kind Secret(yak-dev-src-*) → password/unit (자격은 여기서만) ──────────────
|
# ── 로컬 자격 해석: kind Secret(yak-dev-src-*) → password/unit (자격은 여기서만) ──────────────
|
||||||
def _read_secret(service: str) -> dict:
|
def _read_secret(service: str) -> dict:
|
||||||
r = subprocess.run(["kubectl", "--context", CTX, "-n", NS, "get", "secret", service, "-o", "json"],
|
# 클러스터 전 네임스페이스에서 이름으로 Secret 조회(에이전트가 특정 프로젝트 ns 에 묶이지 않음).
|
||||||
|
r = subprocess.run(["kubectl", "--context", CTX, "get", "secrets", "-A",
|
||||||
|
"--field-selector", f"metadata.name={service}", "-o", "json"],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
raise DsErr("NOT_FOUND")
|
raise DsErr("NOT_FOUND")
|
||||||
data = (json.loads(r.stdout or "{}").get("data", {}) or {})
|
items = json.loads(r.stdout or "{}").get("items", []) or []
|
||||||
|
if not items:
|
||||||
|
raise DsErr("NOT_FOUND")
|
||||||
|
data = (items[0].get("data", {}) or {})
|
||||||
def dec(k: str) -> str:
|
def dec(k: str) -> str:
|
||||||
v = data.get(k)
|
v = data.get(k)
|
||||||
return base64.b64decode(v).decode() if v else ""
|
return base64.b64decode(v).decode() if v else ""
|
||||||
@ -263,7 +268,8 @@ def _h_redis(sec: dict, action: str, params: dict) -> dict:
|
|||||||
|
|
||||||
# ── 앱(워크로드) 라이브 조회 — 로컬 kind 에 kubectl(호스트). 원격 앱 화면 파리티(P2). ───────────
|
# ── 앱(워크로드) 라이브 조회 — 로컬 kind 에 kubectl(호스트). 원격 앱 화면 파리티(P2). ───────────
|
||||||
def _kubectl(argv: list[str]) -> str:
|
def _kubectl(argv: list[str]) -> str:
|
||||||
r = subprocess.run(["kubectl", "--context", CTX, "-n", NS, *argv], capture_output=True, text=True)
|
# 클러스터 단위 에이전트 — NS 고정 안 함(호출자가 -A / --field-selector 로 네임스페이스 무관 조회).
|
||||||
|
r = subprocess.run(["kubectl", "--context", CTX, *argv], capture_output=True, text=True)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
err = (r.stderr or "").lower()
|
err = (r.stderr or "").lower()
|
||||||
if "notfound" in err.replace(" ", "") or "not found" in err:
|
if "notfound" in err.replace(" ", "") or "not found" in err:
|
||||||
@ -380,7 +386,7 @@ def _h_kube(action: str, params: dict) -> dict:
|
|||||||
if action == "pods":
|
if action == "pods":
|
||||||
app = params.get("app") or ""
|
app = params.get("app") or ""
|
||||||
sel = ["-l", f"app={app}"] if app else []
|
sel = ["-l", f"app={app}"] if app else []
|
||||||
items = json.loads(_kubectl(["get", "pods", *sel, "-o", "json"]) or "{}").get("items", [])
|
items = json.loads(_kubectl(["get", "pods", "-A", *sel, "-o", "json"]) or "{}").get("items", [])
|
||||||
pods = []
|
pods = []
|
||||||
for p in items:
|
for p in items:
|
||||||
st = p.get("status", {}) or {}
|
st = p.get("status", {}) or {}
|
||||||
@ -393,7 +399,11 @@ def _h_kube(action: str, params: dict) -> dict:
|
|||||||
return {"app": app, "pods": pods[:100]}
|
return {"app": app, "pods": pods[:100]}
|
||||||
if action == "describe":
|
if action == "describe":
|
||||||
pod = params["pod"]
|
pod = params["pod"]
|
||||||
p = json.loads(_kubectl(["get", "pod", pod, "-o", "json"]) or "{}")
|
# 전 네임스페이스에서 파드 이름으로 조회(에이전트가 ns 무관).
|
||||||
|
_items = json.loads(_kubectl(["get", "pods", "-A", "--field-selector", f"metadata.name={pod}", "-o", "json"]) or "{}").get("items", [])
|
||||||
|
if not _items:
|
||||||
|
raise DsErr("NOT_FOUND")
|
||||||
|
p = _items[0]
|
||||||
meta = p.get("metadata", {}) or {}
|
meta = p.get("metadata", {}) or {}
|
||||||
spec = p.get("spec", {}) or {}
|
spec = p.get("spec", {}) or {}
|
||||||
st = p.get("status", {}) or {}
|
st = p.get("status", {}) or {}
|
||||||
@ -440,7 +450,7 @@ def _h_kube(action: str, params: dict) -> dict:
|
|||||||
"events": [],
|
"events": [],
|
||||||
}
|
}
|
||||||
if action == "workloads":
|
if action == "workloads":
|
||||||
items = json.loads(_kubectl(["get", "deploy", "-o", "json"]) or "{}").get("items", [])
|
items = json.loads(_kubectl(["get", "deploy", "-A", "-l", "app.kubernetes.io/managed-by=yakcloud-dev", "-o", "json"]) or "{}").get("items", [])
|
||||||
wls = []
|
wls = []
|
||||||
for d in items:
|
for d in items:
|
||||||
sp = d.get("spec", {}) or {}
|
sp = d.get("spec", {}) or {}
|
||||||
@ -517,9 +527,9 @@ def poll_once() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
if not (RELAY_KEY and DS_TOKEN and API_BASE and NS):
|
if not (RELAY_KEY and DS_TOKEN and API_BASE):
|
||||||
raise SystemExit("agent: YAK_RELAY_KEY/YAK_DS_TOKEN/YAK_API_BASE/YAK_DEV_NS 필요")
|
raise SystemExit("agent: YAK_RELAY_KEY/YAK_DS_TOKEN/YAK_API_BASE 필요")
|
||||||
log(f"릴레이 연결: {API_BASE}/clusters/{RELAY_KEY}/ds-actions (ns={NS}, ctx={CTX})")
|
log(f"릴레이 연결(클러스터 단위): {API_BASE}/clusters/{RELAY_KEY}/ds-actions (ctx={CTX})")
|
||||||
while True:
|
while True:
|
||||||
if poll_once() < 0:
|
if poll_once() < 0:
|
||||||
break
|
break
|
||||||
|
|||||||
@ -405,18 +405,28 @@ def _agent_dir() -> str:
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
def _agent_pidfile(ns: str) -> str:
|
def _agent_pidfile() -> str:
|
||||||
# 클러스터별 구분 — 서로 다른 dev 클러스터가 같은 ns(프로젝트)를 가져도 pidfile 이 안 겹치게.
|
# 클러스터 단위 에이전트(프로젝트 무관) — 여러 dev 클러스터는 DEV_NAME 으로 구분.
|
||||||
return os.path.join(_agent_dir(), f"{DEV_NAME}-{ns}.pid")
|
return os.path.join(_agent_dir(), f"{DEV_NAME}.pid")
|
||||||
|
|
||||||
|
|
||||||
def _stop_agent(ns: str) -> None:
|
def _agent_alive() -> bool:
|
||||||
pf = _agent_pidfile(ns)
|
pf = _agent_pidfile()
|
||||||
|
if not os.path.exists(pf):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(int(open(pf).read().strip()), 0) # 신호 0 = 존재 확인
|
||||||
|
return True
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_agent() -> None:
|
||||||
|
pf = _agent_pidfile()
|
||||||
if not os.path.exists(pf):
|
if not os.path.exists(pf):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
pid = int(open(pf).read().strip())
|
os.kill(int(open(pf).read().strip()), signal.SIGTERM)
|
||||||
os.kill(pid, signal.SIGTERM) # 그룹장(start_new_session) → 자식까지
|
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
@ -425,15 +435,18 @@ def _stop_agent(ns: str) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _ensure_agent(project: str, ns: str, reqs: list) -> None:
|
def _ensure_agent() -> None:
|
||||||
"""콘솔에서 ds-token 취득 → 릴레이 에이전트 데몬 기동(콘솔에서 로컬 소스 라이브 관리). best-effort.
|
"""콘솔에서 ds-token 취득 → **클러스터 단위** 릴레이 에이전트 데몬 기동. dev up/deploy 공통.
|
||||||
로그인(URL+TOKEN)+등록명(YAK_REG_NAME) 없거나 소스 없으면 스킵(오프라인 dev 불변식 — 스냅샷만)."""
|
노드 모니터링·데이터·앱을 배포 전에도 라이브로. 이미 살아있으면 스킵(멱등).
|
||||||
|
로그인(URL+TOKEN)+등록명(YAK_REG_NAME) 없으면 조용히 스킵(오프라인 dev 불변식)."""
|
||||||
|
if _agent_alive():
|
||||||
|
return
|
||||||
url = os.environ.get("YAKCLOUD_URL")
|
url = os.environ.get("YAKCLOUD_URL")
|
||||||
tok = os.environ.get("YAKCLOUD_TOKEN")
|
tok = os.environ.get("YAKCLOUD_TOKEN")
|
||||||
name = os.environ.get("YAK_REG_NAME")
|
name = os.environ.get("YAK_REG_NAME")
|
||||||
if not (url and tok and name and reqs):
|
if not (url and tok and name):
|
||||||
return
|
return
|
||||||
_stop_agent(ns) # 재기동(토큰/키 갱신) — 멱등
|
_stop_agent() # 죽은 pidfile 정리
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url.rstrip("/") + f"/api/v1/clusters/local/{quote(name)}/agent-token",
|
req = urllib.request.Request(url.rstrip("/") + f"/api/v1/clusters/local/{quote(name)}/agent-token",
|
||||||
data=b"{}", method="POST",
|
data=b"{}", method="POST",
|
||||||
@ -442,7 +455,7 @@ def _ensure_agent(project: str, ns: str, reqs: list) -> None:
|
|||||||
info = json.loads(r.read() or b"{}")
|
info = json.loads(r.read() or b"{}")
|
||||||
data = info.get("data") or info
|
data = info.get("data") or info
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
warn("릴레이 토큰 취득 실패 — 콘솔의 로컬 소스 라이브 관리 비활성(스냅샷만).")
|
warn("릴레이 토큰 취득 실패 — 콘솔 라이브 모니터링/관리 비활성(로그인/등록 확인).")
|
||||||
return
|
return
|
||||||
relay_key, ds_token, api_base = data.get("relayKey"), data.get("dsToken"), data.get("apiBase")
|
relay_key, ds_token, api_base = data.get("relayKey"), data.get("dsToken"), data.get("apiBase")
|
||||||
if not (relay_key and ds_token and api_base):
|
if not (relay_key and ds_token and api_base):
|
||||||
@ -451,12 +464,12 @@ def _ensure_agent(project: str, ns: str, reqs: list) -> None:
|
|||||||
if not os.path.exists(agent_py):
|
if not os.path.exists(agent_py):
|
||||||
return
|
return
|
||||||
env = dict(os.environ, YAK_RELAY_KEY=relay_key, YAK_DS_TOKEN=ds_token, YAK_API_BASE=api_base,
|
env = dict(os.environ, YAK_RELAY_KEY=relay_key, YAK_DS_TOKEN=ds_token, YAK_API_BASE=api_base,
|
||||||
YAK_DEV_NS=ns, YAK_DEV_CONTEXT=CTX, YAK_DEV_RUNTIME=RUNTIME, YAK_DEV_NET=SHARED_NET)
|
YAK_DEV_CONTEXT=CTX, YAK_DEV_RUNTIME=RUNTIME, YAK_DEV_NET=SHARED_NET)
|
||||||
logf = open(os.path.join(_agent_dir(), f"{ns}.log"), "a")
|
logf = open(os.path.join(_agent_dir(), f"{DEV_NAME}.log"), "a")
|
||||||
p = subprocess.Popen([sys.executable, agent_py], env=env, stdout=logf, stderr=logf,
|
p = subprocess.Popen([sys.executable, agent_py], env=env, stdout=logf, stderr=logf,
|
||||||
stdin=subprocess.DEVNULL, start_new_session=True)
|
stdin=subprocess.DEVNULL, start_new_session=True)
|
||||||
open(_agent_pidfile(ns), "w").write(str(p.pid))
|
open(_agent_pidfile(), "w").write(str(p.pid))
|
||||||
log(f"릴레이 에이전트 기동(pid {p.pid}) — 콘솔에서 로컬 소스를 원격처럼 관리(자격은 노트북 밖으로 안 나감)")
|
log(f"릴레이 에이전트 기동(pid {p.pid}) — 콘솔에서 노드·데이터·앱 라이브(자격은 노트북 밖으로 안 나감)")
|
||||||
|
|
||||||
|
|
||||||
def cmd_deploy(m: dict) -> None:
|
def cmd_deploy(m: dict) -> None:
|
||||||
@ -492,7 +505,7 @@ def cmd_deploy(m: dict) -> None:
|
|||||||
_prune(ns, "workload", "app", {sanitize(w["name"]) for w in wls})
|
_prune(ns, "workload", "app", {sanitize(w["name"]) for w in wls})
|
||||||
|
|
||||||
_report_to_console(project, reqs, wls) # 콘솔 LOCAL 클러스터 탭(데이터소스·앱) 리포트
|
_report_to_console(project, reqs, wls) # 콘솔 LOCAL 클러스터 탭(데이터소스·앱) 리포트
|
||||||
_ensure_agent(project, ns, reqs) # 릴레이 에이전트 기동 — 콘솔에서 로컬 소스 라이브 관리(원격 파리티)
|
_ensure_agent() # 클러스터 단위 릴레이 에이전트 보장(dev up 에서 이미 떴으면 스킵)
|
||||||
|
|
||||||
failed = [h for h, ok in results if not ok]
|
failed = [h for h, ok in results if not ok]
|
||||||
print()
|
print()
|
||||||
@ -523,15 +536,18 @@ def cmd_down(m: dict) -> None:
|
|||||||
"""네임스페이스(이 프로젝트의 dev 리소스)만 정리 — kind 클러스터 자체는 'yakcloud dev down' 이 지운다."""
|
"""네임스페이스(이 프로젝트의 dev 리소스)만 정리 — kind 클러스터 자체는 'yakcloud dev down' 이 지운다."""
|
||||||
context_ok()
|
context_ok()
|
||||||
ns = sanitize(m.get("project", "app"))
|
ns = sanitize(m.get("project", "app"))
|
||||||
_stop_agent(ns) # 릴레이 에이전트 데몬 중지
|
_stop_agent() # 클러스터 릴레이 에이전트 데몬 중지
|
||||||
log(f"네임스페이스 '{ns}' 삭제(로컬 dev 리소스 정리)")
|
log(f"네임스페이스 '{ns}' 삭제(로컬 dev 리소스 정리)")
|
||||||
sh(["kubectl", "--context", CTX, "delete", "namespace", ns, "--ignore-not-found"], capture=False, check=False)
|
sh(["kubectl", "--context", CTX, "delete", "namespace", ns, "--ignore-not-found"], capture=False, check=False)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
if len(sys.argv) < 2 or sys.argv[1] not in ("deploy", "status", "down"):
|
if len(sys.argv) < 2 or sys.argv[1] not in ("up", "deploy", "status", "down"):
|
||||||
die("사용: yakcloud_dev.py <deploy|status|down> [manifest.yaml]")
|
die("사용: yakcloud_dev.py <up|deploy|status|down> [manifest.yaml]")
|
||||||
cmd = sys.argv[1]
|
cmd = sys.argv[1]
|
||||||
|
if cmd == "up": # 클러스터 단위 릴레이 에이전트만 기동(매니페스트 불필요) — dev up 에서 호출
|
||||||
|
_ensure_agent()
|
||||||
|
return
|
||||||
path = next((a for a in sys.argv[2:] if not a.startswith("--")), "yakcloud.yaml")
|
path = next((a for a in sys.argv[2:] if not a.startswith("--")), "yakcloud.yaml")
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
die(f"매니페스트 없음: {path} — 'yakcloud project init' 먼저")
|
die(f"매니페스트 없음: {path} — 'yakcloud project init' 먼저")
|
||||||
|
|||||||
18
bin/yakcloud
18
bin/yakcloud
@ -483,6 +483,10 @@ EOF
|
|||||||
|| warn_line "metrics-server 설치 건너뜀(무시 가능 — 실측 대신 예약값만 표시)."
|
|| warn_line "metrics-server 설치 건너뜀(무시 가능 — 실측 대신 예약값만 표시)."
|
||||||
echo "✓ 로컬 dev 클러스터 준비 ($DEV_CTX). 다음: yakcloud dev deploy"
|
echo "✓ 로컬 dev 클러스터 준비 ($DEV_CTX). 다음: yakcloud dev deploy"
|
||||||
_dev_register
|
_dev_register
|
||||||
|
# 클러스터 단위 릴레이 에이전트 기동 → 배포 전에도 콘솔에서 노드 모니터링·연결(로그인 시). best-effort.
|
||||||
|
YAK_DEV_CONTEXT="$DEV_CTX" YAK_DEV_CLUSTER="$DEV_CLUSTER" YAK_DEV_NAME="$DEV_NAME" \
|
||||||
|
YAK_DEV_RUNTIME="$rt" YAK_REG_NAME="$(_local_reg_name)" \
|
||||||
|
python3 "$(_engine dev)" up 2>/dev/null || true
|
||||||
}
|
}
|
||||||
warn_line() { echo " ⚠ $*" >&2; }
|
warn_line() { echo " ⚠ $*" >&2; }
|
||||||
|
|
||||||
@ -534,15 +538,13 @@ cmd_dev() {
|
|||||||
local rt kenv=""; rt="$(_dev_runtime)"; [ "$rt" = podman ] && kenv="KIND_EXPERIMENTAL_PROVIDER=podman"
|
local rt kenv=""; rt="$(_dev_runtime)"; [ "$rt" = podman ] && kenv="KIND_EXPERIMENTAL_PROVIDER=podman"
|
||||||
info "kind 클러스터 '$DEV_CLUSTER' 삭제…"
|
info "kind 클러스터 '$DEV_CLUSTER' 삭제…"
|
||||||
env $kenv kind delete cluster --name "$DEV_CLUSTER" && echo "✓ 삭제 완료"
|
env $kenv kind delete cluster --name "$DEV_CLUSTER" && echo "✓ 삭제 완료"
|
||||||
# 이 클러스터의 릴레이 에이전트만 정리(pidfile <name>-<ns>.pid). 다른 dev 클러스터 것은 건드리지 않음.
|
# 이 클러스터의 릴레이 에이전트만 정리(pidfile <name>.pid, 클러스터 단위). 다른 dev 클러스터는 건드리지 않음.
|
||||||
local agd="${YAK_CONFIG_DIR:-$HOME/.config/yakcloud}/agent"
|
local agd="${YAK_CONFIG_DIR:-$HOME/.config/yakcloud}/agent"
|
||||||
if [ -d "$agd" ]; then
|
local apf="$agd/$DEV_NAME.pid"
|
||||||
for pf in "$agd"/"$DEV_NAME"-*.pid; do
|
if [ -f "$apf" ]; then
|
||||||
[ -f "$pf" ] || continue
|
local apid; apid="$(cat "$apf" 2>/dev/null)"
|
||||||
local apid; apid="$(cat "$pf" 2>/dev/null)"
|
[ -n "$apid" ] && kill "$apid" 2>/dev/null
|
||||||
[ -n "$apid" ] && kill "$apid" 2>/dev/null
|
rm -f "$apf"
|
||||||
rm -f "$pf"
|
|
||||||
done
|
|
||||||
info "릴레이 에이전트 정리($DEV_NAME)"
|
info "릴레이 에이전트 정리($DEV_NAME)"
|
||||||
fi
|
fi
|
||||||
# 공유 소스 컨테이너(yak-dev-*)는 여러 클러스터가 공유 → '마지막' dev 클러스터 삭제 시에만 정리.
|
# 공유 소스 컨테이너(yak-dev-*)는 여러 클러스터가 공유 → '마지막' dev 클러스터 삭제 시에만 정리.
|
||||||
|
|||||||
Reference in New Issue
Block a user