feat(ds-sdk): B3 추출 — DTO·Zod·bind-env 9종·ApiCode (리프)

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 11:14:09 +09:00
parent 7d986a5e6b
commit 7d1c92a406
14 changed files with 803 additions and 7 deletions

View File

@ -25,6 +25,6 @@
"test": "echo \"(no tests yet)\""
},
"dependencies": {
"zod": "^3.23.0"
"zod": "^4.4.3"
}
}

View File

@ -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:*"
},

View File

@ -25,6 +25,6 @@
"test": "echo \"(no tests yet)\""
},
"dependencies": {
"zod": "^3.23.0"
"zod": "^4.4.3"
}
}

View File

@ -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;

View File

@ -0,0 +1,129 @@
// bind-env 9종 계약 (BIND_ENV_VARS) — 앱 컨테이너에 주입되는 <PREFIX>_* 환경변수 명세.
// 백엔드 _bind_env_for(infra/api/yakcloud_api.py) 미러. 실제 URL 조립의 진실은 서버이며
// 여기는 접미사 집합 + 문서/UI 힌트(desc/example)만 노출한다(값 조립은 리프 패키지 대상 아님).
//
// PREFIX 도출: p = alias.upper().replace('-','_'). 아래 헬퍼 bindEnvKeys(alias,type) 가 동일 규칙으로
// 실제 env 키 배열을 만든다. 소스를 연결하면 이 키들이 앱 파드 env 로 주입되어
// 코드에서 process.env.<PREFIX>_URL 등으로 접속한다.
import type { ServiceTypeDTO } from "./enums";
export type BindEnvVar = { suffix: string; desc: string; example: string };
export const BIND_ENV_VARS: Record<ServiceTypeDTO, readonly BindEnvVar[]> = {
// 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<ServiceTypeDTO, number> = {
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 키 목록(<PREFIX>_<SUFFIX>).
export function bindEnvKeys(alias: string, type: ServiceTypeDTO): string[] {
const prefix = bindEnvPrefix(alias);
return BIND_ENV_VARS[type].map((v) => `${prefix}_${v.suffix}`);
}

View File

@ -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 }[];
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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";

View File

@ -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 };
}

View File

@ -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];

View File

@ -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";

View File

@ -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<typeof customSpecBody>;
// 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<typeof createClusterBody>;
// 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(),
});