cli v0.5.0: deploy가 도메인 리컨실 + 소스 405 액션형 에러 + --local 폴백/러너 프리플라이트 + 에러 힌트/pyyaml 가드; init에서 bin/install 제거; 문서(토큰권한·healthz·러너) 보강

This commit is contained in:
2026-08-27 18:43:37 +09:00
parent d0c27e349b
commit d19242029f
5 changed files with 173 additions and 80 deletions

View File

@ -111,3 +111,7 @@ git tag v0.1.0 && git push origin v0.1.0 # v* 태그만 배포 트리거(일
| `docker create` 실패(잡 시작 안 됨) | 러너 config에서 docker.sock **중복 마운트** → act 기본 마운트만(수동 제거) |
| 배포 중 `429 RATE_LIMITED` | CLI가 `retryAfterSec` 백오프로 재시도(내장) |
| 다른 러너가 잡 가져감 | 라벨 충돌 → 전용 라벨(`runs-on`) 사용 |
| 소스 프로비저닝 `405` | 배포 토큰엔 소스 **생성 권한 없음** → 콘솔에서 소스 만들고 `requires[].name`·`binds[].source`를 그 소스명으로. `yakcloud project check`가 미리 경고(READY 소스 목록 출력) |
| 태그 push 했는데 아무 일도 안 남 | 레포에 **러너 0개**(잡이 조용히 큐잉) → `yakcloud project deploy`가 배포 전 러너 프리플라이트 경고. 러너 없으면 `yakcloud project deploy --local`(로컬 빌드·push 후 API 배포) |
| 헬스체크 `/healthz` 404/이상 | 엣지가 `/healthz`를 **가로챔** → 배포 검증은 앱 실제 경로(`/`)로. 컨테이너 readiness엔 여전히 `health:` 사용 OK |
| `ModuleNotFoundError: yaml`(직접 배포) | `pip install pyyaml`(또는 `pip3 install --break-system-packages pyyaml`). CI 이미지엔 포함됨 |

View File

@ -1,54 +1,29 @@
# yakcloud-starter — YakCloud 프로젝트 기반 + CLI + Claude Code 스킬
# yakcloud 스타터 (프레임워크 중립)
빈 폴더에서 **한 명령으로 프로젝트를 초기화**하고, **태그 push로 자동 배포**하며, **배포 환경을 CLI로 상세 설정**한다.
프레임워크 중립 스타터 앱 + CI + 매니페스트 + **`yakcloud` CLI** + **Claude Code 스킬**(`.claude/skills/yakcloud-deploy/`) 포함.
의존성 0의 최소 HTTP 서버. **배포 계약**만 지키면 어떤 스택으로 바꿔도 된다:
- `PORT` 환경변수로 리슨(기본 8080)
- `GET /healthz` → 200 (매니페스트 `health` 경로)
- (선택) 바인딩된 소스는 `<ALIAS>_URL` 등 env로 접속
## CLI 설치 (1회)
## 로컬 실행
```sh
curl -fsSL https://gitea.yakenator.io/yakenator/yakcloud-starter/raw/branch/main/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
PORT=8080 python3 app.py
curl localhost:8080/healthz # {"ok":true}
curl localhost:8080/ # 앱 정보 + 연결된 소스 키
```
## 빈 폴더 → 배포까지
```sh
yakcloud project init my-app # 스타터+CI+매니페스트+Claude 스킬 스캐폴딩
cd my-app
# (앱 개발 — Claude Code: "yakcloud 로 배포 붙여줘")
yakcloud project info # 프로젝트 개괄(매니페스트 + 라이브 상태)
yakcloud project deploy # 커밋 + 태그 push (버전 미지정 시 자동 버전업) → 자동 배포
## 매니페스트 매핑 (`yakcloud.yaml`)
```yaml
workloads:
- name: web
build: ./ # 이 디렉터리(Dockerfile)를 CI 가 빌드
image: gitea.yakenator.io/<GITEA_USER>/<project>-web:${TAG}
port: 8080
health: /healthz
binds:
- { alias: db, source: appdb } # → 앱에서 os.environ["DB_URL"] 로 접속
```
> **Claude Code 흐름**: 빈 폴더에서 "프로젝트 초기화해줘" → `yakcloud project init` → 개발 → "yakcloud에 배포해줘" → `yakcloud project deploy`.
## 명령
```
project init [name] 빈 폴더 스캐폴딩
project deploy [vX.Y.Z] 커밋+태그 push → 자동 배포 (버전 생략 시 최신 태그 patch+1 자동 버전업)
project info 프로젝트 개괄(매니페스트 + 라이브 상태)
project check dry-run(직접 API 계획만)
─ 배포환경 설정(앱별): 매니페스트 수정 + 배포중이면 재빌드 없이 라이브 반영 ─
domain <fqdn> [wl] 도메인 등록 + 워크로드 할당(관리형=즉시 활성)
scale <wl> <n> replicas
set <wl> --image/--port/--health/--cpu/--mem/--path/--rewrite
env <wl> KEY=VAL … [--secret KEY] [--unset KEY]
source add <name> <type> [plan] | source rm <name>
bind <wl> <source> <alias> | unbind <wl> <alias>
```
필요 env: `YAKCLOUD_URL`, `YAKCLOUD_TOKEN`(배포토큰 yakd_…), (선택) `YAKCLOUD_CLUSTER`
## 배포 전 1회 준비
1. 콘솔 → 설정 → **배포 토큰** 발급(`yakd_…`).
2. Gitea 레포 만들고 `git remote add origin …`. **Secrets**: `YAKCLOUD_URL`, `YAKCLOUD_TOKEN`, `YAKCLOUD_CLUSTER`, `REGISTRY_TOKEN`.
3. `yakcloud.yaml``cluster`·`image`(`<GITEA_USER>`) 수정.
## 구성
```
bin/yakcloud # CLI 디스패처
install.sh # CLI 설치
scripts/yakcloud_deploy.py # 리컨실 배포 CLI(매니페스트→콘솔 API)
scripts/yakcloud_ctl.py # info/설정 엔진(매니페스트 편집 + 라이브 PATCH)
.claude/skills/yakcloud-deploy/ # Claude Code 스킬
app.py Dockerfile # 프레임워크 중립 스타터
yakcloud.yaml # 배포 매니페스트
.gitea/workflows/deploy.yml # 태그 push CI(매니페스트 구동)
DATA-SOURCES.md # 소스별 주입 env 표
```
## 다른 스택으로 교체
`app.py`(+`Dockerfile`)를 원하는 언어/프레임워크로 교체하되 위 3계약 유지.
소스별 주입 env 전체 목록은 `../reference/DATA-SOURCES.md` 참고.

View File

@ -18,7 +18,7 @@ set -uo pipefail # -e 미사용: 'test && action' 관용구가 값 없을 때
REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}"
BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}"
VERSION="0.4.0"
VERSION="0.5.0"
CONFIG_DIR="${YAKCLOUD_CONFIG_DIR:-$HOME/.config/yakcloud}"
CONFIG_FILE="$CONFIG_DIR/config"
@ -147,6 +147,8 @@ cmd_project_init() {
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))"
@ -192,28 +194,77 @@ _maybe_login_prompt() {
}
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 설정 후 다시"
|| die "git 원격(origin) 없음 — Gitea 레포 만들어 'git remote add origin <url>' + Secrets 설정 후 다시 (또는 'yakcloud project deploy --local')"
git fetch -q --tags origin 2>/dev/null || true
# 버전: 인자 있으면 그것, 없으면 최신 v* 태그에서 패치 +1(없으면 v0.1.0) — 배포마다 자동 버전업.
local ver="${1:-}"
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
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 에서 빌드·배포. 콘솔 앱 탭/도메인 확인."
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() {
@ -258,14 +309,16 @@ cmd_project_update() {
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 <<EOF
yakcloud $VERSION — YakCloud 프로젝트 CLI
project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포(버전 생략=자동 버전업)
project init [name] 빈 폴더에 스타터 스캐폴딩(대화형 마법사) + 자격 프롬프트
project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions(버전 생략=자동 버전업). 도메인도 리컨실.
project deploy --local 러너 없이 로컬 build/push/리컨실(CI 대안)
project info 프로젝트 개괄(매니페스트 + 라이브 상태)
project check dry-run(직접 API 계획만)
project update 이 프로젝트의 엔진·CI·스킬만 최신화(앱·매니페스트 보존)

View File

@ -22,7 +22,10 @@ import time
import urllib.error
import urllib.request
import yaml
try:
import yaml
except ModuleNotFoundError:
raise SystemExit("PyYAML 필요 — 'pip install pyyaml'(또는 'pip3 install --break-system-packages pyyaml') 후 다시 실행하세요.")
URL = os.environ["YAKCLOUD_URL"].rstrip("/")
TOKEN = os.environ["YAKCLOUD_TOKEN"]
@ -50,7 +53,13 @@ def api(method: str, path: str, body: dict | None = None, _retry: int = 5) -> di
wait = 2
time.sleep(max(1, int(wait)) + 1)
return api(method, path, body, _retry - 1)
raise SystemExit(f"[api] {method} {path} -> {e.code}: {payload[:400]}")
hint = {
401: " — 배포 토큰(YAKCLOUD_TOKEN)이 없거나 만료/오류",
403: " — 이 토큰 권한으로는 불가(엔드포인트 권한 확인)",
404: " — 경로의 id/이름 확인",
405: " — 이 배포 토큰/엔드포인트로 허용되지 않는 작업(콘솔에서 처리 필요)",
}.get(e.code, "")
raise SystemExit(f"[api] {method} {path} -> {e.code}{hint}: {payload[:300]}")
def unwrap(r):
@ -78,6 +87,10 @@ def cluster_deployments() -> list[dict]:
return unwrap(api("GET", f"/clusters/{CLUSTER}/deployments")) or []
def _ready_sources() -> str:
return ", ".join(s.get("name", "?") for s in cluster_services() if s.get("status") == "READY") or "(없음)"
def reconcile_source(req: dict) -> str | None:
name, stype, plan = req["name"], req["type"].upper(), req.get("plan", "small")
match = next((s for s in cluster_services() if s.get("name") == name), None)
@ -85,12 +98,25 @@ def reconcile_source(req: dict) -> str | None:
log(f"source '{name}' ({stype}) 이미 READY → 스킵 (id={match['id']})")
return match["id"]
if DRY:
log(f"source '{name}' ({stype}, {plan}) 없음 → 프로비저닝 예정")
# 배포 토큰으로는 소스 생성이 막힐 수 있으므로, 없으면 미리 경고(실패 예측).
if match:
log(f"source '{name}' 상태={match.get('status')} — READY 대기 필요")
else:
log(f"⚠ source '{name}' 없음 — 배포 토큰으론 생성 불가할 수 있음. "
f"콘솔에서 만들거나 requires[].name/binds[].source 를 기존 READY 소스로 지정. 현재 READY: {_ready_sources()}")
return None
if not match:
log(f"source '{name}' ({stype}, {plan}) 프로비저닝…")
api("POST", f"/clusters/{CLUSTER}/services",
{"clusterId": CLUSTER, "type": stype, "name": name, "mode": "local", "size": plan})
log(f"source '{name}' ({stype}, {plan}) 프로비저닝 시도")
try:
api("POST", f"/clusters/{CLUSTER}/services",
{"clusterId": CLUSTER, "type": stype, "name": name, "mode": "local", "size": plan})
except SystemExit as e:
# 405/403 등 — 이 토큰으로 생성 불가. 행동 가능한 안내로 전환.
raise SystemExit(
f"소스 '{name}' 자동 생성 불가(배포 토큰 권한). 콘솔에서 소스를 만든 뒤 "
f"requires[].name·binds[].source 를 기존 소스명으로 지정하세요.\n"
f" 현재 READY 소스: {_ready_sources()}\n"
f" (원인: {e})")
for _ in range(120): # ~10분
s = next((s for s in cluster_services() if s.get("name") == name), None)
if s and s.get("status") == "READY":
@ -102,6 +128,29 @@ def reconcile_source(req: dict) -> str | None:
raise SystemExit(f"source '{name}' READY 대기 초과")
def reconcile_domain(fqdn: str) -> None:
"""워크로드 expose host 를 도메인 레지스트리에 리컨실 — 없으면 등록.
관리형 도메인(yakenator.io/openrepublic.club/sapiens.inc 등)은 즉시 ACTIVE, 외부는 TXT 검증 안내."""
if not fqdn or fqdn == CLUSTER_HOST:
return # 클러스터 기본 도메인은 이미 등록·라우팅됨
doms = unwrap(api("GET", f"/clusters/{CLUSTER}/domains")) or []
if any(d.get("fqdn") == fqdn for d in doms):
log(f"domain '{fqdn}' 이미 등록됨")
return
if DRY:
log(f"domain '{fqdn}' 없음 → 등록 예정(관리형=즉시 ACTIVE, 외부=TXT 검증)")
return
try:
d = unwrap(api("POST", f"/clusters/{CLUSTER}/domains", {"fqdn": fqdn}))
except SystemExit as e:
log(f"⚠ domain '{fqdn}' 등록 실패 — {e}")
return
log(f"domain '{fqdn}' 등록: status={d.get('status')} cert={d.get('certStatus')}")
v = d.get("verify")
if v:
log(f' 외부 도메인 — DNS 에 TXT 추가 후 검증: {v["host"]} TXT "{v["value"]}"')
def deploy_workload(w: dict) -> tuple[str | None, bool]:
image = w["image"].replace("${TAG}", TAG)
ex = w.get("expose", {}) or {}
@ -126,6 +175,9 @@ def deploy_workload(w: dict) -> tuple[str | None, bool]:
env = [{"key": k, "value": v} for k, v in env.items()]
body["env"] = [{"key": e["key"], "value": str(e.get("value", "")),
"secret": bool(e.get("secret", False))} for e in env]
# 도메인 리컨실 — expose 에 명시한 host(들)를 레지스트리에 등록(없으면). 배포 하나로 '도달 가능한 앱'이 되게.
for h in (ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])):
reconcile_domain(h)
if DRY:
log(f"deploy '{w['name']}' 예정: image={image} port={body['port']} path={body['path']} "
f"replicas={body['replicasDesired']} hosts={hosts or body.get('exposeHost')}")

View File

@ -1,24 +1,33 @@
# YakCloud 선언적 배포 매니페스트 — 스타터(최소, 데이터소스 없이 단독 배포 가능)
# 태그(v*) push → Gitea Actions → build/push → 리컨실 배포. 자세한 절차: .claude/skills/yakcloud-deploy/SKILL.md
# YakCloud 선언적 배포 매니페스트 (범용 템플릿)
# 태그(v*) push → Gitea Actions → 이 매니페스트로 배포:
# 1) requires 리컨실 — 논리 이름의 소스가 READY 면 스킵, 없으면 type/plan 대로 프로비저닝 후 Ready 대기
# 2) workloads 빌드/배포 — build 컨텍스트를 image 로 빌드·push, 각 워크로드가 소스를 alias 로 바인딩
# 3) 앱 탭 등록 + 데이터소스 연결(<ALIAS>_URL 등 env 자동 주입)
apiVersion: yakcloud/v1
project: yakcloud-starter
project: my-app # 프로젝트 이름(이미지 경로/표시에 사용) — 소문자·숫자·하이픈
# 배포 대상 클러스터 — 이름 또는 콘솔 id 로 변경. (YAKCLOUD_CLUSTER 시크릿이 있으면 그게 우선)
# 배포 대상 클러스터 — 이름 또는 콘솔 id. (우선순위: YAKCLOUD_CLUSTER 시크릿 > 이 필드)
cluster: my-service-19
# 데이터 소스 필요하면 추가(없으면 빈 목록):
# - { name: appdb, type: postgresql, plan: small }
requires: []
# 필요한 데이터 소스(논리 이름). 없으면 [] 로 둬도 됨.
# type postgresql·mysql·mariadb·mongodb·redis·minio·rabbitmq·solr·oracle, plan small·medium·large
# ⚠ 이름이 클러스터의 기존 READY 소스와 일치하면 스킵·바인딩. 없을 때 자동 프로비저닝은
# "배포 토큰에 소스 생성 권한이 있을 때"만 됩니다(없으면 405). 권한이 없으면 콘솔에서 소스를
# 먼저 만들고 이 name 을 그 소스명으로 지정하세요. ('yakcloud project check' 로 미리 확인)
requires:
- { name: appdb, type: postgresql, plan: small }
# 워크로드(앱 컨테이너). CI 는 각 workload.build 를 도커 빌드해 image 로 push 한다(${TAG}=릴리스 태그).
workloads:
- name: web
build: ./ # 루트 Dockerfile 을 CI 가 빌드
image: gitea.yakenator.io/yakenator/yakcloud-starter-web:${TAG} # <GITEA_USER>/<project>-web 로 변경
build: ./ # 도커 빌드 컨텍스트(Dockerfile 위치). 예: ./ 또는 ./api
image: gitea.yakenator.io/CHANGE_ME_GITEA_USER/my-app-web:${TAG}
port: 8080
replicas: 2 # ≥2 → 무중단 롤링
replicas: 2 # ≥2 → 무중단 롤링(readiness 게이트)
health: /healthz
resources: { cpu: 25m, mem: 64Mi }
expose: { path: /, rewrite: false } # host: <등록도메인> 지정 시 도메인으로 노출. 여러 개는 hosts: [a, b]
# 데이터 소스를 쓰면 requires 에 추가하고 아래처럼 바인딩(앱은 DB_URL 등으로 접속):
# binds:
# - { alias: db, source: appdb }
resources: { cpu: 25m, mem: 96Mi }
# host 지정 시 등록 도메인으로 노출(미지정=클러스터 기본 도메인). 여러 개는 hosts: [a, b]
expose: { path: /, rewrite: false }
binds:
# source=위 requires 이름, alias=env 프리픽스(대문자화). 예 alias=db → DB_URL/DB_HOST/DB_PORT/…
- { alias: db, source: appdb }