Files
yakcloud-starter/.yakcloud/dev.py

756 lines
39 KiB
Python

#!/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 hashlib
import signal
import subprocess
import sys
import time
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")
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.
# 관리형 shared 모델과 동일 계약 — 이미 있는 공유 서버에 <project>_<src> 격리 DB/유저만 민팅(소스 생성비 최소).
# 클러스터(kind)는 앱(비즈니스)만. 앱은 no-selector Service+Endpoints(host=소스명)로 공유 컨테이너의 격리 DB 접속.
_SVC_PORT = {"POSTGRESQL": 5432, "MYSQL": 3306, "MARIADB": 3306, "MONGODB": 27017, "REDIS": 6379,
"MINIO": 9000, "RABBITMQ": 5672, "SOLR": 8983, "ORACLE": 1521}
_IMG = {"POSTGRESQL": "postgres:16-alpine", "MYSQL": "mysql:8", "MARIADB": "mariadb:11",
"MONGODB": "mongo:7", "REDIS": "redis:7-alpine",
"MINIO": "minio/minio", "RABBITMQ": "rabbitmq:3-management", "SOLR": "solr:9",
"ORACLE": os.environ.get("YAK_DEV_ORACLE_IMG", "gvenzl/oracle-free:slim")}
_SHARED_CT = {"POSTGRESQL": "yak-dev-postgres", "MYSQL": "yak-dev-mysql", "MARIADB": "yak-dev-mariadb",
"MONGODB": "yak-dev-mongo", "REDIS": "yak-dev-redis",
"MINIO": "yak-dev-minio", "RABBITMQ": "yak-dev-rabbitmq", "SOLR": "yak-dev-solr",
"ORACLE": "yak-dev-oracle"}
SHARED_NET = os.environ.get("YAK_DEV_NET", "kind") # kind 가 쓰는 도커 네트워크
SHARED_ADMIN = "yakdevadmin" # dev 전용 공유 컨테이너 관리자 비밀번호(로컬 한정)
MINIO_ROOT_USER = "yakdevadmin" # MinIO 루트 유저(로컬 dev, ≥3자)
ORACLE_PDB = os.environ.get("YAK_DEV_ORACLE_PDB", "FREEPDB1") # gvenzl/oracle-free 기본 PDB
MC_IMG = os.environ.get("YAK_DEV_MC_IMG", "minio/mc") # 버킷 민팅용 MinIO 클라이언트
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:
"""프로젝트-소스별 비밀번호 — 기존 Secret 이 있으면 재사용(멱등), 없으면 랜덤 생성."""
sec = f"yak-dev-src-{sanitize(name)}"
r = kubectl(["get", "secret", sec, "-n", ns, "-o", "json"], check=False)
if r.returncode == 0:
pw = (json.loads(r.stdout).get("data", {}) or {}).get("password")
if pw:
return {"password": base64.b64decode(pw).decode(), "secret": sec}
return {"password": secrets.token_hex(16), "secret": sec}
def _rt(args: list[str], **kw) -> subprocess.CompletedProcess:
return sh([RUNTIME, *args], **kw)
def _dexec(cname: str, shell_cmd: str, check: bool = True) -> subprocess.CompletedProcess:
return _rt(["exec", cname, "sh", "-c", shell_cmd], check=check)
def _dbname(project: str, source: str) -> str:
"""프로젝트별 격리 단위(DB·유저)명 — DB 안전(소문자·영숫자·언더스코어)."""
raw = f"{project}_{source}".lower()
out = "".join(c if (c.isalnum() or c == "_") else "_" for c in raw).strip("_")
return (out or "app")[:40]
def _minio_bucket(unit: str) -> str:
"""S3/MinIO 버킷명 규칙 — 소문자·숫자·하이픈·점만(언더스코어 불가), 3~63자, 시작/끝은 영숫자."""
b = "".join(c if (c.isalnum() or c in "-.") else "-" for c in unit.replace("_", "-").lower()).strip("-.")
if len(b) < 3:
b = (b + "-bkt")
return b[:63].strip("-.")
def _container_ip(cname: str) -> str:
r = _rt(["inspect", "-f",
'{{(index .NetworkSettings.Networks "' + SHARED_NET + '").IPAddress}}', cname], check=False)
return (r.stdout or "").strip()
def _ensure_shared(stype: str) -> tuple[str, str]:
"""타입별 '공유' 컨테이너를 kind 네트워크에 보장(idempotent, 관리자 자격) → (이름, IP)."""
cname = _SHARED_CT[stype]
if not _rt(["ps", "-q", "-f", f"name=^{cname}$"], check=False).stdout.strip():
# 죽은 동명 컨테이너 + 익명 볼륨(-v)까지 정리 — 재생성 시 항상 깨끗한 볼륨으로 시작(고아 볼륨 누적·재사용 방지).
# (특히 rabbitmq/erlang 은 옛 uid 소유 볼륨을 재사용하면 .erlang.cookie eacces 로 재크래시)
_rt(["rm", "-f", "-v", cname], check=False)
img = _IMG[stype]
run = ["run", "-d", "--name", cname, "--network", SHARED_NET,
"--label", "yakcloud.dev/shared=1", "--label", f"yakcloud.dev/type={stype}"]
tries = 90
if stype == "POSTGRESQL":
run += ["-e", f"POSTGRES_PASSWORD={SHARED_ADMIN}", img]
ready = "pg_isready -U postgres >/dev/null 2>&1"
elif stype == "MYSQL":
run += ["-e", f"MYSQL_ROOT_PASSWORD={SHARED_ADMIN}", img]
ready = f'mysql -uroot -p{SHARED_ADMIN} -e "SELECT 1" >/dev/null 2>&1'
elif stype == "MARIADB":
run += ["-e", f"MARIADB_ROOT_PASSWORD={SHARED_ADMIN}", img]
ready = f'mariadb -uroot -p{SHARED_ADMIN} -e "SELECT 1" >/dev/null 2>&1'
elif stype == "MONGODB":
run += ["-e", "MONGO_INITDB_ROOT_USERNAME=root", "-e", f"MONGO_INITDB_ROOT_PASSWORD={SHARED_ADMIN}", img]
ready = (f'mongosh "mongodb://root:{SHARED_ADMIN}@127.0.0.1:27017/admin" --quiet '
'--eval "db.runCommand({ping:1}).ok" | grep -q 1')
elif stype == "REDIS":
run += [img, "redis-server", "--requirepass", SHARED_ADMIN]
ready = f'redis-cli -a {SHARED_ADMIN} ping 2>/dev/null | grep -q PONG'
elif stype == "MINIO":
run += ["-e", f"MINIO_ROOT_USER={MINIO_ROOT_USER}", "-e", f"MINIO_ROOT_PASSWORD={SHARED_ADMIN}",
img, "server", "/data", "--console-address", ":9001"]
ready = None # 준비 대기·버킷 생성은 민팅(mc ready)에서 — minio 서버 이미지엔 헬스툴 없음
elif stype == "RABBITMQ":
# --user 0(root) 로 실행하면 엔트리포인트가 gosu 로 rabbitmq(999) 유저로 내려가 부팅한다.
# ⚠ 준비체크/민팅을 root 로 exec 하면 erlang 이 root 소유 .erlang.cookie 를 만들어 메인(999)과 충돌 → eacces 크래시.
# 반드시 'gosu rabbitmq' 로 diagnostics/ctl 을 999 로 실행해야 함(쿠키 소유자 일치). check_running=앱 기동 확인.
run += ["--user", "0", "-e", "RABBITMQ_DEFAULT_USER=root", "-e", f"RABBITMQ_DEFAULT_PASS={SHARED_ADMIN}", img]
ready = "gosu rabbitmq rabbitmq-diagnostics -q check_running >/dev/null 2>&1"
elif stype == "SOLR":
run += [img]
ready = "wait-for-solr.sh --max-attempts 2 --wait-seconds 1 >/dev/null 2>&1"
else: # ORACLE
run += ["-e", f"ORACLE_PASSWORD={SHARED_ADMIN}", img]
ready = "healthcheck.sh >/dev/null 2>&1"
tries = 240 # 첫 DB 생성이 느림(최대 ~8분)
log(f"공유 소스 컨테이너 '{cname}' ({stype}) 기동 ({RUNTIME}, net={SHARED_NET})")
_rt(run, capture=False)
if ready:
for _ in range(tries):
if _dexec(cname, ready, check=False).returncode == 0:
break
time.sleep(2)
else:
die(f"공유 컨테이너 '{cname}' 준비 대기 초과 — '{RUNTIME} logs {cname}' 확인")
ip = _container_ip(cname)
if not ip:
die(f"공유 컨테이너 '{cname}' IP 조회 실패 — 도커 네트워크 '{SHARED_NET}' 확인")
return cname, ip
def _mint(stype: str, cname: str, unit: str, pw: str) -> None:
"""공유 컨테이너에 프로젝트별 격리 DB/유저 생성(idempotent). unit=DB·유저명(영숫자·언더스코어)."""
if stype == "POSTGRESQL":
_dexec(cname, f'psql -U postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname=\'{unit}\'" | grep -q 1 '
f'|| psql -U postgres -c "CREATE ROLE {unit} LOGIN PASSWORD \'{pw}\'"')
_dexec(cname, f'psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname=\'{unit}\'" | grep -q 1 '
f'|| psql -U postgres -c "CREATE DATABASE {unit} OWNER {unit}"')
elif stype in ("MYSQL", "MARIADB"):
client = "mysql" if stype == "MYSQL" else "mariadb"
sql = (f"CREATE DATABASE IF NOT EXISTS {unit}; "
f"CREATE USER IF NOT EXISTS '{unit}'@'%' IDENTIFIED BY '{pw}'; "
f"GRANT ALL ON {unit}.* TO '{unit}'@'%'; FLUSH PRIVILEGES;")
_dexec(cname, f'{client} -uroot -p{SHARED_ADMIN} -e "{sql}"')
elif stype == "MONGODB":
js = (f'try{{db.getSiblingDB("{unit}").createUser({{user:"{unit}",pwd:"{pw}",'
f'roles:[{{role:"readWrite",db:"{unit}"}}]}})}}catch(e){{if(!/already exists/i.test(e.message))throw e}}')
_dexec(cname, f'mongosh "mongodb://root:{SHARED_ADMIN}@127.0.0.1:27017/admin" --quiet --eval \'{js}\'')
def _mint_minio(cname: str, ip: str, bucket: str) -> None:
"""별도 mc 컨테이너로 준비 대기(mc ready) + 프로젝트별 버킷 생성(idempotent). minio 서버 이미지엔 mc 없음.
별칭은 MC_HOST env 로 주입(막 뜬 서버에 'mc alias set' 검증 핑이 너무 일찍 실패하는 경쟁 회피) → mc ready 가 대기."""
host_env = f"MC_HOST_h=http://{MINIO_ROOT_USER}:{SHARED_ADMIN}@{ip}:9000"
cmd = f"mc ready h && mc mb --ignore-existing h/{bucket}"
_rt(["run", "--rm", "--network", SHARED_NET, "-e", host_env, "--entrypoint", "sh", MC_IMG, "-c", cmd], capture=False)
def _mint_rabbitmq(cname: str, vhost: str, user: str, pw: str) -> None:
"""프로젝트별 vhost + 유저 + 권한(idempotent). rabbitmqctl 은 'gosu rabbitmq'(999)로 —
root 로 실행하면 erlang 쿠키 소유자 불일치로 노드 접속 실패/충돌(부팅 크래시와 동일 원인)."""
g = "gosu rabbitmq rabbitmqctl -q"
cmd = (f"{g} list_vhosts | grep -qx {vhost} || {g} add_vhost {vhost}; "
f"{g} list_users | awk '{{print $1}}' | grep -qx {user} "
f"&& {g} change_password {user} {pw} || {g} add_user {user} {pw}; "
f'{g} set_permissions -p {vhost} {user} ".*" ".*" ".*"')
_dexec(cname, cmd)
def _mint_solr(cname: str, core: str) -> None:
"""프로젝트별 core 생성(이미 있으면 무시)."""
_dexec(cname, f"solr create -c {core} 2>/dev/null || true", check=False)
def _mint_oracle(cname: str, user: str, pw: str) -> None:
"""프로젝트별 스키마/유저 생성(PDB, idempotent — ORA-01920=이미 존재 무시)."""
plsql = (
"SET FEEDBACK OFF VERIFY OFF;\n"
f"BEGIN EXECUTE IMMEDIATE 'CREATE USER {user} IDENTIFIED BY \"{pw}\"'; "
"EXCEPTION WHEN OTHERS THEN IF SQLCODE!=-1920 THEN RAISE; END IF; END;\n/\n"
f"GRANT CONNECT, RESOURCE TO {user};\n"
f"ALTER USER {user} QUOTA UNLIMITED ON USERS;\n"
"EXIT;\n"
)
_rt(["exec", "-i", cname, "bash", "-c",
f"sqlplus -S -L system/{SHARED_ADMIN}@localhost:1521/{ORACLE_PDB}"], input=plsql)
def provision_source(ns: str, project: str, req: dict) -> dict | None:
"""requires 소스 → 공유 도커 컨테이너의 프로젝트별 격리 DB + no-selector Service/Endpoints. 접속정보 반환."""
name = req["name"]
stype = req["type"].upper()
if req.get("mode") == "local":
warn(f"source '{name}': mode=local(인클러스터)는 로컬 dev 에서 공유 컨테이너로 대체됩니다.")
if stype not in SUPPORTED:
die(f"source '{name}' 타입 {stype} — 로컬 dev 미지원(지원: {', '.join(sorted(SUPPORTED))}).")
svc = sanitize(name)
unit = _dbname(project, name)
port = _SVC_PORT[stype]
pw = get_or_make_creds(ns, name)["password"] # 프로젝트-소스별 비밀번호(Secret 재사용=멱등)
cname, ip = _ensure_shared(stype)
if stype == "REDIS":
dbnum = str(int(hashlib.sha1(unit.encode()).hexdigest(), 16) % 16) # DB 번호 격리(0~15)
conn = {"stype": stype, "host": svc, "port": port, "user": "", "password": SHARED_ADMIN, "db": dbnum}
log(f"source '{name}' (REDIS) → 공유 '{cname}' · DB#{dbnum} · 서비스 {svc}:{port}")
elif stype == "MINIO":
bucket = _minio_bucket(unit) # S3 버킷명(언더스코어→하이픈)
_mint_minio(cname, ip, bucket) # 프로젝트별 버킷
conn = {"stype": stype, "host": svc, "port": port, "user": MINIO_ROOT_USER, "password": SHARED_ADMIN, "bucket": bucket}
log(f"source '{name}' (MINIO) → 공유 '{cname}' · 버킷 '{bucket}' · 서비스 {svc}:{port}")
elif stype == "RABBITMQ":
_mint_rabbitmq(cname, unit, unit, pw) # vhost=유저=unit
conn = {"stype": stype, "host": svc, "port": port, "user": unit, "password": pw, "vhost": unit}
log(f"source '{name}' (RABBITMQ) → 공유 '{cname}' · vhost/유저 '{unit}' · 서비스 {svc}:{port}")
elif stype == "SOLR":
_mint_solr(cname, unit) # 프로젝트별 core
conn = {"stype": stype, "host": svc, "port": port, "user": "", "password": "", "core": unit}
log(f"source '{name}' (SOLR) → 공유 '{cname}' · core '{unit}' · 서비스 {svc}:{port}")
elif stype == "ORACLE":
_mint_oracle(cname, unit, pw) # 프로젝트별 스키마/유저
conn = {"stype": stype, "host": svc, "port": port, "user": unit, "password": pw, "service": ORACLE_PDB}
log(f"source '{name}' (ORACLE) → 공유 '{cname}' · 스키마 '{unit}' · {svc}:{port}/{ORACLE_PDB}")
else:
_mint(stype, cname, unit, pw)
conn = {"stype": stype, "host": svc, "port": port, "user": unit, "password": pw, "db": unit}
log(f"source '{name}' ({stype}) → 공유 '{cname}' · 격리 DB '{unit}' · 서비스 {svc}:{port}")
labels = {"app.kubernetes.io/managed-by": "yakcloud-dev", "yakcloud.dev/role": "source", "yakcloud.dev/source": svc}
# no-selector Service + 수동 Endpoints → kind 밖 공유 컨테이너 IP 로 라우팅.
apply([
{"apiVersion": "v1", "kind": "Secret",
"metadata": {"name": f"yak-dev-src-{svc}", "namespace": ns, "labels": labels},
"stringData": {"password": pw, "unit": unit}},
{"apiVersion": "v1", "kind": "Service",
"metadata": {"name": svc, "namespace": ns, "labels": labels},
"spec": {"ports": [{"port": port, "targetPort": port}]}},
{"apiVersion": "v1", "kind": "Endpoints",
"metadata": {"name": svc, "namespace": ns, "labels": labels},
"subsets": [{"addresses": [{"ip": ip}], "ports": [{"port": port}]}]},
])
return conn
# ── 바인딩 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.get("user", ""), conn.get("password", ""), conn.get("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": db,
f"{p}_URL": f"redis://:{qp}@{host}:{port}/{db}"})
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}"})
elif st == "MINIO":
endpoint = f"http://{host}:{port}" # 로컬 kind = http(비 TLS)
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw,
f"{p}_URL": endpoint, f"{p}_ENDPOINT": endpoint,
f"{p}_ACCESS_KEY": user, f"{p}_SECRET_KEY": pw,
f"{p}_BUCKET": conn.get("bucket", ""), f"{p}_REGION": "us-east-1", f"{p}_USE_SSL": "false"})
elif st == "RABBITMQ":
vhost = conn.get("vhost", "")
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_VHOST": vhost,
f"{p}_URL": f"amqp://{qu}:{qp}@{host}:{port}/{quote(vhost, safe='')}",
f"{p}_MGMT_URL": f"http://{host}:15672"})
elif st == "SOLR":
core = conn.get("core", "")
endpoint = f"http://{host}:{port}"
out.update({f"{p}_USERNAME": "", f"{p}_PASSWORD": "",
f"{p}_CORE": core, f"{p}_ENDPOINT": endpoint,
f"{p}_URL": f"{endpoint}/solr/{core}"})
elif st == "ORACLE":
service = conn.get("service", "")
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_SERVICE": service,
f"{p}_URL": f"oracle://{qu}:{qp}@{host}:{port}/{service}",
f"{p}_JDBC_URL": f"jdbc:oracle:thin:@//{host}:{port}/{service}",
f"{p}_DSN": f"{host}:{port}/{service}"})
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 _effective_image(image: str) -> str:
"""로컬 dev 는 레지스트리에 push 하지 않고 kind-load 하므로 이미지명은 '유효한 로컬 태그'면 된다.
- repo(레지스트리/경로)는 소문자여야 빌드된다(docker/podman 공통) → 미설정 CHANGE_ME_GITEA_USER 같은
대문자 placeholder 도 로컬 빌드되게 repo 만 소문자화(태그는 대소문자 보존).
- podman 은 레지스트리 미지정 이미지에 'localhost/' 를 붙여 저장 → kind load·Deployment 이름 일치를 위해 맞춘다
(docker 무영향). 레지스트리 지정 이미지(gitea.yakenator.io/…)는 그대로."""
# 1) repo 소문자화(마지막 '/' 뒤에 ':' 가 있으면 그게 태그 구분자 → 태그 보존).
slash = image.rfind("/")
if ":" in image[slash + 1:]:
repo, tag = image.rsplit(":", 1)
image = repo.lower() + ":" + tag
else:
image = image.lower()
# 2) podman localhost/ 보정.
if RUNTIME != "podman":
return image
first = image.split("/", 1)[0]
if "/" not in image or ("." not in first and ":" not in first and first != "localhost"):
return f"localhost/{image}"
return image
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:
import tempfile
log(f"이미지 빌드({RUNTIME}) {image}{ctx_dir}")
sh([RUNTIME, "build", "-t", image, ctx_dir], capture=False)
log(f"kind load → 클러스터 {KIND_CLUSTER}")
if RUNTIME == "podman":
# podman 은 'kind load docker-image' 이름 해석이 불안정('not present locally') → save → image-archive 로 안정 로드.
tar = os.path.join(tempfile.gettempdir(), f"yak-{sanitize(image)}.tar")
sh([RUNTIME, "save", "-o", tar, image], capture=False)
try:
sh(["kind", "load", "image-archive", tar, "--name", KIND_CLUSTER],
capture=False, env={"KIND_EXPERIMENTAL_PROVIDER": "podman"})
finally:
try:
os.remove(tar)
except OSError:
pass
else:
sh(["kind", "load", "docker-image", image, "--name", KIND_CLUSTER], capture=False)
return _image_id(image)
def deploy_workload(project: str, ns: str, w: dict, src_conns: dict) -> tuple[str, bool]:
# podman localhost/ 보정 — 빌드·kind load·Deployment 이미지 이름을 일치시킨다(docker 는 그대로).
image = _effective_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,endpoints,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 _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 불변식)."""
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,
"context": CTX, # kubectl 컨텍스트(콘솔 연결·삭제 탭 안내용) — kind-yak-<name>
# 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": _disp_hosts(project, w), "port": int(w.get("port", 8080)),
"path": (w.get("expose", {}) or {}).get("path", "/") or "/",
# 바인딩(별칭→소스) → 콘솔이 ServiceBinding 으로 저장 → 앱 카드 '연결' 패널 표시
"binds": [{"alias": b["alias"], "source": b["source"]} for b in (w.get("binds") or [])]}
for w in wls],
}
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:
with urllib.request.urlopen(req, timeout=6):
log("콘솔 로컬 클러스터 목록에 데이터소스·앱 리포트 반영")
except Exception: # noqa: BLE001 — best-effort(오프라인/미로그인/네트워크)
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() -> str:
# 클러스터 단위 에이전트(프로젝트 무관) — 여러 dev 클러스터는 DEV_NAME 으로 구분.
return os.path.join(_agent_dir(), f"{DEV_NAME}.pid")
def _agent_alive() -> bool:
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):
return
try:
os.kill(int(open(pf).read().strip()), signal.SIGTERM)
except (OSError, ValueError):
pass
try:
os.remove(pf)
except OSError:
pass
def _ensure_agent() -> None:
"""콘솔에서 ds-token 취득 → **클러스터 단위** 릴레이 에이전트 데몬 기동. dev up/deploy 공통.
노드 모니터링·데이터·앱을 배포 전에도 라이브로. 이미 살아있으면 스킵(멱등).
로그인(URL+TOKEN)+등록명(YAK_REG_NAME) 없으면 조용히 스킵(오프라인 dev 불변식)."""
if _agent_alive():
return
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
_stop_agent() # 죽은 pidfile 정리
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_CONTEXT=CTX, YAK_DEV_RUNTIME=RUNTIME, YAK_DEV_NET=SHARED_NET)
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,
stdin=subprocess.DEVNULL, start_new_session=True)
open(_agent_pidfile(), "w").write(str(p.pid))
log(f"릴레이 에이전트 기동(pid {p.pid}) — 콘솔에서 노드·데이터·앱 라이브(자격은 노트북 밖으로 안 나감)")
# ── 환경 오버레이: base(requires/workloads) + environments.<env> 이름 매칭 병합 ─────────────────
# 앱 binds 는 논리이름 고정 → 환경별로 소스 정체(type/mode/plan)·워크로드 필드만. 로컬은 항상 env="dev".
# ※ deploy.py 의 동일 함수와 규약 일치(파리티).
def _merge_env_vars(base, ov):
def norm(e):
if isinstance(e, dict):
return [{"key": k, "value": v} for k, v in e.items()]
return [dict(x) for x in (e or [])]
out = {x["key"]: x for x in norm(base)}
for x in norm(ov):
out[x["key"]] = x
return list(out.values())
def resolve_env(m: dict, env: str):
ec = (m.get("environments") or {}).get(env) or {}
by, order = {}, []
for r in (m.get("requires") or []):
by[r["name"]] = dict(r); order.append(r["name"])
for r in (ec.get("requires") or []):
if r["name"] in by:
by[r["name"]].update(r)
else:
by[r["name"]] = dict(r); order.append(r["name"])
requires = [by[n] for n in order]
wov = ec.get("workloads") or {}
workloads = []
for w in (m.get("workloads") or []):
w2 = dict(w)
for k, v in (wov.get(w["name"]) or {}).items():
if k == "env":
w2["env"] = _merge_env_vars(w.get("env"), v)
elif k in ("resources", "expose") and isinstance(v, dict):
merged = dict(w.get(k) or {}); merged.update(v); w2[k] = merged
else:
w2[k] = v
workloads.append(w2)
return requires, workloads
def cmd_deploy(m: dict) -> None:
context_ok()
project = sanitize(m.get("project", "app"))
ns = project
reqs, wls = resolve_env(m, "dev") # base + environments.dev 오버레이(로컬은 항상 dev 환경)
# 충돌 가드 — 서로 다른 소스명/워크로드명이 같은 서비스명으로 뭉개지면 자격 오배선 → 거부.
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, project, 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 클러스터 탭(데이터소스·앱) 리포트
_ensure_agent() # 클러스터 단위 릴레이 에이전트 보장(dev up 에서 이미 떴으면 스킵)
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:
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 에 바인딩 소스 <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"))
_stop_agent() # 클러스터 릴레이 에이전트 데몬 중지
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 ("up", "deploy", "status", "down"):
die("사용: yakcloud_dev.py <up|deploy|status|down> [manifest.yaml]")
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")
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()