feat(cli): clusterName 환경별 소스명 매핑 + 운영 소스 3-모드 결정 조회(datasource options/ls) + 검증·문서 · v0.32.0
This commit is contained in:
@ -94,7 +94,13 @@ def _ready_sources() -> str:
|
||||
|
||||
|
||||
def reconcile_source(req: dict) -> str | None:
|
||||
name, stype, plan = req["name"], req["type"].upper(), req.get("plan", "small")
|
||||
logical = req["name"]
|
||||
# 실제 클러스터 소스 이름 = clusterName(오버레이로 환경별 교체) 없으면 논리 이름.
|
||||
# 앱 binds 는 논리 이름 고정 — 로컬↔운영 소스명이 달라도 매니페스트 수정 없이 매핑된다.
|
||||
# 매칭·프로비저닝·READY 대기는 전부 이 실제 이름(name) 기준. 반환 id 는 호출부가 논리 이름으로 키.
|
||||
name = req.get("clusterName") or logical
|
||||
disp = logical if name == logical else f"{logical}→{name}"
|
||||
stype, plan = req["type"].upper(), req.get("plan", "small")
|
||||
# 기본 mode: 대부분 shared(공유 외부에 격리 DB 민팅). 단 Redis 는 공유 격리가 ACL 키/채널 '프리픽스'(비투명 —
|
||||
# 앱이 맨 키/채널로 pub/sub 하면 NOPERM)라 **기본을 전용(local)**으로 → REDIS_URL 로 전권 사용(pub/sub 포함).
|
||||
# 명시 mode 는 존중. shared redis 는 프리픽스-인지 앱 전용(mode: shared 로 명시).
|
||||
@ -107,22 +113,22 @@ def reconcile_source(req: dict) -> str | None:
|
||||
if match and match.get("status") == "READY":
|
||||
cur = str(match.get("mode") or "").lower()
|
||||
if cur and cur != mode:
|
||||
log(f"⚠ source '{name}' 은 이미 mode={cur} 로 존재 → 매니페스트 mode={mode} 는 무시(재프로비저닝 안 함). "
|
||||
f"바꾸려면 삭제 후 재배포: 'yakcloud source rm {name}'.")
|
||||
log(f"⚠ source '{name}' ({stype}) 이미 READY → 재사용·바인딩 (id={match['id']}). "
|
||||
log(f"⚠ source '{disp}' 은 이미 mode={cur} 로 존재 → 매니페스트 mode={mode} 는 무시(재프로비저닝 안 함). "
|
||||
f"바꾸려면 콘솔에서 라이브 소스 '{name}' 삭제 후 재배포(매니페스트 항목 제거는 'yakcloud source rm {logical}').")
|
||||
log(f"⚠ source '{disp}' ({stype}) 이미 READY → 재사용·바인딩 (id={match['id']}). "
|
||||
f"본인 프로젝트가 만든 소스면 정상이나, 공용 클러스터라면 '다른 프로젝트의 소스'일 수 있음 "
|
||||
f"— 이 앱의 마이그레이션이 그 소스(DB)에 테이블을 만든다. 전용이 필요하면 매니페스트에서 "
|
||||
f"프로젝트 고유 이름(예: <project>-db)으로 바꿔 재배포하라.")
|
||||
return match["id"]
|
||||
if DRY:
|
||||
if match:
|
||||
log(f"source '{name}' 상태={match.get('status')} — READY 대기 필요")
|
||||
log(f"source '{disp}' 상태={match.get('status')} — READY 대기 필요")
|
||||
else:
|
||||
plan_or_mode = "shared" if mode == "shared" else plan
|
||||
log(f"source '{name}' ({stype}, {plan_or_mode}) 없음 → 프로비저닝 예정(POST /services, mode={mode}). 현재 READY: {_ready_sources()}")
|
||||
log(f"source '{disp}' ({stype}, {plan_or_mode}) 없음 → 프로비저닝 예정(POST /services, name={name}, mode={mode}). 현재 READY: {_ready_sources()}")
|
||||
return None
|
||||
if not match:
|
||||
log(f"source '{name}' ({stype}, {'shared' if mode=='shared' else plan}) 프로비저닝 시도… (mode={mode})")
|
||||
log(f"source '{disp}' ({stype}, {'shared' if mode=='shared' else plan}) 프로비저닝 시도… (name={name}, mode={mode})")
|
||||
try:
|
||||
# 소스 생성 = POST /services (clusterId 는 body). /clusters/{id}/services 는 GET 전용.
|
||||
# local=인클러스터 프로비저닝(size 사용) · shared=공유 외부(size 무관, 백엔드가 격리 DB/계정 민팅).
|
||||
@ -132,17 +138,17 @@ def reconcile_source(req: dict) -> str | None:
|
||||
api("POST", "/services", body)
|
||||
except SystemExit as e:
|
||||
raise SystemExit(
|
||||
f"소스 '{name}' 생성 실패 — 콘솔에서 소스를 만든 뒤 requires[].name·binds[].source 를 그 소스명으로 "
|
||||
f"지정하거나 다시 시도하세요.\n 현재 READY 소스: {_ready_sources()}\n (원인: {e})")
|
||||
f"소스 '{name}' 생성 실패 — 콘솔에서 소스를 만든 뒤 requires[].name(또는 clusterName)·binds[].source 를 "
|
||||
f"그 소스명으로 지정하거나 다시 시도하세요.\n 현재 READY 소스: {_ready_sources()}\n (원인: {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":
|
||||
log(f"source '{name}' READY (id={s['id']})")
|
||||
log(f"source '{disp}' READY (id={s['id']})")
|
||||
return s["id"]
|
||||
if s and s.get("status") == "ERROR":
|
||||
raise SystemExit(f"source '{name}' 프로비저닝 ERROR")
|
||||
raise SystemExit(f"source '{disp}' 프로비저닝 ERROR")
|
||||
time.sleep(5)
|
||||
raise SystemExit(f"source '{name}' READY 대기 초과")
|
||||
raise SystemExit(f"source '{disp}' READY 대기 초과")
|
||||
|
||||
|
||||
def reconcile_domain(fqdn: str) -> None:
|
||||
@ -243,8 +249,8 @@ def bind(dep_id: str | None, alias: str, service_id: str | None, source: str) ->
|
||||
|
||||
|
||||
# ── 환경 오버레이: base(requires/workloads) + environments.<env> 이름 매칭 병합 ─────────────────
|
||||
# 앱 binds 는 논리이름 고정 → 환경별로 소스 정체(type/mode/plan)·워크로드 필드만 덮어쓴다. promote 무편집.
|
||||
# ※ dev.py 의 동일 함수와 규약 일치(파리티) — 한쪽 바꾸면 다른 쪽도.
|
||||
# 앱 binds 는 논리이름(name) 고정 → 환경별로 소스 정체(type/mode/plan)·실제 소스명(clusterName)·
|
||||
# 워크로드 필드만 덮어쓴다. promote 무편집. ※ dev.py 의 동일 함수와 규약 일치(파리티) — 한쪽 바꾸면 다른 쪽도.
|
||||
def _merge_env_vars(base, ov):
|
||||
def norm(e):
|
||||
if isinstance(e, dict):
|
||||
@ -256,18 +262,47 @@ def _merge_env_vars(base, ov):
|
||||
return list(out.values())
|
||||
|
||||
|
||||
# 지원 소스 타입(콘솔 카탈로그와 동일) — 매니페스트 사전검증용.
|
||||
SOURCE_TYPES = {"postgresql", "mysql", "mariadb", "mongodb", "redis", "minio", "rabbitmq", "solr", "oracle"}
|
||||
|
||||
|
||||
def _validate_requires(requires: list, env: str) -> None:
|
||||
"""병합된 requires 사전검증 — 배포 도중 KeyError 대신 친절한 오류로 조기 차단.
|
||||
★ 환경 오버레이는 base 하고만 병합되며 dev↔val↔prod 끼리 상속하지 않는다 →
|
||||
type 은 base(권장) 또는 그 환경에 반드시 있어야 한다. (A안: 공유 정체는 base 에 한 번.)"""
|
||||
for r in requires:
|
||||
name = r.get("name")
|
||||
t = str(r.get("type") or "").lower()
|
||||
if not t:
|
||||
raise SystemExit(
|
||||
f"소스 '{name}' 에 type 이 없습니다(환경 '{env}') — base.requires 또는 "
|
||||
f"environments.{env}.requires 에 type 을 지정하세요. 환경 오버레이는 base 하고만 "
|
||||
f"병합되고 dev↔val↔prod 끼리 상속하지 않습니다(공유 정체는 base 에 두는 걸 권장).")
|
||||
if t not in SOURCE_TYPES:
|
||||
raise SystemExit(
|
||||
f"소스 '{name}' 의 type '{r.get('type')}' 은 지원되지 않습니다(환경 '{env}') — "
|
||||
f"{', '.join(sorted(SOURCE_TYPES))} 중 하나여야 합니다.")
|
||||
|
||||
|
||||
def resolve_env(m: dict, env: str) -> tuple[list, list]:
|
||||
ec = (m.get("environments") or {}).get(env) or {}
|
||||
by: dict = {}
|
||||
order: list = []
|
||||
for r in (m.get("requires") or []):
|
||||
by[r["name"]] = dict(r); order.append(r["name"])
|
||||
nm = r.get("name")
|
||||
if not nm:
|
||||
raise SystemExit("requires 항목에 name 이 없습니다(base.requires) — 각 소스는 논리 이름 name 이 필수입니다.")
|
||||
by[nm] = dict(r); order.append(nm)
|
||||
for r in (ec.get("requires") or []):
|
||||
if r["name"] in by:
|
||||
by[r["name"]].update(r) # 필드 병합(type/mode/plan 덮어쓰기)
|
||||
nm = r.get("name")
|
||||
if not nm:
|
||||
raise SystemExit(f"requires 항목에 name 이 없습니다(environments.{env}.requires) — name 은 필수입니다.")
|
||||
if nm in by:
|
||||
by[nm].update(r) # 필드 병합(type/mode/plan/clusterName 덮어쓰기)
|
||||
else:
|
||||
by[r["name"]] = dict(r); order.append(r["name"])
|
||||
by[nm] = dict(r); order.append(nm)
|
||||
requires = [by[n] for n in order]
|
||||
_validate_requires(requires, env) # 병합 후 사전검증(type 누락·미지원 등)
|
||||
wov = ec.get("workloads") or {} # {워크로드명: 패치}
|
||||
workloads = []
|
||||
for w in (m.get("workloads") or []):
|
||||
@ -303,6 +338,16 @@ def main() -> None:
|
||||
dom = f" +도메인 {ENV_DOMAINS}" if ENV_DOMAINS else ""
|
||||
log(f"[{ENV}] project '{m.get('project')}' → cluster '{cname}' ({CLUSTER}){dom} (tag {TAG}){' [DRY-RUN]' if DRY else ''}")
|
||||
requires, workloads = resolve_env(m, ENV) # base + environments.<ENV> 오버레이 병합
|
||||
# 충돌 가드 — 서로 다른 논리 소스가 같은 '실제 소스 이름'(clusterName 우선)으로 뭉개지면 binds 가 한 물리 소스로
|
||||
# 조용히 aliasing 되어 자격/DB 오배선 → 거부(dev.py cmd_deploy 와 동일 계약·파리티).
|
||||
src_owner: dict[str, str] = {}
|
||||
for req in requires:
|
||||
actual = req.get("clusterName") or req["name"]
|
||||
if actual in src_owner and src_owner[actual] != req["name"]:
|
||||
raise SystemExit(
|
||||
f"소스명 충돌 — '{src_owner[actual]}' 와 '{req['name']}' 가 같은 실제 소스명 '{actual}'(clusterName)으로 "
|
||||
f"뭉개집니다. 논리 이름별로 실제 소스명(clusterName)을 구분하세요.")
|
||||
src_owner[actual] = req["name"]
|
||||
# 운영 승격 승인 요청만 등록(project confirm 이 호출) — 게이트 켜진 클러스터면 워크로드별 PENDING 생성 후 종료.
|
||||
if os.environ.get("YAKCLOUD_ACTION") == "register-approval":
|
||||
cinfo = unwrap(api("GET", f"/clusters/{CLUSTER}")) or {}
|
||||
@ -324,6 +369,13 @@ def main() -> None:
|
||||
# 바인딩은 매 배포마다 재적용(멱등·재바인딩) — 승격/소스변경 시 별칭이 '새 소스'를 가리키게.
|
||||
# (기존엔 신규 생성 때만 bind → shared→전용 등 소스 교체가 반영 안 되고 옛 env 에 고착되던 버그)
|
||||
for b in w.get("binds", []):
|
||||
# binds[].source 는 requires 의 '논리 이름'이어야 한다(clusterName/실제명 아님). 미해결이면
|
||||
# 조용히 no-op(옛 동작) 대신 명확히 실패시킨다 — dev.py deploy_workload 와 동일 계약(파리티).
|
||||
if b["source"] not in src_ids:
|
||||
raise SystemExit(
|
||||
f"워크로드 '{w['name']}' bind '{b['alias']}' → source '{b['source']}' 미해결 — "
|
||||
f"binds[].source 는 requires 의 논리 이름이어야 합니다(clusterName/실제명 아님). "
|
||||
f"requires: {', '.join(r['name'] for r in requires) or '(없음)'}")
|
||||
bind(dep_id, b["alias"], src_ids.get(b["source"]), b["source"])
|
||||
log("완료 — 콘솔 앱 탭에서 배포/바인딩 확인" if not DRY else "완료(dry-run) — 실제 변경 없음")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user