cli v0.5.0: deploy가 도메인 리컨실 + 소스 405 액션형 에러 + --local 폴백/러너 프리플라이트 + 에러 힌트/pyyaml 가드; init에서 bin/install 제거; 문서(토큰권한·healthz·러너) 보강
This commit is contained in:
@ -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')}")
|
||||
|
||||
Reference in New Issue
Block a user