From 7d1c92a4069a15aef72fd07e65d806f5282b82dd Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Tue, 25 Aug 2026 11:14:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(ds-sdk):=20B3=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=E2=80=94=20DTO=C2=B7Zod=C2=B7bind-env=209=EC=A2=85=C2=B7ApiCod?= =?UTF-8?q?e=20(=EB=A6=AC=ED=94=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yakconsole → @yakcloud/ds-sdk 추출(11 모듈): - enums.ts: 상태/타입 리터럴 유니온 SSOT(값 배열 + type) - dto/: cluster·service·deployment·account·domain (비밀필드 제외, publicService 계약 동형) - bind-env.ts: BIND_ENV_VARS 9종(MONGODB..ORACLE) + SERVICE_DEFAULT_PORT + bindEnvPrefix/bindEnvKeys - schemas.ts: zod(생성/검증 body + DTO), api-codes.ts: ApiCode 유니온 + CODE_STATUS - zod ^4.4.3(소스 정합), 리프 규칙 준수(react/prisma/next/서버 import 0) 검증: 적대적 Verify=SHIP, bind-env 9종 Python _bind_env_for parity 완전 일치, 누출 0, tsc --noEmit 통과(strict+noUncheckedIndexedAccess+verbatimModuleSyntax). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/backup-core/package.json | 2 +- packages/bff-kit/package.json | 2 +- packages/ds-sdk/package.json | 2 +- packages/ds-sdk/src/api-codes.ts | 20 +++ packages/ds-sdk/src/bind-env.ts | 129 ++++++++++++++ packages/ds-sdk/src/dto/account.ts | 125 +++++++++++++ packages/ds-sdk/src/dto/cluster.ts | 51 ++++++ packages/ds-sdk/src/dto/deployment.ts | 36 ++++ packages/ds-sdk/src/dto/domain.ts | 13 ++ packages/ds-sdk/src/dto/index.ts | 18 ++ packages/ds-sdk/src/dto/service.ts | 31 ++++ packages/ds-sdk/src/enums.ts | 86 +++++++++ packages/ds-sdk/src/index.ts | 49 ++++- packages/ds-sdk/src/schemas.ts | 246 ++++++++++++++++++++++++++ 14 files changed, 803 insertions(+), 7 deletions(-) create mode 100644 packages/ds-sdk/src/api-codes.ts create mode 100644 packages/ds-sdk/src/bind-env.ts create mode 100644 packages/ds-sdk/src/dto/account.ts create mode 100644 packages/ds-sdk/src/dto/cluster.ts create mode 100644 packages/ds-sdk/src/dto/deployment.ts create mode 100644 packages/ds-sdk/src/dto/domain.ts create mode 100644 packages/ds-sdk/src/dto/index.ts create mode 100644 packages/ds-sdk/src/dto/service.ts create mode 100644 packages/ds-sdk/src/enums.ts create mode 100644 packages/ds-sdk/src/schemas.ts diff --git a/packages/backup-core/package.json b/packages/backup-core/package.json index 28daaf4..bae1c51 100644 --- a/packages/backup-core/package.json +++ b/packages/backup-core/package.json @@ -25,6 +25,6 @@ "test": "echo \"(no tests yet)\"" }, "dependencies": { - "zod": "^3.23.0" + "zod": "^4.4.3" } } diff --git a/packages/bff-kit/package.json b/packages/bff-kit/package.json index 6219f99..895dfe5 100644 --- a/packages/bff-kit/package.json +++ b/packages/bff-kit/package.json @@ -25,7 +25,7 @@ "test": "echo \"(no tests yet)\"" }, "dependencies": { - "zod": "^3.23.0", + "zod": "^4.4.3", "@yakcloud/ds-sdk": "workspace:*", "@yakcloud/auth-adapter": "workspace:*" }, diff --git a/packages/ds-sdk/package.json b/packages/ds-sdk/package.json index 8148635..b9eb04d 100644 --- a/packages/ds-sdk/package.json +++ b/packages/ds-sdk/package.json @@ -25,6 +25,6 @@ "test": "echo \"(no tests yet)\"" }, "dependencies": { - "zod": "^3.23.0" + "zod": "^4.4.3" } } diff --git a/packages/ds-sdk/src/api-codes.ts b/packages/ds-sdk/src/api-codes.ts new file mode 100644 index 0000000..84826fb --- /dev/null +++ b/packages/ds-sdk/src/api-codes.ts @@ -0,0 +1,20 @@ +// ApiCode 리터럴 유니온 + CODE_STATUS 맵(코드→HTTP 상태). yakconsole api/respond.ts L4-19 하강. +// 순수 계약만 — DEFAULT_MSG(한국어 문구)·ApiError 클래스·ok/fail/handle/parseBody(서버 전용)는 제외. + +// ── error codes → HTTP status (design.md §4.1) ── +export const CODE_STATUS = { + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + VALIDATION_FAILED: 422, + NAME_TAKEN: 409, + CONFLICT: 409, + NAME_RESERVED: 422, + QUOTA_EXCEEDED: 403, + CAPACITY_UNAVAILABLE: 503, + RANCHER_UPSTREAM_ERROR: 502, + RATE_LIMITED: 429, + NOT_IMPLEMENTED: 501, + INTERNAL: 500, +} as const; +export type ApiCode = keyof typeof CODE_STATUS; diff --git a/packages/ds-sdk/src/bind-env.ts b/packages/ds-sdk/src/bind-env.ts new file mode 100644 index 0000000..a28190a --- /dev/null +++ b/packages/ds-sdk/src/bind-env.ts @@ -0,0 +1,129 @@ +// bind-env 9종 계약 (BIND_ENV_VARS) — 앱 컨테이너에 주입되는 _* 환경변수 명세. +// 백엔드 _bind_env_for(infra/api/yakcloud_api.py) 미러. 실제 URL 조립의 진실은 서버이며 +// 여기는 접미사 집합 + 문서/UI 힌트(desc/example)만 노출한다(값 조립은 리프 패키지 대상 아님). +// +// PREFIX 도출: p = alias.upper().replace('-','_'). 아래 헬퍼 bindEnvKeys(alias,type) 가 동일 규칙으로 +// 실제 env 키 배열을 만든다. 소스를 연결하면 이 키들이 앱 파드 env 로 주입되어 +// 코드에서 process.env._URL 등으로 접속한다. +import type { ServiceTypeDTO } from "./enums"; + +export type BindEnvVar = { suffix: string; desc: string; example: string }; + +export const BIND_ENV_VARS: Record = { + // mongodb://{quote(user)}:{quote(pw)}@{host}:{port}/{db}?authSource={db} — 기본포트 27017 + MONGODB: [ + { suffix: "URL", desc: "연결 문자열", example: "mongodb://user:pass@host:27017/appdb?authSource=appdb" }, + { suffix: "HOST", desc: "호스트", example: "mongo.yakcloud-services.svc.cluster.local" }, + { suffix: "PORT", desc: "포트", example: "27017" }, + { suffix: "USERNAME", desc: "사용자", example: "app" }, + { suffix: "PASSWORD", desc: "비밀번호", example: "(자동 발급)" }, + { suffix: "DB", desc: "데이터베이스", example: "appdb" }, + ], + // redis://:{quote(pw)}@{host}:{port}/{db} — USERNAME 은 항상 ""(requirepass 만) · 기본포트 6379 + REDIS: [ + { suffix: "URL", desc: "연결 문자열", example: "redis://:pass@host:6379/0" }, + { suffix: "HOST", desc: "호스트", example: "redis.yakcloud-services.svc.cluster.local" }, + { suffix: "PORT", desc: "포트", example: "6379" }, + { suffix: "PASSWORD", desc: "비밀번호(requirepass)", example: "(자동 발급)" }, + { suffix: "DB", desc: "DB 번호", example: "0" }, + { suffix: "USERNAME", desc: "사용자(Redis 는 빈값)", example: "" }, + ], + // {scheme}://{netloc} — USE_SSL(_YC_SSL||443), 표준포트 생략, REGION 하드코드 us-east-1 + // USERNAME==ACCESS_KEY, PASSWORD==SECRET_KEY, URL==ENDPOINT · 기본포트 9000 + MINIO: [ + { suffix: "ENDPOINT", desc: "S3 엔드포인트", example: "https://s3.yakenator.io" }, + { suffix: "URL", desc: "엔드포인트(ENDPOINT 와 동일)", example: "https://s3.yakenator.io" }, + { suffix: "ACCESS_KEY", desc: "액세스 키", example: "(자동 발급)" }, + { suffix: "SECRET_KEY", desc: "시크릿 키", example: "(자동 발급)" }, + { suffix: "USERNAME", desc: "액세스 키 별칭", example: "(ACCESS_KEY 와 동일)" }, + { suffix: "PASSWORD", desc: "시크릿 키 별칭", example: "(SECRET_KEY 와 동일)" }, + { suffix: "BUCKET", desc: "버킷", example: "app-bucket" }, + { suffix: "REGION", desc: "리전", example: "us-east-1" }, + { suffix: "USE_SSL", desc: "TLS 사용 여부", example: "true" }, + { suffix: "HOST", desc: "호스트", example: "s3.yakenator.io" }, + { suffix: "PORT", desc: "포트", example: "443" }, + ], + // mysql://{quote(user)}:{quote(pw)}@{host}:{port}/{db} — 기본포트 3306 + MYSQL: [ + { suffix: "URL", desc: "연결 문자열", example: "mysql://user:pass@host:3306/appdb" }, + { suffix: "HOST", desc: "호스트", example: "mysql.yakcloud-services.svc.cluster.local" }, + { suffix: "PORT", desc: "포트", example: "3306" }, + { suffix: "USERNAME", desc: "사용자", example: "app" }, + { suffix: "PASSWORD", desc: "비밀번호", example: "(자동 발급)" }, + { suffix: "DB", desc: "데이터베이스", example: "appdb" }, + ], + // postgresql://{quote(user)}:{quote(pw)}@{host}:{port}/{db} — 기본포트 5432 + POSTGRESQL: [ + { suffix: "URL", desc: "연결 문자열", example: "postgresql://user:pass@host:5432/appdb" }, + { suffix: "HOST", desc: "호스트", example: "postgres.yakcloud-services.svc.cluster.local" }, + { suffix: "PORT", desc: "포트", example: "5432" }, + { suffix: "USERNAME", desc: "사용자", example: "app" }, + { suffix: "PASSWORD", desc: "비밀번호", example: "(자동 발급)" }, + { suffix: "DB", desc: "데이터베이스", example: "appdb" }, + ], + // mysql://… (MySQL 와이어 호환이라 postgresql 아님) — 기본포트 3306 + MARIADB: [ + { suffix: "URL", desc: "연결 문자열(MySQL 와이어 호환)", example: "mysql://user:pass@host:3306/appdb" }, + { suffix: "HOST", desc: "호스트", example: "mariadb.yakcloud-services.svc.cluster.local" }, + { suffix: "PORT", desc: "포트", example: "3306" }, + { suffix: "USERNAME", desc: "사용자", example: "app" }, + { suffix: "PASSWORD", desc: "비밀번호", example: "(자동 발급)" }, + { suffix: "DB", desc: "데이터베이스", example: "appdb" }, + ], + // amqp://{quote(user)}:{quote(pw)}@{host}:{port}/{quote(vhost)} — MGMT_URL=http://{host}:15672 · 기본포트 5672 + RABBITMQ: [ + { suffix: "URL", desc: "AMQP 연결 문자열", example: "amqp://user:pass@host:5672/vhost" }, + { suffix: "HOST", desc: "호스트", example: "rabbitmq.yakcloud-services.svc.cluster.local" }, + { suffix: "PORT", desc: "포트", example: "5672" }, + { suffix: "USERNAME", desc: "사용자", example: "app" }, + { suffix: "PASSWORD", desc: "비밀번호", example: "(자동 발급)" }, + { suffix: "VHOST", desc: "가상 호스트", example: "/" }, + { suffix: "MGMT_URL", desc: "관리 콘솔 URL", example: "http://host:15672" }, + ], + // URL={endpoint}/solr/{core}; ENDPOINT=http://{host}:{port} — 기본 무인증(USER/PASS="") · 기본포트 8983 + SOLR: [ + { suffix: "URL", desc: "코어 URL", example: "http://host:8983/solr/appcore" }, + { suffix: "ENDPOINT", desc: "엔드포인트", example: "http://host:8983" }, + { suffix: "HOST", desc: "호스트", example: "solr.yakcloud-services.svc.cluster.local" }, + { suffix: "PORT", desc: "포트", example: "8983" }, + { suffix: "CORE", desc: "코어", example: "appcore" }, + { suffix: "USERNAME", desc: "사용자(기본 무인증)", example: "" }, + { suffix: "PASSWORD", desc: "비밀번호(기본 무인증)", example: "" }, + ], + // URL=oracle://…; JDBC_URL=jdbc:oracle:thin:@//{host}:{port}/{service}; DSN={host}:{port}/{service} + // JDBC/DSN 은 자격증명 미포함 · 기본포트 1521 + ORACLE: [ + { suffix: "URL", desc: "연결 문자열", example: "oracle://user:pass@host:1521/service" }, + { suffix: "JDBC_URL", desc: "JDBC(thin) URL", example: "jdbc:oracle:thin:@//host:1521/service" }, + { suffix: "DSN", desc: "간단 DSN", example: "host:1521/service" }, + { suffix: "HOST", desc: "호스트", example: "oracle.yakcloud-services.svc.cluster.local" }, + { suffix: "PORT", desc: "포트", example: "1521" }, + { suffix: "USERNAME", desc: "사용자", example: "app" }, + { suffix: "PASSWORD", desc: "비밀번호", example: "(자동 발급)" }, + { suffix: "SERVICE", desc: "서비스명", example: "service" }, + ], +}; + +// 각 서비스 타입의 기본 포트 — 백엔드 _SVC_PORT 미러(실제 port 는 conn.port/_YC_PORT). +export const SERVICE_DEFAULT_PORT: Record = { + MONGODB: 27017, + REDIS: 6379, + MINIO: 9000, + MYSQL: 3306, + POSTGRESQL: 5432, + MARIADB: 3306, + RABBITMQ: 5672, + SOLR: 8983, + ORACLE: 1521, +}; + +// alias → PREFIX 도출(백엔드와 동일 규칙): 대문자화 후 '-' → '_'. +export function bindEnvPrefix(alias: string): string { + return alias.toUpperCase().replace(/-/g, "_"); +} + +// alias + type → 실제 주입되는 env 키 목록(_). +export function bindEnvKeys(alias: string, type: ServiceTypeDTO): string[] { + const prefix = bindEnvPrefix(alias); + return BIND_ENV_VARS[type].map((v) => `${prefix}_${v.suffix}`); +} diff --git a/packages/ds-sdk/src/dto/account.ts b/packages/ds-sdk/src/dto/account.ts new file mode 100644 index 0000000..8e4a7de --- /dev/null +++ b/packages/ds-sdk/src/dto/account.ts @@ -0,0 +1,125 @@ +// 사용자/쿼터/사용량 DTO. 비밀 없음. 관리자 조인 확장 DTO 포함. +import type { ClusterStatus, QuotaField, QuotaRequestStatusDTO, UserRole, UserStatus } from "../enums"; + +export interface UserDTO { + id: string; + email: string; + displayName: string; + role: UserRole; + status: UserStatus; + locale: string; + emailOptIn: boolean; + plan: string; // 현재 활성 플랜 코드(free/standard/…) + requestedPlan: string | null; // 승인 대기 중인 상향 플랜 + createdAt: string; + updatedAt: string; +} + +export interface QuotaDTO { + maxClusters: number; + maxNodesPerCluster: number; + maxDomains: number; + maxServices: number; +} + +export interface MeDTO { + user: UserDTO; + quota: QuotaDTO | null; + usage: { clusters: number; nodes: number; domains: number }; +} + +export interface QuotaRequestDTO { + id: string; + userId: string; + field: QuotaField; + currentValue: number; + requestedValue: number; + reason: string | null; + status: QuotaRequestStatusDTO; + decidedById: string | null; + decidedAt: string | null; + createdAt: string; +} + +export interface QuotaRequestAdminDTO extends QuotaRequestDTO { + user: { email: string; displayName: string }; +} + +export interface PlanRequestAdminDTO { + userId: string; + email: string; + displayName: string; + currentPlan: string; + requestedPlan: string; +} + +// GET /usage/summary — 계정 사용량 (카운트 + 할당 리소스 + 쿼터 + 클러스터별 분해) +export interface UsageClusterDTO { + id: string; + name: string; + status: ClusterStatus; + nodes: number; + vcpu: number; + memGb: number; + diskGb: number; +} + +// 대시보드 추세 스냅샷 1점 — 예약(allocated) + 실측(used) 시계열. +export interface UsageHistoryPoint { + ts: string; + clusters: number; + nodes: number; + domains: number; + vcpu: number; + memGb: number; + diskGb: number; + usedVcpu: number | null; + usedMemGb: number | null; + usedDiskGb: number | null; +} + +export interface UsageSummaryDTO { + counts: { clusters: number; nodes: number; domains: number }; + allocated: { vcpu: number; memGb: number; diskGb: number }; + used: { vcpu: number; memGb: number; diskGb: number } | null; + quota: QuotaDTO | null; + clusters: UsageClusterDTO[]; + history: UsageHistoryPoint[]; +} + +// GET /clusters/:id/usage — 노드 상태/롤 + 실측 CPU/메모리 +export interface NodeMetricDTO { + name: string; + roles: string[]; + ready: boolean; + joined: boolean; // K8s 클러스터에 조인 완료 여부 (false=프로비저닝 중) + maas_status: string | null; // MAAS 상태 (Commissioning/Deploying/Deployed…) + phase: string; // 사용자용 단계 문구 (커미셔닝 중·OS 설치·클러스터 조인 중·Ready…) + cpu: string | null; // "669m" + cpu_pct: string | null; // "16" + mem: string | null; // "3378Mi" + mem_pct: string | null; // "42" + disk: string | null; // "16.3Gi" + disk_pct: string | null; // "45" + cpu_cap?: number | null; // 총 코어 수(스펙) + mem_cap?: number | null; // 총 메모리 GiB(스펙) + disk_cap?: number | null; // 총 디스크 GiB(스펙) + cpu_req?: number | null; // 예약(requests) 합 — 코어 + cpu_req_pct?: number | null; // 예약 ÷ allocatable (%) + mem_req?: number | null; // 예약(requests) 합 — GiB + mem_req_pct?: number | null; // 예약 ÷ allocatable (%) + cpu_alloc?: number | null; // allocatable 코어(예약 분모) + mem_alloc?: number | null; // allocatable GiB(예약 분모) + pending?: boolean; // 예약 슬롯(아직 MAAS 레코드 없음) — "노드 준비중" 플레이스홀더 +} + +export interface ClusterUsageDTO { + available: boolean; // 컨트롤플레인 조회 성공 여부 + metricsAvailable: boolean; // kubectl top(metrics API) 사용 가능 여부 + nodes: NodeMetricDTO[]; + // 오토스케일러가 있으면 노드 수를 그것이 소유 → 수동 조정 비활성. + autoscale: { enabled: boolean; min: number | null; max: number | null }; + provisioning: boolean; // 스케일링 진행 중(미조인 노드 존재) → 설정 변경 잠금 + // 사용량 추이 서버 롤링 버퍼(총사용%) — 새로고침 시 차트 시드용(비면 클라 누적만). + history?: { t: number; cpu: number | null; mem: number | null; disk: number | null }[]; +} diff --git a/packages/ds-sdk/src/dto/cluster.ts b/packages/ds-sdk/src/dto/cluster.ts new file mode 100644 index 0000000..f5735d0 --- /dev/null +++ b/packages/ds-sdk/src/dto/cluster.ts @@ -0,0 +1,51 @@ +// 클러스터 관련 DTO — /api/v1 JSON 응답 형태(DateTime 은 ISO 문자열). 비밀 없음. +// yakconsole types.ts L133-177 미러. metricsJson 은 문자열 페이로드로 유지. +import type { ClusterStatus } from "../enums"; + +export interface ClusterDTO { + id: string; + ownerId: string; + name: string; + displayName: string; + templateCode: string; + desiredNodes: number; + rancherClusterId: string | null; + status: ClusterStatus; + statusMessage: string | null; + defaultHostname: string; + ingressVip: string | null; + // 마지막 적용 오토스케일 범위(설정 다이얼로그 초기값 — metrics 실패 시 폴백) + autoscaleMin: number | null; + autoscaleMax: number | null; + byAdmin: boolean; // 운영자 프로비저닝(쿼터 제외) — 목록 배지 표시용 + createdAt: string; + updatedAt: string; + deletedAt: string | null; + // 마지막 노드 사용 스냅샷(history 포함) — 목록 행 미니 그래프용 + metricsJson: string | null; +} + +export interface ClusterEventDTO { + id: string; + clusterId: string; + phase: string; + message: string; + level: string; + createdAt: string; +} + +export type ClusterDetailDTO = ClusterDTO & { events: ClusterEventDTO[] }; + +export interface TemplateDTO { + code: string; + name: string; + description: string; + vcpuPerNode: number; + memGbPerNode: number; + diskGbPerNode: number; + minNodes: number; + maxNodes: number; + active: boolean; + sortOrder: number; + available: boolean; +} diff --git a/packages/ds-sdk/src/dto/deployment.ts b/packages/ds-sdk/src/dto/deployment.ts new file mode 100644 index 0000000..346d46c --- /dev/null +++ b/packages/ds-sdk/src/dto/deployment.ts @@ -0,0 +1,36 @@ +// 배포/환경변수 DTO. EnvVarDTO.value 는 secret 이면 서버가 마스킹한 값(빈 문자열)을 +// 담는 계약 — SDK 는 형태만 정의. generateManifest 는 포털 소관(여기 미포함). +import type { DeploymentStatus } from "../enums"; + +export interface EnvVarDTO { + key: string; + value: string; // secret 이면 마스킹(빈 문자열) + secret: boolean; +} + +export interface DeploymentDTO { + id: string; + clusterId: string; + name: string; + gitRepo: string | null; // 빌드 배포(Phase 3). image 배포 시 null + gitBranch: string; + gitCommit: string | null; + image: string | null; // 컨테이너 이미지 (Phase 1 배포원) + status: DeploymentStatus; + statusMessage: string | null; + // K8s 스펙 + env: EnvVarDTO[]; + port: number | null; + cpuRequest: string; + memRequest: string; + healthPath: string | null; + exposeHost: string | null; + path: string; + pathType: string; + rewritePrefix: boolean; + replicasDesired: number; + replicasReady: number; + lastDeployedAt: string | null; + createdAt: string; + updatedAt: string; +} diff --git a/packages/ds-sdk/src/dto/domain.ts b/packages/ds-sdk/src/dto/domain.ts new file mode 100644 index 0000000..b3510da --- /dev/null +++ b/packages/ds-sdk/src/dto/domain.ts @@ -0,0 +1,13 @@ +// 도메인 DTO — fqdn·status·certStatus·검증 타임스탬프. +import type { CertStatus, DomainStatus } from "../enums"; + +export interface DomainDTO { + id: string; + clusterId: string; + fqdn: string; + status: DomainStatus; + certStatus: CertStatus; + verifyExpiresAt: string; + verifiedAt: string | null; + createdAt: string; +} diff --git a/packages/ds-sdk/src/dto/index.ts b/packages/ds-sdk/src/dto/index.ts new file mode 100644 index 0000000..3fe691a --- /dev/null +++ b/packages/ds-sdk/src/dto/index.ts @@ -0,0 +1,18 @@ +// dto/* 배럴 재수출. +export type { ClusterDTO, ClusterEventDTO, ClusterDetailDTO, TemplateDTO } from "./cluster"; +export type { ServiceDTO, BindingDTO } from "./service"; +export type { EnvVarDTO, DeploymentDTO } from "./deployment"; +export type { + UserDTO, + QuotaDTO, + MeDTO, + QuotaRequestDTO, + QuotaRequestAdminDTO, + PlanRequestAdminDTO, + UsageClusterDTO, + UsageHistoryPoint, + UsageSummaryDTO, + NodeMetricDTO, + ClusterUsageDTO, +} from "./account"; +export type { DomainDTO } from "./domain"; diff --git a/packages/ds-sdk/src/dto/service.ts b/packages/ds-sdk/src/dto/service.ts new file mode 100644 index 0000000..3baddd0 --- /dev/null +++ b/packages/ds-sdk/src/dto/service.ts @@ -0,0 +1,31 @@ +// 데이터 소스(외부/관리형 서비스) DTO + 바인딩 DTO. +// connInfo 는 host/port/db/bucket 만(비밀 미포함, serialize.publicService 계약과 동형). +// connSecretRef 등 비밀 필드는 절대 미포함. +import type { ServiceStatusDTO, ServiceModeDTO, ServiceTypeDTO } from "../enums"; + +export interface ServiceDTO { + id: string; + ownerId: string; + clusterId: string; + clusterName?: string; // 목록 응답에서 조인 제공 + name: string; + type: ServiceTypeDTO; + mode: ServiceModeDTO; + status: ServiceStatusDTO; + statusMessage: string | null; + size: string; // small | medium | large + storageGb: number; // persist=false 면 0 + persist: boolean; + // 비밀 제외 접속 메타(serialize.publicService 계약). host/port/db/bucket 만. + connInfo: { host?: string; port?: number; db?: number | string; bucket?: string } | null; + bindings?: { app: string; alias: string }[]; // 단일 조회 응답에서 제공 + createdAt: string; + updatedAt: string; +} + +// GET /api/v1/deployments/:id/bindings — 앱에 연결된 서비스(배포 카드 BindPanel) +export interface BindingDTO { + id: string; + alias: string; + service: { id: string; name: string; type: ServiceTypeDTO; status: ServiceStatusDTO }; +} diff --git a/packages/ds-sdk/src/enums.ts b/packages/ds-sdk/src/enums.ts new file mode 100644 index 0000000..12b7d82 --- /dev/null +++ b/packages/ds-sdk/src/enums.ts @@ -0,0 +1,86 @@ +// 코어 상태/타입 리터럴 유니온 — DTO·스키마가 공유하는 단일 원천(SSOT). +// yakconsole types.ts 에서 각 DTO 에 인라인돼 있던 union 을 여기로 승격. +// zod 무의존(순수 type). 값 배열은 schemas.ts 의 z.enum 이 참조해 드리프트 방지. + +// ── 값 배열(런타임 상수) — schemas.ts z.enum 이 재사용 ── +export const CLUSTER_STATUS_VALUES = [ + "REQUESTED", + "PROVISIONING", + "BOOTSTRAPPING", + "INSTALLING_ADDONS", + "ACTIVE", + "UPDATING", + "DELETING", + "ERROR", + "DELETED", +] as const; +export type ClusterStatus = (typeof CLUSTER_STATUS_VALUES)[number]; + +export const DEPLOYMENT_STATUS_VALUES = [ + "QUEUED", + "BUILDING", + "DEPLOYING", + "RUNNING", + "FAILED", + "STOPPED", +] as const; +export type DeploymentStatus = (typeof DEPLOYMENT_STATUS_VALUES)[number]; + +// 데이터 소스(관리형/외부) 서비스 9종 — SOLR·ORACLE 포함. +export const SERVICE_TYPE_VALUES = [ + "MONGODB", + "REDIS", + "MINIO", + "MYSQL", + "POSTGRESQL", + "MARIADB", + "RABBITMQ", + "SOLR", + "ORACLE", +] as const; +export type ServiceTypeDTO = (typeof SERVICE_TYPE_VALUES)[number]; + +export const SERVICE_STATUS_VALUES = [ + "REQUESTED", + "PROVISIONING", + "READY", + "ERROR", + "DELETING", + "DELETED", +] as const; +export type ServiceStatusDTO = (typeof SERVICE_STATUS_VALUES)[number]; + +// 데이터 소스 방식: LOCAL=클러스터 내 관리형 · REMOTE=외부 서버 연결. +export const SERVICE_MODE_VALUES = ["LOCAL", "REMOTE"] as const; +export type ServiceModeDTO = (typeof SERVICE_MODE_VALUES)[number]; + +export const DOMAIN_STATUS_VALUES = [ + "PENDING_DNS", + "VERIFYING", + "VERIFIED", + "EDGE_SYNCING", + "ACTIVE", + "ERROR", + "REMOVED", +] as const; +export type DomainStatus = (typeof DOMAIN_STATUS_VALUES)[number]; + +export const CERT_STATUS_VALUES = ["NONE", "ISSUING", "ISSUED", "RENEWING", "FAILED"] as const; +export type CertStatus = (typeof CERT_STATUS_VALUES)[number]; + +export const QUOTA_REQUEST_STATUS_VALUES = ["PENDING", "APPROVED", "REJECTED"] as const; +export type QuotaRequestStatusDTO = (typeof QUOTA_REQUEST_STATUS_VALUES)[number]; + +export const QUOTA_FIELD_VALUES = [ + "maxClusters", + "maxNodesPerCluster", + "maxDomains", + "maxServices", +] as const; +export type QuotaField = (typeof QUOTA_FIELD_VALUES)[number]; + +export const USER_ROLE_VALUES = ["USER", "ADMIN"] as const; +export type UserRole = (typeof USER_ROLE_VALUES)[number]; + +export const USER_STATUS_VALUES = ["ACTIVE", "SUSPENDED"] as const; +export type UserStatus = (typeof USER_STATUS_VALUES)[number]; diff --git a/packages/ds-sdk/src/index.ts b/packages/ds-sdk/src/index.ts index d255a25..8a925e4 100644 --- a/packages/ds-sdk/src/index.ts +++ b/packages/ds-sdk/src/index.ts @@ -1,4 +1,45 @@ -// @yakcloud/ds-sdk -// 데이터소스 계약(리프): DTO·Zod 스키마·BIND_ENV SSOT·ApiCode 리터럴. ⛔ Prisma/react/서버 금지. -// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조). -export const __package = "@yakcloud/ds-sdk"; +// @yakcloud/ds-sdk — 데이터소스 계약(리프): DTO·Zod 스키마·BIND_ENV SSOT·ApiCode 리터럴. +// ⛔ Prisma/react/next/서버 금지. type-only 소비자(ui/api-client)의 단일 진입점. +// verbatimModuleSyntax: type 은 export type 으로, 값(zod 스키마·상수·헬퍼)은 export 로 분리. + +// ── enums (값 배열 + 리터럴 유니온) ── +export { + CLUSTER_STATUS_VALUES, + DEPLOYMENT_STATUS_VALUES, + SERVICE_TYPE_VALUES, + SERVICE_STATUS_VALUES, + SERVICE_MODE_VALUES, + DOMAIN_STATUS_VALUES, + CERT_STATUS_VALUES, + QUOTA_REQUEST_STATUS_VALUES, + QUOTA_FIELD_VALUES, + USER_ROLE_VALUES, + USER_STATUS_VALUES, +} from "./enums"; +export type { + ClusterStatus, + DeploymentStatus, + ServiceTypeDTO, + ServiceStatusDTO, + ServiceModeDTO, + DomainStatus, + CertStatus, + QuotaRequestStatusDTO, + QuotaField, + UserRole, + UserStatus, +} from "./enums"; + +// ── DTO (순수 type) ── +export type * from "./dto/index"; + +// ── Zod 스키마 + 파생 body 타입 + 공유 상수 ── +export * from "./schemas"; + +// ── bind-env 9종 계약 + 헬퍼 ── +export { BIND_ENV_VARS, SERVICE_DEFAULT_PORT, bindEnvPrefix, bindEnvKeys } from "./bind-env"; +export type { BindEnvVar } from "./bind-env"; + +// ── ApiCode 계약 ── +export { CODE_STATUS } from "./api-codes"; +export type { ApiCode } from "./api-codes"; diff --git a/packages/ds-sdk/src/schemas.ts b/packages/ds-sdk/src/schemas.ts new file mode 100644 index 0000000..2b743b0 --- /dev/null +++ b/packages/ds-sdk/src/schemas.ts @@ -0,0 +1,246 @@ +// Zod 생성/검증 body 스키마 + 공유 상수/정규식. yakconsole api/schemas.ts 미러. +// z.infer 로 body 타입 파생. 서버 respond/serialize 무의존. +// serviceType/quotaField/status enum 은 ./enums 값 배열을 단일 원천으로 참조(드리프트 방지). +import { z } from "zod"; +import { + CLUSTER_STATUS_VALUES, + QUOTA_FIELD_VALUES, + SERVICE_TYPE_VALUES, + USER_STATUS_VALUES, +} from "./enums"; + +// slug 규칙 (design §2-②): 소문자·숫자·하이픈, 3~24자. +// 상수는 폼(new/page.tsx)이 임포트해 재사용 → 클라/서버 검증 드리프트 방지(단일 원천). +export const CLUSTER_NAME_MIN = 3; +export const CLUSTER_NAME_MAX = 24; +export const CLUSTER_NAME_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; +export const clusterName = z + .string() + .trim() + .min(CLUSTER_NAME_MIN) + .max(CLUSTER_NAME_MAX) + .regex(CLUSTER_NAME_REGEX, "소문자·숫자·하이픈만, 3~24자"); + +export const fqdn = z + .string() + .trim() + .toLowerCase() + .max(253) + .regex(/^(?!-)[a-z0-9-]{1,63}(\.[a-z0-9-]{1,63})+$/, "올바른 도메인 형식이 아닙니다."); + +// 커스텀 노드 스펙 (templateCode "C"). 경계는 yakcloud-api._validated_spec 와 일치시켜 드리프트 방지. +const nodeResources = { + cores: z.coerce.number().int().min(1).max(32), + memoryGb: z.coerce.number().int().min(1).max(128), + diskGb: z.coerce.number().int().min(20).max(500), +}; +export const customSpecBody = z + .object({ + controlPlane: z.object({ + count: z.coerce.number().int().refine((c) => c === 1 || c === 3, "control-plane 는 1 또는 3"), + worker: z.boolean().default(false), // CP 에 worker 역할 부여(기본 전용). C 템플릿 전용 옵션. + ...nodeResources, + }), + worker: z.object({ + count: z.coerce.number().int().min(0).max(10), + ...nodeResources, + }), + autoscale: z + .object({ + enabled: z.boolean().default(false), + minWorkers: z.coerce.number().int().min(0).max(20), + maxWorkers: z.coerce.number().int().min(1).max(20), + }) + .refine((a) => a.minWorkers <= a.maxWorkers, { + message: "오토스케일 최소는 최대 이하여야 합니다.", + path: ["minWorkers"], + }), + }) + .refine((s) => s.controlPlane.count + s.worker.count >= 1, { + message: "노드가 1개 이상 필요합니다.", + path: ["worker", "count"], + }) + .refine((s) => s.controlPlane.worker || s.worker.count >= 1, { + message: "control-plane 전용이면 worker 가 1개 이상 필요합니다.", + path: ["worker", "count"], + }); +export type CustomSpecBody = z.infer; + +// POST /clusters — S/M 은 명명 템플릿, C 는 custom 스펙 필수. +export const createClusterBody = z + .object({ + name: clusterName, + displayName: z.string().trim().min(1).max(60).optional(), + templateCode: z.enum(["S", "M", "C"]), + nodeCount: z.coerce.number().int().min(1).max(30), + custom: customSpecBody.optional(), + goldenPath: z.boolean().optional(), // 샘플 게시판 자동 배포(골든 패스) 선택 + }) + .refine((b) => (b.templateCode === "C") === (b.custom !== undefined), { + message: "커스텀 스펙은 templateCode 가 C 일 때만 필요합니다.", + path: ["custom"], + }); +export type CreateClusterBody = z.infer; + +// PATCH /clusters/:id +export const patchClusterBody = z.object({ + displayName: z.string().trim().min(1).max(60), +}); +// DELETE /clusters/:id — confirmName 은 cluster.name 과 일치해야 함 +export const deleteClusterBody = z.object({ confirmName: z.string() }); +// PATCH /clusters/:id/scale (P5) +export const scaleBody = z.object({ nodeCount: z.coerce.number().int().min(1).max(9) }); +// PATCH /me +export const patchMeBody = z + .object({ + displayName: z.string().trim().min(1).max(60).optional(), + locale: z.enum(["ko", "en"]).optional(), + emailOptIn: z.boolean().optional(), + }) + .refine((v) => Object.keys(v).length > 0, "변경할 항목이 없습니다."); +// POST /clusters/:id/domains (P5) +export const addDomainBody = z.object({ fqdn }); +// POST /clusters/:id/deployments (배포 탭) +export const deployName = z + .string() + .trim() + .min(3) + .max(30) + .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, "소문자·숫자·하이픈, 3~30자"); +export const envVarSchema = z.object({ + key: z + .string() + .trim() + .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "환경변수 키 형식이 아닙니다.") + .max(100), + value: z.string().max(8000), + secret: z.boolean().default(false), +}); +const gitRepoField = z + .string() + .trim() + .regex(/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/, "owner/repo 형식이어야 합니다."); +// 컨테이너 이미지 ref (Phase 1: 사전 빌드 이미지 배포). 예: nginx:1.25, reg.example.com/team/app:sha +const imageField = z + .string() + .trim() + .min(1) + .max(255) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/:@]+$/, "이미지 ref 형식이 아닙니다."); + +// 큐레이티드 K8s 배포 스펙 (나머지 YAML 은 기본값 생성). +// Phase 1: image 로 배포. Phase 3(gitRepo→빌드) 대비해 gitRepo 도 허용 — 둘 중 하나 필수. +export const createDeploymentBody = z + .object({ + name: deployName, + image: imageField.nullable().default(null), + gitRepo: gitRepoField.nullable().default(null), + gitBranch: z.string().trim().min(1).max(100).default("main"), + port: z.coerce.number().int().min(1).max(65535).nullable().default(null), + replicasDesired: z.coerce.number().int().min(0).max(20).default(2), + cpuRequest: z.string().trim().min(1).max(20).default("250m"), + memRequest: z.string().trim().min(1).max(20).default("256Mi"), + healthPath: z.string().trim().max(200).nullable().default(null), + exposeHost: z.string().trim().max(253).nullable().default(null), + path: z.string().trim().max(200).regex(/^\//, "경로는 / 로 시작해야 합니다.").default("/"), + pathType: z.enum(["Prefix", "Exact", "ImplementationSpecific"]).default("Prefix"), + rewritePrefix: z.boolean().default(false), + env: z.array(envVarSchema).max(100).default([]), + }) + .refine((b) => Boolean(b.image || b.gitRepo), { + message: "image 또는 gitRepo 중 하나는 필요합니다.", + path: ["image"], + }); + +// 배포 설정/환경변수 수정 (부분) +export const patchDeploymentBody = z.object({ + port: z.coerce.number().int().min(1).max(65535).nullable().optional(), + replicasDesired: z.coerce.number().int().min(0).max(20).optional(), + cpuRequest: z.string().trim().min(1).max(20).optional(), + memRequest: z.string().trim().min(1).max(20).optional(), + healthPath: z.string().trim().max(200).nullable().optional(), + exposeHost: z.string().trim().max(253).nullable().optional(), + path: z.string().trim().max(200).regex(/^\//, "경로는 / 로 시작해야 합니다.").optional(), + pathType: z.enum(["Prefix", "Exact", "ImplementationSpecific"]).optional(), + rewritePrefix: z.boolean().optional(), + env: z.array(envVarSchema).max(100).optional(), +}); + +// ── 외부 서비스 (인클러스터 관리형 Mongo/Redis/MinIO) ── +export const serviceName = z + .string() + .trim() + .min(3) + .max(30) + .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, "소문자·숫자·하이픈, 3~30자"); // yakcloud NAME_RE 호환 +// ./enums SERVICE_TYPE_VALUES 를 단일 원천으로 참조(9종·ServiceTypeDTO 와 동일성 보장). +export const serviceType = z.enum(SERVICE_TYPE_VALUES); +export const serviceSize = z.enum(["small", "medium", "large"]); +// POST /api/v1/services +export const createServiceBody = z + .object({ + clusterId: z.string().min(1), + type: serviceType, + name: serviceName, + mode: z.enum(["local", "remote"]).default("local"), // local=관리형 · remote=외부 서버 연결 + size: serviceSize.default("small"), + storageGb: z.coerce.number().int().min(1).max(500).nullable().optional(), + persist: z.boolean().optional(), // 미지정 시 redis=false, 그 외 true + // remote(외부) 접속정보 — mode=remote 일 때 host·port 필수. + host: z.string().trim().max(253).optional(), + port: z.coerce.number().int().min(1).max(65535).optional(), + username: z.string().trim().max(200).optional(), + password: z.string().max(500).optional(), + database: z.string().trim().max(128).optional(), + vhost: z.string().trim().max(128).optional(), + core: z.string().trim().max(128).optional(), + dbNum: z.coerce.number().int().min(0).max(15).optional(), + }) + .refine((b) => b.mode !== "remote" || (!!b.host && !!b.port), { + message: "외부 연결에는 host 와 port 가 필요합니다.", + path: ["host"], + }); +// POST /api/v1/services/:id/bindings — alias 기본값 = 서비스명(서버) +export const bindServiceBody = z.object({ + deploymentId: z.string().min(1), + alias: z + .string() + .trim() + .regex(/^[a-z][a-z0-9-]*$/, "소문자로 시작, 소문자·숫자·하이픈") + .max(30) + .optional(), +}); + +// admin +export const patchQuotaBody = z.object({ + maxClusters: z.coerce.number().int().min(0).max(50).optional(), + maxNodesPerCluster: z.coerce.number().int().min(1).max(20).optional(), + maxDomains: z.coerce.number().int().min(0).max(100).optional(), + maxServices: z.coerce.number().int().min(0).max(50).optional(), +}); +// 쿼터 증설 요청/승인 (거버넌스: 사용자 요청 → 관리자 승인 시에만 반영) +// ./enums QUOTA_FIELD_VALUES 를 단일 원천으로 참조(QuotaField 와 동일성 보장). +export const quotaField = z.enum(QUOTA_FIELD_VALUES); +export const createQuotaRequestBody = z.object({ + field: quotaField, + requestedValue: z.coerce.number().int().min(1).max(100), + reason: z.string().trim().max(500).optional(), +}); +export const decideQuotaRequestBody = z.object({ approve: z.boolean() }); +// ./enums USER_STATUS_VALUES 를 단일 원천으로 참조(UserStatus 와 동일성 보장). +export const patchUserStatusBody = z.object({ status: z.enum(USER_STATUS_VALUES) }); +// POST /internal/clusters/:id/status +// ./enums CLUSTER_STATUS_VALUES 를 단일 원천으로 참조(ClusterStatus 와 동일성 보장). +export const internalStatusBody = z.object({ + status: z.enum(CLUSTER_STATUS_VALUES), + statusMessage: z.string().max(500).optional(), + rancherClusterId: z.string().optional(), + ingressVip: z.string().optional(), + event: z + .object({ + phase: z.string(), + message: z.string(), + level: z.enum(["info", "warn", "error"]).default("info"), + }) + .optional(), +});