Files
yakcloud-starter/bin/yakcloud

709 lines
41 KiB
Bash
Executable File

#!/usr/bin/env bash
# yakcloud — YakCloud 프로젝트 CLI. 개발=로컬 kind(직선 dev) 또는 yakcloud 클러스터, 검증·운영=yakcloud 클러스터(val 선택).
#
# yakcloud cluster create <name> [--template S|M] [--nodes N] [--golden] 클러스터 생성(완료까지 대기)
# yakcloud cluster ls 내 클러스터 목록
# yakcloud project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
# yakcloud project deploy [vX.Y.Z] 커밋 + 태그 push → CI 빌드 → 개발 클러스터 배포
# yakcloud project promote [vX.Y.Z] [--to val|prod] 그 이미지를 재빌드 없이 상위 환경으로 승격(개발→검증→운영)
# yakcloud project info 프로젝트 개괄(매니페스트 + 라이브 상태)
# yakcloud project check [--env dev|val|prod] dry-run(직접 API 계획만)
#
# yakcloud domain <fqdn> [wl] · scale · set · env · datasource ls · source · bind/unbind
#
# 매니페스트: environments.{dev,val,prod}.cluster (val 선택, + 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.18.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" <<EOF
YAKCLOUD_URL="$1"
YAKCLOUD_CLUSTER="$2"
GITEA_URL="$3"
GITEA_USER="$4"
EOF
); chmod 600 "$CONFIG_FILE"
}
cmd_login() {
echo "yakcloud 자격 설정 (비밀=OS 키체인[$_KC] 암호화, 그 외=$CONFIG_FILE)"
local url tok clus gurl guser gtok
printf "YakCloud URL [%s]: " "${YAKCLOUD_URL:-https://console.yakenator.io}"; read -r url; url="${url:-${YAKCLOUD_URL:-https://console.yakenator.io}}"
printf "YakCloud 배포 토큰(yakd_…): "; read -rs tok; echo
printf "기본 클러스터(선택) [%s]: " "${YAKCLOUD_CLUSTER:-}"; read -r clus; clus="${clus:-${YAKCLOUD_CLUSTER:-}}"
printf "Gitea URL [%s]: " "${GITEA_URL:-https://gitea.yakenator.io}"; read -r gurl; gurl="${gurl:-${GITEA_URL:-https://gitea.yakenator.io}}"
printf "Gitea 사용자 [%s]: " "${GITEA_USER:-}"; read -r guser; guser="${guser:-${GITEA_USER:-}}"
printf "Gitea 토큰: "; read -rs gtok; echo
_save_nonsecret "$url" "$clus" "$gurl" "$guser"
[ -n "$tok" ] && secret_set YAKCLOUD_TOKEN "$tok"
[ -n "$gtok" ] && secret_set GITEA_TOKEN "$gtok"
echo "✓ 저장 완료 — 비밀은 키체인($_KC), 설정은 $CONFIG_FILE (600)"
}
cmd_config() {
[ -f "$CONFIG_FILE" ] || { echo "설정 없음 — 'yakcloud login'"; return 0; }
echo "설정: $CONFIG_FILE (비밀=키체인 $_KC)"
grep -vE '^\s*#' "$CONFIG_FILE" | sed 's/^/ /'
local t; t="$(secret_get YAKCLOUD_TOKEN)"; [ -n "$t" ] && echo " YAKCLOUD_TOKEN=$(printf '%s' "$t" | cut -c1-9)…(키체인)"
local g; g="$(secret_get GITEA_TOKEN)"; [ -n "$g" ] && echo " GITEA_TOKEN=••••••(키체인)"
}
cmd_logout() { rm -f "$CONFIG_FILE"; secret_del YAKCLOUD_TOKEN; secret_del GITEA_TOKEN; echo "✓ 자격 삭제(설정 파일 + 키체인)"; }
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
[ -n "$name" ] && { mkdir -p "$name"; cd "$name"; }
if [ -n "$(ls -A . 2>/dev/null | grep -v '^\.git$' || true)" ]; then
die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud project init <name>'."
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=스킬·명령(전역) · README/DATA-SOURCES=스타터 문서.
rm -rf bin install.sh .yakcloud .claude README.md DATA-SOURCES.md
# 전역 엔진/스킬/명령 준비(최초 init 시에도 보장).
install_global_assets 2>/dev/null || true
# 프로젝트명을 project·워크로드(배포)명·image 에 일괄 반영(고정 'web' 대신 프로젝트명).
[ -n "$name" ] && [ -f yakcloud.yaml ] && { sed -i.bak "s/my-app/$name/g" yakcloud.yaml && rm -f yakcloud.yaml.bak; }
git rev-parse --git-dir >/dev/null 2>&1 || git init -q
echo "✓ 스캐폴딩 완료 ($(pwd))"
# 대화형 마법사(TTY + not --yes): 클러스터·앱·포트·헬스·CPU·메모리·replicas·env 를 물어 매니페스트에 반영.
if [ "$noninteractive" = 0 ] && [ -t 0 ]; then
_init_wizard "${name:-$(basename "$(pwd)")}"
_maybe_login_prompt
fi
echo " 다음: yakcloud project info · yakcloud domain <fqdn> · yakcloud project deploy"
}
# 매니페스트 채우기 마법사 — ctl 은 API 미접속(YAKCLOUD_TOKEN 비움)으로 매니페스트만 편집.
_init_wizard() {
local dflt_app="$1" app devc valc prodc pdom port health cpu mem reps path kv
echo "── 프로젝트 설정 (엔터=기본값) · 개발 → 검증 → 운영 3단계 ──"
printf "앱 이름 [%s]: " "$dflt_app"; read -r app; app="${app:-$dflt_app}"
printf "개발 클러스터(이름 또는 콘솔 id): "; read -r devc
printf "검증 클러스터(선택, 엔터=생략): "; read -r valc
printf "운영 클러스터 [%s]: " "${devc:-개발과 동일}"; read -r prodc; prodc="${prodc:-$devc}"
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; }
[ -n "$devc" ] && { sed -i.bak "s#my-dev-cluster#$devc#" 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
YAKCLOUD_TOKEN= python3 "$(_engine ctl)" set "$app" --port "$port" --health "$health" --cpu "$cpu" --mem "$mem" --path "$path" >/dev/null 2>&1 || true
YAKCLOUD_TOKEN= python3 "$(_engine ctl)" scale "$app" "$reps" >/dev/null 2>&1 || true
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=${devc:-<미지정>} 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
}
cmd_project_deploy() {
local local_mode=0 ver=""
for a in "$@"; do case "$a" in --local) local_mode=1 ;; *) [ -z "$ver" ] && ver="$a" ;; esac; done
[ -f yakcloud.yaml ] || die "yakcloud.yaml 없음 — 'yakcloud project init' 먼저"
if [ "$local_mode" = 1 ]; then cmd_project_deploy_local "$ver"; return; fi
git rev-parse --git-dir >/dev/null 2>&1 || git init -q
git remote get-url origin >/dev/null 2>&1 \
|| die "git 원격(origin) 없음 — Gitea 레포 만들어 'git remote add origin <url>' + Secrets 설정 후 다시 (또는 'yakcloud project deploy --local')"
git fetch -q --tags origin 2>/dev/null || true
if [ -z "$ver" ]; then
local last; last="$(git tag -l 'v*' --sort=-v:refname 2>/dev/null | head -1)"
if [ -n "$last" ]; then ver="$(printf '%s' "$last" | awk -F. 'BEGIN{OFS="."} {$NF=$NF+1; print}')"; else ver="v0.1.0"; fi
fi
case "$ver" in v*) : ;; *) ver="v$ver" ;; esac
_runner_preflight # 러너 없으면 경고(비차단)
info "배포 버전: $ver (커밋 + 태그 push)"
git add -A; git commit -q -m "deploy $ver" 2>/dev/null || true
git push -q origin HEAD 2>/dev/null || true
git tag "$ver" 2>/dev/null || die "태그 $ver 이미 존재 — 'yakcloud project deploy <다음버전>'"
git push -q origin "$ver"
echo "$ver push — Gitea Actions 에서 빌드·배포. Actions 탭에서 결과 확인."
echo " 실패/러너 없음이면: yakcloud project deploy --local (러너 없이 로컬 빌드/푸시/리컨실)"
}
# 러너 프리플라이트(best-effort) — origin 이 Gitea 면 레포 러너 유무 확인. 없으면 경고(비차단).
_runner_preflight() {
local url base owner_repo n
url="$(git remote get-url origin 2>/dev/null)" || return 0
base="${GITEA_URL:-https://gitea.yakenator.io}"
case "$url" in *"${base#https://}"*|*gitea.yakenator.io*) : ;; *) return 0 ;; esac
[ -n "${GITEA_TOKEN:-}" ] || return 0
owner_repo="$(printf '%s' "$url" | sed -E 's#^[a-z]+://[^@]*@?[^/]+/##; s#\.git$##')"
n="$(curl -fsS -H "Authorization: token $GITEA_TOKEN" "$base/api/v1/repos/$owner_repo/actions/runners" 2>/dev/null \
| python3 -c 'import sys,json
try:
d=json.load(sys.stdin); print(len(d if isinstance(d,list) else d.get("runners",[])))
except Exception: print(-1)' 2>/dev/null || echo -1)"
if [ "$n" = "0" ]; then
echo " ⚠ 이 레포에 등록된 Actions 러너가 없습니다 — 공유 러너가 라벨을 못 맞추면 태그 push 잡이 조용히 실패할 수 있습니다."
echo " 확실히 하려면: yakcloud project deploy --local"
fi
}
# CI 없이 로컬에서 build/push/리컨실 — 러너가 없거나 죽었을 때의 확실한 대안.
cmd_project_deploy_local() {
: "${YAKCLOUD_URL:?YAKCLOUD_URL 필요 — 'yakcloud login'}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요 — 'yakcloud login'}"
command -v docker >/dev/null 2>&1 || die "docker 필요(--local 로컬 빌드/푸시)"
ensure_pyyaml
local tag="${1:-}"; [ -z "$tag" ] && tag="dev-$(date +%Y%m%d-%H%M%S)"; case "$tag" in v*|dev-*) : ;; *) tag="v$tag" ;; esac
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" | docker login "$reg" -u "$GITEA_USER" --password-stdin >/dev/null 2>&1 \
|| info "docker login 경고($reg) — 레지스트리 push 실패 가능(yakcloud login 확인)"
else
info "Gitea 자격 없음 — 'yakcloud login' 후 --local 권장(레지스트리 push)"
fi
info "로컬 빌드/푸시 (tag=$tag)…"
TAG="$tag" python3 - <<'PY'
import os, subprocess, yaml
tag = os.environ["TAG"]; m = yaml.safe_load(open("yakcloud.yaml"))
for w in m.get("workloads", []) or []:
ctx = w.get("build")
if not ctx:
continue
img = w["image"].replace("${TAG}", tag)
print("▸ build %s: %s -> %s" % (w["name"], ctx, img), flush=True)
subprocess.check_call(["docker", "build", "-t", img, ctx])
subprocess.check_call(["docker", "push", img])
PY
info "리컨실 배포(콘솔 API)…"
TAG="$tag" python3 "$(_engine deploy)" yakcloud.yaml
echo "✓ 로컬 배포 완료(tag=$tag) — CI 없이 직접 리컨실."
}
cmd_project_check() {
[ -f yakcloud.yaml ] || die "'yakcloud project init' 먼저"
: "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요}"
ensure_pyyaml
local env=""; for a in "$@"; do case "$a" in --env=*) env="${a#--env=}" ;; --prod) env=prod ;; esac; done
YAKCLOUD_ENV="${env:-dev}" python3 "$(_engine deploy)" yakcloud.yaml --dry-run
}
# 운영 승격 — 개발에서 검증한 그 태그의 이미지를 **재빌드 없이** 운영 클러스터로 리컨실 + 운영 도메인 부착.
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
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)"
fi
[ -n "$tag" ] || die "승격할 태그가 없습니다 — 'yakcloud project promote vX.Y.Z' (개발 배포된 태그)"
case "$tag" in v*) : ;; *) tag="v$tag" ;; esac
info "'$env' 승격: $tag → '$env' 환경(재빌드 없이 개발에서 빌드된 그 이미지를 리컨실)"
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 <name> [--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 <ls|create <name> [--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', 등록명 <host>, 인그레스 80/443
# --name dev2 --port N → kind 'yak-dev2', ctx 'kind-yak-dev2', 등록명 <host>-dev2, 인그레스 N (80은 기본 클러스터 몫)
# 나머지 인자(태그 등)는 DEV_ARGS 배열에 남긴다(--name 값이 태그로 오인되지 않게 여기서 소비).
_dev_resolve() {
DEV_NAME="dev"; DEV_HTTP_PORT="80"; DEV_PORT_EXPLICIT=0; DEV_ARGS=()
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 ;;
*) DEV_ARGS+=("$1"); shift ;;
esac
done
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
info "kind 클러스터 '$DEV_CLUSTER' 생성 ($rt) — 인그레스 http 포트 ${DEV_HTTP_PORT}"
local ports=" - { containerPort: 80, hostPort: ${DEV_HTTP_PORT}, protocol: TCP }"
[ "$DEV_HTTP_PORT" = "80" ] && ports="$ports
- { containerPort: 443, hostPort: 443, protocol: TCP }"
env $kenv kind create cluster --name "$DEV_CLUSTER" --config - <<EOF || die "kind 생성 실패(포트 ${DEV_HTTP_PORT} 가 이미 사용 중일 수 있음)"
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
${ports}
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'."
# 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)은 <host>, 이름 붙은 클러스터는 <host>-<name>(콘솔에 구분 표시).
[ -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
}
cmd_dev() {
local sub="${1:-}"; [ "$#" -gt 0 ] && shift
_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 ;;
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)
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 "✓ 삭제 완료"
# 이 클러스터의 릴레이 에이전트만 정리(pidfile <name>.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; }
# 마지막 클러스터 → 남은 모든 에이전트 pidfile 정리(구 <ns>.pid 명명 포함 — 전부 고아).
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
;;
*) echo "yakcloud dev <up [--name N --port P] | deploy [--name N] [tag] | status [--name N] | clean | down [--name N]>"; 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=<dir> 재설치' 또는 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 잔재 제거.
rm -rf scripts .yakcloud .claude bin install.sh README.md DATA-SOURCES.md
install_global_assets # 전역 엔진(deploy/ctl/dev) + 스킬 + /yakcloud 명령 최신화
echo "✓ 최신화 완료 — .gitea CI + 전역 엔진/스킬 갱신(app.py·yakcloud.yaml·Dockerfile 그대로)"
}
usage() {
cat <<EOF
yakcloud $VERSION — YakCloud 프로젝트 CLI (개발·검증·운영 모두 yakcloud 클러스터, val 선택)
cluster create <name> [--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 대안, 개발 클러스터)
project promote [vX.Y.Z] [--to val|prod] 그 이미지를 재빌드 없이 상위 환경으로 승격(개발→검증→운영). 기본 --to prod
project info 프로젝트 개괄(매니페스트 + 라이브 상태)
project check [--env dev|val|prod] dry-run(직접 API 계획만)
project update 이 프로젝트의 엔진·CI·스킬만 최신화(앱·매니페스트 보존)
upgrade 전역 yakcloud CLI 자기 갱신(최신 버전으로)
── 자격(한 곳에 안전 보관; 비밀=OS 키체인 암호화) ──
login Gitea 계정 + YakCloud 배포토큰 입력·저장
config 저장된 설정 표시(비밀 마스킹)
logout 저장 자격 삭제
── 배포환경 설정(앱별) ──
domain <fqdn> [wl] 도메인 등록 + 워크로드 할당(관리형=즉시/배포중이면 라이브)
scale <wl> <n> replicas 변경
set <wl> --image/--port/--health/--cpu/--mem/--path/--rewrite
env <wl> KEY=VAL … [--secret KEY] [--unset KEY]
datasource ls 클러스터에서 이용 가능한 소스 목록(이름·타입·내부/외부·상태)
source add <name> <type> [plan] [--shared] | source rm <name> (--shared=공유 외부, 백엔드가 격리 DB 민팅)
bind <wl> <source> <alias> | unbind <wl> <alias>
매니페스트: 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 "$@" ;;
promote) shift; cmd_project_promote "$@" ;;
info) shift; ctl info "$@" ;;
check|dry-run) shift; cmd_project_check "$@" ;;
update) shift; cmd_project_update "$@" ;;
*) echo "yakcloud project <init|deploy|promote|info|check|update>"; 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