diff --git a/.claude/skills/yakcloud-deploy/SKILL.md b/.claude/skills/yakcloud-deploy/SKILL.md index 7032b14..fa40eda 100644 --- a/.claude/skills/yakcloud-deploy/SKILL.md +++ b/.claude/skills/yakcloud-deploy/SKILL.md @@ -17,12 +17,23 @@ YakCloud 콘솔 API로 **데이터소스 리컨실 + 워크로드 배포 + 바 - "데이터소스(Postgres/Mongo/…) 연결/바인딩" / "커스텀 도메인으로 노출" ## CLI 가 있으면 우선 사용 -`yakcloud` CLI 가 설치돼 있으면(명령 `command -v yakcloud`) 스캐폴딩/배포를 이걸로 처리한다: -- **"프로젝트 초기화"** → 빈 폴더에서 `yakcloud init [name]` (스타터+CI+매니페스트+이 스킬을 내려받음) -- **"yakcloud 에 배포"** → `yakcloud deploy vX.Y.Z` (커밋+태그 push → Gitea Actions) -- 사전 점검 → `yakcloud check` (dry-run) - +`yakcloud` CLI 가 설치돼 있으면(`command -v yakcloud`) 스캐폴딩/배포/환경설정을 이걸로 처리한다. 설치: `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 [wl]` 도메인 등록 + 워크로드 할당 +- `yakcloud scale ` replicas +- `yakcloud set --image/--port/--health/--cpu/--mem/--path/--rewrite` +- `yakcloud env KEY=VAL … [--secret KEY] [--unset KEY]` +- `yakcloud source add [plan]` · `yakcloud source rm ` +- `yakcloud bind ` · `yakcloud unbind ` + CLI 가 없거나 세밀 조정이 필요하면 아래 수동 절차를 따른다. ## 에이전트 실행 절차 diff --git a/.claude/skills/yakcloud-deploy/assets/bin/yakcloud b/.claude/skills/yakcloud-deploy/assets/bin/yakcloud old mode 100644 new mode 100755 index fee1b42..94bbfaa --- a/.claude/skills/yakcloud-deploy/assets/bin/yakcloud +++ b/.claude/skills/yakcloud-deploy/assets/bin/yakcloud @@ -1,25 +1,47 @@ #!/usr/bin/env bash -# yakcloud — YakCloud 프로젝트 CLI. 빈 폴더 초기화(스타터+스킬) + 태그 push 자동배포. -# yakcloud init [name] 현재(빈) 폴더 또는 name/ 에 스타터 스캐폴딩(.claude 스킬 포함) -# yakcloud deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포 -# yakcloud check dry-run(직접 API, 계획만; YAKCLOUD_URL/TOKEN 필요) +# yakcloud — YakCloud 프로젝트 CLI. 빈 폴더 초기화(스타터+스킬), 배포, 배포환경 상세 설정. +# +# yakcloud project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬) +# yakcloud project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포 +# yakcloud project info 프로젝트 개괄(매니페스트 + 라이브 상태) +# yakcloud project check dry-run(직접 API 계획만) +# +# yakcloud domain [wl] 도메인 등록 + 워크로드에 할당(관리형=즉시, 배포중이면 라이브) +# yakcloud scale replicas 변경 +# yakcloud set --image/--port/--health/--cpu/--mem/--path/--rewrite +# yakcloud env KEY=VAL … [--secret KEY] [--unset KEY] +# yakcloud source add [plan] | source rm +# yakcloud bind | unbind +# +# env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER set -euo pipefail REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}" 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 "▸ $*"; } -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:-}" - if [ -n "$name" ]; then - mkdir -p "$name" && cd "$name" - fi - # 빈 폴더인지 확인(.git 은 허용) + [ -n "$name" ] && { mkdir -p "$name"; cd "$name"; } if [ -n "$(ls -A . 2>/dev/null | grep -v '^\.git$' || true)" ]; then - die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud init ' 로 새 폴더에 생성하세요." + die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud project init '." fi info "yakcloud-starter 내려받는 중… ($REPO @ $BRANCH)" 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" \ || { rm -rf "$tmp"; die "스타터 클론 실패 — 네트워크/레포 공개 여부 확인 ($REPO)"; } rm -rf "$tmp/s/.git" - cp -R "$tmp/s/." . # 숨김파일 포함 전부 현재 폴더로 + cp -R "$tmp/s/." . rm -rf "$tmp" - # 프로젝트 이름 치환(선택) - if [ -n "$name" ] && [ -f yakcloud.yaml ]; then - sed -i.bak "s/^project: .*/project: $name/" yakcloud.yaml && rm -f yakcloud.yaml.bak - fi + [ -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))" - echo " 다음:" - echo " 1) yakcloud.yaml 의 cluster / image() 수정, 필요하면 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" + echo " 다음: yakcloud project info · yakcloud domain … · yakcloud project deploy v0.1.0" } -cmd_deploy() { - [ -f yakcloud.yaml ] || die "yakcloud.yaml 없음 — 먼저 'yakcloud init'" - local ver="${1:-v0.1.0}" - case "$ver" in v*) : ;; *) ver="v$ver" ;; esac +cmd_project_deploy() { + [ -f yakcloud.yaml ] || die "yakcloud.yaml 없음 — 'yakcloud project init' 먼저" 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 설정 뒤 다시 실행" - info "커밋 + 태그 $ver push…" - git add -A - git commit -q -m "deploy $ver" 2>/dev/null || true + || die "git 원격(origin) 없음 — Gitea 레포 만들어 'git remote add origin ' + Secrets 설정 후 다시" + 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 + 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 tag "$ver" 2>/dev/null || die "태그 $ver 이미 존재 — 다른 버전으로" + 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 에서 빌드·배포. 콘솔 앱 탭/도메인 확인." } -cmd_check() { - [ -f scripts/yakcloud_deploy.py ] || die "scripts/yakcloud_deploy.py 없음 — 'yakcloud init' 먼저" - : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}" - : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN(배포 토큰) 필요}" - python3 -c "import yaml" 2>/dev/null || pip3 install --quiet pyyaml +cmd_project_check() { + [ -f scripts/yakcloud_deploy.py ] || die "'yakcloud project init' 먼저" + : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요}" + ensure_pyyaml python3 scripts/yakcloud_deploy.py yakcloud.yaml --dry-run } -case "${1:-help}" in - init) shift; cmd_init "$@" ;; - deploy) shift; cmd_deploy "$@" ;; - check|dry-run) shift; cmd_check "$@" ;; - version|-v|--version) echo "yakcloud $VERSION" ;; - *) cat < [wl] 도메인 등록 + 워크로드 할당(관리형=즉시/배포중이면 라이브) + scale replicas 변경 + set --image/--port/--health/--cpu/--mem/--path/--rewrite + env KEY=VAL … [--secret KEY] [--unset KEY] + source add [plan] | source rm + bind | unbind +env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER 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 "; 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 diff --git a/.claude/skills/yakcloud-deploy/assets/install.sh b/.claude/skills/yakcloud-deploy/assets/install.sh old mode 100644 new mode 100755 diff --git a/.claude/skills/yakcloud-deploy/assets/scripts/yakcloud_ctl.py b/.claude/skills/yakcloud-deploy/assets/scripts/yakcloud_ctl.py new file mode 100644 index 0000000..32aead8 --- /dev/null +++ b/.claude/skills/yakcloud-deploy/assets/scripts/yakcloud_ctl.py @@ -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 %s ' 후 '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 ' 먼저" % (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 (%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) diff --git a/.claude/skills/yakcloud-deploy/assets/scripts/yakcloud_deploy.py b/.claude/skills/yakcloud-deploy/assets/scripts/yakcloud_deploy.py index f7d4406..c865a96 100644 --- a/.claude/skills/yakcloud-deploy/assets/scripts/yakcloud_deploy.py +++ b/.claude/skills/yakcloud-deploy/assets/scripts/yakcloud_deploy.py @@ -120,6 +120,12 @@ def deploy_workload(w: dict) -> tuple[str | None, bool]: body["exposeHosts"] = hosts else: 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: log(f"deploy '{w['name']}' 예정: image={image} port={body['port']} path={body['path']} " f"replicas={body['replicasDesired']} hosts={hosts or body.get('exposeHost')}") diff --git a/README.md b/README.md index b7ecb7b..cfe2ab4 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,54 @@ # yakcloud-starter — YakCloud 프로젝트 기반 + CLI + Claude Code 스킬 -빈 폴더에서 **한 명령으로 새 프로젝트를 초기화**하고, **태그 push로 YakCloud에 자동 배포**하기 위한 기반. -프레임워크 중립 스타터 앱 + CI + 매니페스트 + **`yakcloud` CLI** + **Claude Code 스킬**(`.claude/skills/yakcloud-deploy/`)이 들어 있다. +빈 폴더에서 **한 명령으로 프로젝트를 초기화**하고, **태그 push로 자동 배포**하며, **배포 환경을 CLI로 상세 설정**한다. +프레임워크 중립 스타터 앱 + CI + 매니페스트 + **`yakcloud` CLI** + **Claude Code 스킬**(`.claude/skills/yakcloud-deploy/`) 포함. ## CLI 설치 (1회) ```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 -yakcloud init my-app # 빈 폴더에 스타터+CI+매니페스트+Claude 스킬 스캐폴딩 +yakcloud project init my-app # 스타터+CI+매니페스트+Claude 스킬 스캐폴딩 cd my-app -# (앱 개발 — Claude Code 로: "yakcloud 로 배포 붙여줘" / 앱 코드 작성) -# yakcloud.yaml 의 cluster / image() 수정, 필요하면 requires·binds 추가 -yakcloud deploy v0.1.0 # 커밋 + 태그 push → Gitea Actions 자동 배포 +# (앱 개발 — Claude Code: "yakcloud 로 배포 붙여줘") +yakcloud project info # 프로젝트 개괄(매니페스트 + 라이브 상태) +yakcloud project deploy # 커밋 + 태그 push (버전 미지정 시 자동 버전업) → 자동 배포 ``` -> **Claude Code 흐름**: 빈 폴더에서 Claude Code를 열고 "프로젝트 초기화해줘" → Claude가 `yakcloud init` 실행 → -> 개발 → "yakcloud에 배포해줘" → Claude가 `yakcloud deploy` 실행. (CLI가 없으면 스킬의 수동 절차로 진행) +> **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 [wl] 도메인 등록 + 워크로드 할당(관리형=즉시 활성) +scale replicas +set --image/--port/--health/--cpu/--mem/--path/--rewrite +env KEY=VAL … [--secret KEY] [--unset KEY] +source add [plan] | source rm +bind | unbind +``` +필요 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` 수정. - -사전 점검(직접 API, 계획만): -```sh -YAKCLOUD_URL=https://console.yakenator.io YAKCLOUD_TOKEN=yakd_… yakcloud check -``` +3. `yakcloud.yaml` 의 `cluster`·`image`(``) 수정. ## 구성 ``` -bin/yakcloud # CLI(init/deploy/check) +bin/yakcloud # CLI 디스패처 install.sh # CLI 설치 -.claude/skills/yakcloud-deploy/ # Claude Code 스킬(SKILL.md + assets) — 프로젝트에 그대로 따라감 -app.py Dockerfile # 프레임워크 중립 스타터(표준 라이브러리, /healthz + PORT) +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(매니페스트 구동 — 수정 불필요) -scripts/yakcloud_deploy.py # 리컨실 배포 CLI +.gitea/workflows/deploy.yml # 태그 push CI(매니페스트 구동) DATA-SOURCES.md # 소스별 주입 env 표 ``` - -## 확장(추가/삭제) -- **워크로드/데이터소스/도메인 추가·삭제**는 `yakcloud.yaml` 을 편집하고 `yakcloud deploy` — 멱등 리컨실로 반영. -- 상세: `.claude/skills/yakcloud-deploy/SKILL.md`. diff --git a/bin/yakcloud b/bin/yakcloud index fee1b42..94bbfaa 100755 --- a/bin/yakcloud +++ b/bin/yakcloud @@ -1,25 +1,47 @@ #!/usr/bin/env bash -# yakcloud — YakCloud 프로젝트 CLI. 빈 폴더 초기화(스타터+스킬) + 태그 push 자동배포. -# yakcloud init [name] 현재(빈) 폴더 또는 name/ 에 스타터 스캐폴딩(.claude 스킬 포함) -# yakcloud deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포 -# yakcloud check dry-run(직접 API, 계획만; YAKCLOUD_URL/TOKEN 필요) +# yakcloud — YakCloud 프로젝트 CLI. 빈 폴더 초기화(스타터+스킬), 배포, 배포환경 상세 설정. +# +# yakcloud project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬) +# yakcloud project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포 +# yakcloud project info 프로젝트 개괄(매니페스트 + 라이브 상태) +# yakcloud project check dry-run(직접 API 계획만) +# +# yakcloud domain [wl] 도메인 등록 + 워크로드에 할당(관리형=즉시, 배포중이면 라이브) +# yakcloud scale replicas 변경 +# yakcloud set --image/--port/--health/--cpu/--mem/--path/--rewrite +# yakcloud env KEY=VAL … [--secret KEY] [--unset KEY] +# yakcloud source add [plan] | source rm +# yakcloud bind | unbind +# +# env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER set -euo pipefail REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}" 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 "▸ $*"; } -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:-}" - if [ -n "$name" ]; then - mkdir -p "$name" && cd "$name" - fi - # 빈 폴더인지 확인(.git 은 허용) + [ -n "$name" ] && { mkdir -p "$name"; cd "$name"; } if [ -n "$(ls -A . 2>/dev/null | grep -v '^\.git$' || true)" ]; then - die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud init ' 로 새 폴더에 생성하세요." + die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud project init '." fi info "yakcloud-starter 내려받는 중… ($REPO @ $BRANCH)" 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" \ || { rm -rf "$tmp"; die "스타터 클론 실패 — 네트워크/레포 공개 여부 확인 ($REPO)"; } rm -rf "$tmp/s/.git" - cp -R "$tmp/s/." . # 숨김파일 포함 전부 현재 폴더로 + cp -R "$tmp/s/." . rm -rf "$tmp" - # 프로젝트 이름 치환(선택) - if [ -n "$name" ] && [ -f yakcloud.yaml ]; then - sed -i.bak "s/^project: .*/project: $name/" yakcloud.yaml && rm -f yakcloud.yaml.bak - fi + [ -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))" - echo " 다음:" - echo " 1) yakcloud.yaml 의 cluster / image() 수정, 필요하면 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" + echo " 다음: yakcloud project info · yakcloud domain … · yakcloud project deploy v0.1.0" } -cmd_deploy() { - [ -f yakcloud.yaml ] || die "yakcloud.yaml 없음 — 먼저 'yakcloud init'" - local ver="${1:-v0.1.0}" - case "$ver" in v*) : ;; *) ver="v$ver" ;; esac +cmd_project_deploy() { + [ -f yakcloud.yaml ] || die "yakcloud.yaml 없음 — 'yakcloud project init' 먼저" 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 설정 뒤 다시 실행" - info "커밋 + 태그 $ver push…" - git add -A - git commit -q -m "deploy $ver" 2>/dev/null || true + || die "git 원격(origin) 없음 — Gitea 레포 만들어 'git remote add origin ' + Secrets 설정 후 다시" + 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 + 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 tag "$ver" 2>/dev/null || die "태그 $ver 이미 존재 — 다른 버전으로" + 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 에서 빌드·배포. 콘솔 앱 탭/도메인 확인." } -cmd_check() { - [ -f scripts/yakcloud_deploy.py ] || die "scripts/yakcloud_deploy.py 없음 — 'yakcloud init' 먼저" - : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}" - : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN(배포 토큰) 필요}" - python3 -c "import yaml" 2>/dev/null || pip3 install --quiet pyyaml +cmd_project_check() { + [ -f scripts/yakcloud_deploy.py ] || die "'yakcloud project init' 먼저" + : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요}" + ensure_pyyaml python3 scripts/yakcloud_deploy.py yakcloud.yaml --dry-run } -case "${1:-help}" in - init) shift; cmd_init "$@" ;; - deploy) shift; cmd_deploy "$@" ;; - check|dry-run) shift; cmd_check "$@" ;; - version|-v|--version) echo "yakcloud $VERSION" ;; - *) cat < [wl] 도메인 등록 + 워크로드 할당(관리형=즉시/배포중이면 라이브) + scale replicas 변경 + set --image/--port/--health/--cpu/--mem/--path/--rewrite + env KEY=VAL … [--secret KEY] [--unset KEY] + source add [plan] | source rm + bind | unbind +env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER 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 "; 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 diff --git a/scripts/yakcloud_ctl.py b/scripts/yakcloud_ctl.py new file mode 100644 index 0000000..32aead8 --- /dev/null +++ b/scripts/yakcloud_ctl.py @@ -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 %s ' 후 '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 ' 먼저" % (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 (%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) diff --git a/scripts/yakcloud_deploy.py b/scripts/yakcloud_deploy.py index f7d4406..c865a96 100644 --- a/scripts/yakcloud_deploy.py +++ b/scripts/yakcloud_deploy.py @@ -120,6 +120,12 @@ def deploy_workload(w: dict) -> tuple[str | None, bool]: body["exposeHosts"] = hosts else: 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: log(f"deploy '{w['name']}' 예정: image={image} port={body['port']} path={body['path']} " f"replicas={body['replicasDesired']} hosts={hosts or body.get('exposeHost')}")