feat(launchpad): update ports api & schema (#5914)

* update launchpad ports api

* update schema

* delete log

* update
This commit is contained in:
jingyang
2025-09-02 16:41:44 +08:00
committed by GitHub
parent 837a49529b
commit bde5f0314f
6 changed files with 570 additions and 150 deletions
@@ -80,7 +80,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
}
const updateData = parseResult.data;
await updateAppResources(name, updateData, k8s);
const response = await getAppByName(name, k8s);
@@ -1,8 +1,9 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/services/backend/response';
import { createK8sContext, getAppByName, processAppResponse } from '@/services/backend';
import { PatchUtils, V1Deployment, V1StatefulSet } from '@kubernetes/client-node';
import { UpdatePortsSchema } from '@/types/request_schema';
import { K8sContext, createK8sContext, getAppByName, processAppResponse } from '@/services/backend';
import { V1Deployment, V1StatefulSet } from '@kubernetes/client-node';
import { CreatePortsSchema, UpdatePortsSchema, DeletePortsSchema } from '@/types/request_schema';
import { z } from 'zod';
import { customAlphabet } from 'nanoid';
import { json2Service, json2Ingress } from '@/utils/deployYaml2Json';
import type { AppEditType } from '@/types/app';
@@ -11,7 +12,7 @@ const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== 'PATCH') {
if (!['POST', 'PATCH', 'DELETE'].includes(req.method!)) {
return jsonRes(res, {
code: 405,
error: `Method ${req.method} Not Allowed`
@@ -24,17 +25,41 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const currentAppResponse = await getAppByName(appName, k8s);
const currentAppData = await processAppResponse(currentAppResponse, false);
const parseResult = UpdatePortsSchema.safeParse(req.body);
if (!parseResult.success) {
return jsonRes(res, {
code: 400,
error: parseResult.error
});
if (req.method === 'DELETE') {
const parseResult = DeletePortsSchema.safeParse(req.body);
if (!parseResult.success) {
return jsonRes(res, {
code: 400,
error: parseResult.error
});
}
return await handleDeletePorts(parseResult.data.ports, appName, k8s, currentAppData, res);
}
const { ports } = parseResult.data;
const { k8sApp, namespace, applyYamlList } = k8s;
let ports:
| z.infer<typeof CreatePortsSchema>['ports']
| z.infer<typeof UpdatePortsSchema>['ports'];
if (req.method === 'POST') {
const parseResult = CreatePortsSchema.safeParse(req.body);
if (!parseResult.success) {
return jsonRes(res, {
code: 400,
error: parseResult.error
});
}
ports = parseResult.data.ports;
} else {
const parseResult = UpdatePortsSchema.safeParse(req.body);
if (!parseResult.success) {
return jsonRes(res, {
code: 400,
error: parseResult.error
});
}
ports = parseResult.data.ports;
}
const { k8sApp, namespace, applyYamlList } = k8s;
let app: V1Deployment | V1StatefulSet;
const deploymentResult = currentAppResponse[0];
@@ -58,7 +83,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
}
// validate ports
const uniquePorts = new Set(ports.map((p) => p.port));
if (uniquePorts.size !== ports.length) {
return jsonRes(res, {
@@ -67,40 +91,33 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
}
const networks = ports.map((port) => {
// check if there is a unique identifier (networkName, portName, serviceName)
// Only consider it an update if we can find an existing port with matching identifiers
const existingPort = currentAppData.networks?.find(
(n) =>
n.port === port.port &&
((port.networkName && n.networkName === port.networkName) ||
(port.portName && n.portName === port.portName) ||
(port.serviceName && n.serviceName === port.serviceName))
);
const isUpdate = !!existingPort;
if (req.method === 'POST') {
const existingPorts = currentAppData.networks?.map((n) => n.port) || [];
const conflictPorts = ports.filter((p) => existingPorts.includes(p.port));
if (conflictPorts.length > 0) {
return jsonRes(res, {
code: 400,
error: `Ports already exist: ${conflictPorts.map((p) => p.port).join(', ')}`
});
}
}
// Get existing port names to avoid conflicts
const existingPortNames = new Set(currentAppData.networks?.map((n) => n.portName) || []);
const networks = ports!.map((port) => {
if (req.method === 'POST') {
// Generate unique port name that doesn't conflict with existing ones
let portName = nanoid();
while (existingPortNames.has(portName)) {
portName = nanoid();
}
existingPortNames.add(portName);
if (isUpdate) {
return {
...existingPort,
port: port.port,
protocol: port.protocol,
appProtocol: port.appProtocol || 'HTTP',
openPublicDomain: port.exposesPublicDomain,
openNodePort: !port.appProtocol,
// Ensure required fields are always present
networkName: existingPort?.networkName || `network-${nanoid()}`,
portName: existingPort?.portName || nanoid(),
serviceName: existingPort?.serviceName || `service-${nanoid()}`,
publicDomain: existingPort?.publicDomain || nanoid(),
domain: existingPort?.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io',
customDomain: existingPort?.customDomain || ''
};
} else {
// For new ports, create complete configuration
return {
serviceName: `service-${nanoid()}`,
networkName: `network-${nanoid()}`,
portName: nanoid(),
portName,
port: port.port,
protocol: port.protocol,
appProtocol: port.appProtocol || 'HTTP',
@@ -111,6 +128,40 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
nodePort: undefined,
openNodePort: !port.appProtocol
};
} else {
const portWithIds = port as z.infer<typeof UpdatePortsSchema>['ports'][0];
const existingPort = currentAppData.networks?.find((n: any) => {
// Match by specific identifiers in priority order
if (portWithIds.portName && n.portName === portWithIds.portName) {
return true;
}
if (portWithIds.networkName && n.networkName === portWithIds.networkName) {
return true;
}
// Only match by serviceName if no other identifiers are provided
if (
!portWithIds.portName &&
!portWithIds.networkName &&
portWithIds.serviceName &&
n.serviceName === portWithIds.serviceName
) {
return true;
}
return false;
});
if (existingPort) {
return {
...existingPort,
port: portWithIds.port,
protocol: portWithIds.protocol,
appProtocol: portWithIds.appProtocol || 'HTTP',
openPublicDomain: portWithIds.exposesPublicDomain,
openNodePort: !portWithIds.appProtocol
};
} else {
throw new Error(`Port not found for update: ${JSON.stringify(portWithIds)}`);
}
}
});
@@ -121,78 +172,40 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
protocol: network.protocol
}));
const deploymentPatch = {
spec: {
template: {
spec: {
containers: [
{
name: app.spec.template.spec.containers[0].name,
ports: containerPorts
}
]
}
}
}
};
await updateAppPorts(app, appName, k8sApp, namespace, containerPorts, req.method);
if (app.kind === 'Deployment') {
await k8sApp.patchNamespacedDeployment(
appName,
namespace,
deploymentPatch,
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-type': PatchUtils.PATCH_FORMAT_STRATEGIC_MERGE_PATCH } }
);
} else if (app.kind === 'StatefulSet') {
await k8sApp.patchNamespacedStatefulSet(
appName,
namespace,
deploymentPatch,
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-type': PatchUtils.PATCH_FORMAT_STRATEGIC_MERGE_PATCH } }
);
let mergedNetworks: any[];
if (req.method === 'POST') {
mergedNetworks = [...(currentAppData?.networks || []), ...networks];
} else {
throw new Error(`Unsupported app kind: ${app.kind}`);
const updatedPortNames = new Set(networks.map((n) => n.portName));
mergedNetworks = [
...(currentAppData?.networks?.filter(
(existing) => !updatedPortNames.has(existing.portName)
) || []),
...networks
];
}
const updatedPorts = new Set(networks.map((n) => n.port));
const mergedNetworks = [
...(currentAppData?.networks?.filter((existing) => !updatedPorts.has(existing.port)) || []),
...networks
];
// Check for duplicate port names in mergedNetworks
const portNames = mergedNetworks.map((n) => n.portName);
const duplicatePortNames = portNames.filter(
(name, index) => portNames.indexOf(name) !== index
);
if (duplicatePortNames.length > 0) {
console.error('Duplicate port names detected:', duplicatePortNames);
console.error(
'All networks:',
mergedNetworks.map((n) => ({ port: n.port, portName: n.portName }))
);
}
const appEditData: AppEditType = {
...currentAppData,
networks: mergedNetworks
};
console.log(appEditData, 'appEditData');
const yamlList: string[] = [];
const serviceYaml = json2Service(appEditData);
if (serviceYaml.trim()) {
yamlList.push(serviceYaml);
}
const ingressYaml = json2Ingress(appEditData);
if (ingressYaml.trim()) {
yamlList.push(ingressYaml);
}
if (yamlList.length > 0) {
await applyYamlList(yamlList, 'replace');
}
await updateServiceAndIngress(appEditData, applyYamlList);
const response = await getAppByName(appName, k8s);
const filteredData = await processAppResponse(response);
@@ -201,10 +214,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
data: filteredData
});
} catch (error: any) {
console.error('Failed to update ports:', error);
return jsonRes(res, {
code: 500,
error: error.message || 'Failed to update ports'
error: error?.message || `Failed to ports`
});
}
} catch (error: any) {
@@ -215,3 +227,182 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
}
}
async function updateAppPorts(
app: V1Deployment | V1StatefulSet,
appName: string,
k8sApp: any,
namespace: string,
containerPorts: any[],
method?: string
) {
let jsonPatch: any[];
if (method === 'POST') {
const currentPorts = app.spec?.template?.spec?.containers[0]?.ports || [];
const allPorts = [...currentPorts, ...containerPorts];
jsonPatch = [
{
op: 'replace',
path: '/spec/template/spec/containers/0/ports',
value: allPorts
}
];
} else if (method === 'PATCH') {
const currentPorts = app.spec?.template?.spec?.containers[0]?.ports || [];
const updatedPorts = currentPorts.map((port: any) => {
const updatedPort = containerPorts.find((cp: any) => cp.name === port.name);
return updatedPort || port;
});
jsonPatch = [
{
op: 'replace',
path: '/spec/template/spec/containers/0/ports',
value: updatedPorts
}
];
} else if (method === 'DELETE') {
const currentPorts = app.spec?.template?.spec?.containers[0]?.ports || [];
const portsToDelete = new Set(containerPorts.map((cp: any) => cp.containerPort));
const remainingPorts = currentPorts.filter(
(port: any) => !portsToDelete.has(port.containerPort)
);
jsonPatch = [
{
op: 'replace',
path: '/spec/template/spec/containers/0/ports',
value: remainingPorts
}
];
} else {
jsonPatch = [
{
op: 'replace',
path: '/spec/template/spec/containers/0/ports',
value: containerPorts
}
];
}
try {
if (app.kind === 'Deployment') {
await k8sApp.patchNamespacedDeployment(
appName,
namespace,
jsonPatch,
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-type': 'application/json-patch+json' } }
);
} else if (app.kind === 'StatefulSet') {
await k8sApp.patchNamespacedStatefulSet(
appName,
namespace,
jsonPatch,
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-type': 'application/json-patch+json' } }
);
} else {
throw new Error(`Unsupported app kind: ${app.kind}`);
}
} catch (error: any) {
throw error;
}
}
async function updateServiceAndIngress(appEditData: AppEditType, applyYamlList: any) {
const yamlList: string[] = [];
const serviceYaml = json2Service(appEditData);
if (serviceYaml.trim()) {
yamlList.push(serviceYaml);
}
const ingressYaml = json2Ingress(appEditData);
if (ingressYaml.trim()) {
yamlList.push(ingressYaml);
}
if (yamlList.length > 0) {
await applyYamlList(yamlList, 'replace');
}
}
async function handleDeletePorts(
portsToDelete: number[],
appName: string,
k8s: K8sContext,
currentAppData: AppEditType,
res: NextApiResponse
) {
const remainingNetworks =
currentAppData.networks?.filter((network) => !portsToDelete.includes(network.port)) || [];
if (remainingNetworks.length === currentAppData.networks?.length) {
return jsonRes(res, {
code: 404,
error: `No matching ports found to delete: ${portsToDelete.join(', ')}`
});
}
try {
const currentAppResponse = await getAppByName(appName, k8s);
const { applyYamlList } = k8s;
let app: V1Deployment | V1StatefulSet;
const deploymentResult = currentAppResponse[0];
const statefulSetResult = currentAppResponse[1];
if (deploymentResult.status === 'fulfilled' && deploymentResult.value.body) {
app = deploymentResult.value.body;
} else if (statefulSetResult.status === 'fulfilled' && statefulSetResult.value.body) {
app = statefulSetResult.value.body;
} else {
return jsonRes(res, {
code: 404,
error: `App ${appName} not found`
});
}
if (!app?.spec?.template?.spec) {
return jsonRes(res, {
code: 400,
error: `App ${appName} has invalid structure`
});
}
const containerPorts = remainingNetworks.map((network: any) => ({
containerPort: network.port,
name: network.portName,
protocol: network.protocol
}));
await updateAppPorts(app, appName, k8s.k8sApp, k8s.namespace, containerPorts, 'DELETE');
const appEditData: AppEditType = {
...currentAppData,
networks: remainingNetworks
};
await updateServiceAndIngress(appEditData, applyYamlList);
const response = await getAppByName(appName, k8s);
const filteredData = await processAppResponse(response);
return jsonRes(res, {
data: filteredData
});
} catch (error: any) {
return jsonRes(res, {
code: 500,
error: error.message || 'Failed to delete ports'
});
}
}
@@ -8,7 +8,7 @@ import { str2Num } from '@/utils/tools';
import { adaptAppDetail } from '@/utils/adapt';
import { DeployKindsType, AppDetailType } from '@/types/app';
import { z } from 'zod';
import { LaunchpadApplicationSchema } from '@/types/schema';
import { LaunchpadApplicationSchema, resourceConverters } from '@/types/schema';
import { transformFromLegacySchema } from '@/types/request_schema';
import {
PatchUtils,
@@ -550,31 +550,33 @@ export async function updateAppResources(
}> = [];
if (updateData.resource?.cpu !== undefined) {
const millicores = resourceConverters.cpuToMillicores(updateData.resource.cpu);
jsonPatch.push(
{
op: 'replace',
path: '/spec/template/spec/containers/0/resources/requests/cpu',
value: `${str2Num(Math.floor(updateData.resource.cpu * 0.1))}m`
value: `${str2Num(Math.floor(millicores * 0.1))}m`
},
{
op: 'replace',
path: '/spec/template/spec/containers/0/resources/limits/cpu',
value: `${str2Num(updateData.resource.cpu)}m`
value: `${str2Num(millicores)}m`
}
);
}
if (updateData.resource?.memory !== undefined) {
const memoryMB = resourceConverters.memoryToMB(updateData.resource.memory);
jsonPatch.push(
{
op: 'replace',
path: '/spec/template/spec/containers/0/resources/requests/memory',
value: `${str2Num(Math.floor(updateData.resource.memory * 0.1))}Mi`
value: `${str2Num(Math.floor(memoryMB * 0.1))}Mi`
},
{
op: 'replace',
path: '/spec/template/spec/containers/0/resources/limits/memory',
value: `${str2Num(updateData.resource.memory)}Mi`
value: `${str2Num(memoryMB)}Mi`
}
);
}
@@ -625,6 +627,12 @@ export async function updateAppResources(
path: '/spec/template/spec/containers/0/image',
value: updateData.image
});
jsonPatch.push({
op: 'replace',
path: '/metadata/annotations/originImageName',
value: updateData.image
});
}
if (updateData.env !== undefined) {
@@ -7,7 +7,9 @@ import {
GetAppByAppNameResponseSchema,
UpdateAppResourcesSchema,
UpdateConfigMapSchema,
CreatePortsSchema,
UpdatePortsSchema,
DeletePortsSchema,
UpdateStorageSchema
} from './request_schema';
@@ -426,12 +428,74 @@ export const openApiDocument = (sealosDomain: string) =>
}
},
'/api/v1/app/{name}/ports': {
patch: {
summary: 'Update application ports',
post: {
summary: 'Create new application ports',
description:
'Update application port configurations including container ports, services, and ingresses. ' +
'This API applies YAML-based updates to network resources, preserving existing configurations ' +
'for ports not specified in the request. Only the specified ports will be updated or added.',
'Add new port configurations to an application including container ports, services, and ingresses. ' +
'This API creates entirely new ports that do not already exist in the application. ' +
'Port conflicts will be rejected.',
parameters: [
{
name: 'name',
in: 'path',
description: 'Application name',
required: true,
schema: {
type: 'string'
}
}
],
requestBody: {
required: true,
content: {
'application/json': {
schema: CreatePortsSchema
}
}
},
responses: {
'200': {
description: 'Ports created successfully',
content: {
'application/json': {
schema: z.object({
data: LaunchpadApplicationSchema
})
}
}
},
'400': {
description: 'Invalid request parameters, duplicate ports, or ports already exist',
content: {
'application/json': {
schema: ErrorResponseSchema
}
}
},
'404': {
description: 'Application not found',
content: {
'application/json': {
schema: ErrorResponseSchema
}
}
},
'500': {
description: 'Internal server error during ports creation',
content: {
'application/json': {
schema: ErrorResponseSchema
}
}
}
}
},
patch: {
summary: 'Update existing application ports',
description:
'Update existing application port configurations including container ports, services, and ingresses. ' +
'This API requires at least one identifier (networkName, portName, or serviceName) to locate the port to update. ' +
'Only specified ports will be updated, existing ports not mentioned will remain unchanged.',
parameters: [
{
name: 'name',
@@ -463,7 +527,7 @@ export const openApiDocument = (sealosDomain: string) =>
}
},
'400': {
description: 'Invalid request parameters or application structure',
description: 'Invalid request parameters, missing identifiers, or port not found',
content: {
'application/json': {
schema: ErrorResponseSchema
@@ -487,6 +551,68 @@ export const openApiDocument = (sealosDomain: string) =>
}
}
}
},
delete: {
summary: 'Delete application ports',
description:
'Delete specified application ports by port number. ' +
'This API removes the specified ports from container configuration, services, and ingresses. ' +
'At least one port number must be provided.',
parameters: [
{
name: 'name',
in: 'path',
description: 'Application name',
required: true,
schema: {
type: 'string'
}
}
],
requestBody: {
required: true,
content: {
'application/json': {
schema: DeletePortsSchema
}
}
},
responses: {
'200': {
description: 'Ports deleted successfully',
content: {
'application/json': {
schema: z.object({
data: LaunchpadApplicationSchema
})
}
}
},
'400': {
description: 'Invalid request parameters or application structure',
content: {
'application/json': {
schema: ErrorResponseSchema
}
}
},
'404': {
description: 'Application not found or no matching ports found to delete',
content: {
'application/json': {
schema: ErrorResponseSchema
}
}
},
'500': {
description: 'Internal server error during ports deletion',
content: {
'application/json': {
schema: ErrorResponseSchema
}
}
}
}
}
},
'/api/v1/app/{name}/storage': {
@@ -31,7 +31,8 @@ import {
ResourceSchema,
PortConfigSchema,
LaunchpadApplicationSchema,
imageRegistrySchema
imageRegistrySchema,
resourceConverters
} from './schema';
export const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12);
@@ -55,15 +56,40 @@ export const UpdateAppResourcesSchema = z
// Resource configuration (use nested structure like CreateLaunchpadRequestSchema)
resource: z
.object({
cpu: z.number().optional().openapi({
description: 'CPU allocation in millicores'
}),
memory: z.number().optional().openapi({
description: 'Memory allocation in MB'
}),
replicas: z.number().min(0).optional().openapi({
description: 'Number of pod replicas'
})
cpu: z
.number()
.refine((val) => [0.1, 0.2, 0.5, 1, 2, 3, 4, 8].includes(val), {
message: 'CPU must be one of: 0.1, 0.2, 0.5, 1, 2, 3, 4, 8'
})
.optional()
.openapi({
description: 'CPU allocation in cores',
enum: [0.1, 0.2, 0.5, 1, 2, 3, 4, 8]
}),
memory: z
.number()
.refine((val) => [0.1, 0.5, 1, 2, 4, 8, 16].includes(val), {
message: 'Memory must be one of: 0.1, 0.5, 1, 2, 4, 8, 16'
})
.optional()
.openapi({
description: 'Memory allocation in GB',
enum: [0.1, 0.5, 1, 2, 4, 8, 16]
}),
replicas: z
.number()
.refine(
(val) =>
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].includes(val),
{
message: 'Replicas must be between 1 and 20'
}
)
.optional()
.openapi({
description: 'Number of pod replicas',
enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
})
})
.optional()
.openapi({
@@ -217,8 +243,8 @@ export const CreateLaunchpadRequestSchema = z
export function transformToLegacySchema(
standardRequest: z.infer<typeof CreateLaunchpadRequestSchema>
): AppEditType {
const cpuValue = standardRequest.resource.cpu;
const memoryValue = standardRequest.resource.memory;
const cpuValue = resourceConverters.cpuToMillicores(standardRequest.resource.cpu);
const memoryValue = resourceConverters.memoryToMB(standardRequest.resource.memory);
const networks = standardRequest.ports?.map((port) => ({
serviceName: `service-${nanoid()}`, // 自动生成
@@ -357,8 +383,8 @@ export function transformFromLegacySchema(
args: legacyData.cmdParam,
resource: {
replicas: legacyData.replicas || 1,
cpu: legacyData.cpu || 200,
memory: legacyData.memory || 256,
cpu: resourceConverters.millicoresToCpu(legacyData.cpu || 200),
memory: resourceConverters.mbToMemory(legacyData.memory || 256),
gpu: legacyData.gpu
? {
vendor: legacyData.gpu.manufacturers,
@@ -439,27 +465,57 @@ export const UpdateConfigMapSchema = z.object({
})
});
export const UpdatePortsSchema = z.object({
// POST - Create new ports (no identifiers needed)
export const CreatePortsSchema = z.object({
ports: z
.array(
z.object({
port: z.number().default(80),
protocol: z.enum(['TCP', 'UDP', 'SCTP']),
appProtocol: z.enum(['HTTP', 'GRPC', 'WS']).optional(),
exposesPublicDomain: z.boolean(),
networkName: z.string().optional(),
portName: z.string().optional(),
serviceName: z.string().optional()
exposesPublicDomain: z.boolean()
})
)
.min(1)
.openapi({
description:
'Port/Network configurations to update. Include networkName/portName/serviceName for updates, omit for new ports'
description: 'Port configurations to create (new ports only)'
})
});
// PATCH - Update existing ports (requires identifiers to locate)
export const UpdatePortsSchema = z.object({
ports: z
.array(
z
.object({
port: z.number(),
protocol: z.enum(['TCP', 'UDP', 'SCTP']),
appProtocol: z.enum(['HTTP', 'GRPC', 'WS']).optional(),
exposesPublicDomain: z.boolean(),
// At least one identifier required to locate the port to update
networkName: z.string().optional(),
portName: z.string().optional(),
serviceName: z.string().optional()
})
.refine((data) => data.networkName || data.portName || data.serviceName, {
message:
'At least one identifier (networkName/portName/serviceName) is required to locate the port to update'
})
)
.min(1)
.openapi({
description:
'Port configurations to update. Must include at least one identifier (networkName/portName/serviceName) to locate existing port'
})
});
// DELETE - Delete ports by port number
export const DeletePortsSchema = z.object({
ports: z.array(z.number()).min(1).openapi({
description: 'Array of port numbers to delete'
})
});
export const SimpleStorageSchema = z
.object({
path: z.string().openapi({
@@ -22,16 +22,56 @@ import { customAlphabet } from 'nanoid';
export const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12);
// Conversion utilities for resource values
export const resourceConverters = {
// Convert CPU cores to millicores (e.g., 0.1 -> 100, 1 -> 1000)
cpuToMillicores: (cores: number): number => cores * 1000,
// Convert GB to MB (e.g., 0.5 -> 512, 1 -> 1024)
memoryToMB: (gb: number): number => gb * 1024,
// Convert millicores to cores for display (e.g., 100 -> 0.1, 1000 -> 1)
millicoresToCpu: (millicores: number): number => millicores / 1000,
// Convert MB to GB for display (e.g., 512 -> 0.5, 1024 -> 1)
mbToMemory: (mb: number): number => mb / 1024
};
export const ResourceSchema = z.object({
replicas: z.number().min(0).max(10).default(1).openapi({
description: 'Number of pod replicas'
}),
cpu: z.number().default(200).openapi({
description: 'CPU allocation in millicores'
}),
memory: z.number().default(256).openapi({
description: 'Memory allocation in MB'
}),
replicas: z
.number()
.refine(
(val) =>
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].includes(val),
{
message: 'Replicas must be between 1 and 20'
}
)
.default(1)
.openapi({
description: 'Number of pod replicas',
enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
}),
cpu: z
.number()
.refine((val) => [0.1, 0.2, 0.5, 1, 2, 3, 4, 8].includes(val), {
message: 'CPU must be one of: 0.1, 0.2, 0.5, 1, 2, 3, 4, 8'
})
.default(0.2)
.openapi({
description: 'CPU allocation in cores',
enum: [0.1, 0.2, 0.5, 1, 2, 3, 4, 8]
}),
memory: z
.number()
.refine((val) => [0.1, 0.5, 1, 2, 4, 8, 16].includes(val), {
message: 'Memory must be one of: 0.1, 0.5, 1, 2, 4, 8, 16'
})
.default(0.5)
.openapi({
description: 'Memory allocation in GB',
enum: [0.1, 0.5, 1, 2, 4, 8, 16]
}),
gpu: z
.object({
vendor: z.string().default('nvidia'),