From bde5f0314f67752d7826ee3b440df1d38bf712da Mon Sep 17 00:00:00 2001 From: jingyang <72259332+zjy365@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:41:44 +0800 Subject: [PATCH] feat(launchpad): update ports api & schema (#5914) * update launchpad ports api * update schema * delete log * update --- .../src/pages/api/v1/app/[name]/index.ts | 1 - .../src/pages/api/v1/app/[name]/ports.ts | 405 +++++++++++++----- .../src/services/backend/appService.ts | 18 +- .../applaunchpad/src/types/openapi.ts | 138 +++++- .../applaunchpad/src/types/request_schema.ts | 100 ++++- .../applaunchpad/src/types/schema.ts | 58 ++- 6 files changed, 570 insertions(+), 150 deletions(-) diff --git a/frontend/providers/applaunchpad/src/pages/api/v1/app/[name]/index.ts b/frontend/providers/applaunchpad/src/pages/api/v1/app/[name]/index.ts index b82f37070..c6a02ef65 100644 --- a/frontend/providers/applaunchpad/src/pages/api/v1/app/[name]/index.ts +++ b/frontend/providers/applaunchpad/src/pages/api/v1/app/[name]/index.ts @@ -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); diff --git a/frontend/providers/applaunchpad/src/pages/api/v1/app/[name]/ports.ts b/frontend/providers/applaunchpad/src/pages/api/v1/app/[name]/ports.ts index 39e0a3ecf..9220fe359 100644 --- a/frontend/providers/applaunchpad/src/pages/api/v1/app/[name]/ports.ts +++ b/frontend/providers/applaunchpad/src/pages/api/v1/app/[name]/ports.ts @@ -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['ports'] + | z.infer['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['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' + }); + } +} diff --git a/frontend/providers/applaunchpad/src/services/backend/appService.ts b/frontend/providers/applaunchpad/src/services/backend/appService.ts index 0287bbf69..98c5c7859 100644 --- a/frontend/providers/applaunchpad/src/services/backend/appService.ts +++ b/frontend/providers/applaunchpad/src/services/backend/appService.ts @@ -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) { diff --git a/frontend/providers/applaunchpad/src/types/openapi.ts b/frontend/providers/applaunchpad/src/types/openapi.ts index d61f7ae8d..37201cb57 100644 --- a/frontend/providers/applaunchpad/src/types/openapi.ts +++ b/frontend/providers/applaunchpad/src/types/openapi.ts @@ -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': { diff --git a/frontend/providers/applaunchpad/src/types/request_schema.ts b/frontend/providers/applaunchpad/src/types/request_schema.ts index b056f0fbd..e0c837b08 100644 --- a/frontend/providers/applaunchpad/src/types/request_schema.ts +++ b/frontend/providers/applaunchpad/src/types/request_schema.ts @@ -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 ): 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({ diff --git a/frontend/providers/applaunchpad/src/types/schema.ts b/frontend/providers/applaunchpad/src/types/schema.ts index f28153fc9..21d3104fe 100644 --- a/frontend/providers/applaunchpad/src/types/schema.ts +++ b/frontend/providers/applaunchpad/src/types/schema.ts @@ -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'),