#!/usr/bin/env bash # yakcloud — YakCloud 프로젝트 CLI. 개발=로컬 kind(직선 dev) 또는 yakcloud 클러스터, 검증·운영=yakcloud 클러스터(val 선택). # # yakcloud cluster create [--template S|M] [--nodes N] [--golden] 클러스터 생성(완료까지 대기) # yakcloud cluster ls 내 클러스터 목록 # yakcloud project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬) # yakcloud project deploy [tag] 개발 배포 = 로컬 kind(없으면 자동 생성). 오프라인·무료·CI 불필요 # yakcloud project promote [vX.Y.Z] --to val|prod 관리형 검증/운영으로 승격(build+push 후 리컨실) # yakcloud project info 프로젝트 개괄(매니페스트 + 라이브 상태) # yakcloud project check [--env dev|val|prod] dry-run(직접 API 계획만) # # yakcloud domain [wl] · scale · set · env · datasource ls · source · bind/unbind # # 매니페스트: environments.dev=로컬 kind{cluster,port}, val/prod=관리형{cluster,+prod.domains}. env: YAKCLOUD_URL, YAKCLOUD_TOKEN(yakd_…) set -uo pipefail # -e 미사용: 'test && action' 관용구가 값 없을 때 CLI 를 중단시키는 함정 회피. 중요 경로는 명시적 || die. REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}" BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}" VERSION="0.28.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" LIB_DIR="${YAKCLOUD_LIB_DIR:-$CONFIG_DIR/lib}" # 전역 엔진(deploy/ctl/dev.py) — 프로젝트에 복제하지 않음 die() { echo "✗ $*" >&2; exit 1; } # 전역 엔진 경로 반환 — 없으면 스타터에서 내려받는다(자가 치유). install.sh·upgrade 가 미리 깐다. _engine() { local f="$LIB_DIR/$1.py" if [ ! -f "$f" ]; then mkdir -p "$LIB_DIR" 2>/dev/null || true curl -fsSL "$REPO/raw/branch/$BRANCH/.yakcloud/$1.py" -o "$f" 2>/dev/null \ || echo " ⚠ 엔진 '$1' 다운로드 실패 — 네트워크 확인 또는 'yakcloud upgrade'" >&2 fi printf '%s' "$f" } info() { echo "▸ $*"; } ensure_pyyaml() { python3 -c "import yaml" 2>/dev/null && return 0 pip3 install --quiet pyyaml 2>/dev/null \ || python3 -m pip install --quiet --break-system-packages pyyaml 2>/dev/null \ || die "pyyaml 설치 실패 — 'pip install pyyaml'" } # 설정/조회 커맨드는 프로젝트의 ctl 엔진에 위임(현재 폴더 = 프로젝트). ctl() { [ -f yakcloud.yaml ] || die "프로젝트 폴더가 아닙니다 — 'yakcloud project init' 로 초기화하세요." ensure_pyyaml python3 "$(_engine ctl)" "$@" } # ── 자격 저장 ── 비밀(배포토큰·Gitea토큰)=OS 키체인(암호화), 그 외(URL·사용자)=config 파일(600) ── _KC="none" if command -v security >/dev/null 2>&1; then _KC="macos" elif command -v secret-tool >/dev/null 2>&1; then _KC="linux"; fi _enc_key() { [ -f "$CONFIG_DIR/.key" ] || { mkdir -p "$CONFIG_DIR"; (openssl rand -hex 32 2>/dev/null || date +%s%N) > "$CONFIG_DIR/.key"; chmod 600 "$CONFIG_DIR/.key"; } cat "$CONFIG_DIR/.key" } secret_set() { # secret_set NAME VALUE — 항상 exit 0(set -e 안전). case "$_KC" in macos) security add-generic-password -U -a "${USER:-yakcloud}" -s "yakcloud-$1" -w "$2" >/dev/null 2>&1 || echo " ⚠ 키체인 저장 실패($1)" >&2 ;; linux) printf '%s' "$2" | secret-tool store --label="yakcloud $1" service yakcloud name "$1" >/dev/null 2>&1 || echo " ⚠ 키체인 저장 실패($1)" >&2 ;; *) if command -v openssl >/dev/null 2>&1; then mkdir -p "$CONFIG_DIR"; printf '%s' "$2" | openssl enc -aes-256-cbc -pbkdf2 -salt -pass "pass:$(_enc_key)" -out "$CONFIG_DIR/sec-$1.enc" 2>/dev/null && chmod 600 "$CONFIG_DIR/sec-$1.enc" else echo " ⚠ 키체인/openssl 없음 — '$1' 저장 못 함" >&2; fi ;; esac return 0 } secret_get() { # secret_get NAME — 없으면 빈 문자열, 항상 exit 0(set -e 안전). local out="" case "$_KC" in macos) out="$(security find-generic-password -a "${USER:-yakcloud}" -s "yakcloud-$1" -w 2>/dev/null || true)" ;; linux) out="$(secret-tool lookup service yakcloud name "$1" 2>/dev/null || true)" ;; *) [ -f "$CONFIG_DIR/sec-$1.enc" ] && out="$(openssl enc -d -aes-256-cbc -pbkdf2 -pass "pass:$(_enc_key)" -in "$CONFIG_DIR/sec-$1.enc" 2>/dev/null || true)" ;; esac printf '%s' "$out" return 0 } secret_del() { case "$_KC" in macos) security delete-generic-password -a "${USER:-yakcloud}" -s "yakcloud-$1" >/dev/null 2>&1 || true ;; linux) secret-tool clear service yakcloud name "$1" >/dev/null 2>&1 || true ;; *) rm -f "$CONFIG_DIR/sec-$1.enc" 2>/dev/null || true ;; esac } # 저장된 자격 로드 — 이미 export 된 env 가 우선(env > 저장값). set -e 안전(모두 if/then). load_config() { if [ -f "$CONFIG_FILE" ]; then while IFS='=' read -r k v; do case "$k" in ''|\#*) continue ;; esac v="${v%\"}"; v="${v#\"}" if [ -z "$(eval printf '%s' "\${$k:-}")" ]; then export "$k=$v"; fi done < "$CONFIG_FILE" fi if [ -z "${YAKCLOUD_TOKEN:-}" ]; then local t; t="$(secret_get YAKCLOUD_TOKEN)" if [ -n "$t" ]; then export YAKCLOUD_TOKEN="$t"; fi fi if [ -z "${GITEA_TOKEN:-}" ]; then local g; g="$(secret_get GITEA_TOKEN)" if [ -n "$g" ]; then export GITEA_TOKEN="$g"; fi fi return 0 } _save_nonsecret() { # url cluster gitea_url gitea_user mkdir -p "$CONFIG_DIR"; chmod 700 "$CONFIG_DIR" 2>/dev/null || true ( umask 177; cat > "$CONFIG_FILE" < CLAUDE.md <<'YCLAUDE' # 이 프로젝트는 YakCloud 로 개발·검증·배포합니다 (AI 에이전트 필독) 이 저장소의 앱은 **개발·검증부터 운영까지 전 구간을 `yakcloud` CLI** 로 진행합니다. 아래는 강제 규칙입니다. ## 절대 금지 (NEVER) - **`docker compose` / `docker-compose` / `docker run` 으로 이 앱을 로컬 실행·검증하지 마라.** (`.claude/settings.json` 이 차단) - DB·캐시·큐 등 **데이터소스 컨테이너를 손으로 띄우지 마라** — `yakcloud.yaml` 의 `requires` 로 선언한다. - `Dockerfile` 은 **yakcloud 의 이미지 빌드 전용**. 로컬 구동 도구가 아니다. - **사람의 명시적 dev 검증 확인 없이 `yakcloud project confirm` / `promote --to prod` 를 실행하지 마라.** 운영 승격은 사람이 개발계에서 검증한 뒤에만. ## 일상 개발 루프 (ALWAYS) 1. 코드를 고친다. 2. **`yakcloud project deploy`** — 로컬 kind 클러스터에 배포(없으면 자동 생성, 오프라인·무료). 관리형과 **동일한 바인딩 env·데이터소스** 로 뜬다. 3. 로컬 도메인 `*.localhost` 로 화면·API·WSS 확인. 반복 개발 = 2번 반복. 4. (검증계 선택) `yakcloud project promote --to val`. 5. **운영 승격에는 게이트가 있다** — 사람이 dev 에서 검증한 뒤 `yakcloud project confirm ` 로 확정해야 `yakcloud project promote --to prod ` 가 통과한다(확정은 그 버전에 묶임). 에이전트가 임의로 confirm 하지 말 것. ## 데이터소스 - `yakcloud.yaml` 의 `requires` 에 `{ name, type }` 선언 → 워크로드 `binds` 로 연결 → `_URL` 등 자동 주입. - type ∈ postgresql·mysql·mariadb·mongodb·redis·minio·rabbitmq·solr·oracle. 목록 = `yakcloud datasource ls`. ## 참고 - 상태 = `yakcloud project info` · 배포 전 dry-run = `yakcloud project check`. - 슬래시로도 실행: `/yakcloud <명령>`. 상세 절차 = 전역 스킬 `yakcloud-deploy`. - **왜 compose 를 안 쓰나**: compose 로컬 스택은 운영(관리형 k8s + env 주입 + 격리 소스)과 환경이 달라져 "로컬은 되는데 운영에서 깨짐" 을 만든다. yakcloud 로컬 kind 는 운영과 **직선 파리티** 를 보장한다. YCLAUDE fi mkdir -p .claude if [ "$force" = 1 ] || [ ! -f .claude/settings.json ]; then cat > .claude/settings.json <<'YSETTINGS' { "permissions": { "deny": [ "Bash(docker compose:*)", "Bash(docker-compose:*)", "Bash(docker run:*)", "Bash(sudo docker compose:*)", "Bash(sudo docker-compose:*)" ] } } YSETTINGS fi # dev 검증 확정 기록은 로컬·머신단위(git 미포함) — .gitignore 에 보장. touch .gitignore grep -qxF ".yakcloud.confirm" .gitignore 2>/dev/null || printf '\n# yakcloud dev 검증 확정(로컬 전용, 운영 승격 게이트)\n.yakcloud.confirm\n' >> .gitignore } cmd_project_init() { local name="" noninteractive=0 for a in "$@"; do case "$a" in -y|--yes|--no-wizard) noninteractive=1 ;; *) [ -z "$name" ] && name="$a" ;; esac; done # "." / "./" = 현재 폴더에 초기화(이름은 폴더명에서 유도) — 디렉터리 기호가 이름으로 박히지 않도록. case "$name" in .|./) name="" ;; esac [ -n "$name" ] && { mkdir -p "$name"; cd "$name"; } if [ -n "$(ls -A . 2>/dev/null | grep -v '^\.git$' || true)" ]; then die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud project init '." fi info "yakcloud-starter 내려받는 중… ($REPO @ $BRANCH)" command -v git >/dev/null 2>&1 || die "git 이 필요합니다." local tmp; tmp="$(mktemp -d)" git clone -q --depth 1 -b "$BRANCH" "$REPO.git" "$tmp/s" \ || { rm -rf "$tmp"; die "스타터 클론 실패 — 네트워크/레포 공개 여부 확인 ($REPO)"; } rm -rf "$tmp/s/.git" cp -R "$tmp/s/." . rm -rf "$tmp" # 스캐폴드는 '앱 + 매니페스트 + 얇은 CI + 프로젝트 가드레일'만 남긴다. 엔진·스킬·명령은 전역. # bin·install.sh=CLI · .yakcloud=엔진(전역) · .claude/skills·commands=스킬·명령(전역) · README/DATA-SOURCES=스타터 문서. # ⚠ .claude 는 통째로 지우지 않는다 — 프로젝트 로컬 .claude/settings.json(가드레일)은 남겨야 하므로 skills/commands 만 제거. rm -rf bin install.sh .yakcloud .claude/skills .claude/commands README.md DATA-SOURCES.md # 전역 엔진/스킬/명령 준비(최초 init 시에도 보장). install_global_assets 2>/dev/null || true # 프로젝트에 yakcloud 강제 가드레일 심기(CLAUDE.md 지침 + docker-compose 차단) — 에이전트가 딴 길로 새지 않게. _scaffold_yakcloud_guardrails 1 # 프로젝트명 = 지정 이름 or 현재 폴더명 → K8s/이미지 안전하게 정규화(소문자·영숫자·하이픈). local proj; proj="$(_sanitize_name "${name:-$(basename "$(pwd)")}")" git rev-parse --git-dir >/dev/null 2>&1 || git init -q echo "✓ 스캐폴딩 완료 ($(pwd))" # 대화형 마법사(TTY + not --yes): 클러스터·앱·포트·헬스·CPU·메모리·replicas·env 를 물어 매니페스트에 반영. # 마법사가 이름을 확정하므로 여기서 선-sed 하지 않는다(하면 my-app 이 먼저 치환돼 마법사의 재치환이 no-op → 이중 sed 클로버). if [ "$noninteractive" = 0 ] && [ -t 0 ]; then _init_wizard "$proj" _maybe_login_prompt else # 비대화형(마법사 미실행): project·워크로드(배포)명·image 를 정규화된 프로젝트명으로 일괄 반영. [ -f yakcloud.yaml ] && { sed -i.bak "s/my-app/$proj/g" yakcloud.yaml && rm -f yakcloud.yaml.bak; } fi echo " 다음: yakcloud project info · yakcloud domain · yakcloud project deploy" } # 매니페스트 채우기 마법사 — ctl 은 API 미접속(YAKCLOUD_TOKEN 비움)으로 매니페스트만 편집. _init_wizard() { local dflt_app="$1" app devc devp devw valc prodc pdom port health cpu mem reps path kv echo "── 프로젝트 설정 (엔터=기본값) · 개발(로컬 kind) → 검증/운영(관리형) ──" printf "앱 이름 [%s]: " "$dflt_app"; read -r app; app="$(_sanitize_name "${app:-$dflt_app}")" printf "개발 클러스터 = 로컬 kind, 이름 [dev]: "; read -r devc; devc="$(_sanitize_name "${devc:-dev}")" devp=80 if [ "$devc" != "dev" ]; then printf "로컬 인그레스 포트 [8080]: "; read -r devp; devp="${devp:-8080}"; fi printf "워커 노드 수 [2]: "; read -r devw; devw="${devw:-2}"; case "$devw" in ''|*[!0-9]*) devw=2 ;; esac printf "검증 클러스터(관리형, 선택, 엔터=생략): "; read -r valc printf "운영 클러스터(관리형, 선택, 엔터=생략): "; read -r prodc printf "운영 도메인(선택, 예 app.example.com): "; read -r pdom printf "컨테이너 포트 [8080]: "; read -r port; port="${port:-8080}" printf "헬스체크 경로 [/healthz]: "; read -r health; health="${health:-/healthz}" printf "CPU 요청 [25m]: "; read -r cpu; cpu="${cpu:-25m}" printf "메모리 요청 [64Mi]: "; read -r mem; mem="${mem:-64Mi}" printf "replicas [2]: "; read -r reps; reps="${reps:-2}" printf "노출 경로(ingress path) [/]: "; read -r path; path="${path:-/}" sed -i.bak "s/my-app/$app/g" yakcloud.yaml && rm -f yakcloud.yaml.bak # project·워크로드명·image = 앱(프로젝트)명 [ -n "${GITEA_USER:-}" ] && { sed -i.bak "s#CHANGE_ME_GITEA_USER#${GITEA_USER}#" yakcloud.yaml 2>/dev/null; rm -f yakcloud.yaml.bak; } # dev = 로컬 kind: environments.dev 줄 전체를 입력값(cluster/port/workers)으로 재작성. (구분자 | — 치환문의 # 주석과 충돌 회피) sed -i.bak -E "s|^ dev:.*| dev: { cluster: $devc, port: $devp, workers: $devw } # 로컬 kind (project deploy)|" yakcloud.yaml && rm -f yakcloud.yaml.bak [ -n "$prodc" ] && { sed -i.bak "s#my-prod-cluster#$prodc#" yakcloud.yaml && rm -f yakcloud.yaml.bak; } [ -n "$pdom" ] && { sed -i.bak "s#domains: \[\]#domains: [$pdom]#" yakcloud.yaml && rm -f yakcloud.yaml.bak; } if [ -n "$valc" ]; then sed -i.bak "s#my-val-cluster#$valc#" yakcloud.yaml && rm -f yakcloud.yaml.bak else sed -i.bak "/my-val-cluster/d" yakcloud.yaml && rm -f yakcloud.yaml.bak; fi # 검증 생략 시 val 줄 제거 ensure_pyyaml # stderr 는 노출(침묵 유실 방지) — set/scale 이 실패하면 사용자가 알 수 있게. || 로 마법사는 계속. YAKCLOUD_TOKEN= python3 "$(_engine ctl)" set "$app" --port "$port" --health "$health" --cpu "$cpu" --mem "$mem" --path "$path" >/dev/null \ || echo " ⚠ 워크로드 설정(port/health/…) 반영 실패 — yakcloud.yaml 확인" YAKCLOUD_TOKEN= python3 "$(_engine ctl)" scale "$app" "$reps" >/dev/null \ || echo " ⚠ replicas 반영 실패 — yakcloud.yaml 확인" echo "환경변수(KEY=VALUE, 빈 줄=종료):" while :; do printf " env> "; read -r kv || break; [ -z "$kv" ] && break YAKCLOUD_TOKEN= python3 "$(_engine ctl)" env "$app" "$kv" >/dev/null 2>&1 || echo " (형식: KEY=VALUE)" done echo "✓ 매니페스트 반영: app=$app · dev(로컬 kind)=$devc:$devp(워커 $devw) · val=${valc:-<생략>} · prod=${prodc:-<생략>} · port=$port path=$path cpu=$cpu mem=$mem replicas=$reps" } _maybe_login_prompt() { [ -n "${YAKCLOUD_TOKEN:-}" ] && return 0 # 이미 저장/설정됨 printf "배포 자격(YakCloud 배포토큰·Gitea)을 지금 저장할까요? [y/N]: "; read -r a case "$a" in y|Y|yes|YES) cmd_login ;; *) echo " 나중에 'yakcloud login' 으로 설정하세요." ;; esac } # 매니페스트 environments.. 값 출력(없으면 빈 문자열). _manifest_env() { ensure_pyyaml >/dev/null 2>&1 || true python3 - "$1" "$2" <<'PY' 2>/dev/null import sys, yaml try: m = yaml.safe_load(open("yakcloud.yaml")) or {} except Exception: sys.exit(0) e = ((m.get("environments") or {}).get(sys.argv[1]) or {}) v = e.get(sys.argv[2]) if v is not None: print(v) PY } # 로컬 kind 클러스터(yak-) 존재 여부. _dev_cluster_exists() { local n="$1" rt kenv=""; rt="$(_dev_runtime)"; [ "$rt" = podman ] && kenv="KIND_EXPERIMENTAL_PROVIDER=podman" env ${kenv:+$kenv} kind get clusters 2>/dev/null | grep -qx "yak-$n" } # project deploy = 개발(dev) 배포 = **로컬 kind**. environments.dev.cluster=로컬 이름(--name), .port=인그레스 포트. # 로컬 클러스터가 없으면 dev up(생성+인그레스+에이전트) 후 dev deploy. 오프라인·무료·즉시(관리형/CI 미사용). cmd_project_deploy() { local ver="" for a in "$@"; do case "$a" in --local|--ci) : ;; *) [ -z "$ver" ] && ver="$a" ;; esac; done [ -f yakcloud.yaml ] || die "yakcloud.yaml 없음 — 'yakcloud project init' 먼저" local devname devport; devname="$(_manifest_env dev cluster)"; devport="$(_manifest_env dev port)" [ -n "$devname" ] || devname="dev" # 포트 인자 — 이름이 dev 가 아니거나(반드시 비80) 명시 포트가 80이 아니면 up·deploy 양쪽에 동일하게 전달 # (deploy 에도 넘겨야 리포트 URL 이 host:port 로 맞음). 빈 배열은 bash 3.2 set -u 안전 확장. local portargs=() if [ "$devname" != "dev" ]; then [ -n "$devport" ] || devport=8080 portargs=(--port "$devport") elif [ -n "$devport" ] && [ "$devport" != "80" ]; then portargs=(--port "$devport") fi info "개발(dev) = 로컬 kind '$devname'. 없으면 생성 후 배포 — 관리형/CI 미사용(콘솔엔 로컬 클러스터로 표시)." if ! _dev_cluster_exists "$devname"; then info "로컬 클러스터 '$devname' 없음 → 생성(dev up)" cmd_dev up --name "$devname" "${portargs[@]+"${portargs[@]}"}" fi cmd_dev deploy --name "$devname" "${portargs[@]+"${portargs[@]}"}" ${ver:+"$ver"} echo " 검증/운영으로 올리려면: yakcloud project promote --to val|prod (관리형 클러스터)" } # 워크로드 이미지 빌드 + 레지스트리 push(promote 용) — 이미 같은 태그가 레지스트리에 있으면 스킵(운영 재빌드 방지). _build_and_push() { local tag="$1" rt; rt="$(_dev_runtime)"; [ -n "$rt" ] || die "docker 또는 podman 필요(이미지 빌드/푸시)" local reg="${GITEA_URL:-https://gitea.yakenator.io}"; reg="${reg#https://}"; reg="${reg#http://}" if [ -n "${GITEA_USER:-}" ] && [ -n "${GITEA_TOKEN:-}" ]; then printf '%s' "$GITEA_TOKEN" | "$rt" login "$reg" -u "$GITEA_USER" --password-stdin >/dev/null 2>&1 \ || info "레지스트리 로그인 경고($reg) — push 실패 가능('yakcloud login')" fi RT="$rt" TAG="$tag" python3 - <<'PY' import os, subprocess, yaml rt=os.environ["RT"]; tag=os.environ["TAG"]; m=yaml.safe_load(open("yakcloud.yaml")) or {} for w in m.get("workloads",[]) or []: ctx=w.get("build") if not ctx: continue img=w["image"].replace("${TAG}",tag) exists=subprocess.call([rt,"manifest","inspect",img],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)==0 if exists: print("▸ skip(이미 레지스트리에 있음) %s"%img,flush=True); continue print("▸ build %s: %s -> %s"%(w["name"],ctx,img),flush=True) subprocess.check_call([rt,"build","-t",img,ctx]); subprocess.check_call([rt,"push",img]) PY } # 러너 프리플라이트(best-effort) — origin 이 Gitea 면 레포 러너 유무 확인. 없으면 경고(비차단). # (구) --local / deploy-local — dev 는 이제 항상 로컬(kind)이라 별도 모드 불필요. 로컬 배포로 위임. cmd_project_deploy_local() { info "참고: 개발 배포는 이제 기본이 로컬 kind 입니다 — 'yakcloud project deploy' 와 동일(관리형은 'promote')." cmd_project_deploy "$@" } # dry-run — dev 는 로컬(kind)이라 안내만, val/prod 는 관리형 리컨실 계획. cmd_project_check() { [ -f yakcloud.yaml ] || die "'yakcloud project init' 먼저" ensure_pyyaml local env=""; for a in "$@"; do case "$a" in --env=*) env="${a#--env=}" ;; --prod) env=prod ;; esac; done env="${env:-dev}" if [ "$env" = dev ]; then local dn; dn="$(_manifest_env dev cluster)"; [ -n "$dn" ] || dn="dev" echo "dev = 로컬 kind '$dn' — 'yakcloud project deploy' 로 오프라인 로컬 배포(관리형/CI 미사용)." echo " requires/workloads 계획은 로컬 리컨실이 그대로 적용합니다." return fi : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요}" YAKCLOUD_ENV="$env" python3 "$(_engine deploy)" yakcloud.yaml --dry-run } # 승격 — 개발(로컬)에서 검증한 것을 **관리형** 검증/운영 클러스터로. 로컬 이미지는 레지스트리에 없으므로 # 태그 이미지를 build+push(이미 있으면 스킵=운영 재빌드 방지) 후 리컨실 + 도메인 부착. # 운영 승격 게이트(Phase 1, CLI) — dev 검증 확정(.yakcloud.confirm)이 승격 버전과 일치해야 prod 통과. # 확정은 정확히 그 버전(태그)에 묶인다 — 새 태그를 올리면 재검증·재확정 필요. (Phase 2: 백엔드 콘솔 휴먼 승인 추가) _require_dev_confirm() { local want="$1" have="" [ -f .yakcloud.confirm ] && have="$(python3 -c 'import json;print((json.load(open(".yakcloud.confirm")) or {}).get("version",""))' 2>/dev/null || true)" [ "$have" = "$want" ] && return 0 die "운영(prod) 승격 차단 — '$want' 의 개발계 검증 확정이 없습니다(현재 확정: ${have:-<없음>}). 1) 개발계 검증: yakcloud project deploy → 로컬(*.localhost)에서 확인 2) 검증 확정: yakcloud project confirm $want 그다음에 다시: yakcloud project promote --to prod $want (확정은 그 버전에 묶입니다. 코드가 바뀌어 새 태그를 올리면 재검증·재확정해야 합니다.)" } # 개발계 검증 사인오프 — .yakcloud.confirm 에 '이 버전을 dev 에서 검증함'을 기록(로컬·머신단위, git 미포함). # 운영 승격(promote --to prod)이 이 기록을 요구한다. 사람이 검증한 뒤 실행할 것(에이전트 임의 실행 금지 — CLAUDE.md). cmd_project_confirm() { [ -f yakcloud.yaml ] || die "'yakcloud project init' 먼저" ensure_pyyaml local tag="" yes=0 note="" while [ "$#" -gt 0 ]; do case "$1" in -y|--yes) yes=1; shift ;; --note) note="${2:-}"; shift 2 || shift ;; -*) shift ;; *) [ -z "$tag" ] && tag="$1"; shift ;; esac; done if [ -z "$tag" ]; then git fetch -q --tags origin 2>/dev/null || true tag="$(git tag -l 'v*' --sort=-v:refname 2>/dev/null | head -1)" [ -n "$tag" ] || die "확정할 버전을 지정하세요 — 'yakcloud project confirm vX.Y.Z' (project deploy 로 개발계에 올린 그 버전)" fi case "$tag" in v*) : ;; *) tag="v$tag" ;; esac # 최소 증빙: 로컬 dev 클러스터가 있어야(= 개발계 배포·검증을 실제로 거쳤음). 없으면 확정 불가. local devname; devname="$(_manifest_env dev cluster)"; [ -n "$devname" ] || devname="dev" _dev_cluster_exists "$devname" || die "개발계('$devname') 로컬 클러스터가 없습니다 — 먼저 'yakcloud project deploy' 로 배포·검증 후 confirm." if [ "$yes" != 1 ]; then echo "개발계('$devname')에서 이 앱을 실제로 검증했습니까? 이 확정은 '$tag' 의 운영(prod) 승격을 허용합니다." echo " (로컬 확인 호스트는 'yakcloud project info' 참고 — *.localhost)" printf "검증 완료를 확정합니까? [y/N]: "; read -r a case "$a" in y|Y|yes|YES) : ;; *) die "확정 취소 — 운영 승격은 계속 차단됩니다." ;; esac fi YC_TAG="$tag" YC_DEV="$devname" YC_NOTE="$note" python3 - > .yakcloud.confirm <<'PY' import json,os,datetime,yaml m=yaml.safe_load(open("yakcloud.yaml")) or {} rec={"project":m.get("project",""),"version":os.environ["YC_TAG"],"cluster":os.environ["YC_DEV"], "confirmedAt":datetime.datetime.now().astimezone().isoformat(timespec="seconds"), "host":os.uname().nodename,"note":os.environ.get("YC_NOTE","")} print(json.dumps(rec,ensure_ascii=False,indent=2)) PY # 백엔드에 운영 승격 승인 '요청'을 등록(best-effort) — 게이트 켜진 prod 클러스터면 콘솔 승인 대기함에 뜬다. # 승인 자체는 콘솔(사람)만 가능. 로그인 안 됐거나 prod 미지정이면 조용히 스킵(promote 첫 시도에 자동 생성). if [ -n "${YAKCLOUD_TOKEN:-}" ] && [ -n "${YAKCLOUD_URL:-}" ]; then YAKCLOUD_ENV=prod YAKCLOUD_ACTION=register-approval TAG="$tag" python3 "$(_engine deploy)" yakcloud.yaml 2>/dev/null \ || echo " (콘솔 승인 요청 등록은 건너뜀 — 운영 게이트가 켜져 있으면 promote 시 콘솔 승인이 필요합니다.)" fi echo "✓ 개발계 검증 확정: $tag. 다음: 운영 게이트가 켜져 있으면 콘솔에서 승인 → 'yakcloud project promote --to prod $tag'." } cmd_project_promote() { [ -f yakcloud.yaml ] || die "'yakcloud project init' 먼저" : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요 — 'yakcloud login'}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요 — 'yakcloud login'}" ensure_pyyaml local tag="" env="prod" while [ "$#" -gt 0 ]; do case "$1" in --to|--env) env="${2:-prod}"; shift 2 || shift ;; -*) shift ;; *) [ -z "$tag" ] && tag="$1"; shift ;; esac done case "$env" in val|prod) : ;; *) die "promote 대상은 val 또는 prod (관리형). dev 는 'project deploy'(로컬)." ;; esac if [ -z "$tag" ]; then git fetch -q --tags origin 2>/dev/null || true tag="$(git tag -l 'v*' --sort=-v:refname 2>/dev/null | head -1)" [ -n "$tag" ] || tag="v0.1.0" fi case "$tag" in v*) : ;; *) tag="v$tag" ;; esac # 운영 승격 게이트 — prod 는 개발계 검증 확정(그 버전)이 선행돼야 한다(val 은 자유). [ "$env" = prod ] && _require_dev_confirm "$tag" info "'$env' 승격: $tag → 관리형 '$env' 클러스터(이미지 build+push 후 리컨실; 이미 있으면 재빌드 없이 그 이미지)" _build_and_push "$tag" YAKCLOUD_ENV="$env" TAG="$tag" python3 "$(_engine deploy)" yakcloud.yaml echo "✓ 승격 완료: $tag → '$env' 클러스터 + 도메인(기본 도메인은 자동)" } # 클러스터 — 계정 배포토큰으로 생성/조회(프로젝트 폴더 불필요). POST /clusters(템플릿) + 프로비저닝 폴. cmd_cluster() { : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요 — 'yakcloud login'}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요 — 'yakcloud login'}" local sub="${1:-ls}"; [ "$#" -gt 0 ] && shift case "$sub" in ls|list) YAKCLOUD_URL="$YAKCLOUD_URL" YAKCLOUD_TOKEN="$YAKCLOUD_TOKEN" python3 - <<'PY' import json,os,urllib.request,urllib.error,sys U=os.environ["YAKCLOUD_URL"].rstrip("/");T=os.environ["YAKCLOUD_TOKEN"] def api(m,p): r=urllib.request.Request(U+"/api/v1"+p,method=m,headers={"Authorization":"Bearer "+T}) try: with urllib.request.urlopen(r,timeout=30) as x: return json.loads(x.read().decode() or "{}") except urllib.error.HTTPError as e: sys.exit(" ✗ %s -> %s: %s"%(p,e.code,e.read().decode()[:200])) d=api("GET","/clusters"); items=d.get("data",d) or [] print("클러스터 (%d)"%len(items)) print(" %-18s %-10s %-6s %s"%("이름","상태","노드","기본 도메인")) for c in items: print(" %-18s %-10s %-6s %s"%(c.get("name","?"),c.get("status","?"),c.get("desiredNodes","?"),c.get("defaultHostname") or "")) PY ;; create|new) local name="" tmpl="S" nodes="" golden=0 while [ "$#" -gt 0 ]; do case "$1" in -t|--template) tmpl="${2:-S}"; shift 2 || shift ;; -n|--nodes) nodes="${2:-}"; shift 2 || shift ;; --golden|--golden-path) golden=1; shift ;; -*) shift ;; *) [ -z "$name" ] && name="$1"; shift ;; esac done [ -n "$name" ] || die "클러스터 이름 필요 — 'yakcloud cluster create [--template S|M] [--nodes N] [--golden]'" YC_NAME="$name" YC_TMPL="$tmpl" YC_NODES="$nodes" YC_GOLDEN="$golden" \ YAKCLOUD_URL="$YAKCLOUD_URL" YAKCLOUD_TOKEN="$YAKCLOUD_TOKEN" python3 - <<'PY' import json,os,sys,time,urllib.request,urllib.error U=os.environ["YAKCLOUD_URL"].rstrip("/");T=os.environ["YAKCLOUD_TOKEN"] def api(m,p,b=None,to=30): data=json.dumps(b).encode() if b is not None else None r=urllib.request.Request(U+"/api/v1"+p,data=data,method=m, headers={"Authorization":"Bearer "+T,"content-type":"application/json"}) try: with urllib.request.urlopen(r,timeout=to) as x: raw=x.read().decode(); return json.loads(raw) if raw else {} except urllib.error.HTTPError as e: sys.exit(" ✗ %s %s -> %s: %s"%(m,p,e.code,e.read().decode()[:300])) uw=lambda r:(r.get("data",r) if isinstance(r,dict) else r) name=os.environ["YC_NAME"];tmpl=os.environ["YC_TMPL"].upper() nodes=os.environ.get("YC_NODES","");golden=os.environ.get("YC_GOLDEN")=="1" tmap={t["code"]:t for t in uw(api("GET","/templates")) or []} if tmpl not in tmap: sys.exit(" ✗ 템플릿 '%s' 없음 (있음: %s)"%(tmpl,", ".join(tmap))) if tmpl=="C": sys.exit(" ✗ Custom(C)은 사양 상세가 필요 — 콘솔에서 생성하세요") tt=tmap[tmpl];mn,mx=tt.get("minNodes",1),tt.get("maxNodes",9) n=int(nodes) if nodes else mn n=max(mn,min(n,mx)) print("▸ 클러스터 생성: %s (템플릿 %s '%s', 노드 %d%s)"%(name,tmpl,tt.get("name",""),n," +골든패스" if golden else "")) c=uw(api("POST","/clusters",{"name":name,"templateCode":tmpl,"nodeCount":n,"goldenPath":golden})) cid=c.get("id") or name print(" 요청됨(status=%s, id=%s). 프로비저닝 대기(보통 5~8분)…"%(c.get("status"),cid)) last=None for _ in range(200): # ~50분 time.sleep(15) s=uw(api("GET","/clusters/%s"%cid)) st=s.get("status","?");joined=len([x for x in (s.get("nodes") or []) if x.get("joined")]) cur=(st,joined) if cur!=last: print(" … %s (노드 %d/%s)"%(st,joined,s.get("desiredNodes","?")));last=cur if st=="ACTIVE": print("✓ '%s' ACTIVE — 기본 도메인 %s"%(name,s.get("defaultHostname") or "?")) print(" 다음: 매니페스트 environments 에 '%s' 지정 후 'yakcloud project deploy'"%name) break if st in ("ERROR","FAILED","DELETED"): sys.exit(" ✗ 프로비저닝 실패(status=%s)"%st) else: sys.exit(" ⏳ 대기 초과 — 'yakcloud cluster ls' 로 상태 확인") PY ;; *) echo "yakcloud cluster [--template S|M] [--nodes N] [--golden]>"; exit 1 ;; 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() { # 강제 지정(YAK_DEV_RUNTIME=docker|podman) 우선 — docker·podman 둘 다 있을 때 팀/머신이 고정. case "${YAK_DEV_RUNTIME:-}" in docker) command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 && { echo docker; return; }; echo ""; return ;; podman) command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1 && { echo podman; return; }; echo ""; return ;; esac # 자동 감지 — docker 데몬 우선, 없으면 podman. 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 "" } # Docker Desktop 이 ~/.docker/config.json 에 넣는 credHelpers/auths 키가 'https://index.docker.io/v1/' 처럼 # 스킴(http[s]://) 접두어를 가지면 podman 이 자격 파싱에서 거부('key ... contains http[s]:// prefix')해 # 공개 이미지 pull(kindest/node·postgres·앱 베이스)조차 실패한다. podman 일 때, 그리고 문제 키가 있을 때만 # 관리형 빈 auth 파일로 REGISTRY_AUTH_FILE 을 지정해 우회한다(공개 이미지엔 자격 불필요). 사용자가 이미 # REGISTRY_AUTH_FILE 을 지정했으면 존중. docker 런타임엔 영향 없음. _podman_auth_fix() { [ "${1:-}" = podman ] || return 0 [ -n "${REGISTRY_AUTH_FILE:-}" ] && return 0 grep -q 'https\?://' "$HOME/.docker/config.json" 2>/dev/null || return 0 local af="$CONFIG_DIR/podman-auth.json" [ -f "$af" ] || { mkdir -p "$CONFIG_DIR" 2>/dev/null; printf '{}' > "$af"; } export REGISTRY_AUTH_FILE="$af" info "podman: Docker Desktop credHelpers(https:// 키) 우회 — REGISTRY_AUTH_FILE=$af" } # 여러 로컬 dev 클러스터 지원 — --name/--port 를 파싱해 클러스터명·컨텍스트·등록명·인그레스 포트를 파생. # 기본(--name 없음) = dev → kind 'yak-dev', ctx 'kind-yak-dev', 등록명 , 인그레스 80/443 # --name dev2 --port N → kind 'yak-dev2', ctx 'kind-yak-dev2', 등록명 -dev2, 인그레스 N (80은 기본 클러스터 몫) # 나머지 인자(태그 등)는 DEV_ARGS 배열에 남긴다(--name 값이 태그로 오인되지 않게 여기서 소비). _dev_resolve() { DEV_NAME="dev"; DEV_HTTP_PORT="80"; DEV_PORT_EXPLICIT=0; DEV_WORKERS=2; DEV_ARGS=() # 기본 로컬 클라우드 = CP1 + 워커2 while [ "$#" -gt 0 ]; do case "$1" in --name|--cluster) DEV_NAME="${2:-}"; shift 2 || shift ;; --name=*) DEV_NAME="${1#--name=}"; shift ;; --cluster=*) DEV_NAME="${1#--cluster=}"; shift ;; --port) DEV_HTTP_PORT="${2:-}"; DEV_PORT_EXPLICIT=1; shift 2 || shift ;; --port=*) DEV_HTTP_PORT="${1#--port=}"; DEV_PORT_EXPLICIT=1; shift ;; --workers|--worker) DEV_WORKERS="${2:-0}"; shift 2 || shift ;; --workers=*) DEV_WORKERS="${1#--workers=}"; shift ;; --worker=*) DEV_WORKERS="${1#--worker=}"; shift ;; *) DEV_ARGS+=("$1"); shift ;; esac done case "$DEV_WORKERS" in ''|*[!0-9]*) DEV_WORKERS=0 ;; esac [ "$DEV_WORKERS" -gt 5 ] && DEV_WORKERS=5 # 노트북 자원 보호(과도한 노드 방지) DEV_NAME="$(printf '%s' "$DEV_NAME" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9-' '-' | sed 's/^-*//; s/-*$//')" [ -z "$DEV_NAME" ] && DEV_NAME="dev" DEV_CLUSTER="${YAK_DEV_CLUSTER:-yak-$DEV_NAME}" DEV_CTX="kind-$DEV_CLUSTER" # kind 는 항상 컨텍스트를 'kind-<클러스터명>' 으로 만든다 } cmd_dev_up() { command -v kind >/dev/null 2>&1 || die "kind 필요 — 'brew install kind' (https://kind.sigs.k8s.io)" command -v kubectl >/dev/null 2>&1 || die "kubectl 필요" 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 # 이름 붙은(비기본) 클러스터는 80 이 기본 클러스터 몫 → --port 필수. 기본(dev)은 80/443. if [ "$DEV_NAME" != "dev" ] && [ "${DEV_PORT_EXPLICIT:-0}" = 0 ]; then die "이름 붙은 dev 클러스터('$DEV_NAME')는 인그레스 포트가 필요합니다 — 예: yakcloud dev up --name $DEV_NAME --port 8081" fi case "$DEV_HTTP_PORT" in ''|*[!0-9]*) die "포트는 숫자여야 합니다: '$DEV_HTTP_PORT'";; esac if [ "$DEV_HTTP_PORT" -lt 1 ] || [ "$DEV_HTTP_PORT" -gt 65535 ]; then die "포트는 1-65535 범위여야 합니다: '$DEV_HTTP_PORT'" fi local nw="${DEV_WORKERS:-0}" info "kind 클러스터 '$DEV_CLUSTER' 생성 ($rt) — 인그레스 http 포트 ${DEV_HTTP_PORT}, 워커 ${nw}개…" local ports=" - { containerPort: 80, hostPort: ${DEV_HTTP_PORT}, protocol: TCP }" [ "$DEV_HTTP_PORT" = "80" ] && ports="$ports - { containerPort: 443, hostPort: 443, protocol: TCP }" # 워커 노드(N개) — 인그레스는 CP(ingress-ready 라벨·포트매핑) 몫이라 워커는 순수 워크로드 스케줄 대상. local workers="" [ "$nw" -gt 0 ] && workers="$(for _ in $(seq 1 "$nw"); do echo "- role: worker"; done)" env $kenv kind create cluster --name "$DEV_CLUSTER" --config - </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'." # metrics-server(실측 노드/파드 CPU·메모리) — 콘솔 '노드 리소스 사용현황'의 실측 바용. kind 는 kubelet TLS 자체서명 → --kubelet-insecure-tls. info "metrics-server 설치/확인(노드 리소스 실측)…" kubectl --context "$DEV_CTX" apply -f "${YAK_DEV_METRICS:-https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml}" >/dev/null 2>&1 \ && kubectl --context "$DEV_CTX" -n kube-system patch deployment metrics-server --type=json \ -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' >/dev/null 2>&1 \ || warn_line "metrics-server 설치 건너뜀(무시 가능 — 실측 대신 예약값만 표시)." echo "✓ 로컬 dev 클러스터 준비 ($DEV_CTX). 다음: yakcloud dev deploy" _dev_register # 클러스터 단위 릴레이 에이전트 기동 → 배포 전에도 콘솔에서 노드 모니터링·연결(로그인 시). 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; } # 콘솔 클러스터 목록에 이 로컬 클러스터를 메타 등록 — 머신 호스트명 기준(머신당 1개, 프로젝트는 네임스페이스). _local_reg_name() { local h; h="$(hostname -s 2>/dev/null | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9-' '-' | sed 's/^-*//; s/-*$//')" h="${h:-local-dev}" # 여러 dev 클러스터 구분 — 기본(dev)은 , 이름 붙은 클러스터는 -(콘솔에 구분 표시). [ -n "${DEV_NAME:-}" ] && [ "$DEV_NAME" != "dev" ] && h="$h-$DEV_NAME" echo "$h" } # best-effort 등록/heartbeat — 로그인(URL+토큰) 없으면 조용히 스킵(오프라인 dev 불변식 유지). _dev_register() { { [ -n "${YAKCLOUD_URL:-}" ] && [ -n "${YAKCLOUD_TOKEN:-}" ]; } || return 0 local name; name="$(_local_reg_name)" curl -fsS -m 6 -X POST "${YAKCLOUD_URL%/}/api/v1/clusters/local" \ -H "Authorization: Bearer $YAKCLOUD_TOKEN" -H 'content-type: application/json' \ -d "{\"name\":\"$name\",\"displayName\":\"$name\"}" >/dev/null 2>&1 \ && info "콘솔 클러스터 목록에 로컬 '$name' 등록/갱신" || true return 0 } _dev_deregister() { { [ -n "${YAKCLOUD_URL:-}" ] && [ -n "${YAKCLOUD_TOKEN:-}" ]; } || return 0 local name; name="$(_local_reg_name)" curl -fsS -m 6 -X DELETE "${YAKCLOUD_URL%/}/api/v1/clusters/local/$name" \ -H "Authorization: Bearer $YAKCLOUD_TOKEN" >/dev/null 2>&1 \ && info "콘솔 클러스터 목록에서 로컬 '$name' 해제" || true return 0 } # 사용 가능한 런타임(docker·podman) 전체에서 yak-* kind 클러스터 나열 — " " 줄. # 여러 런타임을 섞어 써도 한 번에 보이도록(감지된 하나만 보던 문제). _dev_all_clusters() { local rt kenv cs c for rt in docker podman; do command -v "$rt" >/dev/null 2>&1 || continue "$rt" ps -q >/dev/null 2>&1 || continue # 데몬/머신 미동작이면 스킵 kenv=""; [ "$rt" = podman ] && kenv="KIND_EXPERIMENTAL_PROVIDER=podman" cs="$(env $kenv kind get clusters 2>/dev/null | grep '^yak-' || true)" while IFS= read -r c; do [ -n "$c" ] && echo "$rt $c"; done </dev/null 2>&1 || die "kind 필요" local all; all="$(_dev_all_clusters)" [ -z "$all" ] && { echo "로컬 dev 클러스터 없음 — 'yakcloud dev up' 으로 생성."; return 0; } printf " %-9s %-18s %-14s %s\n" "런타임" "kind 클러스터" "--name" "콘솔 등록명" local rt c nm while read -r rt c; do [ -z "$c" ] && continue nm="${c#yak-}"; DEV_NAME="$nm" printf " %-9s %-18s %-14s %s\n" "$rt" "$c" "$nm" "$(_local_reg_name)" done </dev/null 2>&1 || die "kind 필요" local all; all="$(_dev_all_clusters)" # " " 줄들(양쪽 런타임) local rt="" if [ "$name_given" = 0 ]; then local n; n="$(printf '%s\n' "$all" | grep -c . 2>/dev/null || echo 0)" if [ "$n" -eq 0 ]; then die "로컬 dev 클러스터가 없습니다 — 'yakcloud dev ls'."; fi if [ "$n" -gt 1 ]; then echo "로컬 클러스터가 여러 개입니다 — 삭제할 것을 --name 으로 지정하세요:"; echo; cmd_dev_ls die "예: yakcloud dev down --name <이름>" fi rt="$(printf '%s' "$all" | awk '{print $1}')"; DEV_CLUSTER="$(printf '%s' "$all" | awk '{print $2}')" DEV_NAME="${DEV_CLUSTER#yak-}"; DEV_CTX="kind-$DEV_CLUSTER" else # --name 지정: 양쪽 런타임에서 yak- 찾기(없으면 가짜 삭제 금지). local line; line="$(printf '%s\n' "$all" | awk -v c="$DEV_CLUSTER" '$2==c{print; exit}')" if [ -z "$line" ]; then echo "로컬 클러스터 '$DEV_CLUSTER' (--name $DEV_NAME) 이(가) 없습니다."; echo; cmd_dev_ls die "콘솔 '등록명'(예 imac192)이 아니라 위 '--name' 값으로 지정하세요." fi rt="$(printf '%s' "$line" | awk '{print $1}')" fi local kenv=""; [ "$rt" = podman ] && kenv="KIND_EXPERIMENTAL_PROVIDER=podman" info "kind 클러스터 '$DEV_CLUSTER' 삭제 [$rt] (콘솔 등록명 '$(_local_reg_name)')…" env $kenv kind delete cluster --name "$DEV_CLUSTER" && echo "✓ 삭제 완료" # 이 클러스터의 릴레이 에이전트만 정리(pidfile .pid, 클러스터 단위). 다른 dev 클러스터는 건드리지 않음. local agd="${YAK_CONFIG_DIR:-$HOME/.config/yakcloud}/agent" local apf="$agd/$DEV_NAME.pid" if [ -f "$apf" ]; then local apid; apid="$(cat "$apf" 2>/dev/null)" [ -n "$apid" ] && kill "$apid" 2>/dev/null rm -f "$apf" info "릴레이 에이전트 정리($DEV_NAME)" fi # 공유 소스 컨테이너(yak-dev-*)는 여러 클러스터가 공유 → '마지막' dev 클러스터 삭제 시에만 정리. if env $kenv kind get clusters 2>/dev/null | grep -q '^yak-'; then info "다른 로컬 dev 클러스터가 남아 공유 소스 컨테이너는 유지" else local scs; scs="$("${rt:-docker}" ps -aq -f label=yakcloud.dev/shared=1 2>/dev/null)" [ -n "$scs" ] && { info "공유 소스 컨테이너 정리(마지막 dev 클러스터)…"; echo "$scs" | xargs "${rt:-docker}" rm -f >/dev/null 2>&1; } if [ -d "$agd" ]; then for pf in "$agd"/*.pid; do [ -f "$pf" ] || continue local lp; lp="$(cat "$pf" 2>/dev/null)"; [ -n "$lp" ] && kill "$lp" 2>/dev/null rm -f "$pf" done fi fi _dev_deregister } cmd_dev() { local sub="${1:-}"; [ "$#" -gt 0 ] && shift local name_given=0 case " $* " in *" --name"*|*" --cluster"*) name_given=1 ;; esac # 명시 지정 여부 # 프로젝트 폴더 + --name 미지정이면 매니페스트 environments.dev 값을 자동 주입 — 단, **비파괴적 프로젝트 명령만** # (up/deploy/status/clean). down(클러스터 삭제)은 자동 선택 안 함 → 엉뚱한 삭제 방지. case "$sub" in up|deploy|status|clean) if [ "$name_given" = 0 ] && [ -f yakcloud.yaml ]; then local _mn _mp _mw; _mn="$(_manifest_env dev cluster 2>/dev/null)"; _mp="$(_manifest_env dev port 2>/dev/null)"; _mw="$(_manifest_env dev workers 2>/dev/null)" if [ -n "$_mn" ]; then local _inj=(--name "$_mn") { [ -n "$_mp" ] && [ "$_mp" != "80" ]; } && _inj+=(--port "$_mp") case " $* " in *" --worker"*) : ;; *) [ -n "$_mw" ] && _inj+=(--workers "$_mw") ;; esac # 매니페스트 workers(0 포함) 존중; 없으면 _dev_resolve 기본 2 set -- "${_inj[@]}" "$@"; name_given=1 fi fi ;; esac _dev_resolve "$@" # --name/--port 파싱 → DEV_NAME/DEV_CLUSTER/DEV_CTX/DEV_HTTP_PORT/DEV_ARGS _podman_auth_fix "$(_dev_runtime)" # podman + Docker Desktop credHelpers(https://) 충돌 자동 우회(공개 이미지) case "$sub" in up) cmd_dev_up ;; ls|list) cmd_dev_ls ;; deploy|status|clean) [ -f yakcloud.yaml ] || die "프로젝트 폴더가 아닙니다 — 'yakcloud project init' 먼저" ensure_pyyaml local rt tag=""; rt="$(_dev_runtime)" for a in "${DEV_ARGS[@]+"${DEV_ARGS[@]}"}"; do case "$a" in -*) : ;; *) [ -z "$tag" ] && tag="$a" ;; esac; done local pysub="$sub"; [ "$sub" = clean ] && pysub="down" YAK_DEV_CONTEXT="$DEV_CTX" YAK_DEV_CLUSTER="$DEV_CLUSTER" YAK_DEV_NAME="$DEV_NAME" \ YAK_DEV_RUNTIME="${rt:-docker}" YAK_DEV_HTTP_PORT="$DEV_HTTP_PORT" \ YAK_REG_NAME="$(_local_reg_name)" TAG="${tag:-${TAG:-dev}}" python3 "$(_engine dev)" "$pysub" yakcloud.yaml [ "$sub" = deploy ] && _dev_register || true ;; down) cmd_dev_down "$name_given" ;; *) echo "yakcloud dev [--name N --port P]"; exit 1 ;; esac } # 전역 자산 설치/최신화 — 엔진(deploy/ctl/dev.py → $LIB_DIR) + Claude 스킬 + /yakcloud 슬래시명령. # 프로젝트엔 복제하지 않는다(전역 1벌, 버전 파편화 없음). Claude 자산 opt-out: YAKCLOUD_NO_CLAUDE_CMD=1. 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 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 [ "$ok" = 1 ] && echo "✓ 엔진 → $LIB_DIR (deploy/ctl/dev.py)" || echo " ⚠ 엔진 일부 다운로드 실패(무시 가능 — 필요 시 자동 재시도)" [ -z "${YAKCLOUD_NO_CLAUDE_CMD:-}" ] || return 0 local sdir="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}/yakcloud-deploy" local cdir="${CLAUDE_COMMANDS_DIR:-$HOME/.claude/commands}" mkdir -p "$sdir" "$cdir" 2>/dev/null || true curl -fsSL -H 'Cache-Control: no-cache' "$REPO/raw/branch/$BRANCH/.claude/skills/yakcloud-deploy/SKILL.md?nocache=$bust" -o "$sdir/SKILL.md" 2>/dev/null \ && echo "✓ 배포 스킬 → $sdir/SKILL.md" || echo " ⚠ 스킬 설치 건너뜀(무시 가능)" curl -fsSL -H 'Cache-Control: no-cache' "$REPO/raw/branch/$BRANCH/.claude/commands/yakcloud.md?nocache=$bust" -o "$cdir/yakcloud.md" 2>/dev/null \ && echo "✓ /yakcloud 슬래시 명령 → $cdir/yakcloud.md" || echo " ⚠ 슬래시 명령 설치 건너뜀(무시 가능)" } cmd_upgrade() { local self; self="$(command -v yakcloud 2>/dev/null || true)"; [ -n "$self" ] || self="$0" # 심볼릭 링크면 실체 파일을 갱신. if [ -L "$self" ]; then self="$(readlink -f "$self" 2>/dev/null || readlink "$self" 2>/dev/null || echo "$self")"; fi local before; before="$("$self" version 2>/dev/null || echo '?')" info "yakcloud CLI 강제 갱신… ($self, 현재 $before)" local tmp; tmp="$(mktemp)" # 캐시 우회: no-cache 헤더 + 캐시버스터 쿼리. Gitea/프록시가 raw 를 캐시해도 항상 최신 획득. local bust; bust="$(date +%s 2>/dev/null || echo 0)" curl -fsSL -H 'Cache-Control: no-cache' -H 'Pragma: no-cache' \ "$REPO/raw/branch/$BRANCH/bin/yakcloud?nocache=$bust" -o "$tmp" \ || { rm -f "$tmp"; die "다운로드 실패"; } head -1 "$tmp" | grep -q 'bash' || { rm -f "$tmp"; die "받은 파일이 올바르지 않습니다"; } chmod +x "$tmp" mv "$tmp" "$self" 2>/dev/null || { rm -f "$tmp"; die "설치 실패(권한) — 'DEST= 재설치' 또는 sudo"; } hash -r 2>/dev/null || true # 셸 명령 해시 캐시 초기화(새 바이너리 즉시 반영) echo "✓ 업그레이드 완료: $before → $("$self" version 2>/dev/null || echo yakcloud)" install_global_assets # 전역 엔진(deploy/ctl/dev) + 스킬 + /yakcloud 명령도 최신화 } # 기존 프로젝트의 플러밍(엔진·CI·스킬)만 최신화. 앱/매니페스트/Dockerfile 등 사용자 파일은 보존. cmd_project_update() { [ -f yakcloud.yaml ] || die "프로젝트 폴더가 아닙니다 — 'yakcloud project init' 먼저" command -v git >/dev/null 2>&1 || die "git 이 필요합니다." info "CI(.gitea) 최신화 + 전역 엔진/스킬 갱신(앱·매니페스트 보존)… ($REPO @ $BRANCH)" local tmp; tmp="$(mktemp -d)" git clone -q --depth 1 -b "$BRANCH" "$REPO.git" "$tmp/s" || { rm -rf "$tmp"; die "클론 실패"; } mkdir -p .gitea/workflows cp "$tmp/s/.gitea/workflows/deploy.yml" .gitea/workflows/ 2>/dev/null || true # 얇은 CI(전역 엔진 받아 실행) rm -rf "$tmp" # 엔진·스킬·명령은 전역(프로젝트에 복제 안 함). 옛 per-project 잔재 제거. # ⚠ .claude 를 통째로 지우지 않는다 — 프로젝트 로컬 .claude/settings.json(가드레일) 보존. skills/commands 만 제거. rm -rf scripts .yakcloud .claude/skills .claude/commands bin install.sh README.md DATA-SOURCES.md install_global_assets # 전역 엔진(deploy/ctl/dev) + 스킬 + /yakcloud 명령 최신화 # 가드레일 보장(없으면 생성, 있으면 사용자 편집 보존) — 옛 프로젝트도 update 시 yakcloud 강제 지침을 얻는다. _scaffold_yakcloud_guardrails 0 echo "✓ 최신화 완료 — .gitea CI + 전역 엔진/스킬 갱신 + yakcloud 가드레일(app.py·yakcloud.yaml·Dockerfile 그대로)" } usage() { cat < [--template S|M] [--nodes N] [--golden] 클러스터 생성(프로비저닝 완료까지 대기) cluster ls 내 클러스터 목록(이름·상태·노드·기본 도메인) ── 로컬 개발 클러스터(kind) — 개발 후 운영까지 직선 배포의 dev 구간 ── dev up [--workers N] 로컬 kind 생성 + 인그레스(80/443) — 최초 1회. 기본 CP1+워커2. --workers N 으로 조정(0=CP만, 매니페스트 environments.dev.workers 도 가능) dev deploy [tag] 매니페스트대로 로컬 리컨실(이미지 빌드+load, 소스/워크로드/인그레스; 콘솔 API 불필요) dev ls 로컬 클러스터 목록(kind 이름 / --name / 콘솔 등록명) — 삭제 전 대상 확인 dev status | dev clean 로컬 배포 상태 / 이 프로젝트 리소스 삭제 dev down [--name N] 로컬 kind 클러스터 삭제(여럿이면 --name 필수). 대상은 콘솔 등록명이 아니라 'dev ls' 의 --name project init [name] 빈 폴더에 스타터 스캐폴딩(대화형 마법사) + 자격 프롬프트 project deploy [tag] **개발 배포 = 로컬 kind**(environments.dev). 로컬 클러스터 없으면 자동 생성 후 배포(오프라인·무료·CI 불필요) project confirm [vX.Y.Z] 개발계 검증 확정(사인오프) — **운영 승격의 선행 게이트**. 그 버전 한정, 사람이 검증 후 실행 project promote [vX.Y.Z] --to val|prod **관리형** 검증/운영으로 승격 — 이미지 build+push(이미 있으면 재빌드 없이) 후 리컨실. 기본 --to prod. **prod 는 project confirm 필요** project info 프로젝트 개괄(매니페스트 + 라이브 상태) project check [--env dev|val|prod] dry-run(직접 API 계획만) project update 이 프로젝트의 엔진·CI·스킬만 최신화(앱·매니페스트 보존) upgrade 전역 yakcloud CLI 자기 갱신(최신 버전으로) ── 자격(한 곳에 안전 보관; 비밀=OS 키체인 암호화) ── login Gitea 계정 + YakCloud 배포토큰 입력·저장 config 저장된 설정 표시(비밀 마스킹) logout 저장 자격 삭제 ── 배포환경 설정(앱별) ── domain [wl] 도메인 등록 + 워크로드 할당(관리형=즉시/배포중이면 라이브) scale replicas 변경 set --image/--port/--health/--cpu/--mem/--path/--rewrite env KEY=VAL … [--secret KEY] [--unset KEY] datasource ls 클러스터에서 이용 가능한 소스 목록(이름·타입·내부/외부·상태) source add [plan] [--shared] | source rm (--shared=공유 외부, 백엔드가 격리 DB 민팅) bind | unbind 매니페스트: environments.{dev,val,prod}.cluster(val 선택, +prod.domains). env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…) EOF } load_config # 저장된 자격 로드(env 우선) case "${1:-help}" in project) shift case "${1:-}" in init) shift; cmd_project_init "$@" ;; deploy) shift; cmd_project_deploy "$@" ;; confirm) shift; cmd_project_confirm "$@" ;; promote) shift; cmd_project_promote "$@" ;; info) shift; ctl info "$@" ;; check|dry-run) shift; cmd_project_check "$@" ;; update) shift; cmd_project_update "$@" ;; *) echo "yakcloud project "; exit 1 ;; esac ;; cluster) shift; cmd_cluster "$@" ;; dev) shift; cmd_dev "$@" ;; login) cmd_login ;; config) cmd_config ;; logout) cmd_logout ;; datasource|ds) shift; ctl source "$@" ;; domain|scale|set|env|source|bind|unbind) ctl "$@" ;; upgrade|self-update) cmd_upgrade ;; version|-v|--version) echo "yakcloud $VERSION" ;; help|-h|--help|"") usage ;; *) echo "알 수 없는 명령: $1"; echo; usage; exit 1 ;; esac