feat(cli): clusterName 환경별 소스명 매핑 + 운영 소스 3-모드 결정 조회(datasource options/ls) + 검증·문서 · v0.32.0
This commit is contained in:
@ -23,22 +23,23 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep, AskUserQuestion
|
||||
- **`dev up|deploy|status|clean|down`** → **로컬 kind 개발 클러스터**. `dev up`(최초 1회, kind+인그레스) → `dev deploy`(소스+워크로드를 로컬에서 관리형과 동일한 바인딩 env로; 콘솔/CI 불필요). `$ARGUMENTS` 실행 후 출력 그대로 + 접속 URL(`http://<project>.dev.localhost/`) 안내. `dev down`(kind 삭제)은 실행 전 한 줄 알리고 진행.
|
||||
- **`project promote [vX.Y.Z] [--to val|prod]`** → 그 이미지를 **재빌드 없이 상위 환경으로** 승격(개발→검증→운영, 기본 --to prod).
|
||||
파괴적/외부노출이므로 실행 전 무엇을(어느 태그를 어느 환경 클러스터로) 하는지 한 줄 알리고 진행.
|
||||
- **그 외 전부**(`project deploy|info|check|update`, `datasource ls`, `domain`, `scale`, `set`, `env`,
|
||||
- **그 외 전부**(`project deploy|info|check|update`, `datasource ls|options`, `domain`, `scale`, `set`, `env`,
|
||||
`source`, `bind`, `unbind`, `config`, `upgrade`) → `yakcloud $ARGUMENTS` 를 실행하고 **출력을 그대로** 보여준 뒤,
|
||||
필요할 때만 한 줄로 해석. 배포/승격/삭제성(`deploy`·`promote`·`source rm`·`unbind`·`domain`)은 실행 전 무엇을 하는지 한 줄 알리고 진행.
|
||||
- **로컬 개발 = `yakcloud dev`(로컬 kind)**, 검증·운영 = 관리형 yakcloud 클러스터. 같은 매니페스트·같은 바인딩 계약으로 직선. 매니페스트 `environments.{dev,val,prod}`(val=검증계, 선택).
|
||||
- **로컬 개발 = `yakcloud dev`(로컬 kind)**, 검증·운영 = 관리형 yakcloud 클러스터. 같은 매니페스트·같은 바인딩 계약으로 직선. 매니페스트 = base(공유 정체) + `environments.{dev,val,prod}`(차이만; val=검증계, 선택). requires 는 논리 `name` 매칭으로 병합되고, 오버레이는 base 하고만 병합된다(dev↔val↔prod 끼리 상속 없음). 잘못 짜면 배포 전 친절한 검증 오류가 뜬다.
|
||||
|
||||
## 2) `project init` — 마법사 대체 (핵심)
|
||||
터미널 마법사는 TTY가 없으면 안 뜬다. 네가 대신한다:
|
||||
1. `yakcloud project init <name> -y` 로 **비대화형 스캐폴딩**(기본 매니페스트 생성; `bin/`·`install.sh` 잔재 없음).
|
||||
2. `$ARGUMENTS`/대화 맥락에 이미 있는 값은 **재질문 금지**. 부족한 핵심값만 `AskUserQuestion` 으로 모은다:
|
||||
**개발 클러스터, (검증 클러스터·선택), 운영 클러스터**(같으면 하나로), 앱 포트, 노출 경로(기본 `/`), CPU/메모리, 필요한 데이터소스(있으면), 운영 도메인(있으면).
|
||||
→ 매니페스트 `environments.dev.cluster` / (`environments.val.cluster`) / `environments.prod.cluster` / `environments.prod.domains` 에 반영.
|
||||
→ 매니페스트 `environments.dev.cluster` / (`environments.val.cluster`) / `environments.prod.cluster` / `environments.prod.domains` 에 반영. 참고: `requires[].name` 은 binds·오버레이 매칭용 논리 이름(불변)이고, 실제 클러스터 소스 이름은 `clusterName`(기본=name) — 로컬↔운영 소스명이 다를 때 `environments.<env>` 오버레이에서 `clusterName` 만 바꿔 매핑한다(binds 는 항상 논리 name 참조).
|
||||
3. 모은 값을 CLI로 반영:
|
||||
- `yakcloud set <wl> --port … --cpu … --mem … --path …`
|
||||
- 소스: 인클러스터(local)=`yakcloud source add <name> <type> [plan]`, 공유 외부(shared)=`yakcloud source add <name> mongodb --shared`(백엔드가 클러스터별 격리 DB 민팅; 콘솔/API 등록도 가능) + `yakcloud bind <wl> <name> <alias>`.
|
||||
- 소스(운영은 3-모드 중 결정): **local**(인클러스터 신규)=`yakcloud source add <name> <type> [plan]`, **shared**(공유·`datasource options` 로 sharedAvailable 확인 후 백엔드가 클러스터별 격리 DB 민팅)=`yakcloud source add <name> <type> --shared`, **remote**(기존 외부·접속정보는 매니페스트/git 에 넣지 말고 콘솔/API 로 등록) + `yakcloud bind <wl> <name> <alias>`. 타입 제약: oracle=remote 전용, redis·minio=shared 없음, 나머지 6종=3모드 전부. 로컬 dev 는 도커로 논리 이름 그대로 떠서 결정 불필요(이 조회/결정은 운영·검증용).
|
||||
- 도메인: `yakcloud domain <fqdn> <wl>`
|
||||
- 이용 가능한 클러스터 소스를 먼저 보고 싶으면 `yakcloud datasource ls`.
|
||||
- 이용 가능한 클러스터 소스를 먼저 보고 싶으면 `yakcloud datasource ls [--env prod|val] [--cluster <id>] [--json]` — 대상 환경/클러스터의 기존 소스 목록(에이전트용 `--json`).
|
||||
- 운영/검증에서 소스 모드를 정하기 전엔 `yakcloud datasource options <type> [--env prod|val] [--cluster <id>] [--json]` — 타입별 지원모드(local/remote/shared) + 이 배포의 공유 가용성(sharedAvailable) + 기존 소스(existing) + 조회실패 구분(existingError)을 확인.
|
||||
4. 자격 미설정이면(`yakcloud config` 로 확인) 터미널 `yakcloud login` 안내.
|
||||
5. 다음 단계(개발 → `yakcloud project deploy`) 를 요약.
|
||||
|
||||
|
||||
@ -45,13 +45,28 @@ CLI 가 스스로 전제조건을 검사하고 오류를 안내하므로, 실패
|
||||
- **배포/승격** — `yakcloud project deploy` (v* 태그 push → CI → 개발 클러스터) · `--local`(러너 없이 로컬
|
||||
build+push+리컨실) · `yakcloud project promote [vX.Y.Z] --to val|prod` (같은 이미지, 재빌드 없이 승격).
|
||||
- **앱 설정** — `domain <fqdn> [wl]` · `scale <wl> <n>` · `set <wl> --image/--port/--health/--cpu/--mem/--path/--rewrite`
|
||||
· `env <wl> KEY=VAL [--secret K] [--unset K]` · `datasource ls` · `source add <name> <type> [plan] [--shared]` · `bind|unbind <wl> <source> <alias>`.
|
||||
· `env <wl> KEY=VAL [--secret K] [--unset K]` · `datasource ls [--env prod|val] [--cluster <id>] [--json]` · `datasource options <type> [--env prod|val] [--cluster <id>] [--json]`
|
||||
· `source add <name> <type> [plan] [--shared]` · `bind|unbind <wl> <source> <alias>`.
|
||||
|
||||
### 운영 소스 모드 결정 (에이전트 흐름 — 사람은 거의 안 씀)
|
||||
로컬(dev)은 도커로 논리 이름 그대로 뜨므로 결정 불필요. **운영(prod/val)은 소스마다 모드를 골라야** 한다:
|
||||
1. **정보 조회**: `yakcloud datasource options <type> --env prod --json`
|
||||
→ `{ modes:[…], sharedAvailable:bool, existing:[…], existingError? }` — `modes`=이 타입이 지원하는 모드, `sharedAvailable`=공유 서버가 이 배포에 있는지, `existing`=기존 소스, `existingError`=조회 실패 사유(있으면 빈 `existing` 이 '없음'이 아니라 '조회 실패'임).
|
||||
2. **결정 → 매니페스트**:
|
||||
- **공유 가능**(`sharedAvailable:true`)하고 원하면 → `requires` 에 `{ mode: shared }` (백엔드가 격리 DB 민팅).
|
||||
- **기존 외부 소스에 붙임** → `datasource ls --env prod` 로 이름 확인 후 `environments.prod.requires` 에 `{ name:<논리>, clusterName:<그 이름> }`.
|
||||
- **외부 새로 등록**(remote) → 접속정보는 **매니페스트/git 금지**(자격 격리) → 콘솔/API로 등록 후 이름 참조.
|
||||
- **그 외** → `{ mode: local, plan: small }`(인클러스터 전용 생성).
|
||||
- **타입 제약**: `oracle`=remote 전용, `redis`·`minio`=shared 없음, 나머지 6종(postgresql·mysql·mariadb·mongodb·rabbitmq·solr)=local/remote/shared 3모드. `datasource options` 의 `modes` 가 그 타입에 실제 가능한 모드를 알려준다.
|
||||
|
||||
## 매니페스트 (`yakcloud.yaml`)
|
||||
- `project` — 이름(이미지 경로).
|
||||
- `environments.{dev,val,prod}.cluster` — 대상 클러스터 이름/id (val 선택, +`prod.domains`). 로컬 dev 는 `yakcloud dev`.
|
||||
- `requires[]` — `{ name, type, plan | mode: shared }`. type ∈ postgresql·mysql·mariadb·mongodb·redis·minio·rabbitmq·solr·oracle.
|
||||
이름이 클러스터의 READY 소스와 일치하면 스킵, 없으면 프로비저닝(shared=공유 외부, 백엔드가 격리 DB 민팅).
|
||||
- **환경 오버레이 병합** — 최상위 `requires`/`workloads`=base(공유 정체), `environments.<env>` 는 그 환경의 '차이만'. `requires` 는 논리 `name` 매칭 병합(같은 name=덮어쓰기, 새 name=추가). 오버레이는 **base 하고만** 병합되고 dev↔val↔prod 끼리 상속 안 함. `type` 은 base 또는 그 환경에 반드시 있어야 하며, 없으면 배포 전 검증 오류로 막는다.
|
||||
- `requires[]` — `{ name, type, plan | mode: shared, clusterName? }`. type ∈ postgresql·mysql·mariadb·mongodb·redis·minio·rabbitmq·solr·oracle.
|
||||
이름이 클러스터의 READY 소스와 일치하면 스킵, 없으면 프로비저닝(shared=공유 외부, 백엔드가 격리 DB 민팅; 원격 기본은 인클러스터 아님).
|
||||
`clusterName` = 논리 `name` 과 다른 '실제 클러스터 소스 이름'(기본=name) — 주로 `environments.<env>` 오버레이에서
|
||||
로컬↔운영 소스명이 다를 때 매핑. binds 는 항상 논리 `name` 참조(불변).
|
||||
- `workloads[]` — `build`(도커 컨텍스트)·`image`(`gitea.yakenator.io/<GITEA_USER>/<project>-<name>:${TAG}`)·`port`·
|
||||
`health`·`replicas`(≥2 무중단)·`resources{cpu,mem}`·`expose{path,rewrite,host|hosts}`·`binds[]{alias,source}`.
|
||||
|
||||
|
||||
152
.yakcloud/ctl.py
152
.yakcloud/ctl.py
@ -78,6 +78,28 @@ def resolve_cluster(ref: str) -> dict:
|
||||
sys.exit(" ✗ 클러스터 '%s' 없음" % ref)
|
||||
|
||||
|
||||
def resolve_requires(m: dict, env: str | None = None) -> list:
|
||||
"""base + environments.<env> 오버레이(이름 매칭) 병합된 requires — deploy/dev 의 resolve_env 와 규약 일치.
|
||||
실제 소스 이름(clusterName)·type/mode/plan 이 이 병합으로 환경별 결정된다(binds 는 논리 이름 고정)."""
|
||||
env = env or os.environ.get("YAKCLOUD_ENV", "dev")
|
||||
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"])
|
||||
for r in (ec.get("requires") or []):
|
||||
if r["name"] in by:
|
||||
by[r["name"]].update(r)
|
||||
else:
|
||||
by[r["name"]] = dict(r); order.append(r["name"])
|
||||
return [by[n] for n in order]
|
||||
|
||||
|
||||
def actual_source(r: dict) -> str:
|
||||
"""이 소스가 대상 클러스터에서 갖는 실제 이름(clusterName 우선, 없으면 논리 name)."""
|
||||
return r.get("clusterName") or r["name"]
|
||||
|
||||
|
||||
def find_workload(m: dict, name: str | None) -> dict:
|
||||
ws = m.get("workloads", []) or []
|
||||
if name:
|
||||
@ -111,19 +133,23 @@ def patch_live(cid: str, name: str, patch: dict) -> bool:
|
||||
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 "-"
|
||||
# 대상 클러스터 = environments.<env>.cluster 우선(cluster_ref 규약과 동일) — 미지정이면 None 폴백.
|
||||
# 레거시 top-level cluster: 만 보던 게이트는 환경-온리 템플릿에서 라이브 상태가 항상 죽어 clusterName 진단이 안 뜸.
|
||||
env = os.environ.get("YAKCLOUD_ENV", "dev")
|
||||
ref = (((m.get("environments") or {}).get(env) or {}).get("cluster")
|
||||
or os.environ.get("YAKCLOUD_CLUSTER") or m.get("cluster"))
|
||||
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))
|
||||
if URL and TOK and ref:
|
||||
cinfo = resolve_cluster(ref)
|
||||
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("cluster : %s (라이브 상태는 YAKCLOUD_URL/TOKEN + 대상 클러스터 설정 시 표시)" % (ref or "-"))
|
||||
|
||||
print("\nworkloads:")
|
||||
for w in m.get("workloads", []) or []:
|
||||
@ -144,13 +170,15 @@ def cmd_info(_a) -> None:
|
||||
line += "\n binds: %s" % ", ".join("%s→%s" % (b["alias"], b["source"]) for b in binds)
|
||||
print(line)
|
||||
|
||||
reqs = m.get("requires", []) or []
|
||||
reqs = resolve_requires(m) # 현재 환경(YAKCLOUD_ENV, 기본 dev) 오버레이 반영 — clusterName·mode 등
|
||||
if reqs:
|
||||
print("\ndata sources (requires):")
|
||||
for r in reqs:
|
||||
s = svcs.get(r["name"])
|
||||
actual = actual_source(r)
|
||||
s = svcs.get(actual) # 라이브 상태는 '실제 소스 이름'으로 조회
|
||||
st = (" [%s]" % s.get("status")) if s else ""
|
||||
print(" • %-14s %s/%s%s" % (r["name"], r.get("type"), r.get("plan", "small"), st))
|
||||
mp = (" → %s" % actual) if actual != r["name"] else "" # 논리→실제 매핑 표시
|
||||
print(" • %-14s%s %s/%s%s" % (r["name"], mp, r.get("type"), r.get("plan", "small"), st))
|
||||
if doms:
|
||||
print("\ndomains (cluster):")
|
||||
for d in doms:
|
||||
@ -262,17 +290,42 @@ def cmd_source(a) -> None:
|
||||
print(" ✓ requires 에서 제거: %s (관련 binds 정리). 'yakcloud deploy' 로 반영" % a.name)
|
||||
|
||||
|
||||
def _ls_ref(m: dict, env_opt: str | None, cluster_opt: str | None) -> str:
|
||||
"""소스 조회 대상 클러스터 ref — --cluster(명시) > --env(environments.<env>.cluster) > 현재 환경(cluster_ref)."""
|
||||
if cluster_opt:
|
||||
return cluster_opt
|
||||
if env_opt:
|
||||
ref = ((m.get("environments") or {}).get(env_opt) or {}).get("cluster")
|
||||
if not ref:
|
||||
sys.exit(" ✗ environments.%s.cluster 미지정 — --cluster <이름/id> 로 직접 지정하거나 매니페스트에 추가하세요." % env_opt)
|
||||
return ref
|
||||
return cluster_ref(m)
|
||||
|
||||
|
||||
def cmd_source_ls(a) -> None:
|
||||
"""클러스터에서 이용 가능한 데이터 소스 목록 — 이름·타입·내부/외부·상태·플랜."""
|
||||
"""대상 클러스터에서 이용 가능한 데이터 소스 목록 — 이름·타입·내부/외부·상태·플랜.
|
||||
운영 소스를 clusterName 으로 고를 때: 'yakcloud datasource ls --env prod' 로 실제 이름을 확인한다.
|
||||
(로컬 dev 는 도커로 논리 이름 그대로 뜨므로 조회 불필요 — 이 명령은 주로 운영/검증 소스 선택용.)"""
|
||||
need_api()
|
||||
m = (yaml.safe_load(open(MANIFEST)) if os.path.exists(MANIFEST) else {}) or {}
|
||||
c = resolve_cluster(cluster_ref(m))
|
||||
env_opt = getattr(a, "env", None)
|
||||
c = resolve_cluster(_ls_ref(m, env_opt, getattr(a, "cluster", None)))
|
||||
svcs = unwrap(api("GET", "/clusters/%s/services" % c["id"])) or []
|
||||
# ✓ 표시·매핑 = 그 환경의 병합 requires 기준(clusterName 우선) — 클러스터 서비스 이름과 일치.
|
||||
used = {actual_source(r) for r in resolve_requires(m, env_opt)}
|
||||
|
||||
if getattr(a, "json", False): # 에이전트용 기계판독 출력
|
||||
out = [{"name": s.get("name"), "type": s.get("type"), "mode": s.get("mode"),
|
||||
"status": s.get("status"), "plan": s.get("size") or s.get("plan"),
|
||||
"inRequires": s.get("name") in used} for s in svcs]
|
||||
print(json.dumps({"cluster": c.get("name") or c["id"], "clusterId": c["id"],
|
||||
"env": env_opt, "sources": out}, ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
if not svcs:
|
||||
print("클러스터 '%s' — 데이터 소스 없음. 콘솔 '데이터 소스' 또는 'yakcloud source add' 로 추가."
|
||||
% (c.get("name") or c["id"]))
|
||||
return
|
||||
used = {r.get("name") for r in (m.get("requires", []) or [])}
|
||||
rows = []
|
||||
for s in sorted(svcs, key=lambda x: (x.get("type", ""), x.get("name", ""))):
|
||||
mode = s.get("mode")
|
||||
@ -282,16 +335,74 @@ def cmd_source_ls(a) -> None:
|
||||
s.get("status", "?"), s.get("size") or s.get("plan") or "-"))
|
||||
wn = max([len(r[1]) for r in rows] + [4])
|
||||
wt = max([len(r[2]) for r in rows] + [4])
|
||||
print("클러스터 '%s' 데이터 소스 (%d)" % (c.get("name") or c["id"], len(rows)))
|
||||
tag = (" [env=%s]" % env_opt) if env_opt else ""
|
||||
print("클러스터 '%s'%s 데이터 소스 (%d)" % (c.get("name") or c["id"], tag, len(rows)))
|
||||
print(" %-*s %-*s %-4s %-8s 플랜" % (wn, "이름", wt, "타입", "위치", "상태"))
|
||||
for mark, name, typ, loc, st, plan in rows:
|
||||
print(" %s %-*s %-*s %-4s %-8s %s" % (mark, wn, name, wt, typ, loc, st, plan))
|
||||
print("\n ✓=매니페스트 requires 에 이미 있음 · 바인딩: yakcloud bind <workload> <이름> <alias>")
|
||||
print("\n ✓=이 환경 requires 에 이미 있음(clusterName 우선) · 바인딩: yakcloud bind <workload> <이름> <alias>")
|
||||
print(" 이 소스를 논리 이름에 매핑: environments.%s.requires 에 { name: <논리>, clusterName: <위 이름> }" % (env_opt or "prod"))
|
||||
|
||||
|
||||
def cmd_source_options(a) -> None:
|
||||
"""운영 소스 '모드 결정'용 정보 — 타입별 지원 모드(local/remote/shared) + 이 배포의 공유 가용성 + 기존 소스.
|
||||
예) 'yakcloud datasource options postgresql --env prod' → shared 가능 여부·기존 소스로 모드를 고른다.
|
||||
(로컬 dev 는 도커로 뜨므로 이 결정은 주로 운영/검증용.)"""
|
||||
need_api()
|
||||
m = (yaml.safe_load(open(MANIFEST)) if os.path.exists(MANIFEST) else {}) or {}
|
||||
stype = a.type.lower()
|
||||
types = (unwrap(api("GET", "/source-options")) or {}).get("types") or {}
|
||||
info = types.get(stype)
|
||||
if not info:
|
||||
sys.exit(" ✗ 알 수 없는 타입 '%s' — %s 중 하나" % (stype, ", ".join(sorted(types)) or "?"))
|
||||
modes = info.get("modes") or []
|
||||
shared_ok = bool(info.get("sharedAvailable"))
|
||||
# 대상 클러스터의 같은 타입 기존 소스(있으면 clusterName 으로 붙일 후보)
|
||||
env_opt = getattr(a, "env", None)
|
||||
existing = []
|
||||
existing_error = None
|
||||
# 기존 소스 조회는 best-effort(모드 정보는 항상 출력). 단 '빈 목록'과 '조회 실패'를 반드시 구분해 노출한다
|
||||
# — 삼키면 에이전트가 '소스 없음 → 신규 생성'으로 오판(있는데 중복 생성)할 수 있다.
|
||||
try:
|
||||
c = resolve_cluster(_ls_ref(m, env_opt, getattr(a, "cluster", None)))
|
||||
svcs = unwrap(api("GET", "/clusters/%s/services" % c["id"])) or []
|
||||
existing = [{"name": s.get("name"), "mode": s.get("mode"), "status": s.get("status")}
|
||||
for s in svcs if str(s.get("type", "")).lower() == stype]
|
||||
except SystemExit as e:
|
||||
existing_error = (str(e).strip().lstrip("✗").strip() or "클러스터/소스 조회 실패")
|
||||
except Exception as e: # noqa: BLE001 — 네트워크 오류 등도 조회만 실패시키고 모드 정보는 출력
|
||||
existing_error = "조회 오류: %s" % e
|
||||
|
||||
if getattr(a, "json", False): # 에이전트용
|
||||
out = {"type": stype, "modes": modes, "sharedAvailable": shared_ok, "existing": existing}
|
||||
if existing_error:
|
||||
out["existingError"] = existing_error # '빈 목록' vs '조회 실패' 구분 — 오판 방지
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
return
|
||||
print("데이터 소스 '%s' — 운영 소스 모드 선택지" % stype)
|
||||
print(" 지원 모드: %s" % ", ".join(modes))
|
||||
if "shared" in modes:
|
||||
print(" · shared (공유) : %s" % ("가능 — 이 배포에 공유 서버 있음 → requires 에 { mode: shared } (격리 DB 민팅)"
|
||||
if shared_ok else "이 배포에 공유 서버 없음 → 사용 불가(local/remote 로)"))
|
||||
if "remote" in modes:
|
||||
print(" · remote (외부) : 콘솔/API로 외부 접속정보 등록(자격은 매니페스트/git 금지) → clusterName 으로 참조")
|
||||
if "local" in modes:
|
||||
print(" · local (인클러스터): 전용 인스턴스 신규 생성 → requires 에 { mode: local, plan: small }")
|
||||
if existing_error:
|
||||
print(" ⚠ 기존 소스 조회 실패(%s) — 대상 클러스터/토큰 확인. 위 모드 정보는 유효(기존 소스 유무는 미확인)." % existing_error)
|
||||
elif existing:
|
||||
print(" 기존 소스(이 클러스터·같은 타입):")
|
||||
for e in existing:
|
||||
print(" - %-20s [%s] %s" % (e["name"], e.get("mode") or "?", e.get("status") or "?"))
|
||||
print(" ↑ 기존 소스에 붙이려면 environments.<env>.requires 에 { name: <논리>, clusterName: <이름> }")
|
||||
else:
|
||||
print(" 기존 소스 없음(같은 타입) — 위 모드 중 골라 requires 에 선언")
|
||||
|
||||
|
||||
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 [])):
|
||||
# 검증은 병합 뷰(base + 현재 환경 오버레이) 기준 — info/datasource ls 와 일관. binds 는 논리 이름 참조.
|
||||
if not any(r.get("name") == a.source for r in resolve_requires(m)):
|
||||
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]
|
||||
@ -377,11 +488,22 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
so = sub.add_parser("source", help="데이터 소스: ls(클러스터 목록) · add · rm")
|
||||
sosub = so.add_subparsers(dest="action", required=True)
|
||||
sol = sosub.add_parser("ls", help="클러스터에서 이용 가능한 소스 목록"); sol.set_defaults(fn=cmd_source_ls)
|
||||
sol = sosub.add_parser("ls", help="클러스터 소스 목록(운영 조회 --env prod, 에이전트 --json)")
|
||||
sol.add_argument("--env", choices=["dev", "val", "prod"], help="대상 환경(environments.<env>.cluster) — 기본=현재 환경")
|
||||
sol.add_argument("--cluster", help="대상 클러스터 이름/id 직접 지정(--env 무시)")
|
||||
sol.add_argument("--json", action="store_true", help="기계판독 JSON 출력(에이전트용)")
|
||||
sol.set_defaults(fn=cmd_source_ls)
|
||||
soo = sosub.add_parser("options", help="타입별 모드(local/remote/shared)+공유 가용성+기존 소스 — 운영 모드 결정용")
|
||||
soo.add_argument("type", help="데이터 소스 타입(postgresql·mysql·mongodb·redis·minio·rabbitmq·solr·oracle·mariadb)")
|
||||
soo.add_argument("--env", choices=["dev", "val", "prod"], help="대상 환경(기존 소스 조회용)")
|
||||
soo.add_argument("--cluster", help="대상 클러스터 이름/id 직접 지정")
|
||||
soo.add_argument("--json", action="store_true", help="기계판독 JSON 출력(에이전트용)")
|
||||
soo.set_defaults(fn=cmd_source_options)
|
||||
soa = sosub.add_parser("add"); soa.add_argument("name"); soa.add_argument("type")
|
||||
soa.add_argument("plan", nargs="?", default="small")
|
||||
soa.add_argument("--shared", action="store_true",
|
||||
help="공유 외부 소스(mode:shared) — 백엔드가 클러스터별 격리 DB/계정 민팅(소용량 클러스터용, 현재 mongodb)")
|
||||
help="공유 외부 소스(mode:shared) — 백엔드가 격리 DB/계정 민팅. 지원=pg·mysql·mariadb·mongodb·rabbitmq·solr "
|
||||
"(배포별 가용성은 'yakcloud datasource options <type>' 로 확인)")
|
||||
soa.set_defaults(fn=cmd_source)
|
||||
sor = sosub.add_parser("rm"); sor.add_argument("name"); sor.set_defaults(fn=cmd_source)
|
||||
|
||||
|
||||
@ -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) — 실제 변경 없음")
|
||||
|
||||
|
||||
@ -287,8 +287,13 @@ def _mint_oracle(cname: str, user: str, pw: str) -> None:
|
||||
|
||||
def provision_source(ns: str, project: str, req: dict) -> dict | None:
|
||||
"""requires 소스 → 공유 도커 컨테이너의 프로젝트별 격리 DB + no-selector Service/Endpoints. 접속정보 반환."""
|
||||
name = req["name"]
|
||||
logical = req["name"]
|
||||
# 실제 로컬 소스 이름 = clusterName(오버레이) 없으면 논리 이름. 서비스/격리DB/Secret 은 이 실제 이름 기준,
|
||||
# 앱 binds 는 논리 이름 고정(로컬↔운영 매핑). 호출부는 반환 conn 을 논리 이름으로 키.
|
||||
name = req.get("clusterName") or logical
|
||||
stype = req["type"].upper()
|
||||
if name != logical:
|
||||
log(f"source '{logical}' → 실제 소스 '{name}' (clusterName)")
|
||||
if req.get("mode") == "local":
|
||||
warn(f"source '{name}': mode=local(인클러스터)는 로컬 dev 에서 공유 컨테이너로 대체됩니다.")
|
||||
if stype not in SUPPORTED:
|
||||
@ -547,9 +552,10 @@ def _report_to_console(project: str, reqs: list, wls: list) -> None:
|
||||
"project": project,
|
||||
"context": CTX, # kubectl 컨텍스트(콘솔 연결·삭제 탭 안내용) — kind-yak-<name>
|
||||
|
||||
# secret = 노트북 kind 커넥션 Secret 이름 → 콘솔이 connSecretRef 로 저장, 에이전트가 이 이름으로 읽음.
|
||||
# name=논리 이름(binds 매칭·표시용), secret=실제 소스명(clusterName 우선)의 Secret 이름.
|
||||
# 콘솔이 secret 을 connSecretRef 로 저장 → 에이전트가 액션의 그 이름으로 조회(재계산 아님)하므로 정합 유지.
|
||||
"sources": [{"name": r["name"], "type": str(r.get("type", "")).upper(),
|
||||
"secret": f"yak-dev-src-{sanitize(r['name'])}"} for r in reqs],
|
||||
"secret": f"yak-dev-src-{sanitize(r.get('clusterName') or r['name'])}"} for r in reqs],
|
||||
"workloads": [{"name": sanitize(w["name"]), "image": w["image"].replace("${TAG}", TAG),
|
||||
"hosts": _disp_hosts(project, w), "port": int(w.get("port", 8080)),
|
||||
"path": (w.get("expose", {}) or {}).get("path", "/") or "/",
|
||||
@ -642,8 +648,8 @@ def _ensure_agent() -> None:
|
||||
|
||||
|
||||
# ── 환경 오버레이: base(requires/workloads) + environments.<env> 이름 매칭 병합 ─────────────────
|
||||
# 앱 binds 는 논리이름 고정 → 환경별로 소스 정체(type/mode/plan)·워크로드 필드만. 로컬은 항상 env="dev".
|
||||
# ※ deploy.py 의 동일 함수와 규약 일치(파리티).
|
||||
# 앱 binds 는 논리이름(name) 고정 → 환경별로 소스 정체(type/mode/plan)·실제 소스명(clusterName)·
|
||||
# 워크로드 필드만. 로컬은 항상 env="dev". ※ deploy.py 의 동일 함수와 규약 일치(파리티).
|
||||
def _merge_env_vars(base, ov):
|
||||
def norm(e):
|
||||
if isinstance(e, dict):
|
||||
@ -655,17 +661,40 @@ def _merge_env_vars(base, ov):
|
||||
return list(out.values())
|
||||
|
||||
|
||||
def _validate_requires(requires: list, env: str) -> None:
|
||||
"""병합된 requires 사전검증 — 프로비저닝 도중 KeyError 대신 친절한 오류로 조기 차단.
|
||||
★ 환경 오버레이는 base 하고만 병합되며 dev↔val↔prod 끼리 상속하지 않는다 →
|
||||
type 은 base(권장) 또는 그 환경에 반드시 있어야 한다. ※ deploy.py 와 규약 일치(파리티)."""
|
||||
for r in requires:
|
||||
name = r.get("name")
|
||||
t = str(r.get("type") or "").upper()
|
||||
if not t:
|
||||
die(f"소스 '{name}' 에 type 이 없습니다(환경 '{env}') — base.requires 또는 "
|
||||
f"environments.{env}.requires 에 type 을 지정하세요. 환경 오버레이는 base 하고만 "
|
||||
f"병합되고 dev↔val↔prod 끼리 상속하지 않습니다(공유 정체는 base 에 두는 걸 권장).")
|
||||
if t not in SUPPORTED:
|
||||
die(f"소스 '{name}' 의 type '{r.get('type')}' 은 미지원(환경 '{env}') — "
|
||||
f"{', '.join(sorted(s.lower() for s in SUPPORTED))} 중 하나여야 합니다.")
|
||||
|
||||
|
||||
def resolve_env(m: dict, env: str):
|
||||
ec = (m.get("environments") or {}).get(env) or {}
|
||||
by, order = {}, []
|
||||
for r in (m.get("requires") or []):
|
||||
by[r["name"]] = dict(r); order.append(r["name"])
|
||||
nm = r.get("name")
|
||||
if not nm:
|
||||
die("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)
|
||||
nm = r.get("name")
|
||||
if not nm:
|
||||
die(f"requires 항목에 name 이 없습니다(environments.{env}.requires) — name 은 필수입니다.")
|
||||
if nm in by:
|
||||
by[nm].update(r)
|
||||
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 []):
|
||||
@ -688,9 +717,10 @@ def cmd_deploy(m: dict) -> None:
|
||||
reqs, wls = resolve_env(m, "dev") # base + environments.dev 오버레이(로컬은 항상 dev 환경)
|
||||
|
||||
# 충돌 가드 — 서로 다른 소스명/워크로드명이 같은 서비스명으로 뭉개지면 자격 오배선 → 거부.
|
||||
# 서비스명은 '실제 소스 이름'(clusterName 우선)으로 만들어지므로 그 기준으로 검사.
|
||||
svc_owner: dict[str, str] = {}
|
||||
for r in reqs:
|
||||
s = sanitize(r["name"])
|
||||
s = sanitize(r.get("clusterName") or r["name"])
|
||||
if s in svc_owner and svc_owner[s] != r["name"]:
|
||||
die(f"소스명 충돌 — '{svc_owner[s]}' 와 '{r['name']}' 가 같은 서비스명 '{s}' 로 뭉개집니다. 이름을 구분하세요.")
|
||||
svc_owner[s] = r["name"]
|
||||
@ -709,7 +739,8 @@ def cmd_deploy(m: dict) -> None:
|
||||
results = [deploy_workload(project, ns, w, src_conns) for w in wls]
|
||||
|
||||
# 매니페스트에서 빠진 소스/워크로드 정리(고아 방지 — add-only 가 아니라 리컨실).
|
||||
_prune(ns, "source", "yakcloud.dev/source", {sanitize(r["name"]) for r in reqs})
|
||||
# 소스 라벨(yakcloud.dev/source)=실제 소스명(clusterName 우선)의 sanitize → desired 도 그 기준.
|
||||
_prune(ns, "source", "yakcloud.dev/source", {sanitize(r.get("clusterName") or r["name"]) for r in reqs})
|
||||
_prune(ns, "workload", "app", {sanitize(w["name"]) for w in wls})
|
||||
|
||||
_report_to_console(project, reqs, wls) # 콘솔 LOCAL 클러스터 탭(데이터소스·앱) 리포트
|
||||
|
||||
@ -44,11 +44,63 @@
|
||||
관리형 데이터 소스는 두 가지 모드:
|
||||
1. **인클러스터(local)**: 클러스터 안에서 실제로 프로비저닝(StatefulSet). 개발 클러스터에 배포하면 그 클러스터의 소스가
|
||||
`<ALIAS>_*` 로 주입되고, 상위 환경으로 승격하면 그 환경 클러스터의 소스가 주입된다 — 각 환경이 자체 데이터 계층을 가진다.
|
||||
2. **공유 외부(shared)**: 소용량(S) 클러스터용. 매니페스트 `requires` 에 `{ name, type: mongodb, mode: shared }` 로 선언
|
||||
2. **공유 외부(shared)**: 소용량(S) 클러스터용. 매니페스트 `requires` 에 `{ name, type, mode: shared }` 로 선언(shared 지원 타입은 아래 '운영 소스 모드 결정' 표 참조)
|
||||
(또는 `yakcloud source add <name> mongodb --shared`; 콘솔/API 등록도 가능). 배포 시 백엔드가 클러스터별 격리 DB/계정을
|
||||
만들어, 모든 환경이 같은 외부 소스 이름으로 환경별 격리 접속(인클러스터 StatefulSet 불필요).
|
||||
환경 간 데이터 백업·싱크는 별도(추후).
|
||||
|
||||
> **원격 클러스터의 기본은 인클러스터(local)가 아니라 외부/공유(shared/remote) 소스다.** 인클러스터 전용
|
||||
> 인스턴스(`mode: local`)는 정말 필요할 때만 옵트인한다.
|
||||
|
||||
## 환경별 실제 소스 이름 매핑 (`clusterName`)
|
||||
로컬(dev)에서 쓰는 소스 이름과 운영(prod)에서 붙는 **실제 소스 이름이 다를 수 있다.** 예를 들어 dev 는 논리 이름으로
|
||||
로컬 소스를 새로 띄우지만, prod 는 이미 존재하는 (이름이 다른) 외부/공유 소스에 붙여야 한다.
|
||||
|
||||
`requires[].clusterName` 으로 **논리 이름(`name`)** 과 **실제 클러스터 소스 이름(`clusterName`)** 을 분리한다.
|
||||
- `name` = 논리 이름 — `binds[].source` 가 가리키는 안정 키. 환경이 바뀌어도 앱/바인딩은 불변.
|
||||
- `clusterName` = 대상 클러스터에서의 실제 소스 이름. 생략하면 `name` 과 같다. 매칭·프로비저닝·상태 조회는 이 값 기준.
|
||||
|
||||
주로 `environments.<env>` 오버레이에서 환경별로만 덮어쓴다(base 는 논리 이름 그대로). 오버레이는 **base 하고만 병합**되며(`requires` 는 `name` 매칭 병합), dev↔val↔prod 끼리는 서로 상속하지 않는다. `type` 은 base 또는 해당 환경에 반드시 있어야 하고, 없거나 매칭이 안 맞으면 **배포 전 검증 오류**로 걸러진다. 예:
|
||||
```yaml
|
||||
requires:
|
||||
- { name: appdb, type: postgresql } # dev: 실제 소스명 = appdb (논리 이름)
|
||||
|
||||
environments:
|
||||
dev: { cluster: my-dev }
|
||||
prod:
|
||||
cluster: my-prod
|
||||
requires:
|
||||
- { name: appdb, clusterName: shared-prod-pg } # prod: 기존 외부 소스 'shared-prod-pg' 에 매핑(type 은 상속)
|
||||
|
||||
workloads:
|
||||
- name: api
|
||||
binds:
|
||||
- { alias: db, source: appdb } # 항상 논리 이름 참조 — 환경 무관
|
||||
```
|
||||
결과: dev 는 `appdb` 소스를, prod 는 `shared-prod-pg` 소스를 쓰지만 앱은 두 환경 모두 `DB_URL` 등 동일 env 로 접속한다.
|
||||
`yakcloud project info` / `datasource ls` 는 현재 환경 기준으로 `논리 → 실제` 매핑을 함께 보여준다.
|
||||
|
||||
## 운영 소스 모드 결정 (shared / remote / local)
|
||||
로컬(dev)은 도커로 논리 이름 그대로 뜨므로 결정할 게 없다. **운영(prod/val)은 소스마다 세 모드 중 하나를 고른다:**
|
||||
|
||||
| 모드 | 언제 | 매니페스트 |
|
||||
|---|---|---|
|
||||
| **shared** | 이 배포에 그 타입의 공유 서버가 있을 때(격리 DB 민팅) | `requires: [{ name, type, mode: shared }]` (+`clusterName` 으로 특정 소스 지정 가능) |
|
||||
| **remote** | 이미 있는 외부 서버에 붙일 때 | 접속정보는 **매니페스트/git 금지**(자격 격리) → 콘솔/API로 등록 후 `clusterName` 으로 이름 참조 |
|
||||
| **local** | 전용 인스턴스를 클러스터 안에 새로 만들 때 | `requires: [{ name, type, mode: local, plan: small }]` |
|
||||
|
||||
타입별 지원 모드: pg·mysql·mariadb·mongodb·rabbitmq·solr = `local/remote/shared`, redis·minio = `local/remote`, **oracle = remote 전용**.
|
||||
|
||||
**결정 정보 조회(에이전트용):**
|
||||
```sh
|
||||
yakcloud datasource options postgresql --env prod --json
|
||||
# → { "modes":["local","remote","shared"], "sharedAvailable": true|false, "existing":[…], "existingError": null|"<사유>" }
|
||||
# sharedAvailable=이 배포에 공유 서버가 실제로 구성됐는지(자격 미노출, 존재 여부만).
|
||||
# existingError=기존 소스 조회 실패 사유(빈 existing 이 '없음'인지 '조회 실패'인지 구분).
|
||||
yakcloud datasource ls --env prod --json # 그 클러스터의 기존 소스. --cluster <id> 로 클러스터 직접 지정도 가능
|
||||
```
|
||||
`sharedAvailable:false` 인데 `mode: shared` 로 배포하면 `503 공유 백킹 미구성` 으로 거부된다 — 위 조회로 미리 확인하라.
|
||||
|
||||
## 앱 코드 예시
|
||||
```python
|
||||
# Python — alias=db (postgres)
|
||||
|
||||
@ -24,6 +24,9 @@ yakcloud dev down # 정리
|
||||
|
||||
## 매니페스트 매핑 (`yakcloud.yaml`)
|
||||
```yaml
|
||||
requires:
|
||||
- { name: appdb, type: postgresql } # 논리 이름(안정 키). 환경별 실제 소스명은 clusterName 으로 오버레이(→ reference 참고)
|
||||
|
||||
workloads:
|
||||
- name: web
|
||||
build: ./ # 이 디렉터리(Dockerfile)를 CI 가 빌드
|
||||
@ -36,4 +39,4 @@ workloads:
|
||||
|
||||
## 다른 스택으로 교체
|
||||
`app.py`(+`Dockerfile`)를 원하는 언어/프레임워크로 교체하되 위 3계약 유지.
|
||||
소스별 주입 env 전체 목록은 `../reference/DATA-SOURCES.md` 참고.
|
||||
소스 바인딩 상세는 `../reference/DATA-SOURCES.md` 참고 — 주입 env 전체 목록뿐 아니라 환경별 실제 소스명 매핑(`requires[].clusterName`, 논리 `name` ↔ 실제 소스명 분리), 환경 오버레이(base + `environments.<env>`, 차이만) 병합, 운영 소스 3모드(shared/remote/local) 결정과 `yakcloud datasource ls`/`datasource options` 조회를 함께 다룬다.
|
||||
|
||||
11
bin/yakcloud
11
bin/yakcloud
@ -9,14 +9,14 @@
|
||||
# yakcloud project info 프로젝트 개괄(매니페스트 + 라이브 상태)
|
||||
# yakcloud project check [--env dev|val|prod] dry-run(직접 API 계획만)
|
||||
#
|
||||
# yakcloud domain <fqdn> [wl] · scale · set · env · datasource ls · source · bind/unbind
|
||||
# yakcloud domain <fqdn> [wl] · scale · set · env · datasource ls/options · source · bind/unbind
|
||||
#
|
||||
# 매니페스트: environments.dev=로컬 kind{cluster,port}, val/prod=관리형{cluster,+prod.domains}. env: YAKCLOUD_URL, YAKCLOUD_TOKEN(yakd_…)
|
||||
set -uo pipefail # -e 미사용: 'test && action' 관용구가 값 없을 때 CLI 를 중단시키는 함정 회피. 중요 경로는 명시적 || die.
|
||||
|
||||
REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}"
|
||||
BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}"
|
||||
VERSION="0.31.0"
|
||||
VERSION="0.32.0"
|
||||
# 로컬 개발 클러스터(kind) — 개발 후 운영까지 '직선 배포'의 dev 구간.
|
||||
DEV_CTX="${YAK_DEV_CONTEXT:-kind-yak-dev}"
|
||||
DEV_CLUSTER="${YAK_DEV_CLUSTER:-yak-dev}"
|
||||
@ -179,8 +179,8 @@ _scaffold_yakcloud_guardrails() {
|
||||
`yakcloud project promote --to prod <ver>` 가 통과한다(확정은 그 버전에 묶임). 에이전트가 임의로 confirm 하지 말 것.
|
||||
|
||||
## 데이터소스
|
||||
- `yakcloud.yaml` 의 `requires` 에 `{ name, type }` 선언 → 워크로드 `binds` 로 연결 → `<ALIAS>_URL` 등 자동 주입.
|
||||
- type ∈ postgresql·mysql·mariadb·mongodb·redis·minio·rabbitmq·solr·oracle. 목록 = `yakcloud datasource ls`.
|
||||
- `yakcloud.yaml` 의 `requires` 에 `{ name, type }` 선언 → 워크로드 `binds` 로 연결(binds 는 항상 논리 `name` 참조, 불변) → `<ALIAS>_URL` 등 자동 주입. 운영에서 실제 소스 이름이 다르면 `environments.<env>` 오버레이에 같은 `name` + `clusterName: <실제 소스명>` (기본=name)으로 매핑한다.
|
||||
- type ∈ postgresql·mysql·mariadb·mongodb·redis·minio·rabbitmq·solr·oracle. 기존 소스 나열 = `yakcloud datasource ls`, 타입별 지원모드·공유 가용성 확인(운영 모드 결정) = `yakcloud datasource options <type> --env prod`. (oracle=remote 전용, redis·minio=shared 없음, 나머지 6종=local/remote/shared 3모드; 로컬 dev 는 도커로 논리 이름 그대로 떠서 결정 불필요)
|
||||
|
||||
## 참고
|
||||
- 상태 = `yakcloud project info` · 배포 전 dry-run = `yakcloud project check`.
|
||||
@ -979,7 +979,8 @@ yakcloud $VERSION — YakCloud 프로젝트 CLI (개발·검증·운영 모두
|
||||
scale <wl> <n> replicas 변경
|
||||
set <wl> --image/--port/--health/--cpu/--mem/--path/--rewrite
|
||||
env <wl> KEY=VAL … [--secret KEY] [--unset KEY]
|
||||
datasource ls 클러스터에서 이용 가능한 소스 목록(이름·타입·내부/외부·상태)
|
||||
datasource ls [--env prod|val] [--cluster <id>] [--json] 대상 클러스터의 소스 목록(이름·타입·내부/외부·상태)
|
||||
datasource options <type> [--env prod|val] [--cluster <id>] [--json] 타입별 지원모드(local/remote/shared)+이 배포의 공유 가용성(sharedAvailable)+기존 소스(existing, 조회실패는 existingError). 운영 소스 '모드 결정'용
|
||||
source add <name> <type> [plan] [--shared] | source rm <name> (--shared=공유 외부, 백엔드가 격리 DB 민팅)
|
||||
bind <wl> <source> <alias> | unbind <wl> <alias>
|
||||
매니페스트: environments.{dev,val,prod}.cluster(val 선택, +prod.domains). env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…)
|
||||
|
||||
@ -14,12 +14,20 @@ project: my-app # 프로젝트 이름(이미지 경로/표
|
||||
#
|
||||
# ★ 환경별 오버라이드(오버레이 병합): 아래 base 의 requires/workloads 를 environments.<env> 에서 '이름 매칭'으로
|
||||
# 덮어쓰거나 추가할 수 있다 → dev↔prod 소스/리소스가 달라도 promote 때 매니페스트 수정 불필요(앱 binds 는 논리이름 고정).
|
||||
# 예) prod 는 관리형 공유 mongo + 큰 리소스:
|
||||
# · requires 는 논리 '이름(name)' 으로 매칭 → type/mode/plan 을 환경별로 덮어쓴다.
|
||||
# · 각 environments.<env> 오버레이는 **base 하고만** 병합된다 — dev↔val↔prod 끼리는 서로 상속하지 않는다
|
||||
# (그래서 type 이 base 에 없으면 필요한 환경마다 오버레이에 직접 적어야 한다. 위반은 배포 전 친절한 검증 오류로 잡힌다).
|
||||
# · clusterName: 논리 이름과 '실제 클러스터 소스 이름'을 분리한다 — 로컬(dev)은 논리 이름으로 소스를 만들고,
|
||||
# 운영(prod)은 이미 존재하는 (이름이 다른) 외부/공유 소스에 매핑한다. binds 는 그대로 논리 이름을 가리키므로
|
||||
# 앱 코드·바인딩은 불변. (원격 클러스터의 데이터 소스 기본은 인클러스터가 아니라 외부/공유 → 이름이 다른 게 흔하다.)
|
||||
# 예) base 의 논리 소스(appdb)를 prod 에선 이미 있는 외부 공유 소스(이름이 다름)에 매핑 + 큰 리소스:
|
||||
# (전제: base.requires 에 { name: appdb, type: … } 가 있음 — 오버레이는 그 논리 이름을 매칭해 덮어쓴다.
|
||||
# base 에 없는 이름을 오버레이에 처음 쓰면 신규 항목이라 type 을 반드시 함께 적어야 한다.)
|
||||
# prod:
|
||||
# cluster: my-prod-cluster
|
||||
# domains: [app.example.com]
|
||||
# requires: # 이름(appdb) 매칭 → base 의 정체를 덮어씀
|
||||
# - { name: appdb, type: mongodb, mode: shared, plan: medium }
|
||||
# requires: # 이름(appdb) 매칭 → base 의 정체를 덮어씀(type 은 base 상속)
|
||||
# - { name: appdb, mode: shared, clusterName: shared-prod-db } # 운영의 기존 소스명으로 매핑
|
||||
# workloads: # 워크로드명(web) 키로 필드 패치(resources·replicas·env 는 병합, 그 외 교체)
|
||||
# web: { replicas: 3, resources: { cpu: 500m, mem: 512Mi } }
|
||||
environments:
|
||||
@ -31,10 +39,19 @@ environments:
|
||||
# type ∈ postgresql·mysql·mariadb·mongodb·redis·minio·rabbitmq·solr·oracle
|
||||
# - 기본(shared, mode 생략): 공유 외부 서버에 클러스터/프로젝트별 **격리 DB·계정** 민팅(인클러스터 리소스 생성 없음).
|
||||
# - 인클러스터(옵션): 정말 전용 인스턴스가 필요할 때만 `mode: local, plan: small|medium|large`.
|
||||
# - 외부(옵션): `mode: remote` — 관리형이 프로비저닝 안 하고 기존 외부 서버에 연결. **접속정보(호스트·계정)는 매니페스트/git 에**
|
||||
# **절대 쓰지 말고** 콘솔/API 로 등록한 뒤, 매니페스트는 `{ name, type, mode: remote, clusterName: <등록된 소스명> }` 로 매핑만 한다.
|
||||
# - 타입별 지원 모드: postgresql·mysql·mariadb·mongodb·rabbitmq·solr = local/remote/shared, redis·minio = local/remote(**shared 없음**), oracle = remote 전용.
|
||||
# (배포별 실제 공유 가용성 확인 = `yakcloud datasource options <type> --env prod`)
|
||||
# - ★ redis 는 예외 — 공유 격리가 ACL 키/채널 '프리픽스'(비투명, 맨 pub/sub 차단)라 **기본이 전용(local)**.
|
||||
# REDIS_URL 로 전권(pub/sub 포함). 프리픽스-인지 앱이면 `mode: shared` 로 공유 사용 가능.
|
||||
# - 이름이 클러스터의 기존 READY 소스와 일치하면 스킵·바인딩. ('yakcloud project check' 로 미리 확인)
|
||||
# (운영 모드/이름 결정 조회: 'yakcloud datasource options <type> --env prod' = 지원모드·공유 가용성·기존 소스,
|
||||
# 'yakcloud datasource ls --env prod' = 대상 클러스터의 기존 소스 이름 목록 → clusterName 매핑에 사용)
|
||||
# (기존 소스의 mode 를 바꾸려면 삭제 후 재배포 — 리컨실은 재프로비저닝하지 않고 경고만 함)
|
||||
# - clusterName(선택): 이 소스가 '대상 클러스터에서 갖는 실제 이름'을 논리 name 과 다르게 지정. 기본값=name.
|
||||
# 주로 environments.<env> 오버레이에서 '운영의 기존 외부/공유 소스명'으로 매핑할 때 쓴다(로컬↔운영 이름이 달라도 됨).
|
||||
# 매칭·프로비저닝·상태 조회는 clusterName(실제명) 기준, 앱 binds 는 논리 name 참조로 불변.
|
||||
# ⚠ 소스 이름은 '프로젝트 고유'로 두라(my-app-db → init 이 프로젝트명으로 치환). 공용 클러스터에선
|
||||
# 리컨실이 '같은 이름의 기존 READY 소스'를 재사용·바인딩하므로, appdb 같은 일반 이름은 다른 프로젝트의
|
||||
# DB 에 붙어 그 DB 에 테이블을 만드는 사고가 난다. 고유 이름이면 각 프로젝트가 자기 소스를 갖는다.
|
||||
|
||||
Reference in New Issue
Block a user