#!/usr/bin/env bash # yakcloud — YakCloud 프로젝트 CLI. 개발·운영 모두 yakcloud 클러스터에서(로컬 dev 없음). # # yakcloud project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬) # yakcloud project deploy [vX.Y.Z] 커밋 + 태그 push → CI 빌드 → 개발 클러스터 배포 # yakcloud project promote [vX.Y.Z] 그 이미지를 재빌드 없이 운영 클러스터로 승격 + 운영 도메인 # yakcloud project info 프로젝트 개괄(매니페스트 + dev/prod 라이브 상태) # yakcloud project check [--prod] dry-run(직접 API 계획만) # # yakcloud domain [wl] · scale · set · env · datasource ls · source · bind/unbind # # 매니페스트: environments.{dev,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.11.0" CONFIG_DIR="${YAKCLOUD_CONFIG_DIR:-$HOME/.config/yakcloud}" CONFIG_FILE="$CONFIG_DIR/config" die() { echo "✗ $*" >&2; exit 1; } 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 scripts/yakcloud_ctl.py ] || die "프로젝트 폴더가 아닙니다 — 'yakcloud project init' 로 초기화하세요." ensure_pyyaml python3 scripts/yakcloud_ctl.py "$@" } # ── 자격 저장 ── 비밀(배포토큰·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" </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" # CLI 배포 파일(전역 yakcloud 사용)은 사용자 프로젝트에 불필요 → 스캐폴드에서 제거. rm -rf bin install.sh [ -n "$name" ] && [ -f yakcloud.yaml ] && { sed -i.bak "s/^project: .*/project: $name/" 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 · yakcloud project deploy" } # 매니페스트 채우기 마법사 — ctl 은 API 미접속(YAKCLOUD_TOKEN 비움)으로 매니페스트만 편집. _init_wizard() { local dflt_app="$1" app devc prodc pdom port health cpu mem reps path kv echo "── 프로젝트 설정 (엔터=기본값) ──" printf "앱 이름 [%s]: " "$dflt_app"; read -r app; app="${app:-$dflt_app}" printf "개발 클러스터(이름 또는 콘솔 id): "; read -r devc 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/^project: .*/project: $app/" yakcloud.yaml && rm -f yakcloud.yaml.bak sed -i.bak "s/yakcloud-starter-web/${app}-web/" yakcloud.yaml 2>/dev/null; rm -f yakcloud.yaml.bak [ -n "${GITEA_USER:-}" ] && { sed -i.bak "s#/yakenator/#/${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; } ensure_pyyaml YAKCLOUD_TOKEN= python3 scripts/yakcloud_ctl.py set web --port "$port" --health "$health" --cpu "$cpu" --mem "$mem" --path "$path" >/dev/null 2>&1 || true YAKCLOUD_TOKEN= python3 scripts/yakcloud_ctl.py scale web "$reps" >/dev/null 2>&1 || true echo "환경변수(KEY=VALUE, 빈 줄=종료):" while :; do printf " env> "; read -r kv || break; [ -z "$kv" ] && break YAKCLOUD_TOKEN= python3 scripts/yakcloud_ctl.py env web "$kv" >/dev/null 2>&1 || echo " (형식: KEY=VALUE)" done echo "✓ 매니페스트 반영: app=$app dev=${devc:-<미지정>} 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 ' + 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 scripts/yakcloud_deploy.py yakcloud.yaml echo "✓ 로컬 배포 완료(tag=$tag) — CI 없이 직접 리컨실." } cmd_project_check() { [ -f scripts/yakcloud_deploy.py ] || 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 scripts/yakcloud_deploy.py yakcloud.yaml --dry-run } # 운영 승격 — 개발에서 검증한 그 태그의 이미지를 **재빌드 없이** 운영 클러스터로 리컨실 + 운영 도메인 부착. cmd_project_promote() { [ -f scripts/yakcloud_deploy.py ] || die "'yakcloud project init' 먼저" : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요 — 'yakcloud login'}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요 — 'yakcloud login'}" ensure_pyyaml local tag=""; for a in "$@"; do case "$a" in -*) : ;; *) [ -z "$tag" ] && tag="$a" ;; 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 "운영 승격: $tag → prod 환경(재빌드 없이 개발에서 빌드된 그 이미지를 운영 클러스터로 리컨실)" YAKCLOUD_ENV=prod TAG="$tag" python3 scripts/yakcloud_deploy.py yakcloud.yaml echo "✓ 승격 완료: $tag → 운영 클러스터 + 운영 도메인(기본 도메인은 자동)" } # 전역 CLI 자기 갱신 — 버전이 같아도 항상 최신 파일을 강제로 다시 받아 덮어쓴다(캐시 우회). # Claude Code 전역 슬래시 명령(/yakcloud) 설치/최신화 — 설치·업그레이드 시 함께 실행. # opt-out: YAKCLOUD_NO_CLAUDE_CMD=1. 실패해도 CLI 동작엔 지장 없음(비치명적). install_claude_command() { [ -z "${YAKCLOUD_NO_CLAUDE_CMD:-}" ] || return 0 local dir="${CLAUDE_COMMANDS_DIR:-$HOME/.claude/commands}" local bust; bust="$(date +%s 2>/dev/null || echo 0)" if mkdir -p "$dir" 2>/dev/null && \ curl -fsSL -H 'Cache-Control: no-cache' -H 'Pragma: no-cache' \ "$REPO/raw/branch/$BRANCH/.claude/commands/yakcloud.md?nocache=$bust" -o "$dir/yakcloud.md" 2>/dev/null; then echo "✓ Claude Code 전역 슬래시 명령 → $dir/yakcloud.md (Claude Code에서 /yakcloud)" else echo " ⚠ /yakcloud 슬래시 명령 설치 건너뜀(무시 가능) — 터미널 yakcloud 는 정상" fi } 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_claude_command # /yakcloud 슬래시 명령도 최신화 } # 기존 프로젝트의 플러밍(엔진·CI·스킬)만 최신화. 앱/매니페스트/Dockerfile 등 사용자 파일은 보존. cmd_project_update() { [ -f yakcloud.yaml ] || die "프로젝트 폴더가 아닙니다 — 'yakcloud project init' 먼저" command -v git >/dev/null 2>&1 || die "git 이 필요합니다." info "프로젝트 플러밍 최신화(앱·매니페스트 보존)… ($REPO @ $BRANCH)" local tmp; tmp="$(mktemp -d)" git clone -q --depth 1 -b "$BRANCH" "$REPO.git" "$tmp/s" || { rm -rf "$tmp"; die "클론 실패"; } 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/.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 cp -R "$tmp/s/.claude/skills/yakcloud-deploy" .claude/skills/ 2>/dev/null || true rm -rf "$tmp" rm -rf bin install.sh # 옛 스캐폴드 잔재(CLI 배포 파일) 정리 echo "✓ 최신화 완료 — scripts/·.gitea/·.claude 스킬 갱신(app.py·yakcloud.yaml·Dockerfile 그대로)" } usage() { cat < [wl] 도메인 등록 + 워크로드 할당(관리형=즉시/배포중이면 라이브) scale replicas 변경 set --image/--port/--health/--cpu/--mem/--path/--rewrite env KEY=VAL … [--secret KEY] [--unset KEY] datasource ls 클러스터에서 이용 가능한 소스 목록(이름·타입·내부/외부·상태) source add [plan] | source rm bind | unbind 매니페스트: environments.{dev,prod}.cluster(+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 "; exit 1 ;; esac ;; 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