스킬 슬림화: 스캐폴딩 시 중복되던 assets 제거 → SKILL.md만. 템플릿은 루트/스타터 레포가 단일 소스
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -8,12 +8,13 @@ description: Scaffold and wire up YakCloud declarative CI/CD for a project — G
|
|||||||
VS Code로 개발 → 커밋 → **`v*` 태그 push** 하면, Gitea Actions가 이미지를 빌드/푸시하고
|
VS Code로 개발 → 커밋 → **`v*` 태그 push** 하면, Gitea Actions가 이미지를 빌드/푸시하고
|
||||||
YakCloud 콘솔 API로 **데이터소스 리컨실 + 워크로드 배포 + 바인딩**을 자동 처리한다.
|
YakCloud 콘솔 API로 **데이터소스 리컨실 + 워크로드 배포 + 바인딩**을 자동 처리한다.
|
||||||
|
|
||||||
이 스킬은 **아무 프로젝트에나 이 파이프라인을 심는다.** 번들된 자산(`assets/`)을 프로젝트에 복사하고
|
이 스킬은 **아무 프로젝트에나 이 파이프라인을 심는다.** `yakcloud` CLI(권장) 또는 스타터 레포로
|
||||||
매니페스트를 채운 뒤, 1회 시크릿만 설정하면 끝. 빈 프로젝트면 프레임워크 중립 **스타터**로 바로 시작.
|
스캐폴딩하고, 매니페스트를 채운 뒤 1회 시크릿만 설정하면 끝. 빈 프로젝트면 프레임워크 중립 **스타터**로 바로 시작.
|
||||||
|
> 이 스킬은 **번들 파일을 두지 않는다**(SKILL.md 하나). 실제 템플릿은 CLI 또는 스타터 레포(`yakenator/yakcloud-starter`)에서 온다.
|
||||||
|
|
||||||
## 언제 쓰나
|
## 언제 쓰나
|
||||||
- "이 프로젝트를 YakCloud에 배포/자동화" / "Gitea CI/CD 붙여줘" / "yakcloud.yaml 만들어줘"
|
- "이 프로젝트를 YakCloud에 배포/자동화" / "Gitea CI/CD 붙여줘" / "yakcloud.yaml 만들어줘"
|
||||||
- "새 프로젝트 시작해서 배포까지" (→ `assets/starter/` 로 스캐폴딩)
|
- "새 프로젝트 시작해서 배포까지" (→ `yakcloud project init`)
|
||||||
- "데이터소스(Postgres/Mongo/…) 연결/바인딩" / "커스텀 도메인으로 노출"
|
- "데이터소스(Postgres/Mongo/…) 연결/바인딩" / "커스텀 도메인으로 노출"
|
||||||
|
|
||||||
## CLI 가 있으면 우선 사용
|
## CLI 가 있으면 우선 사용
|
||||||
@ -39,24 +40,24 @@ CLI 가 없거나 세밀 조정이 필요하면 아래 수동 절차를 따른
|
|||||||
## 에이전트 실행 절차
|
## 에이전트 실행 절차
|
||||||
|
|
||||||
### 0) 상황 파악
|
### 0) 상황 파악
|
||||||
- git 레포인지, 앱 코드가 이미 있는지 확인. **빈/신규**면 §1-A(스타터), **기존 앱**이면 §1-B.
|
- 이미 `yakcloud project init` 으로 스캐폴딩된 프로젝트면 루트에 `yakcloud.yaml`·`scripts/`·`.gitea/`·`app.py` 가 이미 있다 → 바로 §3 이후로.
|
||||||
- Gitea 원격이 있는지(`git remote -v`) 확인. 없으면 사용자에게 Gitea 레포 URL을 물어 추가하도록 안내.
|
- 빈/신규면 §1, 기존 앱이면 §1-B. Gitea 원격 여부(`git remote -v`)도 확인(없으면 사용자에게 레포 URL 요청).
|
||||||
|
|
||||||
### 1-A) 신규 — 스타터로 스캐폴딩
|
### 1) 스캐폴딩 (신규/빈 프로젝트)
|
||||||
1. `assets/starter/`(app.py·Dockerfile·README) 를 프로젝트 루트에 복사. (프레임워크 무의존 최소 HTTP 서버: `PORT` 리슨, `/healthz` 200, `/` 는 바인딩된 소스의 `<PREFIX>_URL` 키만 표시)
|
- **CLI 있으면(권장)**: `yakcloud project init [name]` — 앱·CI·매니페스트·이 스킬을 한 번에 생성.
|
||||||
2. 아래 §2 공통 파일 복사 + §3 매니페스트 작성.
|
- **CLI 없으면**: `install.sh` 로 CLI 설치 후 init, 또는 스타터 레포에서 루트 파일을 복사:
|
||||||
|
```sh
|
||||||
|
git clone --depth 1 https://gitea.yakenator.io/yakenator/yakcloud-starter .yc-tmp
|
||||||
|
cp -R .yc-tmp/app.py .yc-tmp/Dockerfile .yc-tmp/yakcloud.yaml .yc-tmp/scripts .yc-tmp/DATA-SOURCES.md .
|
||||||
|
cp -R .yc-tmp/.gitea . && rm -rf .yc-tmp
|
||||||
|
```
|
||||||
|
스타터 앱 = 프레임워크 무의존 최소 HTTP 서버(`PORT` 리슨, `/healthz` 200). 이후 §3 매니페스트 작성.
|
||||||
|
|
||||||
### 1-B) 기존 앱에 부착
|
### 1-B) 기존 앱에 부착
|
||||||
1. 앱이 **`PORT` env로 리슨 + `/healthz`(또는 health 경로) 제공**하는지 확인. 없으면 추가 제안.
|
1. 앱이 **`PORT` env로 리슨 + `/healthz`(또는 health 경로) 제공**하는지 확인. 없으면 추가 제안.
|
||||||
2. 앱이 데이터소스 접속정보를 **하드코딩하지 말고 주입 env**(`<ALIAS>_URL` 등)로 읽도록 수정 제안. 키 규약: `reference/DATA-SOURCES.md`.
|
2. 데이터소스 접속정보를 **하드코딩하지 말고 주입 env**(`<ALIAS>_URL` 등)로 읽도록 수정 제안. 키 규약: 루트 `DATA-SOURCES.md`(스타터에서 복사).
|
||||||
3. 각 배포 단위마다 `Dockerfile` 필요(없으면 언어에 맞게 생성). 베이스 이미지는 Gitea 미러(`gitea.yakenator.io/yakenator/{node,python,...}`) 권장.
|
3. 각 배포 단위마다 `Dockerfile` 필요(없으면 언어에 맞게 생성). 베이스 이미지는 Gitea 미러(`gitea.yakenator.io/yakenator/{node,python,...}`).
|
||||||
|
4. CI/배포 플러밍(`scripts/yakcloud_deploy.py`·`scripts/yakcloud_ctl.py`·`.gitea/workflows/deploy.yml`·`yakcloud.yaml`)은 §1 처럼 스타터 레포에서 복사(또는 `yakcloud project init` 이 대신 처리).
|
||||||
### 2) 공통 파일 복사 (모든 경우)
|
|
||||||
프로젝트 루트에 그대로 복사:
|
|
||||||
- `assets/yakcloud.yaml` → `./yakcloud.yaml` (매니페스트 — §3에서 채움)
|
|
||||||
- `assets/workflows/deploy.yml` → `./.gitea/workflows/deploy.yml` (매니페스트 구동형 CI — **수정 불필요**)
|
|
||||||
- `assets/scripts/yakcloud_deploy.py` → `./scripts/yakcloud_deploy.py` (리컨실 배포 CLI — 그대로)
|
|
||||||
- `assets/reference/DATA-SOURCES.md` → `./DATA-SOURCES.md` (선택 — 소스별 주입 env 표)
|
|
||||||
|
|
||||||
### 3) 매니페스트(`yakcloud.yaml`) 채우기 (핵심)
|
### 3) 매니페스트(`yakcloud.yaml`) 채우기 (핵심)
|
||||||
사용자와 함께 값을 정한다:
|
사용자와 함께 값을 정한다:
|
||||||
|
|||||||
@ -1,158 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# yakcloud — YakCloud 프로젝트 CLI. 빈 폴더 초기화(스타터+스킬), 배포, 배포환경 상세 설정.
|
|
||||||
#
|
|
||||||
# yakcloud project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
|
|
||||||
# yakcloud project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포
|
|
||||||
# yakcloud project info 프로젝트 개괄(매니페스트 + 라이브 상태)
|
|
||||||
# yakcloud project check dry-run(직접 API 계획만)
|
|
||||||
#
|
|
||||||
# yakcloud domain <fqdn> [wl] 도메인 등록 + 워크로드에 할당(관리형=즉시, 배포중이면 라이브)
|
|
||||||
# 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
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}"
|
|
||||||
BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}"
|
|
||||||
VERSION="0.3.0"
|
|
||||||
|
|
||||||
die() { echo "✗ $*" >&2; exit 1; }
|
|
||||||
info() { echo "▸ $*"; }
|
|
||||||
|
|
||||||
ensure_pyyaml() {
|
|
||||||
python3 -c "import yaml" 2>/dev/null && return 0
|
|
||||||
pip3 install --quiet pyyaml 2>/dev/null \
|
|
||||||
|| python3 -m pip install --quiet --break-system-packages pyyaml 2>/dev/null \
|
|
||||||
|| die "pyyaml 설치 실패 — 'pip install pyyaml'"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 설정/조회 커맨드는 프로젝트의 ctl 엔진에 위임(현재 폴더 = 프로젝트).
|
|
||||||
ctl() {
|
|
||||||
[ -f scripts/yakcloud_ctl.py ] || die "프로젝트 폴더가 아닙니다 — 'yakcloud project init' 로 초기화하세요."
|
|
||||||
ensure_pyyaml
|
|
||||||
python3 scripts/yakcloud_ctl.py "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_project_init() {
|
|
||||||
local name="${1:-}"
|
|
||||||
[ -n "$name" ] && { mkdir -p "$name"; cd "$name"; }
|
|
||||||
if [ -n "$(ls -A . 2>/dev/null | grep -v '^\.git$' || true)" ]; then
|
|
||||||
die "폴더가 비어있지 않습니다. 빈 폴더에서 실행하거나 'yakcloud project init <name>'."
|
|
||||||
fi
|
|
||||||
info "yakcloud-starter 내려받는 중… ($REPO @ $BRANCH)"
|
|
||||||
command -v git >/dev/null 2>&1 || die "git 이 필요합니다."
|
|
||||||
local tmp; tmp="$(mktemp -d)"
|
|
||||||
git clone -q --depth 1 -b "$BRANCH" "$REPO.git" "$tmp/s" \
|
|
||||||
|| { rm -rf "$tmp"; die "스타터 클론 실패 — 네트워크/레포 공개 여부 확인 ($REPO)"; }
|
|
||||||
rm -rf "$tmp/s/.git"
|
|
||||||
cp -R "$tmp/s/." .
|
|
||||||
rm -rf "$tmp"
|
|
||||||
[ -n "$name" ] && [ -f yakcloud.yaml ] && { sed -i.bak "s/^project: .*/project: $name/" yakcloud.yaml && rm -f yakcloud.yaml.bak; }
|
|
||||||
git rev-parse --git-dir >/dev/null 2>&1 || git init -q
|
|
||||||
echo "✓ 초기화 완료 ($(pwd))"
|
|
||||||
echo " 다음: yakcloud project info · yakcloud domain … · yakcloud project deploy v0.1.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_project_deploy() {
|
|
||||||
[ -f yakcloud.yaml ] || die "yakcloud.yaml 없음 — 'yakcloud project init' 먼저"
|
|
||||||
git rev-parse --git-dir >/dev/null 2>&1 || git init -q
|
|
||||||
git remote get-url origin >/dev/null 2>&1 \
|
|
||||||
|| die "git 원격(origin) 없음 — Gitea 레포 만들어 'git remote add origin <url>' + Secrets 설정 후 다시"
|
|
||||||
git fetch -q --tags origin 2>/dev/null || true
|
|
||||||
# 버전: 인자 있으면 그것, 없으면 최신 v* 태그에서 패치 +1(없으면 v0.1.0) — 배포마다 자동 버전업.
|
|
||||||
local ver="${1:-}"
|
|
||||||
if [ -z "$ver" ]; then
|
|
||||||
local last; last="$(git tag -l 'v*' --sort=-v:refname 2>/dev/null | head -1)"
|
|
||||||
if [ -n "$last" ]; then
|
|
||||||
ver="$(printf '%s' "$last" | awk -F. 'BEGIN{OFS="."} {$NF=$NF+1; print}')"
|
|
||||||
else
|
|
||||||
ver="v0.1.0"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
case "$ver" in v*) : ;; *) ver="v$ver" ;; esac
|
|
||||||
info "배포 버전: $ver (커밋 + 태그 push)"
|
|
||||||
git add -A; git commit -q -m "deploy $ver" 2>/dev/null || true
|
|
||||||
git push -q origin HEAD 2>/dev/null || true
|
|
||||||
git tag "$ver" 2>/dev/null || die "태그 $ver 이미 존재 — 'yakcloud project deploy <다음버전>'"
|
|
||||||
git push -q origin "$ver"
|
|
||||||
echo "✓ $ver push — Gitea Actions 에서 빌드·배포. 콘솔 앱 탭/도메인 확인."
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_project_check() {
|
|
||||||
[ -f scripts/yakcloud_deploy.py ] || die "'yakcloud project init' 먼저"
|
|
||||||
: "${YAKCLOUD_URL:?YAKCLOUD_URL 필요}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요}"
|
|
||||||
ensure_pyyaml
|
|
||||||
python3 scripts/yakcloud_deploy.py yakcloud.yaml --dry-run
|
|
||||||
}
|
|
||||||
|
|
||||||
# 전역 CLI 자기 갱신(설치된 yakcloud 를 최신 bin 으로 덮어씀).
|
|
||||||
cmd_upgrade() {
|
|
||||||
local self; self="$(command -v yakcloud 2>/dev/null || true)"; [ -n "$self" ] || self="$0"
|
|
||||||
info "yakcloud CLI 업그레이드… ($self)"
|
|
||||||
local tmp; tmp="$(mktemp)"
|
|
||||||
curl -fsSL "$REPO/raw/branch/$BRANCH/bin/yakcloud" -o "$tmp" || { rm -f "$tmp"; die "다운로드 실패"; }
|
|
||||||
head -1 "$tmp" | grep -q 'bash' || { rm -f "$tmp"; die "받은 파일이 올바르지 않습니다"; }
|
|
||||||
chmod +x "$tmp"
|
|
||||||
mv "$tmp" "$self" 2>/dev/null || { rm -f "$tmp"; die "설치 실패(권한) — 'DEST=<dir> 재설치' 또는 sudo"; }
|
|
||||||
echo "✓ 업그레이드 완료: $("$self" version 2>/dev/null || echo yakcloud)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 기존 프로젝트의 플러밍(엔진·CI·스킬)만 최신화. 앱/매니페스트/Dockerfile 등 사용자 파일은 보존.
|
|
||||||
cmd_project_update() {
|
|
||||||
[ -f yakcloud.yaml ] || die "프로젝트 폴더가 아닙니다 — 'yakcloud project init' 먼저"
|
|
||||||
command -v git >/dev/null 2>&1 || die "git 이 필요합니다."
|
|
||||||
info "프로젝트 플러밍 최신화(앱·매니페스트 보존)… ($REPO @ $BRANCH)"
|
|
||||||
local tmp; tmp="$(mktemp -d)"
|
|
||||||
git clone -q --depth 1 -b "$BRANCH" "$REPO.git" "$tmp/s" || { rm -rf "$tmp"; die "클론 실패"; }
|
|
||||||
mkdir -p scripts .gitea/workflows .claude/skills
|
|
||||||
cp "$tmp/s/scripts/yakcloud_deploy.py" scripts/ 2>/dev/null || true
|
|
||||||
cp "$tmp/s/scripts/yakcloud_ctl.py" scripts/ 2>/dev/null || true
|
|
||||||
cp "$tmp/s/.gitea/workflows/deploy.yml" .gitea/workflows/ 2>/dev/null || true
|
|
||||||
cp "$tmp/s/DATA-SOURCES.md" DATA-SOURCES.md 2>/dev/null || true
|
|
||||||
rm -rf .claude/skills/yakcloud-deploy
|
|
||||||
cp -R "$tmp/s/.claude/skills/yakcloud-deploy" .claude/skills/ 2>/dev/null || true
|
|
||||||
rm -rf "$tmp"
|
|
||||||
echo "✓ 최신화 완료 — scripts/·.gitea/·.claude 스킬 갱신(app.py·yakcloud.yaml·Dockerfile 그대로)"
|
|
||||||
}
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
yakcloud $VERSION — YakCloud 프로젝트 CLI
|
|
||||||
project init [name] 빈 폴더에 스타터 스캐폴딩(앱+CI+매니페스트+Claude 스킬)
|
|
||||||
project deploy [vX.Y.Z] 커밋 + 태그 push → Gitea Actions 자동 배포(버전 생략=자동 버전업)
|
|
||||||
project info 프로젝트 개괄(매니페스트 + 라이브 상태)
|
|
||||||
project check dry-run(직접 API 계획만)
|
|
||||||
project update 이 프로젝트의 엔진·CI·스킬만 최신화(앱·매니페스트 보존)
|
|
||||||
upgrade 전역 yakcloud CLI 자기 갱신(최신 버전으로)
|
|
||||||
── 배포환경 설정(앱별) ──
|
|
||||||
domain <fqdn> [wl] 도메인 등록 + 워크로드 할당(관리형=즉시/배포중이면 라이브)
|
|
||||||
scale <wl> <n> replicas 변경
|
|
||||||
set <wl> --image/--port/--health/--cpu/--mem/--path/--rewrite
|
|
||||||
env <wl> KEY=VAL … [--secret KEY] [--unset KEY]
|
|
||||||
source add <name> <type> [plan] | source rm <name>
|
|
||||||
bind <wl> <source> <alias> | unbind <wl> <alias>
|
|
||||||
env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-help}" in
|
|
||||||
project)
|
|
||||||
shift
|
|
||||||
case "${1:-}" in
|
|
||||||
init) shift; cmd_project_init "$@" ;;
|
|
||||||
deploy) shift; cmd_project_deploy "$@" ;;
|
|
||||||
info) shift; ctl info "$@" ;;
|
|
||||||
check|dry-run) shift; cmd_project_check "$@" ;;
|
|
||||||
update) shift; cmd_project_update "$@" ;;
|
|
||||||
*) echo "yakcloud project <init|deploy|info|check|update>"; exit 1 ;;
|
|
||||||
esac ;;
|
|
||||||
domain|scale|set|env|source|bind|unbind) ctl "$@" ;;
|
|
||||||
upgrade|self-update) cmd_upgrade ;;
|
|
||||||
version|-v|--version) echo "yakcloud $VERSION" ;;
|
|
||||||
help|-h|--help|"") usage ;;
|
|
||||||
*) echo "알 수 없는 명령: $1"; echo; usage; exit 1 ;;
|
|
||||||
esac
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
# yakcloud CLI 설치 — PATH 의 bin 디렉터리에 내려받는다.
|
|
||||||
# curl -fsSL https://gitea.yakenator.io/yakenator/yakcloud-starter/raw/branch/main/install.sh | sh
|
|
||||||
# 설치 위치 변경: DEST=/usr/local/bin sh install.sh
|
|
||||||
set -e
|
|
||||||
REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}"
|
|
||||||
BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}"
|
|
||||||
DEST="${DEST:-$HOME/.local/bin}"
|
|
||||||
mkdir -p "$DEST"
|
|
||||||
echo "▸ yakcloud CLI 설치 → $DEST/yakcloud"
|
|
||||||
curl -fsSL "$REPO/raw/branch/$BRANCH/bin/yakcloud" -o "$DEST/yakcloud"
|
|
||||||
chmod +x "$DEST/yakcloud"
|
|
||||||
echo "✓ 완료. PATH 에 $DEST 가 있는지 확인하세요:"
|
|
||||||
echo ' export PATH="$HOME/.local/bin:$PATH"'
|
|
||||||
echo " 사용: yakcloud init my-app → 개발 → yakcloud deploy v0.1.0"
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
# 지원 데이터 소스 & 바인딩 env 주입
|
|
||||||
|
|
||||||
앱은 접속정보를 **하드코딩하지 말고**, 바인딩된 소스가 주입하는 아래 env로 읽는다.
|
|
||||||
매니페스트에서 `binds: [{ alias, source }]` 로 연결하면, `alias`(대문자화)가 env 프리픽스가 된다.
|
|
||||||
예: `alias: db` → `DB_URL`, `DB_HOST`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD`, `DB_DB`.
|
|
||||||
|
|
||||||
## 지원 소스 (`requires[].type`)
|
|
||||||
| type | 소스 | 비고 |
|
|
||||||
|---|---|---|
|
|
||||||
| `postgresql` | PostgreSQL | 관계형 |
|
|
||||||
| `mysql` | MySQL | 관계형 |
|
|
||||||
| `mariadb` | MariaDB | 관계형(MySQL 와이어 호환) |
|
|
||||||
| `mongodb` | MongoDB | 도큐먼트 |
|
|
||||||
| `redis` | Redis | 인메모리 KV/캐시 |
|
|
||||||
| `minio` | MinIO | S3 호환 오브젝트 스토리지 |
|
|
||||||
| `rabbitmq` | RabbitMQ | 메시지 브로커(AMQP) |
|
|
||||||
| `solr` | Solr | 검색 엔진 |
|
|
||||||
| `oracle` | Oracle | 관계형(외부 연결) |
|
|
||||||
|
|
||||||
`plan` ∈ `{small, medium, large}`.
|
|
||||||
|
|
||||||
## 소스별 주입 env (예시 alias=`db`)
|
|
||||||
모든 타입 공통: **`<ALIAS>_HOST`**, **`<ALIAS>_PORT`**.
|
|
||||||
|
|
||||||
| type | 추가 env 키 | `<ALIAS>_URL` 형식 |
|
|
||||||
|---|---|---|
|
|
||||||
| `postgresql` | `_USERNAME` `_PASSWORD` `_DB` | `postgresql://user:pw@host:port/db` |
|
|
||||||
| `mysql` | `_USERNAME` `_PASSWORD` `_DB` | `mysql://user:pw@host:port/db` |
|
|
||||||
| `mariadb` | `_USERNAME` `_PASSWORD` `_DB` | `mysql://user:pw@host:port/db` |
|
|
||||||
| `mongodb` | `_USERNAME` `_PASSWORD` `_DB` | `mongodb://user:pw@host:port/db?authSource=db` |
|
|
||||||
| `redis` | `_USERNAME`(빈) `_PASSWORD` `_DB`(번호) | `redis://:pw@host:port/db` |
|
|
||||||
| `minio` | `_ACCESS_KEY` `_SECRET_KEY` `_ENDPOINT` `_BUCKET` `_REGION` `_USE_SSL` | `_URL`=`_ENDPOINT` |
|
|
||||||
| `rabbitmq` | `_USERNAME` `_PASSWORD` `_VHOST` `_MGMT_URL` | `amqp://user:pw@host:port/vhost` |
|
|
||||||
| `solr` | `_CORE` `_ENDPOINT` | `http://host:port/solr/core` |
|
|
||||||
| `oracle` | `_USERNAME` `_PASSWORD` `_SERVICE` `_JDBC_URL` `_DSN` | `oracle://user:pw@host:port/service` |
|
|
||||||
|
|
||||||
> 비밀번호·키는 인클러스터 Secret으로 주입되고 콘솔/브라우저에 노출되지 않는다. URL의 user/pw는 URL-인코딩됨.
|
|
||||||
|
|
||||||
## 앱 코드 예시
|
|
||||||
```python
|
|
||||||
# Python — alias=db (postgres)
|
|
||||||
import os, psycopg2
|
|
||||||
conn = psycopg2.connect(os.environ["DB_URL"])
|
|
||||||
```
|
|
||||||
```js
|
|
||||||
// Node — alias=mongo
|
|
||||||
const client = new MongoClient(process.env.MONGO_URL);
|
|
||||||
```
|
|
||||||
```js
|
|
||||||
// MinIO(S3) — alias=files
|
|
||||||
const s3 = new S3Client({ endpoint: process.env.FILES_ENDPOINT, region: process.env.FILES_REGION,
|
|
||||||
credentials: { accessKeyId: process.env.FILES_ACCESS_KEY, secretAccessKey: process.env.FILES_SECRET_KEY } });
|
|
||||||
```
|
|
||||||
@ -1,347 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""yakcloud ctl — 배포 환경(매니페스트 + 라이브 배포) 조회/수정 엔진. bin/yakcloud 가 위임 호출.
|
|
||||||
|
|
||||||
앱마다 바뀌는 부분을 CLI 로 수정한다. 매니페스트(yakcloud.yaml)가 선언적 소스이고,
|
|
||||||
scale/set/env/domain 은 이미 배포돼 있으면 재빌드 없이 라이브 반영(PATCH)한다.
|
|
||||||
|
|
||||||
env: YAKCLOUD_URL, YAKCLOUD_TOKEN(배포토큰 yakd_…), (선택) YAKCLOUD_CLUSTER
|
|
||||||
서브커맨드: info · domain · scale · set · env · source · bind · unbind
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
URL = os.environ.get("YAKCLOUD_URL", "").rstrip("/")
|
|
||||||
TOK = os.environ.get("YAKCLOUD_TOKEN", "")
|
|
||||||
MANIFEST = "yakcloud.yaml"
|
|
||||||
|
|
||||||
|
|
||||||
# ── 유틸 ────────────────────────────────────────────────────────────────
|
|
||||||
def need_api() -> None:
|
|
||||||
if not URL or not TOK:
|
|
||||||
sys.exit(" ✗ YAKCLOUD_URL / YAKCLOUD_TOKEN(배포 토큰) 환경변수가 필요합니다.")
|
|
||||||
|
|
||||||
|
|
||||||
def api(method: str, path: str, body: dict | None = None) -> dict:
|
|
||||||
data = json.dumps(body).encode() if body is not None else None
|
|
||||||
req = urllib.request.Request(URL + "/api/v1" + path, data=data, method=method,
|
|
||||||
headers={"Authorization": "Bearer " + TOK, "content-type": "application/json"})
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as r:
|
|
||||||
raw = r.read().decode()
|
|
||||||
return json.loads(raw) if raw else {}
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
try:
|
|
||||||
msg = json.loads(e.read().decode()).get("error", {})
|
|
||||||
except Exception:
|
|
||||||
msg = {}
|
|
||||||
sys.exit(" ✗ %s %s → %s: %s" % (method, path, e.code, msg.get("message") or msg or "요청 실패"))
|
|
||||||
|
|
||||||
|
|
||||||
def unwrap(r):
|
|
||||||
return r.get("data", r) if isinstance(r, dict) else r
|
|
||||||
|
|
||||||
|
|
||||||
def load_manifest() -> dict:
|
|
||||||
if not os.path.exists(MANIFEST):
|
|
||||||
sys.exit(" ✗ %s 없음 — 'yakcloud init' 먼저" % MANIFEST)
|
|
||||||
return yaml.safe_load(open(MANIFEST)) or {}
|
|
||||||
|
|
||||||
|
|
||||||
def save_manifest(m: dict) -> None:
|
|
||||||
yaml.safe_dump(m, open(MANIFEST, "w"), sort_keys=False, allow_unicode=True)
|
|
||||||
|
|
||||||
|
|
||||||
def cluster_ref(m: dict) -> str:
|
|
||||||
ref = os.environ.get("YAKCLOUD_CLUSTER") or m.get("cluster")
|
|
||||||
if not ref:
|
|
||||||
sys.exit(" ✗ 대상 클러스터 미지정 — YAKCLOUD_CLUSTER 또는 yakcloud.yaml 의 cluster:")
|
|
||||||
return ref
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_cluster(ref: str) -> dict:
|
|
||||||
for c in unwrap(api("GET", "/clusters")) or []:
|
|
||||||
if ref in (c.get("id"), c.get("name")):
|
|
||||||
return c
|
|
||||||
sys.exit(" ✗ 클러스터 '%s' 없음" % ref)
|
|
||||||
|
|
||||||
|
|
||||||
def find_workload(m: dict, name: str | None) -> dict:
|
|
||||||
ws = m.get("workloads", []) or []
|
|
||||||
if name:
|
|
||||||
w = next((w for w in ws if w.get("name") == name), None)
|
|
||||||
if not w:
|
|
||||||
sys.exit(" ✗ 워크로드 '%s' 없음 (%s)" % (name, ", ".join(w.get("name", "?") for w in ws)))
|
|
||||||
return w
|
|
||||||
if len(ws) == 1:
|
|
||||||
return ws[0]
|
|
||||||
sys.exit(" ✗ 워크로드를 지정하세요 (%s)" % ", ".join(w.get("name", "?") for w in ws))
|
|
||||||
|
|
||||||
|
|
||||||
def live_deps(cid: str) -> dict[str, dict]:
|
|
||||||
return {d["name"]: d for d in (unwrap(api("GET", "/clusters/%s/deployments" % cid)) or [])}
|
|
||||||
|
|
||||||
|
|
||||||
def live_services(cid: str) -> dict[str, dict]:
|
|
||||||
return {s["name"]: s for s in (unwrap(api("GET", "/clusters/%s/services" % cid)) or [])}
|
|
||||||
|
|
||||||
|
|
||||||
def patch_live(cid: str, name: str, patch: dict) -> bool:
|
|
||||||
"""워크로드가 이미 배포돼 있으면 PATCH(재빌드 없이 롤링 반영). 반환=적용여부."""
|
|
||||||
dep = live_deps(cid).get(name)
|
|
||||||
if not dep:
|
|
||||||
return False
|
|
||||||
api("PATCH", "/deployments/%s" % dep["id"], patch)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# ── 커맨드 ──────────────────────────────────────────────────────────────
|
|
||||||
def cmd_info(_a) -> None:
|
|
||||||
m = load_manifest()
|
|
||||||
print("project : %s" % m.get("project", "-"))
|
|
||||||
ref = m.get("cluster") or os.environ.get("YAKCLOUD_CLUSTER") or "-"
|
|
||||||
deps: dict[str, dict] = {}
|
|
||||||
svcs: dict[str, dict] = {}
|
|
||||||
doms: list[dict] = []
|
|
||||||
cinfo = None
|
|
||||||
if URL and TOK and (m.get("cluster") or os.environ.get("YAKCLOUD_CLUSTER")):
|
|
||||||
cinfo = resolve_cluster(cluster_ref(m))
|
|
||||||
deps = live_deps(cinfo["id"])
|
|
||||||
svcs = live_services(cinfo["id"])
|
|
||||||
doms = unwrap(api("GET", "/clusters/%s/domains" % cinfo["id"])) or []
|
|
||||||
print("cluster : %s (id %s) 도메인 %s" % (cinfo.get("name"), cinfo["id"], cinfo.get("defaultHostname", "-")))
|
|
||||||
else:
|
|
||||||
print("cluster : %s (라이브 상태는 YAKCLOUD_URL/TOKEN 설정 시 표시)" % ref)
|
|
||||||
|
|
||||||
print("\nworkloads:")
|
|
||||||
for w in m.get("workloads", []) or []:
|
|
||||||
ex = w.get("expose", {}) or {}
|
|
||||||
hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])
|
|
||||||
line = " • %-14s image=%s port=%s replicas=%s health=%s" % (
|
|
||||||
w.get("name"), w.get("image"), w.get("port"), w.get("replicas", 1), w.get("health", "-"))
|
|
||||||
d = deps.get(w.get("name"))
|
|
||||||
if d:
|
|
||||||
live_hosts = d.get("exposeHosts") or ([d["exposeHost"]] if d.get("exposeHost") else [])
|
|
||||||
line += "\n live: %s %s/%s hosts=%s" % (
|
|
||||||
d.get("status"), d.get("replicasReady", 0), d.get("replicasDesired", 0),
|
|
||||||
", ".join(live_hosts) or "(내부)")
|
|
||||||
elif hosts:
|
|
||||||
line += "\n manifest hosts=%s" % ", ".join(hosts)
|
|
||||||
binds = w.get("binds", []) or []
|
|
||||||
if binds:
|
|
||||||
line += "\n binds: %s" % ", ".join("%s→%s" % (b["alias"], b["source"]) for b in binds)
|
|
||||||
print(line)
|
|
||||||
|
|
||||||
reqs = m.get("requires", []) or []
|
|
||||||
if reqs:
|
|
||||||
print("\ndata sources (requires):")
|
|
||||||
for r in reqs:
|
|
||||||
s = svcs.get(r["name"])
|
|
||||||
st = (" [%s]" % s.get("status")) if s else ""
|
|
||||||
print(" • %-14s %s/%s%s" % (r["name"], r.get("type"), r.get("plan", "small"), st))
|
|
||||||
if doms:
|
|
||||||
print("\ndomains (cluster):")
|
|
||||||
for d in doms:
|
|
||||||
print(" • %-28s %s cert=%s" % (d["fqdn"], d["status"], d.get("certStatus")))
|
|
||||||
print("\n다음: 수정=yakcloud (scale|set|env|domain|source|bind) · 배포=yakcloud deploy vX.Y.Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_workload_patch(m, cid_or_none, w, patch, label):
|
|
||||||
"""매니페스트 저장 후, 배포돼 있으면 라이브 PATCH."""
|
|
||||||
save_manifest(m)
|
|
||||||
print(" ✓ 매니페스트: %s %s" % (w["name"], label))
|
|
||||||
if cid_or_none and patch and patch_live(cid_or_none, w["name"], patch):
|
|
||||||
print(" ✓ 라이브 반영(재빌드 없음): %s" % w["name"])
|
|
||||||
elif cid_or_none:
|
|
||||||
print(" · 아직 미배포 — 'yakcloud deploy' 시 반영")
|
|
||||||
|
|
||||||
|
|
||||||
def _cid(m):
|
|
||||||
if URL and TOK:
|
|
||||||
return resolve_cluster(cluster_ref(m))["id"]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_scale(a) -> None:
|
|
||||||
m = load_manifest(); w = find_workload(m, a.workload)
|
|
||||||
w["replicas"] = int(a.replicas)
|
|
||||||
_apply_workload_patch(m, _cid(m), w, {"replicasDesired": int(a.replicas)}, "replicas=%s" % a.replicas)
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_set(a) -> None:
|
|
||||||
m = load_manifest(); w = find_workload(m, a.workload)
|
|
||||||
patch, changed = {}, []
|
|
||||||
if a.image is not None:
|
|
||||||
w["image"] = a.image; patch["image"] = a.image; changed.append("image")
|
|
||||||
if a.port is not None:
|
|
||||||
w["port"] = int(a.port); patch["port"] = int(a.port); changed.append("port")
|
|
||||||
if a.health is not None:
|
|
||||||
w["health"] = a.health; patch["healthPath"] = a.health; changed.append("health")
|
|
||||||
if a.cpu is not None or a.mem is not None:
|
|
||||||
res = w.get("resources", {}) or {}; w["resources"] = res
|
|
||||||
if a.cpu is not None:
|
|
||||||
res["cpu"] = a.cpu; patch["cpuRequest"] = a.cpu; changed.append("cpu")
|
|
||||||
if a.mem is not None:
|
|
||||||
res["mem"] = a.mem; patch["memRequest"] = a.mem; changed.append("mem")
|
|
||||||
if a.path is not None or a.rewrite is not None:
|
|
||||||
ex = w.get("expose", {}) or {}; w["expose"] = ex
|
|
||||||
if a.path is not None:
|
|
||||||
ex["path"] = a.path; patch["path"] = a.path; changed.append("path")
|
|
||||||
if a.rewrite is not None:
|
|
||||||
rw = a.rewrite.lower() in ("1", "true", "yes", "on")
|
|
||||||
ex["rewrite"] = rw; patch["rewritePrefix"] = rw; changed.append("rewrite")
|
|
||||||
if not changed:
|
|
||||||
sys.exit(" ✗ 변경할 항목 없음 — --image/--port/--health/--cpu/--mem/--path/--rewrite")
|
|
||||||
_apply_workload_patch(m, _cid(m), w, patch, "set " + " ".join(changed))
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_env(a) -> None:
|
|
||||||
m = load_manifest(); w = find_workload(m, a.workload)
|
|
||||||
env = w.get("env", []) or []
|
|
||||||
if isinstance(env, dict):
|
|
||||||
env = [{"key": k, "value": v} for k, v in env.items()]
|
|
||||||
idx = {e["key"]: e for e in env}
|
|
||||||
for pair in a.pairs:
|
|
||||||
if "=" not in pair:
|
|
||||||
sys.exit(" ✗ KEY=VALUE 형식이어야 합니다: %s" % pair)
|
|
||||||
k, v = pair.split("=", 1)
|
|
||||||
idx[k] = {"key": k, "value": v, **({"secret": True} if k in (a.secret or []) else {})}
|
|
||||||
for k in (a.unset or []):
|
|
||||||
idx.pop(k, None)
|
|
||||||
env = list(idx.values())
|
|
||||||
w["env"] = env
|
|
||||||
patch = {"env": [{"key": e["key"], "value": str(e.get("value", "")), "secret": bool(e.get("secret", False))} for e in env]}
|
|
||||||
_apply_workload_patch(m, _cid(m), w, patch, "env=[%s]" % ", ".join(e["key"] for e in env))
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_source(a) -> None:
|
|
||||||
m = load_manifest(); reqs = m.setdefault("requires", []) or []
|
|
||||||
m["requires"] = reqs
|
|
||||||
if a.action == "add":
|
|
||||||
if any(r.get("name") == a.name for r in reqs):
|
|
||||||
sys.exit(" ✗ 이미 있음: %s" % a.name)
|
|
||||||
reqs.append({"name": a.name, "type": a.type, "plan": a.plan})
|
|
||||||
save_manifest(m)
|
|
||||||
print(" ✓ requires += {name=%s, type=%s, plan=%s}" % (a.name, a.type, a.plan))
|
|
||||||
print(" · 프로비저닝/바인딩은 'yakcloud bind <workload> %s <alias>' 후 'yakcloud deploy'" % a.name)
|
|
||||||
else: # rm
|
|
||||||
m["requires"] = [r for r in reqs if r.get("name") != a.name]
|
|
||||||
# 관련 바인딩도 정리
|
|
||||||
for w in m.get("workloads", []) or []:
|
|
||||||
w["binds"] = [b for b in (w.get("binds", []) or []) if b.get("source") != a.name]
|
|
||||||
save_manifest(m)
|
|
||||||
print(" ✓ requires 에서 제거: %s (관련 binds 정리). 'yakcloud deploy' 로 반영" % a.name)
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_bind(a) -> None:
|
|
||||||
m = load_manifest(); w = find_workload(m, a.workload)
|
|
||||||
if not any(r.get("name") == a.source for r in (m.get("requires", []) or [])):
|
|
||||||
sys.exit(" ✗ requires 에 소스 '%s' 없음 — 'yakcloud source add %s <type>' 먼저" % (a.source, a.source))
|
|
||||||
binds = w.get("binds", []) or []; w["binds"] = binds
|
|
||||||
binds[:] = [b for b in binds if b.get("alias") != a.alias]
|
|
||||||
binds.append({"alias": a.alias, "source": a.source})
|
|
||||||
save_manifest(m)
|
|
||||||
print(" ✓ %s.binds += {alias=%s, source=%s}. 'yakcloud deploy' 로 반영(env 주입)" % (w["name"], a.alias, a.source))
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_unbind(a) -> None:
|
|
||||||
m = load_manifest(); w = find_workload(m, a.workload)
|
|
||||||
binds = w.get("binds", []) or []
|
|
||||||
w["binds"] = [b for b in binds if b.get("alias") != a.alias]
|
|
||||||
save_manifest(m)
|
|
||||||
print(" ✓ %s.binds 에서 alias=%s 제거. 'yakcloud deploy' 로 반영" % (w["name"], a.alias))
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_domain(a) -> None:
|
|
||||||
need_api()
|
|
||||||
m = load_manifest(); c = resolve_cluster(cluster_ref(m)); cid = c["id"]
|
|
||||||
doms = unwrap(api("GET", "/clusters/%s/domains" % cid)) or []
|
|
||||||
d = next((x for x in doms if x["fqdn"] == a.fqdn), None)
|
|
||||||
if d:
|
|
||||||
print(" = 이미 등록됨: %s (status=%s)" % (a.fqdn, d["status"]))
|
|
||||||
else:
|
|
||||||
d = unwrap(api("POST", "/clusters/%s/domains" % cid, {"fqdn": a.fqdn}))
|
|
||||||
print(" ✓ 등록: %s status=%s cert=%s" % (a.fqdn, d["status"], d["certStatus"]))
|
|
||||||
v = d.get("verify")
|
|
||||||
if v:
|
|
||||||
print(' 외부 도메인 — DNS 에 TXT 추가 후 검증:\n %s TXT "%s"' % (v["host"], v["value"]))
|
|
||||||
# 매니페스트 워크로드에 연결
|
|
||||||
ws = m.get("workloads", []) or []
|
|
||||||
w = next((x for x in ws if x.get("name") == a.workload), None) if a.workload else (ws[0] if len(ws) == 1 else None)
|
|
||||||
if w is None and ws:
|
|
||||||
print(" · 워크로드를 지정하세요: yakcloud domain %s <workload> (%s)"
|
|
||||||
% (a.fqdn, ", ".join(x.get("name", "?") for x in ws)))
|
|
||||||
return
|
|
||||||
if w is None:
|
|
||||||
return
|
|
||||||
ex = w.get("expose", {}) or {}; w["expose"] = ex
|
|
||||||
hosts = ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])
|
|
||||||
if a.fqdn not in hosts:
|
|
||||||
hosts.append(a.fqdn)
|
|
||||||
ex.pop("host", None); ex["hosts"] = hosts
|
|
||||||
save_manifest(m)
|
|
||||||
print(" ✓ 매니페스트: %s.expose.hosts=%s" % (w["name"], hosts))
|
|
||||||
dep = live_deps(cid).get(w["name"])
|
|
||||||
if dep and d["status"] == "ACTIVE":
|
|
||||||
cur = dep.get("exposeHosts") or ([dep["exposeHost"]] if dep.get("exposeHost") else [])
|
|
||||||
newh = list(dict.fromkeys(cur + [a.fqdn]))
|
|
||||||
api("PATCH", "/deployments/%s" % dep["id"], {"exposeHosts": newh})
|
|
||||||
print(" ✓ 라이브 반영(재빌드 없음): %s → %s" % (w["name"], ", ".join(newh)))
|
|
||||||
elif dep:
|
|
||||||
print(" · 도메인 활성 후 'yakcloud deploy' 로 반영")
|
|
||||||
else:
|
|
||||||
print(" · 미배포 — 'yakcloud deploy' 시 이 도메인으로 노출")
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
|
||||||
p = argparse.ArgumentParser(prog="yakcloud", add_help=True)
|
|
||||||
sub = p.add_subparsers(dest="cmd", required=True)
|
|
||||||
|
|
||||||
sub.add_parser("info", help="프로젝트 개괄(매니페스트 + 라이브 상태)").set_defaults(fn=cmd_info)
|
|
||||||
|
|
||||||
d = sub.add_parser("domain", help="도메인 등록 + 워크로드 할당")
|
|
||||||
d.add_argument("fqdn"); d.add_argument("workload", nargs="?")
|
|
||||||
d.set_defaults(fn=cmd_domain)
|
|
||||||
|
|
||||||
s = sub.add_parser("scale", help="워크로드 replicas 변경")
|
|
||||||
s.add_argument("workload"); s.add_argument("replicas", type=int)
|
|
||||||
s.set_defaults(fn=cmd_scale)
|
|
||||||
|
|
||||||
se = sub.add_parser("set", help="워크로드 필드 수정")
|
|
||||||
se.add_argument("workload")
|
|
||||||
se.add_argument("--image"); se.add_argument("--port"); se.add_argument("--health")
|
|
||||||
se.add_argument("--cpu"); se.add_argument("--mem"); se.add_argument("--path"); se.add_argument("--rewrite")
|
|
||||||
se.set_defaults(fn=cmd_set)
|
|
||||||
|
|
||||||
e = sub.add_parser("env", help="워크로드 환경변수 KEY=VALUE 설정/해제")
|
|
||||||
e.add_argument("workload"); e.add_argument("pairs", nargs="*")
|
|
||||||
e.add_argument("--secret", action="append", help="이 KEY 를 secret 으로 표시")
|
|
||||||
e.add_argument("--unset", action="append", help="이 KEY 제거")
|
|
||||||
e.set_defaults(fn=cmd_env)
|
|
||||||
|
|
||||||
so = sub.add_parser("source", help="데이터 소스(requires) 추가/삭제")
|
|
||||||
sosub = so.add_subparsers(dest="action", required=True)
|
|
||||||
soa = sosub.add_parser("add"); soa.add_argument("name"); soa.add_argument("type")
|
|
||||||
soa.add_argument("plan", nargs="?", default="small"); soa.set_defaults(fn=cmd_source)
|
|
||||||
sor = sosub.add_parser("rm"); sor.add_argument("name"); sor.set_defaults(fn=cmd_source)
|
|
||||||
|
|
||||||
b = sub.add_parser("bind", help="워크로드에 소스 바인딩")
|
|
||||||
b.add_argument("workload"); b.add_argument("source"); b.add_argument("alias")
|
|
||||||
b.set_defaults(fn=cmd_bind)
|
|
||||||
|
|
||||||
u = sub.add_parser("unbind", help="워크로드 바인딩 해제")
|
|
||||||
u.add_argument("workload"); u.add_argument("alias")
|
|
||||||
u.set_defaults(fn=cmd_unbind)
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
args = build_parser().parse_args()
|
|
||||||
args.fn(args)
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""yakcloud deploy — 매니페스트(yakcloud.yaml) 기반 리컨실 배포 CLI (콘솔 API).
|
|
||||||
|
|
||||||
흐름(선언적 idempotent):
|
|
||||||
1) requires 리컨실 — 논리 이름의 소스가 READY 면 스킵, 없으면 type/plan 으로 프로비저닝 → READY 대기.
|
|
||||||
2) workloads 배포 — 각 워크로드 배포(POST .../deployments). 기존이면 PATCH(무중단 롤링).
|
|
||||||
3) 바인딩 — 각 bind(alias→source) 를 POST /services/{serviceId}/bindings {deploymentId, alias}.
|
|
||||||
|
|
||||||
인증: 콘솔 API 에 개인 배포 토큰(PAT) Bearer. (환경변수)
|
|
||||||
YAKCLOUD_URL 예) https://console.yakenator.io
|
|
||||||
YAKCLOUD_TOKEN 배포 토큰(PAT) — 콘솔 설정에서 발급
|
|
||||||
YAKCLOUD_CLUSTER 대상 클러스터 이름 또는 콘솔 id (없으면 매니페스트 cluster:)
|
|
||||||
TAG 이미지 태그 치환용(${TAG}); 없으면 latest
|
|
||||||
사용: yakcloud_deploy.py [manifest.yaml] [--dry-run]
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
URL = os.environ["YAKCLOUD_URL"].rstrip("/")
|
|
||||||
TOKEN = os.environ["YAKCLOUD_TOKEN"]
|
|
||||||
CLUSTER_REF = os.environ.get("YAKCLOUD_CLUSTER")
|
|
||||||
CLUSTER = "" # main 에서 이름→id 로 해석해 채운다
|
|
||||||
CLUSTER_HOST = "" # 클러스터 기본 도메인 — expose.host(s) 미지정 시 여기로 노출
|
|
||||||
TAG = os.environ.get("TAG", "latest")
|
|
||||||
DRY = "--dry-run" in sys.argv[1:]
|
|
||||||
|
|
||||||
|
|
||||||
def api(method: str, path: str, body: dict | None = None, _retry: int = 5) -> dict:
|
|
||||||
data = json.dumps(body).encode() if body is not None else None
|
|
||||||
req = urllib.request.Request(f"{URL}/api/v1{path}", data=data, method=method,
|
|
||||||
headers={"Authorization": f"Bearer {TOKEN}", "content-type": "application/json"})
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as r:
|
|
||||||
raw = r.read().decode()
|
|
||||||
return json.loads(raw) if raw else {}
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
payload = e.read().decode()
|
|
||||||
if e.code == 429 and _retry > 0: # 쓰기 레이트리밋 — retryAfterSec 백오프 후 재시도
|
|
||||||
try:
|
|
||||||
wait = json.loads(payload).get("error", {}).get("detail", {}).get("retryAfterSec", 2)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
wait = 2
|
|
||||||
time.sleep(max(1, int(wait)) + 1)
|
|
||||||
return api(method, path, body, _retry - 1)
|
|
||||||
raise SystemExit(f"[api] {method} {path} -> {e.code}: {payload[:400]}")
|
|
||||||
|
|
||||||
|
|
||||||
def unwrap(r):
|
|
||||||
return r.get("data", r) if isinstance(r, dict) else r
|
|
||||||
|
|
||||||
|
|
||||||
def log(m: str) -> None:
|
|
||||||
print(("\033[33m[dry]\033[0m " if DRY else "\033[36m▸\033[0m ") + m, flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_cluster(ref: str) -> tuple[str, str]:
|
|
||||||
clusters = unwrap(api("GET", "/clusters")) or []
|
|
||||||
for c in clusters:
|
|
||||||
if ref in (c.get("id"), c.get("name")):
|
|
||||||
return c["id"], c.get("name") or c["id"]
|
|
||||||
names = ", ".join(c.get("name", "?") for c in clusters) or "(없음)"
|
|
||||||
raise SystemExit(f"클러스터 '{ref}' 를 찾을 수 없습니다. 계정 클러스터: {names}")
|
|
||||||
|
|
||||||
|
|
||||||
def cluster_services() -> list[dict]:
|
|
||||||
return unwrap(api("GET", f"/clusters/{CLUSTER}/services")) or []
|
|
||||||
|
|
||||||
|
|
||||||
def cluster_deployments() -> list[dict]:
|
|
||||||
return unwrap(api("GET", f"/clusters/{CLUSTER}/deployments")) or []
|
|
||||||
|
|
||||||
|
|
||||||
def reconcile_source(req: dict) -> str | None:
|
|
||||||
name, stype, plan = req["name"], req["type"].upper(), req.get("plan", "small")
|
|
||||||
match = next((s for s in cluster_services() if s.get("name") == name), None)
|
|
||||||
if match and match.get("status") == "READY":
|
|
||||||
log(f"source '{name}' ({stype}) 이미 READY → 스킵 (id={match['id']})")
|
|
||||||
return match["id"]
|
|
||||||
if DRY:
|
|
||||||
log(f"source '{name}' ({stype}, {plan}) 없음 → 프로비저닝 예정")
|
|
||||||
return None
|
|
||||||
if not match:
|
|
||||||
log(f"source '{name}' ({stype}, {plan}) 프로비저닝…")
|
|
||||||
api("POST", f"/clusters/{CLUSTER}/services",
|
|
||||||
{"clusterId": CLUSTER, "type": stype, "name": name, "mode": "local", "size": plan})
|
|
||||||
for _ in range(120): # ~10분
|
|
||||||
s = next((s for s in cluster_services() if s.get("name") == name), None)
|
|
||||||
if s and s.get("status") == "READY":
|
|
||||||
log(f"source '{name}' READY (id={s['id']})")
|
|
||||||
return s["id"]
|
|
||||||
if s and s.get("status") == "ERROR":
|
|
||||||
raise SystemExit(f"source '{name}' 프로비저닝 ERROR")
|
|
||||||
time.sleep(5)
|
|
||||||
raise SystemExit(f"source '{name}' READY 대기 초과")
|
|
||||||
|
|
||||||
|
|
||||||
def deploy_workload(w: dict) -> tuple[str | None, bool]:
|
|
||||||
image = w["image"].replace("${TAG}", TAG)
|
|
||||||
ex = w.get("expose", {}) or {}
|
|
||||||
res = w.get("resources", {}) or {}
|
|
||||||
body = {
|
|
||||||
"name": w["name"], "image": image,
|
|
||||||
"port": w.get("port"),
|
|
||||||
"replicasDesired": w.get("replicas", 1),
|
|
||||||
"cpuRequest": res.get("cpu", "25m"), "memRequest": res.get("mem", "96Mi"),
|
|
||||||
"healthPath": w.get("health"),
|
|
||||||
"path": ex.get("path", "/"), "pathType": "Prefix", "rewritePrefix": bool(ex.get("rewrite", False)),
|
|
||||||
}
|
|
||||||
# 노출 도메인: 여러 개(hosts) > 단일(host) > 클러스터 기본 도메인.
|
|
||||||
hosts = ex.get("hosts")
|
|
||||||
if hosts:
|
|
||||||
body["exposeHosts"] = hosts
|
|
||||||
else:
|
|
||||||
body["exposeHost"] = ex.get("host") or (CLUSTER_HOST or None)
|
|
||||||
# 환경변수(선언적): 리스트[{key,value,secret?}] 또는 맵{KEY: VALUE}.
|
|
||||||
env = w.get("env") or []
|
|
||||||
if isinstance(env, dict):
|
|
||||||
env = [{"key": k, "value": v} for k, v in env.items()]
|
|
||||||
body["env"] = [{"key": e["key"], "value": str(e.get("value", "")),
|
|
||||||
"secret": bool(e.get("secret", False))} for e in env]
|
|
||||||
if DRY:
|
|
||||||
log(f"deploy '{w['name']}' 예정: image={image} port={body['port']} path={body['path']} "
|
|
||||||
f"replicas={body['replicasDesired']} hosts={hosts or body.get('exposeHost')}")
|
|
||||||
return None, False
|
|
||||||
# 멱등: 기존 배포면 PATCH(이미지 갱신 → kubectl apply = 무중단 롤링). 없으면 신규 생성.
|
|
||||||
existing = next((d for d in cluster_deployments() if d.get("name") == w["name"]), None)
|
|
||||||
if existing:
|
|
||||||
log(f"deploy '{w['name']}' 기존 존재 → PATCH 롤링 업데이트 image={image}")
|
|
||||||
api("PATCH", f"/deployments/{existing['id']}", body)
|
|
||||||
return existing["id"], False
|
|
||||||
log(f"deploy '{w['name']}' 신규 생성 image={image}")
|
|
||||||
dep = unwrap(api("POST", f"/clusters/{CLUSTER}/deployments", body))
|
|
||||||
return dep.get("id"), True
|
|
||||||
|
|
||||||
|
|
||||||
def bind(dep_id: str | None, alias: str, service_id: str | None, source: str) -> None:
|
|
||||||
if DRY or not dep_id or not service_id:
|
|
||||||
log(f"bind '{alias}' → source '{source}' (serviceId={service_id}) 예정")
|
|
||||||
return
|
|
||||||
api("POST", f"/services/{service_id}/bindings", {"deploymentId": dep_id, "alias": alias})
|
|
||||||
log(f"bind '{alias}' → '{source}' 완료")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
global CLUSTER, CLUSTER_HOST
|
|
||||||
path = next((a for a in sys.argv[1:] if not a.startswith("--")), "yakcloud.yaml")
|
|
||||||
m = yaml.safe_load(open(path))
|
|
||||||
ref = CLUSTER_REF or m.get("cluster")
|
|
||||||
if not ref:
|
|
||||||
raise SystemExit("대상 클러스터 미지정 — YAKCLOUD_CLUSTER 환경변수 또는 매니페스트 cluster: 필드")
|
|
||||||
CLUSTER, cname = resolve_cluster(ref)
|
|
||||||
CLUSTER_HOST = (unwrap(api("GET", f"/clusters/{CLUSTER}")) or {}).get("defaultHostname") or ""
|
|
||||||
log(f"project '{m.get('project')}' → cluster '{cname}' ({CLUSTER}) (tag {TAG}){' [DRY-RUN]' if DRY else ''}")
|
|
||||||
src_ids: dict[str, str | None] = {}
|
|
||||||
for req in m.get("requires", []):
|
|
||||||
src_ids[req["name"]] = reconcile_source(req)
|
|
||||||
for w in m.get("workloads", []):
|
|
||||||
dep_id, created = deploy_workload(w)
|
|
||||||
if DRY or created: # 기존 배포는 PATCH(롤링)로 바인딩 유지 → 신규일 때만 바인딩
|
|
||||||
for b in w.get("binds", []):
|
|
||||||
bind(dep_id, b["alias"], src_ids.get(b["source"]), b["source"])
|
|
||||||
log("완료 — 콘솔 앱 탭에서 배포/바인딩 확인" if not DRY else "완료(dry-run) — 실제 변경 없음")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
# 프레임워크 중립 스타터 — 의존성 없음(표준 라이브러리). 베이스는 Gitea 미러 이미지.
|
|
||||||
FROM gitea.yakenator.io/yakenator/python:3.12-slim
|
|
||||||
WORKDIR /app
|
|
||||||
COPY app.py .
|
|
||||||
ENV PORT=8080
|
|
||||||
EXPOSE 8080
|
|
||||||
CMD ["python", "app.py"]
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
# yakcloud 스타터 (프레임워크 중립)
|
|
||||||
|
|
||||||
의존성 0의 최소 HTTP 서버. **배포 계약**만 지키면 어떤 스택으로 바꿔도 된다:
|
|
||||||
- `PORT` 환경변수로 리슨(기본 8080)
|
|
||||||
- `GET /healthz` → 200 (매니페스트 `health` 경로)
|
|
||||||
- (선택) 바인딩된 소스는 `<ALIAS>_URL` 등 env로 접속
|
|
||||||
|
|
||||||
## 로컬 실행
|
|
||||||
```sh
|
|
||||||
PORT=8080 python3 app.py
|
|
||||||
curl localhost:8080/healthz # {"ok":true}
|
|
||||||
curl localhost:8080/ # 앱 정보 + 연결된 소스 키
|
|
||||||
```
|
|
||||||
|
|
||||||
## 매니페스트 매핑 (`yakcloud.yaml`)
|
|
||||||
```yaml
|
|
||||||
workloads:
|
|
||||||
- name: web
|
|
||||||
build: ./ # 이 디렉터리(Dockerfile)를 CI 가 빌드
|
|
||||||
image: gitea.yakenator.io/<GITEA_USER>/<project>-web:${TAG}
|
|
||||||
port: 8080
|
|
||||||
health: /healthz
|
|
||||||
binds:
|
|
||||||
- { alias: db, source: appdb } # → 앱에서 os.environ["DB_URL"] 로 접속
|
|
||||||
```
|
|
||||||
|
|
||||||
## 다른 스택으로 교체
|
|
||||||
`app.py`(+`Dockerfile`)를 원하는 언어/프레임워크로 교체하되 위 3계약 유지.
|
|
||||||
소스별 주입 env 전체 목록은 `../reference/DATA-SOURCES.md` 참고.
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""프레임워크 중립 최소 스타터 — Python 표준 라이브러리만(의존성 0).
|
|
||||||
|
|
||||||
배포 계약만 보여준다:
|
|
||||||
· PORT 환경변수로 리슨(기본 8080)
|
|
||||||
· GET /healthz → 200 {"ok": true} (매니페스트 health 경로)
|
|
||||||
· GET / → 앱 정보 + 바인딩된 소스의 <PREFIX>_URL '키'만(값은 노출 안 함)
|
|
||||||
새 프로젝트는 이 파일을 원하는 스택으로 교체하되, 위 3가지 계약만 지키면 된다.
|
|
||||||
"""
|
|
||||||
import http.server
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socketserver
|
|
||||||
|
|
||||||
PORT = int(os.environ.get("PORT", "8080"))
|
|
||||||
|
|
||||||
|
|
||||||
def bound_source_keys() -> list[str]:
|
|
||||||
"""바인딩된 데이터 소스가 주입한 <PREFIX>_URL 키만(값 미노출) — 무엇이 연결됐는지 확인용."""
|
|
||||||
return sorted(
|
|
||||||
k for k, v in os.environ.items()
|
|
||||||
if k.endswith("_URL") and isinstance(v, str) and "://" in v
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(http.server.BaseHTTPRequestHandler):
|
|
||||||
def _send(self, code: int, obj: dict) -> None:
|
|
||||||
body = json.dumps(obj, ensure_ascii=False).encode()
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("content-type", "application/json; charset=utf-8")
|
|
||||||
self.send_header("content-length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802
|
|
||||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
|
||||||
if path in ("/healthz", "/health"):
|
|
||||||
self._send(200, {"ok": True})
|
|
||||||
return
|
|
||||||
self._send(200, {
|
|
||||||
"app": os.environ.get("APP_NAME", "yakcloud-starter"),
|
|
||||||
"host": os.environ.get("HOSTNAME", "?"),
|
|
||||||
"bound_sources": bound_source_keys(), # 연결된 소스 <PREFIX>_URL 키(값 미노출)
|
|
||||||
"path": self.path,
|
|
||||||
})
|
|
||||||
|
|
||||||
def log_message(self, *_args) -> None: # 로그 조용히
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
socketserver.ThreadingTCPServer.allow_reuse_address = True
|
|
||||||
with socketserver.ThreadingTCPServer(("", PORT), Handler) as httpd:
|
|
||||||
print(f"yakcloud-starter listening on :{PORT}", flush=True)
|
|
||||||
httpd.serve_forever()
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
# Gitea Actions — 릴리스 태그(v*) push 시 이미지 빌드/푸시 후 YakCloud 에 리컨실 배포.
|
|
||||||
# 매니페스트 구동형: yakcloud.yaml 의 각 workload.build 를 빌드해 image 로 push → yakcloud_deploy.py.
|
|
||||||
# 프로젝트마다 수정 불필요(레포명은 GITHUB_REPOSITORY 로 자동).
|
|
||||||
# 필요 secrets: REGISTRY_TOKEN, YAKCLOUD_URL, YAKCLOUD_TOKEN, YAKCLOUD_CLUSTER
|
|
||||||
# 러너: 전용 host act_runner, 라벨 ci-polyglot(잡 컨테이너에 host docker.sock 마운트).
|
|
||||||
name: deploy
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ["v*"]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ci-polyglot
|
|
||||||
env:
|
|
||||||
REGISTRY: gitea.yakenator.io
|
|
||||||
steps:
|
|
||||||
- name: Build, push images & deploy
|
|
||||||
env:
|
|
||||||
REG_USER: ${{ github.actor }}
|
|
||||||
REG_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
YAKCLOUD_URL: ${{ secrets.YAKCLOUD_URL }}
|
|
||||||
YAKCLOUD_TOKEN: ${{ secrets.YAKCLOUD_TOKEN }}
|
|
||||||
YAKCLOUD_CLUSTER: ${{ secrets.YAKCLOUD_CLUSTER }}
|
|
||||||
run: |
|
|
||||||
set -eo pipefail
|
|
||||||
TAG="${GITHUB_REF_NAME}"
|
|
||||||
echo "▸ tag=$TAG repo=$GITHUB_REPOSITORY registry=$REGISTRY"
|
|
||||||
|
|
||||||
# 1) 소스 체크아웃(git clone — JS 액션 미사용, 견고)
|
|
||||||
git clone --depth 1 -b "$TAG" \
|
|
||||||
"https://${REG_USER}:${REG_TOKEN}@${REGISTRY}/${GITHUB_REPOSITORY}.git" src
|
|
||||||
cd src
|
|
||||||
|
|
||||||
# 2) 레지스트리 로그인
|
|
||||||
echo "$REG_TOKEN" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin
|
|
||||||
|
|
||||||
# 3) PyYAML 준비(yakcloud-runner 이미지엔 이미 있음)
|
|
||||||
python3 -c "import yaml" 2>/dev/null \
|
|
||||||
|| python3 -m pip install --quiet --break-system-packages pyyaml 2>/dev/null \
|
|
||||||
|| pip3 install --quiet pyyaml
|
|
||||||
|
|
||||||
# 4) 매니페스트의 각 workload.build → image 빌드/푸시(${TAG} 치환)
|
|
||||||
python3 - "$TAG" <<'PY'
|
|
||||||
import subprocess, sys, yaml
|
|
||||||
tag = sys.argv[1]
|
|
||||||
m = yaml.safe_load(open("yakcloud.yaml"))
|
|
||||||
for w in m.get("workloads", []):
|
|
||||||
ctx = w.get("build")
|
|
||||||
if not ctx:
|
|
||||||
continue # build 없으면 외부 이미지로 간주(빌드 스킵)
|
|
||||||
img = w["image"].replace("${TAG}", tag)
|
|
||||||
print(f"▸ build {w['name']}: {ctx} -> {img}", flush=True)
|
|
||||||
subprocess.check_call(["docker", "build", "-t", img, ctx])
|
|
||||||
subprocess.check_call(["docker", "push", img])
|
|
||||||
PY
|
|
||||||
|
|
||||||
# 5) YakCloud 리컨실 배포(콘솔 API, PAT 인증)
|
|
||||||
TAG="$TAG" python3 scripts/yakcloud_deploy.py yakcloud.yaml
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
# YakCloud 선언적 배포 매니페스트 (범용 템플릿)
|
|
||||||
# 태그(v*) push → Gitea Actions → 이 매니페스트로 배포:
|
|
||||||
# 1) requires 리컨실 — 논리 이름의 소스가 READY 면 스킵, 없으면 type/plan 대로 프로비저닝 후 Ready 대기
|
|
||||||
# 2) workloads 빌드/배포 — build 컨텍스트를 image 로 빌드·push, 각 워크로드가 소스를 alias 로 바인딩
|
|
||||||
# 3) 앱 탭 등록 + 데이터소스 연결(<ALIAS>_URL 등 env 자동 주입)
|
|
||||||
apiVersion: yakcloud/v1
|
|
||||||
project: my-app # 프로젝트 이름(이미지 경로/표시에 사용) — 소문자·숫자·하이픈
|
|
||||||
|
|
||||||
# 배포 대상 클러스터 — 이름 또는 콘솔 id. (우선순위: YAKCLOUD_CLUSTER 시크릿 > 이 필드)
|
|
||||||
cluster: my-service-19
|
|
||||||
|
|
||||||
# 필요한 데이터 소스(논리 이름). 없으면 [] 로 둬도 됨.
|
|
||||||
# type ∈ postgresql·mysql·mariadb·mongodb·redis·minio·rabbitmq·solr·oracle, plan ∈ small·medium·large
|
|
||||||
requires:
|
|
||||||
- { name: appdb, type: postgresql, plan: small }
|
|
||||||
|
|
||||||
# 워크로드(앱 컨테이너). CI 는 각 workload.build 를 도커 빌드해 image 로 push 한다(${TAG}=릴리스 태그).
|
|
||||||
workloads:
|
|
||||||
- name: web
|
|
||||||
build: ./ # 도커 빌드 컨텍스트(Dockerfile 위치). 예: ./ 또는 ./api
|
|
||||||
image: gitea.yakenator.io/CHANGE_ME_GITEA_USER/my-app-web:${TAG}
|
|
||||||
port: 8080
|
|
||||||
replicas: 2 # ≥2 → 무중단 롤링(readiness 게이트)
|
|
||||||
health: /healthz
|
|
||||||
resources: { cpu: 25m, mem: 96Mi }
|
|
||||||
# host 지정 시 등록 도메인으로 노출(미지정=클러스터 기본 도메인). 여러 개는 hosts: [a, b]
|
|
||||||
expose: { path: /, rewrite: false }
|
|
||||||
binds:
|
|
||||||
# source=위 requires 이름, alias=env 프리픽스(대문자화). 예 alias=db → DB_URL/DB_HOST/DB_PORT/…
|
|
||||||
- { alias: db, source: appdb }
|
|
||||||
Reference in New Issue
Block a user