385 lines
22 KiB
Python
385 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""yakcloud deploy — 매니페스트(yakcloud.yaml) 기반 리컨실 배포 CLI (콘솔 API).
|
|
|
|
흐름(선언적 idempotent):
|
|
1) requires 리컨실 — 논리 이름의 소스가 READY 면 스킵, 없으면 type/plan 으로 프로비저닝 → READY 대기.
|
|
2) workloads 배포 — 각 워크로드 배포(POST .../deployments). 기존이면 PATCH(무중단 롤링).
|
|
3) 바인딩 — 각 bind(alias→source) 를 POST /services/{serviceId}/bindings {deploymentId, alias}.
|
|
|
|
인증: 콘솔 API 에 개인 배포 토큰(PAT) Bearer. (환경변수)
|
|
YAKCLOUD_URL 예) https://console.yakenator.io
|
|
YAKCLOUD_TOKEN 배포 토큰(PAT) — 콘솔 설정에서 발급
|
|
YAKCLOUD_CLUSTER 대상 클러스터 이름 또는 콘솔 id (없으면 매니페스트 cluster:)
|
|
TAG 이미지 태그 치환용(${TAG}); 없으면 latest
|
|
사용: yakcloud_deploy.py [manifest.yaml] [--dry-run]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
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"]
|
|
CLUSTER_REF = os.environ.get("YAKCLOUD_CLUSTER")
|
|
CLUSTER = "" # main 에서 이름→id 로 해석해 채운다
|
|
CLUSTER_HOST = "" # 클러스터 기본 도메인 — 항상 노출(운영은 여기 + 운영 도메인)
|
|
ENV = os.environ.get("YAKCLOUD_ENV", "dev") # 대상 환경 environments.<env> (기본 dev). promote=prod
|
|
ENV_DOMAINS: list = [] # main 에서 environments.<env>.domains 로 채움(운영 도메인)
|
|
TAG = os.environ.get("TAG", "latest")
|
|
DRY = "--dry-run" in sys.argv[1:]
|
|
|
|
|
|
def api(method: str, path: str, body: dict | None = None, _retry: int = 5) -> dict:
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
req = urllib.request.Request(f"{URL}/api/v1{path}", data=data, method=method,
|
|
headers={"Authorization": f"Bearer {TOKEN}", "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:
|
|
payload = e.read().decode()
|
|
if e.code == 429 and _retry > 0: # 쓰기 레이트리밋 — retryAfterSec 백오프 후 재시도
|
|
try:
|
|
wait = json.loads(payload).get("error", {}).get("detail", {}).get("retryAfterSec", 2)
|
|
except Exception: # noqa: BLE001
|
|
wait = 2
|
|
time.sleep(max(1, int(wait)) + 1)
|
|
return api(method, path, body, _retry - 1)
|
|
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):
|
|
return r.get("data", r) if isinstance(r, dict) else r
|
|
|
|
|
|
def log(m: str) -> None:
|
|
print(("\033[33m[dry]\033[0m " if DRY else "\033[36m▸\033[0m ") + m, flush=True)
|
|
|
|
|
|
def resolve_cluster(ref: str) -> tuple[str, str]:
|
|
clusters = unwrap(api("GET", "/clusters")) or []
|
|
for c in clusters:
|
|
if ref in (c.get("id"), c.get("name")):
|
|
return c["id"], c.get("name") or c["id"]
|
|
names = ", ".join(c.get("name", "?") for c in clusters) or "(없음)"
|
|
raise SystemExit(f"클러스터 '{ref}' 를 찾을 수 없습니다. 계정 클러스터: {names}")
|
|
|
|
|
|
def cluster_services() -> list[dict]:
|
|
return unwrap(api("GET", f"/clusters/{CLUSTER}/services")) or []
|
|
|
|
|
|
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:
|
|
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 로 명시).
|
|
mode = req.get("mode") or ("local" if stype == "REDIS" else "shared")
|
|
if stype == "REDIS" and mode == "shared": # 통일: redis 는 공유(프리픽스) 비투명 → 항상 전용(local)
|
|
log("⚠ Redis 는 공유(ACL 키/채널 프리픽스)가 pub/sub·맨키에 비투명이라 지원하지 않습니다 → 전용(local)으로 전환. "
|
|
"외부 관리 redis 는 mode: remote 로.")
|
|
mode = "local"
|
|
match = next((s for s in cluster_services() if s.get("name") == name), None)
|
|
if match and match.get("status") == "READY":
|
|
cur = str(match.get("mode") or "").lower()
|
|
if cur and cur != mode:
|
|
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 '{disp}' 상태={match.get('status')} — READY 대기 필요")
|
|
else:
|
|
plan_or_mode = "shared" if mode == "shared" else plan
|
|
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 '{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/계정 민팅).
|
|
body = {"clusterId": CLUSTER, "type": stype, "name": name, "mode": mode}
|
|
if mode == "local":
|
|
body["size"] = plan
|
|
api("POST", "/services", body)
|
|
except SystemExit as e:
|
|
raise SystemExit(
|
|
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 '{disp}' READY (id={s['id']})")
|
|
return s["id"]
|
|
if s and s.get("status") == "ERROR":
|
|
raise SystemExit(f"source '{disp}' 프로비저닝 ERROR")
|
|
time.sleep(5)
|
|
raise SystemExit(f"source '{disp}' 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 _repo(img: str) -> str:
|
|
"""이미지에서 :tag 제거한 repo(레지스트리 포트 host:5000/… 의 콜론은 보존)."""
|
|
seg = img.rsplit("/", 1)[-1]
|
|
return img.rsplit(":", 1)[0] if ":" in seg else img
|
|
|
|
|
|
def synced_health_path(path: str) -> str:
|
|
"""경로 동기화 기본: healthPath = 경로 + '/healthz' (예: '/b21'→'/b21/healthz', '/'→'/healthz').
|
|
콘솔 '경로 동기화'(기본 ON)와 정합 — 매니페스트에 health 를 명시하면 그 값을 그대로 쓴다."""
|
|
base = (path or "/").strip().rstrip("/")
|
|
return base + "/healthz"
|
|
|
|
|
|
def deploy_workload(w: dict) -> tuple[str | None, bool]:
|
|
image = w["image"].replace("${TAG}", TAG)
|
|
ex = w.get("expose", {}) or {}
|
|
res = w.get("resources", {}) or {}
|
|
body = {
|
|
"name": w["name"], "image": image,
|
|
"port": w.get("port"),
|
|
"replicasDesired": w.get("replicas", 1),
|
|
"cpuRequest": res.get("cpu", "25m"), "memRequest": res.get("mem", "96Mi"),
|
|
# health 미지정 시 경로와 동기화(경로+/healthz). 명시하면 그 값(커스텀).
|
|
"healthPath": w.get("health") or synced_health_path(ex.get("path", "/")),
|
|
"path": ex.get("path", "/"), "pathType": "Prefix", "rewritePrefix": bool(ex.get("rewrite", False)),
|
|
}
|
|
# 노출 도메인 = 워크로드 지정 host(s) + 환경 도메인(운영). 커스텀이 있으면 기본 도메인도 함께 노출.
|
|
wl_hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])
|
|
custom = list(dict.fromkeys(wl_hosts + ENV_DOMAINS))
|
|
if custom:
|
|
body["exposeHosts"] = list(dict.fromkeys(custom + ([CLUSTER_HOST] if CLUSTER_HOST else [])))
|
|
else:
|
|
body["exposeHost"] = 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]
|
|
# 충돌 가드 — 같은 이름의 기존 배포가 '다른 앱'(다른 이미지 repo)이면 덮어쓰기 거부(무조건 배포 금지).
|
|
# 같은 repo(태그만 다름)=정상 재배포는 PATCH 로 진행. (백엔드 create_deployment 도 동일하게 409 로 강제.)
|
|
existing = next((d for d in cluster_deployments() if d.get("name") == w["name"]), None)
|
|
if existing:
|
|
ex_repo = _repo(existing.get("image") or "")
|
|
if ex_repo and ex_repo != _repo(image):
|
|
raise SystemExit(
|
|
f"배포명 '{w['name']}' 충돌 — 이 클러스터에 이미 다른 앱이 그 이름을 쓰고 있습니다.\n"
|
|
f" 기존 image: {existing.get('image')}\n 내 image: {image}\n"
|
|
f" → 워크로드 이름을 바꾸거나(yakcloud set 로 name 변경) 기존 배포를 먼저 정리하세요.")
|
|
# 도메인 리컨실 — 워크로드 host(들) + 환경(운영) 도메인 등록(없으면). 충돌 통과 뒤에만.
|
|
for h in custom:
|
|
reconcile_domain(h)
|
|
if DRY:
|
|
note = "기존 동일 앱 → PATCH 롤링" if existing else "신규 생성"
|
|
log(f"deploy '{w['name']}' 예정({note}): image={image} port={body['port']} path={body['path']} "
|
|
f"replicas={body['replicasDesired']} hosts={body.get('exposeHosts') or body.get('exposeHost')}")
|
|
return None, False
|
|
if existing:
|
|
log(f"deploy '{w['name']}' 기존(동일 앱) → PATCH 롤링 업데이트 image={image}")
|
|
api("PATCH", f"/deployments/{existing['id']}", body)
|
|
return existing["id"], False
|
|
log(f"deploy '{w['name']}' 신규 생성 image={image}")
|
|
dep = unwrap(api("POST", f"/clusters/{CLUSTER}/deployments", body))
|
|
return dep.get("id"), True
|
|
|
|
|
|
def bind(dep_id: str | None, alias: str, service_id: str | None, source: str) -> None:
|
|
if DRY or not dep_id or not service_id:
|
|
log(f"bind '{alias}' → source '{source}' (serviceId={service_id}) 예정")
|
|
return
|
|
api("POST", f"/services/{service_id}/bindings", {"deploymentId": dep_id, "alias": alias})
|
|
log(f"bind '{alias}' → '{source}' 완료")
|
|
|
|
|
|
# ── 환경 오버레이: base(requires/workloads) + environments.<env> 이름 매칭 병합 ─────────────────
|
|
# 앱 binds 는 논리이름(name) 고정 → 환경별로 소스 정체(type/mode/plan)·실제 소스명(clusterName)·
|
|
# 워크로드 필드만 덮어쓴다. promote 무편집. ※ dev.py 의 동일 함수와 규약 일치(파리티) — 한쪽 바꾸면 다른 쪽도.
|
|
def _merge_env_vars(base, ov):
|
|
def norm(e):
|
|
if isinstance(e, dict):
|
|
return [{"key": k, "value": v} for k, v in e.items()]
|
|
return [dict(x) for x in (e or [])]
|
|
out = {x["key"]: x for x in norm(base)}
|
|
for x in norm(ov):
|
|
out[x["key"]] = x
|
|
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 []):
|
|
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 []):
|
|
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[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 []):
|
|
w2 = dict(w)
|
|
ov = wov.get(w["name"]) or {}
|
|
for k, v in ov.items():
|
|
if k == "env":
|
|
w2["env"] = _merge_env_vars(w.get("env"), v)
|
|
elif k in ("resources", "expose") and isinstance(v, dict):
|
|
merged = dict(w.get(k) or {}); merged.update(v); w2[k] = merged
|
|
else:
|
|
w2[k] = v # replicas/image/port/binds 등 교체
|
|
workloads.append(w2)
|
|
return requires, workloads
|
|
|
|
|
|
def main() -> None:
|
|
global CLUSTER, CLUSTER_HOST, ENV_DOMAINS
|
|
path = next((a for a in sys.argv[1:] if not a.startswith("--")), "yakcloud.yaml")
|
|
m = yaml.safe_load(open(path))
|
|
# 환경 해석: environments.<ENV>.{cluster,domains}. 하위호환: 없으면 top-level cluster:.
|
|
envs = m.get("environments") or {}
|
|
env_cfg = envs.get(ENV) or {}
|
|
# environments 의 env 클러스터가 최우선(저장/전역 YAKCLOUD_CLUSTER 는 하위호환 폴백만).
|
|
ref = env_cfg.get("cluster") or CLUSTER_REF or m.get("cluster")
|
|
if not ref:
|
|
raise SystemExit(
|
|
f"환경 '{ENV}' 대상 클러스터 미지정 — 매니페스트 environments.{ENV}.cluster (또는 YAKCLOUD_CLUSTER). "
|
|
f"등록된 환경: {', '.join(envs) or '(없음)'}")
|
|
ENV_DOMAINS = env_cfg.get("domains") or []
|
|
CLUSTER, cname = resolve_cluster(ref)
|
|
CLUSTER_HOST = (unwrap(api("GET", f"/clusters/{CLUSTER}")) or {}).get("defaultHostname") or ""
|
|
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 {}
|
|
if not cinfo.get("requireProdApproval"):
|
|
log(f"클러스터 '{cname}' 은 운영 승격 승인 게이트가 꺼져 있어 요청 불필요.")
|
|
return
|
|
for w in workloads:
|
|
try:
|
|
api("POST", f"/clusters/{CLUSTER}/prod-approvals", {"name": w["name"], "version": TAG})
|
|
log(f"운영 승격 승인 요청 등록: {w['name']} {TAG} — 콘솔에서 승인 필요")
|
|
except BaseException as e: # noqa: BLE001 — best-effort(로그인/네트워크 실패 무시)
|
|
log(f"승인 요청 등록 스킵({w['name']}): {e}")
|
|
return
|
|
src_ids: dict[str, str | None] = {}
|
|
for req in requires:
|
|
src_ids[req["name"]] = reconcile_source(req)
|
|
for w in workloads:
|
|
dep_id, created = deploy_workload(w)
|
|
# 바인딩은 매 배포마다 재적용(멱등·재바인딩) — 승격/소스변경 시 별칭이 '새 소스'를 가리키게.
|
|
# (기존엔 신규 생성 때만 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) — 실제 변경 없음")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|