From 859e5058b87feb3cf17cccf1215ce0d61a01e3ee Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Thu, 27 Aug 2026 16:44:01 +0900 Subject: [PATCH] =?UTF-8?q?yakcloud-starter:=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98(=EC=8A=A4=ED=83=80=ED=84=B0=20=EC=95=B1+CI+?= =?UTF-8?q?=EB=A7=A4=EB=8B=88=ED=8E=98=EC=8A=A4=ED=8A=B8)=20+=20Claude=20C?= =?UTF-8?q?ode=20=EC=8A=A4=ED=82=AC=20yakcloud-deploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .claude/skills/yakcloud-deploy/SKILL.md | 92 ++++++++++ .../assets/reference/DATA-SOURCES.md | 53 ++++++ .../assets/scripts/yakcloud_deploy.py | 168 ++++++++++++++++++ .../yakcloud-deploy/assets/starter/Dockerfile | 7 + .../yakcloud-deploy/assets/starter/README.md | 29 +++ .../yakcloud-deploy/assets/starter/app.py | 55 ++++++ .../assets/workflows/deploy.yml | 58 ++++++ .../yakcloud-deploy/assets/yakcloud.yaml | 30 ++++ .gitea/workflows/deploy.yml | 58 ++++++ .gitignore | 5 + DATA-SOURCES.md | 53 ++++++ Dockerfile | 7 + README.md | 41 +++++ app.py | 55 ++++++ scripts/yakcloud_deploy.py | 168 ++++++++++++++++++ yakcloud.yaml | 24 +++ 16 files changed, 903 insertions(+) create mode 100644 .claude/skills/yakcloud-deploy/SKILL.md create mode 100644 .claude/skills/yakcloud-deploy/assets/reference/DATA-SOURCES.md create mode 100644 .claude/skills/yakcloud-deploy/assets/scripts/yakcloud_deploy.py create mode 100644 .claude/skills/yakcloud-deploy/assets/starter/Dockerfile create mode 100644 .claude/skills/yakcloud-deploy/assets/starter/README.md create mode 100644 .claude/skills/yakcloud-deploy/assets/starter/app.py create mode 100644 .claude/skills/yakcloud-deploy/assets/workflows/deploy.yml create mode 100644 .claude/skills/yakcloud-deploy/assets/yakcloud.yaml create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 DATA-SOURCES.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 scripts/yakcloud_deploy.py create mode 100644 yakcloud.yaml diff --git a/.claude/skills/yakcloud-deploy/SKILL.md b/.claude/skills/yakcloud-deploy/SKILL.md new file mode 100644 index 0000000..7c69bbe --- /dev/null +++ b/.claude/skills/yakcloud-deploy/SKILL.md @@ -0,0 +1,92 @@ +--- +name: yakcloud-deploy +description: Scaffold and wire up YakCloud declarative CI/CD for a project — Gitea Actions (tag push) → build/push images → reconcile data sources → deploy workloads → bind → tenant cluster. Use when the user wants to deploy this project to YakCloud, set up the Gitea Actions workflow, create the yakcloud.yaml manifest, bind data sources, add a custom domain, or start a brand-new project from a minimal starter. +--- + +# YakCloud 배포 스킬 (선언적 CI/CD) + +VS Code로 개발 → 커밋 → **`v*` 태그 push** 하면, Gitea Actions가 이미지를 빌드/푸시하고 +YakCloud 콘솔 API로 **데이터소스 리컨실 + 워크로드 배포 + 바인딩**을 자동 처리한다. + +이 스킬은 **아무 프로젝트에나 이 파이프라인을 심는다.** 번들된 자산(`assets/`)을 프로젝트에 복사하고 +매니페스트를 채운 뒤, 1회 시크릿만 설정하면 끝. 빈 프로젝트면 프레임워크 중립 **스타터**로 바로 시작. + +## 언제 쓰나 +- "이 프로젝트를 YakCloud에 배포/자동화" / "Gitea CI/CD 붙여줘" / "yakcloud.yaml 만들어줘" +- "새 프로젝트 시작해서 배포까지" (→ `assets/starter/` 로 스캐폴딩) +- "데이터소스(Postgres/Mongo/…) 연결/바인딩" / "커스텀 도메인으로 노출" + +## 에이전트 실행 절차 + +### 0) 상황 파악 +- git 레포인지, 앱 코드가 이미 있는지 확인. **빈/신규**면 §1-A(스타터), **기존 앱**이면 §1-B. +- Gitea 원격이 있는지(`git remote -v`) 확인. 없으면 사용자에게 Gitea 레포 URL을 물어 추가하도록 안내. + +### 1-A) 신규 — 스타터로 스캐폴딩 +1. `assets/starter/`(app.py·Dockerfile·README) 를 프로젝트 루트에 복사. (프레임워크 무의존 최소 HTTP 서버: `PORT` 리슨, `/healthz` 200, `/` 는 바인딩된 소스의 `_URL` 키만 표시) +2. 아래 §2 공통 파일 복사 + §3 매니페스트 작성. + +### 1-B) 기존 앱에 부착 +1. 앱이 **`PORT` env로 리슨 + `/healthz`(또는 health 경로) 제공**하는지 확인. 없으면 추가 제안. +2. 앱이 데이터소스 접속정보를 **하드코딩하지 말고 주입 env**(`_URL` 등)로 읽도록 수정 제안. 키 규약: `reference/DATA-SOURCES.md`. +3. 각 배포 단위마다 `Dockerfile` 필요(없으면 언어에 맞게 생성). 베이스 이미지는 Gitea 미러(`gitea.yakenator.io/yakenator/{node,python,...}`) 권장. + +### 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`) 채우기 (핵심) +사용자와 함께 값을 정한다: +- `project`: 프로젝트 이름(이미지 경로에 사용). +- `cluster`: 배포 대상 클러스터 **이름 또는 콘솔 id** (콘솔 대시보드에서 확인). 여러 개면 여기서 특정. +- `requires`: 필요한 데이터 소스 목록 `{ name, type, plan }`. 없으면 `[]`. + - `type` ∈ `postgresql·mysql·mariadb·mongodb·redis·minio·rabbitmq·solr·oracle`, `plan` ∈ `small·medium·large`. + - 이 이름의 소스가 클러스터에 이미 READY면 스킵, 없으면 자동 프로비저닝. +- `workloads[]`: + - `build`: 도커 빌드 컨텍스트 경로(예 `./` 또는 `./api`). **CI가 이 컨텍스트를 빌드해 `image`로 push.** + - `image`: `gitea.yakenator.io//-:${TAG}` (`${TAG}`=릴리스 태그로 치환). + - `port`·`health`·`replicas`(≥2 권장 → 무중단 롤링)·`resources{cpu,mem}`. + - `expose`: `{ path, rewrite, host? }` — `host` 지정 시 등록 도메인으로 노출(미지정=클러스터 기본 도메인). 여러 도메인은 `hosts: [a, b]`. + - `binds[]`: `{ alias, source }` — `source`는 위 `requires` 이름, `alias`는 env 프리픽스(대문자화). 예 `alias: db` → `DB_URL/DB_HOST/…` 주입. + +### 4) 1회 준비 (사용자에게 안내 — 에이전트가 대신 못 함) +아래를 **사용자가** 설정하도록 명확히 출력: +1. **배포 토큰(PAT)**: 콘솔 → **설정 → 배포 토큰** → 발급(`yakd_…`, 한 번만 표시). 계정 스코프(어느 클러스터든). +2. **Gitea 토큰**: 스코프 `read:repository` + `write:package` + `read:package`. (레포 read 없으면 CI `git clone` 403) +3. **레포 시크릿**(Gitea: Settings → Actions → Secrets): + | 이름 | 값 | + |---|---| + | `YAKCLOUD_URL` | `https://console.yakenator.io` | + | `YAKCLOUD_TOKEN` | 1의 배포 토큰(`yakd_…`) | + | `YAKCLOUD_CLUSTER` | 대상 클러스터 이름/ id (생략 시 매니페스트 `cluster:`) | + | `REGISTRY_TOKEN` | 2의 Gitea 토큰 | +4. **러너**: 전용 러너(라벨 `ci-polyglot`)가 이미 공유로 떠 있으면 레포 **Actions만 켜면** 됨. 없으면 GUIDE의 러너 등록 참고. + +### 5) 배포 +```sh +git add -A && git commit -m "yakcloud: CI/CD 스캐폴딩" +git push origin main +git tag v0.1.0 && git push origin v0.1.0 # v* 태그만 배포 트리거(일반 커밋은 배포 안 함) +``` +→ Gitea **Actions** 탭에서 실행 확인. 성공 시 콘솔 **앱 탭 / 데이터 소스 / 도메인**에 반영. +로컬 사전 점검: `YAKCLOUD_URL=… YAKCLOUD_TOKEN=… python3 scripts/yakcloud_deploy.py yakcloud.yaml --dry-run` + +### 6) 커스텀 도메인(선택) +콘솔 **설정 → 도메인 → 도메인 추가**로 등록(관리형 도메인은 즉시 Active). 매니페스트 `expose.host`에 지정하거나 +콘솔 앱 편집 폼에서 선택. 여러 도메인은 `expose.hosts: [a.example.com, b.example.com]`. + +## 동작/규약 요점 +- **트리거**: `v*` 태그 push만. 일반 커밋은 배포 안 함. +- **멱등/무중단**: 기존 배포는 이미지 PATCH(→ kubectl apply 롤링). replicas≥2 + health면 무중단. +- **자격**: 데이터소스 비밀번호/키는 인클러스터 Secret으로 주입되고 브라우저/포털에 노출되지 않음. 앱은 `_URL` 등으로 접속. + +## 트러블슈팅(실제 겪은 것) +| 증상 | 원인 → 해결 | +|---|---| +| CI `git clone … 403` | REGISTRY_TOKEN에 레포 read 없음 → `read:repository` 추가 | +| `docker create` 실패(잡 시작 안 됨) | 러너 config에서 docker.sock **중복 마운트** → act 기본 마운트만(수동 제거) | +| 배포 중 `429 RATE_LIMITED` | CLI가 `retryAfterSec` 백오프로 재시도(내장) | +| 다른 러너가 잡 가져감 | 라벨 충돌 → 전용 라벨(`runs-on`) 사용 | diff --git a/.claude/skills/yakcloud-deploy/assets/reference/DATA-SOURCES.md b/.claude/skills/yakcloud-deploy/assets/reference/DATA-SOURCES.md new file mode 100644 index 0000000..a0a773d --- /dev/null +++ b/.claude/skills/yakcloud-deploy/assets/reference/DATA-SOURCES.md @@ -0,0 +1,53 @@ +# 지원 데이터 소스 & 바인딩 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`) +모든 타입 공통: **`_HOST`**, **`_PORT`**. + +| type | 추가 env 키 | `_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 } }); +``` diff --git a/.claude/skills/yakcloud-deploy/assets/scripts/yakcloud_deploy.py b/.claude/skills/yakcloud-deploy/assets/scripts/yakcloud_deploy.py new file mode 100644 index 0000000..f7d4406 --- /dev/null +++ b/.claude/skills/yakcloud-deploy/assets/scripts/yakcloud_deploy.py @@ -0,0 +1,168 @@ +#!/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) + 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() diff --git a/.claude/skills/yakcloud-deploy/assets/starter/Dockerfile b/.claude/skills/yakcloud-deploy/assets/starter/Dockerfile new file mode 100644 index 0000000..4130cb4 --- /dev/null +++ b/.claude/skills/yakcloud-deploy/assets/starter/Dockerfile @@ -0,0 +1,7 @@ +# 프레임워크 중립 스타터 — 의존성 없음(표준 라이브러리). 베이스는 Gitea 미러 이미지. +FROM gitea.yakenator.io/yakenator/python:3.12-slim +WORKDIR /app +COPY app.py . +ENV PORT=8080 +EXPOSE 8080 +CMD ["python", "app.py"] diff --git a/.claude/skills/yakcloud-deploy/assets/starter/README.md b/.claude/skills/yakcloud-deploy/assets/starter/README.md new file mode 100644 index 0000000..d39a6e3 --- /dev/null +++ b/.claude/skills/yakcloud-deploy/assets/starter/README.md @@ -0,0 +1,29 @@ +# yakcloud 스타터 (프레임워크 중립) + +의존성 0의 최소 HTTP 서버. **배포 계약**만 지키면 어떤 스택으로 바꿔도 된다: +- `PORT` 환경변수로 리슨(기본 8080) +- `GET /healthz` → 200 (매니페스트 `health` 경로) +- (선택) 바인딩된 소스는 `_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//-web:${TAG} + port: 8080 + health: /healthz + binds: + - { alias: db, source: appdb } # → 앱에서 os.environ["DB_URL"] 로 접속 +``` + +## 다른 스택으로 교체 +`app.py`(+`Dockerfile`)를 원하는 언어/프레임워크로 교체하되 위 3계약 유지. +소스별 주입 env 전체 목록은 `../reference/DATA-SOURCES.md` 참고. diff --git a/.claude/skills/yakcloud-deploy/assets/starter/app.py b/.claude/skills/yakcloud-deploy/assets/starter/app.py new file mode 100644 index 0000000..532328c --- /dev/null +++ b/.claude/skills/yakcloud-deploy/assets/starter/app.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""프레임워크 중립 최소 스타터 — Python 표준 라이브러리만(의존성 0). + +배포 계약만 보여준다: + · PORT 환경변수로 리슨(기본 8080) + · GET /healthz → 200 {"ok": true} (매니페스트 health 경로) + · GET / → 앱 정보 + 바인딩된 소스의 _URL '키'만(값은 노출 안 함) +새 프로젝트는 이 파일을 원하는 스택으로 교체하되, 위 3가지 계약만 지키면 된다. +""" +import http.server +import json +import os +import socketserver + +PORT = int(os.environ.get("PORT", "8080")) + + +def bound_source_keys() -> list[str]: + """바인딩된 데이터 소스가 주입한 _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(), # 연결된 소스 _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() diff --git a/.claude/skills/yakcloud-deploy/assets/workflows/deploy.yml b/.claude/skills/yakcloud-deploy/assets/workflows/deploy.yml new file mode 100644 index 0000000..678ccc3 --- /dev/null +++ b/.claude/skills/yakcloud-deploy/assets/workflows/deploy.yml @@ -0,0 +1,58 @@ +# 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 diff --git a/.claude/skills/yakcloud-deploy/assets/yakcloud.yaml b/.claude/skills/yakcloud-deploy/assets/yakcloud.yaml new file mode 100644 index 0000000..6790a5a --- /dev/null +++ b/.claude/skills/yakcloud-deploy/assets/yakcloud.yaml @@ -0,0 +1,30 @@ +# YakCloud 선언적 배포 매니페스트 (범용 템플릿) +# 태그(v*) push → Gitea Actions → 이 매니페스트로 배포: +# 1) requires 리컨실 — 논리 이름의 소스가 READY 면 스킵, 없으면 type/plan 대로 프로비저닝 후 Ready 대기 +# 2) workloads 빌드/배포 — build 컨텍스트를 image 로 빌드·push, 각 워크로드가 소스를 alias 로 바인딩 +# 3) 앱 탭 등록 + 데이터소스 연결(_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 } diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..678ccc3 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,58 @@ +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80cf3ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +node_modules/ +.env diff --git a/DATA-SOURCES.md b/DATA-SOURCES.md new file mode 100644 index 0000000..a0a773d --- /dev/null +++ b/DATA-SOURCES.md @@ -0,0 +1,53 @@ +# 지원 데이터 소스 & 바인딩 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`) +모든 타입 공통: **`_HOST`**, **`_PORT`**. + +| type | 추가 env 키 | `_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 } }); +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4130cb4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +# 프레임워크 중립 스타터 — 의존성 없음(표준 라이브러리). 베이스는 Gitea 미러 이미지. +FROM gitea.yakenator.io/yakenator/python:3.12-slim +WORKDIR /app +COPY app.py . +ENV PORT=8080 +EXPOSE 8080 +CMD ["python", "app.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..faaa0ca --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# yakcloud-starter — YakCloud 배포 기반(스타터) + Claude Code 스킬 + +새 프로젝트를 **YakCloud 선언적 CI/CD**(Gitea 태그 push → 자동 배포)로 시작하기 위한 **기반 레포**다. +프레임워크 중립 최소 앱 + CI + 매니페스트 + **Claude Code 스킬**(`.claude/skills/yakcloud-deploy/`)이 들어 있어, +클론하면 바로 굴리거나, Claude Code에게 맡겨 원하는 대로 **추가/삭제**하며 확장할 수 있다. + +``` +.claude/skills/yakcloud-deploy/ # Claude Code 스킬(SKILL.md + assets) — 프로젝트에 그대로 따라감 +app.py Dockerfile # 프레임워크 중립 스타터(표준 라이브러리, /healthz + PORT) +yakcloud.yaml # 배포 매니페스트(대상 클러스터·소스·워크로드) +.gitea/workflows/deploy.yml # 태그 push CI(매니페스트 구동 — 수정 불필요) +scripts/yakcloud_deploy.py # 리컨실 배포 CLI +DATA-SOURCES.md # 소스별 주입 env 표 +``` + +## 새 프로젝트 시작 (택1) +**A. 이 레포를 템플릿으로 클론** +```sh +git clone https://gitea.yakenator.io/yakenator/yakcloud-starter.git my-app +cd my-app && rm -rf .git && git init +``` +**B. Claude Code로**: 새 프로젝트에서 `.claude/skills/yakcloud-deploy/` 만 복사해두면, Claude가 스킬을 참고해 + 앱·매니페스트·CI를 상황에 맞게 **추가/삭제**하며 스캐폴딩한다. (예: "yakcloud로 배포 붙여줘") + +## 굴리기 (요약 — 상세는 SKILL.md §4~5) +1. 콘솔 → 설정 → **배포 토큰** 발급(`yakd_…`). +2. Gitea 레포 **Secrets**: `YAKCLOUD_URL`, `YAKCLOUD_TOKEN`, `YAKCLOUD_CLUSTER`, `REGISTRY_TOKEN`. +3. `yakcloud.yaml` 의 `cluster` / `image`(``) / (필요시) `requires`·`binds` 수정. +4. `git tag v0.1.0 && git push origin v0.1.0` → Actions 실행 → 콘솔 앱 탭/도메인 확인. + +로컬 사전 점검: +```sh +YAKCLOUD_URL=https://console.yakenator.io YAKCLOUD_TOKEN=yakd_… \ + python3 scripts/yakcloud_deploy.py yakcloud.yaml --dry-run +``` + +## 확장(추가/삭제) +- **워크로드 추가**: `yakcloud.yaml` `workloads[]` 에 `{name, build, image, port, health, binds}` 추가(+ 그 build 컨텍스트에 Dockerfile). +- **데이터 소스 추가**: `requires[]` 에 `{name,type,plan}` + 워크로드 `binds` 에 `{alias, source}`. 앱은 `_URL` 로 접속. +- **도메인 노출**: 콘솔 설정→도메인 등록 후 `expose.host`(또는 `hosts: [...]`) 지정. +- **삭제**: 해당 워크로드/`requires` 항목 제거 후 태그 push(멱등 리컨실). diff --git a/app.py b/app.py new file mode 100644 index 0000000..532328c --- /dev/null +++ b/app.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""프레임워크 중립 최소 스타터 — Python 표준 라이브러리만(의존성 0). + +배포 계약만 보여준다: + · PORT 환경변수로 리슨(기본 8080) + · GET /healthz → 200 {"ok": true} (매니페스트 health 경로) + · GET / → 앱 정보 + 바인딩된 소스의 _URL '키'만(값은 노출 안 함) +새 프로젝트는 이 파일을 원하는 스택으로 교체하되, 위 3가지 계약만 지키면 된다. +""" +import http.server +import json +import os +import socketserver + +PORT = int(os.environ.get("PORT", "8080")) + + +def bound_source_keys() -> list[str]: + """바인딩된 데이터 소스가 주입한 _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(), # 연결된 소스 _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() diff --git a/scripts/yakcloud_deploy.py b/scripts/yakcloud_deploy.py new file mode 100644 index 0000000..f7d4406 --- /dev/null +++ b/scripts/yakcloud_deploy.py @@ -0,0 +1,168 @@ +#!/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) + 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() diff --git a/yakcloud.yaml b/yakcloud.yaml new file mode 100644 index 0000000..58680da --- /dev/null +++ b/yakcloud.yaml @@ -0,0 +1,24 @@ +# YakCloud 선언적 배포 매니페스트 — 스타터(최소, 데이터소스 없이 단독 배포 가능) +# 태그(v*) push → Gitea Actions → build/push → 리컨실 배포. 자세한 절차: .claude/skills/yakcloud-deploy/SKILL.md +apiVersion: yakcloud/v1 +project: yakcloud-starter + +# 배포 대상 클러스터 — 이름 또는 콘솔 id 로 변경. (YAKCLOUD_CLUSTER 시크릿이 있으면 그게 우선) +cluster: my-service-19 + +# 데이터 소스 필요하면 추가(없으면 빈 목록): +# - { name: appdb, type: postgresql, plan: small } +requires: [] + +workloads: + - name: web + build: ./ # 루트 Dockerfile 을 CI 가 빌드 + image: gitea.yakenator.io/yakenator/yakcloud-starter-web:${TAG} # /-web 로 변경 + port: 8080 + replicas: 2 # ≥2 → 무중단 롤링 + health: /healthz + resources: { cpu: 25m, mem: 64Mi } + expose: { path: /, rewrite: false } # host: <등록도메인> 지정 시 그 도메인으로 노출. 여러 개는 hosts: [a, b] + # 데이터 소스를 쓰면 requires 에 추가하고 아래처럼 바인딩(앱은 DB_URL 등으로 접속): + # binds: + # - { alias: db, source: appdb }