CLI 재구성: yakcloud project init/deploy/info/check + 설정동사(domain/scale/set/env/source/bind) + 자동 버전업 + yakcloud_ctl 엔진 + deploy CLI env 지원

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 17:22:44 +09:00
parent 8067507e6b
commit c161da3b65
9 changed files with 933 additions and 123 deletions

View File

@ -17,12 +17,23 @@ YakCloud 콘솔 API로 **데이터소스 리컨실 + 워크로드 배포 + 바
- "데이터소스(Postgres/Mongo/…) 연결/바인딩" / "커스텀 도메인으로 노출" - "데이터소스(Postgres/Mongo/…) 연결/바인딩" / "커스텀 도메인으로 노출"
## CLI 가 있으면 우선 사용 ## CLI 가 있으면 우선 사용
`yakcloud` CLI 가 설치돼 있으면(명령 `command -v yakcloud`) 스캐폴딩/배포 이걸로 처리한다: `yakcloud` CLI 가 설치돼 있으면(`command -v yakcloud`) 스캐폴딩/배포/환경설정을 이걸로 처리한다.
- **"프로젝트 초기화"** → 빈 폴더에서 `yakcloud init [name]` (스타터+CI+매니페스트+이 스킬을 내려받음)
- **"yakcloud 에 배포"** → `yakcloud deploy vX.Y.Z` (커밋+태그 push → Gitea Actions)
- 사전 점검 → `yakcloud check` (dry-run)
설치: `curl -fsSL https://gitea.yakenator.io/yakenator/yakcloud-starter/raw/branch/main/install.sh | sh` 설치: `curl -fsSL https://gitea.yakenator.io/yakenator/yakcloud-starter/raw/branch/main/install.sh | sh`
**프로젝트 라이프사이클**
- **"프로젝트 초기화"** → 빈 폴더에서 `yakcloud project init [name]` (스타터+CI+매니페스트+이 스킬)
- **"yakcloud 에 배포"** → `yakcloud project deploy` (버전 미지정 시 **자동 버전업**: 최신 태그 patch+1 → 태그 push)
- **"프로젝트 개괄"** → `yakcloud project info` (매니페스트 + 라이브 상태)
- 사전 점검 → `yakcloud project check` (dry-run)
**배포환경 설정(앱별) — 매니페스트 수정 + 배포중이면 재빌드 없이 라이브 반영**
- `yakcloud domain <fqdn> [wl]` 도메인 등록 + 워크로드 할당
- `yakcloud scale <wl> <n>` replicas
- `yakcloud set <wl> --image/--port/--health/--cpu/--mem/--path/--rewrite`
- `yakcloud env <wl> KEY=VAL … [--secret KEY] [--unset KEY]`
- `yakcloud source add <name> <type> [plan]` · `yakcloud source rm <name>`
- `yakcloud bind <wl> <source> <alias>` · `yakcloud unbind <wl> <alias>`
CLI 가 없거나 세밀 조정이 필요하면 아래 수동 절차를 따른다. CLI 가 없거나 세밀 조정이 필요하면 아래 수동 절차를 따른다.
## 에이전트 실행 절차 ## 에이전트 실행 절차

135
.claude/skills/yakcloud-deploy/assets/bin/yakcloud Normal file → Executable file
View File

@ -1,25 +1,47 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# yakcloud — YakCloud 프로젝트 CLI. 빈 폴더 초기화(스타터+스킬) + 태그 push 자동배포. # yakcloud — YakCloud 프로젝트 CLI. 빈 폴더 초기화(스타터+스킬), 배포, 배포환경 상세 설정.
# yakcloud init [name] 현재(빈) 폴더 또는 name/ 에 스타터 스캐폴딩(.claude 스킬 포함) #
# yakcloud deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포 # yakcloud project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
# yakcloud check dry-run(직접 API, 계획만; YAKCLOUD_URL/TOKEN 필요) # yakcloud project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포
# yakcloud project info 프로젝트 개괄(매니페스트 + 라이브 상태)
# yakcloud project check dry-run(직접 API 계획만)
#
# yakcloud domain <fqdn> [wl] 도메인 등록 + 워크로드에 할당(관리형=즉시, 배포중이면 라이브)
# yakcloud scale <wl> <n> replicas 변경
# yakcloud set <wl> --image/--port/--health/--cpu/--mem/--path/--rewrite
# yakcloud env <wl> KEY=VAL … [--secret KEY] [--unset KEY]
# yakcloud source add <name> <type> [plan] | source rm <name>
# yakcloud bind <wl> <source> <alias> | unbind <wl> <alias>
#
# env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER
set -euo pipefail set -euo pipefail
REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}" REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}"
BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}" BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}"
VERSION="0.1.0" VERSION="0.2.0"
die() { echo "✗ $*" >&2; exit 1; } die() { echo "✗ $*" >&2; exit 1; }
info() { echo "▸ $*"; } info() { echo "▸ $*"; }
cmd_init() { 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 "$@"
}
cmd_project_init() {
local name="${1:-}" local name="${1:-}"
if [ -n "$name" ]; then [ -n "$name" ] && { mkdir -p "$name"; cd "$name"; }
mkdir -p "$name" && cd "$name"
fi
# 빈 폴더인지 확인(.git 은 허용)
if [ -n "$(ls -A . 2>/dev/null | grep -v '^\.git$' || true)" ]; then if [ -n "$(ls -A . 2>/dev/null | grep -v '^\.git$' || true)" ]; then
die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud init <name>' 로 새 폴더에 생성하세요." die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud project init <name>'."
fi fi
info "yakcloud-starter 내려받는 중… ($REPO @ $BRANCH)" info "yakcloud-starter 내려받는 중… ($REPO @ $BRANCH)"
command -v git >/dev/null 2>&1 || die "git 이 필요합니다." command -v git >/dev/null 2>&1 || die "git 이 필요합니다."
@ -27,55 +49,76 @@ cmd_init() {
git clone -q --depth 1 -b "$BRANCH" "$REPO.git" "$tmp/s" \ git clone -q --depth 1 -b "$BRANCH" "$REPO.git" "$tmp/s" \
|| { rm -rf "$tmp"; die "스타터 클론 실패 — 네트워크/레포 공개 여부 확인 ($REPO)"; } || { rm -rf "$tmp"; die "스타터 클론 실패 — 네트워크/레포 공개 여부 확인 ($REPO)"; }
rm -rf "$tmp/s/.git" rm -rf "$tmp/s/.git"
cp -R "$tmp/s/." . # 숨김파일 포함 전부 현재 폴더로 cp -R "$tmp/s/." .
rm -rf "$tmp" rm -rf "$tmp"
# 프로젝트 이름 치환(선택) [ -n "$name" ] && [ -f yakcloud.yaml ] && { sed -i.bak "s/^project: .*/project: $name/" yakcloud.yaml && rm -f yakcloud.yaml.bak; }
if [ -n "$name" ] && [ -f yakcloud.yaml ]; then
sed -i.bak "s/^project: .*/project: $name/" yakcloud.yaml && rm -f yakcloud.yaml.bak
fi
git rev-parse --git-dir >/dev/null 2>&1 || git init -q git rev-parse --git-dir >/dev/null 2>&1 || git init -q
echo "✓ 초기화 완료 ($(pwd))" echo "✓ 초기화 완료 ($(pwd))"
echo " 다음:" echo " 다음: yakcloud project info · yakcloud domain … · yakcloud project deploy v0.1.0"
echo " 1) yakcloud.yaml 의 cluster / image(<GITEA_USER>) 수정, 필요하면 requires·binds 추가"
echo " 2) 앱 개발 (Claude Code 스킬: .claude/skills/yakcloud-deploy)"
echo " 3) Gitea 레포 연결 + Secrets(YAKCLOUD_URL/TOKEN/CLUSTER, REGISTRY_TOKEN) 설정"
echo " 4) yakcloud deploy v0.1.0"
} }
cmd_deploy() { cmd_project_deploy() {
[ -f yakcloud.yaml ] || die "yakcloud.yaml 없음 — 먼저 'yakcloud init'" [ -f yakcloud.yaml ] || die "yakcloud.yaml 없음 — 'yakcloud project init' 먼저"
local ver="${1:-v0.1.0}"
case "$ver" in v*) : ;; *) ver="v$ver" ;; esac
git rev-parse --git-dir >/dev/null 2>&1 || git init -q git rev-parse --git-dir >/dev/null 2>&1 || git init -q
git remote get-url origin >/dev/null 2>&1 \ 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 설정 다시"
info "커밋 + 태그 $ver push…" git fetch -q --tags origin 2>/dev/null || true
git add -A # 버전: 인자 있으면 그것, 없으면 최신 v* 태그에서 패치 +1(없으면 v0.1.0) — 배포마다 자동 버전업.
git commit -q -m "deploy $ver" 2>/dev/null || true 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
fi
case "$ver" in v*) : ;; *) ver="v$ver" ;; esac
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 push -q origin HEAD 2>/dev/null || true
git tag "$ver" 2>/dev/null || die "태그 $ver 이미 존재 — 다른 버전으로" git tag "$ver" 2>/dev/null || die "태그 $ver 이미 존재 — 'yakcloud project deploy <다음버전>'"
git push -q origin "$ver" git push -q origin "$ver"
echo "✓ $ver push — Gitea Actions 에서 빌드·배포 진행. 콘솔 앱 탭/도메인에서 확인." echo "✓ $ver push — Gitea Actions 에서 빌드·배포. 콘솔 앱 탭/도메인 확인."
} }
cmd_check() { cmd_project_check() {
[ -f scripts/yakcloud_deploy.py ] || die "scripts/yakcloud_deploy.py 없음 — 'yakcloud init' 먼저" [ -f scripts/yakcloud_deploy.py ] || die "'yakcloud project init' 먼저"
: "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}" : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요}"
: "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN(배포 토큰) 필요}" ensure_pyyaml
python3 -c "import yaml" 2>/dev/null || pip3 install --quiet pyyaml
python3 scripts/yakcloud_deploy.py yakcloud.yaml --dry-run python3 scripts/yakcloud_deploy.py yakcloud.yaml --dry-run
} }
case "${1:-help}" in usage() {
init) shift; cmd_init "$@" ;; cat <<EOF
deploy) shift; cmd_deploy "$@" ;;
check|dry-run) shift; cmd_check "$@" ;;
version|-v|--version) echo "yakcloud $VERSION" ;;
*) cat <<EOF
yakcloud $VERSION — YakCloud 프로젝트 CLI yakcloud $VERSION — YakCloud 프로젝트 CLI
yakcloud init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬) project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
yakcloud deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포 project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포
yakcloud check dry-run(직접 API 계획만; YAKCLOUD_URL/TOKEN 필요) 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
EOF EOF
;; }
case "${1:-help}" in
project)
shift
case "${1:-}" in
init) shift; cmd_project_init "$@" ;;
deploy) shift; cmd_project_deploy "$@" ;;
info) shift; ctl info "$@" ;;
check|dry-run) shift; cmd_project_check "$@" ;;
*) echo "yakcloud project <init|deploy|info|check>"; exit 1 ;;
esac ;;
domain|scale|set|env|source|bind|unbind) ctl "$@" ;;
version|-v|--version) echo "yakcloud $VERSION" ;;
help|-h|--help|"") usage ;;
*) echo "알 수 없는 명령: $1"; echo; usage; exit 1 ;;
esac esac

0
.claude/skills/yakcloud-deploy/assets/install.sh Normal file → Executable file
View File

View File

@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""yakcloud ctl — 배포 환경(매니페스트 + 라이브 배포) 조회/수정 엔진. bin/yakcloud 가 위임 호출.
앱마다 바뀌는 부분을 CLI 로 수정한다. 매니페스트(yakcloud.yaml)가 선언적 소스이고,
scale/set/env/domain 은 이미 배포돼 있으면 재빌드 없이 라이브 반영(PATCH)한다.
env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER
서브커맨드: info · domain · scale · set · env · source · bind · unbind
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
import yaml
URL = os.environ.get("YAKCLOUD_URL", "").rstrip("/")
TOK = os.environ.get("YAKCLOUD_TOKEN", "")
MANIFEST = "yakcloud.yaml"
# ── 유틸 ────────────────────────────────────────────────────────────────
def need_api() -> None:
if not URL or not TOK:
sys.exit(" ✗ YAKCLOUD_URL / YAKCLOUD_TOKEN(배포 토큰) 환경변수가 필요합니다.")
def api(method: str, path: str, body: dict | None = None) -> dict:
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(URL + "/api/v1" + path, data=data, method=method,
headers={"Authorization": "Bearer " + TOK, "content-type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read().decode()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
try:
msg = json.loads(e.read().decode()).get("error", {})
except Exception:
msg = {}
sys.exit("%s %s%s: %s" % (method, path, e.code, msg.get("message") or msg or "요청 실패"))
def unwrap(r):
return r.get("data", r) if isinstance(r, dict) else r
def load_manifest() -> dict:
if not os.path.exists(MANIFEST):
sys.exit("%s 없음 — 'yakcloud init' 먼저" % MANIFEST)
return yaml.safe_load(open(MANIFEST)) or {}
def save_manifest(m: dict) -> None:
yaml.safe_dump(m, open(MANIFEST, "w"), sort_keys=False, allow_unicode=True)
def cluster_ref(m: dict) -> str:
ref = os.environ.get("YAKCLOUD_CLUSTER") or m.get("cluster")
if not ref:
sys.exit(" ✗ 대상 클러스터 미지정 — YAKCLOUD_CLUSTER 또는 yakcloud.yaml 의 cluster:")
return ref
def resolve_cluster(ref: str) -> dict:
for c in unwrap(api("GET", "/clusters")) or []:
if ref in (c.get("id"), c.get("name")):
return c
sys.exit(" ✗ 클러스터 '%s' 없음" % ref)
def find_workload(m: dict, name: str | None) -> dict:
ws = m.get("workloads", []) or []
if name:
w = next((w for w in ws if w.get("name") == name), None)
if not w:
sys.exit(" ✗ 워크로드 '%s' 없음 (%s)" % (name, ", ".join(w.get("name", "?") for w in ws)))
return w
if len(ws) == 1:
return ws[0]
sys.exit(" ✗ 워크로드를 지정하세요 (%s)" % ", ".join(w.get("name", "?") for w in ws))
def live_deps(cid: str) -> dict[str, dict]:
return {d["name"]: d for d in (unwrap(api("GET", "/clusters/%s/deployments" % cid)) or [])}
def live_services(cid: str) -> dict[str, dict]:
return {s["name"]: s for s in (unwrap(api("GET", "/clusters/%s/services" % cid)) or [])}
def patch_live(cid: str, name: str, patch: dict) -> bool:
"""워크로드가 이미 배포돼 있으면 PATCH(재빌드 없이 롤링 반영). 반환=적용여부."""
dep = live_deps(cid).get(name)
if not dep:
return False
api("PATCH", "/deployments/%s" % dep["id"], patch)
return True
# ── 커맨드 ──────────────────────────────────────────────────────────────
def cmd_info(_a) -> None:
m = load_manifest()
print("project : %s" % m.get("project", "-"))
ref = m.get("cluster") or os.environ.get("YAKCLOUD_CLUSTER") or "-"
deps: dict[str, dict] = {}
svcs: dict[str, dict] = {}
doms: list[dict] = []
cinfo = None
if URL and TOK and (m.get("cluster") or os.environ.get("YAKCLOUD_CLUSTER")):
cinfo = resolve_cluster(cluster_ref(m))
deps = live_deps(cinfo["id"])
svcs = live_services(cinfo["id"])
doms = unwrap(api("GET", "/clusters/%s/domains" % cinfo["id"])) or []
print("cluster : %s (id %s) 도메인 %s" % (cinfo.get("name"), cinfo["id"], cinfo.get("defaultHostname", "-")))
else:
print("cluster : %s (라이브 상태는 YAKCLOUD_URL/TOKEN 설정 시 표시)" % ref)
print("\nworkloads:")
for w in m.get("workloads", []) or []:
ex = w.get("expose", {}) or {}
hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])
line = "%-14s image=%s port=%s replicas=%s health=%s" % (
w.get("name"), w.get("image"), w.get("port"), w.get("replicas", 1), w.get("health", "-"))
d = deps.get(w.get("name"))
if d:
live_hosts = d.get("exposeHosts") or ([d["exposeHost"]] if d.get("exposeHost") else [])
line += "\n live: %s %s/%s hosts=%s" % (
d.get("status"), d.get("replicasReady", 0), d.get("replicasDesired", 0),
", ".join(live_hosts) or "(내부)")
elif hosts:
line += "\n manifest hosts=%s" % ", ".join(hosts)
binds = w.get("binds", []) or []
if binds:
line += "\n binds: %s" % ", ".join("%s%s" % (b["alias"], b["source"]) for b in binds)
print(line)
reqs = m.get("requires", []) or []
if reqs:
print("\ndata sources (requires):")
for r in reqs:
s = svcs.get(r["name"])
st = (" [%s]" % s.get("status")) if s else ""
print("%-14s %s/%s%s" % (r["name"], r.get("type"), r.get("plan", "small"), st))
if doms:
print("\ndomains (cluster):")
for d in doms:
print("%-28s %s cert=%s" % (d["fqdn"], d["status"], d.get("certStatus")))
print("\n다음: 수정=yakcloud (scale|set|env|domain|source|bind) · 배포=yakcloud deploy vX.Y.Z")
def _apply_workload_patch(m, cid_or_none, w, patch, label):
"""매니페스트 저장 후, 배포돼 있으면 라이브 PATCH."""
save_manifest(m)
print(" ✓ 매니페스트: %s %s" % (w["name"], label))
if cid_or_none and patch and patch_live(cid_or_none, w["name"], patch):
print(" ✓ 라이브 반영(재빌드 없음): %s" % w["name"])
elif cid_or_none:
print(" · 아직 미배포 — 'yakcloud deploy' 시 반영")
def _cid(m):
if URL and TOK:
return resolve_cluster(cluster_ref(m))["id"]
return None
def cmd_scale(a) -> None:
m = load_manifest(); w = find_workload(m, a.workload)
w["replicas"] = int(a.replicas)
_apply_workload_patch(m, _cid(m), w, {"replicasDesired": int(a.replicas)}, "replicas=%s" % a.replicas)
def cmd_set(a) -> None:
m = load_manifest(); w = find_workload(m, a.workload)
patch, changed = {}, []
if a.image is not None:
w["image"] = a.image; patch["image"] = a.image; changed.append("image")
if a.port is not None:
w["port"] = int(a.port); patch["port"] = int(a.port); changed.append("port")
if a.health is not None:
w["health"] = a.health; patch["healthPath"] = a.health; changed.append("health")
if a.cpu is not None or a.mem is not None:
res = w.get("resources", {}) or {}; w["resources"] = res
if a.cpu is not None:
res["cpu"] = a.cpu; patch["cpuRequest"] = a.cpu; changed.append("cpu")
if a.mem is not None:
res["mem"] = a.mem; patch["memRequest"] = a.mem; changed.append("mem")
if a.path is not None or a.rewrite is not None:
ex = w.get("expose", {}) or {}; w["expose"] = ex
if a.path is not None:
ex["path"] = a.path; patch["path"] = a.path; changed.append("path")
if a.rewrite is not None:
rw = a.rewrite.lower() in ("1", "true", "yes", "on")
ex["rewrite"] = rw; patch["rewritePrefix"] = rw; changed.append("rewrite")
if not changed:
sys.exit(" ✗ 변경할 항목 없음 — --image/--port/--health/--cpu/--mem/--path/--rewrite")
_apply_workload_patch(m, _cid(m), w, patch, "set " + " ".join(changed))
def cmd_env(a) -> None:
m = load_manifest(); w = find_workload(m, a.workload)
env = w.get("env", []) or []
if isinstance(env, dict):
env = [{"key": k, "value": v} for k, v in env.items()]
idx = {e["key"]: e for e in env}
for pair in a.pairs:
if "=" not in pair:
sys.exit(" ✗ KEY=VALUE 형식이어야 합니다: %s" % pair)
k, v = pair.split("=", 1)
idx[k] = {"key": k, "value": v, **({"secret": True} if k in (a.secret or []) else {})}
for k in (a.unset or []):
idx.pop(k, None)
env = list(idx.values())
w["env"] = env
patch = {"env": [{"key": e["key"], "value": str(e.get("value", "")), "secret": bool(e.get("secret", False))} for e in env]}
_apply_workload_patch(m, _cid(m), w, patch, "env=[%s]" % ", ".join(e["key"] for e in env))
def cmd_source(a) -> None:
m = load_manifest(); reqs = m.setdefault("requires", []) or []
m["requires"] = reqs
if a.action == "add":
if any(r.get("name") == a.name for r in reqs):
sys.exit(" ✗ 이미 있음: %s" % a.name)
reqs.append({"name": a.name, "type": a.type, "plan": a.plan})
save_manifest(m)
print(" ✓ requires += {name=%s, type=%s, plan=%s}" % (a.name, a.type, a.plan))
print(" · 프로비저닝/바인딩은 'yakcloud bind <workload> %s <alias>''yakcloud deploy'" % a.name)
else: # rm
m["requires"] = [r for r in reqs if r.get("name") != a.name]
# 관련 바인딩도 정리
for w in m.get("workloads", []) or []:
w["binds"] = [b for b in (w.get("binds", []) or []) if b.get("source") != a.name]
save_manifest(m)
print(" ✓ requires 에서 제거: %s (관련 binds 정리). 'yakcloud deploy' 로 반영" % a.name)
def cmd_bind(a) -> None:
m = load_manifest(); w = find_workload(m, a.workload)
if not any(r.get("name") == a.source for r in (m.get("requires", []) or [])):
sys.exit(" ✗ requires 에 소스 '%s' 없음 — 'yakcloud source add %s <type>' 먼저" % (a.source, a.source))
binds = w.get("binds", []) or []; w["binds"] = binds
binds[:] = [b for b in binds if b.get("alias") != a.alias]
binds.append({"alias": a.alias, "source": a.source})
save_manifest(m)
print("%s.binds += {alias=%s, source=%s}. 'yakcloud deploy' 로 반영(env 주입)" % (w["name"], a.alias, a.source))
def cmd_unbind(a) -> None:
m = load_manifest(); w = find_workload(m, a.workload)
binds = w.get("binds", []) or []
w["binds"] = [b for b in binds if b.get("alias") != a.alias]
save_manifest(m)
print("%s.binds 에서 alias=%s 제거. 'yakcloud deploy' 로 반영" % (w["name"], a.alias))
def cmd_domain(a) -> None:
need_api()
m = load_manifest(); c = resolve_cluster(cluster_ref(m)); cid = c["id"]
doms = unwrap(api("GET", "/clusters/%s/domains" % cid)) or []
d = next((x for x in doms if x["fqdn"] == a.fqdn), None)
if d:
print(" = 이미 등록됨: %s (status=%s)" % (a.fqdn, d["status"]))
else:
d = unwrap(api("POST", "/clusters/%s/domains" % cid, {"fqdn": a.fqdn}))
print(" ✓ 등록: %s status=%s cert=%s" % (a.fqdn, d["status"], d["certStatus"]))
v = d.get("verify")
if v:
print(' 외부 도메인 — DNS 에 TXT 추가 후 검증:\n %s TXT "%s"' % (v["host"], v["value"]))
# 매니페스트 워크로드에 연결
ws = m.get("workloads", []) or []
w = next((x for x in ws if x.get("name") == a.workload), None) if a.workload else (ws[0] if len(ws) == 1 else None)
if w is None and ws:
print(" · 워크로드를 지정하세요: yakcloud domain %s <workload> (%s)"
% (a.fqdn, ", ".join(x.get("name", "?") for x in ws)))
return
if w is None:
return
ex = w.get("expose", {}) or {}; w["expose"] = ex
hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])
if a.fqdn not in hosts:
hosts.append(a.fqdn)
ex.pop("host", None); ex["hosts"] = hosts
save_manifest(m)
print(" ✓ 매니페스트: %s.expose.hosts=%s" % (w["name"], hosts))
dep = live_deps(cid).get(w["name"])
if dep and d["status"] == "ACTIVE":
cur = dep.get("exposeHosts") or ([dep["exposeHost"]] if dep.get("exposeHost") else [])
newh = list(dict.fromkeys(cur + [a.fqdn]))
api("PATCH", "/deployments/%s" % dep["id"], {"exposeHosts": newh})
print(" ✓ 라이브 반영(재빌드 없음): %s%s" % (w["name"], ", ".join(newh)))
elif dep:
print(" · 도메인 활성 후 'yakcloud deploy' 로 반영")
else:
print(" · 미배포 — 'yakcloud deploy' 시 이 도메인으로 노출")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="yakcloud", add_help=True)
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("info", help="프로젝트 개괄(매니페스트 + 라이브 상태)").set_defaults(fn=cmd_info)
d = sub.add_parser("domain", help="도메인 등록 + 워크로드 할당")
d.add_argument("fqdn"); d.add_argument("workload", nargs="?")
d.set_defaults(fn=cmd_domain)
s = sub.add_parser("scale", help="워크로드 replicas 변경")
s.add_argument("workload"); s.add_argument("replicas", type=int)
s.set_defaults(fn=cmd_scale)
se = sub.add_parser("set", help="워크로드 필드 수정")
se.add_argument("workload")
se.add_argument("--image"); se.add_argument("--port"); se.add_argument("--health")
se.add_argument("--cpu"); se.add_argument("--mem"); se.add_argument("--path"); se.add_argument("--rewrite")
se.set_defaults(fn=cmd_set)
e = sub.add_parser("env", help="워크로드 환경변수 KEY=VALUE 설정/해제")
e.add_argument("workload"); e.add_argument("pairs", nargs="*")
e.add_argument("--secret", action="append", help="이 KEY 를 secret 으로 표시")
e.add_argument("--unset", action="append", help="이 KEY 제거")
e.set_defaults(fn=cmd_env)
so = sub.add_parser("source", help="데이터 소스(requires) 추가/삭제")
sosub = so.add_subparsers(dest="action", required=True)
soa = sosub.add_parser("add"); soa.add_argument("name"); soa.add_argument("type")
soa.add_argument("plan", nargs="?", default="small"); soa.set_defaults(fn=cmd_source)
sor = sosub.add_parser("rm"); sor.add_argument("name"); sor.set_defaults(fn=cmd_source)
b = sub.add_parser("bind", help="워크로드에 소스 바인딩")
b.add_argument("workload"); b.add_argument("source"); b.add_argument("alias")
b.set_defaults(fn=cmd_bind)
u = sub.add_parser("unbind", help="워크로드 바인딩 해제")
u.add_argument("workload"); u.add_argument("alias")
u.set_defaults(fn=cmd_unbind)
return p
if __name__ == "__main__":
args = build_parser().parse_args()
args.fn(args)

View File

@ -120,6 +120,12 @@ def deploy_workload(w: dict) -> tuple[str | None, bool]:
body["exposeHosts"] = hosts body["exposeHosts"] = hosts
else: else:
body["exposeHost"] = ex.get("host") or (CLUSTER_HOST or None) body["exposeHost"] = ex.get("host") or (CLUSTER_HOST or None)
# 환경변수(선언적): 리스트[{key,value,secret?}] 또는 맵{KEY: VALUE}.
env = w.get("env") or []
if isinstance(env, dict):
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]
if DRY: if DRY:
log(f"deploy '{w['name']}' 예정: image={image} port={body['port']} path={body['path']} " log(f"deploy '{w['name']}' 예정: image={image} port={body['port']} path={body['path']} "
f"replicas={body['replicasDesired']} hosts={hosts or body.get('exposeHost')}") f"replicas={body['replicasDesired']} hosts={hosts or body.get('exposeHost')}")

View File

@ -1,47 +1,54 @@
# yakcloud-starter — YakCloud 프로젝트 기반 + CLI + Claude Code 스킬 # yakcloud-starter — YakCloud 프로젝트 기반 + CLI + Claude Code 스킬
빈 폴더에서 **한 명령으로 프로젝트를 초기화**하고, **태그 push로 YakCloud에 자동 배포**하기 위한 기반. 빈 폴더에서 **한 명령으로 프로젝트를 초기화**하고, **태그 push로 자동 배포**하며, **배포 환경을 CLI로 상세 설정**한다.
프레임워크 중립 스타터 앱 + CI + 매니페스트 + **`yakcloud` CLI** + **Claude Code 스킬**(`.claude/skills/yakcloud-deploy/`)이 들어 있다. 프레임워크 중립 스타터 앱 + CI + 매니페스트 + **`yakcloud` CLI** + **Claude Code 스킬**(`.claude/skills/yakcloud-deploy/`) 포함.
## CLI 설치 (1회) ## CLI 설치 (1회)
```sh ```sh
curl -fsSL https://gitea.yakenator.io/yakenator/yakcloud-starter/raw/branch/main/install.sh | sh curl -fsSL https://gitea.yakenator.io/yakenator/yakcloud-starter/raw/branch/main/install.sh | sh
# PATH: export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
``` ```
## 빈 폴더 → 배포까지 ## 빈 폴더 → 배포까지
```sh ```sh
yakcloud init my-app # 빈 폴더에 스타터+CI+매니페스트+Claude 스킬 스캐폴딩 yakcloud project init my-app # 스타터+CI+매니페스트+Claude 스킬 스캐폴딩
cd my-app cd my-app
# (앱 개발 — Claude Code: "yakcloud 로 배포 붙여줘" / 앱 코드 작성) # (앱 개발 — Claude Code: "yakcloud 로 배포 붙여줘")
# yakcloud.yaml 의 cluster / image(<GITEA_USER>) 수정, 필요하면 requires·binds 추가 yakcloud project info # 프로젝트 개괄(매니페스트 + 라이브 상태)
yakcloud deploy v0.1.0 # 커밋 + 태그 push → Gitea Actions 자동 배포 yakcloud project deploy # 커밋 + 태그 push (버전 미지정 시 자동 버전업) → 자동 배포
``` ```
> **Claude Code 흐름**: 빈 폴더에서 Claude Code를 열고 "프로젝트 초기화해줘" → Claude가 `yakcloud init` 실행 → > **Claude Code 흐름**: 빈 폴더에서 "프로젝트 초기화해줘" → `yakcloud project init` → 개발 → "yakcloud에 배포해줘" → `yakcloud project deploy`.
> 개발 → "yakcloud에 배포해줘" → Claude가 `yakcloud deploy` 실행. (CLI가 없으면 스킬의 수동 절차로 진행)
## 명령
```
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회 준비
1. 콘솔 → 설정 → **배포 토큰** 발급(`yakd_…`). 1. 콘솔 → 설정 → **배포 토큰** 발급(`yakd_…`).
2. Gitea 레포 만들고 `git remote add origin …`. **Secrets**: `YAKCLOUD_URL`, `YAKCLOUD_TOKEN`, `YAKCLOUD_CLUSTER`, `REGISTRY_TOKEN`. 2. Gitea 레포 만들고 `git remote add origin …`. **Secrets**: `YAKCLOUD_URL`, `YAKCLOUD_TOKEN`, `YAKCLOUD_CLUSTER`, `REGISTRY_TOKEN`.
3. `yakcloud.yaml``cluster`·`image` 수정. 3. `yakcloud.yaml``cluster`·`image`(`<GITEA_USER>`) 수정.
사전 점검(직접 API, 계획만):
```sh
YAKCLOUD_URL=https://console.yakenator.io YAKCLOUD_TOKEN=yakd_… yakcloud check
```
## 구성 ## 구성
``` ```
bin/yakcloud # CLI(init/deploy/check) bin/yakcloud # CLI 디스패처
install.sh # CLI 설치 install.sh # CLI 설치
.claude/skills/yakcloud-deploy/ # Claude Code 스킬(SKILL.md + assets) — 프로젝트에 그대로 따라감 scripts/yakcloud_deploy.py # 리컨실 배포 CLI(매니페스트→콘솔 API)
app.py Dockerfile # 프레임워크 중립 스타터(표준 라이브러리, /healthz + PORT) scripts/yakcloud_ctl.py # info/설정 엔진(매니페스트 편집 + 라이브 PATCH)
.claude/skills/yakcloud-deploy/ # Claude Code 스킬
app.py Dockerfile # 프레임워크 중립 스타터
yakcloud.yaml # 배포 매니페스트 yakcloud.yaml # 배포 매니페스트
.gitea/workflows/deploy.yml # 태그 push CI(매니페스트 구동 — 수정 불필요) .gitea/workflows/deploy.yml # 태그 push CI(매니페스트 구동)
scripts/yakcloud_deploy.py # 리컨실 배포 CLI
DATA-SOURCES.md # 소스별 주입 env 표 DATA-SOURCES.md # 소스별 주입 env 표
``` ```
## 확장(추가/삭제)
- **워크로드/데이터소스/도메인 추가·삭제**는 `yakcloud.yaml` 을 편집하고 `yakcloud deploy` — 멱등 리컨실로 반영.
- 상세: `.claude/skills/yakcloud-deploy/SKILL.md`.

View File

@ -1,25 +1,47 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# yakcloud — YakCloud 프로젝트 CLI. 빈 폴더 초기화(스타터+스킬) + 태그 push 자동배포. # yakcloud — YakCloud 프로젝트 CLI. 빈 폴더 초기화(스타터+스킬), 배포, 배포환경 상세 설정.
# yakcloud init [name] 현재(빈) 폴더 또는 name/ 에 스타터 스캐폴딩(.claude 스킬 포함) #
# yakcloud deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포 # yakcloud project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
# yakcloud check dry-run(직접 API, 계획만; YAKCLOUD_URL/TOKEN 필요) # yakcloud project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포
# yakcloud project info 프로젝트 개괄(매니페스트 + 라이브 상태)
# yakcloud project check dry-run(직접 API 계획만)
#
# yakcloud domain <fqdn> [wl] 도메인 등록 + 워크로드에 할당(관리형=즉시, 배포중이면 라이브)
# yakcloud scale <wl> <n> replicas 변경
# yakcloud set <wl> --image/--port/--health/--cpu/--mem/--path/--rewrite
# yakcloud env <wl> KEY=VAL … [--secret KEY] [--unset KEY]
# yakcloud source add <name> <type> [plan] | source rm <name>
# yakcloud bind <wl> <source> <alias> | unbind <wl> <alias>
#
# env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER
set -euo pipefail set -euo pipefail
REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}" REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}"
BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}" BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}"
VERSION="0.1.0" VERSION="0.2.0"
die() { echo "✗ $*" >&2; exit 1; } die() { echo "✗ $*" >&2; exit 1; }
info() { echo "▸ $*"; } info() { echo "▸ $*"; }
cmd_init() { 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 "$@"
}
cmd_project_init() {
local name="${1:-}" local name="${1:-}"
if [ -n "$name" ]; then [ -n "$name" ] && { mkdir -p "$name"; cd "$name"; }
mkdir -p "$name" && cd "$name"
fi
# 빈 폴더인지 확인(.git 은 허용)
if [ -n "$(ls -A . 2>/dev/null | grep -v '^\.git$' || true)" ]; then if [ -n "$(ls -A . 2>/dev/null | grep -v '^\.git$' || true)" ]; then
die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud init <name>' 로 새 폴더에 생성하세요." die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud project init <name>'."
fi fi
info "yakcloud-starter 내려받는 중… ($REPO @ $BRANCH)" info "yakcloud-starter 내려받는 중… ($REPO @ $BRANCH)"
command -v git >/dev/null 2>&1 || die "git 이 필요합니다." command -v git >/dev/null 2>&1 || die "git 이 필요합니다."
@ -27,55 +49,76 @@ cmd_init() {
git clone -q --depth 1 -b "$BRANCH" "$REPO.git" "$tmp/s" \ git clone -q --depth 1 -b "$BRANCH" "$REPO.git" "$tmp/s" \
|| { rm -rf "$tmp"; die "스타터 클론 실패 — 네트워크/레포 공개 여부 확인 ($REPO)"; } || { rm -rf "$tmp"; die "스타터 클론 실패 — 네트워크/레포 공개 여부 확인 ($REPO)"; }
rm -rf "$tmp/s/.git" rm -rf "$tmp/s/.git"
cp -R "$tmp/s/." . # 숨김파일 포함 전부 현재 폴더로 cp -R "$tmp/s/." .
rm -rf "$tmp" rm -rf "$tmp"
# 프로젝트 이름 치환(선택) [ -n "$name" ] && [ -f yakcloud.yaml ] && { sed -i.bak "s/^project: .*/project: $name/" yakcloud.yaml && rm -f yakcloud.yaml.bak; }
if [ -n "$name" ] && [ -f yakcloud.yaml ]; then
sed -i.bak "s/^project: .*/project: $name/" yakcloud.yaml && rm -f yakcloud.yaml.bak
fi
git rev-parse --git-dir >/dev/null 2>&1 || git init -q git rev-parse --git-dir >/dev/null 2>&1 || git init -q
echo "✓ 초기화 완료 ($(pwd))" echo "✓ 초기화 완료 ($(pwd))"
echo " 다음:" echo " 다음: yakcloud project info · yakcloud domain … · yakcloud project deploy v0.1.0"
echo " 1) yakcloud.yaml 의 cluster / image(<GITEA_USER>) 수정, 필요하면 requires·binds 추가"
echo " 2) 앱 개발 (Claude Code 스킬: .claude/skills/yakcloud-deploy)"
echo " 3) Gitea 레포 연결 + Secrets(YAKCLOUD_URL/TOKEN/CLUSTER, REGISTRY_TOKEN) 설정"
echo " 4) yakcloud deploy v0.1.0"
} }
cmd_deploy() { cmd_project_deploy() {
[ -f yakcloud.yaml ] || die "yakcloud.yaml 없음 — 먼저 'yakcloud init'" [ -f yakcloud.yaml ] || die "yakcloud.yaml 없음 — 'yakcloud project init' 먼저"
local ver="${1:-v0.1.0}"
case "$ver" in v*) : ;; *) ver="v$ver" ;; esac
git rev-parse --git-dir >/dev/null 2>&1 || git init -q git rev-parse --git-dir >/dev/null 2>&1 || git init -q
git remote get-url origin >/dev/null 2>&1 \ 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 설정 다시"
info "커밋 + 태그 $ver push…" git fetch -q --tags origin 2>/dev/null || true
git add -A # 버전: 인자 있으면 그것, 없으면 최신 v* 태그에서 패치 +1(없으면 v0.1.0) — 배포마다 자동 버전업.
git commit -q -m "deploy $ver" 2>/dev/null || true 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
fi
case "$ver" in v*) : ;; *) ver="v$ver" ;; esac
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 push -q origin HEAD 2>/dev/null || true
git tag "$ver" 2>/dev/null || die "태그 $ver 이미 존재 — 다른 버전으로" git tag "$ver" 2>/dev/null || die "태그 $ver 이미 존재 — 'yakcloud project deploy <다음버전>'"
git push -q origin "$ver" git push -q origin "$ver"
echo "✓ $ver push — Gitea Actions 에서 빌드·배포 진행. 콘솔 앱 탭/도메인에서 확인." echo "✓ $ver push — Gitea Actions 에서 빌드·배포. 콘솔 앱 탭/도메인 확인."
} }
cmd_check() { cmd_project_check() {
[ -f scripts/yakcloud_deploy.py ] || die "scripts/yakcloud_deploy.py 없음 — 'yakcloud init' 먼저" [ -f scripts/yakcloud_deploy.py ] || die "'yakcloud project init' 먼저"
: "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}" : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요}"
: "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN(배포 토큰) 필요}" ensure_pyyaml
python3 -c "import yaml" 2>/dev/null || pip3 install --quiet pyyaml
python3 scripts/yakcloud_deploy.py yakcloud.yaml --dry-run python3 scripts/yakcloud_deploy.py yakcloud.yaml --dry-run
} }
case "${1:-help}" in usage() {
init) shift; cmd_init "$@" ;; cat <<EOF
deploy) shift; cmd_deploy "$@" ;;
check|dry-run) shift; cmd_check "$@" ;;
version|-v|--version) echo "yakcloud $VERSION" ;;
*) cat <<EOF
yakcloud $VERSION — YakCloud 프로젝트 CLI yakcloud $VERSION — YakCloud 프로젝트 CLI
yakcloud init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬) project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
yakcloud deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포 project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포
yakcloud check dry-run(직접 API 계획만; YAKCLOUD_URL/TOKEN 필요) 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
EOF EOF
;; }
case "${1:-help}" in
project)
shift
case "${1:-}" in
init) shift; cmd_project_init "$@" ;;
deploy) shift; cmd_project_deploy "$@" ;;
info) shift; ctl info "$@" ;;
check|dry-run) shift; cmd_project_check "$@" ;;
*) echo "yakcloud project <init|deploy|info|check>"; exit 1 ;;
esac ;;
domain|scale|set|env|source|bind|unbind) ctl "$@" ;;
version|-v|--version) echo "yakcloud $VERSION" ;;
help|-h|--help|"") usage ;;
*) echo "알 수 없는 명령: $1"; echo; usage; exit 1 ;;
esac esac

347
scripts/yakcloud_ctl.py Normal file
View File

@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""yakcloud ctl — 배포 환경(매니페스트 + 라이브 배포) 조회/수정 엔진. bin/yakcloud 가 위임 호출.
앱마다 바뀌는 부분을 CLI 로 수정한다. 매니페스트(yakcloud.yaml)가 선언적 소스이고,
scale/set/env/domain 은 이미 배포돼 있으면 재빌드 없이 라이브 반영(PATCH)한다.
env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER
서브커맨드: info · domain · scale · set · env · source · bind · unbind
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
import yaml
URL = os.environ.get("YAKCLOUD_URL", "").rstrip("/")
TOK = os.environ.get("YAKCLOUD_TOKEN", "")
MANIFEST = "yakcloud.yaml"
# ── 유틸 ────────────────────────────────────────────────────────────────
def need_api() -> None:
if not URL or not TOK:
sys.exit(" ✗ YAKCLOUD_URL / YAKCLOUD_TOKEN(배포 토큰) 환경변수가 필요합니다.")
def api(method: str, path: str, body: dict | None = None) -> dict:
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(URL + "/api/v1" + path, data=data, method=method,
headers={"Authorization": "Bearer " + TOK, "content-type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read().decode()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
try:
msg = json.loads(e.read().decode()).get("error", {})
except Exception:
msg = {}
sys.exit("%s %s%s: %s" % (method, path, e.code, msg.get("message") or msg or "요청 실패"))
def unwrap(r):
return r.get("data", r) if isinstance(r, dict) else r
def load_manifest() -> dict:
if not os.path.exists(MANIFEST):
sys.exit("%s 없음 — 'yakcloud init' 먼저" % MANIFEST)
return yaml.safe_load(open(MANIFEST)) or {}
def save_manifest(m: dict) -> None:
yaml.safe_dump(m, open(MANIFEST, "w"), sort_keys=False, allow_unicode=True)
def cluster_ref(m: dict) -> str:
ref = os.environ.get("YAKCLOUD_CLUSTER") or m.get("cluster")
if not ref:
sys.exit(" ✗ 대상 클러스터 미지정 — YAKCLOUD_CLUSTER 또는 yakcloud.yaml 의 cluster:")
return ref
def resolve_cluster(ref: str) -> dict:
for c in unwrap(api("GET", "/clusters")) or []:
if ref in (c.get("id"), c.get("name")):
return c
sys.exit(" ✗ 클러스터 '%s' 없음" % ref)
def find_workload(m: dict, name: str | None) -> dict:
ws = m.get("workloads", []) or []
if name:
w = next((w for w in ws if w.get("name") == name), None)
if not w:
sys.exit(" ✗ 워크로드 '%s' 없음 (%s)" % (name, ", ".join(w.get("name", "?") for w in ws)))
return w
if len(ws) == 1:
return ws[0]
sys.exit(" ✗ 워크로드를 지정하세요 (%s)" % ", ".join(w.get("name", "?") for w in ws))
def live_deps(cid: str) -> dict[str, dict]:
return {d["name"]: d for d in (unwrap(api("GET", "/clusters/%s/deployments" % cid)) or [])}
def live_services(cid: str) -> dict[str, dict]:
return {s["name"]: s for s in (unwrap(api("GET", "/clusters/%s/services" % cid)) or [])}
def patch_live(cid: str, name: str, patch: dict) -> bool:
"""워크로드가 이미 배포돼 있으면 PATCH(재빌드 없이 롤링 반영). 반환=적용여부."""
dep = live_deps(cid).get(name)
if not dep:
return False
api("PATCH", "/deployments/%s" % dep["id"], patch)
return True
# ── 커맨드 ──────────────────────────────────────────────────────────────
def cmd_info(_a) -> None:
m = load_manifest()
print("project : %s" % m.get("project", "-"))
ref = m.get("cluster") or os.environ.get("YAKCLOUD_CLUSTER") or "-"
deps: dict[str, dict] = {}
svcs: dict[str, dict] = {}
doms: list[dict] = []
cinfo = None
if URL and TOK and (m.get("cluster") or os.environ.get("YAKCLOUD_CLUSTER")):
cinfo = resolve_cluster(cluster_ref(m))
deps = live_deps(cinfo["id"])
svcs = live_services(cinfo["id"])
doms = unwrap(api("GET", "/clusters/%s/domains" % cinfo["id"])) or []
print("cluster : %s (id %s) 도메인 %s" % (cinfo.get("name"), cinfo["id"], cinfo.get("defaultHostname", "-")))
else:
print("cluster : %s (라이브 상태는 YAKCLOUD_URL/TOKEN 설정 시 표시)" % ref)
print("\nworkloads:")
for w in m.get("workloads", []) or []:
ex = w.get("expose", {}) or {}
hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])
line = "%-14s image=%s port=%s replicas=%s health=%s" % (
w.get("name"), w.get("image"), w.get("port"), w.get("replicas", 1), w.get("health", "-"))
d = deps.get(w.get("name"))
if d:
live_hosts = d.get("exposeHosts") or ([d["exposeHost"]] if d.get("exposeHost") else [])
line += "\n live: %s %s/%s hosts=%s" % (
d.get("status"), d.get("replicasReady", 0), d.get("replicasDesired", 0),
", ".join(live_hosts) or "(내부)")
elif hosts:
line += "\n manifest hosts=%s" % ", ".join(hosts)
binds = w.get("binds", []) or []
if binds:
line += "\n binds: %s" % ", ".join("%s%s" % (b["alias"], b["source"]) for b in binds)
print(line)
reqs = m.get("requires", []) or []
if reqs:
print("\ndata sources (requires):")
for r in reqs:
s = svcs.get(r["name"])
st = (" [%s]" % s.get("status")) if s else ""
print("%-14s %s/%s%s" % (r["name"], r.get("type"), r.get("plan", "small"), st))
if doms:
print("\ndomains (cluster):")
for d in doms:
print("%-28s %s cert=%s" % (d["fqdn"], d["status"], d.get("certStatus")))
print("\n다음: 수정=yakcloud (scale|set|env|domain|source|bind) · 배포=yakcloud deploy vX.Y.Z")
def _apply_workload_patch(m, cid_or_none, w, patch, label):
"""매니페스트 저장 후, 배포돼 있으면 라이브 PATCH."""
save_manifest(m)
print(" ✓ 매니페스트: %s %s" % (w["name"], label))
if cid_or_none and patch and patch_live(cid_or_none, w["name"], patch):
print(" ✓ 라이브 반영(재빌드 없음): %s" % w["name"])
elif cid_or_none:
print(" · 아직 미배포 — 'yakcloud deploy' 시 반영")
def _cid(m):
if URL and TOK:
return resolve_cluster(cluster_ref(m))["id"]
return None
def cmd_scale(a) -> None:
m = load_manifest(); w = find_workload(m, a.workload)
w["replicas"] = int(a.replicas)
_apply_workload_patch(m, _cid(m), w, {"replicasDesired": int(a.replicas)}, "replicas=%s" % a.replicas)
def cmd_set(a) -> None:
m = load_manifest(); w = find_workload(m, a.workload)
patch, changed = {}, []
if a.image is not None:
w["image"] = a.image; patch["image"] = a.image; changed.append("image")
if a.port is not None:
w["port"] = int(a.port); patch["port"] = int(a.port); changed.append("port")
if a.health is not None:
w["health"] = a.health; patch["healthPath"] = a.health; changed.append("health")
if a.cpu is not None or a.mem is not None:
res = w.get("resources", {}) or {}; w["resources"] = res
if a.cpu is not None:
res["cpu"] = a.cpu; patch["cpuRequest"] = a.cpu; changed.append("cpu")
if a.mem is not None:
res["mem"] = a.mem; patch["memRequest"] = a.mem; changed.append("mem")
if a.path is not None or a.rewrite is not None:
ex = w.get("expose", {}) or {}; w["expose"] = ex
if a.path is not None:
ex["path"] = a.path; patch["path"] = a.path; changed.append("path")
if a.rewrite is not None:
rw = a.rewrite.lower() in ("1", "true", "yes", "on")
ex["rewrite"] = rw; patch["rewritePrefix"] = rw; changed.append("rewrite")
if not changed:
sys.exit(" ✗ 변경할 항목 없음 — --image/--port/--health/--cpu/--mem/--path/--rewrite")
_apply_workload_patch(m, _cid(m), w, patch, "set " + " ".join(changed))
def cmd_env(a) -> None:
m = load_manifest(); w = find_workload(m, a.workload)
env = w.get("env", []) or []
if isinstance(env, dict):
env = [{"key": k, "value": v} for k, v in env.items()]
idx = {e["key"]: e for e in env}
for pair in a.pairs:
if "=" not in pair:
sys.exit(" ✗ KEY=VALUE 형식이어야 합니다: %s" % pair)
k, v = pair.split("=", 1)
idx[k] = {"key": k, "value": v, **({"secret": True} if k in (a.secret or []) else {})}
for k in (a.unset or []):
idx.pop(k, None)
env = list(idx.values())
w["env"] = env
patch = {"env": [{"key": e["key"], "value": str(e.get("value", "")), "secret": bool(e.get("secret", False))} for e in env]}
_apply_workload_patch(m, _cid(m), w, patch, "env=[%s]" % ", ".join(e["key"] for e in env))
def cmd_source(a) -> None:
m = load_manifest(); reqs = m.setdefault("requires", []) or []
m["requires"] = reqs
if a.action == "add":
if any(r.get("name") == a.name for r in reqs):
sys.exit(" ✗ 이미 있음: %s" % a.name)
reqs.append({"name": a.name, "type": a.type, "plan": a.plan})
save_manifest(m)
print(" ✓ requires += {name=%s, type=%s, plan=%s}" % (a.name, a.type, a.plan))
print(" · 프로비저닝/바인딩은 'yakcloud bind <workload> %s <alias>''yakcloud deploy'" % a.name)
else: # rm
m["requires"] = [r for r in reqs if r.get("name") != a.name]
# 관련 바인딩도 정리
for w in m.get("workloads", []) or []:
w["binds"] = [b for b in (w.get("binds", []) or []) if b.get("source") != a.name]
save_manifest(m)
print(" ✓ requires 에서 제거: %s (관련 binds 정리). 'yakcloud deploy' 로 반영" % a.name)
def cmd_bind(a) -> None:
m = load_manifest(); w = find_workload(m, a.workload)
if not any(r.get("name") == a.source for r in (m.get("requires", []) or [])):
sys.exit(" ✗ requires 에 소스 '%s' 없음 — 'yakcloud source add %s <type>' 먼저" % (a.source, a.source))
binds = w.get("binds", []) or []; w["binds"] = binds
binds[:] = [b for b in binds if b.get("alias") != a.alias]
binds.append({"alias": a.alias, "source": a.source})
save_manifest(m)
print("%s.binds += {alias=%s, source=%s}. 'yakcloud deploy' 로 반영(env 주입)" % (w["name"], a.alias, a.source))
def cmd_unbind(a) -> None:
m = load_manifest(); w = find_workload(m, a.workload)
binds = w.get("binds", []) or []
w["binds"] = [b for b in binds if b.get("alias") != a.alias]
save_manifest(m)
print("%s.binds 에서 alias=%s 제거. 'yakcloud deploy' 로 반영" % (w["name"], a.alias))
def cmd_domain(a) -> None:
need_api()
m = load_manifest(); c = resolve_cluster(cluster_ref(m)); cid = c["id"]
doms = unwrap(api("GET", "/clusters/%s/domains" % cid)) or []
d = next((x for x in doms if x["fqdn"] == a.fqdn), None)
if d:
print(" = 이미 등록됨: %s (status=%s)" % (a.fqdn, d["status"]))
else:
d = unwrap(api("POST", "/clusters/%s/domains" % cid, {"fqdn": a.fqdn}))
print(" ✓ 등록: %s status=%s cert=%s" % (a.fqdn, d["status"], d["certStatus"]))
v = d.get("verify")
if v:
print(' 외부 도메인 — DNS 에 TXT 추가 후 검증:\n %s TXT "%s"' % (v["host"], v["value"]))
# 매니페스트 워크로드에 연결
ws = m.get("workloads", []) or []
w = next((x for x in ws if x.get("name") == a.workload), None) if a.workload else (ws[0] if len(ws) == 1 else None)
if w is None and ws:
print(" · 워크로드를 지정하세요: yakcloud domain %s <workload> (%s)"
% (a.fqdn, ", ".join(x.get("name", "?") for x in ws)))
return
if w is None:
return
ex = w.get("expose", {}) or {}; w["expose"] = ex
hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])
if a.fqdn not in hosts:
hosts.append(a.fqdn)
ex.pop("host", None); ex["hosts"] = hosts
save_manifest(m)
print(" ✓ 매니페스트: %s.expose.hosts=%s" % (w["name"], hosts))
dep = live_deps(cid).get(w["name"])
if dep and d["status"] == "ACTIVE":
cur = dep.get("exposeHosts") or ([dep["exposeHost"]] if dep.get("exposeHost") else [])
newh = list(dict.fromkeys(cur + [a.fqdn]))
api("PATCH", "/deployments/%s" % dep["id"], {"exposeHosts": newh})
print(" ✓ 라이브 반영(재빌드 없음): %s%s" % (w["name"], ", ".join(newh)))
elif dep:
print(" · 도메인 활성 후 'yakcloud deploy' 로 반영")
else:
print(" · 미배포 — 'yakcloud deploy' 시 이 도메인으로 노출")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="yakcloud", add_help=True)
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("info", help="프로젝트 개괄(매니페스트 + 라이브 상태)").set_defaults(fn=cmd_info)
d = sub.add_parser("domain", help="도메인 등록 + 워크로드 할당")
d.add_argument("fqdn"); d.add_argument("workload", nargs="?")
d.set_defaults(fn=cmd_domain)
s = sub.add_parser("scale", help="워크로드 replicas 변경")
s.add_argument("workload"); s.add_argument("replicas", type=int)
s.set_defaults(fn=cmd_scale)
se = sub.add_parser("set", help="워크로드 필드 수정")
se.add_argument("workload")
se.add_argument("--image"); se.add_argument("--port"); se.add_argument("--health")
se.add_argument("--cpu"); se.add_argument("--mem"); se.add_argument("--path"); se.add_argument("--rewrite")
se.set_defaults(fn=cmd_set)
e = sub.add_parser("env", help="워크로드 환경변수 KEY=VALUE 설정/해제")
e.add_argument("workload"); e.add_argument("pairs", nargs="*")
e.add_argument("--secret", action="append", help="이 KEY 를 secret 으로 표시")
e.add_argument("--unset", action="append", help="이 KEY 제거")
e.set_defaults(fn=cmd_env)
so = sub.add_parser("source", help="데이터 소스(requires) 추가/삭제")
sosub = so.add_subparsers(dest="action", required=True)
soa = sosub.add_parser("add"); soa.add_argument("name"); soa.add_argument("type")
soa.add_argument("plan", nargs="?", default="small"); soa.set_defaults(fn=cmd_source)
sor = sosub.add_parser("rm"); sor.add_argument("name"); sor.set_defaults(fn=cmd_source)
b = sub.add_parser("bind", help="워크로드에 소스 바인딩")
b.add_argument("workload"); b.add_argument("source"); b.add_argument("alias")
b.set_defaults(fn=cmd_bind)
u = sub.add_parser("unbind", help="워크로드 바인딩 해제")
u.add_argument("workload"); u.add_argument("alias")
u.set_defaults(fn=cmd_unbind)
return p
if __name__ == "__main__":
args = build_parser().parse_args()
args.fn(args)

View File

@ -120,6 +120,12 @@ def deploy_workload(w: dict) -> tuple[str | None, bool]:
body["exposeHosts"] = hosts body["exposeHosts"] = hosts
else: else:
body["exposeHost"] = ex.get("host") or (CLUSTER_HOST or None) body["exposeHost"] = ex.get("host") or (CLUSTER_HOST or None)
# 환경변수(선언적): 리스트[{key,value,secret?}] 또는 맵{KEY: VALUE}.
env = w.get("env") or []
if isinstance(env, dict):
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]
if DRY: if DRY:
log(f"deploy '{w['name']}' 예정: image={image} port={body['port']} path={body['path']} " log(f"deploy '{w['name']}' 예정: image={image} port={body['port']} path={body['path']} "
f"replicas={body['replicasDesired']} hosts={hosts or body.get('exposeHost')}") f"replicas={body['replicasDesired']} hosts={hosts or body.get('exposeHost')}")