From 7a90cb2be8768d4a14b6176602eaf59e1b850a24 Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Sat, 29 Aug 2026 10:47:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A1=9C=EC=BB=AC=20kind=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20=ED=81=B4=EB=9F=AC=EC=8A=A4=ED=84=B0(yakcloud=20dev?= =?UTF-8?q?)=20=E2=80=94=20=EA=B0=9C=EB=B0=9C=20=ED=9B=84=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=EA=B9=8C=EC=A7=80=20=EC=A7=81=EC=84=A0=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yakcloud dev up|deploy|status|clean|down. 로컬 kind 를 dev 구간으로 삼아 관리형과 동일한 바인딩 env 계약을 로컬에서 재현(pg/mysql/mariadb/mongodb/redis). dev 에선 CLI 가 곧 컨트롤플레인 이라 오프라인 반복. 같은 매니페스트·같은 이미지로 promote --to val/prod 직선. VERSION 0.14.0. 문서 동시 갱신(SKILL·/yakcloud 명령·yakcloud.yaml·README·DATA-SOURCES). Co-Authored-By: Claude Opus 4.8 --- .claude/commands/yakcloud.md | 3 +- .claude/skills/yakcloud-deploy/SKILL.md | 9 +- DATA-SOURCES.md | 8 +- README.md | 12 +- bin/yakcloud | 88 ++++- scripts/yakcloud_dev.py | 430 ++++++++++++++++++++++++ yakcloud.yaml | 2 + 7 files changed, 541 insertions(+), 11 deletions(-) create mode 100644 scripts/yakcloud_dev.py diff --git a/.claude/commands/yakcloud.md b/.claude/commands/yakcloud.md index 3392008..9f15e36 100644 --- a/.claude/commands/yakcloud.md +++ b/.claude/commands/yakcloud.md @@ -20,12 +20,13 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep, AskUserQuestion - **`login`** → 비밀 입력이라 여기서 실행하지 말고, 터미널에서 `yakcloud login` 하라고 한 줄 안내. (사용자가 값을 직접 주면 `YAKCLOUD_URL=… YAKCLOUD_TOKEN=… GITEA_USER=… GITEA_TOKEN=… yakcloud config` 로 저장하되 **값은 화면에 출력 금지**.) - **`cluster create …`** → 배포토큰으로 클러스터 프로비저닝(5~8분 대기). 실행 전 무엇을(템플릿·노드 수) 만드는지 한 줄 알리고 진행. `cluster ls` = 목록. +- **`dev up|deploy|status|clean|down`** → **로컬 kind 개발 클러스터**. `dev up`(최초 1회, kind+인그레스) → `dev deploy`(소스+워크로드를 로컬에서 관리형과 동일한 바인딩 env로; 콘솔/CI 불필요). `$ARGUMENTS` 실행 후 출력 그대로 + 접속 URL(`http://.dev.localhost/`) 안내. `dev down`(kind 삭제)은 실행 전 한 줄 알리고 진행. - **`project promote [vX.Y.Z] [--to val|prod]`** → 그 이미지를 **재빌드 없이 상위 환경으로** 승격(개발→검증→운영, 기본 --to prod). 파괴적/외부노출이므로 실행 전 무엇을(어느 태그를 어느 환경 클러스터로) 하는지 한 줄 알리고 진행. - **그 외 전부**(`project deploy|info|check|update`, `datasource ls`, `domain`, `scale`, `set`, `env`, `source`, `bind`, `unbind`, `config`, `upgrade`) → `yakcloud $ARGUMENTS` 를 실행하고 **출력을 그대로** 보여준 뒤, 필요할 때만 한 줄로 해석. 배포/승격/삭제성(`deploy`·`promote`·`source rm`·`unbind`·`domain`)은 실행 전 무엇을 하는지 한 줄 알리고 진행. -- 개발·검증·운영 모두 yakcloud 클러스터에서 배포·테스트한다(로컬 docker 데이터소스 없음). 매니페스트 `environments.{dev,val,prod}`(val=검증계, 선택). +- **로컬 개발 = `yakcloud dev`(로컬 kind)**, 검증·운영 = 관리형 yakcloud 클러스터. 같은 매니페스트·같은 바인딩 계약으로 직선. 매니페스트 `environments.{dev,val,prod}`(val=검증계, 선택). ## 2) `project init` — 마법사 대체 (핵심) 터미널 마법사는 TTY가 없으면 안 뜬다. 네가 대신한다: diff --git a/.claude/skills/yakcloud-deploy/SKILL.md b/.claude/skills/yakcloud-deploy/SKILL.md index 73d1bbe..7f18d5d 100644 --- a/.claude/skills/yakcloud-deploy/SKILL.md +++ b/.claude/skills/yakcloud-deploy/SKILL.md @@ -22,13 +22,14 @@ YakCloud 콘솔 API로 **데이터소스 리컨실 + 워크로드 배포 + 바 설치: `curl -fsSL https://gitea.yakenator.io/yakenator/yakcloud-starter/raw/branch/main/install.sh | sh` > 설치 시 **Claude Code 전역 슬래시 명령 `/yakcloud`** 도 함께 생성된다(`~/.claude/commands/yakcloud.md`). 예: `/yakcloud project init my-app`, `/yakcloud datasource ls`, `/yakcloud project deploy`. 이후 `yakcloud upgrade` 가 CLI와 슬래시 명령을 함께 최신화. -**프로젝트 라이프사이클 (개발·운영 모두 yakcloud 클러스터)** -- **"클러스터 생성"** → `yakcloud cluster create [--template S|M] [--nodes N] [--golden]` (배포토큰으로 프로비저닝, 완료까지 대기). `yakcloud cluster ls` 로 목록. 개발/운영 각각 만들어 매니페스트 environments 에 지정. +**프로젝트 라이프사이클 (로컬 kind dev → 관리형 val/prod, 한 줄기 직선)** +- **"클러스터 생성"** → `yakcloud cluster create [--template S|M] [--nodes N] [--golden]` (배포토큰으로 프로비저닝, 완료까지 대기). `yakcloud cluster ls` 로 목록. 검증/운영 클러스터를 만들어 매니페스트 environments 에 지정. - **"프로젝트 초기화"** → 빈 폴더에서 `yakcloud project init [name]` (스타터+CI+매니페스트+이 스킬) -- **"개발 배포"** → `yakcloud project deploy` (자동 버전업 → 태그 push → CI 빌드 → **개발 클러스터**). 기본 도메인으로 확인. +- **"로컬 개발"**(권장 반복 루프) → `yakcloud dev up`(최초 1회 — 로컬 kind 생성+인그레스) → `yakcloud dev deploy`. `requires` 소스+`workloads` 가 로컬에서 **관리형과 동일한 바인딩 env**로 뜬다(콘솔/CI·인터넷 불필요, 오프라인 반복). `dev status`/`dev clean`/`dev down`. 상세 `docs/local-dev-cluster.md`. +- **"개발 배포"**(관리형 개발 클러스터) → `yakcloud project deploy` (자동 버전업 → 태그 push → CI 빌드 → **개발 클러스터**). 기본 도메인으로 확인. - **"승격"**(개발→검증→운영) → `yakcloud project promote [vX.Y.Z] [--to val|prod]` (그 이미지를 **재빌드 없이 상위 환경으로**; `--to prod` 는 운영 도메인 부착). 검증계(val)는 선택 — `environments` 에 없으면 개발→운영 2단계 - **"프로젝트 개괄"** → `yakcloud project info` · 사전 점검 → `yakcloud project check [--env dev|val|prod]` -- 매니페스트 `environments.{dev,val,prod}.cluster` (val 선택, + `prod.domains`). 로컬 개발/로컬 데이터소스는 없음 — 개발도 클러스터 배포로 확인. +- 매니페스트 `environments.{dev,val,prod}.cluster` (val 선택, + `prod.domains`). **로컬 개발은 `yakcloud dev`(로컬 kind)**, 상위 환경(val/prod)은 관리형 클러스터 — 같은 매니페스트·같은 바인딩 계약(직선 파리티). **배포환경 설정(앱별) — 매니페스트 수정 + 배포중이면 재빌드 없이 라이브 반영** - `yakcloud domain [wl]` 도메인 등록 + 워크로드 할당 diff --git a/DATA-SOURCES.md b/DATA-SOURCES.md index 208cabb..6f787a9 100644 --- a/DATA-SOURCES.md +++ b/DATA-SOURCES.md @@ -36,8 +36,12 @@ > 비밀번호·키는 인클러스터 Secret으로 주입되고 콘솔/브라우저에 노출되지 않는다. URL의 user/pw는 URL-인코딩됨. -## 개발/검증/운영 모두 클러스터에서 -데이터 소스는 두 가지 모드: +## 로컬 개발 → 검증 → 운영, 같은 바인딩 계약 +**로컬 개발(`yakcloud dev`, 로컬 kind)**: 선언한 소스를 로컬 컨테이너로 띄우고 위 `_*` env를 관리형과 +**동일하게** 주입한다 — 앱 코드는 dev/prod 구분 없이 같은 env로 접속(직선 파리티). 상위 환경(val/prod)은 +아래 관리형 모드로 배포·승격된다. + +관리형 데이터 소스는 두 가지 모드: 1. **인클러스터(local)**: 클러스터 안에서 실제로 프로비저닝(StatefulSet). 개발 클러스터에 배포하면 그 클러스터의 소스가 `_*` 로 주입되고, 상위 환경으로 승격하면 그 환경 클러스터의 소스가 주입된다 — 각 환경이 자체 데이터 계층을 가진다. 2. **공유 외부(shared)**: 소용량(S) 클러스터용. 매니페스트 `requires` 에 `{ name, type: mongodb, mode: shared }` 로 선언 diff --git a/README.md b/README.md index d39a6e3..76588d1 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,23 @@ - `GET /healthz` → 200 (매니페스트 `health` 경로) - (선택) 바인딩된 소스는 `_URL` 등 env로 접속 -## 로컬 실행 +## 로컬 실행 (앱만) ```sh PORT=8080 python3 app.py curl localhost:8080/healthz # {"ok":true} curl localhost:8080/ # 앱 정보 + 연결된 소스 키 ``` +## 로컬 개발 (K8s 파리티) — `yakcloud dev` +운영과 **같은 K8s + 같은 바인딩 env**로, 선언한 데이터소스까지 로컬에 띄워 검증한다: +```sh +yakcloud dev up # 최초 1회 — 로컬 kind + 인그레스 +yakcloud dev deploy # requires 소스 + workloads 를 로컬 배포(관리형과 동일한 _URL 등 주입) +curl -s http://.dev.localhost/ # bound_sources 에 연결 키 확인 +yakcloud dev down # 정리 +``` +같은 매니페스트·같은 이미지로 준비되면 `yakcloud project promote --to val|prod` 로 직선 승격. + ## 매니페스트 매핑 (`yakcloud.yaml`) ```yaml workloads: diff --git a/bin/yakcloud b/bin/yakcloud index 9807dd9..0955024 100755 --- a/bin/yakcloud +++ b/bin/yakcloud @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# yakcloud — YakCloud 프로젝트 CLI. 개발·검증·운영 모두 yakcloud 클러스터(로컬 dev 없음, val 선택). +# yakcloud — YakCloud 프로젝트 CLI. 개발=로컬 kind(직선 dev) 또는 yakcloud 클러스터, 검증·운영=yakcloud 클러스터(val 선택). # # yakcloud cluster create [--template S|M] [--nodes N] [--golden] 클러스터 생성(완료까지 대기) # yakcloud cluster ls 내 클러스터 목록 @@ -16,7 +16,11 @@ set -uo pipefail # -e 미사용: 'test && action' 관용구가 값 없을 때 REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}" BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}" -VERSION="0.13.0" +VERSION="0.14.0" +# 로컬 개발 클러스터(kind) — 개발 후 운영까지 '직선 배포'의 dev 구간. +DEV_CTX="${YAK_DEV_CONTEXT:-kind-yak-dev}" +DEV_CLUSTER="${YAK_DEV_CLUSTER:-yak-dev}" +INGRESS_MANIFEST="${YAK_DEV_INGRESS:-https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.3/deploy/static/provider/kind/deploy.yaml}" CONFIG_DIR="${YAKCLOUD_CONFIG_DIR:-$HOME/.config/yakcloud}" CONFIG_FILE="$CONFIG_DIR/config" @@ -381,6 +385,77 @@ PY esac } +# ── 로컬 개발 클러스터(kind) — '개발 후 운영까지 직선 배포'의 dev 구간 ───────────── +# dev up 로컬 kind 생성 + 인그레스(80/443) — 최초 1회 +# dev deploy 매니페스트대로 이미지 빌드+kind load + 소스/워크로드/인그레스 로컬 리컨실(콘솔 API 불필요) +# dev status 로컬 배포 상태 · dev clean 이 프로젝트 리소스만 삭제 · dev down kind 클러스터 삭제 +# 로컬 dev = CLI 로컬 완결(오프라인 반복). 같은 이미지를 push 하면 promote --to val/prod 로 그대로 직선. +_dev_runtime() { + if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then echo docker; return; fi + if command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; then echo podman; return; fi + echo "" +} + +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 필요" + local rt; rt="$(_dev_runtime)"; [ -n "$rt" ] || die "docker 또는 podman 데몬이 실행 중이어야 합니다." + local kenv=""; [ "$rt" = podman ] && kenv="KIND_EXPERIMENTAL_PROVIDER=podman" + 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 생성 실패" +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + kubeadmConfigPatches: + - | + kind: InitConfiguration + nodeRegistration: + kubeletExtraArgs: + node-labels: "ingress-ready=true" + extraPortMappings: + - { containerPort: 80, hostPort: 80, protocol: TCP } + - { containerPort: 443, hostPort: 443, protocol: TCP } +EOF + fi + info "ingress-nginx 설치/확인…" + kubectl --context "$DEV_CTX" apply -f "$INGRESS_MANIFEST" >/dev/null 2>&1 \ + || warn_line "ingress-nginx 매니페스트 적용 경고(네트워크 확인). 이미 설치돼 있으면 무시." + # 컨트롤러 파드가 아직 안 생겼을 때 'kubectl wait' 는 즉시 에러 → rollout status 로 견고하게 대기(짧은 초기 유예). + sleep 3 + kubectl --context "$DEV_CTX" -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=180s >/dev/null 2>&1 \ + || warn_line "ingress 컨트롤러 준비 지연 — 잠시 후 'yakcloud dev deploy'." + echo "✓ 로컬 dev 클러스터 준비 ($DEV_CTX). 다음: yakcloud dev deploy" +} +warn_line() { echo " ⚠ $*" >&2; } + +cmd_dev() { + local sub="${1:-}"; [ "$#" -gt 0 ] && shift + case "$sub" in + up) cmd_dev_up "$@" ;; + deploy|status|clean) + [ -f scripts/yakcloud_dev.py ] || 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 + local pysub="$sub"; [ "$sub" = clean ] && pysub="down" + YAK_DEV_CONTEXT="$DEV_CTX" YAK_DEV_CLUSTER="$DEV_CLUSTER" YAK_DEV_RUNTIME="${rt:-docker}" \ + TAG="${tag:-${TAG:-dev}}" python3 scripts/yakcloud_dev.py "$pysub" yakcloud.yaml + ;; + down) + command -v kind >/dev/null 2>&1 || die "kind 필요" + 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 "✓ 삭제 완료" + ;; + *) echo "yakcloud dev "; exit 1 ;; + esac +} + # 전역 CLI 자기 갱신 — 버전이 같아도 항상 최신 파일을 강제로 다시 받아 덮어쓴다(캐시 우회). # Claude Code 전역 슬래시 명령(/yakcloud) 설치/최신화 — 설치·업그레이드 시 함께 실행. # opt-out: YAKCLOUD_NO_CLAUDE_CMD=1. 실패해도 CLI 동작엔 지장 없음(비치명적). @@ -427,7 +502,8 @@ cmd_project_update() { mkdir -p scripts .gitea/workflows .claude/skills cp "$tmp/s/scripts/yakcloud_deploy.py" scripts/ 2>/dev/null || true cp "$tmp/s/scripts/yakcloud_ctl.py" scripts/ 2>/dev/null || true - rm -f scripts/yakcloud_dev.py scripts/yakcloud_migrate.py # 옛 로컬 dev/migrate 잔재 제거 + cp "$tmp/s/scripts/yakcloud_dev.py" scripts/ 2>/dev/null || true # 로컬 kind 리컨실 엔진(직선 dev) + rm -f scripts/yakcloud_migrate.py # 옛 migrate 잔재 제거 cp "$tmp/s/.gitea/workflows/deploy.yml" .gitea/workflows/ 2>/dev/null || true cp "$tmp/s/DATA-SOURCES.md" DATA-SOURCES.md 2>/dev/null || true rm -rf .claude/skills/yakcloud-deploy @@ -442,6 +518,11 @@ usage() { yakcloud $VERSION — YakCloud 프로젝트 CLI (개발·검증·운영 모두 yakcloud 클러스터, val 선택) cluster create [--template S|M] [--nodes N] [--golden] 클러스터 생성(프로비저닝 완료까지 대기) cluster ls 내 클러스터 목록(이름·상태·노드·기본 도메인) + ── 로컬 개발 클러스터(kind) — 개발 후 운영까지 직선 배포의 dev 구간 ── + dev up 로컬 kind 생성 + 인그레스(80/443) — 최초 1회 + dev deploy [tag] 매니페스트대로 로컬 리컨실(이미지 빌드+load, 소스/워크로드/인그레스; 콘솔 API 불필요) + dev status | dev clean 로컬 배포 상태 / 이 프로젝트 리소스 삭제 + dev down 로컬 kind 클러스터 삭제 project init [name] 빈 폴더에 스타터 스캐폴딩(대화형 마법사) + 자격 프롬프트 project deploy [vX.Y.Z] 커밋 + 태그 push → CI 빌드 → **개발 클러스터** 배포(버전 생략=자동 버전업) project deploy --local 러너 없이 로컬 build/push/리컨실(CI 대안, 개발 클러스터) @@ -481,6 +562,7 @@ case "${1:-help}" in *) echo "yakcloud project "; exit 1 ;; esac ;; cluster) shift; cmd_cluster "$@" ;; + dev) shift; cmd_dev "$@" ;; login) cmd_login ;; config) cmd_config ;; logout) cmd_logout ;; diff --git a/scripts/yakcloud_dev.py b/scripts/yakcloud_dev.py new file mode 100644 index 0000000..922098c --- /dev/null +++ b/scripts/yakcloud_dev.py @@ -0,0 +1,430 @@ +#!/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 와 **동일한** _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) → .dev.localhost + TAG 이미지 태그 치환(${TAG}); 없으면 dev +사용: yakcloud_dev.py [manifest.yaml] +""" +from __future__ import annotations + +import base64 +import json +import os +import secrets +import subprocess +import sys +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 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}) + + 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 에 바인딩 소스 _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") + 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 [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() diff --git a/yakcloud.yaml b/yakcloud.yaml index 087fa19..4710acc 100644 --- a/yakcloud.yaml +++ b/yakcloud.yaml @@ -7,6 +7,8 @@ apiVersion: yakcloud/v1 project: my-app # 프로젝트 이름(이미지 경로/표시에 사용) — 소문자·숫자·하이픈 # 환경 — 개발 → 검증 → 운영 3단계. 각 환경 = 별도 클러스터. 검증계(val)가 필요 없으면 그 줄을 지워도 됩니다. +# 로컬 개발(kind) → yakcloud dev up (최초 1회) → yakcloud dev deploy — 콘솔/CI 불필요, 오프라인 반복. +# 로컬에서 requires 소스+workloads 가 관리형과 '동일한 바인딩 env'로 뜬다(직선 파리티). # deploy → dev (태그 push → CI 빌드 → 개발 클러스터, 기본 도메인으로 확인) # promote --to val→ 그 이미지를 재빌드 없이 검증 클러스터로 # promote --to prod→ 검증 통과분을 운영 클러스터로 + 운영 도메인(기본 도메인 c-xxxx.yakenator.io 은 자동)