cli v0.11.0: 로컬 dev/migrate 제거, 개발/운영 클러스터 승격 모델. environments.{dev,prod} 매니페스트; project deploy=개발 클러스터, project promote=재빌드 없이 운영 클러스터+운영 도메인. CI=dev 환경. dev/prod dry-run 검증
This commit is contained in:
@ -19,19 +19,19 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep, AskUserQuestion
|
|||||||
- **`project init [name]`** → §2 스캐폴딩(마법사 대체).
|
- **`project init [name]`** → §2 스캐폴딩(마법사 대체).
|
||||||
- **`login`** → 비밀 입력이라 여기서 실행하지 말고, 터미널에서 `yakcloud login` 하라고 한 줄 안내.
|
- **`login`** → 비밀 입력이라 여기서 실행하지 말고, 터미널에서 `yakcloud login` 하라고 한 줄 안내.
|
||||||
(사용자가 값을 직접 주면 `YAKCLOUD_URL=… YAKCLOUD_TOKEN=… GITEA_USER=… GITEA_TOKEN=… yakcloud config` 로 저장하되 **값은 화면에 출력 금지**.)
|
(사용자가 값을 직접 주면 `YAKCLOUD_URL=… YAKCLOUD_TOKEN=… GITEA_USER=… GITEA_TOKEN=… yakcloud config` 로 저장하되 **값은 화면에 출력 금지**.)
|
||||||
- **`dev …`**(`dev up|run|down|status|logs|env|reset|doctor`) → 로컬 docker 데이터소스 + `.env.dev`.
|
- **`project promote [vX.Y.Z]`** → 개발에서 검증한 그 이미지를 **재빌드 없이 운영 클러스터로** 승격 + 운영 도메인.
|
||||||
`docker` 데몬이 떠 있어야 함(없으면 `yakcloud dev doctor` 로 진단). `dev run -- <명령>` 은 앱을 배포와 동일한 `<ALIAS>_*` 로 실행.
|
파괴적/외부노출이므로 실행 전 무엇을(어느 태그를 어느 운영 클러스터로) 하는지 한 줄 알리고 진행.
|
||||||
- **`datasource migrate …`**(`capture|apply|status`) → 데이터소스 스냅샷(블랙리스트 `db/<source>/.migrateignore`).
|
|
||||||
`capture` = 소스 전체−블랙리스트 덤프(**읽기전용**, 먼저 미리보기로 무엇이 잡히는지 보여줄 것). `apply` = **빈 타깃만** 복원(데이터 있으면 스킵). prod 적용은 콘솔 소스관리자 컨펌 + 백업 후(롤백) — 이 명령으로 prod를 직접 덮지 말 것.
|
|
||||||
- **그 외 전부**(`project deploy|info|check|update`, `datasource ls`, `domain`, `scale`, `set`, `env`,
|
- **그 외 전부**(`project deploy|info|check|update`, `datasource ls`, `domain`, `scale`, `set`, `env`,
|
||||||
`source`, `bind`, `unbind`, `config`, `upgrade`) → `yakcloud $ARGUMENTS` 를 실행하고 **출력을 그대로** 보여준 뒤,
|
`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` — 마법사 대체 (핵심)
|
## 2) `project init` — 마법사 대체 (핵심)
|
||||||
터미널 마법사는 TTY가 없으면 안 뜬다. 네가 대신한다:
|
터미널 마법사는 TTY가 없으면 안 뜬다. 네가 대신한다:
|
||||||
1. `yakcloud project init <name> -y` 로 **비대화형 스캐폴딩**(기본 매니페스트 생성; `bin/`·`install.sh` 잔재 없음).
|
1. `yakcloud project init <name> -y` 로 **비대화형 스캐폴딩**(기본 매니페스트 생성; `bin/`·`install.sh` 잔재 없음).
|
||||||
2. `$ARGUMENTS`/대화 맥락에 이미 있는 값은 **재질문 금지**. 부족한 핵심값만 `AskUserQuestion` 으로 모은다:
|
2. `$ARGUMENTS`/대화 맥락에 이미 있는 값은 **재질문 금지**. 부족한 핵심값만 `AskUserQuestion` 으로 모은다:
|
||||||
대상 클러스터, 앱 포트, 노출 경로(기본 `/`), CPU/메모리, 필요한 데이터소스(있으면), 도메인(있으면).
|
**개발 클러스터, 운영 클러스터**(같으면 하나로), 앱 포트, 노출 경로(기본 `/`), CPU/메모리, 필요한 데이터소스(있으면), 운영 도메인(있으면).
|
||||||
|
→ 매니페스트 `environments.dev.cluster` / `environments.prod.cluster` / `environments.prod.domains` 에 반영.
|
||||||
3. 모은 값을 CLI로 반영:
|
3. 모은 값을 CLI로 반영:
|
||||||
- `yakcloud set <wl> --port … --cpu … --mem … --path …`
|
- `yakcloud set <wl> --port … --cpu … --mem … --path …`
|
||||||
- 소스: `yakcloud source add <name> <type> [plan]` + `yakcloud bind <wl> <name> <alias>`
|
- 소스: `yakcloud source add <name> <type> [plan]` + `yakcloud bind <wl> <name> <alias>`
|
||||||
|
|||||||
@ -22,17 +22,12 @@ YakCloud 콘솔 API로 **데이터소스 리컨실 + 워크로드 배포 + 바
|
|||||||
설치: `curl -fsSL https://gitea.yakenator.io/yakenator/yakcloud-starter/raw/branch/main/install.sh | sh`
|
설치: `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와 슬래시 명령을 함께 최신화.
|
> 설치 시 **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 project init [name]` (스타터+CI+매니페스트+이 스킬)
|
||||||
- **"yakcloud 에 배포"** → `yakcloud project deploy` (버전 미지정 시 **자동 버전업**: 최신 태그 patch+1 → 태그 push)
|
- **"개발 배포"** → `yakcloud project deploy` (자동 버전업 → 태그 push → CI 빌드 → **개발 클러스터**). 기본 도메인으로 확인.
|
||||||
- **"프로젝트 개괄"** → `yakcloud project info` (매니페스트 + 라이브 상태)
|
- **"운영 승격"** → `yakcloud project promote [vX.Y.Z]` (개발에서 검증한 **그 이미지를 재빌드 없이 운영 클러스터로** + 운영 도메인)
|
||||||
- 사전 점검 → `yakcloud project check` (dry-run)
|
- **"프로젝트 개괄"** → `yakcloud project info` · 사전 점검 → `yakcloud project check [--prod]`
|
||||||
|
- 매니페스트 `environments.{dev,prod}.cluster` (+ `prod.domains`). 로컬 개발/로컬 데이터소스는 없음 — 개발도 클러스터 배포로 확인.
|
||||||
**로컬 개발(docker 데이터소스 + 배포와 동일 env)**
|
|
||||||
- `yakcloud dev up` — `requires[]` 를 로컬 docker 로 기동 + `.env.dev`(프로덕션과 **바이트 동일**한 `<ALIAS>_*`) 생성
|
|
||||||
- `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/<source>/.migrateignore`) 스냅샷: 전체−제외 캡처(읽기전용), **빈 타깃만** 적용(부트스트랩). **9종 전부**(SQL 3·mongo·minio·redis·solr·rabbitmq·oracle). prod는 콘솔 소스관리자 컨펌+백업 후
|
|
||||||
|
|
||||||
**배포환경 설정(앱별) — 매니페스트 수정 + 배포중이면 재빌드 없이 라이브 반영**
|
**배포환경 설정(앱별) — 매니페스트 수정 + 배포중이면 재빌드 없이 라이브 반영**
|
||||||
- `yakcloud domain <fqdn> [wl]` 도메인 등록 + 워크로드 할당
|
- `yakcloud domain <fqdn> [wl]` 도메인 등록 + 워크로드 할당
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Gitea Actions — 릴리스 태그(v*) push 시 이미지 빌드/푸시 후 YakCloud 에 리컨실 배포.
|
# Gitea Actions — 릴리스 태그(v*) push 시 이미지 빌드/푸시 후 YakCloud 에 리컨실 배포.
|
||||||
# 매니페스트 구동형: yakcloud.yaml 의 각 workload.build 를 빌드해 image 로 push → yakcloud_deploy.py.
|
# 매니페스트 구동형: yakcloud.yaml 의 각 workload.build 를 빌드해 image 로 push → yakcloud_deploy.py.
|
||||||
# 프로젝트마다 수정 불필요(레포명은 GITHUB_REPOSITORY 로 자동).
|
# 프로젝트마다 수정 불필요(레포명은 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 마운트).
|
# 러너: 전용 host act_runner, 라벨 ci-polyglot(잡 컨테이너에 host docker.sock 마운트).
|
||||||
name: deploy
|
name: deploy
|
||||||
on:
|
on:
|
||||||
@ -20,7 +20,7 @@ jobs:
|
|||||||
REG_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REG_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
YAKCLOUD_URL: ${{ secrets.YAKCLOUD_URL }}
|
YAKCLOUD_URL: ${{ secrets.YAKCLOUD_URL }}
|
||||||
YAKCLOUD_TOKEN: ${{ secrets.YAKCLOUD_TOKEN }}
|
YAKCLOUD_TOKEN: ${{ secrets.YAKCLOUD_TOKEN }}
|
||||||
YAKCLOUD_CLUSTER: ${{ secrets.YAKCLOUD_CLUSTER }}
|
YAKCLOUD_ENV: dev # 태그 push → 개발 클러스터(environments.dev). 운영은 'yakcloud project promote'
|
||||||
run: |
|
run: |
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
TAG="${GITHUB_REF_NAME}"
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,6 +3,3 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
.env.dev
|
|
||||||
.yakcloud/
|
|
||||||
docker-compose.yakcloud-dev.yml
|
|
||||||
|
|||||||
@ -36,31 +36,10 @@
|
|||||||
|
|
||||||
> 비밀번호·키는 인클러스터 Secret으로 주입되고 콘솔/브라우저에 노출되지 않는다. URL의 user/pw는 URL-인코딩됨.
|
> 비밀번호·키는 인클러스터 Secret으로 주입되고 콘솔/브라우저에 노출되지 않는다. URL의 user/pw는 URL-인코딩됨.
|
||||||
|
|
||||||
## 로컬 개발(`yakcloud dev`) — 배포와 동일한 env 계약
|
## 개발/운영 모두 클러스터에서
|
||||||
개발 중에는 `yakcloud dev up` 이 `requires[]` 를 **로컬 docker 컨테이너**로 띄우고, 위 표와 **똑같은 `<ALIAS>_*`**
|
데이터 소스는 **클러스터 안에서 실제로 프로비저닝**된다(로컬 docker 없음). 개발 클러스터에 배포하면 그 클러스터의 소스가
|
||||||
(같은 키·URL 포맷, 값만 로컬 컨테이너를 가리킴)를 `.env.dev` 로 생성한다. 즉 로컬에서 짠 코드가 배포 후에도 무수정 동작.
|
`<ALIAS>_*` 로 주입되고, 운영 클러스터로 승격하면 운영 클러스터의 소스가 주입된다 — 각 환경이 자체 데이터 계층을 가진다.
|
||||||
```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/<source>/snapshot/ 에 전체−블랙리스트 덤프
|
|
||||||
yakcloud datasource migrate apply [source…] # 빈 타깃만 복원(데이터 있으면 스킵, force 없음)
|
|
||||||
yakcloud datasource migrate status
|
|
||||||
```
|
|
||||||
- **블랙리스트**: `db/<source>/.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 직접 덮지 않음.
|
|
||||||
|
|
||||||
## 앱 코드 예시
|
## 앱 코드 예시
|
||||||
```python
|
```python
|
||||||
|
|||||||
95
bin/yakcloud
95
bin/yakcloud
@ -1,24 +1,20 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# yakcloud — YakCloud 프로젝트 CLI. 빈 폴더 초기화(스타터+스킬), 배포, 배포환경 상세 설정.
|
# yakcloud — YakCloud 프로젝트 CLI. 개발·운영 모두 yakcloud 클러스터에서(로컬 dev 없음).
|
||||||
#
|
#
|
||||||
# yakcloud project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
|
# yakcloud project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
|
||||||
# yakcloud project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포
|
# yakcloud project deploy [vX.Y.Z] 커밋 + 태그 push → CI 빌드 → 개발 클러스터 배포
|
||||||
# yakcloud project info 프로젝트 개괄(매니페스트 + 라이브 상태)
|
# yakcloud project promote [vX.Y.Z] 그 이미지를 재빌드 없이 운영 클러스터로 승격 + 운영 도메인
|
||||||
# yakcloud project check dry-run(직접 API 계획만)
|
# yakcloud project info 프로젝트 개괄(매니페스트 + dev/prod 라이브 상태)
|
||||||
|
# yakcloud project check [--prod] dry-run(직접 API 계획만)
|
||||||
#
|
#
|
||||||
# yakcloud domain <fqdn> [wl] 도메인 등록 + 워크로드에 할당(관리형=즉시, 배포중이면 라이브)
|
# yakcloud domain <fqdn> [wl] · scale · set · env · datasource ls · source · bind/unbind
|
||||||
# yakcloud scale <wl> <n> replicas 변경
|
|
||||||
# yakcloud set <wl> --image/--port/--health/--cpu/--mem/--path/--rewrite
|
|
||||||
# yakcloud env <wl> KEY=VAL … [--secret KEY] [--unset KEY]
|
|
||||||
# yakcloud source add <name> <type> [plan] | source rm <name>
|
|
||||||
# yakcloud bind <wl> <source> <alias> | unbind <wl> <alias>
|
|
||||||
#
|
#
|
||||||
# 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.
|
set -uo pipefail # -e 미사용: 'test && action' 관용구가 값 없을 때 CLI 를 중단시키는 함정 회피. 중요 경로는 명시적 || die.
|
||||||
|
|
||||||
REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}"
|
REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}"
|
||||||
BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}"
|
BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}"
|
||||||
VERSION="0.10.0"
|
VERSION="0.11.0"
|
||||||
CONFIG_DIR="${YAKCLOUD_CONFIG_DIR:-$HOME/.config/yakcloud}"
|
CONFIG_DIR="${YAKCLOUD_CONFIG_DIR:-$HOME/.config/yakcloud}"
|
||||||
CONFIG_FILE="$CONFIG_DIR/config"
|
CONFIG_FILE="$CONFIG_DIR/config"
|
||||||
|
|
||||||
@ -39,18 +35,6 @@ ctl() {
|
|||||||
python3 scripts/yakcloud_ctl.py "$@"
|
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) ──
|
# ── 자격 저장 ── 비밀(배포토큰·Gitea토큰)=OS 키체인(암호화), 그 외(URL·사용자)=config 파일(600) ──
|
||||||
_KC="none"
|
_KC="none"
|
||||||
if command -v security >/dev/null 2>&1; then _KC="macos"
|
if command -v security >/dev/null 2>&1; then _KC="macos"
|
||||||
@ -174,10 +158,12 @@ cmd_project_init() {
|
|||||||
|
|
||||||
# 매니페스트 채우기 마법사 — ctl 은 API 미접속(YAKCLOUD_TOKEN 비움)으로 매니페스트만 편집.
|
# 매니페스트 채우기 마법사 — ctl 은 API 미접속(YAKCLOUD_TOKEN 비움)으로 매니페스트만 편집.
|
||||||
_init_wizard() {
|
_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 "── 프로젝트 설정 (엔터=기본값) ──"
|
echo "── 프로젝트 설정 (엔터=기본값) ──"
|
||||||
printf "앱 이름 [%s]: " "$dflt_app"; read -r app; app="${app:-$dflt_app}"
|
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 "컨테이너 포트 [8080]: "; read -r port; port="${port:-8080}"
|
||||||
printf "헬스체크 경로 [/healthz]: "; read -r health; health="${health:-/healthz}"
|
printf "헬스체크 경로 [/healthz]: "; read -r health; health="${health:-/healthz}"
|
||||||
printf "CPU 요청 [25m]: "; read -r cpu; cpu="${cpu:-25m}"
|
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/^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
|
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 "${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
|
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 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
|
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
|
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)"
|
YAKCLOUD_TOKEN= python3 scripts/yakcloud_ctl.py env web "$kv" >/dev/null 2>&1 || echo " (형식: KEY=VALUE)"
|
||||||
done
|
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() {
|
_maybe_login_prompt() {
|
||||||
@ -283,7 +271,25 @@ cmd_project_check() {
|
|||||||
[ -f scripts/yakcloud_deploy.py ] || die "'yakcloud project init' 먼저"
|
[ -f scripts/yakcloud_deploy.py ] || die "'yakcloud project init' 먼저"
|
||||||
: "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요}"
|
: "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요}"
|
||||||
ensure_pyyaml
|
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 자기 갱신 — 버전이 같아도 항상 최신 파일을 강제로 다시 받아 덮어쓴다(캐시 우회).
|
# 전역 CLI 자기 갱신 — 버전이 같아도 항상 최신 파일을 강제로 다시 받아 덮어쓴다(캐시 우회).
|
||||||
@ -332,8 +338,7 @@ cmd_project_update() {
|
|||||||
mkdir -p scripts .gitea/workflows .claude/skills
|
mkdir -p scripts .gitea/workflows .claude/skills
|
||||||
cp "$tmp/s/scripts/yakcloud_deploy.py" scripts/ 2>/dev/null || true
|
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_ctl.py" scripts/ 2>/dev/null || true
|
||||||
cp "$tmp/s/scripts/yakcloud_dev.py" scripts/ 2>/dev/null || true
|
rm -f scripts/yakcloud_dev.py scripts/yakcloud_migrate.py # 옛 로컬 dev/migrate 잔재 제거
|
||||||
cp "$tmp/s/scripts/yakcloud_migrate.py" scripts/ 2>/dev/null || true
|
|
||||||
cp "$tmp/s/.gitea/workflows/deploy.yml" .gitea/workflows/ 2>/dev/null || true
|
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
|
cp "$tmp/s/DATA-SOURCES.md" DATA-SOURCES.md 2>/dev/null || true
|
||||||
rm -rf .claude/skills/yakcloud-deploy
|
rm -rf .claude/skills/yakcloud-deploy
|
||||||
@ -345,12 +350,13 @@ cmd_project_update() {
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
yakcloud $VERSION — YakCloud 프로젝트 CLI
|
yakcloud $VERSION — YakCloud 프로젝트 CLI (개발·운영 모두 yakcloud 클러스터)
|
||||||
project init [name] 빈 폴더에 스타터 스캐폴딩(대화형 마법사) + 자격 프롬프트
|
project init [name] 빈 폴더에 스타터 스캐폴딩(대화형 마법사) + 자격 프롬프트
|
||||||
project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions(버전 생략=자동 버전업). 도메인도 리컨실.
|
project deploy [vX.Y.Z] 커밋 + 태그 push → CI 빌드 → **개발 클러스터** 배포(버전 생략=자동 버전업)
|
||||||
project deploy --local 러너 없이 로컬 build/push/리컨실(CI 대안)
|
project deploy --local 러너 없이 로컬 build/push/리컨실(CI 대안, 개발 클러스터)
|
||||||
project info 프로젝트 개괄(매니페스트 + 라이브 상태)
|
project promote [vX.Y.Z] 개발에서 검증한 그 이미지를 재빌드 없이 **운영 클러스터**로 승격 + 운영 도메인
|
||||||
project check dry-run(직접 API 계획만)
|
project info 프로젝트 개괄(매니페스트 + dev/prod 라이브 상태)
|
||||||
|
project check [--prod] dry-run(직접 API 계획만)
|
||||||
project update 이 프로젝트의 엔진·CI·스킬만 최신화(앱·매니페스트 보존)
|
project update 이 프로젝트의 엔진·CI·스킬만 최신화(앱·매니페스트 보존)
|
||||||
upgrade 전역 yakcloud CLI 자기 갱신(최신 버전으로)
|
upgrade 전역 yakcloud CLI 자기 갱신(최신 버전으로)
|
||||||
── 자격(한 곳에 안전 보관; 비밀=OS 키체인 암호화) ──
|
── 자격(한 곳에 안전 보관; 비밀=OS 키체인 암호화) ──
|
||||||
@ -365,13 +371,7 @@ yakcloud $VERSION — YakCloud 프로젝트 CLI
|
|||||||
datasource ls 클러스터에서 이용 가능한 소스 목록(이름·타입·내부/외부·상태)
|
datasource ls 클러스터에서 이용 가능한 소스 목록(이름·타입·내부/외부·상태)
|
||||||
source add <name> <type> [plan] | source rm <name>
|
source add <name> <type> [plan] | source rm <name>
|
||||||
bind <wl> <source> <alias> | unbind <wl> <alias>
|
bind <wl> <source> <alias> | unbind <wl> <alias>
|
||||||
── 로컬 개발(docker 데이터소스 + prod 동일 env) ──
|
매니페스트: environments.{dev,prod}.cluster(+prod.domains). env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…)
|
||||||
dev up [source…] requires[] 를 로컬 docker 로 기동 + .env.dev 생성
|
|
||||||
dev run -- <명령> up 보장 후 .env.dev 로 앱 실행(배포와 동일 <ALIAS>_* 주입)
|
|
||||||
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
|
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -383,19 +383,16 @@ case "${1:-help}" in
|
|||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
init) shift; cmd_project_init "$@" ;;
|
init) shift; cmd_project_init "$@" ;;
|
||||||
deploy) shift; cmd_project_deploy "$@" ;;
|
deploy) shift; cmd_project_deploy "$@" ;;
|
||||||
|
promote) shift; cmd_project_promote "$@" ;;
|
||||||
info) shift; ctl info "$@" ;;
|
info) shift; ctl info "$@" ;;
|
||||||
check|dry-run) shift; cmd_project_check "$@" ;;
|
check|dry-run) shift; cmd_project_check "$@" ;;
|
||||||
update) shift; cmd_project_update "$@" ;;
|
update) shift; cmd_project_update "$@" ;;
|
||||||
*) echo "yakcloud project <init|deploy|info|check|update>"; exit 1 ;;
|
*) echo "yakcloud project <init|deploy|promote|info|check|update>"; exit 1 ;;
|
||||||
esac ;;
|
esac ;;
|
||||||
login) cmd_login ;;
|
login) cmd_login ;;
|
||||||
config) cmd_config ;;
|
config) cmd_config ;;
|
||||||
logout) cmd_logout ;;
|
logout) cmd_logout ;;
|
||||||
dev) shift; dev "$@" ;;
|
datasource|ds) shift; ctl source "$@" ;;
|
||||||
migrate) shift; migrate "$@" ;;
|
|
||||||
datasource|ds)
|
|
||||||
shift
|
|
||||||
if [ "${1:-}" = migrate ]; then shift; migrate "$@"; else ctl source "$@"; fi ;;
|
|
||||||
domain|scale|set|env|source|bind|unbind) ctl "$@" ;;
|
domain|scale|set|env|source|bind|unbind) ctl "$@" ;;
|
||||||
upgrade|self-update) cmd_upgrade ;;
|
upgrade|self-update) cmd_upgrade ;;
|
||||||
version|-v|--version) echo "yakcloud $VERSION" ;;
|
version|-v|--version) echo "yakcloud $VERSION" ;;
|
||||||
|
|||||||
@ -60,9 +60,13 @@ def save_manifest(m: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cluster_ref(m: dict) -> str:
|
def cluster_ref(m: dict) -> str:
|
||||||
ref = os.environ.get("YAKCLOUD_CLUSTER") or m.get("cluster")
|
# 조회/설정은 기본 개발 환경 클러스터 기준. environments.<env>.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:
|
if not ref:
|
||||||
sys.exit(" ✗ 대상 클러스터 미지정 — YAKCLOUD_CLUSTER 또는 yakcloud.yaml 의 cluster:")
|
sys.exit(" ✗ 대상 클러스터 미지정 — yakcloud.yaml 의 environments.%s.cluster (또는 YAKCLOUD_CLUSTER)" % env)
|
||||||
return ref
|
return ref
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,9 @@ URL = os.environ["YAKCLOUD_URL"].rstrip("/")
|
|||||||
TOKEN = os.environ["YAKCLOUD_TOKEN"]
|
TOKEN = os.environ["YAKCLOUD_TOKEN"]
|
||||||
CLUSTER_REF = os.environ.get("YAKCLOUD_CLUSTER")
|
CLUSTER_REF = os.environ.get("YAKCLOUD_CLUSTER")
|
||||||
CLUSTER = "" # main 에서 이름→id 로 해석해 채운다
|
CLUSTER = "" # main 에서 이름→id 로 해석해 채운다
|
||||||
CLUSTER_HOST = "" # 클러스터 기본 도메인 — expose.host(s) 미지정 시 여기로 노출
|
CLUSTER_HOST = "" # 클러스터 기본 도메인 — 항상 노출(운영은 여기 + 운영 도메인)
|
||||||
|
ENV = os.environ.get("YAKCLOUD_ENV", "dev") # 대상 환경 environments.<env> (기본 dev). promote=prod
|
||||||
|
ENV_DOMAINS: list = [] # main 에서 environments.<env>.domains 로 채움(운영 도메인)
|
||||||
TAG = os.environ.get("TAG", "latest")
|
TAG = os.environ.get("TAG", "latest")
|
||||||
DRY = "--dry-run" in sys.argv[1:]
|
DRY = "--dry-run" in sys.argv[1:]
|
||||||
|
|
||||||
@ -163,24 +165,25 @@ def deploy_workload(w: dict) -> tuple[str | None, bool]:
|
|||||||
"healthPath": w.get("health"),
|
"healthPath": w.get("health"),
|
||||||
"path": ex.get("path", "/"), "pathType": "Prefix", "rewritePrefix": bool(ex.get("rewrite", False)),
|
"path": ex.get("path", "/"), "pathType": "Prefix", "rewritePrefix": bool(ex.get("rewrite", False)),
|
||||||
}
|
}
|
||||||
# 노출 도메인: 여러 개(hosts) > 단일(host) > 클러스터 기본 도메인.
|
# 노출 도메인 = 워크로드 지정 host(s) + 환경 도메인(운영). 커스텀이 있으면 기본 도메인도 함께 노출.
|
||||||
hosts = ex.get("hosts")
|
wl_hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])
|
||||||
if hosts:
|
custom = list(dict.fromkeys(wl_hosts + ENV_DOMAINS))
|
||||||
body["exposeHosts"] = hosts
|
if custom:
|
||||||
|
body["exposeHosts"] = list(dict.fromkeys(custom + ([CLUSTER_HOST] if CLUSTER_HOST else [])))
|
||||||
else:
|
else:
|
||||||
body["exposeHost"] = ex.get("host") or (CLUSTER_HOST or None)
|
body["exposeHost"] = CLUSTER_HOST or None
|
||||||
# 환경변수(선언적): 리스트[{key,value,secret?}] 또는 맵{KEY: VALUE}.
|
# 환경변수(선언적): 리스트[{key,value,secret?}] 또는 맵{KEY: VALUE}.
|
||||||
env = w.get("env") or []
|
env = w.get("env") or []
|
||||||
if isinstance(env, dict):
|
if isinstance(env, dict):
|
||||||
env = [{"key": k, "value": v} for k, v in env.items()]
|
env = [{"key": k, "value": v} for k, v in env.items()]
|
||||||
body["env"] = [{"key": e["key"], "value": str(e.get("value", "")),
|
body["env"] = [{"key": e["key"], "value": str(e.get("value", "")),
|
||||||
"secret": bool(e.get("secret", False))} for e in env]
|
"secret": bool(e.get("secret", False))} for e in env]
|
||||||
# 도메인 리컨실 — expose 에 명시한 host(들)를 레지스트리에 등록(없으면). 배포 하나로 '도달 가능한 앱'이 되게.
|
# 도메인 리컨실 — 워크로드 host(들) + 환경(운영) 도메인을 레지스트리에 등록(없으면).
|
||||||
for h in (ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])):
|
for h in custom:
|
||||||
reconcile_domain(h)
|
reconcile_domain(h)
|
||||||
if DRY:
|
if DRY:
|
||||||
log(f"deploy '{w['name']}' 예정: image={image} port={body['port']} path={body['path']} "
|
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
|
return None, False
|
||||||
# 멱등: 기존 배포면 PATCH(이미지 갱신 → kubectl apply = 무중단 롤링). 없으면 신규 생성.
|
# 멱등: 기존 배포면 PATCH(이미지 갱신 → kubectl apply = 무중단 롤링). 없으면 신규 생성.
|
||||||
existing = next((d for d in cluster_deployments() if d.get("name") == w["name"]), None)
|
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:
|
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")
|
path = next((a for a in sys.argv[1:] if not a.startswith("--")), "yakcloud.yaml")
|
||||||
m = yaml.safe_load(open(path))
|
m = yaml.safe_load(open(path))
|
||||||
ref = CLUSTER_REF or m.get("cluster")
|
# 환경 해석: environments.<ENV>.{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:
|
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, cname = resolve_cluster(ref)
|
||||||
CLUSTER_HOST = (unwrap(api("GET", f"/clusters/{CLUSTER}")) or {}).get("defaultHostname") or ""
|
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] = {}
|
src_ids: dict[str, str | None] = {}
|
||||||
for req in m.get("requires", []):
|
for req in m.get("requires", []):
|
||||||
src_ids[req["name"]] = reconcile_source(req)
|
src_ids[req["name"]] = reconcile_source(req)
|
||||||
|
|||||||
@ -1,797 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""yakcloud dev — 로컬 개발용 데이터소스(docker) + 프로덕션 동일 <ALIAS>_* env 주입 엔진.
|
|
||||||
|
|
||||||
매니페스트(yakcloud.yaml)의 requires[] 를 로컬 docker 컨테이너로 띄우고,
|
|
||||||
프로덕션 백엔드(_bind_env_for)와 **동일한 env 계약**을 .env.dev 로 생성한다.
|
|
||||||
개발한 코드가 배포 후에도 무수정 동작(같은 <ALIAS>_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> 계약 → 대상 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 별 <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)}",
|
|
||||||
"# 프로덕션 배포 시 백엔드가 주입하는 <ALIAS>_* 와 동일한 계약(로컬 컨테이너를 가리킴).",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
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 <name> <type>' 로 소스를 추가하세요.")
|
|
||||||
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 <workload> <source> <alias>' 후 다시 up 하면 <ALIAS>_* 가 채워집니다.")
|
|
||||||
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·<src>-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="<ALIAS>_* 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()
|
|
||||||
@ -1,531 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""yakcloud datasource migrate — 데이터소스 스냅샷(블랙리스트) 캡처 + 빈 타깃 적용(부트스트랩).
|
|
||||||
|
|
||||||
모델(확정):
|
|
||||||
capture = 소스 전체 native 덤프 − 블랙리스트(db/<source>/.migrateignore, 글롭) → db/<source>/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/<source>/ (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 는 패턴(*)을 지원 → 블랙리스트 글롭을 그대로 넘겨 소유 시퀀스(<tbl>_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/<source>/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/<source>/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)
|
|
||||||
@ -6,8 +6,11 @@
|
|||||||
apiVersion: yakcloud/v1
|
apiVersion: yakcloud/v1
|
||||||
project: my-app # 프로젝트 이름(이미지 경로/표시에 사용) — 소문자·숫자·하이픈
|
project: my-app # 프로젝트 이름(이미지 경로/표시에 사용) — 소문자·숫자·하이픈
|
||||||
|
|
||||||
# 배포 대상 클러스터 — 이름 또는 콘솔 id. (우선순위: YAKCLOUD_CLUSTER 시크릿 > 이 필드)
|
# 환경 — 개발/운영 클러스터. 'yakcloud project deploy'=dev 로 배포, 'yakcloud project promote'=그 이미지를
|
||||||
cluster: my-service-19
|
# 재빌드 없이 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
|
# type ∈ postgresql·mysql·mariadb·mongodb·redis·minio·rabbitmq·solr·oracle, plan ∈ small·medium·large
|
||||||
|
|||||||
Reference in New Issue
Block a user