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:
@ -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 <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 가 없거나 세밀 조정이 필요하면 아래 수동 절차를 따른다.
|
||||
|
||||
## 에이전트 실행 절차
|
||||
|
||||
135
.claude/skills/yakcloud-deploy/assets/bin/yakcloud
Normal file → Executable file
135
.claude/skills/yakcloud-deploy/assets/bin/yakcloud
Normal file → Executable file
@ -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 <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
|
||||
|
||||
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; }
|
||||
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 <name>' 로 새 폴더에 생성하세요."
|
||||
die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud project init <name>'."
|
||||
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(<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"
|
||||
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 <url>' 후, 레포 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 <url>' + 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 <<EOF
|
||||
usage() {
|
||||
cat <<EOF
|
||||
yakcloud $VERSION — YakCloud 프로젝트 CLI
|
||||
yakcloud init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
|
||||
yakcloud deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포
|
||||
yakcloud check dry-run(직접 API 계획만; YAKCLOUD_URL/TOKEN 필요)
|
||||
project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
|
||||
project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포
|
||||
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
|
||||
;;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
0
.claude/skills/yakcloud-deploy/assets/install.sh
Normal file → Executable file
0
.claude/skills/yakcloud-deploy/assets/install.sh
Normal file → Executable file
347
.claude/skills/yakcloud-deploy/assets/scripts/yakcloud_ctl.py
Normal file
347
.claude/skills/yakcloud-deploy/assets/scripts/yakcloud_ctl.py
Normal 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)
|
||||
@ -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')}")
|
||||
|
||||
55
README.md
55
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(<GITEA_USER>) 수정, 필요하면 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 <fqdn> [wl] 도메인 등록 + 워크로드 할당(관리형=즉시 활성)
|
||||
scale <wl> <n> replicas
|
||||
set <wl> --image/--port/--health/--cpu/--mem/--path/--rewrite
|
||||
env <wl> KEY=VAL … [--secret KEY] [--unset KEY]
|
||||
source add <name> <type> [plan] | source rm <name>
|
||||
bind <wl> <source> <alias> | unbind <wl> <alias>
|
||||
```
|
||||
필요 env: `YAKCLOUD_URL`, `YAKCLOUD_TOKEN`(배포토큰 yakd_…), (선택) `YAKCLOUD_CLUSTER`
|
||||
|
||||
## 배포 전 1회 준비
|
||||
1. 콘솔 → 설정 → **배포 토큰** 발급(`yakd_…`).
|
||||
2. Gitea 레포 만들고 `git remote add origin …`. **Secrets**: `YAKCLOUD_URL`, `YAKCLOUD_TOKEN`, `YAKCLOUD_CLUSTER`, `REGISTRY_TOKEN`.
|
||||
3. `yakcloud.yaml` 의 `cluster`·`image` 수정.
|
||||
|
||||
사전 점검(직접 API, 계획만):
|
||||
```sh
|
||||
YAKCLOUD_URL=https://console.yakenator.io YAKCLOUD_TOKEN=yakd_… yakcloud check
|
||||
```
|
||||
3. `yakcloud.yaml` 의 `cluster`·`image`(`<GITEA_USER>`) 수정.
|
||||
|
||||
## 구성
|
||||
```
|
||||
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`.
|
||||
|
||||
135
bin/yakcloud
135
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 <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
|
||||
|
||||
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; }
|
||||
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 <name>' 로 새 폴더에 생성하세요."
|
||||
die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud project init <name>'."
|
||||
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(<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"
|
||||
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 <url>' 후, 레포 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 <url>' + 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 <<EOF
|
||||
usage() {
|
||||
cat <<EOF
|
||||
yakcloud $VERSION — YakCloud 프로젝트 CLI
|
||||
yakcloud init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
|
||||
yakcloud deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포
|
||||
yakcloud check dry-run(직접 API 계획만; YAKCLOUD_URL/TOKEN 필요)
|
||||
project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
|
||||
project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포
|
||||
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
|
||||
;;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
347
scripts/yakcloud_ctl.py
Normal file
347
scripts/yakcloud_ctl.py
Normal 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)
|
||||
@ -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')}")
|
||||
|
||||
Reference in New Issue
Block a user