diff --git a/.claude/commands/yakcloud.md b/.claude/commands/yakcloud.md index edde94c..b624aba 100644 --- a/.claude/commands/yakcloud.md +++ b/.claude/commands/yakcloud.md @@ -19,19 +19,19 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep, AskUserQuestion - **`project init [name]`** → §2 스캐폴딩(마법사 대체). - **`login`** → 비밀 입력이라 여기서 실행하지 말고, 터미널에서 `yakcloud login` 하라고 한 줄 안내. (사용자가 값을 직접 주면 `YAKCLOUD_URL=… YAKCLOUD_TOKEN=… GITEA_USER=… GITEA_TOKEN=… yakcloud config` 로 저장하되 **값은 화면에 출력 금지**.) -- **`dev …`**(`dev up|run|down|status|logs|env|reset|doctor`) → 로컬 docker 데이터소스 + `.env.dev`. - `docker` 데몬이 떠 있어야 함(없으면 `yakcloud dev doctor` 로 진단). `dev run -- <명령>` 은 앱을 배포와 동일한 `_*` 로 실행. -- **`datasource migrate …`**(`capture|apply|status`) → 데이터소스 스냅샷(블랙리스트 `db//.migrateignore`). - `capture` = 소스 전체−블랙리스트 덤프(**읽기전용**, 먼저 미리보기로 무엇이 잡히는지 보여줄 것). `apply` = **빈 타깃만** 복원(데이터 있으면 스킵). prod 적용은 콘솔 소스관리자 컨펌 + 백업 후(롤백) — 이 명령으로 prod를 직접 덮지 말 것. +- **`project promote [vX.Y.Z]`** → 개발에서 검증한 그 이미지를 **재빌드 없이 운영 클러스터로** 승격 + 운영 도메인. + 파괴적/외부노출이므로 실행 전 무엇을(어느 태그를 어느 운영 클러스터로) 하는지 한 줄 알리고 진행. - **그 외 전부**(`project deploy|info|check|update`, `datasource ls`, `domain`, `scale`, `set`, `env`, `source`, `bind`, `unbind`, `config`, `upgrade`) → `yakcloud $ARGUMENTS` 를 실행하고 **출력을 그대로** 보여준 뒤, - 필요할 때만 한 줄로 해석. 배포/삭제성(`deploy`·`source rm`·`unbind`·`domain`·`dev down --volumes`)은 실행 전 무엇을 하는지 한 줄 알리고 진행. + 필요할 때만 한 줄로 해석. 배포/승격/삭제성(`deploy`·`promote`·`source rm`·`unbind`·`domain`)은 실행 전 무엇을 하는지 한 줄 알리고 진행. +- 개발·운영 모두 yakcloud 클러스터에서 배포·테스트한다(로컬 docker 데이터소스 없음). 매니페스트 `environments.{dev,prod}`. ## 2) `project init` — 마법사 대체 (핵심) 터미널 마법사는 TTY가 없으면 안 뜬다. 네가 대신한다: 1. `yakcloud project init -y` 로 **비대화형 스캐폴딩**(기본 매니페스트 생성; `bin/`·`install.sh` 잔재 없음). 2. `$ARGUMENTS`/대화 맥락에 이미 있는 값은 **재질문 금지**. 부족한 핵심값만 `AskUserQuestion` 으로 모은다: - 대상 클러스터, 앱 포트, 노출 경로(기본 `/`), CPU/메모리, 필요한 데이터소스(있으면), 도메인(있으면). + **개발 클러스터, 운영 클러스터**(같으면 하나로), 앱 포트, 노출 경로(기본 `/`), CPU/메모리, 필요한 데이터소스(있으면), 운영 도메인(있으면). + → 매니페스트 `environments.dev.cluster` / `environments.prod.cluster` / `environments.prod.domains` 에 반영. 3. 모은 값을 CLI로 반영: - `yakcloud set --port … --cpu … --mem … --path …` - 소스: `yakcloud source add [plan]` + `yakcloud bind ` diff --git a/.claude/skills/yakcloud-deploy/SKILL.md b/.claude/skills/yakcloud-deploy/SKILL.md index a9fc897..ea5885d 100644 --- a/.claude/skills/yakcloud-deploy/SKILL.md +++ b/.claude/skills/yakcloud-deploy/SKILL.md @@ -22,17 +22,12 @@ YakCloud 콘솔 API로 **데이터소스 리컨실 + 워크로드 배포 + 바 설치: `curl -fsSL https://gitea.yakenator.io/yakenator/yakcloud-starter/raw/branch/main/install.sh | sh` > 설치 시 **Claude Code 전역 슬래시 명령 `/yakcloud`** 도 함께 생성된다(`~/.claude/commands/yakcloud.md`). 예: `/yakcloud project init my-app`, `/yakcloud datasource ls`, `/yakcloud project deploy`. 이후 `yakcloud upgrade` 가 CLI와 슬래시 명령을 함께 최신화. -**프로젝트 라이프사이클** +**프로젝트 라이프사이클 (개발·운영 모두 yakcloud 클러스터)** - **"프로젝트 초기화"** → 빈 폴더에서 `yakcloud project init [name]` (스타터+CI+매니페스트+이 스킬) -- **"yakcloud 에 배포"** → `yakcloud project deploy` (버전 미지정 시 **자동 버전업**: 최신 태그 patch+1 → 태그 push) -- **"프로젝트 개괄"** → `yakcloud project info` (매니페스트 + 라이브 상태) -- 사전 점검 → `yakcloud project check` (dry-run) - -**로컬 개발(docker 데이터소스 + 배포와 동일 env)** -- `yakcloud dev up` — `requires[]` 를 로컬 docker 로 기동 + `.env.dev`(프로덕션과 **바이트 동일**한 `_*`) 생성 -- `yakcloud dev run -- <명령>` — up 보장 후 `.env.dev` 주입해 앱 실행(배포된 파드와 동일 env → 무수정 이식) -- `yakcloud dev status|logs|down [--volumes]|reset|doctor` — 자격은 시드에서 결정적 파생(`.yakcloud/dev/`, gitignore) -- `yakcloud datasource migrate capture|apply|status` — 블랙리스트(`db//.migrateignore`) 스냅샷: 전체−제외 캡처(읽기전용), **빈 타깃만** 적용(부트스트랩). **9종 전부**(SQL 3·mongo·minio·redis·solr·rabbitmq·oracle). prod는 콘솔 소스관리자 컨펌+백업 후 +- **"개발 배포"** → `yakcloud project deploy` (자동 버전업 → 태그 push → CI 빌드 → **개발 클러스터**). 기본 도메인으로 확인. +- **"운영 승격"** → `yakcloud project promote [vX.Y.Z]` (개발에서 검증한 **그 이미지를 재빌드 없이 운영 클러스터로** + 운영 도메인) +- **"프로젝트 개괄"** → `yakcloud project info` · 사전 점검 → `yakcloud project check [--prod]` +- 매니페스트 `environments.{dev,prod}.cluster` (+ `prod.domains`). 로컬 개발/로컬 데이터소스는 없음 — 개발도 클러스터 배포로 확인. **배포환경 설정(앱별) — 매니페스트 수정 + 배포중이면 재빌드 없이 라이브 반영** - `yakcloud domain [wl]` 도메인 등록 + 워크로드 할당 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 678ccc3..6ee9eed 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,7 +1,7 @@ # Gitea Actions — 릴리스 태그(v*) push 시 이미지 빌드/푸시 후 YakCloud 에 리컨실 배포. # 매니페스트 구동형: yakcloud.yaml 의 각 workload.build 를 빌드해 image 로 push → yakcloud_deploy.py. # 프로젝트마다 수정 불필요(레포명은 GITHUB_REPOSITORY 로 자동). -# 필요 secrets: REGISTRY_TOKEN, YAKCLOUD_URL, YAKCLOUD_TOKEN, YAKCLOUD_CLUSTER +# 필요 secrets: REGISTRY_TOKEN, YAKCLOUD_URL, YAKCLOUD_TOKEN (대상 클러스터=매니페스트 environments.dev) # 러너: 전용 host act_runner, 라벨 ci-polyglot(잡 컨테이너에 host docker.sock 마운트). name: deploy on: @@ -20,7 +20,7 @@ jobs: REG_TOKEN: ${{ secrets.REGISTRY_TOKEN }} YAKCLOUD_URL: ${{ secrets.YAKCLOUD_URL }} YAKCLOUD_TOKEN: ${{ secrets.YAKCLOUD_TOKEN }} - YAKCLOUD_CLUSTER: ${{ secrets.YAKCLOUD_CLUSTER }} + YAKCLOUD_ENV: dev # 태그 push → 개발 클러스터(environments.dev). 운영은 'yakcloud project promote' run: | set -eo pipefail TAG="${GITHUB_REF_NAME}" diff --git a/.gitignore b/.gitignore index 3e1938c..80cf3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,3 @@ __pycache__/ *.pyc node_modules/ .env -.env.dev -.yakcloud/ -docker-compose.yakcloud-dev.yml diff --git a/DATA-SOURCES.md b/DATA-SOURCES.md index ff8f9d6..293519a 100644 --- a/DATA-SOURCES.md +++ b/DATA-SOURCES.md @@ -36,31 +36,10 @@ > 비밀번호·키는 인클러스터 Secret으로 주입되고 콘솔/브라우저에 노출되지 않는다. URL의 user/pw는 URL-인코딩됨. -## 로컬 개발(`yakcloud dev`) — 배포와 동일한 env 계약 -개발 중에는 `yakcloud dev up` 이 `requires[]` 를 **로컬 docker 컨테이너**로 띄우고, 위 표와 **똑같은 `_*`** -(같은 키·URL 포맷, 값만 로컬 컨테이너를 가리킴)를 `.env.dev` 로 생성한다. 즉 로컬에서 짠 코드가 배포 후에도 무수정 동작. -```sh -yakcloud dev up # requires[] 기동 + .env.dev 생성(무거운 oracle 은 명시 기동) -yakcloud dev run -- python app.py # .env.dev 주입해 앱 실행(= 배포된 파드와 동일한 env) -yakcloud dev status | logs | down [--volumes] | reset | doctor -``` -- 자격은 프로젝트 시드에서 **결정적 파생**(재현 가능), `.yakcloud/dev/` 에만 저장(gitignore). 브라우저/원격 미노출. -- `.env.dev`·`.yakcloud/`·`docker-compose.yakcloud-dev.yml` 은 커밋 금지(스캐폴드 `.gitignore` 에 포함). -- env 계약은 프로덕션 백엔드 `_bind_env_for` 와 **바이트 동일**하게 유지(단일 진실원천). - -## 스키마·데이터 마이그레이션(`yakcloud datasource migrate`) — 블랙리스트 스냅샷 -개발 DB는 테스트로 엉망이 되기 쉬우니, **전부 캡처하되 블랙리스트로 뺀다**(denylist). 새 환경을 dev 상태로 채우는 **부트스트랩** 도구. -```sh -yakcloud datasource migrate capture [source…] --dry-run # 무엇이 잡히는지 미리보기(읽기전용) -yakcloud datasource migrate capture [source…] # db//snapshot/ 에 전체−블랙리스트 덤프 -yakcloud datasource migrate apply [source…] # 빈 타깃만 복원(데이터 있으면 스킵, force 없음) -yakcloud datasource migrate status -``` -- **블랙리스트**: `db//.migrateignore` (글롭, git 추적). 예: `audit_log_*`, `tmp_*`, `cache/*`. 민감/개인정보·쓰레기는 반드시 여기에. -- **capture=읽기전용**(안전), 전체 native 덤프. **9종 전부 지원**(pg_dump/mysqldump/mongodump/mc mirror/redis DUMP·RESTORE/solr export/rabbitmq definitions/oracle Data Pump). 블랙리스트 단위: SQL·oracle=테이블, mongo=컬렉션, minio=오브젝트, redis=키, solr=문서(쿼리절), rabbitmq=큐/익스체인지. rabbitmq는 정의만(메시지 제외), oracle은 REMAP_SCHEMA로 스키마 이식. -- **apply=빈 타깃만**. 데이터가 있으면 **절대 안 덮음**(스킵). 라이브 prod 지속 동기화가 아님. -- **db/** 는 커밋·리뷰 대상(`.env.dev`·`.yakcloud/` 와 반대로 gitignore 아님). -- prod 적용은 콘솔 종류별 소스관리자 **컨펌 + 항상 백업 후(롤백 보장)** — CLI 로 prod 직접 덮지 않음. +## 개발/운영 모두 클러스터에서 +데이터 소스는 **클러스터 안에서 실제로 프로비저닝**된다(로컬 docker 없음). 개발 클러스터에 배포하면 그 클러스터의 소스가 +`_*` 로 주입되고, 운영 클러스터로 승격하면 운영 클러스터의 소스가 주입된다 — 각 환경이 자체 데이터 계층을 가진다. +환경 간 데이터 백업·싱크는 별도(추후). ## 앱 코드 예시 ```python diff --git a/bin/yakcloud b/bin/yakcloud index 2acba63..432b4fa 100755 --- a/bin/yakcloud +++ b/bin/yakcloud @@ -1,24 +1,20 @@ #!/usr/bin/env bash -# yakcloud — YakCloud 프로젝트 CLI. 빈 폴더 초기화(스타터+스킬), 배포, 배포환경 상세 설정. +# yakcloud — YakCloud 프로젝트 CLI. 개발·운영 모두 yakcloud 클러스터에서(로컬 dev 없음). # -# yakcloud project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬) -# yakcloud project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포 -# yakcloud project info 프로젝트 개괄(매니페스트 + 라이브 상태) -# yakcloud project check dry-run(직접 API 계획만) +# yakcloud project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬) +# yakcloud project deploy [vX.Y.Z] 커밋 + 태그 push → CI 빌드 → 개발 클러스터 배포 +# yakcloud project promote [vX.Y.Z] 그 이미지를 재빌드 없이 운영 클러스터로 승격 + 운영 도메인 +# yakcloud project info 프로젝트 개괄(매니페스트 + dev/prod 라이브 상태) +# yakcloud project check [--prod] dry-run(직접 API 계획만) # -# yakcloud domain [wl] 도메인 등록 + 워크로드에 할당(관리형=즉시, 배포중이면 라이브) -# yakcloud scale replicas 변경 -# yakcloud set --image/--port/--health/--cpu/--mem/--path/--rewrite -# yakcloud env KEY=VAL … [--secret KEY] [--unset KEY] -# yakcloud source add [plan] | source rm -# yakcloud bind | unbind +# yakcloud domain [wl] · scale · set · env · datasource ls · source · bind/unbind # -# env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER +# 매니페스트: environments.{dev,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.10.0" +VERSION="0.11.0" CONFIG_DIR="${YAKCLOUD_CONFIG_DIR:-$HOME/.config/yakcloud}" CONFIG_FILE="$CONFIG_DIR/config" @@ -39,18 +35,6 @@ ctl() { python3 scripts/yakcloud_ctl.py "$@" } -dev() { - [ -f scripts/yakcloud_dev.py ] || die "프로젝트 폴더가 아닙니다 — 'yakcloud project init' 로 초기화하세요." - ensure_pyyaml - python3 scripts/yakcloud_dev.py "$@" -} - -migrate() { - [ -f scripts/yakcloud_migrate.py ] || die "프로젝트 폴더가 아닙니다 — 'yakcloud project init' 로 초기화하세요." - ensure_pyyaml - python3 scripts/yakcloud_migrate.py "$@" -} - # ── 자격 저장 ── 비밀(배포토큰·Gitea토큰)=OS 키체인(암호화), 그 외(URL·사용자)=config 파일(600) ── _KC="none" if command -v security >/dev/null 2>&1; then _KC="macos" @@ -174,10 +158,12 @@ cmd_project_init() { # 매니페스트 채우기 마법사 — ctl 은 API 미접속(YAKCLOUD_TOKEN 비움)으로 매니페스트만 편집. _init_wizard() { - local dflt_app="$1" app cluster port health cpu mem reps kv + local dflt_app="$1" app devc prodc pdom port health cpu mem reps path kv echo "── 프로젝트 설정 (엔터=기본값) ──" printf "앱 이름 [%s]: " "$dflt_app"; read -r app; app="${app:-$dflt_app}" - printf "대상 클러스터(이름 또는 콘솔 id): "; read -r cluster + printf "개발 클러스터(이름 또는 콘솔 id): "; read -r devc + printf "운영 클러스터 [%s]: " "${devc:-개발과 동일}"; read -r prodc; prodc="${prodc:-$devc}" + printf "운영 도메인(선택, 예 app.example.com): "; read -r pdom printf "컨테이너 포트 [8080]: "; read -r port; port="${port:-8080}" printf "헬스체크 경로 [/healthz]: "; read -r health; health="${health:-/healthz}" printf "CPU 요청 [25m]: "; read -r cpu; cpu="${cpu:-25m}" @@ -187,7 +173,9 @@ _init_wizard() { sed -i.bak "s/^project: .*/project: $app/" yakcloud.yaml && rm -f yakcloud.yaml.bak sed -i.bak "s/yakcloud-starter-web/${app}-web/" yakcloud.yaml 2>/dev/null; rm -f yakcloud.yaml.bak [ -n "${GITEA_USER:-}" ] && { sed -i.bak "s#/yakenator/#/${GITEA_USER}/#" yakcloud.yaml 2>/dev/null; rm -f yakcloud.yaml.bak; } - [ -n "$cluster" ] && { sed -i.bak "s#^cluster: .*#cluster: $cluster#" yakcloud.yaml && rm -f yakcloud.yaml.bak; } + [ -n "$devc" ] && { sed -i.bak "s#my-dev-cluster#$devc#" yakcloud.yaml && rm -f yakcloud.yaml.bak; } + [ -n "$prodc" ] && { sed -i.bak "s#my-prod-cluster#$prodc#" yakcloud.yaml && rm -f yakcloud.yaml.bak; } + [ -n "$pdom" ] && { sed -i.bak "s#domains: \[\]#domains: [$pdom]#" yakcloud.yaml && rm -f yakcloud.yaml.bak; } ensure_pyyaml YAKCLOUD_TOKEN= python3 scripts/yakcloud_ctl.py set web --port "$port" --health "$health" --cpu "$cpu" --mem "$mem" --path "$path" >/dev/null 2>&1 || true YAKCLOUD_TOKEN= python3 scripts/yakcloud_ctl.py scale web "$reps" >/dev/null 2>&1 || true @@ -196,7 +184,7 @@ _init_wizard() { printf " env> "; read -r kv || break; [ -z "$kv" ] && break YAKCLOUD_TOKEN= python3 scripts/yakcloud_ctl.py env web "$kv" >/dev/null 2>&1 || echo " (형식: KEY=VALUE)" done - echo "✓ 매니페스트 반영: app=$app cluster=${cluster:-<미지정>} port=$port path=$path cpu=$cpu mem=$mem replicas=$reps" + echo "✓ 매니페스트 반영: app=$app dev=${devc:-<미지정>} prod=${prodc:-<미지정>} port=$port path=$path cpu=$cpu mem=$mem replicas=$reps" } _maybe_login_prompt() { @@ -283,7 +271,25 @@ cmd_project_check() { [ -f scripts/yakcloud_deploy.py ] || die "'yakcloud project init' 먼저" : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요}" ensure_pyyaml - python3 scripts/yakcloud_deploy.py yakcloud.yaml --dry-run + local env=""; for a in "$@"; do case "$a" in --env=*) env="${a#--env=}" ;; --prod) env=prod ;; esac; done + YAKCLOUD_ENV="${env:-dev}" python3 scripts/yakcloud_deploy.py yakcloud.yaml --dry-run +} + +# 운영 승격 — 개발에서 검증한 그 태그의 이미지를 **재빌드 없이** 운영 클러스터로 리컨실 + 운영 도메인 부착. +cmd_project_promote() { + [ -f scripts/yakcloud_deploy.py ] || die "'yakcloud project init' 먼저" + : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요 — 'yakcloud login'}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요 — 'yakcloud login'}" + ensure_pyyaml + local tag=""; for a in "$@"; do case "$a" in -*) : ;; *) [ -z "$tag" ] && tag="$a" ;; esac; done + if [ -z "$tag" ]; then + git fetch -q --tags origin 2>/dev/null || true + tag="$(git tag -l 'v*' --sort=-v:refname 2>/dev/null | head -1)" + fi + [ -n "$tag" ] || die "승격할 태그가 없습니다 — 'yakcloud project promote vX.Y.Z' (개발 배포된 태그)" + case "$tag" in v*) : ;; *) tag="v$tag" ;; esac + info "운영 승격: $tag → prod 환경(재빌드 없이 개발에서 빌드된 그 이미지를 운영 클러스터로 리컨실)" + YAKCLOUD_ENV=prod TAG="$tag" python3 scripts/yakcloud_deploy.py yakcloud.yaml + echo "✓ 승격 완료: $tag → 운영 클러스터 + 운영 도메인(기본 도메인은 자동)" } # 전역 CLI 자기 갱신 — 버전이 같아도 항상 최신 파일을 강제로 다시 받아 덮어쓴다(캐시 우회). @@ -332,8 +338,7 @@ cmd_project_update() { mkdir -p scripts .gitea/workflows .claude/skills cp "$tmp/s/scripts/yakcloud_deploy.py" scripts/ 2>/dev/null || true cp "$tmp/s/scripts/yakcloud_ctl.py" scripts/ 2>/dev/null || true - cp "$tmp/s/scripts/yakcloud_dev.py" scripts/ 2>/dev/null || true - cp "$tmp/s/scripts/yakcloud_migrate.py" scripts/ 2>/dev/null || true + rm -f scripts/yakcloud_dev.py scripts/yakcloud_migrate.py # 옛 로컬 dev/migrate 잔재 제거 cp "$tmp/s/.gitea/workflows/deploy.yml" .gitea/workflows/ 2>/dev/null || true cp "$tmp/s/DATA-SOURCES.md" DATA-SOURCES.md 2>/dev/null || true rm -rf .claude/skills/yakcloud-deploy @@ -345,12 +350,13 @@ cmd_project_update() { usage() { cat < [plan] | source rm bind | unbind - ── 로컬 개발(docker 데이터소스 + prod 동일 env) ── - dev up [source…] requires[] 를 로컬 docker 로 기동 + .env.dev 생성 - dev run -- <명령> up 보장 후 .env.dev 로 앱 실행(배포와 동일 _* 주입) - dev down [--volumes] | status | logs [src] | env | reset [src] | doctor - datasource migrate capture [src…] [--dry-run] 소스 전체−블랙리스트(.migrateignore) 스냅샷(읽기전용) - datasource migrate apply [src…] 스냅샷을 빈 타깃에 적용(데이터 있으면 스킵) | migrate status -env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER +매니페스트: environments.{dev,prod}.cluster(+prod.domains). env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…) EOF } @@ -383,19 +383,16 @@ case "${1:-help}" in case "${1:-}" in init) shift; cmd_project_init "$@" ;; deploy) shift; cmd_project_deploy "$@" ;; + promote) shift; cmd_project_promote "$@" ;; info) shift; ctl info "$@" ;; check|dry-run) shift; cmd_project_check "$@" ;; update) shift; cmd_project_update "$@" ;; - *) echo "yakcloud project "; exit 1 ;; + *) echo "yakcloud project "; exit 1 ;; esac ;; login) cmd_login ;; config) cmd_config ;; logout) cmd_logout ;; - dev) shift; dev "$@" ;; - migrate) shift; migrate "$@" ;; - datasource|ds) - shift - if [ "${1:-}" = migrate ]; then shift; migrate "$@"; else ctl source "$@"; fi ;; + datasource|ds) shift; ctl source "$@" ;; domain|scale|set|env|source|bind|unbind) ctl "$@" ;; upgrade|self-update) cmd_upgrade ;; version|-v|--version) echo "yakcloud $VERSION" ;; diff --git a/scripts/yakcloud_ctl.py b/scripts/yakcloud_ctl.py index 0a1f523..dd91a0e 100644 --- a/scripts/yakcloud_ctl.py +++ b/scripts/yakcloud_ctl.py @@ -60,9 +60,13 @@ def save_manifest(m: dict) -> None: def cluster_ref(m: dict) -> str: - ref = os.environ.get("YAKCLOUD_CLUSTER") or m.get("cluster") + # 조회/설정은 기본 개발 환경 클러스터 기준. environments..cluster → 하위호환 cluster:. + env = os.environ.get("YAKCLOUD_ENV", "dev") + envs = m.get("environments") or {} + ref = (os.environ.get("YAKCLOUD_CLUSTER") + or (envs.get(env) or {}).get("cluster") or m.get("cluster")) if not ref: - sys.exit(" ✗ 대상 클러스터 미지정 — YAKCLOUD_CLUSTER 또는 yakcloud.yaml 의 cluster:") + sys.exit(" ✗ 대상 클러스터 미지정 — yakcloud.yaml 의 environments.%s.cluster (또는 YAKCLOUD_CLUSTER)" % env) return ref diff --git a/scripts/yakcloud_deploy.py b/scripts/yakcloud_deploy.py index 5bcf34f..fca9fcd 100644 --- a/scripts/yakcloud_deploy.py +++ b/scripts/yakcloud_deploy.py @@ -31,7 +31,9 @@ URL = os.environ["YAKCLOUD_URL"].rstrip("/") TOKEN = os.environ["YAKCLOUD_TOKEN"] CLUSTER_REF = os.environ.get("YAKCLOUD_CLUSTER") CLUSTER = "" # main 에서 이름→id 로 해석해 채운다 -CLUSTER_HOST = "" # 클러스터 기본 도메인 — expose.host(s) 미지정 시 여기로 노출 +CLUSTER_HOST = "" # 클러스터 기본 도메인 — 항상 노출(운영은 여기 + 운영 도메인) +ENV = os.environ.get("YAKCLOUD_ENV", "dev") # 대상 환경 environments. (기본 dev). promote=prod +ENV_DOMAINS: list = [] # main 에서 environments..domains 로 채움(운영 도메인) TAG = os.environ.get("TAG", "latest") DRY = "--dry-run" in sys.argv[1:] @@ -163,24 +165,25 @@ def deploy_workload(w: dict) -> tuple[str | None, bool]: "healthPath": w.get("health"), "path": ex.get("path", "/"), "pathType": "Prefix", "rewritePrefix": bool(ex.get("rewrite", False)), } - # 노출 도메인: 여러 개(hosts) > 단일(host) > 클러스터 기본 도메인. - hosts = ex.get("hosts") - if hosts: - body["exposeHosts"] = hosts + # 노출 도메인 = 워크로드 지정 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"] = ex.get("host") or (CLUSTER_HOST or None) + 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] - # 도메인 리컨실 — expose 에 명시한 host(들)를 레지스트리에 등록(없으면). 배포 하나로 '도달 가능한 앱'이 되게. - for h in (ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])): + # 도메인 리컨실 — 워크로드 host(들) + 환경(운영) 도메인을 레지스트리에 등록(없으면). + for h in custom: 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')}") + f"replicas={body['replicasDesired']} hosts={body.get('exposeHosts') or body.get('exposeHost')}") return None, False # 멱등: 기존 배포면 PATCH(이미지 갱신 → kubectl apply = 무중단 롤링). 없으면 신규 생성. existing = next((d for d in cluster_deployments() if d.get("name") == w["name"]), None) @@ -202,15 +205,22 @@ def bind(dep_id: str | None, alias: str, service_id: str | None, source: str) -> def main() -> None: - global CLUSTER, CLUSTER_HOST + 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)) - ref = CLUSTER_REF or m.get("cluster") + # 환경 해석: environments..{cluster,domains}. 하위호환: 없으면 top-level cluster:. + envs = m.get("environments") or {} + env_cfg = envs.get(ENV) or {} + ref = CLUSTER_REF or env_cfg.get("cluster") or m.get("cluster") if not ref: - raise SystemExit("대상 클러스터 미지정 — YAKCLOUD_CLUSTER 환경변수 또는 매니페스트 cluster: 필드") + 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 "" - log(f"project '{m.get('project')}' → cluster '{cname}' ({CLUSTER}) (tag {TAG}){' [DRY-RUN]' if DRY else ''}") + 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 ''}") src_ids: dict[str, str | None] = {} for req in m.get("requires", []): src_ids[req["name"]] = reconcile_source(req) diff --git a/scripts/yakcloud_dev.py b/scripts/yakcloud_dev.py deleted file mode 100644 index 3debb76..0000000 --- a/scripts/yakcloud_dev.py +++ /dev/null @@ -1,797 +0,0 @@ -#!/usr/bin/env python3 -"""yakcloud dev — 로컬 개발용 데이터소스(docker) + 프로덕션 동일 _* env 주입 엔진. - -매니페스트(yakcloud.yaml)의 requires[] 를 로컬 docker 컨테이너로 띄우고, -프로덕션 백엔드(_bind_env_for)와 **동일한 env 계약**을 .env.dev 로 생성한다. -개발한 코드가 배포 후에도 무수정 동작(같은 _URL 등)하도록 하는 게 목적. - -명령: up · down · status · logs · env · run · reset · doctor -로컬 전용(콘솔 API 미접속). 자격은 프로젝트 시드에서 결정적 파생 → .yakcloud/dev/ 에만 저장(gitignore). - -⚠ _bind_env_for 는 infra/api/yakcloud_api.py(2448~2542) 를 **바이트 동일 포팅**한 것(단일 진실원천). - 값 계약(키·URL 포맷)을 절대 바꾸지 말 것. 프로덕션 함수가 바뀌면 여기도 동일 반영(doctor 로 점검). -""" -from __future__ import annotations - -import argparse -import hashlib -import hmac -import json -import os -import re -import shutil -import socket -import subprocess -import sys -import time -from urllib.parse import quote - -try: - import yaml -except ModuleNotFoundError: - raise SystemExit("PyYAML 필요 — 'pip install pyyaml' 후 다시 실행하세요.") - -MANIFEST = "yakcloud.yaml" -DEV_DIR = ".yakcloud/dev" -SEED_FILE = f"{DEV_DIR}/.seed" -PORTS_FILE = f"{DEV_DIR}/ports.json" -COMPOSE_FILE = "docker-compose.yakcloud-dev.yml" -ENV_FILE = ".env.dev" -HOST = "127.0.0.1" -RABBIT_MGMT_PORT = 15672 # _bind_env_for 가 MGMT_URL 을 http://host:15672 로 하드코딩 → 고정 퍼블리시 -PORT_BASE_LOW, PORT_SPAN = 20000, 20000 - - -# ════════════════════ 프로덕션 env 계약(_bind_env_for) — 바이트 동일 포팅 ════════════════════ -# 원본: infra/api/yakcloud_api.py 2448~2542. HTTPException → RuntimeError 만 치환(문자열·로직 불변). -def _bind_env_for(inst: str, alias: str, stype: str, - data: dict, conn: dict) -> dict: - p = alias.upper().replace("-", "_") - - def need(key: str) -> str: - v = data.get(key) - if not v: - raise RuntimeError(f"service '{inst}' secret missing key '{key}'") - return v - - host, port = conn["host"], str(conn["port"]) - out: dict = {f"{p}_HOST": host, f"{p}_PORT": port} - if stype == "mongodb": - user, pw = need("MONGO_APP_USER"), need("MONGO_APP_PASSWORD") - db = conn.get("db") or need("MONGO_APP_DB") - out.update({ - f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db, - f"{p}_URL": (f"mongodb://{quote(user, safe='')}:{quote(pw, safe='')}" - f"@{host}:{port}/{db}?authSource={db}"), - }) - elif stype == "redis": - pw = need("REDIS_PASSWORD") - db = str(conn.get("db", 0)) - out.update({ - f"{p}_USERNAME": "", - f"{p}_PASSWORD": pw, f"{p}_DB": db, - f"{p}_URL": f"redis://:{quote(pw, safe='')}@{host}:{port}/{db}", - }) - elif stype == "minio": - ak, sk = need("MINIO_ROOT_USER"), need("MINIO_ROOT_PASSWORD") - bucket = conn.get("bucket") or need("MINIO_BUCKET") - ssl = str(data.get("_YC_SSL", "")).lower() == "true" or str(port) == "443" - scheme = "https" if ssl else "http" - netloc = host if ((ssl and str(port) == "443") or (not ssl and str(port) == "80")) else f"{host}:{port}" - endpoint = f"{scheme}://{netloc}" - out.update({ - f"{p}_USERNAME": ak, f"{p}_PASSWORD": sk, - f"{p}_URL": endpoint, f"{p}_ENDPOINT": endpoint, - f"{p}_ACCESS_KEY": ak, f"{p}_SECRET_KEY": sk, - f"{p}_BUCKET": bucket, f"{p}_REGION": "us-east-1", f"{p}_USE_SSL": "true" if ssl else "false", - }) - elif stype == "mysql": - user, pw = need("MYSQL_USER"), need("MYSQL_PASSWORD") - db = conn.get("db") or need("MYSQL_DATABASE") - out.update({ - f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db, - f"{p}_URL": f"mysql://{quote(user, safe='')}:{quote(pw, safe='')}@{host}:{port}/{db}", - }) - elif stype == "postgresql": - user, pw = need("POSTGRES_USER"), need("POSTGRES_PASSWORD") - db = conn.get("db") or need("POSTGRES_DB") - out.update({ - f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db, - f"{p}_URL": f"postgresql://{quote(user, safe='')}:{quote(pw, safe='')}@{host}:{port}/{db}", - }) - elif stype == "mariadb": - user, pw = need("MARIADB_USER"), need("MARIADB_PASSWORD") - db = conn.get("db") or need("MARIADB_DATABASE") - out.update({ - f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db, - f"{p}_URL": f"mysql://{quote(user, safe='')}:{quote(pw, safe='')}@{host}:{port}/{db}", - }) - elif stype == "rabbitmq": - user, pw = need("RABBITMQ_DEFAULT_USER"), need("RABBITMQ_DEFAULT_PASS") - vhost = conn.get("vhost") or need("RABBITMQ_DEFAULT_VHOST") - out.update({ - f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_VHOST": vhost, - f"{p}_URL": f"amqp://{quote(user, safe='')}:{quote(pw, safe='')}@{host}:{port}/{quote(vhost, safe='')}", - f"{p}_MGMT_URL": f"http://{host}:15672", - }) - elif stype == "solr": - core = conn.get("core") or need("SOLR_CORE") - endpoint = f"http://{host}:{port}" - out.update({ - f"{p}_USERNAME": "", f"{p}_PASSWORD": "", - f"{p}_CORE": core, f"{p}_ENDPOINT": endpoint, - f"{p}_URL": f"{endpoint}/solr/{core}", - }) - elif stype == "oracle": - user, pw = need("ORACLE_USER"), need("ORACLE_PASSWORD") - service = conn.get("service") or need("ORACLE_SERVICE") - out.update({ - f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_SERVICE": service, - f"{p}_URL": f"oracle://{quote(user, safe='')}:{quote(pw, safe='')}@{host}:{port}/{service}", - f"{p}_JDBC_URL": f"jdbc:oracle:thin:@//{host}:{port}/{service}", - f"{p}_DSN": f"{host}:{port}/{service}", - }) - else: - raise RuntimeError(f"unknown service type '{stype}'") - return out -# ════════════════════ (포팅 끝) ════════════════════ - - -TYPES = { # type → 내부포트(컨테이너), 무거움 여부 - "postgresql": {"port": 5432}, "mysql": {"port": 3306}, "mariadb": {"port": 3306}, - "mongodb": {"port": 27017}, "redis": {"port": 6379}, "minio": {"port": 9000}, - "rabbitmq": {"port": 5672}, "solr": {"port": 8983}, "oracle": {"port": 1521, "heavy": True}, -} - - -# ── 유틸 ──────────────────────────────────────────────────────────────── -def die(msg: str) -> None: - sys.exit(f" ✗ {msg}") - - -def sh(*args, check=True, capture=False, env=None): - r = subprocess.run(args, text=True, env=env, - stdout=subprocess.PIPE if capture else None, - stderr=subprocess.PIPE if capture else None) - if check and r.returncode != 0: - detail = (r.stderr or r.stdout or "").strip() if capture else "" - die(f"명령 실패({r.returncode}): {' '.join(args[:3])}…\n {detail}") - return r - - -def load_manifest() -> dict: - if not os.path.exists(MANIFEST): - die(f"{MANIFEST} 없음 — 'yakcloud project init' 먼저") - return yaml.safe_load(open(MANIFEST)) or {} - - -def project_name(m: dict) -> str: - raw = (m.get("project") or os.path.basename(os.getcwd()) or "app").lower() - return re.sub(r"[^a-z0-9]+", "-", raw).strip("-") or "app" - - -def manifest_hash(m: dict) -> str: - return hashlib.sha256(json.dumps(m, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:12] - - -def compose_project(m: dict) -> str: - return f"yakdev-{project_name(m)}-{manifest_hash(m)[:8]}" - - -def requires(m: dict) -> list[dict]: - return [r for r in (m.get("requires") or []) if r.get("name") and r.get("type")] - - -def binds(m: dict) -> list[tuple[str, str, str]]: - """(workload, alias, source) 목록 — .env.dev 는 실제 바인딩된 alias 기준으로 생성.""" - out = [] - for w in (m.get("workloads") or []): - for b in (w.get("binds") or []): - if b.get("alias") and b.get("source"): - out.append((w.get("name", "?"), b["alias"], b["source"])) - return out - - -# ── 결정적 자격 파생(시드→HMAC) ────────────────────────────────────────── -_B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - - -def ensure_seed() -> bytes: - os.makedirs(DEV_DIR, exist_ok=True) - if os.path.exists(SEED_FILE): - return bytes.fromhex(open(SEED_FILE).read().strip()) - seed = os.urandom(32) - with open(SEED_FILE, "w") as f: - f.write(seed.hex()) - os.chmod(SEED_FILE, 0o600) - return seed - - -def _hmac(seed: bytes, label: str) -> bytes: - return hmac.new(seed, label.encode(), hashlib.sha256).digest() - - -def _b62(b: bytes, n: int) -> str: - num = int.from_bytes(b, "big") - out = [] - while num and len(out) < n: - num, r = divmod(num, 62) - out.append(_B62[r]) - while len(out) < n: - out.append("0") - return "".join(out[:n]) - - -def _hex(b: bytes, n: int) -> str: - return b.hex()[:n] - - -def _norm(name: str) -> str: - s = re.sub(r"[^a-z0-9_]+", "_", (name or "app").lower()).strip("_") - if not s or not s[0].isalpha(): - s = "db_" + s - return s[:32] - - -def creds_for(seed: bytes, source: str, stype: str) -> dict: - """(seed, source, type) 로부터 결정적 자격. 특수문자 배제(base62)로 컨테이너 엔트리포인트 파싱 안전.""" - def d(field): - return _hmac(seed, f"{source}:{stype}:{field}") - pw = _b62(d("password"), 24) - if stype in ("postgresql", "mysql", "mariadb"): - return {"user": "app_" + _hex(d("user"), 8), "password": pw, - "db": _norm(source), "root": _b62(d("root"), 24)} - if stype == "mongodb": - return {"user": "app_" + _hex(d("user"), 8), "password": pw, "db": _norm(source), - "root_user": "root", "root_pw": _b62(d("root"), 24)} - if stype == "redis": - return {"password": pw, "db": "0"} - if stype == "minio": - return {"access": _b62(d("access"), 20), "secret": _b62(d("secret"), 40), "bucket": _norm(source)} - if stype == "rabbitmq": - return {"user": "app_" + _hex(d("user"), 8), "password": pw, "vhost": _norm(source)} - if stype == "solr": - return {"core": _norm(source)} - if stype == "oracle": - return {"user": "APP_" + _hex(d("user"), 8).upper(), "password": pw, - "service": "FREEPDB1", "sys_pw": _b62(d("sys"), 24)} - die(f"지원하지 않는 타입: {stype}") - - -def data_conn(source: str, stype: str, cr: dict, pub: int) -> tuple[dict, dict]: - """_bind_env_for 에 넘길 (data=인스턴스 Secret 상당, conn=접속정보). 키명은 need()/_DS_SQL_KEYS 와 정확히 일치.""" - conn = {"host": HOST, "port": pub} - if stype == "postgresql": - data = {"POSTGRES_USER": cr["user"], "POSTGRES_PASSWORD": cr["password"], "POSTGRES_DB": cr["db"]} - conn["db"] = cr["db"] - elif stype == "mysql": - data = {"MYSQL_USER": cr["user"], "MYSQL_PASSWORD": cr["password"], "MYSQL_DATABASE": cr["db"]} - conn["db"] = cr["db"] - elif stype == "mariadb": - data = {"MARIADB_USER": cr["user"], "MARIADB_PASSWORD": cr["password"], "MARIADB_DATABASE": cr["db"]} - conn["db"] = cr["db"] - elif stype == "mongodb": - data = {"MONGO_APP_USER": cr["user"], "MONGO_APP_PASSWORD": cr["password"], "MONGO_APP_DB": cr["db"]} - conn["db"] = cr["db"] - elif stype == "redis": - data = {"REDIS_PASSWORD": cr["password"]} - conn["db"] = 0 - elif stype == "minio": - data = {"MINIO_ROOT_USER": cr["access"], "MINIO_ROOT_PASSWORD": cr["secret"], "MINIO_BUCKET": cr["bucket"]} - conn["bucket"] = cr["bucket"] - elif stype == "rabbitmq": - data = {"RABBITMQ_DEFAULT_USER": cr["user"], "RABBITMQ_DEFAULT_PASS": cr["password"], - "RABBITMQ_DEFAULT_VHOST": cr["vhost"]} - conn["vhost"] = cr["vhost"] - elif stype == "solr": - data = {"SOLR_CORE": cr["core"]} - conn["core"] = cr["core"] - elif stype == "oracle": - data = {"ORACLE_USER": cr["user"], "ORACLE_PASSWORD": cr["password"], "ORACLE_SERVICE": cr["service"]} - conn["service"] = cr["service"] - else: - die(f"지원하지 않는 타입: {stype}") - return data, conn - - -# ── 포트 할당(결정적 후보 + 점유 검사, ports.json 고정) ────────────────── -def _free(port: int) -> bool: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - try: - s.bind((HOST, port)) - return True - except OSError: - return False - - -def alloc_ports(m: dict, reqs: list[dict]) -> dict: - os.makedirs(DEV_DIR, exist_ok=True) - saved = {} - if os.path.exists(PORTS_FILE): - saved = json.load(open(PORTS_FILE)) - base = PORT_BASE_LOW + (int(manifest_hash(m), 16) % PORT_SPAN) - used = set() - ports = {} - for i, r in enumerate(sorted(reqs, key=lambda x: x["name"])): - name, stype = r["name"], r["type"] - need_aux = stype in ("minio", "rabbitmq") - prev = saved.get(name) - # 기존 할당이 여전히 비어 있으면 재사용(재현성). 아니면 결정적 후보에서 재탐색. - cand = (prev.get("port") if isinstance(prev, dict) else None) or (base + i * 10) - while cand in used or (not _free(cand) and cand != (prev or {}).get("port")): - cand += 1 - used.add(cand) - entry = {"port": cand} - if need_aux: - acand = (prev.get("aux") if isinstance(prev, dict) else None) or (cand + 1) - while acand in used or not _free(acand): - acand += 1 - used.add(acand) - entry["aux"] = acand - ports[name] = entry - json.dump(ports, open(PORTS_FILE, "w"), indent=2) - return ports - - -# ── compose 서비스/초기화 자산 생성 ────────────────────────────────────── -def build_service(source: str, stype: str, cr: dict, pub: int, aux: int | None): - """(compose 서비스 dict, {초기화 사이드카 서비스}, {써야 할 자산파일: 내용}) 반환.""" - internal = TYPES[stype]["port"] - svc = {"container_name": f"yakdev-{source}", "restart": "unless-stopped", - "ports": [f"{HOST}:{pub}:{internal}"]} - side, assets = {}, {} - if stype == "postgresql": - svc.update(image="postgres:16-alpine", - environment={"POSTGRES_USER": cr["user"], "POSTGRES_PASSWORD": cr["password"], "POSTGRES_DB": cr["db"]}, - volumes=[f"yakdev-{source}-data:/var/lib/postgresql/data"], - healthcheck={"test": ["CMD-SHELL", f"pg_isready -U {cr['user']} -d {cr['db']}"], - "interval": "3s", "timeout": "3s", "retries": 20}) - elif stype in ("mysql", "mariadb"): - img = "mysql:8.4" if stype == "mysql" else "mariadb:11.4" - pre = "MYSQL" if stype == "mysql" else "MARIADB" - svc.update(image=img, - environment={f"{pre}_USER": cr["user"], f"{pre}_PASSWORD": cr["password"], - f"{pre}_DATABASE": cr["db"], f"{pre}_ROOT_PASSWORD": cr["root"]}, - volumes=[f"yakdev-{source}-data:/var/lib/mysql"]) - if stype == "mysql": - svc["healthcheck"] = {"test": ["CMD-SHELL", f"mysqladmin ping -h127.0.0.1 -u{cr['user']} -p{cr['password']}"], - "interval": "3s", "timeout": "3s", "retries": 30, "start_period": "20s"} - else: - svc["healthcheck"] = {"test": ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized"], - "interval": "3s", "timeout": "3s", "retries": 30, "start_period": "10s"} - elif stype == "mongodb": - # authSource= 계약 → 대상 db 에 앱유저 생성(initdb.d, 볼륨 최초부팅에만 실행). - js = (f"db.getSiblingDB({json.dumps(cr['db'])}).createUser({{" - f"user:{json.dumps(cr['user'])},pwd:{json.dumps(cr['password'])}," - f"roles:[{{role:'dbOwner',db:{json.dumps(cr['db'])}}}]}});\n") - assets[f"{source}-init/00-appuser.js"] = js - svc.update(image="mongo:7", - environment={"MONGO_INITDB_ROOT_USERNAME": cr["root_user"], "MONGO_INITDB_ROOT_PASSWORD": cr["root_pw"], - "MONGO_INITDB_DATABASE": cr["db"]}, - volumes=[f"yakdev-{source}-data:/data/db", - f"./{DEV_DIR}/{source}-init:/docker-entrypoint-initdb.d:ro"], - healthcheck={"test": ["CMD-SHELL", "mongosh --quiet --eval 'db.adminCommand(\"ping\").ok' | grep 1"], - "interval": "3s", "timeout": "3s", "retries": 20, "start_period": "5s"}) - elif stype == "redis": - svc.update(image="redis:7-alpine", - command=["redis-server", "--requirepass", cr["password"], "--save", ""], - healthcheck={"test": ["CMD-SHELL", f"redis-cli -a {cr['password']} ping | grep -q PONG"], - "interval": "3s", "timeout": "3s", "retries": 10}) - elif stype == "minio": - svc.update(image="minio/minio:latest", - command=["server", "/data", "--console-address", ":9001"], - environment={"MINIO_ROOT_USER": cr["access"], "MINIO_ROOT_PASSWORD": cr["secret"]}, - volumes=[f"yakdev-{source}-data:/data"], - healthcheck={"test": ["CMD-SHELL", "mc ready local 2>/dev/null || curl -fsS http://localhost:9000/minio/health/live"], - "interval": "3s", "timeout": "3s", "retries": 20, "start_period": "5s"}) - if aux: - svc["ports"].append(f"{HOST}:{aux}:9001") - # 버킷 생성 사이드카(멱등). minio healthy 후 실행, 성공 exit. - side[f"{source}-init"] = { - "image": "minio/mc:latest", "container_name": f"yakdev-{source}-init", "restart": "no", - "depends_on": {source: {"condition": "service_healthy"}}, - "entrypoint": ["sh", "-c", - f"mc alias set local http://{source}:9000 {cr['access']} {cr['secret']} && " - f"mc mb -p local/{cr['bucket']} && echo bucket-ready"]} - elif stype == "rabbitmq": - svc.update(image="rabbitmq:3.13-management", - environment={"RABBITMQ_DEFAULT_USER": cr["user"], "RABBITMQ_DEFAULT_PASS": cr["password"], - "RABBITMQ_DEFAULT_VHOST": cr["vhost"]}, - volumes=[f"yakdev-{source}-data:/var/lib/rabbitmq"], - healthcheck={"test": ["CMD-SHELL", "rabbitmq-diagnostics -q check_running && rabbitmq-diagnostics -q check_port_connectivity"], - "interval": "5s", "timeout": "5s", "retries": 20, "start_period": "20s"}) - # MGMT_URL 은 계약상 http://host:15672 고정 → mgmt 는 항상 15672 로 퍼블리시. - svc["ports"].append(f"{HOST}:{RABBIT_MGMT_PORT}:15672") - elif stype == "solr": - svc.update(image="solr:9", - command=["solr-precreate", cr["core"]], - volumes=[f"yakdev-{source}-data:/var/solr"], - healthcheck={"test": ["CMD-SHELL", f"curl -fsS http://localhost:8983/solr/{cr['core']}/admin/ping || exit 1"], - "interval": "3s", "timeout": "3s", "retries": 20, "start_period": "15s"}) - elif stype == "oracle": - svc.update(image="gvenzl/oracle-free:23-slim-faststart", - environment={"ORACLE_PASSWORD": cr["sys_pw"], "APP_USER": cr["user"], "APP_USER_PASSWORD": cr["password"]}, - volumes=[f"yakdev-{source}-data:/opt/oracle/oradata"], - healthcheck={"test": ["CMD-SHELL", "healthcheck.sh"], - "interval": "10s", "timeout": "10s", "retries": 40, "start_period": "90s"}) - else: - die(f"지원하지 않는 타입: {stype}") - return svc, side, assets - - -def gen_compose(m: dict, reqs: list[dict], ports: dict, seed: bytes) -> dict: - services, volumes = {}, {} - os.makedirs(DEV_DIR, exist_ok=True) - for r in reqs: - name, stype = r["name"], r["type"] - cr = creds_for(seed, name, stype) - pub = ports[name]["port"] - aux = ports[name].get("aux") - svc, side, assets = build_service(name, stype, cr, pub, aux) - services[name] = svc - services.update(side) - volumes[f"yakdev-{name}-data"] = None - for rel, content in assets.items(): - path = os.path.join(DEV_DIR, rel) - os.makedirs(os.path.dirname(path), exist_ok=True) - open(path, "w").write(content) - doc = {"name": compose_project(m), "services": services} - if volumes: - doc["volumes"] = {k: (v or {}) for k, v in volumes.items()} - yaml.safe_dump(doc, open(COMPOSE_FILE, "w"), sort_keys=False, allow_unicode=True, default_flow_style=False) - return doc - - -# ── .env.dev 생성(바인딩 alias 별 _* + 정적 env 병합) ────────────── -def compute_env(m: dict, reqs: list[dict], ports: dict, seed: bytes) -> tuple[dict, list[str]]: - by_name = {r["name"]: r["type"] for r in reqs} - env: dict = {} - lines: list[str] = [] - for wl, alias, source in binds(m): - stype = by_name.get(source) - if not stype or source not in ports: - continue - cr = creds_for(seed, source, stype) - data, conn = data_conn(source, stype, cr, ports[source]["port"]) - block = _bind_env_for(source, alias, stype, data, conn) - lines.append(f"# workload {wl} · alias {alias} → {source} ({stype})") - for k, v in block.items(): - env[k] = v - lines.append(f"{k}={v}") - lines.append("") - # 워크로드 정적 env(prod 배포 env 세트 재현) - static = [] - for w in (m.get("workloads") or []): - for e in (w.get("env") or []): - if isinstance(e, dict) and e.get("key"): - static.append((e["key"], str(e.get("value", "")))) - if isinstance(m.get("env"), dict): - static += [(k, str(v)) for k, v in m["env"].items()] - if static: - lines.append("# workloads[].env (정적)") - for k, v in static: - env[k] = v - lines.append(f"{k}={v}") - lines.append("") - return env, lines - - -def write_env_file(m: dict, lines: list[str]) -> None: - header = [ - "# ⚠ 자동 생성 — 'yakcloud dev up/run' 이 매번 덮어씀. 직접 수정 금지.", - f"# manifest-hash: {manifest_hash(m)}", - "# 프로덕션 배포 시 백엔드가 주입하는 _* 와 동일한 계약(로컬 컨테이너를 가리킴).", - "", - ] - open(ENV_FILE, "w").write("\n".join(header + lines) + "\n") - os.chmod(ENV_FILE, 0o600) - - -def parse_env_file() -> dict: - if not os.path.exists(ENV_FILE): - die(f"{ENV_FILE} 없음 — 'yakcloud dev up' 먼저") - out = {} - for ln in open(ENV_FILE): - ln = ln.rstrip("\n") - if not ln or ln.lstrip().startswith("#") or "=" not in ln: - continue - k, v = ln.split("=", 1) - out[k.strip()] = v - return out - - -# ── docker/compose 프리플라이트 ────────────────────────────────────────── -def preflight() -> None: - if subprocess.run(["docker", "version"], capture_output=True, text=True).returncode != 0: - die("docker 데몬에 접속 불가 — Docker Desktop/데몬을 켜세요.") - if subprocess.run(["docker", "compose", "version"], capture_output=True, text=True).returncode != 0: - die("'docker compose' 없음 — Docker Compose v2 필요.") - - -def compose(m: dict, *args, check=True, capture=False): - return sh("docker", "compose", "-p", compose_project(m), "-f", COMPOSE_FILE, *args, check=check, capture=capture) - - -def _ps_state(m: dict) -> dict: - r = compose(m, "ps", "-a", "--format", "json", check=False, capture=True) - st = {} - for ln in (r.stdout or "").splitlines(): - ln = ln.strip() - if not ln: - continue - try: - j = json.loads(ln) - st[j.get("Service")] = j - except Exception: - pass - return st - - -def wait_ready(m: dict, timeout: int) -> None: - """compose --wait 대체(일회성 init 컨테이너 exit0 을 실패로 오인하는 문제 회피). - 장기 서비스=healthy(또는 healthcheck 없으면 running), 일회성(*-init/restart:no)=exit 0 로 판정.""" - doc = yaml.safe_load(open(COMPOSE_FILE)) - long_svc, oneshot = [], [] - for name, sv in doc["services"].items(): - (oneshot if (name.endswith("-init") or str(sv.get("restart")) == "no") else long_svc).append(name) - deadline = time.time() + timeout - while True: - state = _ps_state(m) - pending, failed = [], [] - for n in long_svc: - j = state.get(n) or {} - h, s = (j.get("Health") or ""), (j.get("State") or "") - if h == "healthy" or (h == "" and s == "running"): - continue - (failed if h == "unhealthy" else pending).append(n) - for n in oneshot: - j = state.get(n) or {} - s, ec = (j.get("State") or ""), j.get("ExitCode") - if s == "exited": - if ec in (0, "0", None): - continue - failed.append(f"{n}(init exit={ec})") - else: - pending.append(n) - if failed: - die(f"기동 실패: {', '.join(map(str, failed))} — 'yakcloud dev logs <소스>' 로 확인") - if not pending: - return - if time.time() > deadline: - die(f"준비 대기 초과({timeout}s): {', '.join(pending)} — 'yakcloud dev logs' 확인 또는 --timeout 늘리기") - time.sleep(2) - - -# ── 명령 ──────────────────────────────────────────────────────────────── -def select_reqs(m: dict, names: list[str]) -> list[dict]: - reqs = requires(m) - if not reqs: - die("requires[] 가 비어 있음 — 'yakcloud source add ' 로 소스를 추가하세요.") - if names: - want = set(names) - reqs = [r for r in reqs if r["name"] in want] - missing = want - {r["name"] for r in reqs} - if missing: - die(f"매니페스트 requires 에 없음: {', '.join(sorted(missing))}") - return reqs - - -def cmd_up(a) -> None: - preflight() - m = load_manifest() - reqs = select_reqs(m, a.source) - heavy = [r["name"] for r in reqs if TYPES.get(r["type"], {}).get("heavy")] - if heavy and not a.source: - print(f" ⚠ 무거운 소스 제외(기본): {', '.join(heavy)} — 필요하면 'yakcloud dev up {heavy[0]}' 로 명시 기동") - reqs = [r for r in reqs if r["name"] not in heavy] - if not reqs: - die("기동할 소스가 없습니다.") - seed = ensure_seed() - if a.fresh: - compose(m, "down", "-v", check=False, capture=True) - ports = alloc_ports(m, reqs) - gen_compose(m, reqs, ports, seed) - names = [r["name"] for r in reqs] - print(f"▸ dev 소스 기동: {', '.join(names)} (project {compose_project(m)})") - # compose 파일은 선택된 소스(+init 사이드카)만 담으므로 서비스 명시 없이 전체 up. - up = ["up", "-d"] - if a.pull: - up.append("--pull=always") - compose(m, *up) - if not a.no_wait: - wait_ready(m, a.timeout) - env, lines = compute_env(m, reqs, ports, seed) - write_env_file(m, lines) - print(f"✓ 준비 완료 · {ENV_FILE} 생성({len(env)}개 env)") - for r in reqs: - p = ports[r["name"]]["port"] - print(f" {r['name']:<16} {r['type']:<11} 127.0.0.1:{p}") - if not binds(m): - print(" · 아직 바인딩 없음 — 'yakcloud bind ' 후 다시 up 하면 _* 가 채워집니다.") - print(f" 다음: 앱 실행 = yakcloud dev run -- <명령> (예: yakcloud dev run -- python app.py)") - - -def cmd_down(a) -> None: - m = load_manifest() - args = ["down"] - if a.volumes: - args.append("-v") - compose(m, *args, check=False) - if a.volumes: - shutil.rmtree(DEV_DIR, ignore_errors=True) # .seed·ports.json·-init 자산 일괄 삭제 - for f in (ENV_FILE, COMPOSE_FILE): - if os.path.exists(f): - os.remove(f) - print("✓ dev 중지 + 볼륨·자격·env·compose 삭제(완전 초기화)") - else: - print(f"✓ dev 중지(볼륨 보존 — 재 up 시 자격/데이터 유지). 완전 초기화=--volumes") - - -def cmd_status(a) -> None: - m = load_manifest() - if not os.path.exists(COMPOSE_FILE): - die("dev 미기동 — 'yakcloud dev up' 먼저") - r = compose(m, "ps", "--format", "json", check=False, capture=True) - rows = [] - for ln in (r.stdout or "").splitlines(): - ln = ln.strip() - if ln: - try: - rows.append(json.loads(ln)) - except Exception: - pass - ports = json.load(open(PORTS_FILE)) if os.path.exists(PORTS_FILE) else {} - bmap = {s: al for _, al, s in binds(m)} - print(f"dev 상태 · project {compose_project(m)}") - print(f" {'소스':<16} {'상태':<20} {'포트':<8} {'alias'}") - for row in rows: - name = row.get("Service", "?") - st = row.get("Health") or row.get("State", "?") - p = ports.get(name, {}).get("port", "-") - print(f" {name:<16} {str(st):<20} {str(p):<8} {bmap.get(name, '')}") - fresh = "최신" - if os.path.exists(ENV_FILE): - want = manifest_hash(m) - cur = next((l.split(":", 1)[1].strip() for l in open(ENV_FILE) if l.startswith("# manifest-hash:")), "") - fresh = "최신" if cur == want else "⚠ 오래됨(매니페스트 변경 — 'yakcloud dev up' 재실행)" - else: - fresh = "없음" - print(f" {ENV_FILE}: {fresh}") - - -def cmd_logs(a) -> None: - m = load_manifest() - args = ["logs"] - if a.follow: - args.append("-f") - if a.source: - args.append(a.source) - compose(m, *args, check=False) - - -def cmd_env(a) -> None: - m = load_manifest() - seed = ensure_seed() - reqs = requires(m) - ports = json.load(open(PORTS_FILE)) if os.path.exists(PORTS_FILE) else alloc_ports(m, reqs) - env, lines = compute_env(m, reqs, ports, seed) - if a.json: - print(json.dumps(env, ensure_ascii=False, indent=2)) - elif a.export: - for k, v in env.items(): - print(f"export {k}={json.dumps(v)}") - elif a.check: - ok = os.path.exists(ENV_FILE) and os.path.exists(COMPOSE_FILE) - print(f" {ENV_FILE}={'있음' if os.path.exists(ENV_FILE) else '없음'} · compose={'있음' if os.path.exists(COMPOSE_FILE) else '없음'} · env {len(env)}개") - if not ok: - die("dev 미기동 — 'yakcloud dev up' 먼저") - else: - print("\n".join(lines)) - - -def cmd_run(a) -> None: - if not a.cmd: - die("실행할 명령을 주세요 — 예: yakcloud dev run -- python app.py") - m = load_manifest() - if not a.no_up: - # up 을 선행(최신 .env.dev 보장). 인자 없이 = 모든(무거운 것 제외) 소스. - up_args = argparse.Namespace(source=[], fresh=False, no_wait=False, pull=False, timeout=a.timeout) - cmd_up(up_args) - env = dict(os.environ) - env.update(parse_env_file()) - print(f"▸ dev run: {' '.join(a.cmd)}") - os.execvpe(a.cmd[0], a.cmd, env) - - -def cmd_reset(a) -> None: - m = load_manifest() - if os.path.exists(COMPOSE_FILE): - if a.source: - compose(m, "rm", "-fs", a.source, check=False) - # named 볼륨은 compose rm -v 로 안 지워짐 → 명시 삭제(진짜 초기화) - sh("docker", "volume", "rm", "-f", f"{compose_project(m)}_yakdev-{a.source}-data", - check=False, capture=True) - else: - compose(m, "down", "-v", check=False) - print(f"✓ 초기화 완료{'('+a.source+')' if a.source else ''} — 'yakcloud dev up' 로 재기동(초기화 재실행)") - - -def cmd_doctor(a) -> None: - print("yakcloud dev doctor") - d = subprocess.run(["docker", "version"], capture_output=True, text=True) - print(f" docker : {'OK' if d.returncode == 0 else '✗ 데몬 미가동'}") - c = subprocess.run(["docker", "compose", "version"], capture_output=True, text=True) - print(f" docker compose : {'OK ' + (c.stdout or '').strip()[:40] if c.returncode == 0 else '✗ 없음'}") - print(f" arch : {os.uname().machine} (arm64=Apple Silicon: oracle 이미지 무거움/미지원 가능)") - m = load_manifest() if os.path.exists(MANIFEST) else {} - reqs = requires(m) - print(f" requires : {', '.join(r['name']+'('+r['type']+')' for r in reqs) or '(없음)'}") - # SSOT drift 점검: 프로덕션 원본이 있으면(_유지보수 환경_) 함수 본문 비교. - src = "infra/api/yakcloud_api.py" - if os.path.exists(src): - body = open(src).read() - ok = 'f"{p}_URL": (f"mongodb://{quote(user, safe=' in body and 'f"{p}_MGMT_URL": f"http://{host}:15672"' in body - print(f" _bind_env_for SSOT: {'참조 원본 발견 — 계약 마커 일치' if ok else '⚠ 원본과 계약 마커 불일치(포팅 재검토)'}") - else: - print(" _bind_env_for SSOT: (프로덕션 원본 없음 — 배포 시 백엔드가 동일 계약 주입)") - # 포트 충돌 - if reqs: - ports = alloc_ports(m, reqs) - conflict = [n for n, p in ports.items() if not _free(p["port"])] - print(f" 포트 : {'충돌 없음' if not conflict else '사용 중(재기동 시 자동 시프트): ' + ', '.join(conflict)}") - - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(prog="yakcloud dev", description="로컬 개발 데이터소스(docker) + prod 동일 env") - sub = p.add_subparsers(dest="cmd", required=True) - - up = sub.add_parser("up", help="소스 기동 + .env.dev 생성") - up.add_argument("source", nargs="*", help="일부만(기본=전체, 무거운 것 제외)") - up.add_argument("--fresh", action="store_true", help="볼륨·자격 초기화 후 기동") - up.add_argument("--no-wait", action="store_true", help="healthy 대기 생략") - up.add_argument("--pull", action="store_true", help="이미지 최신 pull") - up.add_argument("--timeout", type=int, default=180, help="healthy 대기 초(기본 180)") - up.set_defaults(fn=cmd_up) - - dn = sub.add_parser("down", help="중지(기본=볼륨 보존)") - dn.add_argument("--volumes", action="store_true", help="데이터 볼륨·자격·env 까지 삭제") - dn.set_defaults(fn=cmd_down) - - sub.add_parser("status", help="컨테이너 상태·포트·env 신선도").set_defaults(fn=cmd_status) - - lg = sub.add_parser("logs", help="컨테이너 로그") - lg.add_argument("source", nargs="?") - lg.add_argument("-f", "--follow", action="store_true") - lg.set_defaults(fn=cmd_logs) - - ev = sub.add_parser("env", help="_* env 출력") - ev.add_argument("--export", action="store_true", help="eval 용 export K=V") - ev.add_argument("--json", action="store_true") - ev.add_argument("--check", action="store_true", help="기동/신선도 진단") - ev.set_defaults(fn=cmd_env) - - rn = sub.add_parser("run", help="up 보장 후 .env.dev 로 앱 실행") - rn.add_argument("--no-up", action="store_true", help="기동 생략(이미 떠 있음)") - rn.add_argument("--timeout", type=int, default=180) - rn.add_argument("cmd", nargs=argparse.REMAINDER, help="-- 뒤에 실행할 명령") - rn.set_defaults(fn=cmd_run) - - rs = sub.add_parser("reset", help="볼륨 삭제 후 재초기화") - rs.add_argument("source", nargs="?") - rs.set_defaults(fn=cmd_reset) - - sub.add_parser("doctor", help="docker/compose/포트/계약 점검").set_defaults(fn=cmd_doctor) - return p - - -def main() -> None: - args = build_parser().parse_args() - # run 의 REMAINDER 는 앞의 '--' 를 포함할 수 있음 → 제거. - if getattr(args, "cmd", None) == "run" or getattr(args, "fn", None) is cmd_run: - if args.cmd and args.cmd[0] == "--": - args.cmd = args.cmd[1:] - args.fn(args) - - -if __name__ == "__main__": - main() diff --git a/scripts/yakcloud_migrate.py b/scripts/yakcloud_migrate.py deleted file mode 100644 index 1ff902f..0000000 --- a/scripts/yakcloud_migrate.py +++ /dev/null @@ -1,531 +0,0 @@ -#!/usr/bin/env python3 -"""yakcloud datasource migrate — 데이터소스 스냅샷(블랙리스트) 캡처 + 빈 타깃 적용(부트스트랩). - -모델(확정): - capture = 소스 전체 native 덤프 − 블랙리스트(db//.migrateignore, 글롭) → db//snapshot/. - **읽기전용**(항상 안전). 커밋 전 '무엇이 잡히는지' 미리보기. - apply = 타깃이 **비어 있을 때만** 복원. 데이터가 있으면 **스킵**(절대 안 덮음, force 없음). - 즉 새 환경을 dev 상태로 채우는 부트스트랩/시딩 도구. (prod 적용은 백엔드 경유 — 별도 승인) - -dev 채널: 러너를 DB 클라이언트 이미지 one-shot 으로 compose 네트워크에 join → 소스 컨테이너 직접(자격 미노출). -자격은 yakcloud_dev 의 결정적 파생(creds_for)을 그대로 사용. 지원 타입: postgresql·mysql·mariadb·mongodb·minio. -""" -from __future__ import annotations - -import argparse -import fnmatch -import json -import os -import subprocess -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import yakcloud_dev as dev # noqa: E402 (동일 scripts/ 의 dev 엔진 헬퍼 재사용) - -SNAP = "db" # 프로젝트 루트 db// (git 추적 — .gitignore 아님) -IGNORE = ".migrateignore" -SUPPORTED = ("postgresql", "mysql", "mariadb", "mongodb", "minio", "redis", "solr", "rabbitmq", "oracle") -IMG = {"postgresql": "postgres:16-alpine", "mysql": "mysql:8.4", "mariadb": "mariadb:11.4", - "mongodb": "mongo:7", "minio": "minio/mc:latest", - "redis": "redis:7-alpine", "solr": "solr:9", "rabbitmq": "rabbitmq:3.13-management"} -UNSUPPORTED_NEXT = () # 9종 전부 지원(oracle=Data Pump) - - -def _fill(tmpl: str, **kw) -> str: - """플레이스홀더 치환 — f-string/.format 대신(셸의 \\r\\n·$7·${..} 이스케이프를 보존).""" - for k, v in kw.items(): - tmpl = tmpl.replace("{" + k + "}", str(v)) - return tmpl - - -# redis 캡처: SCAN → 블랙리스트(키 글롭) 클라이언트 제외 → 키별 PTTL+DUMP(base64) → keys.b64. 읽기전용. -_REDIS_CAPTURE = r''' -R="redis-cli -h {host} -a {pw} --no-auth-warning -n {db}"; EX="{bl}"; : > /out/keys.b64 -$R --scan | while IFS= read -r k; do - [ -z "$k" ] && continue - skip=0; for pat in $EX; do case "$k" in $pat) skip=1;; esac; done; [ "$skip" = 1 ] && continue - pttl=$($R PTTL "$k"); case "$pttl" in -*) pttl=0;; esac - kb=$(printf "%s" "$k" | base64 | tr -d "\n") - vb=$($R DUMP "$k" | head -c -1 | base64 | tr -d "\n") - printf "%s\t%s\t%s\n" "$pttl" "$kb" "$vb" >> /out/keys.b64 -done -''' - -# redis 복원: keys.b64 → RESP RESTORE(REPLACE) 스트림 → redis-cli --pipe(바이너리 안전). 빈 타깃 전제. -_REDIS_RESTORE = r''' -R="redis-cli -h {host} -a {pw} --no-auth-warning -n {db}"; TAB=$(printf '\t') -{ while IFS="$TAB" read -r pttl kb vb; do - [ -z "$kb" ] && continue - k=$(printf "%s" "$kb" | base64 -d) - vlen=$(printf "%s" "$vb" | base64 -d | wc -c | tr -d ' ') - klen=$(printf "%s" "$k" | wc -c | tr -d ' '); tlen=$(printf "%s" "$pttl" | wc -c | tr -d ' ') - printf '*5\r\n$7\r\nRESTORE\r\n' - printf '$%s\r\n%s\r\n' "$klen" "$k" - printf '$%s\r\n%s\r\n' "$tlen" "$pttl" - printf '$%s\r\n' "$vlen"; printf "%s" "$vb" | base64 -d; printf '\r\n' - printf '$7\r\nREPLACE\r\n' - done < /out/keys.b64; } | $R --pipe -''' - - -def die(m: str) -> None: - sys.exit(f" ✗ {m}") - - -def _net(m: dict) -> str: - return f"{dev.compose_project(m)}_default" - - -def _running(m: dict, source: str) -> bool: - j = dev._ps_state(m).get(source) or {} - return (j.get("State") or "") == "running" - - -def _srcdir(source: str) -> str: - return os.path.join(SNAP, source) - - -def _blacklist(source: str) -> list[str]: - p = os.path.join(_srcdir(source), IGNORE) - if not os.path.exists(p): - return [] - out = [] - for ln in open(p): - ln = ln.split("#", 1)[0].strip() - if ln: - out.append(ln) - return out - - -def _excluded(name: str, patterns: list[str]) -> bool: - return any(fnmatch.fnmatch(name, pat) for pat in patterns) - - -def drun(net: str, image: str, shell: str, mounts=None, capture=True, check=True): - """docker run one-shot(sh -c) — 모든 명령을 쉘 경유(리다이렉트/파이프 지원).""" - args = ["docker", "run", "--rm", "--network", net] - for host, cont in (mounts or []): - os.makedirs(host, exist_ok=True) - args += ["-v", f"{os.path.abspath(host)}:{cont}"] - args += ["--entrypoint", "sh", image, "-c", shell] - r = subprocess.run(args, text=True, - stdout=subprocess.PIPE if capture else None, - stderr=subprocess.PIPE if capture else None) - if check and r.returncode != 0: - die(f"클라이언트 실행 실패({r.returncode}): {(r.stderr or r.stdout or '').strip()[:400]}") - return r - - -# ── 타입별: 대상 단위 나열(미리보기·글롭 확장용) ──────────────────────────── -def list_units(m: dict, source: str, stype: str, cr: dict) -> list[str]: - net = _net(m) - if stype == "postgresql": - url = f"postgresql://{cr['user']}:{cr['password']}@{source}:5432/{cr['db']}" - q = "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" - r = drun(net, IMG[stype], f"psql '{url}' -tAc \"{q}\"", check=False) - elif stype in ("mysql", "mariadb"): - q = f"SELECT table_name FROM information_schema.tables WHERE table_schema='{cr['db']}'" - r = drun(net, IMG[stype], - f"mysql -h {source} -u{cr['user']} -p{cr['password']} -N -B -e \"{q}\" 2>/dev/null", check=False) - elif stype == "mongodb": - js = "db.getCollectionNames().filter(c=>!c.startsWith('_yakcloud')).join('\\n')" - r = drun(net, IMG[stype], - f"mongosh --quiet --host {source} -u {cr['user']} -p {cr['password']} " - f"--authenticationDatabase {cr['db']} {cr['db']} --eval \"{js}\"", check=False) - elif stype == "minio": - # minio/mc 이미지엔 awk 가 없음 → --json 으로 받아 파이썬에서 key 파싱. - setup = f"mc alias set s http://{source}:9000 {cr['access']} {cr['secret']} >/dev/null 2>&1" - r = drun(net, IMG[stype], - f"{setup} && mc ls --recursive --json s/{cr['bucket']} 2>/dev/null", check=False) - keys = [] - for ln in (r.stdout or "").splitlines(): - ln = ln.strip() - if not ln: - continue - try: - j = json.loads(ln) - if j.get("key"): - keys.append(j["key"]) - except Exception: - pass - return keys - elif stype == "redis": - r = drun(net, IMG[stype], - f"redis-cli -h {source} -a {cr['password']} --no-auth-warning -n {cr['db']} --scan", check=False) - return [x for x in (r.stdout or "").splitlines() if x.strip()] - elif stype == "rabbitmq": - ra = (f"rabbitmqadmin -H {source} -P 15672 -u {cr['user']} -p {cr['password']} " - f"-V {cr['vhost']} -f tsv -q") - q = drun(net, IMG[stype], f"{ra} list queues name", check=False) - e = drun(net, IMG[stype], f"{ra} list exchanges name", check=False) - qn = [x.strip() for x in (q.stdout or "").splitlines() if x.strip() and x.strip() != "name"] - en = [x.strip() for x in (e.stdout or "").splitlines() - if x.strip() and x.strip() != "name" and not x.strip().startswith("amq.")] - return qn + en - elif stype == "solr": - # source=core 1:1 → 단위='문서'. is_empty/미리보기용 표본 id(최대 10). 전체 수는 _solr_numfound. - r = drun(net, IMG[stype], - f'curl -sS "http://{source}:8983/solr/{cr["core"]}/select?q=*:*&rows=10&fl=id&wt=json"', check=False) - try: - return [str(d.get("id", "?")) for d in json.loads(r.stdout or "{}").get("response", {}).get("docs", [])] - except Exception: - return [] - elif stype == "oracle": - return _ora_lines(_ora_sql(source, cr, - f"SELECT table_name FROM all_tables WHERE owner = UPPER('{cr['user']}');")) - else: - return [] - return [x.strip() for x in (r.stdout or "").splitlines() if x.strip() and "Warning" not in x] - - -def _solr_numfound(m: dict, source: str, cr: dict) -> int: - r = drun(_net(m), IMG["solr"], - f'curl -sS "http://{source}:8983/solr/{cr["core"]}/select?q=*:*&rows=0&wt=json"', check=False) - try: - return int(json.loads(r.stdout or "{}").get("response", {}).get("numFound", 0)) - except Exception: - return 0 - - -def _rabbit_filter(path: str, bl: list[str], info: dict) -> None: - """rabbitmq defs.json 에서 블랙리스트(큐/익스체인지 글롭) 항목 + 참조 bindings 를 제거.""" - d = json.load(open(path)) - rmq = {q["name"] for q in d.get("queues", []) if _excluded(q.get("name", ""), bl)} - rmx = {x["name"] for x in d.get("exchanges", []) if _excluded(x.get("name", ""), bl)} - d["queues"] = [q for q in d.get("queues", []) if q.get("name", "") not in rmq] - d["exchanges"] = [x for x in d.get("exchanges", []) if x.get("name", "") not in rmx] - keep = [] - for b in d.get("bindings", []): - if b.get("source", "") in rmx: - continue - dt, dn = b.get("destination_type", ""), b.get("destination", "") - if dt == "queue" and dn in rmq: - continue - if dt == "exchange" and dn in rmx: - continue - keep.append(b) - d["bindings"] = keep - json.dump(d, open(path, "w"), ensure_ascii=False, indent=2) - info["excluded"] = sorted(rmq | rmx) - - -def _solr_capture(net: str, source: str, cr: dict, bl: list[str], outdir: str) -> dict: - """cursorMark 딥페이징으로 전체 문서 export(블랙리스트=서버측 fq 제외), 내부필드 제거 → docs.json.""" - core = cr["core"] - blfq = "" - if bl: - expr = " OR ".join(f"( {ln} )" for ln in bl) - blfq = f"--data-urlencode 'fq=-({expr})'" - internal = {"_version_", "_root_", "_nest_path_", "_nest_parent_"} - docs, mark, n = [], "*", 0 - while True: - cmd = (f'curl -sS "http://{source}:8983/solr/{core}/select" -G --data-urlencode "q=*:*" {blfq} ' - f'--data-urlencode "fl=*" --data-urlencode "sort=id asc" --data-urlencode "rows=1000" ' - f'--data-urlencode "cursorMark={mark}" --data-urlencode "wt=json" -o /out/_page.json') - drun(net, IMG["solr"], cmd, [(outdir, "/out")]) - j = json.load(open(os.path.join(outdir, "_page.json"))) - for doc in j.get("response", {}).get("docs", []): - docs.append({k: v for k, v in doc.items() if k not in internal}) - nxt = j.get("nextCursorMark", mark) - if nxt == mark: - break - mark = nxt - n += 1 - p = os.path.join(outdir, "_page.json") - if os.path.exists(p): - os.remove(p) - json.dump(docs, open(os.path.join(outdir, "docs.json"), "w"), ensure_ascii=False) - drun(net, IMG["solr"], f'curl -sS "http://{source}:8983/solr/{core}/schema?wt=json" -o /out/schema.json', - [(outdir, "/out")]) - return {"file": "docs.json", "captured": len(docs)} - - -# ── oracle: Data Pump(expdp/impdp) — 파일이 DB 서버측이라 소스 컨테이너 exec + docker cp + REMAP_SCHEMA ── -def _ora_sql(source: str, cr: dict, sql: str) -> str: - script = "SET HEADING OFF PAGESIZE 0 FEEDBACK OFF VERIFY OFF ECHO OFF TRIMSPOOL ON\n" + sql + "\nEXIT\n" - r = subprocess.run(["docker", "exec", "-i", f"yakdev-{source}", "bash", "-lc", - f"sqlplus -s system/{cr['sys_pw']}@//localhost:1521/FREEPDB1"], - input=script, text=True, capture_output=True) - return r.stdout or "" - - -def _ora_lines(out: str) -> list[str]: - bad = ("ORA-", "SP2-", "SQL>", "altered", "PL/SQL", "Connected") - return [ln.strip() for ln in out.splitlines() if ln.strip() and not any(b in ln for b in bad)] - - -def _ora_dppath(source: str, cr: dict) -> str: - ls = _ora_lines(_ora_sql(source, cr, - "SELECT directory_path FROM dba_directories WHERE directory_name='DATA_PUMP_DIR';")) - return ls[-1] if ls else "/opt/oracle/admin/FREE/dpdump" - - -def _ora_datapump(source: str, cr: dict, parfile: str, tool: str) -> None: - cont = f"yakdev-{source}" - subprocess.run(["docker", "exec", "-i", cont, "bash", "-lc", "cat > /tmp/yak.par"], - input=parfile, text=True, capture_output=True) - r = subprocess.run(["docker", "exec", cont, "bash", "-lc", - f"{tool} system/{cr['sys_pw']}@//localhost:1521/FREEPDB1 parfile=/tmp/yak.par"], - text=True, capture_output=True) - out = (r.stdout or "") + (r.stderr or "") # expdp/impdp 는 진행상황을 stderr 로 출력 - if "successfully completed" not in out: - die(f"{tool} 실패:\n{out[-600:]}") - - -# ── 미리보기 ──────────────────────────────────────────────────────────── -def preview(m: dict, targets: list[tuple[str, str]]) -> dict: - seed = dev.ensure_seed() - plan = {} - print("캡처 미리보기 (읽기전용) — ✓=포함, ✗=제외(블랙리스트)") - for source, stype in targets: - cr = dev.creds_for(seed, source, stype) - bl = _blacklist(source) - if stype == "solr": # solr 블랙리스트=서버측 fq(문서 필터), fnmatch 미적용 → 문서 수만 표시 - nf = _solr_numfound(m, source, cr) - plan[source] = {"type": stype, "numFound": nf, "blacklist": bl} - print(f"\n ● {source} (solr) — 문서 {nf}개" - + (f" · 블랙리스트 {len(bl)}줄(서버측 fq 로 제외)" if bl else "")) - if not bl: - print(f" · 블랙리스트 없음 → 전 문서 포함. 제외는 " - f"{os.path.join(_srcdir(source), IGNORE)} 에 Solr 쿼리절(한 줄에 하나)로") - continue - units = list_units(m, source, stype, cr) - blm = [p.upper() for p in bl] if stype == "oracle" else bl # oracle 식별자=대문자 - inc = [u for u in units if not _excluded(u, blm)] - exc = [u for u in units if _excluded(u, blm)] - plan[source] = {"type": stype, "include": inc, "exclude": exc, "blacklist": bl} - unit_word = {"minio": "오브젝트", "redis": "키", "rabbitmq": "큐/익스체인지", - "oracle": "테이블"}.get(stype, "테이블/컬렉션") - print(f"\n ● {source} ({stype}) — {unit_word} {len(units)}개 · 포함 {len(inc)} / 제외 {len(exc)}") - for u in inc[:20]: - print(f" ✓ {u}") - if len(inc) > 20: - print(f" … 외 {len(inc)-20}개") - for u in exc: - print(f" ✗ {u} (블랙리스트)") - if not bl: - print(f" · 블랙리스트 없음 → 전부 포함. 민감/개인정보·쓰레기는 " - f"{os.path.join(_srcdir(source), IGNORE)} 에 글롭으로 제외 권장") - return plan - - -# ── 캡처(전체 native 덤프 − 블랙리스트) ─────────────────────────────────── -def capture_source(m: dict, source: str, stype: str, cr: dict) -> dict: - net, outdir = _net(m), os.path.join(_srcdir(source), "snapshot") - os.makedirs(outdir, exist_ok=True) - bl = _blacklist(source) - if stype == "solr": # solr 는 fq(서버측) 제외라 fnmatch 단위 계산 안 함 - units, excluded = [], [] - elif stype == "oracle": # oracle 식별자=대문자 → 블랙리스트 글롭도 대문자 정규화 매칭 - units = list_units(m, source, stype, cr) - excluded = [u for u in units if _excluded(u, [p.upper() for p in bl])] - else: - units = list_units(m, source, stype, cr) - excluded = [u for u in units if _excluded(u, bl)] - info = {"type": stype, "excluded": excluded, "blacklist": bl} - if stype == "postgresql": - url = f"postgresql://{cr['user']}:{cr['password']}@{source}:5432/{cr['db']}" - # pg_dump 는 패턴(*)을 지원 → 블랙리스트 글롭을 그대로 넘겨 소유 시퀀스(_id_seq 등)까지 제외. - ex = " ".join(f"--exclude-table='{g}'" for g in bl) - drun(net, IMG[stype], f"pg_dump '{url}' {ex} -f /out/snapshot.sql", [(outdir, "/out")]) - info["file"] = "snapshot.sql" - elif stype in ("mysql", "mariadb"): - ig = " ".join(f"--ignore-table={cr['db']}.{e}" for e in excluded) - drun(net, IMG[stype], - f"mysqldump -h {source} -u{cr['user']} -p{cr['password']} --skip-comments {ig} " - f"{cr['db']} > /out/snapshot.sql", [(outdir, "/out")]) - info["file"] = "snapshot.sql" - elif stype == "mongodb": - ex = " ".join(f"--excludeCollection={e}" for e in excluded) - drun(net, IMG[stype], - f"mongodump --host {source} -u {cr['user']} -p {cr['password']} " - f"--authenticationDatabase {cr['db']} --db {cr['db']} {ex} --archive=/out/dump.archive", - [(outdir, "/out")]) - info["file"] = "dump.archive" - elif stype == "minio": - setup = f"mc alias set s http://{source}:9000 {cr['access']} {cr['secret']} >/dev/null" - exflag = " ".join(f"--exclude '{e}'" for e in bl) - drun(net, IMG[stype], - f"{setup} && mc mirror --overwrite {exflag} s/{cr['bucket']} /out/bucket", [(outdir, "/out")]) - info["file"], info["bucket"] = "bucket/", cr["bucket"] - elif stype == "redis": - drun(net, IMG[stype], _fill(_REDIS_CAPTURE, host=source, pw=cr["password"], db=cr["db"], bl=" ".join(bl)), - [(outdir, "/out")]) - info["file"] = "keys.b64" - elif stype == "rabbitmq": - drun(net, IMG[stype], - f"rabbitmqadmin -H {source} -P 15672 -u {cr['user']} -p {cr['password']} -V {cr['vhost']} " - f"export /out/defs.json", [(outdir, "/out")]) - _rabbit_filter(os.path.join(outdir, "defs.json"), bl, info) # 블랙리스트 큐/익스체인지 + bindings 제거 - info["file"] = "defs.json" - info["note"] = "메시지 본문 제외(전이성). 정의(큐/익스체인지/바인딩/정책)만 이관." - elif stype == "solr": - info.update(_solr_capture(net, source, cr, bl, outdir)) - elif stype == "oracle": - cont, schema, dp = f"yakdev-{source}", cr["user"].upper(), _ora_dppath(source, cr) - subprocess.run(["docker", "exec", cont, "bash", "-lc", f"rm -f {dp}/yak.dmp"], capture_output=True) - par = f"schemas={schema}\ndirectory=DATA_PUMP_DIR\ndumpfile=yak.dmp\nreuse_dumpfiles=y\nnologfile=y\n" - if excluded: - lst = ", ".join(f"'{e.upper()}'" for e in excluded) - par += f'EXCLUDE=TABLE:"IN ({lst})"\n' - _ora_datapump(source, cr, par, "expdp") - cp = subprocess.run(["docker", "cp", f"{cont}:{dp}/yak.dmp", os.path.join(outdir, "data.dmp")], - capture_output=True, text=True) - if cp.returncode != 0: - die(f"덤프 추출 실패: {cp.stderr[-300:]}") - info["file"], info["schema"] = "data.dmp", schema - else: - die(f"미지원 타입(캡처): {stype}") - json.dump(info, open(os.path.join(outdir, "manifest.json"), "w"), ensure_ascii=False, indent=2) - return info - - -# ── 타깃 비어있음 검사 + 적용(부트스트랩) ───────────────────────────────── -def is_empty(m: dict, source: str, stype: str, cr: dict) -> bool: - if stype == "solr": - return _solr_numfound(m, source, cr) == 0 - return len(list_units(m, source, stype, cr)) == 0 - - -def apply_source(m: dict, source: str, stype: str, cr: dict) -> str: - net, outdir = _net(m), os.path.join(_srcdir(source), "snapshot") - if not os.path.isdir(outdir): - return "스냅샷 없음(먼저 capture)" - if not is_empty(m, source, stype, cr): - return "타깃에 데이터 있음 → 스킵(덮지 않음)" - if stype == "postgresql": - url = f"postgresql://{cr['user']}:{cr['password']}@{source}:5432/{cr['db']}" - drun(net, IMG[stype], f"psql '{url}' -v ON_ERROR_STOP=1 -f /out/snapshot.sql", [(outdir, "/out")]) - elif stype in ("mysql", "mariadb"): - drun(net, IMG[stype], - f"mysql -h {source} -u{cr['user']} -p{cr['password']} {cr['db']} < /out/snapshot.sql", - [(outdir, "/out")]) - elif stype == "mongodb": - drun(net, IMG[stype], - f"mongorestore --host {source} -u {cr['user']} -p {cr['password']} " - f"--authenticationDatabase {cr['db']} --archive=/out/dump.archive", [(outdir, "/out")]) - elif stype == "minio": - setup = f"mc alias set s http://{source}:9000 {cr['access']} {cr['secret']} >/dev/null" - drun(net, IMG[stype], - f"{setup} && (mc mb -p s/{cr['bucket']} 2>/dev/null || true) && " - f"mc mirror --overwrite /out/bucket s/{cr['bucket']}", [(outdir, "/out")]) - elif stype == "redis": - drun(net, IMG[stype], _fill(_REDIS_RESTORE, host=source, pw=cr["password"], db=cr["db"]), [(outdir, "/out")]) - elif stype == "rabbitmq": - drun(net, IMG[stype], - f"rabbitmqadmin -H {source} -P 15672 -u {cr['user']} -p {cr['password']} -V {cr['vhost']} " - f"import /out/defs.json", [(outdir, "/out")]) - elif stype == "solr": - drun(net, IMG[stype], - f'curl -sS "http://{source}:8983/solr/{cr["core"]}/update?commit=true" ' - f'-H "Content-Type: application/json" --data-binary @/out/docs.json', [(outdir, "/out")]) - elif stype == "oracle": - cont, dst, dp = f"yakdev-{source}", cr["user"].upper(), _ora_dppath(source, cr) - src = json.load(open(os.path.join(outdir, "manifest.json"))).get("schema", dst) - cp = subprocess.run(["docker", "cp", os.path.join(outdir, "data.dmp"), f"{cont}:{dp}/yak.dmp"], - capture_output=True, text=True) - if cp.returncode != 0: - die(f"덤프 주입 실패: {cp.stderr[-300:]}") - # docker cp 는 호스트 uid 로 파일 생성 → oracle 프로세스가 읽도록 root(-u 0)로 권한 부여. - subprocess.run(["docker", "exec", "-u", "0", cont, "bash", "-lc", f"chmod 644 {dp}/yak.dmp"], - capture_output=True) - _ora_sql(source, cr, f"ALTER USER {dst} QUOTA UNLIMITED ON USERS;") # 쿼터 보장(멱등) - # exclude=user: 대상 스키마가 이미 존재(gvenzl 생성) → CREATE USER 스킵(ORA-31684 benign 방지, 깨끗한 성공) - par = (f"directory=DATA_PUMP_DIR\ndumpfile=yak.dmp\nnologfile=y\nexclude=user\n" - f"remap_schema={src}:{dst}\ntable_exists_action=skip\n") - _ora_datapump(source, cr, par, "impdp") - else: - die(f"미지원 타입(적용): {stype}") - return "적용 완료(부트스트랩)" - - -def select_targets(m: dict, names: list[str], require_running: bool) -> list[tuple[str, str]]: - reqs = {r["name"]: r["type"] for r in dev.requires(m)} - if names: - miss = [n for n in names if n not in reqs] - if miss: - die(f"requires 에 없음: {', '.join(miss)}") - chosen = [(n, reqs[n]) for n in names] - else: - chosen = list(reqs.items()) - out = [] - for s, t in chosen: - if t not in SUPPORTED: - print(f" · {s}({t}) — 마이그레이션 미지원(다음 단계: {', '.join(UNSUPPORTED_NEXT)}). 건너뜀") - continue - if require_running and not _running(m, s): - print(f" · {s} — dev 컨테이너 미기동('yakcloud dev up {s}' 먼저). 건너뜀") - continue - out.append((s, t)) - if not out: - die("대상 소스가 없습니다.") - return out - - -# ── 명령 ──────────────────────────────────────────────────────────────── -def cmd_capture(a) -> None: - m = dev.load_manifest() - targets = select_targets(m, a.source, require_running=True) - preview(m, targets) - if a.dry_run: - print("\n(미리보기 전용 — 캡처 안 함)") - return - if not a.yes and sys.stdin.isatty(): - if input("\n위 대상을 db//snapshot/ 에 캡처할까요? [y/N] ").strip().lower() not in ("y", "yes"): - print("취소.") - return - seed = dev.ensure_seed() - for source, stype in targets: - cr = dev.creds_for(seed, source, stype) - info = capture_source(m, source, stype, cr) - print(f" ✓ {source} 캡처 → {os.path.join(_srcdir(source), 'snapshot', info['file'])}" - f" (제외 {len(info['excluded'])})") - print(" · db/ 를 커밋하세요(리뷰 대상). 적용: 'yakcloud datasource migrate apply'(빈 타깃만)") - - -def cmd_apply(a) -> None: - m = dev.load_manifest() - targets = select_targets(m, a.source, require_running=True) - seed = dev.ensure_seed() - for source, stype in targets: - cr = dev.creds_for(seed, source, stype) - print(f" ▸ {source} ({stype}): {apply_source(m, source, stype, cr)}") - print(" · prod 적용은 콘솔 소스관리자 컨펌 + 백업 후(롤백 보장) — 별도 경로") - - -def cmd_status(a) -> None: - m = dev.load_manifest() - reqs = {r["name"]: r["type"] for r in dev.requires(m)} - names = a.source or list(reqs) - print("migrate 스냅샷 상태") - for s in names: - t = reqs.get(s, "?") - snap = os.path.join(_srcdir(s), "snapshot", "manifest.json") - bl = _blacklist(s) - if os.path.exists(snap): - info = json.load(open(snap)) - print(f" {s} ({t}): 스냅샷 있음(제외 {len(info.get('excluded', []))}) · 블랙리스트 {len(bl)}줄") - else: - print(f" {s} ({t}): 스냅샷 없음 · 블랙리스트 {len(bl)}줄") - - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(prog="yakcloud datasource migrate") - sub = p.add_subparsers(dest="cmd", required=True) - c = sub.add_parser("capture", help="소스 전체 상태 − 블랙리스트 를 db//snapshot/ 에 캡처(읽기전용)") - c.add_argument("source", nargs="*") - c.add_argument("--dry-run", action="store_true", help="미리보기만") - c.add_argument("-y", "--yes", action="store_true", help="확인 없이") - c.set_defaults(fn=cmd_capture) - ap = sub.add_parser("apply", help="스냅샷을 타깃에 적용(빈 타깃만, 데이터 있으면 스킵)") - ap.add_argument("source", nargs="*") - ap.set_defaults(fn=cmd_apply) - st = sub.add_parser("status", help="스냅샷·블랙리스트 상태") - st.add_argument("source", nargs="*") - st.set_defaults(fn=cmd_status) - return p - - -if __name__ == "__main__": - args = build_parser().parse_args() - args.fn(args) diff --git a/yakcloud.yaml b/yakcloud.yaml index d625c69..e3c9a8d 100644 --- a/yakcloud.yaml +++ b/yakcloud.yaml @@ -6,8 +6,11 @@ apiVersion: yakcloud/v1 project: my-app # 프로젝트 이름(이미지 경로/표시에 사용) — 소문자·숫자·하이픈 -# 배포 대상 클러스터 — 이름 또는 콘솔 id. (우선순위: YAKCLOUD_CLUSTER 시크릿 > 이 필드) -cluster: my-service-19 +# 환경 — 개발/운영 클러스터. 'yakcloud project deploy'=dev 로 배포, 'yakcloud project promote'=그 이미지를 +# 재빌드 없이 prod 로 승격(운영 도메인 부착; 기본 도메인 c-xxxx.yakenator.io 은 두 환경 모두 자동). +environments: + dev: { cluster: my-dev-cluster } # 태그 push → CI 빌드 → 개발 클러스터 + prod: { cluster: my-prod-cluster, domains: [] } # promote → 운영 클러스터 + domains(예: [app.example.com]) # 필요한 데이터 소스(논리 이름). 없으면 [] 로 둬도 됨. # type ∈ postgresql·mysql·mariadb·mongodb·redis·minio·rabbitmq·solr·oracle, plan ∈ small·medium·large