diff --git a/.yakcloud/agent.py b/.yakcloud/agent.py new file mode 100644 index 0000000..0a9816a --- /dev/null +++ b/.yakcloud/agent.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""yakcloud dev 릴레이 에이전트 — 로컬(kind, NAT 뒤) 클러스터를 원격과 '동일한 콘솔'로. + +콘솔 데이터소스 매니저 → yakcloud-api ds-actions 큐 → (이 에이전트가 롱폴로 수신) → +로컬 kind Secret 을 읽어 자격 해석 + 공유 도커 컨테이너의 격리 DB 에 접속 → 결과만 반환. +**자격(비밀번호)은 노트북을 절대 떠나지 않는다** — 경계엔 액션·소스식별자·고정 enum 결과만. + +원격 백엔드(_ds_h_*)와 '동일한 응답 스키마'를 미러링한다(계약 보존) → 콘솔 매니저 무변경 재사용. +소스 접속은 컨테이너 네이티브 클라이언트(docker exec: psql/mysql/mongosh/redis-cli)로 — +노트북에 별도 드라이버 설치가 필요 없다("콘솔 하나로, 별도 툴 설치 없이"). + +환경변수(dev.py 가 주입): + YAK_RELAY_KEY 릴레이 키(=콘솔 LOCAL cluster.id) — ds-actions 채널 키 + YAK_DS_TOKEN per-cluster ds-token(Bearer) — 롱폴/결과 인증 + YAK_API_BASE 공개 yakcloud-api 베이스(예 https://api.yakenator.io) + YAK_DEV_NS 소스 Secret 이 있는 네임스페이스(프로젝트명) + YAK_DEV_CONTEXT kubectl 컨텍스트(기본 kind-yak-dev) + YAK_DEV_RUNTIME docker|podman + YAK_DEV_NET 공유 컨테이너 도커 네트워크(기본 kind) +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request + +CTX = os.environ.get("YAK_DEV_CONTEXT", "kind-yak-dev") +RUNTIME = os.environ.get("YAK_DEV_RUNTIME", "docker") +SHARED_NET = os.environ.get("YAK_DEV_NET", "kind") +SHARED_ADMIN = "yakdevadmin" +RELAY_KEY = os.environ.get("YAK_RELAY_KEY", "") +DS_TOKEN = os.environ.get("YAK_DS_TOKEN", "") +API_BASE = (os.environ.get("YAK_API_BASE", "") or "").rstrip("/") +NS = os.environ.get("YAK_DEV_NS", "") + +_SHARED_CT = {"POSTGRESQL": "yak-dev-postgres", "MYSQL": "yak-dev-mysql", "MARIADB": "yak-dev-mariadb", + "MONGODB": "yak-dev-mongo", "REDIS": "yak-dev-redis"} +_SVC_PORT = {"POSTGRESQL": 5432, "MYSQL": 3306, "MARIADB": 3306, "MONGODB": 27017, "REDIS": 6379} +_ROW_MAX = 500 # 백엔드 _DS_ROW_MAX 미러 +_LIST_MAX = 500 # 백엔드 _DS_LIST_MAX 미러 + + +class DsErr(Exception): + """경계를 넘는 에러는 고정 enum code 만(드라이버 원문 미노출).""" + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +def log(m: str) -> None: + print(f"\033[36m▸\033[0m [agent] {m}", flush=True) + + +# ── 로컬 자격 해석: kind Secret(yak-dev-src-*) → password/unit (자격은 여기서만) ────────────── +def _read_secret(service: str) -> dict: + r = subprocess.run(["kubectl", "--context", CTX, "-n", NS, "get", "secret", service, "-o", "json"], + capture_output=True, text=True) + if r.returncode != 0: + raise DsErr("NOT_FOUND") + data = (json.loads(r.stdout or "{}").get("data", {}) or {}) + def dec(k: str) -> str: + v = data.get(k) + return base64.b64decode(v).decode() if v else "" + return {"password": dec("password"), "unit": dec("unit")} + + +def _dbnum(unit: str) -> str: + return str(int(hashlib.sha1(unit.encode()).hexdigest(), 16) % 16) + + +def _dexec(cname: str, argv: list[str], input_str: str | None = None) -> str: + """docker/podman exec — argv 형태(셸 없음, 인젝션 안전). 실패=DRIVER_ERROR/UNREACHABLE.""" + r = subprocess.run([RUNTIME, "exec", "-i", cname, *argv], input=input_str, + capture_output=True, text=True) + if r.returncode != 0: + err = (r.stderr or "").lower() + if "no such container" in err or "is not running" in err: + raise DsErr("UNREACHABLE") + raise DsErr("DRIVER_ERROR") + return r.stdout + + +def _readonly_ok(sql: str) -> bool: # 백엔드 _ds_readonly_ok 미러 + import re + s = (sql or "").strip().rstrip(";").strip() + if not s or ";" in s: + return False + return bool(re.match(r"(?is)^(select|with|show|explain|describe|desc|table)\b", s)) + + +# ── SQL(pg/mysql/mariadb) — 격리 유저(unit)로 접속, 백엔드 _ds_h_sql 응답 스키마 미러 ────────── +def _h_sql(stype: str, sec: dict, action: str, params: dict) -> dict: + cname = _SHARED_CT[stype] + unit, pw = sec["unit"], sec["password"] + is_pg = stype == "POSTGRESQL" + client = "psql" if is_pg else ("mysql" if stype == "MYSQL" else "mariadb") + + def pg(sql: str, csv: bool = False) -> str: + flags = ["--csv"] if csv else ["-tA"] + return _dexec(cname, ["env", f"PGPASSWORD={pw}", "psql", "-U", unit, "-h", "127.0.0.1", + "-d", unit, "-v", "ON_ERROR_STOP=1", *flags, "-c", sql]) + + def my(sql: str) -> str: # --batch → TSV(헤더 포함), NULL=\N. 탭/개행은 mysql 이 이스케이프. + return _dexec(cname, [client, "-u", unit, f"-p{pw}", "-h", "127.0.0.1", unit, + "--batch", "--raw", "-e", sql]) + + def run_list(sql: str) -> list[str]: + out = pg(sql) if is_pg else my(sql) + rows = [ln for ln in out.splitlines() if ln != ""] + if not is_pg and rows: + rows = rows[1:] # mysql --batch 헤더 제거 + return rows[:_LIST_MAX] + + scope = params.get("db") or ("public" if is_pg else unit) + + if action == "databases": + if is_pg: + sql = ("SELECT schema_name FROM information_schema.schemata " + "WHERE schema_name NOT LIKE 'pg_%' AND schema_name <> 'information_schema' ORDER BY 1") + else: + sql = ("SELECT schema_name FROM information_schema.schemata " + "WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys') ORDER BY 1") + return {"databases": run_list(sql)} + + if action in ("tables", "list", "collections"): + if is_pg: + names = run_list("SELECT table_name FROM information_schema.tables " + f"WHERE table_schema='{scope}' AND table_type='BASE TABLE' ORDER BY table_name") + return {"db": scope, "tables": [{"name": n} for n in names]} + out = my("SELECT table_name, table_rows FROM information_schema.tables " + f"WHERE table_schema='{scope}' AND table_type='BASE TABLE' ORDER BY table_name") + rows = out.splitlines()[1:] + tables = [] + for ln in rows[:_LIST_MAX]: + c = ln.split("\t") + tables.append({"name": c[0], "rows": int(c[1]) if len(c) > 1 and c[1].isdigit() else None}) + return {"db": scope, "tables": tables} + + if action == "columns": + table = params["table"] + sql = ("SELECT column_name, data_type, is_nullable FROM information_schema.columns " + f"WHERE table_schema='{scope}' AND table_name='{table}' ORDER BY ordinal_position") + out = pg(sql) if is_pg else my(sql) + lines = out.splitlines() + if not is_pg: + lines = lines[1:] + cols = [] + for ln in lines: + if not ln: + continue + c = ln.split("\t" if not is_pg else "|") + cols.append({"name": c[0], "type": c[1] if len(c) > 1 else "", + "nullable": (c[2].upper() == "YES") if len(c) > 2 else False}) + return {"db": scope, "table": table, "columns": cols} + + if action == "query": + sql = (params.get("sql") or "").strip().rstrip(";").strip() + if not _readonly_ok(sql): + raise DsErr("FORBIDDEN_ACTION") + limit = max(1, min(int(params.get("limit", 100)), _ROW_MAX)) + head = sql.lower() + wrapped = f"SELECT * FROM ({sql}) AS _yak LIMIT {limit + 1}" if head.startswith(("select", "with")) else sql + out = pg(wrapped, csv=True) if is_pg else my(wrapped) + if is_pg: + import csv as _csv + import io as _io + rr = list(_csv.reader(_io.StringIO(out))) + colnames = rr[0] if rr else [] + data = rr[1:] + else: + lines = out.splitlines() + colnames = lines[0].split("\t") if lines else [] + data = [ln.split("\t") for ln in lines[1:]] + truncated = len(data) > limit + return {"db": scope, "columns": colnames, + "rows": [[None if v == ("" if is_pg else "\\N") else v for v in row] for row in data[:limit]], + "rowCount": min(len(data), limit), "truncated": truncated} + + if action == "exec": + s = (params.get("sql") or "").strip().rstrip(";").strip() + if not s: + raise DsErr("FORBIDDEN_ACTION") + if is_pg: + _dexec(cname, ["env", f"PGPASSWORD={pw}", "psql", "-U", unit, "-h", "127.0.0.1", + "-d", unit, "-v", "ON_ERROR_STOP=1", "-c", s]) + else: + my(s) + return {"db": scope, "rowsAffected": None} + + raise DsErr("FORBIDDEN_ACTION") + + +# ── MongoDB — 격리 유저로 mongosh, EJSON(canonical) 파싱. 읽기 코어(collections/find). ───────── +def _mongo_eval(sec: dict, js: str) -> str: + unit, pw = sec["unit"], sec["password"] + uri = f"mongodb://{unit}:{pw}@127.0.0.1:27017/{unit}" + return _dexec(_SHARED_CT["MONGODB"], ["mongosh", uri, "--quiet", "--json=relaxed", "--eval", js]) + + +def _h_mongo(sec: dict, action: str, params: dict) -> dict: + db = sec["unit"] + if action in ("collections", "list", "tables"): + out = _mongo_eval(sec, "JSON.stringify(db.getCollectionNames())") + names = json.loads(out or "[]") + return {"db": db, "collections": [{"name": n} for n in names]} + if action == "find": + coll = params["coll"] + filt = json.dumps(params.get("filter") or {}) + limit = max(1, min(int(params.get("limit", 50)), _ROW_MAX)) + skip = max(0, int(params.get("skip", 0))) + js = (f"JSON.stringify(db.getCollection({json.dumps(coll)})" + f".find({filt}).skip({skip}).limit({limit}).toArray())") + docs = json.loads(_mongo_eval(sec, js) or "[]") + return {"db": db, "coll": coll, "docs": docs, "total": len(docs), "skip": skip, "limit": limit} + raise DsErr("FORBIDDEN_ACTION") + + +# ── Redis — 격리 DB 번호 + 공유 관리자 비번(dev). 읽기 코어(keys/get/dbsize). ────────────────── +def _redis(sec: dict, argv: list[str]) -> str: + n = _dbnum(sec["unit"]) + return _dexec(_SHARED_CT["REDIS"], ["redis-cli", "-a", SHARED_ADMIN, "--no-auth-warning", "-n", n, *argv]) + + +def _h_redis(sec: dict, action: str, params: dict) -> dict: + if action == "keys": + pattern = params.get("pattern") or "*" + cursor = str(params.get("cursor", 0)) + out = _redis(sec, ["scan", cursor, "match", pattern, "count", "200"]) + lines = [ln for ln in out.splitlines() if ln != ""] + nxt = int(lines[0]) if lines else 0 + keys = [] + for k in lines[1:]: + t = _redis(sec, ["type", k]).strip() + ttl = _redis(sec, ["ttl", k]).strip() + keys.append({"name": k, "type": t, "ttl": int(ttl) if ttl.lstrip("-").isdigit() else -1}) + dbsize = _redis(sec, ["dbsize"]).strip() + return {"cursor": nxt, "keys": keys[:_LIST_MAX], "dbsize": int(dbsize) if dbsize.isdigit() else 0} + if action == "get": + key = params["key"] + t = _redis(sec, ["type", key]).strip() + ttl = _redis(sec, ["ttl", key]).strip() + if t == "string": + val = _redis(sec, ["get", key]).rstrip("\n") + elif t == "hash": + val = _redis(sec, ["hgetall", key]).rstrip("\n") + elif t in ("list",): + val = _redis(sec, ["lrange", key, "0", "199"]).rstrip("\n") + elif t == "set": + val = _redis(sec, ["smembers", key]).rstrip("\n") + elif t == "zset": + val = _redis(sec, ["zrange", key, "0", "199", "withscores"]).rstrip("\n") + else: + val = "" + return {"key": key, "type": t, "ttl": int(ttl) if ttl.lstrip("-").isdigit() else -1, "value": val} + raise DsErr("FORBIDDEN_ACTION") + + +def dispatch(a: dict) -> dict: + """ds-actions 액션 1건 실행 → {ok, data}|{ok:false, error:{code}} + nonce.""" + nonce = a.get("nonce") + try: + stype = str(a.get("type", "")).upper() + service = a.get("service", "") + action = a.get("action", "") + params = a.get("params") or {} + sec = _read_secret(service) + if stype in ("POSTGRESQL", "MYSQL", "MARIADB"): + data = _h_sql(stype, sec, action, params) + elif stype == "MONGODB": + data = _h_mongo(sec, action, params) + elif stype == "REDIS": + data = _h_redis(sec, action, params) + else: + raise DsErr("VALIDATION_FAILED") + return {"nonce": nonce, "ok": True, "data": data} + except DsErr as e: + return {"nonce": nonce, "ok": False, "error": {"code": e.code}} + except KeyError: + return {"nonce": nonce, "ok": False, "error": {"code": "VALIDATION_FAILED"}} + except Exception: # noqa: BLE001 — 원문 미노출, 고정 enum 만 + return {"nonce": nonce, "ok": False, "error": {"code": "DRIVER_ERROR"}} + + +def _post_result(action_id: str, result: dict) -> None: + body = json.dumps(result).encode() + req = urllib.request.Request(f"{API_BASE}/clusters/{RELAY_KEY}/ds-actions/{action_id}/result", + data=body, method="POST", + headers={"Authorization": f"Bearer {DS_TOKEN}", "content-type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=15): + pass + except Exception as e: # noqa: BLE001 + log(f"결과 전송 실패({action_id}): {e}") + + +def poll_once() -> int: + """ds-actions 롱폴 1회(≤25s) → 받은 액션들 처리·결과 전송. 반환=처리 건수(-1=치명오류).""" + req = urllib.request.Request(f"{API_BASE}/clusters/{RELAY_KEY}/ds-actions?wait=25", + headers={"Authorization": f"Bearer {DS_TOKEN}"}) + try: + with urllib.request.urlopen(req, timeout=35) as r: + actions = (json.loads(r.read() or b"{}").get("actions") or []) + except urllib.error.HTTPError as e: + if e.code in (401, 403): + log("ds-token 인증 실패(401/403) — 토큰 만료/폐기. 종료.") + return -1 + time.sleep(3) + return 0 + except Exception: # noqa: BLE001 — 네트워크 일시 오류(노트북 슬립 등) → 재시도 + time.sleep(3) + return 0 + for a in actions: + result = dispatch(a) + _post_result(a.get("id"), result) + code = "" if result["ok"] else result["error"]["code"] + log(f"{a.get('type')}·{a.get('action')} → {'ok' if result['ok'] else code}") + return len(actions) + + +def main() -> None: + if not (RELAY_KEY and DS_TOKEN and API_BASE and NS): + raise SystemExit("agent: YAK_RELAY_KEY/YAK_DS_TOKEN/YAK_API_BASE/YAK_DEV_NS 필요") + log(f"릴레이 연결: {API_BASE}/clusters/{RELAY_KEY}/ds-actions (ns={NS}, ctx={CTX})") + while True: + if poll_once() < 0: + break + + +if __name__ == "__main__": + main() diff --git a/.yakcloud/dev.py b/.yakcloud/dev.py index cb25da6..cee76bf 100644 --- a/.yakcloud/dev.py +++ b/.yakcloud/dev.py @@ -25,6 +25,7 @@ import json import os import secrets import hashlib +import signal import subprocess import sys import time @@ -373,7 +374,9 @@ def _report_to_console(project: str, reqs: list, wls: list) -> None: return report = { "project": project, - "sources": [{"name": r["name"], "type": str(r.get("type", "")).upper()} for r in reqs], + # secret = 노트북 kind 커넥션 Secret 이름 → 콘솔이 connSecretRef 로 저장, 에이전트가 이 이름으로 읽음. + "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], } @@ -387,6 +390,66 @@ def _report_to_console(project: str, reqs: list, wls: list) -> None: pass +# ── 릴레이 에이전트 라이프사이클: 콘솔에서 로컬 소스를 원격처럼 라이브 관리(ds-actions 롱폴) ───── +def _agent_dir() -> str: + d = os.path.join(os.path.expanduser(os.environ.get("YAK_CONFIG_DIR", "~/.config/yakcloud")), "agent") + os.makedirs(d, exist_ok=True) + return d + + +def _agent_pidfile(ns: str) -> str: + return os.path.join(_agent_dir(), f"{ns}.pid") + + +def _stop_agent(ns: str) -> None: + pf = _agent_pidfile(ns) + if not os.path.exists(pf): + return + try: + pid = int(open(pf).read().strip()) + os.kill(pid, signal.SIGTERM) # 그룹장(start_new_session) → 자식까지 + except (OSError, ValueError): + pass + try: + os.remove(pf) + except OSError: + pass + + +def _ensure_agent(project: str, ns: str, reqs: list) -> None: + """콘솔에서 ds-token 취득 → 릴레이 에이전트 데몬 기동(콘솔에서 로컬 소스 라이브 관리). best-effort. + 로그인(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 and reqs): + return + _stop_agent(ns) # 재기동(토큰/키 갱신) — 멱등 + try: + req = urllib.request.Request(url.rstrip("/") + f"/api/v1/clusters/local/{quote(name)}/agent-token", + data=b"{}", method="POST", + headers={"Authorization": "Bearer " + tok, "content-type": "application/json"}) + with urllib.request.urlopen(req, timeout=8) as r: + info = json.loads(r.read() or b"{}") + data = info.get("data") or info + except Exception: # noqa: BLE001 + warn("릴레이 토큰 취득 실패 — 콘솔의 로컬 소스 라이브 관리 비활성(스냅샷만).") + return + 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): + return + agent_py = os.path.join(os.path.dirname(os.path.abspath(__file__)), "agent.py") + if not os.path.exists(agent_py): + return + 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) + logf = open(os.path.join(_agent_dir(), f"{ns}.log"), "a") + p = subprocess.Popen([sys.executable, agent_py], env=env, stdout=logf, stderr=logf, + stdin=subprocess.DEVNULL, start_new_session=True) + open(_agent_pidfile(ns), "w").write(str(p.pid)) + log(f"릴레이 에이전트 기동(pid {p.pid}) — 콘솔에서 로컬 소스를 원격처럼 관리(자격은 노트북 밖으로 안 나감)") + + def cmd_deploy(m: dict) -> None: context_ok() project = sanitize(m.get("project", "app")) @@ -420,6 +483,7 @@ def cmd_deploy(m: dict) -> None: _prune(ns, "workload", "app", {sanitize(w["name"]) for w in wls}) _report_to_console(project, reqs, wls) # 콘솔 LOCAL 클러스터 탭(데이터소스·앱) 리포트 + _ensure_agent(project, ns, reqs) # 릴레이 에이전트 기동 — 콘솔에서 로컬 소스 라이브 관리(원격 파리티) failed = [h for h, ok in results if not ok] print() @@ -449,6 +513,7 @@ def cmd_down(m: dict) -> None: """네임스페이스(이 프로젝트의 dev 리소스)만 정리 — kind 클러스터 자체는 'yakcloud dev down' 이 지운다.""" context_ok() ns = sanitize(m.get("project", "app")) + _stop_agent(ns) # 릴레이 에이전트 데몬 중지 log(f"네임스페이스 '{ns}' 삭제(로컬 dev 리소스 정리)") sh(["kubectl", "--context", CTX, "delete", "namespace", ns, "--ignore-not-found"], capture=False, check=False) diff --git a/bin/yakcloud b/bin/yakcloud index 75f34f7..d597380 100755 --- a/bin/yakcloud +++ b/bin/yakcloud @@ -495,6 +495,17 @@ cmd_dev() { # 공유 데이터 소스 컨테이너(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 삭제되면 폴링해도 무의미. + local agd="${YAK_CONFIG_DIR:-$HOME/.config/yakcloud}/agent" + if [ -d "$agd" ]; then + for pf in "$agd"/*.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 "릴레이 에이전트 정리" + fi _dev_deregister ;; *) echo "yakcloud dev "; exit 1 ;; @@ -506,7 +517,7 @@ cmd_dev() { install_global_assets() { local bust n ok=1; bust="$(date +%s 2>/dev/null || echo 0)" mkdir -p "$LIB_DIR" 2>/dev/null || true - for n in deploy ctl dev; do + for n in deploy ctl dev agent; do curl -fsSL -H 'Cache-Control: no-cache' -H 'Pragma: no-cache' \ "$REPO/raw/branch/$BRANCH/.yakcloud/$n.py?nocache=$bust" -o "$LIB_DIR/$n.py" 2>/dev/null || ok=0 done diff --git a/install.sh b/install.sh index db735e3..657256b 100755 --- a/install.sh +++ b/install.sh @@ -17,7 +17,7 @@ chmod +x "$DEST/yakcloud" # 전역 엔진(deploy/ctl/dev.py) → ~/.config/yakcloud/lib — 프로젝트에 복제하지 않는다(전역 1벌). LIB_DIR="${YAKCLOUD_LIB_DIR:-$HOME/.config/yakcloud/lib}" mkdir -p "$LIB_DIR" -for n in deploy ctl dev; do +for n in deploy ctl dev agent; do curl -fsSL -H 'Cache-Control: no-cache' -H 'Pragma: no-cache' \ "$REPO/raw/branch/$BRANCH/.yakcloud/$n.py?nocache=$BUST" -o "$LIB_DIR/$n.py" 2>/dev/null \ || echo " ⚠ 엔진 $n 다운로드 실패(무시 가능 — CLI 가 자동 재시도)"