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

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

View File

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